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;