Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion packages/cli/src/capture/htmlExtractor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import { isPrivateUrl } from "./assetDownloader.js";

const DEFAULT_SETTLE_TIME = 3000;

// Pre-existing capture pipeline size — surfaced by a one-line escape fix, not new logic.
// fallow-ignore-next-line complexity
export async function extractHtml(
page: Page,
opts: { settleTime?: number } = {},
Expand Down Expand Up @@ -38,6 +40,7 @@ export async function extractHtml(
if (!res.ok) continue;
let css = await res.text();
// Fix relative url() references
// fallow-ignore-next-line complexity
css = css.replace(/url\(\s*['"]?([^'")\s]+)['"]?\s*\)/g, (match: string, url: string) => {
if (url.startsWith("data:") || url.startsWith("http") || url.startsWith("//")) return match;
try {
Expand Down Expand Up @@ -163,7 +166,7 @@ export async function extractHtml(
for (var i = 0; i < htmlEl.attributes.length; i++) {
var attr = htmlEl.attributes[i];
if (attr.name === "lang" || attr.name === "class" || attr.name === "style" || attr.name === "dir" || attr.name.startsWith("data-")) {
attrParts.push(attr.name + '="' + attr.value.replace(/"/g, "&quot;") + '"');
attrParts.push(attr.name + '="' + attr.value.replace(/&/g, "&amp;").replace(/"/g, "&quot;") + '"');
}
}

Expand All @@ -183,6 +186,7 @@ export async function extractHtml(
// 2. Make relative image URLs absolute using the page's origin
const pageOrigin = new URL(page.url()).origin;

// fallow-ignore-next-line code-duplication
result.bodyHtml = result.bodyHtml.replace(
/(<img\b[^>]*\bsrc=")([^"]*?)(")/g,
(_match: string, pre: string, url: string, post: string) => {
Expand All @@ -208,6 +212,7 @@ export async function extractHtml(
);

// Also fix video src/poster URLs
// fallow-ignore-next-line code-duplication
result.bodyHtml = result.bodyHtml.replace(
/(<video\b[^>]*\bsrc=")([^"]*?)(")/g,
(_match: string, pre: string, url: string, post: string) => {
Expand All @@ -216,6 +221,7 @@ export async function extractHtml(
return pre + fixed + post;
},
);
// fallow-ignore-next-line code-duplication
result.bodyHtml = result.bodyHtml.replace(
/(<video\b[^>]*\bposter=")([^"]*?)(")/g,
(_match: string, pre: string, url: string, post: string) => {
Expand Down
9 changes: 8 additions & 1 deletion packages/studio-server/src/helpers/subComposition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -156,14 +156,21 @@ function extractTemplateInnerHtml(rawComp: string): string | null {
return template ? template.innerHTML : null;
}

/** Attribute values read from the DOM are decoded — re-escape on rebuild or
* quote-bearing values (data-composition-variables is a JSON array) shred
* the wrapper's markup into bogus attributes. */
function escapeAttrValue(value: string): string {
return value.replace(/&/g, "&amp;").replace(/"/g, "&quot;");
}

function extractElementAttrs(el: Element): string {
const parts: string[] = [];
for (let i = 0; i < el.attributes.length; i++) {
const attr = el.attributes[i]!;
if (attr.value === "") {
parts.push(attr.name);
} else {
parts.push(`${attr.name}="${attr.value}"`);
parts.push(`${attr.name}="${escapeAttrValue(attr.value)}"`);
}
}
return parts.join(" ");
Expand Down
24 changes: 24 additions & 0 deletions packages/studio-server/src/routes/preview.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -613,3 +613,27 @@ describe("preview ?variables= injection", () => {
expect(html).toContain('window.__hfVariables={"accent":"#f00"}');
});
});

describe("sub-composition preview attribute integrity", () => {
it("preserves quote-bearing html attributes (data-composition-variables JSON)", async () => {
const projectDir = createProjectDir();
const decls = JSON.stringify([
{ id: "title", type: "string", label: "Title", default: "Hello" },
]);
writeFileSync(
join(projectDir, "card.html"),
`<!doctype html><html data-composition-variables='${decls}'><head></head><body><div class="clip" data-start="0" data-duration="2">x</div></body></html>`,
);
const app = new Hono();
registerPreviewRoutes(app, createAdapter(projectDir));

const res = await app.request("http://localhost/projects/demo/preview/comp/card.html");
expect(res.status).toBe(200);
const html = await res.text();
const attr = /data-composition-variables="([^"]*)"/.exec(html)?.[1] ?? "";
// Entities decode back to the exact declared JSON — a lost/shredded
// attribute here silently breaks getVariables() on the comp route.
const decoded = attr.replace(/&quot;/g, '"').replace(/&amp;/g, "&");
expect(JSON.parse(decoded)).toEqual(JSON.parse(decls));
});
});
242 changes: 242 additions & 0 deletions packages/studio/src/components/panels/VariablesBindElement.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,242 @@
/**
* "Bind selected element" card for the Variables panel — the promote-a-
* property-to-variable gesture. Each action declares a variable (default =
* the element's current value, so promoting never changes the render) and
* writes the declarative binding the runtime resolves:
* data-var-src / data-var-text attributes, or `<prop>: var(--id)` styles.
*/

import { useMemo, useState } from "react";
import type { Composition, CompositionVariable } from "@hyperframes/sdk";
import type { DomEditSelection } from "../editor/domEditingTypes";

import { VARIABLES_INPUT_CLASS } from "./VariablesValueControls";

// <source> is deliberately excluded: rewriting a <source> child's src after
// the parent media element ran resource selection is a spec no-op.
const MEDIA_TAGS = new Set(["img", "video", "audio"]);

export interface BindAction {
key: string;
label: string;
/** Binding channel: data-var-src / data-var-text attribute, or a style prop. */
kind: "src" | "text" | "style";
styleProp?: string;
suggestedId: string;
declaration: (id: string) => CompositionVariable;
}

function sanitizeId(raw: string): string {
const cleaned = raw
.trim()
.replace(/[^a-zA-Z0-9_-]+/g, "-")
.replace(/^-+|-+$/g, "");
return cleaned || "variable";
}

/**
* "rgb(0, 195, 255)" / "rgba(0, 195, 255, 0.4)" → "#00c3ff". Alpha is dropped
* (color variables are hex) — a fully transparent computed color maps to
* #000000, which the picker can at least display. Unrecognized formats pass
* through verbatim.
*/
function rgbToHex(value: string): string {
const m = /^rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*(?:,\s*[\d.]+\s*)?\)$/.exec(value);
if (!m) return value;
return `#${m
.slice(1, 4)
.map((n) => Number(n).toString(16).padStart(2, "0"))
.join("")}`;
}

function firstFontFamily(value: string): string {
const first = value.split(",")[0] ?? "";
return first.trim().replace(/^["']|["']$/g, "") || "sans-serif";
}

// fallow-ignore-next-line complexity
function buildBindActions(selection: DomEditSelection, sdkSession: Composition): BindAction[] {
const hfId = selection.hfId;
if (!hfId) return [];
// No snapshot = the session can't resolve this element (e.g. a sub-comp
// element that missed the source map) — a bind would target the wrong
// document or dead-end, so offer nothing.
const snapshot = sdkSession.getElement(hfId);
if (!snapshot) return [];
const base = sanitizeId(snapshot.attributes.id ?? selection.label ?? hfId);
const actions: BindAction[] = [];

const tag = selection.tagName.toLowerCase();
if (MEDIA_TAGS.has(tag)) {
const currentSrc = snapshot.attributes.src ?? "";
actions.push({
key: "src",
label: tag === "img" ? "Image source" : "Media source",
kind: "src",
suggestedId: base,
declaration: (id) =>
tag === "img"
? { id, type: "image", label: `${selection.label} image`, default: currentSrc }
: { id, type: "string", label: `${selection.label} source`, default: currentSrc },
});
}

// Text binds only on leaf elements: the runtime preserves children, but a
// container's "own text" default rarely matches what the user sees, so the
// promote-never-changes-the-render guarantee only holds for leaves.
const text = (snapshot.text ?? "").trim();
if (text && snapshot.children.length === 0 && !selection.isCompositionHost) {
actions.push({
key: "text",
label: "Text",
kind: "text",
suggestedId: `${base}-text`,
declaration: (id) => ({
id,
type: "string",
label: `${selection.label} text`,
default: text,
}),
});
}

if (selection.capabilities.canEditStyles) {
const computed = selection.computedStyles;
actions.push(
{
key: "color",
label: "Text color",
kind: "style",
styleProp: "color",
suggestedId: `${base}-color`,
declaration: (id) => ({
id,
type: "color",
label: `${selection.label} color`,
default: rgbToHex(computed["color"] ?? "#000000"),
}),
},
{
key: "background",
label: "Background",
kind: "style",
styleProp: "background-color",
suggestedId: `${base}-bg`,
declaration: (id) => ({
id,
type: "color",
label: `${selection.label} background`,
default: rgbToHex(computed["background-color"] ?? "#000000"),
}),
},
{
key: "font",
label: "Font",
kind: "style",
styleProp: "font-family",
suggestedId: `${base}-font`,
declaration: (id) => ({
id,
type: "font",
label: `${selection.label} font`,
default: firstFontFamily(computed["font-family"] ?? "sans-serif"),
}),
},
);
}

return actions;
}

/** One batched schema edit: declare (unless the id already exists) + bind. */
export function applyBind(
session: Composition,
hfId: string,
action: BindAction,
id: string,
): void {
session.batch(() => {
if (!session.getVariableDeclarations().some((d) => d.id === id)) {
session.declareVariable(action.declaration(id));
}
if (action.kind === "style" && action.styleProp) {
session.setStyle(hfId, { [action.styleProp]: `var(--${id})` });
} else {
session.setAttribute(hfId, `data-var-${action.kind}`, id);
}
});
}

export function VariablesBindElement({
selection,
sdkSession,
onBind,
}: {
selection: DomEditSelection;
sdkSession: Composition;
onBind: (action: BindAction, id: string) => void;
}) {
const [activeKey, setActiveKey] = useState<string | null>(null);
const [idDraft, setIdDraft] = useState("");
const actions = useMemo(() => buildBindActions(selection, sdkSession), [selection, sdkSession]);
if (actions.length === 0) return null;
const active = actions.find((a) => a.key === activeKey) ?? null;

return (
<div className="space-y-1.5 rounded-lg border border-studio-accent/30 bg-neutral-900/40 p-2">
<p className="text-[9px] font-medium uppercase tracking-wider text-neutral-500">
Bind selected: <span className="normal-case text-neutral-300">{selection.label}</span>
</p>
{active ? (
<div className="space-y-1.5">
<label className="text-[9px] font-medium text-neutral-500">
Variable id for {active.label.toLowerCase()}
</label>
<input
type="text"
value={idDraft}
onChange={(e) => setIdDraft(e.target.value)}
onKeyDown={(e) => e.key === "Escape" && setActiveKey(null)}
className={`${VARIABLES_INPUT_CLASS} font-mono`}
/>
<div className="flex items-center justify-end gap-2">
<button
type="button"
onClick={() => setActiveKey(null)}
className="h-6 rounded px-2 text-[10px] text-neutral-500 hover:text-neutral-300"
>
Cancel
</button>
<button
type="button"
onClick={() => {
const id = sanitizeId(idDraft);
setActiveKey(null);
onBind(active, id);
}}
className="h-6 rounded bg-neutral-800 px-2 text-[10px] font-medium text-neutral-200 hover:bg-neutral-700"
>
Bind
</button>
</div>
</div>
) : (
<div className="flex flex-wrap gap-1.5">
{actions.map((action) => (
<button
key={action.key}
type="button"
onClick={() => {
setActiveKey(action.key);
setIdDraft(action.suggestedId);
}}
className="h-6 rounded-md border border-neutral-800 px-2 text-[10px] font-medium text-neutral-400 transition-colors hover:border-neutral-700 hover:text-neutral-200"
>
{action.label} →&nbsp;variable
</button>
))}
</div>
)}
</div>
);
}
Loading
Loading