// Shared types, regex constants, and utility functions used across lint rule modules. // Nothing in this file should emit findings — it only parses and extracts. export type OpenTag = { raw: string; name: string; attrs: string; index: number; }; export type ExtractedBlock = { attrs: string; content: string; raw: string; index: number; }; export const TAG_PATTERN = /<([a-z][\w:-]*)(\s[^<>]*?)?>/gi; export const STYLE_BLOCK_PATTERN = /]*)>([\s\S]*?)<\/style>/gi; export const SCRIPT_BLOCK_PATTERN = /]*)>([\s\S]*?)<\/script>/gi; export const COMPOSITION_ID_IN_CSS_PATTERN = /\[data-composition-id=["']([^"']+)["']\]/g; export const TIMELINE_REGISTRY_INIT_PATTERN = /window\.__timelines\s*=\s*window\.__timelines\s*\|\|\s*\{\}|window\.__timelines\s*=\s*\{\}|window\.__timelines\s*\?\?=\s*\{\}/i; export const TIMELINE_REGISTRY_ASSIGN_PATTERN = /window\.__timelines\[[^\]]+\]\s*=/i; export const WINDOW_TIMELINE_ASSIGN_PATTERN = /window\.__timelines\[\s*["']([^"']+)["']\s*\]\s*=\s*([A-Za-z_$][\w$]*)/i; export const INVALID_SCRIPT_CLOSE_PATTERN = /]*>[\s\S]*?<\s*\/\s*script(?!>)/i; export function extractOpenTags(source: string): OpenTag[] { const tags: OpenTag[] = []; let match: RegExpExecArray | null; const pattern = new RegExp(TAG_PATTERN.source, TAG_PATTERN.flags); while ((match = pattern.exec(source)) !== null) { const raw = match[0]; if (raw.startsWith("` open tag in the source. Distinct from `findRootTag`, * which returns the first element inside `` — the latter is "the * composition's visible root", whereas `` is where document-level * metadata like `data-composition-variables` lives. */ export function findHtmlTag(source: string): OpenTag | null { const match = /]*)>/i.exec(source); if (!match) return null; return { raw: match[0], name: "html", attrs: match[1] ?? "", index: match.index, }; } export function findRootTag(source: string): OpenTag | null { const bodyOpenMatch = /]*>/i.exec(source); const bodyCloseMatch = /<\/body>/i.exec(source); const bodyStart = bodyOpenMatch ? bodyOpenMatch.index + bodyOpenMatch[0].length : 0; const bodyEnd = bodyOpenMatch && bodyCloseMatch && bodyCloseMatch.index > bodyStart ? bodyCloseMatch.index : source.length; const bodyContent = bodyOpenMatch ? source.slice(bodyStart, bodyEnd) : source; const bodyTags = extractOpenTags(bodyContent); for (const tag of bodyTags) { if (["script", "style", "meta", "link", "title"].includes(tag.name)) continue; return { ...tag, index: tag.index + bodyStart }; } return null; } export function readAttr(tagSource: string, attr: string): string | null { if (!tagSource) return null; const escaped = attr.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); const match = tagSource.match(new RegExp(`\\b${escaped}\\s*=\\s*["']([^"']+)["']`, "i")); return match?.[1] || null; } /** * Read an attribute that may legitimately contain the opposite quote * character. `readAttr` truncates `data-variable-values='{"title":"Hello"}'` * at the first internal `"` because its `[^"']+` class excludes both quote * types. This variant alternates: a double-quoted value never contains an * unescaped `"`, and a single-quoted value never contains an unescaped `'`, * so each branch can use a quote-specific class. * * Use for attributes whose values are JSON or otherwise carry the opposite * quote character. Existing single-token attributes (`id`, `class`, etc.) * stick with `readAttr` for consistency with the rest of the lint code. */ export function readJsonAttr(tagSource: string, attr: string): string | null { if (!tagSource) return null; const escaped = attr.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); const match = tagSource.match(new RegExp(`\\b${escaped}\\s*=\\s*(?:"([^"]*)"|'([^']*)')`, "i")); if (!match) return null; return match[1] ?? match[2] ?? null; } export function collectCompositionIds(tags: OpenTag[]): Set { const ids = new Set(); for (const tag of tags) { const compId = readAttr(tag.raw, "data-composition-id"); if (compId) ids.add(compId); } return ids; } export function extractCompositionIdsFromCss(css: string): string[] { const ids = new Set(); let match: RegExpExecArray | null; const pattern = new RegExp( COMPOSITION_ID_IN_CSS_PATTERN.source, COMPOSITION_ID_IN_CSS_PATTERN.flags, ); while ((match = pattern.exec(css)) !== null) { if (match[1]) ids.add(match[1]); } return [...ids]; } export function getInlineScriptSyntaxError(source: string): string | null { if (!source.trim()) return null; try { // eslint-disable-next-line no-new-func new Function(source); return null; } catch (error) { if (error instanceof Error) return error.message; return String(error); } } export function isMediaTag(tagName: string): boolean { return tagName === "video" || tagName === "audio" || tagName === "img"; } export function truncateSnippet(value: string, maxLength = 220): string | undefined { const normalized = value.replace(/\s+/g, " ").trim(); if (!normalized) return undefined; if (normalized.length <= maxLength) return normalized; return `${normalized.slice(0, maxLength - 3)}...`; }