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(); 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; dropShadowRules: DropShadowRule[]; } { const definedTextureClasses = new Set(); 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); // fallow-ignore-next-line complexity 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) { // fallow-ignore-next-line complexity 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 }; } // fallow-ignore-next-line complexity 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: "error", message: `Texture material class \`${textureClass}\` is not defined by local CSS.`, elementId: readAttr(tag.raw, "id") || undefined, fixHint: "Paste the Texture Mask Text component `` 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; }, ];