diff --git a/docs/reference/lint-rules.mdx b/docs/reference/lint-rules.mdx index d198134be13..4c8c63b27bc 100644 --- a/docs/reference/lint-rules.mdx +++ b/docs/reference/lint-rules.mdx @@ -860,7 +860,7 @@ Source: the project lint pass in `packages/lint/src/project.ts`, `packages/lint/ ### `texture_mask_asset_not_found` -**Error.** A CSS `mask-image` references files that are not in the project, so the texture mask fails to load. +**Error.** A CSS `mask-image` or `-webkit-mask-image` declaration references files that are not in the project, so the texture mask fails to load. In valid CSS, comments and text inside other declarations, such as `content` strings, are ignored. **Fix:** Add the missing mask file(s) to the project, or update the `mask-image` URL. diff --git a/packages/lint/src/project.test.ts b/packages/lint/src/project.test.ts index 980921638d7..71885aba0ff 100644 --- a/packages/lint/src/project.test.ts +++ b/packages/lint/src/project.test.ts @@ -399,6 +399,73 @@ describe("missing_or_empty_sub_composition", () => { }); }); +describe("texture_mask_asset_not_found declarations", () => { + it("still reports a missing inline mask beside a malformed declaration", async () => { + const project = makeProject( + validHtml().replace( + "
entry.result.findings) + .filter((finding) => finding.code === "texture_mask_asset_not_found"); + + expect(findings).toEqual([ + expect.objectContaining({ + severity: "error", + message: expect.stringContaining("missing.png"), + }), + ]); + }); + + it.each([ + ["embedded", "comment", "/* .badge { mask-image: url(removed-texture.png); } */"], + ["linked", "comment", "/* .badge { mask-image: url(removed-texture.png); } */"], + ["embedded", "content string", '.badge::after { content: "mask-image: url(example.png)"; }'], + ["linked", "content string", '.badge::after { content: "mask-image: url(example.png)"; }'], + ])("ignores mask syntax in a %s stylesheet's %s", async (source, _kind, css) => { + const style = + source === "linked" ? '' : ``; + const project = makeProject(validHtml().replace("", `${style}`)); + if (source === "linked") writeFileSync(join(project, "style.css"), css); + + const { totalErrors, results } = await lintProject(project); + + expect(totalErrors).toBe(0); + expect( + results.flatMap((entry) => entry.result.findings).map((finding) => finding.code), + ).not.toContain("texture_mask_asset_not_found"); + }); + + it.each(["embedded", "linked"])( + "still errors for missing masks declared in %s CSS", + async (source) => { + const css = `/* .badge { mask-image: url(removed-texture.png); } */ + .badge::after { content: "mask-image: url(example.png)"; } + .badge { -webkit-mask-image: url(missing-webkit.png); MASK-IMAGE: url(missing.png); }`; + const style = + source === "linked" ? '' : ``; + const project = makeProject(validHtml().replace("", `${style}`)); + if (source === "linked") writeFileSync(join(project, "style.css"), css); + + const { results } = await lintProject(project); + const findings = results + .flatMap((entry) => entry.result.findings) + .filter((finding) => finding.code === "texture_mask_asset_not_found"); + + expect(findings).toHaveLength(1); + expect(findings[0]).toMatchObject({ severity: "error", file: join(project, "index.html") }); + expect(findings[0]?.message).toContain("missing-webkit.png"); + expect(findings[0]?.message).toContain("missing.png"); + expect(findings[0]?.message).not.toContain("removed-texture.png"); + expect(findings[0]?.message).not.toContain("example.png"); + }, + ); +}); + describe("template shell style sources", () => { it("collects links, style blocks, and inline styles from template content", async () => { const project = makeProject(` diff --git a/packages/lint/src/project.ts b/packages/lint/src/project.ts index b20c9e3eb93..8994ed20de3 100644 --- a/packages/lint/src/project.ts +++ b/packages/lint/src/project.ts @@ -20,6 +20,7 @@ import { } from "@hyperframes/parsers/reference-resolver"; import { checkSubCompositionUsability } from "@hyperframes/parsers/sub-composition-validity"; import { parseHTML } from "linkedom"; +import postcss from "postcss"; import { cleanAssetUrl, collectSubCompositionSrcs, @@ -98,6 +99,7 @@ function contentDigest(html: string): string { } const AUDIO_EXTENSIONS = new Set([".mp3", ".wav", ".aac", ".ogg", ".m4a", ".flac", ".opus"]); +const CSS_URL_RE = /\burl\(\s*(?:"([^"]+)"|'([^']+)'|([^"')\s]+))\s*\)/gi; const MASK_IMAGE_URL_RE = /\b(?:-webkit-)?mask-image\s*:\s*[^;{}]*url\(\s*(?:"([^"]+)"|'([^']+)'|([^"')\s]+))\s*\)/gi; @@ -501,9 +503,16 @@ function lintTextureMaskAssetNotFound( for (const { html, compSrcPath } of htmlSources) { const file = sourceFile(projectDir, compSrcPath); 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) { + let matches: RegExpExecArray[] = []; + try { + postcss.parse(cssSource.content).walkDecls(/^(?:-webkit-)?mask-image$/i, (declaration) => { + matches.push(...declaration.value.matchAll(CSS_URL_RE)); + }); + } catch (error) { + if (!(error instanceof postcss.CssSyntaxError)) throw error; + matches = [...cssSource.content.matchAll(MASK_IMAGE_URL_RE)]; + } + for (const match of matches) { const rawUrl = match[1] ?? match[2] ?? match[3] ?? ""; // Placeholder check runs on the RAW value: cleanAssetUrl() splits on ?/# and would chop inside a ${...} token. if (isUnresolvedAssetPlaceholder(rawUrl)) continue;