Skip to content
Merged
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));
});
});
263 changes: 263 additions & 0 deletions packages/studio/src/components/panels/VariablesBindElement.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,263 @@
/**
* "Bind selected element" card for the Variables panel — the promote-a-
* property-to-variable gesture. Each action declares a variable whose default
* is the element's current value, so binding to a NEW id never changes the
* render. Binding to an EXISTING id instead wires the element to that
* variable's current default (which came from another element) — the card
* warns before committing, since that does change the render. The binding the
* runtime resolves is written as a data-var-src / data-var-text attribute, or
* a `<prop>: var(--id)` style.
*/

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;

const trimmedId = sanitizeId(idDraft);
const idIsEmpty = idDraft.trim().length === 0;
// Binding to an id that already exists wires the element to THAT variable's
// existing default (declared from a different element), so the render changes
// silently. Surface it before the user commits rather than after.
const existingDecl = active
? sdkSession.getVariableDeclarations().find((d) => d.id === trimmedId)
: undefined;

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`}
/>
{existingDecl && (
<p className="text-[9px] leading-snug text-amber-400/90">
"{trimmedId}" already exists. This element will use its current value
{existingDecl.default !== undefined && (
<span className="font-mono"> ({String(existingDecl.default)})</span>
)}
, not the element's own — binding won't change "{trimmedId}".
</p>
)}
<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"
disabled={idIsEmpty}
onClick={() => {
setActiveKey(null);
onBind(active, trimmedId);
}}
className="h-6 rounded bg-neutral-800 px-2 text-[10px] font-medium text-neutral-200 hover:bg-neutral-700 disabled:cursor-not-allowed disabled:opacity-40"
>
{existingDecl ? "Bind anyway" : "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