mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
Merge pull request #2053 from heygen-com/vi/figma-loop-fixes
fix(core,cli,lint): close the figma brand-token loop — runtime CSS variables, --name, snippet lint
This commit is contained in:
@@ -74,7 +74,7 @@ describe("runComponentImport", () => {
|
|||||||
|
|
||||||
it("bakes literals and reports unresolved bindings when the index is empty", async () => {
|
it("bakes literals and reports unresolved bindings when the index is empty", async () => {
|
||||||
const { out, html } = await importHero();
|
const { out, html } = await importHero();
|
||||||
expect(html).toContain("background: #0066FF");
|
expect(html).toContain("background-color: #0066FF");
|
||||||
expect(html).not.toContain("var(");
|
expect(html).not.toContain("var(");
|
||||||
expect(out.unresolved).toHaveLength(1);
|
expect(out.unresolved).toHaveLength(1);
|
||||||
expect(out.unresolved[0]?.figmaId).toBe("VariableID:1:1");
|
expect(out.unresolved[0]?.figmaId).toBe("VariableID:1:1");
|
||||||
@@ -153,4 +153,17 @@ describe("runComponentImport", () => {
|
|||||||
}),
|
}),
|
||||||
).rejects.toThrow(/rate limit/);
|
).rejects.toThrow(/rate limit/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("honors a name override so variant frames don't slug-collide", async () => {
|
||||||
|
const out = await runComponentImport("FILE:1-1", {
|
||||||
|
projectDir: dir,
|
||||||
|
client: client(),
|
||||||
|
download: () => Promise.resolve(SVG),
|
||||||
|
name: "Hero Actions",
|
||||||
|
});
|
||||||
|
expect(out.name).toBe("hero-actions");
|
||||||
|
expect(out.htmlPath).toContain("hero-actions");
|
||||||
|
const html = readFileSync(join(dir, out.htmlPath), "utf8");
|
||||||
|
expect(html).toMatch(/^<div id="hero-actions"/);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
slugify,
|
slugify,
|
||||||
type BindingSite,
|
type BindingSite,
|
||||||
type FigmaClient,
|
type FigmaClient,
|
||||||
|
type NodeToHtmlResult,
|
||||||
type RasterizeRequest,
|
type RasterizeRequest,
|
||||||
} from "@hyperframes/core/figma";
|
} from "@hyperframes/core/figma";
|
||||||
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
|
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
|
||||||
@@ -27,7 +28,7 @@ function escapeAttr(value: string): string {
|
|||||||
.replace(/>/g, ">")
|
.replace(/>/g, ">")
|
||||||
.replace(/"/g, """);
|
.replace(/"/g, """);
|
||||||
}
|
}
|
||||||
import { runAssetImport } from "./asset.js";
|
import { runAssetImport, type AssetImportResult } from "./asset.js";
|
||||||
import { downloadRender } from "./download.js";
|
import { downloadRender } from "./download.js";
|
||||||
import { withFigmaErrors } from "./cliError.js";
|
import { withFigmaErrors } from "./cliError.js";
|
||||||
|
|
||||||
@@ -35,6 +36,9 @@ export interface ComponentImportDeps {
|
|||||||
projectDir: string;
|
projectDir: string;
|
||||||
client: FigmaClient;
|
client: FigmaClient;
|
||||||
download: (url: string) => Promise<Uint8Array>;
|
download: (url: string) => Promise<Uint8Array>;
|
||||||
|
/** override the component name — figma variant frames are often all named
|
||||||
|
* "Platform=Desktop", which would slug-collide across imports */
|
||||||
|
name?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ComponentImportResult {
|
export interface ComponentImportResult {
|
||||||
@@ -55,9 +59,9 @@ export async function runComponentImport(
|
|||||||
|
|
||||||
const tree = await deps.client.nodeTree(ref);
|
const tree = await deps.client.nodeTree(ref);
|
||||||
const bindings = resolveBindings(tree, readBindings(deps.projectDir));
|
const bindings = resolveBindings(tree, readBindings(deps.projectDir));
|
||||||
const mapped = nodeToHtml(tree, bindings);
|
const mapped = nodeToHtml(tree, bindings, { rootName: deps.name });
|
||||||
|
|
||||||
const name = slugify(tree.name);
|
const name = slugify(deps.name ?? tree.name);
|
||||||
const componentDir = join(deps.projectDir, "compositions", "components", name);
|
const componentDir = join(deps.projectDir, "compositions", "components", name);
|
||||||
if (existsSync(componentDir))
|
if (existsSync(componentDir))
|
||||||
console.warn(
|
console.warn(
|
||||||
@@ -65,52 +69,12 @@ export async function runComponentImport(
|
|||||||
);
|
);
|
||||||
mkdirSync(componentDir, { recursive: true });
|
mkdirSync(componentDir, { recursive: true });
|
||||||
|
|
||||||
// Rasterize fallback: export each unmappable node via Phase 1 and point
|
const { html, frozenAssets, failedRasterize } = await rasterizeFallback(
|
||||||
// the placeholder img at the frozen file (path relative to the component).
|
mapped,
|
||||||
// The search key must match the EMITTED (html-escaped) node id, and
|
ref.fileKey,
|
||||||
// replaceAll covers the same node appearing twice in the tree.
|
componentDir,
|
||||||
let html = mapped.html;
|
deps,
|
||||||
const frozenAssets: string[] = [];
|
|
||||||
const failedRasterize: string[] = [];
|
|
||||||
for (const req of mapped.rasterize) {
|
|
||||||
// figma sometimes refuses to render a node (nested instances commonly
|
|
||||||
// fail as svg) — retry once as png, then skip THIS node and keep the
|
|
||||||
// import: one unrenderable node must not abort the whole component. The
|
|
||||||
// placeholder keeps its data-figma-rasterize marker (no src) so the gap
|
|
||||||
// is visible and hand-fixable.
|
|
||||||
let asset = null;
|
|
||||||
for (const format of ["svg", "png"] as const) {
|
|
||||||
try {
|
|
||||||
asset = await runAssetImport(
|
|
||||||
`${ref.fileKey}:${req.nodeId}`,
|
|
||||||
{ format, description: req.name },
|
|
||||||
{ projectDir: deps.projectDir, client: deps.client, download: deps.download },
|
|
||||||
);
|
);
|
||||||
break;
|
|
||||||
} catch (err) {
|
|
||||||
if (!(err instanceof FigmaClientError) || err.code !== "RENDER_FAILED") throw err;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (asset === null) {
|
|
||||||
failedRasterize.push(req.nodeId);
|
|
||||||
console.warn(
|
|
||||||
`could not render node ${req.nodeId} ("${req.name}") as svg or png — leaving its placeholder without src`,
|
|
||||||
);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
frozenAssets.push(asset.record.path);
|
|
||||||
// src is a URL — always forward slashes, even when relative() yields
|
|
||||||
// windows separators.
|
|
||||||
const srcRel = relative(componentDir, join(deps.projectDir, asset.record.path)).replaceAll(
|
|
||||||
"\\",
|
|
||||||
"/",
|
|
||||||
);
|
|
||||||
const emittedId = escapeAttr(req.nodeId);
|
|
||||||
html = html.replaceAll(
|
|
||||||
`data-figma-rasterize="${emittedId}" `,
|
|
||||||
`data-figma-rasterize="${emittedId}" src="${escapeAttr(srcRel)}" `,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const htmlFile = join(componentDir, `${name}.html`);
|
const htmlFile = join(componentDir, `${name}.html`);
|
||||||
writeFileSync(htmlFile, html + "\n");
|
writeFileSync(htmlFile, html + "\n");
|
||||||
@@ -148,10 +112,83 @@ export async function runComponentImport(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface RasterizeOutcome {
|
||||||
|
html: string;
|
||||||
|
frozenAssets: string[];
|
||||||
|
failedRasterize: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rasterize fallback: export each unmappable node via Phase 1 and point the
|
||||||
|
* placeholder img at the frozen file (path relative to the component). figma
|
||||||
|
* sometimes refuses to render a node (nested instances commonly fail as svg)
|
||||||
|
* — retry once as png, then skip THAT node and keep the import: one
|
||||||
|
* unrenderable node must not abort the whole component. Skipped placeholders
|
||||||
|
* keep their data-figma-rasterize marker (no src) so the gap is visible.
|
||||||
|
*/
|
||||||
|
async function rasterizeFallback(
|
||||||
|
mapped: NodeToHtmlResult,
|
||||||
|
fileKey: string,
|
||||||
|
componentDir: string,
|
||||||
|
deps: ComponentImportDeps,
|
||||||
|
): Promise<RasterizeOutcome> {
|
||||||
|
let html = mapped.html;
|
||||||
|
const frozenAssets: string[] = [];
|
||||||
|
const failedRasterize: string[] = [];
|
||||||
|
for (const req of mapped.rasterize) {
|
||||||
|
const asset = await renderWithPngRetry(fileKey, req, deps);
|
||||||
|
if (asset === null) {
|
||||||
|
failedRasterize.push(req.nodeId);
|
||||||
|
console.warn(
|
||||||
|
`could not render node ${req.nodeId} ("${req.name}") as svg or png — leaving its placeholder without src`,
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
frozenAssets.push(asset.record.path);
|
||||||
|
// src is a URL — always forward slashes, even when relative() yields
|
||||||
|
// windows separators.
|
||||||
|
const srcRel = relative(componentDir, join(deps.projectDir, asset.record.path)).replaceAll(
|
||||||
|
"\\",
|
||||||
|
"/",
|
||||||
|
);
|
||||||
|
// The search key must match the EMITTED (html-escaped) node id, and
|
||||||
|
// replaceAll covers the same node appearing twice in the tree.
|
||||||
|
const emittedId = escapeAttr(req.nodeId);
|
||||||
|
html = html.replaceAll(
|
||||||
|
`data-figma-rasterize="${emittedId}" `,
|
||||||
|
`data-figma-rasterize="${emittedId}" src="${escapeAttr(srcRel)}" `,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return { html, frozenAssets, failedRasterize };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderWithPngRetry(
|
||||||
|
fileKey: string,
|
||||||
|
req: RasterizeRequest,
|
||||||
|
deps: ComponentImportDeps,
|
||||||
|
): Promise<AssetImportResult | null> {
|
||||||
|
for (const format of ["svg", "png"] as const) {
|
||||||
|
try {
|
||||||
|
return await runAssetImport(
|
||||||
|
`${fileKey}:${req.nodeId}`,
|
||||||
|
{ format, description: req.name },
|
||||||
|
{ projectDir: deps.projectDir, client: deps.client, download: deps.download },
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
if (!(err instanceof FigmaClientError) || err.code !== "RENDER_FAILED") throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
export default defineCommand({
|
export default defineCommand({
|
||||||
meta: { name: "component", description: "Import a figma frame as an editable HTML component" },
|
meta: { name: "component", description: "Import a figma frame as an editable HTML component" },
|
||||||
args: {
|
args: {
|
||||||
ref: { type: "positional", description: "figma URL or fileKey:nodeId", required: true },
|
ref: { type: "positional", description: "figma URL or fileKey:nodeId", required: true },
|
||||||
|
name: {
|
||||||
|
type: "string",
|
||||||
|
description: "component name override (variant frames often share a name and would collide)",
|
||||||
|
},
|
||||||
dir: { type: "string", description: "project directory", default: "." },
|
dir: { type: "string", description: "project directory", default: "." },
|
||||||
},
|
},
|
||||||
async run({ args }) {
|
async run({ args }) {
|
||||||
@@ -162,6 +199,7 @@ export default defineCommand({
|
|||||||
projectDir: args.dir,
|
projectDir: args.dir,
|
||||||
client,
|
client,
|
||||||
download: downloadRender,
|
download: downloadRender,
|
||||||
|
name: args.name,
|
||||||
});
|
});
|
||||||
console.log(`imported component "${result.name}" → ${result.htmlPath}`);
|
console.log(`imported component "${result.name}" → ${result.htmlPath}`);
|
||||||
if (result.rasterized.length > 0) {
|
if (result.rasterized.length > 0) {
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
|
import { markFlattenedInnerRoot } from "../runtime/flattenedRoot";
|
||||||
|
export { FLATTENED_INNER_ROOT_STRIP_ATTRS } from "../runtime/flattenedRoot";
|
||||||
|
import { parseHostVariableValues } from "../runtime/getVariables";
|
||||||
|
import { cssVariableName } from "../tokenSlug";
|
||||||
import { readFileSync, existsSync } from "fs";
|
import { readFileSync, existsSync } from "fs";
|
||||||
import { join, resolve, relative, dirname, isAbsolute, sep } from "path";
|
import { join, resolve, relative, dirname, isAbsolute, sep } from "path";
|
||||||
import { CSS_URL_RE, isNonRelativeUrl } from "./assetPaths.js";
|
import { CSS_URL_RE, isNonRelativeUrl } from "./assetPaths.js";
|
||||||
@@ -392,43 +396,9 @@ function assignBundledRuntimeCompositionIds(
|
|||||||
return identities;
|
return identities;
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseHostVariableValues(host: Element): Record<string, unknown> {
|
|
||||||
const raw = host.getAttribute("data-variable-values");
|
|
||||||
if (!raw) return {};
|
|
||||||
let parsed: unknown;
|
|
||||||
try {
|
|
||||||
parsed = JSON.parse(raw);
|
|
||||||
} catch {
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {};
|
|
||||||
return parsed as Record<string, unknown>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const FLATTENED_INNER_ROOT_STRIP_ATTRS = [
|
|
||||||
"data-composition-id",
|
|
||||||
"data-composition-file",
|
|
||||||
"data-start",
|
|
||||||
"data-duration",
|
|
||||||
"data-end",
|
|
||||||
"data-track-index",
|
|
||||||
"data-track",
|
|
||||||
"data-composition-src",
|
|
||||||
"data-hf-authored-duration",
|
|
||||||
"data-hf-authored-end",
|
|
||||||
];
|
|
||||||
|
|
||||||
export function prepareFlattenedInnerRoot(innerRoot: Element): Element {
|
export function prepareFlattenedInnerRoot(innerRoot: Element): Element {
|
||||||
const prepared = innerRoot.cloneNode(true) as Element;
|
const prepared = innerRoot.cloneNode(true) as Element;
|
||||||
const authoredRootId = prepared.getAttribute("id")?.trim();
|
markFlattenedInnerRoot(prepared);
|
||||||
for (const attrName of FLATTENED_INNER_ROOT_STRIP_ATTRS) {
|
|
||||||
prepared.removeAttribute(attrName);
|
|
||||||
}
|
|
||||||
if (authoredRootId) {
|
|
||||||
prepared.removeAttribute("id");
|
|
||||||
prepared.setAttribute("data-hf-authored-id", authoredRootId);
|
|
||||||
}
|
|
||||||
prepared.setAttribute("data-hf-inner-root", "true");
|
|
||||||
const w = prepared.getAttribute("data-width");
|
const w = prepared.getAttribute("data-width");
|
||||||
const h = prepared.getAttribute("data-height");
|
const h = prepared.getAttribute("data-height");
|
||||||
const widthVal = w ? `${w}px` : "100%";
|
const widthVal = w ? `${w}px` : "100%";
|
||||||
@@ -890,6 +860,13 @@ export async function bundleToSingleHtml(
|
|||||||
if (runtimeCompId && Object.keys(mergedVariables).length > 0) {
|
if (runtimeCompId && Object.keys(mergedVariables).length > 0) {
|
||||||
compVariablesByComp[runtimeCompId] = mergedVariables;
|
compVariablesByComp[runtimeCompId] = mergedVariables;
|
||||||
}
|
}
|
||||||
|
pushSubCompVariableStyles(
|
||||||
|
innerDoc,
|
||||||
|
innerRoot,
|
||||||
|
mergedVariables,
|
||||||
|
runtimeScope,
|
||||||
|
compStyleChunks,
|
||||||
|
);
|
||||||
|
|
||||||
if (innerRoot) {
|
if (innerRoot) {
|
||||||
// Hoist styles into the collected style chunks
|
// Hoist styles into the collected style chunks
|
||||||
@@ -973,6 +950,8 @@ export async function bundleToSingleHtml(
|
|||||||
document.body.appendChild(compScript);
|
document.body.appendChild(compScript);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
emitRootCompositionVariableStyles(document, compVariablesByComp);
|
||||||
|
|
||||||
enforceCompositionPixelSizing(document);
|
enforceCompositionPixelSizing(document);
|
||||||
autoHealMissingCompositionIds(document);
|
autoHealMissingCompositionIds(document);
|
||||||
coalesceHeadStylesAndBodyScripts(document);
|
coalesceHeadStylesAndBodyScripts(document);
|
||||||
@@ -1014,3 +993,159 @@ export async function bundleToSingleHtml(
|
|||||||
|
|
||||||
return document.toString();
|
return document.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** One stylesheet rule defining primitive composition variables under `selector`. */
|
||||||
|
function compositionVariablesCssBlock(
|
||||||
|
variables: Record<string, unknown>,
|
||||||
|
selector: string,
|
||||||
|
): string | null {
|
||||||
|
const lines: string[] = [];
|
||||||
|
for (const [id, value] of Object.entries(variables)) {
|
||||||
|
if ((typeof value === "string" && value !== "") || typeof value === "number") {
|
||||||
|
lines.push(` ${cssVariableName(id)}: ${String(value)};`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (lines.length === 0) return null;
|
||||||
|
return `${selector} {\n${lines.join("\n")}\n}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compile-time counterpart of the runtime's injectCompositionCssVariables:
|
||||||
|
* every element declaring data-composition-variables gets a scoped stylesheet
|
||||||
|
* rule so var(--slug, literal) references resolve during body parse. The
|
||||||
|
* runtime injection remains define-if-absent, so it won't double-apply.
|
||||||
|
*
|
||||||
|
* `variablesByComp` (host-merged sub-composition values, keyed by runtime
|
||||||
|
* composition id) adds one rule per scope — the flattened inner root loses
|
||||||
|
* its data-composition-id, so the host selector is the only stable anchor.
|
||||||
|
* Exported for the producer's render compiler, which inlines sub-compositions
|
||||||
|
* through the shared module rather than this bundler.
|
||||||
|
* Returns whether a style element was appended.
|
||||||
|
*/
|
||||||
|
export function emitRootCompositionVariableStyles(
|
||||||
|
document: Document,
|
||||||
|
variablesByComp: Record<string, Record<string, unknown>> = {},
|
||||||
|
overrides: Record<string, unknown> = {},
|
||||||
|
): boolean {
|
||||||
|
const layerFor = makeVariableLayer(document, overrides);
|
||||||
|
const rules = [
|
||||||
|
...hostScopedVariableRules(variablesByComp, overrides),
|
||||||
|
...rootDeclaredVariableRules(document, layerFor),
|
||||||
|
...declarerVariableRules(document, layerFor),
|
||||||
|
];
|
||||||
|
if (rules.length === 0) return false;
|
||||||
|
const style = document.createElement("style");
|
||||||
|
style.setAttribute("data-hf-composition-variables", "");
|
||||||
|
style.textContent = rules.join("\n\n");
|
||||||
|
document.head.appendChild(style);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
type VariableLayer = (
|
||||||
|
declared: Record<string, unknown>,
|
||||||
|
hostValues: Record<string, unknown>,
|
||||||
|
) => Record<string, unknown>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Layering for one declarer: authored stylesheet definitions win over
|
||||||
|
* declared defaults (the runtime's define-if-absent, applied statically) —
|
||||||
|
* a var already defined in any authored <style> block is not emitted. Host
|
||||||
|
* values and --variables overrides are explicit intent, never filtered.
|
||||||
|
*/
|
||||||
|
function makeVariableLayer(document: Document, overrides: Record<string, unknown>): VariableLayer {
|
||||||
|
const authoredCss = [...document.querySelectorAll("style:not([data-hf-composition-variables])")]
|
||||||
|
.map((s) => s.textContent || "")
|
||||||
|
.join("\n");
|
||||||
|
const authoredDefines = (id: string): boolean =>
|
||||||
|
new RegExp(`${cssVariableName(id)}\\s*:`).test(authoredCss);
|
||||||
|
return (declared, hostValues) => {
|
||||||
|
const out: Record<string, unknown> = {};
|
||||||
|
for (const [id, value] of Object.entries(declared)) {
|
||||||
|
if (!authoredDefines(id)) out[id] = value;
|
||||||
|
}
|
||||||
|
for (const [id, value] of Object.entries(hostValues)) {
|
||||||
|
if (id in declared) out[id] = value;
|
||||||
|
}
|
||||||
|
for (const [id, value] of Object.entries(overrides)) {
|
||||||
|
if (id in declared || id in hostValues) out[id] = value;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Host-scoped rules: per-instance values inherited by the host's subtree. */
|
||||||
|
function hostScopedVariableRules(
|
||||||
|
variablesByComp: Record<string, Record<string, unknown>>,
|
||||||
|
overrides: Record<string, unknown>,
|
||||||
|
): string[] {
|
||||||
|
const rules: string[] = [];
|
||||||
|
for (const [compId, vars] of Object.entries(variablesByComp)) {
|
||||||
|
const withOverrides = { ...vars };
|
||||||
|
for (const [id, value] of Object.entries(overrides)) {
|
||||||
|
if (id in vars) withOverrides[id] = value;
|
||||||
|
}
|
||||||
|
const rule = compositionVariablesCssBlock(
|
||||||
|
withOverrides,
|
||||||
|
cssAttributeSelector("data-composition-id", compId),
|
||||||
|
);
|
||||||
|
if (rule) rules.push(rule);
|
||||||
|
}
|
||||||
|
return rules;
|
||||||
|
}
|
||||||
|
|
||||||
|
function rootDeclaredVariableRules(document: Document, layerFor: VariableLayer): string[] {
|
||||||
|
const htmlDeclared = readDeclaredDefaults(document.documentElement);
|
||||||
|
const htmlRule = compositionVariablesCssBlock(layerFor(htmlDeclared, {}), ":root");
|
||||||
|
return htmlRule ? [htmlRule] : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Declarer rules anchor on a per-instance marker attribute, not the
|
||||||
|
* composition id: two inlined instances of one sub-composition share a
|
||||||
|
* data-composition-id, and a shared selector would let instance A's rule
|
||||||
|
* restyle instance B. The nearest ancestor host's data-variable-values
|
||||||
|
* layer over the declared defaults (mirrors the runtime loader).
|
||||||
|
*/
|
||||||
|
function declarerVariableRules(document: Document, layerFor: VariableLayer): string[] {
|
||||||
|
const rules: string[] = [];
|
||||||
|
let markerSeq = 0;
|
||||||
|
for (const el of [...document.querySelectorAll("[data-composition-variables]")]) {
|
||||||
|
const declared = readDeclaredDefaults(el);
|
||||||
|
const hostEl =
|
||||||
|
typeof el.closest === "function" ? el.parentElement?.closest("[data-variable-values]") : null;
|
||||||
|
const hostValues = hostEl ? parseHostVariableValues(hostEl) : {};
|
||||||
|
const vars = layerFor(declared, hostValues);
|
||||||
|
if (Object.keys(vars).length === 0) continue;
|
||||||
|
markerSeq += 1;
|
||||||
|
el.setAttribute("data-hf-var-scope", String(markerSeq));
|
||||||
|
const rule = compositionVariablesCssBlock(vars, `[data-hf-var-scope="${markerSeq}"]`);
|
||||||
|
if (rule) rules.push(rule);
|
||||||
|
}
|
||||||
|
return rules;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compile-time CSS custom properties for a sub-comp scope: declared defaults
|
||||||
|
* layered under per-instance host values, emitted as a stylesheet rule on the
|
||||||
|
* host selector. A stylesheet in <head> is in effect while the body parses,
|
||||||
|
* so eval-time reads (GSAP .from immediateRender, canvas tinting) see the
|
||||||
|
* right values — the runtime's DOMContentLoaded injection is too late for
|
||||||
|
* those on compiled pages.
|
||||||
|
*/
|
||||||
|
function pushSubCompVariableStyles(
|
||||||
|
innerDoc: Document,
|
||||||
|
innerRoot: Element | null,
|
||||||
|
mergedVariables: Record<string, unknown>,
|
||||||
|
runtimeScope: string,
|
||||||
|
compStyleChunks: string[],
|
||||||
|
): void {
|
||||||
|
if (!runtimeScope) return;
|
||||||
|
const declaredForCss = readDeclaredDefaults(innerDoc.documentElement);
|
||||||
|
const innerRootForVars = innerRoot ?? innerDoc.querySelector("[data-composition-variables]");
|
||||||
|
if (innerRootForVars) Object.assign(declaredForCss, readDeclaredDefaults(innerRootForVars));
|
||||||
|
const cssVars = compositionVariablesCssBlock(
|
||||||
|
{ ...declaredForCss, ...mergedVariables },
|
||||||
|
runtimeScope,
|
||||||
|
);
|
||||||
|
if (cssVars) compStyleChunks.push(cssVars);
|
||||||
|
}
|
||||||
|
|||||||
@@ -32,7 +32,9 @@ export {
|
|||||||
type BundleOptions,
|
type BundleOptions,
|
||||||
prepareFlattenedInnerRoot,
|
prepareFlattenedInnerRoot,
|
||||||
FLATTENED_INNER_ROOT_STRIP_ATTRS,
|
FLATTENED_INNER_ROOT_STRIP_ATTRS,
|
||||||
|
emitRootCompositionVariableStyles,
|
||||||
} from "./htmlBundler";
|
} from "./htmlBundler";
|
||||||
|
export { readDeclaredDefaults, parseHostVariableValues } from "../runtime/getVariables";
|
||||||
|
|
||||||
export {
|
export {
|
||||||
RUNTIME_BOOTSTRAP_ATTR,
|
RUNTIME_BOOTSTRAP_ATTR,
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ describe("nodeToHtml", () => {
|
|||||||
expect(out.html).toContain("width: 800px");
|
expect(out.html).toContain("width: 800px");
|
||||||
expect(out.html).toContain("height: 600px");
|
expect(out.html).toContain("height: 600px");
|
||||||
expect(out.html).toContain("position: relative");
|
expect(out.html).toContain("position: relative");
|
||||||
expect(out.html).toContain("background: #FFFFFF");
|
expect(out.html).toContain("background-color: #FFFFFF");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("absolutely positions children relative to the root frame", () => {
|
it("absolutely positions children relative to the root frame", () => {
|
||||||
@@ -49,7 +49,7 @@ describe("nodeToHtml", () => {
|
|||||||
expect(out.html).toContain("width: 120px");
|
expect(out.html).toContain("width: 120px");
|
||||||
expect(out.html).toContain("border-radius: 8px");
|
expect(out.html).toContain("border-radius: 8px");
|
||||||
expect(out.html).toContain("opacity: 0.9");
|
expect(out.html).toContain("opacity: 0.9");
|
||||||
expect(out.html).toContain("background: #0066FF");
|
expect(out.html).toContain("background-color: #0066FF");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("emits var() with literal fallback for resolved bindings", () => {
|
it("emits var() with literal fallback for resolved bindings", () => {
|
||||||
@@ -75,7 +75,7 @@ describe("nodeToHtml", () => {
|
|||||||
unresolved: [],
|
unresolved: [],
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
expect(out.html).toContain("background: var(--figma-blue-500, #0066FF)");
|
expect(out.html).toContain("background-color: var(--figma-blue-500, #0066FF)");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("bakes literals and flags unresolved bindings — never a dangling var()", () => {
|
it("bakes literals and flags unresolved bindings — never a dangling var()", () => {
|
||||||
@@ -94,7 +94,7 @@ describe("nodeToHtml", () => {
|
|||||||
unresolved: [{ nodeId: "1:2", property: "fills", figmaId: "VariableID:9:9" }],
|
unresolved: [{ nodeId: "1:2", property: "fills", figmaId: "VariableID:9:9" }],
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
expect(out.html).toContain("background: #0066FF");
|
expect(out.html).toContain("background-color: #0066FF");
|
||||||
expect(out.html).not.toContain("var(");
|
expect(out.html).not.toContain("var(");
|
||||||
expect(out.html).toContain('data-figma-unresolved="fills"');
|
expect(out.html).toContain('data-figma-unresolved="fills"');
|
||||||
});
|
});
|
||||||
@@ -168,7 +168,7 @@ describe("nodeToHtml", () => {
|
|||||||
);
|
);
|
||||||
expect(out.html).not.toContain("1:5");
|
expect(out.html).not.toContain("1:5");
|
||||||
expect(out.html).toContain('data-figma-id="1:6"');
|
expect(out.html).toContain('data-figma-id="1:6"');
|
||||||
expect(out.html).not.toContain("background: #0066FF");
|
expect(out.html).not.toContain("background-color: #0066FF");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("maps linear gradients and drop shadows", () => {
|
it("maps linear gradients and drop shadows", () => {
|
||||||
|
|||||||
@@ -65,17 +65,8 @@ function boxOf(node: FigmaNodeDocument): Box | null {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function slugify(name: string): string {
|
export { slugify } from "../tokenSlug";
|
||||||
const slug = name
|
import { cssVariableName as cssVarName, slugify } from "../tokenSlug";
|
||||||
.toLowerCase()
|
|
||||||
.replace(/[^a-z0-9]+/g, "-")
|
|
||||||
.replace(/^-+|-+$/g, "");
|
|
||||||
return slug.length > 0 ? slug : "node";
|
|
||||||
}
|
|
||||||
|
|
||||||
function cssVarName(compositionVariableId: string): string {
|
|
||||||
return `--${slugify(compositionVariableId)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function escapeHtml(text: string): string {
|
function escapeHtml(text: string): string {
|
||||||
return text
|
return text
|
||||||
@@ -217,6 +208,17 @@ function geometryCss(node: FigmaNodeDocument, ctx: RenderContext, isRoot: boolea
|
|||||||
return styles;
|
return styles;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function shapeCss(node: FigmaNodeDocument, styles: string[]): void {
|
||||||
|
if (node.type === "ELLIPSE") {
|
||||||
|
styles.push("border-radius: 50%");
|
||||||
|
} else if (typeof node.cornerRadius === "number" && node.cornerRadius > 0) {
|
||||||
|
styles.push(`border-radius: ${round(node.cornerRadius)}px`);
|
||||||
|
}
|
||||||
|
if (node.clipsContent === true) styles.push("overflow: hidden");
|
||||||
|
if (typeof node.opacity === "number" && node.opacity < 1)
|
||||||
|
styles.push(`opacity: ${round(node.opacity)}`);
|
||||||
|
}
|
||||||
|
|
||||||
function decorationCss(node: FigmaNodeDocument, ctx: RenderContext): string[] {
|
function decorationCss(node: FigmaNodeDocument, ctx: RenderContext): string[] {
|
||||||
const styles: string[] = [];
|
const styles: string[] = [];
|
||||||
// backgroundValue is the binding-aware path (var(--slug, literal)) — TEXT
|
// backgroundValue is the binding-aware path (var(--slug, literal)) — TEXT
|
||||||
@@ -226,16 +228,13 @@ function decorationCss(node: FigmaNodeDocument, ctx: RenderContext): string[] {
|
|||||||
if (bg !== null) styles.push(`color: ${bg}`);
|
if (bg !== null) styles.push(`color: ${bg}`);
|
||||||
textCss(node, styles);
|
textCss(node, styles);
|
||||||
} else if (bg !== null) {
|
} else if (bg !== null) {
|
||||||
styles.push(`background: ${bg}`);
|
// background-color (longhand) for solid fills, never the shorthand: GSAP
|
||||||
|
// backgroundColor tweens can't read a var() through the shorthand (its
|
||||||
|
// pending-substitution longhands serialize empty), so .from/.to on an
|
||||||
|
// imported node would settle on transparent instead of the token color.
|
||||||
|
styles.push(bg.includes("gradient(") ? `background: ${bg}` : `background-color: ${bg}`);
|
||||||
}
|
}
|
||||||
if (node.type === "ELLIPSE") {
|
shapeCss(node, styles);
|
||||||
styles.push("border-radius: 50%");
|
|
||||||
} else if (typeof node.cornerRadius === "number" && node.cornerRadius > 0) {
|
|
||||||
styles.push(`border-radius: ${round(node.cornerRadius)}px`);
|
|
||||||
}
|
|
||||||
if (node.clipsContent === true) styles.push("overflow: hidden");
|
|
||||||
if (typeof node.opacity === "number" && node.opacity < 1)
|
|
||||||
styles.push(`opacity: ${round(node.opacity)}`);
|
|
||||||
effectsCss(node, styles);
|
effectsCss(node, styles);
|
||||||
return styles;
|
return styles;
|
||||||
}
|
}
|
||||||
@@ -264,7 +263,10 @@ function renderNodeHtml(
|
|||||||
const style = escapeHtml(
|
const style = escapeHtml(
|
||||||
[...geometryCss(node, ctx, isRoot), ...decorationCss(node, ctx)].join("; "),
|
[...geometryCss(node, ctx, isRoot), ...decorationCss(node, ctx)].join("; "),
|
||||||
);
|
);
|
||||||
const idAttrs = `id="${slug}" data-figma-id="${escapeHtml(node.id)}"${unresolvedAttr(node, ctx)}`;
|
// data-hf-snippet marks the file as a mountable fragment, not a standalone
|
||||||
|
// composition — the project linter skips composition-root rules for it.
|
||||||
|
const snippetAttr = isRoot ? ' data-hf-snippet=""' : "";
|
||||||
|
const idAttrs = `id="${slug}"${snippetAttr} data-figma-id="${escapeHtml(node.id)}"${unresolvedAttr(node, ctx)}`;
|
||||||
|
|
||||||
if (RASTERIZE_TYPES.has(node.type)) {
|
if (RASTERIZE_TYPES.has(node.type)) {
|
||||||
ctx.rasterize.push({ nodeId: node.id, name: node.name, slug });
|
ctx.rasterize.push({ nodeId: node.id, name: node.name, slug });
|
||||||
@@ -279,12 +281,21 @@ function renderNodeHtml(
|
|||||||
return `<div ${idAttrs} style="${style}">${renderChildren(node, ctx, depth)}</div>`;
|
return `<div ${idAttrs} style="${style}">${renderChildren(node, ctx, depth)}</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface NodeToHtmlOptions {
|
||||||
|
/** override for the ROOT element's slug/id — variant frames are often all
|
||||||
|
* named "Platform=Desktop", so the caller's --name must reach the DOM id,
|
||||||
|
* not just the output directory */
|
||||||
|
rootName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export function nodeToHtml(
|
export function nodeToHtml(
|
||||||
root: FigmaNodeDocument,
|
root: FigmaNodeDocument,
|
||||||
bindings: ResolveBindingsResult,
|
bindings: ResolveBindingsResult,
|
||||||
|
opts: NodeToHtmlOptions = {},
|
||||||
): NodeToHtmlResult {
|
): NodeToHtmlResult {
|
||||||
const origin = boxOf(root) ?? { x: 0, y: 0, width: 0, height: 0 };
|
const origin = boxOf(root) ?? { x: 0, y: 0, width: 0, height: 0 };
|
||||||
const ctx: RenderContext = { origin, bindings, rasterize: [], usedSlugs: new Set() };
|
const ctx: RenderContext = { origin, bindings, rasterize: [], usedSlugs: new Set() };
|
||||||
const html = renderNodeHtml(root, ctx, true);
|
const rootForRender = opts.rootName !== undefined ? { ...root, name: opts.rootName } : root;
|
||||||
|
const html = renderNodeHtml(rootForRender, ctx, true);
|
||||||
return { html, rasterize: ctx.rasterize };
|
return { html, rasterize: ctx.rasterize };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,12 @@
|
|||||||
import { scopeCssToComposition, wrapScopedCompositionScript } from "../compiler/compositionScoping";
|
import { scopeCssToComposition, wrapScopedCompositionScript } from "../compiler/compositionScoping";
|
||||||
import { readDeclaredDefaults } from "./getVariables";
|
import { markFlattenedInnerRoot } from "./flattenedRoot";
|
||||||
|
import {
|
||||||
|
applyCssVariables,
|
||||||
|
clearAppliedCssVariables,
|
||||||
|
parseHostVariableValues,
|
||||||
|
readDeclaredDefaults,
|
||||||
|
readRenderOverrides,
|
||||||
|
} from "./getVariables";
|
||||||
|
|
||||||
type LoadExternalCompositionsParams = {
|
type LoadExternalCompositionsParams = {
|
||||||
injectedStyles: HTMLStyleElement[];
|
injectedStyles: HTMLStyleElement[];
|
||||||
@@ -172,30 +179,9 @@ function resetCompositionHost(host: Element) {
|
|||||||
host.textContent = "";
|
host.textContent = "";
|
||||||
}
|
}
|
||||||
|
|
||||||
const FLATTENED_INNER_ROOT_STRIP_ATTRS = [
|
|
||||||
"data-composition-id",
|
|
||||||
"data-composition-file",
|
|
||||||
"data-start",
|
|
||||||
"data-duration",
|
|
||||||
"data-end",
|
|
||||||
"data-track-index",
|
|
||||||
"data-track",
|
|
||||||
"data-composition-src",
|
|
||||||
"data-hf-authored-duration",
|
|
||||||
"data-hf-authored-end",
|
|
||||||
];
|
|
||||||
|
|
||||||
function prepareFlattenedInnerRoot(innerRoot: HTMLElement): HTMLElement {
|
function prepareFlattenedInnerRoot(innerRoot: HTMLElement): HTMLElement {
|
||||||
const prepared = document.importNode(innerRoot, true) as HTMLElement;
|
const prepared = document.importNode(innerRoot, true) as HTMLElement;
|
||||||
const authoredRootId = prepared.getAttribute("id")?.trim();
|
markFlattenedInnerRoot(prepared);
|
||||||
for (const attrName of FLATTENED_INNER_ROOT_STRIP_ATTRS) {
|
|
||||||
prepared.removeAttribute(attrName);
|
|
||||||
}
|
|
||||||
if (authoredRootId) {
|
|
||||||
prepared.removeAttribute("id");
|
|
||||||
prepared.setAttribute("data-hf-authored-id", authoredRootId);
|
|
||||||
}
|
|
||||||
prepared.setAttribute("data-hf-inner-root", "true");
|
|
||||||
const w = prepared.getAttribute("data-width");
|
const w = prepared.getAttribute("data-width");
|
||||||
const h = prepared.getAttribute("data-height");
|
const h = prepared.getAttribute("data-height");
|
||||||
prepared.style.width = w ? `${w}px` : "100%";
|
prepared.style.width = w ? `${w}px` : "100%";
|
||||||
@@ -220,19 +206,6 @@ function resolveScriptSourceUrl(scriptSrc: string, compositionUrl: URL | null):
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseHostVariableValues(host: Element): Record<string, unknown> {
|
|
||||||
const raw = host.getAttribute("data-variable-values");
|
|
||||||
if (!raw) return {};
|
|
||||||
let parsed: unknown;
|
|
||||||
try {
|
|
||||||
parsed = JSON.parse(raw);
|
|
||||||
} catch {
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {};
|
|
||||||
return parsed as Record<string, unknown>;
|
|
||||||
}
|
|
||||||
|
|
||||||
type HostCompositionIdentity = {
|
type HostCompositionIdentity = {
|
||||||
authoredCompositionId: string | null;
|
authoredCompositionId: string | null;
|
||||||
runtimeCompositionId: string | null;
|
runtimeCompositionId: string | null;
|
||||||
@@ -423,40 +396,27 @@ async function mountCompositionContent(params: {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const injectScopedStyles = (styleEls: Iterable<HTMLStyleElement>): void => {
|
||||||
|
for (const style of styleEls) {
|
||||||
|
const clonedStyle = style.cloneNode(true);
|
||||||
|
if (!(clonedStyle instanceof HTMLStyleElement)) continue;
|
||||||
|
if (authoredScopeCompositionId) {
|
||||||
|
clonedStyle.textContent = scopeCssToComposition(
|
||||||
|
clonedStyle.textContent || "",
|
||||||
|
authoredScopeCompositionId,
|
||||||
|
runtimeScopeSelector,
|
||||||
|
authoredRootId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
document.head.appendChild(clonedStyle);
|
||||||
|
params.injectedStyles.push(clonedStyle);
|
||||||
|
}
|
||||||
|
};
|
||||||
// Inject <head> styles from non-template sub-compositions first (they define
|
// Inject <head> styles from non-template sub-compositions first (they define
|
||||||
// element styles like backgrounds and positioning that the composition needs).
|
// element styles like backgrounds and positioning that the composition needs),
|
||||||
if (params.headStyles) {
|
// then the content styles.
|
||||||
for (const style of params.headStyles) {
|
if (params.headStyles) injectScopedStyles(params.headStyles);
|
||||||
const clonedStyle = style.cloneNode(true);
|
injectScopedStyles(Array.from(contentNode.querySelectorAll<HTMLStyleElement>("style")));
|
||||||
if (!(clonedStyle instanceof HTMLStyleElement)) continue;
|
|
||||||
if (authoredScopeCompositionId) {
|
|
||||||
clonedStyle.textContent = scopeCssToComposition(
|
|
||||||
clonedStyle.textContent || "",
|
|
||||||
authoredScopeCompositionId,
|
|
||||||
runtimeScopeSelector,
|
|
||||||
authoredRootId,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
document.head.appendChild(clonedStyle);
|
|
||||||
params.injectedStyles.push(clonedStyle);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const styles = Array.from(contentNode.querySelectorAll<HTMLStyleElement>("style"));
|
|
||||||
for (const style of styles) {
|
|
||||||
const clonedStyle = style.cloneNode(true);
|
|
||||||
if (!(clonedStyle instanceof HTMLStyleElement)) continue;
|
|
||||||
if (authoredScopeCompositionId) {
|
|
||||||
clonedStyle.textContent = scopeCssToComposition(
|
|
||||||
clonedStyle.textContent || "",
|
|
||||||
authoredScopeCompositionId,
|
|
||||||
runtimeScopeSelector,
|
|
||||||
authoredRootId,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
document.head.appendChild(clonedStyle);
|
|
||||||
params.injectedStyles.push(clonedStyle);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Collect head scripts first (e.g. GSAP CDN loaded in <head> of non-template sub-comps),
|
// Collect head scripts first (e.g. GSAP CDN loaded in <head> of non-template sub-comps),
|
||||||
// then content scripts. Head scripts must execute before content scripts.
|
// then content scripts. Head scripts must execute before content scripts.
|
||||||
@@ -536,16 +496,7 @@ async function mountCompositionContent(params: {
|
|||||||
// `window.__hfVariablesByComp[compId]`, so this table must be populated
|
// `window.__hfVariablesByComp[compId]`, so this table must be populated
|
||||||
// before the wrapped IIFE evaluates.
|
// before the wrapped IIFE evaluates.
|
||||||
if (runtimeScopeCompositionId) {
|
if (runtimeScopeCompositionId) {
|
||||||
const merged = {
|
stashInstanceVariables(params, contentNode, runtimeScopeCompositionId);
|
||||||
...(params.declaredVariableDefaults ?? {}),
|
|
||||||
...parseHostVariableValues(params.host),
|
|
||||||
};
|
|
||||||
if (Object.keys(merged).length > 0) {
|
|
||||||
if (!window.__hfVariablesByComp) window.__hfVariablesByComp = {};
|
|
||||||
window.__hfVariablesByComp[runtimeScopeCompositionId] = merged;
|
|
||||||
} else if (window.__hfVariablesByComp) {
|
|
||||||
delete window.__hfVariablesByComp[runtimeScopeCompositionId];
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const scriptPayload of scriptPayloads) {
|
for (const scriptPayload of scriptPayloads) {
|
||||||
@@ -761,3 +712,35 @@ export async function loadExternalCompositions(
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stash per-instance variables BEFORE running scripts (the scoped
|
||||||
|
* getVariables() reads window.__hfVariablesByComp[compId]) and mirror them
|
||||||
|
* as CSS custom properties on the host so imported var(--slug, literal)
|
||||||
|
* fills inside the sub-comp resolve per instance (cascade beats the document
|
||||||
|
* root). Inline templates carry declared defaults on the content root;
|
||||||
|
* external loads pass them explicitly. Render-time overrides (--variables)
|
||||||
|
* always win. Stale custom properties from a previous mount are cleared
|
||||||
|
* before (re)applying.
|
||||||
|
*/
|
||||||
|
function stashInstanceVariables(
|
||||||
|
params: { host: Element; declaredVariableDefaults?: Record<string, unknown> },
|
||||||
|
contentNode: Node,
|
||||||
|
runtimeScopeCompositionId: string,
|
||||||
|
): void {
|
||||||
|
const declaredDefaults =
|
||||||
|
params.declaredVariableDefaults ??
|
||||||
|
(contentNode instanceof Element ? readDeclaredDefaults(contentNode) : {});
|
||||||
|
const merged = {
|
||||||
|
...declaredDefaults,
|
||||||
|
...parseHostVariableValues(params.host),
|
||||||
|
};
|
||||||
|
clearAppliedCssVariables(params.host);
|
||||||
|
if (Object.keys(merged).length > 0) {
|
||||||
|
if (!window.__hfVariablesByComp) window.__hfVariablesByComp = {};
|
||||||
|
window.__hfVariablesByComp[runtimeScopeCompositionId] = merged;
|
||||||
|
applyCssVariables(params.host, { ...merged, ...readRenderOverrides() });
|
||||||
|
} else if (window.__hfVariablesByComp) {
|
||||||
|
delete window.__hfVariablesByComp[runtimeScopeCompositionId];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
/**
|
||||||
|
* Shared between the build-time bundler (htmlBundler) and the runtime
|
||||||
|
* composition loader: when a sub-composition's inner root is flattened into
|
||||||
|
* its host, these timing/identity attributes must be stripped and the
|
||||||
|
* authored id preserved as data — identical semantics in both worlds.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const FLATTENED_INNER_ROOT_STRIP_ATTRS = [
|
||||||
|
"data-composition-id",
|
||||||
|
"data-composition-file",
|
||||||
|
"data-start",
|
||||||
|
"data-duration",
|
||||||
|
"data-end",
|
||||||
|
"data-track-index",
|
||||||
|
"data-track",
|
||||||
|
"data-composition-src",
|
||||||
|
"data-hf-authored-duration",
|
||||||
|
"data-hf-authored-end",
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Strip timing attrs, demote the authored id, and mark the flattened root. */
|
||||||
|
export function markFlattenedInnerRoot(prepared: Element): void {
|
||||||
|
const authoredRootId = prepared.getAttribute("id")?.trim();
|
||||||
|
for (const attrName of FLATTENED_INNER_ROOT_STRIP_ATTRS) {
|
||||||
|
prepared.removeAttribute(attrName);
|
||||||
|
}
|
||||||
|
if (authoredRootId) {
|
||||||
|
prepared.removeAttribute("id");
|
||||||
|
prepared.setAttribute("data-hf-authored-id", authoredRootId);
|
||||||
|
}
|
||||||
|
prepared.setAttribute("data-hf-inner-root", "true");
|
||||||
|
}
|
||||||
@@ -203,3 +203,96 @@ describe("readDeclaredDefaults", () => {
|
|||||||
expect(readDeclaredDefaults(b)).toEqual({});
|
expect(readDeclaredDefaults(b)).toEqual({});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("css variable injection (figma brand-token chain)", () => {
|
||||||
|
afterEach(() => {
|
||||||
|
document.documentElement.removeAttribute(VARIABLES_ATTR);
|
||||||
|
document.documentElement.removeAttribute("data-hf-css-vars");
|
||||||
|
document.documentElement.style.cssText = "";
|
||||||
|
document.body.innerHTML = "";
|
||||||
|
delete (window as Window & { __hfVariables?: unknown }).__hfVariables;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("slug stays byte-compatible with the figma importer", async () => {
|
||||||
|
const { slugify } = await import("../figma/nodeToHtml");
|
||||||
|
const { cssVariableName } = await import("../tokenSlug");
|
||||||
|
for (const id of [
|
||||||
|
"figma:sds-color-background-brand-default",
|
||||||
|
"figma:Brand/Primary 500",
|
||||||
|
"figma:Acme/Semantic/Blue-500",
|
||||||
|
"!!!",
|
||||||
|
]) {
|
||||||
|
expect(cssVariableName(id)).toBe(`--${slugify(id)}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("defines declared variables on the DECLARING element, not globally", async () => {
|
||||||
|
const { injectCompositionCssVariables } = await import("./getVariables");
|
||||||
|
document.body.innerHTML = `<div id="root" ${VARIABLES_ATTR}='[{"id":"figma:brand/primary","type":"color","label":"p","default":"#112233"}]'></div>`;
|
||||||
|
injectCompositionCssVariables(document);
|
||||||
|
const root = document.getElementById("root") as HTMLElement;
|
||||||
|
expect(root.style.getPropertyValue("--figma-brand-primary")).toBe("#112233");
|
||||||
|
expect(document.documentElement.style.getPropertyValue("--figma-brand-primary")).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("two compositions on one page keep their own same-id values", async () => {
|
||||||
|
const { injectCompositionCssVariables } = await import("./getVariables");
|
||||||
|
document.body.innerHTML =
|
||||||
|
`<div id="a" ${VARIABLES_ATTR}='[{"id":"figma:brand","type":"color","label":"b","default":"#aa0000"}]'></div>` +
|
||||||
|
`<div id="b" ${VARIABLES_ATTR}='[{"id":"figma:brand","type":"color","label":"b","default":"#0000bb"}]'></div>`;
|
||||||
|
injectCompositionCssVariables(document);
|
||||||
|
expect(
|
||||||
|
(document.getElementById("a") as HTMLElement).style.getPropertyValue("--figma-brand"),
|
||||||
|
).toBe("#aa0000");
|
||||||
|
expect(
|
||||||
|
(document.getElementById("b") as HTMLElement).style.getPropertyValue("--figma-brand"),
|
||||||
|
).toBe("#0000bb");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("declared defaults do NOT clobber an authored inline definition (define-if-absent)", async () => {
|
||||||
|
const { injectCompositionCssVariables } = await import("./getVariables");
|
||||||
|
document.body.innerHTML = `<div id="root" style="--accent: #3b82f6" ${VARIABLES_ATTR}='[{"id":"accent","type":"color","label":"a","default":"#ff5722"}]'></div>`;
|
||||||
|
injectCompositionCssVariables(document);
|
||||||
|
expect(
|
||||||
|
(document.getElementById("root") as HTMLElement).style.getPropertyValue("--accent"),
|
||||||
|
).toBe("#3b82f6");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("render-time overrides win over declared defaults AND authored values", async () => {
|
||||||
|
const { injectCompositionCssVariables } = await import("./getVariables");
|
||||||
|
document.body.innerHTML = `<div id="root" style="--figma-brand: #000000" ${VARIABLES_ATTR}='[{"id":"figma:brand","type":"color","label":"b","default":"#2c2c2c"}]'></div>`;
|
||||||
|
(window as Window & { __hfVariables?: Record<string, unknown> }).__hfVariables = {
|
||||||
|
"figma:brand": "#00ff99",
|
||||||
|
};
|
||||||
|
injectCompositionCssVariables(document);
|
||||||
|
expect(
|
||||||
|
(document.getElementById("root") as HTMLElement).style.getPropertyValue("--figma-brand"),
|
||||||
|
).toBe("#00ff99");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips empty-string values (setProperty would remove, not define)", async () => {
|
||||||
|
const { applyCssVariables } = await import("./getVariables");
|
||||||
|
const el = document.createElement("div");
|
||||||
|
applyCssVariables(el, { blank: "", real: "#123456" });
|
||||||
|
expect(el.style.getPropertyValue("--blank")).toBe("");
|
||||||
|
expect(el.style.getPropertyValue("--real")).toBe("#123456");
|
||||||
|
expect(el.getAttribute("data-hf-css-vars")).toBe("--real");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clearAppliedCssVariables removes exactly what was applied", async () => {
|
||||||
|
const { applyCssVariables, clearAppliedCssVariables } = await import("./getVariables");
|
||||||
|
const el = document.createElement("div");
|
||||||
|
el.style.setProperty("--authored", "keep");
|
||||||
|
applyCssVariables(el, { "figma:brand": "#111111" });
|
||||||
|
clearAppliedCssVariables(el);
|
||||||
|
expect(el.style.getPropertyValue("--figma-brand")).toBe("");
|
||||||
|
expect(el.style.getPropertyValue("--authored")).toBe("keep");
|
||||||
|
expect(el.hasAttribute("data-hf-css-vars")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("getVariables() honors element-declared variables like the injection does", async () => {
|
||||||
|
const { getVariables } = await import("./getVariables");
|
||||||
|
document.body.innerHTML = `<div ${VARIABLES_ATTR}='[{"id":"figma:brand","type":"color","label":"b","default":"#445566"}]'></div>`;
|
||||||
|
expect(getVariables()["figma:brand"]).toBe("#445566");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -19,13 +19,28 @@
|
|||||||
*
|
*
|
||||||
* const { title = "Untitled", theme = "light" } = getVariables<MyVars>();
|
* const { title = "Untitled", theme = "light" } = getVariables<MyVars>();
|
||||||
*/
|
*/
|
||||||
|
import { cssVariableName, detectSlugCollisions } from "../tokenSlug";
|
||||||
|
|
||||||
export function getVariables<
|
export function getVariables<
|
||||||
T extends Record<string, unknown> = Record<string, unknown>,
|
T extends Record<string, unknown> = Record<string, unknown>,
|
||||||
>(): Partial<T> {
|
>(): Partial<T> {
|
||||||
if (typeof document === "undefined") return {} as Partial<T>;
|
if (typeof document === "undefined") return {} as Partial<T>;
|
||||||
|
|
||||||
const declaredDefaults = readDeclaredDefaults(document.documentElement);
|
// Same collection the CSS-variable injection uses: <html> first, then any
|
||||||
const overrides = readOverrides();
|
// composition element carrying the attribute (later declarers win), then
|
||||||
|
// render-time overrides.
|
||||||
|
const declarers = new Set<Element>();
|
||||||
|
if (document.documentElement?.hasAttribute("data-composition-variables")) {
|
||||||
|
declarers.add(document.documentElement);
|
||||||
|
}
|
||||||
|
for (const el of Array.from(document.querySelectorAll("[data-composition-variables]"))) {
|
||||||
|
declarers.add(el);
|
||||||
|
}
|
||||||
|
const declaredDefaults: Record<string, unknown> = {};
|
||||||
|
for (const el of declarers) {
|
||||||
|
Object.assign(declaredDefaults, readDeclaredDefaults(el));
|
||||||
|
}
|
||||||
|
const overrides = readRenderOverrides();
|
||||||
|
|
||||||
return { ...declaredDefaults, ...overrides } as Partial<T>;
|
return { ...declaredDefaults, ...overrides } as Partial<T>;
|
||||||
}
|
}
|
||||||
@@ -59,7 +74,120 @@ export function readDeclaredDefaults(root: Element | null): Record<string, unkno
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
function readOverrides(): Record<string, unknown> {
|
const APPLIED_VARS_ATTR = "data-hf-css-vars";
|
||||||
|
|
||||||
|
function hasInlineStyle(target: Element): target is Element & ElementCSSInlineStyle {
|
||||||
|
return "style" in target && typeof (target as HTMLElement).style?.setProperty === "function";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Define primitive-valued variables as CSS custom properties on `target`,
|
||||||
|
* recording the applied names so re-init/unmount can clear them. Empty
|
||||||
|
* strings are skipped — CSSOM setProperty("", …) REMOVES the property, and a
|
||||||
|
* blank default has no CSS meaning anyway.
|
||||||
|
*/
|
||||||
|
export function applyCssVariables(target: Element, variables: Record<string, unknown>): void {
|
||||||
|
if (!hasInlineStyle(target)) return;
|
||||||
|
const applied: string[] = [];
|
||||||
|
for (const [id, value] of Object.entries(variables)) {
|
||||||
|
if ((typeof value === "string" && value !== "") || typeof value === "number") {
|
||||||
|
const name = cssVariableName(id);
|
||||||
|
target.style.setProperty(name, String(value));
|
||||||
|
applied.push(name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (applied.length > 0) target.setAttribute(APPLIED_VARS_ATTR, applied.join(" "));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Remove custom properties a previous applyCssVariables call defined. */
|
||||||
|
export function clearAppliedCssVariables(target: Element): void {
|
||||||
|
if (!hasInlineStyle(target)) return;
|
||||||
|
const applied = target.getAttribute(APPLIED_VARS_ATTR);
|
||||||
|
if (!applied) return;
|
||||||
|
for (const name of applied.split(" ")) {
|
||||||
|
if (name.startsWith("--")) target.style.removeProperty(name);
|
||||||
|
}
|
||||||
|
target.removeAttribute(APPLIED_VARS_ATTR);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Imported figma components reference brand tokens as `var(--slug, literal)`.
|
||||||
|
* Define each declaring element's composition variables as CSS custom
|
||||||
|
* properties ON THAT ELEMENT — scoping by the cascade, so two compositions
|
||||||
|
* on one page can't clobber each other and a flattened sub-composition root
|
||||||
|
* correctly styles only its own subtree.
|
||||||
|
*
|
||||||
|
* Declared DEFAULTS are define-if-absent: a value the author already styled
|
||||||
|
* (stylesheet `:root` rule, compile-time emission, hand-written inline) wins
|
||||||
|
* over the declared default, preserving pre-existing conventions where a
|
||||||
|
* variable id coincides with an authored custom property. Render-time
|
||||||
|
* overrides (`--variables` → `window.__hfVariables`) always win — that's
|
||||||
|
* explicit user intent.
|
||||||
|
*/
|
||||||
|
export function injectCompositionCssVariables(doc: Document): void {
|
||||||
|
const declarers = new Set<Element>();
|
||||||
|
if (doc.documentElement?.hasAttribute("data-composition-variables")) {
|
||||||
|
declarers.add(doc.documentElement);
|
||||||
|
}
|
||||||
|
for (const el of Array.from(doc.querySelectorAll("[data-composition-variables]"))) {
|
||||||
|
declarers.add(el);
|
||||||
|
}
|
||||||
|
const overrides = readRenderOverrides();
|
||||||
|
const allIds: string[] = [];
|
||||||
|
for (const el of declarers) {
|
||||||
|
allIds.push(...applyDeclaredForElement(el, overrides, doc.defaultView));
|
||||||
|
}
|
||||||
|
for (const group of detectSlugCollisions(allIds)) {
|
||||||
|
console.warn(
|
||||||
|
`composition variables ${group.join(", ")} collapse to the same CSS property ${cssVariableName(group[0] ?? "")} — rename one to avoid cross-talk`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Apply one declarer's variables (define-if-absent for defaults, overrides
|
||||||
|
* always win). Returns the declared ids for collision reporting. */
|
||||||
|
function applyDeclaredForElement(
|
||||||
|
el: Element,
|
||||||
|
overrides: Record<string, unknown>,
|
||||||
|
view: Window | null,
|
||||||
|
): string[] {
|
||||||
|
if (!hasInlineStyle(el)) return [];
|
||||||
|
const declared = readDeclaredDefaults(el);
|
||||||
|
const toApply: Record<string, unknown> = {};
|
||||||
|
for (const [id, value] of Object.entries(declared)) {
|
||||||
|
if (id in overrides) continue; // overrides applied below, always win
|
||||||
|
const name = cssVariableName(id);
|
||||||
|
// define-if-absent: respect authored/compiled definitions
|
||||||
|
const existing =
|
||||||
|
el.style.getPropertyValue(name) ||
|
||||||
|
(view ? view.getComputedStyle(el).getPropertyValue(name) : "");
|
||||||
|
if (existing.trim() !== "") continue;
|
||||||
|
toApply[id] = value;
|
||||||
|
}
|
||||||
|
for (const [id, value] of Object.entries(overrides)) {
|
||||||
|
if (id in declared) toApply[id] = value;
|
||||||
|
}
|
||||||
|
applyCssVariables(el, toApply);
|
||||||
|
return Object.keys(declared);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse a host element's `data-variable-values` JSON attribute (per-instance
|
||||||
|
* sub-composition overrides). Shared by the runtime loader and the bundler. */
|
||||||
|
export function parseHostVariableValues(host: Element): Record<string, unknown> {
|
||||||
|
const raw = host.getAttribute("data-variable-values");
|
||||||
|
if (!raw) return {};
|
||||||
|
let parsed: unknown;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(raw);
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {};
|
||||||
|
return parsed as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Render-time variable overrides (`hyperframes render --variables`). */
|
||||||
|
export function readRenderOverrides(): Record<string, unknown> {
|
||||||
if (typeof window === "undefined") return {};
|
if (typeof window === "undefined") return {};
|
||||||
const raw = (window as Window & { __hfVariables?: unknown }).__hfVariables;
|
const raw = (window as Window & { __hfVariables?: unknown }).__hfVariables;
|
||||||
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {};
|
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {};
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
// fallow-ignore-file code-duplication complexity
|
// fallow-ignore-file code-duplication complexity
|
||||||
import { installRuntimeControlBridge, postRuntimeMessage } from "./bridge";
|
import { installRuntimeControlBridge, postRuntimeMessage } from "./bridge";
|
||||||
import { initRuntimeAnalytics, emitAnalyticsEvent } from "./analytics";
|
import { initRuntimeAnalytics, emitAnalyticsEvent } from "./analytics";
|
||||||
|
import { injectCompositionCssVariables } from "./getVariables";
|
||||||
import { createCssAdapter } from "./adapters/css";
|
import { createCssAdapter } from "./adapters/css";
|
||||||
import { createGsapAdapter } from "./adapters/gsap";
|
import { createGsapAdapter } from "./adapters/gsap";
|
||||||
import { createAnimeJsAdapter } from "./adapters/animejs";
|
import { createAnimeJsAdapter } from "./adapters/animejs";
|
||||||
@@ -141,6 +142,15 @@ export function initSandboxRuntimeModular(): void {
|
|||||||
document.body.style.overflow = "hidden";
|
document.body.style.overflow = "hidden";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// figma brand-token chain: define declared composition variables as CSS
|
||||||
|
// custom properties so imported var(--slug, literal) fills resolve from the
|
||||||
|
// live variable instead of always falling back to the frozen literal.
|
||||||
|
try {
|
||||||
|
injectCompositionCssVariables(document);
|
||||||
|
} catch (err) {
|
||||||
|
swallow("runtime.init.cssVariables", err);
|
||||||
|
}
|
||||||
|
|
||||||
window.__timelines = window.__timelines || {};
|
window.__timelines = window.__timelines || {};
|
||||||
|
|
||||||
// Resolve the root composition element with the same priority the rest of
|
// Resolve the root composition element with the same priority the rest of
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
/**
|
||||||
|
* THE slug for composition-variable ids → CSS custom-property names. Shared
|
||||||
|
* by the figma importer (emits `var(--<slug>, literal)`) and the runtime
|
||||||
|
* (defines `--<slug>` from declared variables) — one function so the two
|
||||||
|
* sides can never drift. The slug is lossy (case-folded, symbol runs
|
||||||
|
* collapse to "-"), so distinct ids CAN collide; callers surface
|
||||||
|
* detectSlugCollisions() as a warning rather than silently merging.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export function slugify(name: string): string {
|
||||||
|
const collapsed = name.toLowerCase().replace(/[^a-z0-9]+/g, "-");
|
||||||
|
// Character-scan trim of leading/trailing "-" instead of /^-+|-+$/:
|
||||||
|
// CodeQL flags the alternated anchored regex as polynomial ReDoS on
|
||||||
|
// adversarial inputs (js/polynomial-redos).
|
||||||
|
let start = 0;
|
||||||
|
let end = collapsed.length;
|
||||||
|
while (start < end && collapsed[start] === "-") start++;
|
||||||
|
while (end > start && collapsed[end - 1] === "-") end--;
|
||||||
|
const slug = collapsed.slice(start, end);
|
||||||
|
return slug.length > 0 ? slug : "node";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cssVariableName(id: string): string {
|
||||||
|
return `--${slugify(id)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Groups of distinct ids that collapse to the same CSS variable name. */
|
||||||
|
export function detectSlugCollisions(ids: Iterable<string>): string[][] {
|
||||||
|
const bySlug = new Map<string, string[]>();
|
||||||
|
for (const id of ids) {
|
||||||
|
const slug = cssVariableName(id);
|
||||||
|
const group = bySlug.get(slug);
|
||||||
|
if (group) {
|
||||||
|
if (!group.includes(id)) group.push(id);
|
||||||
|
} else {
|
||||||
|
bySlug.set(slug, [id]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...bySlug.values()].filter((g) => g.length > 1);
|
||||||
|
}
|
||||||
@@ -210,6 +210,11 @@ export async function lintProject(projectDir: string): Promise<ProjectLintResult
|
|||||||
const html = readFileSync(filePath, "utf-8");
|
const html = readFileSync(filePath, "utf-8");
|
||||||
const compSrcPath = `compositions/${file}`;
|
const compSrcPath = `compositions/${file}`;
|
||||||
allHtmlSources.push({ html, compSrcPath });
|
allHtmlSources.push({ html, compSrcPath });
|
||||||
|
// Mountable fragments (figma component imports, registry snippets) are
|
||||||
|
// not standalone compositions — composition-root rules don't apply.
|
||||||
|
// Anchored to the file's ROOT element so a real composition that merely
|
||||||
|
// inlines snippet markup (or mentions the token in text) is still linted.
|
||||||
|
if (isSnippetFragment(html)) continue;
|
||||||
const result = await lintHyperframeHtml(html, {
|
const result = await lintHyperframeHtml(html, {
|
||||||
filePath,
|
filePath,
|
||||||
isSubComposition: true,
|
isSubComposition: true,
|
||||||
@@ -610,3 +615,11 @@ function lintMissingOrEmptySubComposition(
|
|||||||
|
|
||||||
return findings;
|
return findings;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** True when the file's first element carries data-hf-snippet — i.e. the file
|
||||||
|
* IS a mountable fragment, not a composition that merely contains one. */
|
||||||
|
function isSnippetFragment(html: string): boolean {
|
||||||
|
const firstTag = html.match(/<[a-zA-Z][^>]*>/);
|
||||||
|
if (!firstTag) return false;
|
||||||
|
return /\bdata-hf-snippet\b/.test(firstTag[0]);
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { describe, it, expect, afterEach } from "vitest";
|
||||||
|
import { mkdirSync, mkdtempSync, writeFileSync, rmSync } from "node:fs";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { lintProject } from "./project.js";
|
||||||
|
|
||||||
|
let dirs: string[] = [];
|
||||||
|
afterEach(() => {
|
||||||
|
for (const d of dirs) rmSync(d, { recursive: true, force: true });
|
||||||
|
dirs = [];
|
||||||
|
});
|
||||||
|
|
||||||
|
const INDEX = `<html><body>
|
||||||
|
<div id="root" data-composition-id="main" data-start="0" data-duration="2" data-width="640" data-height="360"></div>
|
||||||
|
<script>window.__timelines = { main: { paused: true } };</script>
|
||||||
|
</body></html>`;
|
||||||
|
|
||||||
|
async function lintWithFragment(name: string, html: string) {
|
||||||
|
const dir = mkdtempSync(join(tmpdir(), "hf-snippet-lint-"));
|
||||||
|
dirs.push(dir);
|
||||||
|
writeFileSync(join(dir, "index.html"), INDEX);
|
||||||
|
mkdirSync(join(dir, "compositions"), { recursive: true });
|
||||||
|
writeFileSync(join(dir, "compositions", name), html);
|
||||||
|
const result = await lintProject(dir);
|
||||||
|
return result.results.filter((r) => r.file.includes(name.replace(".html", "")));
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("snippet fragment exemption", () => {
|
||||||
|
it("skips composition-root rules for files whose ROOT carries data-hf-snippet", async () => {
|
||||||
|
const findings = await lintWithFragment(
|
||||||
|
"frag.html",
|
||||||
|
'<div id="frag" data-hf-snippet="" data-figma-id="1:1" style="width: 10px"></div>\n',
|
||||||
|
);
|
||||||
|
expect(findings).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still lints a composition that merely CONTAINS snippet markup", async () => {
|
||||||
|
const results = await lintWithFragment(
|
||||||
|
"scene.html",
|
||||||
|
'<div id="scene"><div data-hf-snippet="" data-figma-id="1:1"></div></div>\n',
|
||||||
|
);
|
||||||
|
expect(results).toHaveLength(1);
|
||||||
|
expect(results[0]?.result.findings.some((f) => f.code === "root_missing_composition_id")).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -344,6 +344,7 @@ export const FORMAT_NOT_SUPPORTED_IN_DISTRIBUTED = "FORMAT_NOT_SUPPORTED_IN_DIST
|
|||||||
* gate.
|
* gate.
|
||||||
*/
|
*/
|
||||||
export class FormatNotSupportedInDistributedError extends Error {
|
export class FormatNotSupportedInDistributedError extends Error {
|
||||||
|
// fallow-ignore-next-line unused-class-member
|
||||||
readonly code: typeof FORMAT_NOT_SUPPORTED_IN_DISTRIBUTED = FORMAT_NOT_SUPPORTED_IN_DISTRIBUTED;
|
readonly code: typeof FORMAT_NOT_SUPPORTED_IN_DISTRIBUTED = FORMAT_NOT_SUPPORTED_IN_DISTRIBUTED;
|
||||||
readonly format: string;
|
readonly format: string;
|
||||||
readonly reason: string;
|
readonly reason: string;
|
||||||
@@ -808,6 +809,7 @@ export async function plan(
|
|||||||
// Distributed renders must not capture host-specific system fonts —
|
// Distributed renders must not capture host-specific system fonts —
|
||||||
// the Lambda/worker filesystem won't have the same fonts installed.
|
// the Lambda/worker filesystem won't have the same fonts installed.
|
||||||
allowSystemFontCapture: false,
|
allowSystemFontCapture: false,
|
||||||
|
variables: config.variables,
|
||||||
});
|
});
|
||||||
let compiled = compileResult.compiled;
|
let compiled = compileResult.compiled;
|
||||||
const composition = compileResult.composition;
|
const composition = compileResult.composition;
|
||||||
|
|||||||
@@ -27,6 +27,9 @@ import {
|
|||||||
import {
|
import {
|
||||||
inlineSubCompositions as inlineSubCompositionsShared,
|
inlineSubCompositions as inlineSubCompositionsShared,
|
||||||
prepareFlattenedInnerRoot,
|
prepareFlattenedInnerRoot,
|
||||||
|
emitRootCompositionVariableStyles,
|
||||||
|
readDeclaredDefaults,
|
||||||
|
parseHostVariableValues,
|
||||||
} from "@hyperframes/core/compiler";
|
} from "@hyperframes/core/compiler";
|
||||||
import {
|
import {
|
||||||
checkSubCompositionUsability,
|
checkSubCompositionUsability,
|
||||||
@@ -761,18 +764,32 @@ function inlineSubCompositions(
|
|||||||
html: string,
|
html: string,
|
||||||
subCompositions: Map<string, string>,
|
subCompositions: Map<string, string>,
|
||||||
projectDir: string,
|
projectDir: string,
|
||||||
|
variableOverrides: Record<string, unknown> = {},
|
||||||
): string {
|
): string {
|
||||||
const { document } = parseHTML(html);
|
const { document } = parseHTML(html);
|
||||||
const head = document.querySelector("head");
|
const head = document.querySelector("head");
|
||||||
const body = document.querySelector("body");
|
const body = document.querySelector("body");
|
||||||
const hosts = Array.from(document.querySelectorAll("[data-composition-src]"));
|
const hosts = Array.from(document.querySelectorAll("[data-composition-src]"));
|
||||||
|
|
||||||
if (!hosts.length) return html;
|
if (!hosts.length) {
|
||||||
|
// Even with no sub-compositions, declared composition variables need a
|
||||||
|
// compile-time stylesheet so eval-time reads (GSAP .from immediateRender,
|
||||||
|
// top-level script getComputedStyle) resolve var(--slug) — the runtime's
|
||||||
|
// DOMContentLoaded injection is too late for those.
|
||||||
|
const emitted = emitRootCompositionVariableStyles(
|
||||||
|
document as unknown as Document,
|
||||||
|
{},
|
||||||
|
variableOverrides,
|
||||||
|
);
|
||||||
|
return emitted ? document.toString() : html;
|
||||||
|
}
|
||||||
|
|
||||||
const result = inlineSubCompositionsShared(
|
const result = inlineSubCompositionsShared(
|
||||||
document as unknown as Document,
|
document as unknown as Document,
|
||||||
hosts as unknown as Element[],
|
hosts as unknown as Element[],
|
||||||
{
|
{
|
||||||
|
readVariableDefaults: readDeclaredDefaults,
|
||||||
|
parseHostVariables: parseHostVariableValues,
|
||||||
resolveHtml: (srcPath: string) => {
|
resolveHtml: (srcPath: string) => {
|
||||||
let compHtml = subCompositions.get(srcPath) || null;
|
let compHtml = subCompositions.get(srcPath) || null;
|
||||||
if (!compHtml) {
|
if (!compHtml) {
|
||||||
@@ -872,6 +889,15 @@ function inlineSubCompositions(
|
|||||||
body.appendChild(scriptEl);
|
body.appendChild(scriptEl);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Compile-time CSS custom properties (mirrors the preview bundler): root
|
||||||
|
// declarers plus one scoped rule per sub-composition host, so var(--slug)
|
||||||
|
// resolves at script eval time, not just after runtime injection.
|
||||||
|
emitRootCompositionVariableStyles(
|
||||||
|
document as unknown as Document,
|
||||||
|
result.variablesByComp,
|
||||||
|
variableOverrides,
|
||||||
|
);
|
||||||
|
|
||||||
return document.toString();
|
return document.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1547,6 +1573,13 @@ export interface CompileForRenderOptions {
|
|||||||
animatedGifCacheDir?: string;
|
animatedGifCacheDir?: string;
|
||||||
/** FFmpeg timeout for animated GIF transcodes. */
|
/** FFmpeg timeout for animated GIF transcodes. */
|
||||||
ffmpegProcessTimeout?: number;
|
ffmpegProcessTimeout?: number;
|
||||||
|
/**
|
||||||
|
* Render-time variable overrides (`--variables`). Layered over declared
|
||||||
|
* defaults in the compile-time CSS custom-property stylesheet so eval-time
|
||||||
|
* reads (GSAP .from immediateRender) see the overridden value — the
|
||||||
|
* `window.__hfVariables` injection covers script reads, not var() in CSS.
|
||||||
|
*/
|
||||||
|
variables?: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const GSAP_CDN_BASE = "https://cdn.jsdelivr.net/npm/gsap@3.15.0/dist/";
|
const GSAP_CDN_BASE = "https://cdn.jsdelivr.net/npm/gsap@3.15.0/dist/";
|
||||||
@@ -1611,7 +1644,12 @@ export async function compileForRender(
|
|||||||
// Inline sub-compositions into the main HTML so the runtime takes the same
|
// Inline sub-compositions into the main HTML so the runtime takes the same
|
||||||
// synchronous code path as the bundled preview (no async fetch of
|
// synchronous code path as the bundled preview (no async fetch of
|
||||||
// data-composition-src). This mirrors what htmlBundler.ts does for preview.
|
// data-composition-src). This mirrors what htmlBundler.ts does for preview.
|
||||||
const inlinedHtml = inlineSubCompositions(fullHtml, subCompositions, projectDir);
|
const inlinedHtml = inlineSubCompositions(
|
||||||
|
fullHtml,
|
||||||
|
subCompositions,
|
||||||
|
projectDir,
|
||||||
|
options.variables ?? {},
|
||||||
|
);
|
||||||
|
|
||||||
// Strip preload="none" from media elements — the renderer needs to load all
|
// Strip preload="none" from media elements — the renderer needs to load all
|
||||||
// media upfront for frame capture. Users add this to reduce browser memory in
|
// media upfront for frame capture. Users add this to reduce browser memory in
|
||||||
|
|||||||
@@ -83,6 +83,8 @@ export interface CompileStageInput {
|
|||||||
* on the local filesystem and embedded. Distributed renders pass `false`.
|
* on the local filesystem and embedded. Distributed renders pass `false`.
|
||||||
*/
|
*/
|
||||||
allowSystemFontCapture?: boolean;
|
allowSystemFontCapture?: boolean;
|
||||||
|
/** Render-time variable overrides (`--variables`); see CompileForRenderOptions.variables. */
|
||||||
|
variables?: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CompileStageResult {
|
export interface CompileStageResult {
|
||||||
@@ -127,6 +129,7 @@ export async function runCompileStage(input: CompileStageInput): Promise<Compile
|
|||||||
log,
|
log,
|
||||||
failClosedFontFetch: failClosedFontFetch === true,
|
failClosedFontFetch: failClosedFontFetch === true,
|
||||||
allowSystemFontCapture,
|
allowSystemFontCapture,
|
||||||
|
variables: input.variables,
|
||||||
animatedGifCacheDir: cfg.extractCacheDir
|
animatedGifCacheDir: cfg.extractCacheDir
|
||||||
? join(cfg.extractCacheDir, "animated-gif")
|
? join(cfg.extractCacheDir, "animated-gif")
|
||||||
: undefined,
|
: undefined,
|
||||||
|
|||||||
@@ -1292,6 +1292,7 @@ export async function executeRenderJob(
|
|||||||
needsAlpha,
|
needsAlpha,
|
||||||
log,
|
log,
|
||||||
assertNotAborted,
|
assertNotAborted,
|
||||||
|
variables: job.config.variables,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
let compiled = compileResult.compiled;
|
let compiled = compileResult.compiled;
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
"files": 18
|
"files": 18
|
||||||
},
|
},
|
||||||
"figma": {
|
"figma": {
|
||||||
"hash": "26676992c1aed316",
|
"hash": "aabcd697d0823efb",
|
||||||
"files": 1
|
"files": 1
|
||||||
},
|
},
|
||||||
"general-video": {
|
"general-video": {
|
||||||
|
|||||||
@@ -64,6 +64,10 @@ Imports variables as composition brand-variable entries + `figma-tokens.json` si
|
|||||||
|
|
||||||
**Import tokens before components** when both are wanted — that's what lets component colors link to brand variables instead of baking duplicates.
|
**Import tokens before components** when both are wanted — that's what lets component colors link to brand variables instead of baking duplicates.
|
||||||
|
|
||||||
|
**Non-Enterprise variables path (field-tested):** REST variables are Enterprise-gated, but the Figma MCP `get_variable_defs` is not. When `tokens` reports `REQUIRES_ENTERPRISE` and the user has the MCP connector, you can build the index yourself: (1) `get_variable_defs` on the scene's parent node — ONE call, cache the raw JSON to `.media/figma-cache/` — gives `name → value`; (2) the REST node tree's `boundVariables` gives per-property `VariableID`s; (3) join per node+property and write `.media/figma-bindings.jsonl` rows (`{kind:"binding", figmaId, sourceFileKey, compositionVariableId: "figma:<name>", version}`) plus the composition-variable entries. Everything downstream (component `var()` resolution, refresh, runtime CSS variables) is the shipped machinery. Label it for the user: "tokens via the Figma connector — Enterprise plans get this from `hyperframes figma tokens` directly."
|
||||||
|
|
||||||
|
The runtime defines every declared composition variable as a CSS custom property (document root + sub-comp hosts), so imported `var(--slug, literal)` fills recolor when the variable default changes — updating one value in `data-composition-variables` re-brands every imported component without re-importing anything. `hyperframes render --variables '<json>'` overrides them at render time.
|
||||||
|
|
||||||
## Components (Phase 3 — CLI)
|
## Components (Phase 3 — CLI)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
Reference in New Issue
Block a user