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:
Vance Ingalls
2026-05-07 00:23:36 -07:00
committed by GitHub
parent 73966cd53b
commit edac92b431
85 changed files with 2779 additions and 33 deletions
+116
View File
@@ -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)