mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
docs: add texture mask text catalog entry (#650)
* feat(registry): add texture mask PNGs for texture-mask-text component * feat(registry): add texture-mask-text CSS snippet * feat(registry): add registry-item.json for texture-mask-text * feat(registry): add texture-mask-text demo composition * feat(registry): register texture-mask-text component in manifest * style: format texture-mask-text files with oxfmt * fix: set mask-image directly on texture classes instead of via CSS custom property url() inside CSS custom properties doesn't resolve correctly with mask-image in some browsers. Move mask-image declarations to each texture class directly. * docs: add texture mask text catalog entry * test: lint texture mask text usage * fix: harden texture mask text docs and lint * fix: stabilize texture mask asset paths * fix: address texture catalog review feedback * fix: harden texture mask text instructions * docs: remove texture catalog intro copy * docs: use canonical texture preview URL * docs: use cdn texture mask assets * fix: escape catalog frontmatter safely * test: stabilize windows render cli test * test: pin texture catalog instructions
This commit is contained in:
@@ -52,6 +52,11 @@ const COMPONENT_ITEM: RegistryItem = {
|
||||
target: "compositions/components/my-component/my-component.css",
|
||||
type: "hyperframes:style",
|
||||
},
|
||||
{
|
||||
path: "assets/mask.png",
|
||||
target: "assets/my-component/mask.png",
|
||||
type: "hyperframes:asset",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -190,7 +195,7 @@ describe("runAdd (integration, mocked registry)", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("remaps component targets per hyperframes.json paths.components", async () => {
|
||||
it("remaps component snippet/style targets while leaving asset targets stable", async () => {
|
||||
const dir = tmp();
|
||||
try {
|
||||
const baseUrl = uniqueBase();
|
||||
@@ -206,9 +211,10 @@ describe("runAdd (integration, mocked registry)", () => {
|
||||
projectDir: dir,
|
||||
skipClipboard: true,
|
||||
});
|
||||
expect(result.written.length).toBe(2);
|
||||
expect(result.written.length).toBe(3);
|
||||
expect(existsSync(join(dir, "src/fx/my-component/my-component.html"))).toBe(true);
|
||||
expect(existsSync(join(dir, "src/fx/my-component/my-component.css"))).toBe(true);
|
||||
expect(existsSync(join(dir, "assets/my-component/mask.png"))).toBe(true);
|
||||
expect(result.snippet).toContain("src/fx/my-component/my-component.html");
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
|
||||
@@ -82,7 +82,7 @@ describe("renderLocal browser GPU config", () => {
|
||||
browserGpuMode: "software",
|
||||
resolved: true,
|
||||
});
|
||||
});
|
||||
}, 15_000);
|
||||
|
||||
it("forwards browserGpuMode='auto' into producer config (probe-then-choose)", async () => {
|
||||
await renderLocal("/tmp/project", "/tmp/out.mp4", {
|
||||
|
||||
@@ -391,6 +391,110 @@ describe("audio_src_not_found", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("texture_mask_asset_not_found", () => {
|
||||
it("errors when CSS mask-image references a missing local texture", () => {
|
||||
const html = `<html><body>
|
||||
<div data-composition-id="main" data-width="1920" data-height="1080">
|
||||
<div class="hf-texture-text hf-texture-lava">TEXT</div>
|
||||
</div>
|
||||
<style>
|
||||
.hf-texture-lava {
|
||||
-webkit-mask-image: url("masks/lava.png");
|
||||
mask-image: url("masks/lava.png");
|
||||
}
|
||||
</style>
|
||||
<script>window.__timelines = window.__timelines || {}; window.__timelines["main"] = gsap.timeline({ paused: true });</script>
|
||||
</body></html>`;
|
||||
const project = makeProject(html);
|
||||
|
||||
const { totalErrors, results } = lintProject(project);
|
||||
const finding = results[0]?.result.findings.find(
|
||||
(item) => item.code === "texture_mask_asset_not_found",
|
||||
);
|
||||
|
||||
expect(totalErrors).toBeGreaterThan(0);
|
||||
expect(finding).toBeDefined();
|
||||
expect(finding?.severity).toBe("error");
|
||||
expect(finding?.message).toContain("masks/lava.png");
|
||||
});
|
||||
|
||||
it("does not error when the referenced texture mask exists", () => {
|
||||
const html = `<html><body>
|
||||
<div data-composition-id="main" data-width="1920" data-height="1080">
|
||||
<div class="hf-texture-text hf-texture-lava">TEXT</div>
|
||||
</div>
|
||||
<style>
|
||||
.hf-texture-lava {
|
||||
-webkit-mask-image: url("masks/lava.png");
|
||||
mask-image: url("masks/lava.png");
|
||||
}
|
||||
</style>
|
||||
<script>window.__timelines = window.__timelines || {}; window.__timelines["main"] = gsap.timeline({ paused: true });</script>
|
||||
</body></html>`;
|
||||
const project = makeProject(html);
|
||||
mkdirSync(join(project.dir, "masks"), { recursive: true });
|
||||
writeFileSync(join(project.dir, "masks", "lava.png"), "fake");
|
||||
|
||||
const { results } = lintProject(project);
|
||||
const finding = results[0]?.result.findings.find(
|
||||
(item) => item.code === "texture_mask_asset_not_found",
|
||||
);
|
||||
|
||||
expect(finding).toBeUndefined();
|
||||
});
|
||||
|
||||
it("resolves mask-image URLs inside linked sub-composition stylesheets", () => {
|
||||
const project = makeProject(validHtml(), {
|
||||
"scene.html": `<html><head><link rel="stylesheet" href="scene.css"></head><body>
|
||||
<div data-composition-id="scene" data-width="1920" data-height="1080">
|
||||
<div class="hf-texture-text hf-texture-lava">TEXT</div>
|
||||
</div>
|
||||
<script>window.__timelines = window.__timelines || {}; window.__timelines["scene"] = gsap.timeline({ paused: true });</script>
|
||||
</body></html>`,
|
||||
});
|
||||
writeFileSync(
|
||||
join(project.dir, "compositions", "scene.css"),
|
||||
'.hf-texture-lava { mask-image: url("masks/lava.png"); }',
|
||||
);
|
||||
mkdirSync(join(project.dir, "compositions", "masks"), { recursive: true });
|
||||
writeFileSync(join(project.dir, "compositions", "masks", "lava.png"), "fake");
|
||||
|
||||
const { results } = lintProject(project);
|
||||
const finding = results[0]?.result.findings.find(
|
||||
(item) => item.code === "texture_mask_asset_not_found",
|
||||
);
|
||||
|
||||
expect(finding).toBeUndefined();
|
||||
});
|
||||
|
||||
it("resolves root-absolute mask-image URLs from the project root", () => {
|
||||
const html = `<html><body>
|
||||
<div data-composition-id="main" data-width="1920" data-height="1080">
|
||||
<div class="hf-texture-text hf-texture-lava">TEXT</div>
|
||||
</div>
|
||||
<style>
|
||||
.hf-texture-lava {
|
||||
-webkit-mask-image: url("/assets/texture-mask-text/masks/lava.png");
|
||||
mask-image: url("/assets/texture-mask-text/masks/lava.png");
|
||||
}
|
||||
</style>
|
||||
<script>window.__timelines = window.__timelines || {}; window.__timelines["main"] = gsap.timeline({ paused: true });</script>
|
||||
</body></html>`;
|
||||
const project = makeProject(html);
|
||||
mkdirSync(join(project.dir, "assets", "texture-mask-text", "masks"), {
|
||||
recursive: true,
|
||||
});
|
||||
writeFileSync(join(project.dir, "assets", "texture-mask-text", "masks", "lava.png"), "fake");
|
||||
|
||||
const { results } = lintProject(project);
|
||||
const finding = results[0]?.result.findings.find(
|
||||
(item) => item.code === "texture_mask_asset_not_found",
|
||||
);
|
||||
|
||||
expect(finding).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("multiple_root_compositions", () => {
|
||||
it("fires when two HTML files have data-composition-id", () => {
|
||||
const project = makeProject(validHtml());
|
||||
|
||||
@@ -18,6 +18,12 @@ interface HtmlSource {
|
||||
compSrcPath?: string;
|
||||
}
|
||||
|
||||
interface CssSource {
|
||||
content: string;
|
||||
/** Root-relative path to the CSS file. Undefined means inline HTML CSS. */
|
||||
rootRelativePath?: string;
|
||||
}
|
||||
|
||||
export interface ProjectLintResult {
|
||||
results: Array<{ file: string; result: HyperframeLintResult }>;
|
||||
totalErrors: number;
|
||||
@@ -26,6 +32,16 @@ export interface ProjectLintResult {
|
||||
}
|
||||
|
||||
const AUDIO_EXTENSIONS = new Set([".mp3", ".wav", ".aac", ".ogg", ".m4a", ".flac", ".opus"]);
|
||||
const STYLE_BLOCK_RE = /<style\b[^>]*>([\s\S]*?)<\/style>/gi;
|
||||
const OPEN_TAG_RE = /<([a-z][\w:-]*)(\s[^<>]*?)?>/gi;
|
||||
const MASK_IMAGE_URL_RE =
|
||||
/\b(?:-webkit-)?mask-image\s*:\s*[^;{}]*url\(\s*(?:"([^"]+)"|'([^']+)'|([^"')\s]+))\s*\)/gi;
|
||||
|
||||
function readHtmlAttr(tag: string, name: string): string | null {
|
||||
const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
const match = tag.match(new RegExp(`\\b${escaped}\\s*=\\s*(?:"([^"]*)"|'([^']*)')`, "i"));
|
||||
return match?.[1] ?? match?.[2] ?? null;
|
||||
}
|
||||
|
||||
function isLocalStylesheetHref(href: string): boolean {
|
||||
return !!href && !/^(https?:|data:|blob:|\/\/)/i.test(href);
|
||||
@@ -53,6 +69,62 @@ function collectExternalStyles(
|
||||
return styles;
|
||||
}
|
||||
|
||||
function collectCssSources(projectDir: string, html: string, compSrcPath?: string): CssSource[] {
|
||||
const sources: CssSource[] = [];
|
||||
|
||||
let styleMatch: RegExpExecArray | null;
|
||||
const stylePattern = new RegExp(STYLE_BLOCK_RE.source, STYLE_BLOCK_RE.flags);
|
||||
while ((styleMatch = stylePattern.exec(html)) !== null) {
|
||||
sources.push({ content: styleMatch[1] ?? "" });
|
||||
}
|
||||
|
||||
const linkRe = /<link\b[^>]*>/gi;
|
||||
let linkMatch: RegExpExecArray | null;
|
||||
while ((linkMatch = linkRe.exec(html)) !== null) {
|
||||
const tag = linkMatch[0];
|
||||
const rel = readHtmlAttr(tag, "rel") ?? "";
|
||||
if (!rel.split(/\s+/).some((part) => part.toLowerCase() === "stylesheet")) continue;
|
||||
const href = readHtmlAttr(tag, "href") ?? "";
|
||||
if (!isLocalStylesheetHref(href)) continue;
|
||||
|
||||
const rootRelativePath = compSrcPath ? join(dirname(compSrcPath), href) : href;
|
||||
const resolved = resolve(projectDir, rootRelativePath);
|
||||
if (!existsSync(resolved)) continue;
|
||||
sources.push({ content: readFileSync(resolved, "utf-8"), rootRelativePath });
|
||||
}
|
||||
|
||||
let tagMatch: RegExpExecArray | null;
|
||||
const tagPattern = new RegExp(OPEN_TAG_RE.source, OPEN_TAG_RE.flags);
|
||||
while ((tagMatch = tagPattern.exec(html)) !== null) {
|
||||
const tag = tagMatch[0];
|
||||
const style = readHtmlAttr(tag, "style");
|
||||
if (!style) continue;
|
||||
sources.push({ content: style });
|
||||
}
|
||||
|
||||
return sources;
|
||||
}
|
||||
|
||||
function isRemoteOrInlineUrl(url: string): boolean {
|
||||
return /^(https?:|data:|blob:|\/\/|#)/i.test(url);
|
||||
}
|
||||
|
||||
function cleanAssetUrl(url: string): string {
|
||||
return url.trim().split(/[?#]/, 1)[0] ?? "";
|
||||
}
|
||||
|
||||
function resolveCssAssetPath(
|
||||
projectDir: string,
|
||||
url: string,
|
||||
htmlCompSrcPath?: string,
|
||||
cssRootRelativePath?: string,
|
||||
): string {
|
||||
if (url.startsWith("/")) return resolve(projectDir, url.slice(1));
|
||||
if (cssRootRelativePath) return resolve(projectDir, join(dirname(cssRootRelativePath), url));
|
||||
if (htmlCompSrcPath) return resolve(projectDir, rewriteAssetPath(htmlCompSrcPath, url));
|
||||
return resolve(projectDir, url);
|
||||
}
|
||||
|
||||
/**
|
||||
* Lint the root index.html and all sub-compositions in the compositions/ directory.
|
||||
* Returns aggregated results across all files.
|
||||
@@ -101,6 +173,7 @@ export function lintProject(project: ProjectDir): ProjectLintResult {
|
||||
const projectFindings = [
|
||||
...lintProjectAudioFiles(project.dir, allHtmlSources),
|
||||
...lintAudioSrcNotFound(project.dir, allHtmlSources),
|
||||
...lintTextureMaskAssetNotFound(project.dir, allHtmlSources),
|
||||
...lintMultipleRootCompositions(project.dir),
|
||||
...lintDuplicateAudioTracks(allHtmlSources),
|
||||
];
|
||||
@@ -215,6 +288,49 @@ function lintAudioSrcNotFound(
|
||||
return findings;
|
||||
}
|
||||
|
||||
function lintTextureMaskAssetNotFound(
|
||||
projectDir: string,
|
||||
htmlSources: HtmlSource[],
|
||||
): HyperframeLintFinding[] {
|
||||
const missing = new Map<string, string>();
|
||||
|
||||
for (const { html, compSrcPath } of htmlSources) {
|
||||
for (const cssSource of collectCssSources(projectDir, html, compSrcPath)) {
|
||||
let match: RegExpExecArray | null;
|
||||
const pattern = new RegExp(MASK_IMAGE_URL_RE.source, MASK_IMAGE_URL_RE.flags);
|
||||
while ((match = pattern.exec(cssSource.content)) !== null) {
|
||||
const rawUrl = match[1] ?? match[2] ?? match[3] ?? "";
|
||||
const url = cleanAssetUrl(rawUrl);
|
||||
if (!url || isRemoteOrInlineUrl(url)) continue;
|
||||
if (/^__[A-Z_]+__$/.test(url)) continue;
|
||||
|
||||
const resolved = resolveCssAssetPath(
|
||||
projectDir,
|
||||
url,
|
||||
compSrcPath,
|
||||
cssSource.rootRelativePath,
|
||||
);
|
||||
if (existsSync(resolved)) continue;
|
||||
missing.set(url, resolved);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (missing.size === 0) return [];
|
||||
const urls = [...missing.keys()];
|
||||
return [
|
||||
{
|
||||
code: "texture_mask_asset_not_found",
|
||||
severity: "error",
|
||||
message: `CSS mask-image references file(s) not found in the project: ${urls.join(", ")}.`,
|
||||
fixHint:
|
||||
urls.length === 1
|
||||
? `Add "${urls[0]}" to the project, or update the mask-image URL to point to an existing texture mask.`
|
||||
: "Add the missing texture mask files to the project, or update the mask-image URLs to point to existing files.",
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Error if multiple root-level HTML files with data-composition-id exist.
|
||||
* Scans the project directory filesystem (not just what lintProject chose to read)
|
||||
|
||||
@@ -7,6 +7,7 @@ import { gsapRules } from "./rules/gsap";
|
||||
import { captionRules } from "./rules/captions";
|
||||
import { compositionRules } from "./rules/composition";
|
||||
import { adapterRules } from "./rules/adapters";
|
||||
import { textureRules } from "./rules/textures";
|
||||
|
||||
const ALL_RULES = [
|
||||
...coreRules,
|
||||
@@ -15,6 +16,7 @@ const ALL_RULES = [
|
||||
...captionRules,
|
||||
...compositionRules,
|
||||
...adapterRules,
|
||||
...textureRules,
|
||||
];
|
||||
|
||||
export function lintHyperframeHtml(
|
||||
|
||||
@@ -35,6 +35,21 @@ describe("composition rules", () => {
|
||||
expect(finding).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not count inline style block internals as structural lines", () => {
|
||||
const style = `<style>\n${Array.from({ length: 320 }, (_, i) => `.rule-${i} { color: red; }`).join("\n")}\n</style>`;
|
||||
const html = `<!doctype html>
|
||||
<html>
|
||||
<head>${style}</head>
|
||||
<body>
|
||||
<div data-composition-id="main" data-start="0" data-duration="1">TEXT</div>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
const result = lintHyperframeHtml(html, { filePath: "/project/index.html" });
|
||||
const finding = result.findings.find((f) => f.code === "composition_file_too_large");
|
||||
expect(finding).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not warn for large registry source block files", () => {
|
||||
const html = Array.from({ length: 301 }, (_, i) =>
|
||||
i === 0 ? "<html><body>" : `<!-- filler ${i} -->`,
|
||||
|
||||
@@ -16,6 +16,10 @@ function countPhysicalLines(source: string): number {
|
||||
return withoutFinalNewline.split("\n").length;
|
||||
}
|
||||
|
||||
function countStructuralLines(source: string): number {
|
||||
return countPhysicalLines(source.replace(/<style\b[^>]*>[\s\S]*?<\/style>/gi, "<style></style>"));
|
||||
}
|
||||
|
||||
function isRegistrySourceFile(filePath?: string): boolean {
|
||||
if (!filePath) return false;
|
||||
|
||||
@@ -38,7 +42,7 @@ export const compositionRules: Array<(ctx: LintContext) => HyperframeLintFinding
|
||||
({ rawSource, options }) => {
|
||||
if (isRegistrySourceFile(options.filePath) || isRegistryInstalledFile(rawSource)) return [];
|
||||
|
||||
const lineCount = countPhysicalLines(rawSource);
|
||||
const lineCount = countStructuralLines(rawSource);
|
||||
if (lineCount <= MAX_COMPOSITION_LINES) return [];
|
||||
|
||||
const splitTarget = options.isSubComposition
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { lintHyperframeHtml } from "../hyperframeLinter.js";
|
||||
|
||||
function baseHtml(body: string, style = ""): string {
|
||||
return `<html><body>
|
||||
<div data-composition-id="main" data-width="1920" data-height="1080">
|
||||
${body}
|
||||
</div>
|
||||
<style>${style}</style>
|
||||
<script>window.__timelines = window.__timelines || {}; window.__timelines["main"] = gsap.timeline({ paused: true });</script>
|
||||
</body></html>`;
|
||||
}
|
||||
|
||||
const textureCss = `
|
||||
.hf-texture-text {
|
||||
color: #fff;
|
||||
-webkit-mask-size: var(--mask-size, cover);
|
||||
mask-size: var(--mask-size, cover);
|
||||
}
|
||||
.hf-texture-lava {
|
||||
-webkit-mask-image: url("masks/lava.png");
|
||||
mask-image: url("masks/lava.png");
|
||||
}
|
||||
`;
|
||||
|
||||
describe("texture rules", () => {
|
||||
it("does not warn for a valid texture mask text usage", () => {
|
||||
const html = baseHtml(
|
||||
'<div class="shadow"><div class="hf-texture-text hf-texture-lava">TEXT</div></div>',
|
||||
`${textureCss}.shadow { filter: drop-shadow(1px 2px 1px rgba(0,0,0,.48)); }`,
|
||||
);
|
||||
|
||||
const result = lintHyperframeHtml(html);
|
||||
|
||||
expect(result.findings.filter((finding) => finding.code.startsWith("texture_"))).toEqual([]);
|
||||
});
|
||||
|
||||
it("warns when a material class is used without hf-texture-text", () => {
|
||||
const html = baseHtml('<div class="hf-texture-lava">TEXT</div>', textureCss);
|
||||
|
||||
const result = lintHyperframeHtml(html);
|
||||
const finding = result.findings.find((item) => item.code === "texture_class_missing_base");
|
||||
|
||||
expect(finding).toBeDefined();
|
||||
expect(finding?.severity).toBe("warning");
|
||||
expect(finding?.fixHint).toContain("hf-texture-text");
|
||||
});
|
||||
|
||||
it("warns when hf-texture-text has no material class or custom mask image", () => {
|
||||
const html = baseHtml('<div class="hf-texture-text">TEXT</div>', textureCss);
|
||||
|
||||
const result = lintHyperframeHtml(html);
|
||||
const finding = result.findings.find((item) => item.code === "texture_text_missing_mask");
|
||||
|
||||
expect(finding).toBeDefined();
|
||||
expect(finding?.severity).toBe("warning");
|
||||
});
|
||||
|
||||
it("allows hf-texture-text with an inline custom mask image", () => {
|
||||
const html = baseHtml(
|
||||
'<div class="hf-texture-text" style="-webkit-mask-image:url(custom.png); mask-image:url(custom.png)">TEXT</div>',
|
||||
textureCss,
|
||||
);
|
||||
|
||||
const result = lintHyperframeHtml(html);
|
||||
const finding = result.findings.find((item) => item.code === "texture_text_missing_mask");
|
||||
|
||||
expect(finding).toBeUndefined();
|
||||
});
|
||||
|
||||
it("warns when a texture material class is not defined by local CSS", () => {
|
||||
const html = baseHtml('<div class="hf-texture-text hf-texture-marbel">TEXT</div>', textureCss);
|
||||
|
||||
const result = lintHyperframeHtml(html);
|
||||
const finding = result.findings.find((item) => item.code === "texture_class_unknown");
|
||||
|
||||
expect(finding).toBeDefined();
|
||||
expect(finding?.message).toContain("hf-texture-marbel");
|
||||
});
|
||||
|
||||
it("warns when drop-shadow is applied inline to the textured text element", () => {
|
||||
const html = baseHtml(
|
||||
'<div class="hf-texture-text hf-texture-lava" style="filter: drop-shadow(1px 2px 1px black)">TEXT</div>',
|
||||
textureCss,
|
||||
);
|
||||
|
||||
const result = lintHyperframeHtml(html);
|
||||
const finding = result.findings.find((item) => item.code === "texture_drop_shadow_on_text");
|
||||
|
||||
expect(finding).toBeDefined();
|
||||
expect(finding?.fixHint).toContain("wrapper");
|
||||
});
|
||||
|
||||
it("warns when drop-shadow is applied by CSS directly to hf-texture-text", () => {
|
||||
const html = baseHtml(
|
||||
'<div class="hf-texture-text hf-texture-lava">TEXT</div>',
|
||||
`${textureCss}.hf-texture-text { filter: drop-shadow(1px 2px 1px black); }`,
|
||||
);
|
||||
|
||||
const result = lintHyperframeHtml(html);
|
||||
const finding = result.findings.find((item) => item.code === "texture_drop_shadow_on_text");
|
||||
|
||||
expect(finding).toBeDefined();
|
||||
expect(finding?.selector).toBe(".hf-texture-text");
|
||||
});
|
||||
|
||||
it("warns when drop-shadow targets a material class before the mask rule is declared", () => {
|
||||
const html = baseHtml(
|
||||
'<div class="hf-texture-text hf-texture-lava">TEXT</div>',
|
||||
`.hf-texture-lava { filter: drop-shadow(1px 2px 1px black); }
|
||||
${textureCss}`,
|
||||
);
|
||||
|
||||
const result = lintHyperframeHtml(html);
|
||||
const finding = result.findings.find((item) => item.code === "texture_drop_shadow_on_text");
|
||||
|
||||
expect(finding).toBeDefined();
|
||||
expect(finding?.selector).toBe(".hf-texture-lava");
|
||||
});
|
||||
|
||||
it("warns when drop-shadow targets another class on the textured text element", () => {
|
||||
const html = baseHtml(
|
||||
'<div class="hf-texture-text hf-texture-lava headline">TEXT</div>',
|
||||
`${textureCss}.headline { filter: drop-shadow(1px 2px 1px black); }`,
|
||||
);
|
||||
|
||||
const result = lintHyperframeHtml(html);
|
||||
const finding = result.findings.find((item) => item.code === "texture_drop_shadow_on_text");
|
||||
|
||||
expect(finding).toBeDefined();
|
||||
expect(finding?.selector).toBe(".headline");
|
||||
});
|
||||
|
||||
it("does not warn when another-class drop-shadow selector needs an unmatched ancestor", () => {
|
||||
const html = baseHtml(
|
||||
'<div class="hf-texture-text hf-texture-lava headline">TEXT</div>',
|
||||
`${textureCss}.card .headline { filter: drop-shadow(1px 2px 1px black); }`,
|
||||
);
|
||||
|
||||
const result = lintHyperframeHtml(html);
|
||||
const finding = result.findings.find((item) => item.code === "texture_drop_shadow_on_text");
|
||||
|
||||
expect(finding).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,223 @@
|
||||
import postcss from "postcss";
|
||||
import type { LintContext, HyperframeLintFinding, OpenTag } from "../context";
|
||||
import { readAttr, truncateSnippet } from "../utils";
|
||||
|
||||
const TEXTURE_BASE_CLASS = "hf-texture-text";
|
||||
const TEXTURE_CLASS_PREFIX = "hf-texture-";
|
||||
|
||||
type DropShadowRule = {
|
||||
selector: string;
|
||||
directlyTargetsTexture: boolean;
|
||||
};
|
||||
|
||||
function classNames(tag: OpenTag): string[] {
|
||||
return (readAttr(tag.raw, "class") ?? "").split(/\s+/).filter(Boolean);
|
||||
}
|
||||
|
||||
function isTextureMaterialClass(className: string): boolean {
|
||||
return className.startsWith(TEXTURE_CLASS_PREFIX) && className !== TEXTURE_BASE_CLASS;
|
||||
}
|
||||
|
||||
function hasInlineMaskImage(tag: OpenTag): boolean {
|
||||
const style = readAttr(tag.raw, "style") ?? "";
|
||||
return /\b(?:-webkit-)?mask-image\s*:/i.test(style);
|
||||
}
|
||||
|
||||
function hasInlineDropShadow(tag: OpenTag): boolean {
|
||||
const style = readAttr(tag.raw, "style") ?? "";
|
||||
return /\bfilter\s*:\s*[^;]*\bdrop-shadow\s*\(/i.test(style);
|
||||
}
|
||||
|
||||
function classNamesInSelector(selector: string): string[] {
|
||||
const classes = new Set<string>();
|
||||
const pattern = /\.([A-Za-z_][\w-]*)/g;
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = pattern.exec(selector)) !== null) {
|
||||
const className = match[1];
|
||||
if (!className) continue;
|
||||
classes.add(className);
|
||||
}
|
||||
return [...classes];
|
||||
}
|
||||
|
||||
function textureClassesInSelector(selector: string): string[] {
|
||||
return classNamesInSelector(selector).filter(isTextureMaterialClass);
|
||||
}
|
||||
|
||||
function simpleSelectorMatchesTag(selector: string, tag: OpenTag, tagClasses: string[]): boolean {
|
||||
const trimmed = selector.trim();
|
||||
const simpleSelectorPattern = /^(?:[A-Za-z][\w-]*)?(?:\.[A-Za-z_][\w-]*)+$/;
|
||||
if (!simpleSelectorPattern.test(trimmed)) return false;
|
||||
|
||||
const typeMatch = /^([A-Za-z][\w-]*)/.exec(trimmed);
|
||||
if (typeMatch && typeMatch[1]!.toLowerCase() !== tag.name) return false;
|
||||
|
||||
const selectorClasses = classNamesInSelector(trimmed);
|
||||
return (
|
||||
selectorClasses.length > 0 &&
|
||||
selectorClasses.every((className) => tagClasses.includes(className))
|
||||
);
|
||||
}
|
||||
|
||||
function collectTextureCss(styles: LintContext["styles"]): {
|
||||
definedTextureClasses: Set<string>;
|
||||
dropShadowRules: DropShadowRule[];
|
||||
} {
|
||||
const definedTextureClasses = new Set<string>();
|
||||
const dropShadowRules: DropShadowRule[] = [];
|
||||
const roots: postcss.Root[] = [];
|
||||
|
||||
for (const style of styles) {
|
||||
let root: postcss.Root;
|
||||
try {
|
||||
root = postcss.parse(style.content);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
roots.push(root);
|
||||
|
||||
root.walkRules((rule) => {
|
||||
const selectors = rule.selectors ?? [];
|
||||
let hasMaskImage = false;
|
||||
|
||||
for (const node of rule.nodes ?? []) {
|
||||
if (node.type !== "decl") continue;
|
||||
const prop = node.prop.toLowerCase();
|
||||
if (prop === "mask-image" || prop === "-webkit-mask-image") hasMaskImage = true;
|
||||
}
|
||||
|
||||
if (hasMaskImage) {
|
||||
for (const selector of selectors) {
|
||||
for (const className of textureClassesInSelector(selector)) {
|
||||
definedTextureClasses.add(className);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
for (const root of roots) {
|
||||
root.walkRules((rule) => {
|
||||
const selectors = rule.selectors ?? [];
|
||||
let hasDropShadow = false;
|
||||
|
||||
for (const node of rule.nodes ?? []) {
|
||||
if (node.type !== "decl") continue;
|
||||
if (node.prop.toLowerCase() === "filter" && /\bdrop-shadow\s*\(/i.test(node.value)) {
|
||||
hasDropShadow = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (hasDropShadow) {
|
||||
for (const selector of selectors) {
|
||||
const targetsBaseClass = /\.hf-texture-text\b/.test(selector);
|
||||
const targetsDefinedTextureClass = textureClassesInSelector(selector).some((className) =>
|
||||
definedTextureClasses.has(className),
|
||||
);
|
||||
dropShadowRules.push({
|
||||
selector,
|
||||
directlyTargetsTexture: targetsBaseClass || targetsDefinedTextureClass,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return { definedTextureClasses, dropShadowRules };
|
||||
}
|
||||
|
||||
export const textureRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> = [
|
||||
({ tags, styles }) => {
|
||||
const findings: HyperframeLintFinding[] = [];
|
||||
const { definedTextureClasses, dropShadowRules } = collectTextureCss(styles);
|
||||
|
||||
for (const { selector, directlyTargetsTexture } of dropShadowRules) {
|
||||
if (!directlyTargetsTexture) continue;
|
||||
findings.push({
|
||||
code: "texture_drop_shadow_on_text",
|
||||
severity: "warning",
|
||||
message: "Drop shadow is applied directly to textured text.",
|
||||
selector,
|
||||
fixHint:
|
||||
"Wrap the textured text and apply `filter: drop-shadow(...)` to the wrapper, not the `hf-texture-text` element.",
|
||||
});
|
||||
}
|
||||
|
||||
for (const tag of tags) {
|
||||
if (tag.name === "style" || tag.name === "script") continue;
|
||||
|
||||
const classes = classNames(tag);
|
||||
if (classes.length === 0) continue;
|
||||
|
||||
const hasBaseClass = classes.includes(TEXTURE_BASE_CLASS);
|
||||
const textureClasses = classes.filter(isTextureMaterialClass);
|
||||
|
||||
if (textureClasses.length > 0 && !hasBaseClass) {
|
||||
findings.push({
|
||||
code: "texture_class_missing_base",
|
||||
severity: "warning",
|
||||
message: `Texture material class \`${textureClasses[0]}\` is used without \`${TEXTURE_BASE_CLASS}\`.`,
|
||||
elementId: readAttr(tag.raw, "id") || undefined,
|
||||
fixHint: `Add \`${TEXTURE_BASE_CLASS}\` alongside the material class, for example \`class="${TEXTURE_BASE_CLASS} ${textureClasses[0]}"\`.`,
|
||||
snippet: truncateSnippet(tag.raw),
|
||||
});
|
||||
}
|
||||
|
||||
if (hasBaseClass && textureClasses.length === 0 && !hasInlineMaskImage(tag)) {
|
||||
findings.push({
|
||||
code: "texture_text_missing_mask",
|
||||
severity: "warning",
|
||||
message: `\`${TEXTURE_BASE_CLASS}\` is used without a texture material class or custom mask image.`,
|
||||
elementId: readAttr(tag.raw, "id") || undefined,
|
||||
fixHint:
|
||||
"Add a material class such as `hf-texture-lava`, or set `mask-image` and `-webkit-mask-image` on the element.",
|
||||
snippet: truncateSnippet(tag.raw),
|
||||
});
|
||||
}
|
||||
|
||||
for (const textureClass of textureClasses) {
|
||||
if (definedTextureClasses.has(textureClass)) continue;
|
||||
findings.push({
|
||||
code: "texture_class_unknown",
|
||||
severity: "warning",
|
||||
message: `Texture material class \`${textureClass}\` is not defined by local CSS.`,
|
||||
elementId: readAttr(tag.raw, "id") || undefined,
|
||||
fixHint:
|
||||
"Paste the Texture Mask Text component `<style>...</style>` block into the composition, or fix the texture class typo.",
|
||||
snippet: truncateSnippet(tag.raw),
|
||||
});
|
||||
}
|
||||
|
||||
if (hasBaseClass) {
|
||||
for (const rule of dropShadowRules) {
|
||||
if (rule.directlyTargetsTexture) continue;
|
||||
if (!simpleSelectorMatchesTag(rule.selector, tag, classes)) continue;
|
||||
findings.push({
|
||||
code: "texture_drop_shadow_on_text",
|
||||
severity: "warning",
|
||||
message: "Drop shadow is applied directly to textured text.",
|
||||
selector: rule.selector,
|
||||
elementId: readAttr(tag.raw, "id") || undefined,
|
||||
fixHint:
|
||||
"Wrap the textured text and apply `filter: drop-shadow(...)` to the wrapper, not the `hf-texture-text` element.",
|
||||
snippet: truncateSnippet(tag.raw),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (hasBaseClass && hasInlineDropShadow(tag)) {
|
||||
findings.push({
|
||||
code: "texture_drop_shadow_on_text",
|
||||
severity: "warning",
|
||||
message: "Drop shadow is applied directly to textured text.",
|
||||
elementId: readAttr(tag.raw, "id") || undefined,
|
||||
fixHint:
|
||||
"Wrap the textured text and apply `filter: drop-shadow(...)` to the wrapper, not the `hf-texture-text` element.",
|
||||
snippet: truncateSnippet(tag.raw),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return findings;
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,18 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const generatorSource = readFileSync(
|
||||
resolve(here, "../../../../scripts/generate-catalog-pages.ts"),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
describe("catalog generator texture instructions", () => {
|
||||
it("pins the unambiguous texture style-block copy instruction", () => {
|
||||
expect(generatorSource).toContain("paste the real <style> block");
|
||||
expect(generatorSource).toContain("near the bottom into the composition once");
|
||||
expect(generatorSource).toContain("real \\`<style>\\` element near the bottom");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user