From a68b4b6590ed40c3455fee33826cef7441fe4997 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Sun, 24 May 2026 16:38:54 -0400 Subject: [PATCH] fix(cli): decode linked stylesheet paths --- packages/cli/src/utils/lintProject.test.ts | 47 ++++++++++++++++++++++ packages/cli/src/utils/lintProject.ts | 25 +++++++++--- 2 files changed, 66 insertions(+), 6 deletions(-) diff --git a/packages/cli/src/utils/lintProject.test.ts b/packages/cli/src/utils/lintProject.test.ts index 25bd371bf..65def2039 100644 --- a/packages/cli/src/utils/lintProject.test.ts +++ b/packages/cli/src/utils/lintProject.test.ts @@ -119,6 +119,29 @@ describe("lintProject", () => { expect(finding?.selector).toBe('[data-composition-id="scene"] .title'); }); + it("lints percent-encoded linked CSS filenames that exist decoded on disk", () => { + const encodedFilename = "%E6%97%A5%E6%9C%AC%E8%AA%9E.css"; + const project = makeProject(validHtml(), { + "scene.html": ` +
+ +`, + }); + writeFileSync( + join(project.dir, "compositions", decodeURIComponent(encodedFilename)), + '[data-composition-id="scene"] .title { opacity: 0; }', + ); + + const { results } = lintProject(project); + const subResult = results.find((result) => result.file === "compositions/scene.html"); + const finding = subResult?.result.findings.find( + (item) => item.code === "composition_self_attribute_selector", + ); + + expect(finding).toBeDefined(); + expect(finding?.selector).toBe('[data-composition-id="scene"] .title'); + }); + it("aggregates errors across index.html and sub-compositions", () => { const project = makeProject(htmlWithMissingMediaId(), { "overlay.html": htmlWithMissingMediaId(), @@ -528,6 +551,30 @@ describe("texture_mask_asset_not_found", () => { expect(finding).toBeUndefined(); }); + it("checks mask-image URLs inside percent-encoded linked CSS filenames", () => { + const encodedFilename = "%E6%97%A5%E6%9C%AC%E8%AA%9E.css"; + const project = makeProject(validHtml(), { + "scene.html": ` +
+
TEXT
+
+ +`, + }); + writeFileSync( + join(project.dir, "compositions", decodeURIComponent(encodedFilename)), + '.hf-texture-lava { mask-image: url("masks/missing.png"); }', + ); + + const { results } = lintProject(project); + const finding = results[0]?.result.findings.find( + (item) => item.code === "texture_mask_asset_not_found", + ); + + expect(finding).toBeDefined(); + expect(finding?.message).toContain("masks/missing.png"); + }); + it("resolves root-absolute mask-image URLs from the project root", () => { const html = `
diff --git a/packages/cli/src/utils/lintProject.ts b/packages/cli/src/utils/lintProject.ts index 3d1af293c..a3a8f8d5a 100644 --- a/packages/cli/src/utils/lintProject.ts +++ b/packages/cli/src/utils/lintProject.ts @@ -62,9 +62,9 @@ function collectExternalStyles( const href = tag.match(/\bhref\s*=\s*["']([^"']+)["']/i)?.[1] ?? ""; if (!isLocalStylesheetHref(href)) continue; const rootRelative = compSrcPath ? join(dirname(compSrcPath), href) : href; - const resolved = resolve(projectDir, rootRelative); - if (!existsSync(resolved)) continue; - styles.push({ href, content: readFileSync(resolved, "utf-8") }); + const stylesheet = resolveExistingLocalAsset(projectDir, rootRelative); + if (!stylesheet) continue; + styles.push({ href, content: readFileSync(stylesheet.resolved, "utf-8") }); } return styles; } @@ -88,9 +88,12 @@ function collectCssSources(projectDir: string, html: string, compSrcPath?: strin 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 }); + const stylesheet = resolveExistingLocalAsset(projectDir, rootRelativePath); + if (!stylesheet) continue; + sources.push({ + content: readFileSync(stylesheet.resolved, "utf-8"), + rootRelativePath: stylesheet.rootRelativePath, + }); } let tagMatch: RegExpExecArray | null; @@ -146,6 +149,16 @@ function resolveLocalAssetCandidates(projectDir: string, url: string): string[] return candidates; } +function resolveExistingLocalAsset( + projectDir: string, + url: string, +): { resolved: string; rootRelativePath: string } | null { + const projectRoot = resolve(projectDir); + const resolved = resolveLocalAssetCandidates(projectRoot, url).find(existsSync); + if (!resolved) return null; + return { resolved, rootRelativePath: relative(projectRoot, resolved) }; +} + function resolveCssAssetCandidates( projectDir: string, url: string,