/** * A small, dependency-free markup scanner — the structural pass the regex rules * cannot be (DESIGN-MCP-V2 §6/§10 "AST pass"). It does NOT build a full Svelte * AST (that would mean a `svelte/compiler` dependency, and this engine is * zero-dep): it extracts the one thing the line-based rules miss — *which * attribute belongs to which element* — by walking the source once and emitting a * flat list of opening tags with their attributes, plus a helper to slice an * element's inner content. * * It is deliberately conservative: anything it cannot parse confidently (an * unterminated tag, an exotic expression) is skipped, never guessed. A rule built * on this therefore never fires on a mis-parse — a missed element is silence, not * a false positive, which is the contract the correctness gate depends on. */ import { maskHtmlComments, maskScriptAndStyle } from './mask.js'; /** How an attribute carries its value. */ export type AttrKind = | 'string' // attr="literal" / attr='literal' / attr=bare | 'expression' // attr={expr} | 'boolean' // bare attr, no value | 'shorthand' // {value} — name and expression are the same identifier | 'spread'; // {...rest} export interface Attr { /** Attribute name, e.g. `variant`, `aria-label`, `on:click`. Empty for spread/shorthand. */ name: string; /** Raw inner value (no quotes/braces); `null` for a boolean attribute. */ value: string | null; kind: AttrKind; /** 1-based line of the attribute name. */ line: number; /** Char offset of the attribute name (undefined for spread/shorthand, which have none). */ nameStart?: number; /** Char offset where the raw inner value begins (inside quotes/braces). Undefined for boolean attrs. */ valueStart?: number; /** Char offset just past the raw inner value (before the closing quote/brace). Undefined for boolean attrs. */ valueEnd?: number; } export interface Element { /** Tag name as written, e.g. `Button`, `button`, `Foo.Bar`. */ tag: string; /** PascalCase or dotted tag → a component (not a raw HTML element). */ isComponent: boolean; attrs: Attr[]; /** 1-based line of the opening `<`. */ line: number; selfClosing: boolean; /** Char offset of the opening `<`. */ openStart: number; /** Char offset just past the opening tag's `>`. */ openEnd: number; } /** * Blank HTML comments and ``, so such a literal * inside a JS string ends the blank early and the trailing JS is then scanned as * markup. This is narrower than the line-based rules (which don't blank scripts at * all); no real file hits it, and any mis-scan only ever yields a skipped tag, * never a wrong finding from the curated rules. * * Two deliberate differences from the older non-greedy regexes this replaced: * the opener requires a word boundary, so a `` component no longer * opens a script region and blanks the markup after it (it did, case-insensitively, * and every rule went quiet until the next ``); and the closer allows * ``, which HTML permits and which previously left the region open to * the end of the file. */ function blankNonMarkup(src: string): string { return maskScriptAndStyle(maskHtmlComments(src)); } const isNameStart = (c: string | undefined): boolean => c !== undefined && /[A-Za-z]/.test(c); const isTagNameChar = (c: string | undefined): boolean => c !== undefined && /[A-Za-z0-9.\-:]/.test(c); const isAttrNameChar = (c: string | undefined): boolean => c !== undefined && !/[\s=/>]/.test(c) && c !== '<'; /** Read a quoted string starting at `src[i]` (a quote char). Returns inner value + index past the close. */ function readQuoted(src: string, i: number): { value: string; end: number } { const quote = src[i]; let j = i + 1; while (j < src.length && src[j] !== quote) j++; return { value: src.slice(i + 1, j), end: j + 1 }; // j+1 steps past the closing quote (or EOF) } /** * Read a balanced `{…}` expression starting at `src[i]` (`{`). Brace depth counts * outside of `"`/`'` strings (so a `}` inside a string literal does not close it, * and a `\"` escape does not end the string early); `${…}` in template literals * balances naturally through the same counter. Returns the inner text + index past * the closing brace, or `end: -1` if never closed. */ export function readBraced(src: string, i: number): { value: string; end: number } { let depth = 0; let str: string | null = null; // active "/' string delimiter, if any for (let j = i; j < src.length; j++) { const c = src[j]; if (str !== null) { if (c === '\\') { j++; // a backslash escapes the next char — don't let `\"` close the string early continue; } if (c === str) str = null; continue; } if (c === '"' || c === "'") str = c; else if (c === '{') depth++; else if (c === '}') { depth--; if (depth === 0) return { value: src.slice(i + 1, j), end: j + 1 }; } } return { value: '', end: -1 }; } /** Parse one attribute starting at `src[i]` (first name/`{` char). Returns the attr + next index, or null if malformed. */ function parseAttr(src: string, i: number, line: number): { attr: Attr; end: number } | null { if (src[i] === '{') { const { value, end } = readBraced(src, i); if (end === -1) return null; const trimmed = value.trim(); const spread = trimmed.startsWith('...'); return { attr: { name: spread ? '' : trimmed, value: spread ? trimmed.slice(3).trim() : trimmed, kind: spread ? 'spread' : 'shorthand', line, valueStart: i + 1, valueEnd: end - 1 }, end }; } let j = i; while (isAttrNameChar(src[j])) j++; const name = src.slice(i, j); if (name === '') return null; // not a valid attribute start — bail (caller skips the tag) // Optional `= value`, allowing whitespace around `=`. let k = j; while (k < src.length && /\s/.test(src[k] ?? '')) k++; if (src[k] !== '=') { return { attr: { name, value: null, kind: 'boolean', line, nameStart: i }, end: j }; } k++; // past '=' while (k < src.length && /\s/.test(src[k] ?? '')) k++; const c = src[k]; if (c === '"' || c === "'") { const { value, end } = readQuoted(src, k); return { attr: { name, value, kind: 'string', line, nameStart: i, valueStart: k + 1, valueEnd: end - 1 }, end }; } if (c === '{') { const { value, end } = readBraced(src, k); if (end === -1) return null; return { attr: { name, value, kind: 'expression', line, nameStart: i, valueStart: k + 1, valueEnd: end - 1 }, end }; } // Bare unquoted value: read until whitespace or tag end. let m = k; while (m < src.length && !/[\s/>]/.test(src[m] ?? '')) m++; return { attr: { name, value: src.slice(k, m), kind: 'string', line, nameStart: i, valueStart: k, valueEnd: m }, end: m }; } /** Parse an opening tag starting at `src[start]` (`<`). Returns the element + index past `>`, or null. */ function parseOpenTag( src: string, start: number, line: number ): { element: Element; end: number } | null { let i = start + 1; while (isTagNameChar(src[i])) i++; const tag = src.slice(start + 1, i); if (tag === '') return null; const attrs: Attr[] = []; let curLine = line; // Count newlines as we advance so each attr/tag gets the right line. const bump = (from: number, to: number): void => { for (let p = from; p < to; p++) if (src[p] === '\n') curLine++; }; let selfClosing = false; let closed = false; while (i < src.length) { const before = i; while (i < src.length && /\s/.test(src[i] ?? '')) i++; bump(before, i); const c = src[i]; if (c === undefined) break; // EOF reached — `closed` stays false, rejected below if (c === '>') { i++; closed = true; break; } if (c === '/' && src[i + 1] === '>') { selfClosing = true; i += 2; closed = true; break; } const parsed = parseAttr(src, i, curLine); if (!parsed) return null; // unparseable attribute — skip the whole tag conservatively attrs.push(parsed.attr); bump(i, parsed.end); i = parsed.end; } if (!closed) return null; // ran off the end without a `>`/`/>` — malformed, skip const isComponent = /^[A-Z]/.test(tag) || tag.includes('.'); return { element: { tag, isComponent, attrs, line, selfClosing, openStart: start, openEnd: i }, end: i }; } /** Scan source for opening element/component tags with their attributes. */ export function scanMarkup(source: string): Element[] { const src = blankNonMarkup(source); const elements: Element[] = []; let line = 1; let i = 0; while (i < src.length) { const c = src[i]; if (c === '\n') { line++; i++; continue; } // A tag opens at `<` immediately followed by a letter (not `]/.test(after); } /** * The raw inner content of an element (between its opening `>` and matching * ``), honouring same-name nesting. Returns `null` for a self-closing * element or when no balanced close is found — callers treat `null` as "unknown", * and skip, so an unbalanced document never produces a false finding. */ export function innerContent(source: string, el: Element): string | null { if (el.selfClosing) return null; const src = blankNonMarkup(source); let depth = 1; let i = el.openEnd; while (i < src.length) { if (src[i] === '<') { if (tagAt(src, i, el.tag, true)) { depth--; if (depth === 0) return src.slice(el.openEnd, i); } else if (tagAt(src, i, el.tag, false)) { depth++; } } i++; } return null; }