Skip to content

fix(lint): parse HTML structure without regex#2223

Merged
miguel-heygen merged 5 commits into
mainfrom
investigate/srcdoc-timeline-lint
Jul 11, 2026
Merged

fix(lint): parse HTML structure without regex#2223
miguel-heygen merged 5 commits into
mainfrom
investigate/srcdoc-timeline-lint

Conversation

@miguel-heygen

@miguel-heygen miguel-heygen commented Jul 11, 2026

Copy link
Copy Markdown
Collaborator

What

Prevent HTML embedded in quoted attributes such as iframe[srcdoc] from producing host-document lint findings. Structural HTML extraction now covers tags, scripts, styles, templates, roots, remote media, variable declarations, and project CSS/link discovery through a real parser.

Why

Regex block and tag scans treated nested srcdoc content as part of the host composition. That produced false JavaScript syntax and missing-timeline errors, plus false media, visibility, and Studio-editability findings, even when the root composition was valid.

How

Use htmlparser2 for HTML structure while preserving exact source positions and raw snippets. Source-semantic checks remain regex-based where appropriate: GSAP/JavaScript patterns, CSS values and selectors, malformed raw markup, and asset paths.

The same parser-backed structure is reused across lint context construction, template/root discovery, remote-media validation, variable declarations, and project stylesheet collection.

Test plan

  • Unit tests added/updated — full lint suite passes (357/357), including srcdoc script, element, and remote-media regressions
  • Manual testing performed — reproduced the reported syntax/timeline findings and the additional media/editability false positives before the fix
  • Documentation updated (not applicable; no workflow or public API change)

Additional verification: lint typecheck, format, oxlint, lint build, unchanged 167 KB browser entry, and Fallow audit all pass.

- replace repeated attribute scanner with quoted tag ranges
- remove the Fallow complexity finding
@miguel-heygen

Copy link
Copy Markdown
Collaborator Author

isInsideQuotedTagAttribute has CRAP score 31.6

Addressed in 60a9b21: removed the complex scanner in favor of quoted tag ranges. fallow audit --changed-since origin/main now reports no issues.

@miguel-heygen miguel-heygen changed the title fix(lint): ignore scripts inside quoted attributes fix(lint): parse HTML structure without regex Jul 11, 2026

@vanceingalls vanceingalls left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict: 🟢 LGTM at b114db3b

Clean regex → real-parser refactor. htmlparser2 correctly ignores tag-like content inside quoted attribute values, which was the root cause of the iframe[srcdoc] false-positives — the fix is at the right layer.

Mechanism verified:

  • parseHtmlStructure in packages/lint/src/utils.ts — event-based Parser with onopentag/onclosetag handlers, LIFO-per-name stack for close-index attribution. { decodeEntities: false, lowerCaseAttributeNames: false, lowerCaseTags: true } — decode-off matches the old regex behavior (both operated on raw source slices), lowercase-tags matches the old (match[1] || "").toLowerCase(), attr-case preserved (unchanged). Void elements like <img> / <link> / <meta> fire onclosetag immediately in htmlparser2, so closeIndex/endIndex are set for them too — the findRootTag filter and SVG-skip logic work unchanged.
  • Attribute-value tag suppression is a natural property of a real parser: <iframe srcdoc="<img src=x>"> emits exactly one onopentag("iframe") and never a nested img — the img inside quotes is an attribute value, not a token. That's what fixes the false positives across scripts, media URLs, root/composition rules, and variable-declaration collection.
  • findRootTag at utils.tsbodyTags = tags.filter((tag) => tag.index >= bodyStart && tag.index < bodyEnd) reads absolute indices, so the old { ...tag, index: tag.index + bodyStart } adjustment goes away cleanly (the old code was compensating for a sliced-view scan; the new code doesn't need to). SVG-skip: skipBefore = tag.endIndex ?? Infinity matches the old closeMatch ? tag.index + closeMatch.index + closeMatch[0].length : Infinity on both the found-close and no-close branches.
  • context.ts template-unwrap: iterates templateTags in reverse and space-fills each [index, endIndex) — preserves absolute positions in sourceWithoutTemplates (important because downstream findRootTag is called on it and needs coherent indices). Unwrap-fallback uses templateTags[0] and slices the first template's inner content.
  • project.ts — separate parser choice (linkedom.parseHTML) for content-only querying (stylesheets, [style] attributes) makes sense: htmlparser2's position-aware output isn't needed there, and linkedom gives a DOM-shape querySelectorAll interface. Both are real parsers, both respect quoted-attribute boundaries — no interop concern.

Test coverage locked at every affected surface:

  • hyperframeLinter.test.ts:127-134 — pins the iframe srcdoc non-fetch on lintMediaUrls.
  • core.test.ts:78-101 — pins non-lint on srcdoc-embedded <script> (no invalid_inline_script_syntax, no gsap_timeline_not_registered) and on srcdoc-embedded <video> (no top-level media finding).

Full lint suite green (357/357), and the PR body's list of previously-broken surfaces (JS syntax, missing-timeline, media, visibility, Studio-editability) all route through parseHtmlStructure now.

Minor observations, non-blocking:

  • Two-template unwrap behavior narrows. The old greedy regex <template[^>]*>([\s\S]*)<\/template> on <template>A</template>X<template>B</template> captured A</template>X<template>B (greedy scan across the middle </template>). The new code slices the FIRST template's inner content only ("A"). This is more principled — a hyperframe should have one composition root, and if the fallback unwrap is triggered the first template is the useful one — but it's a real behavior change if any existing sub-composition file has two templates AND no root outside them AND relied on the greedy junk-inclusive capture. I don't have a reason to think any file does, but worth flagging for grep-verification against your corpus of sub-composition files if you have one handy.
  • attrs on self-closing tags keeps a trailing slash. raw.slice(name.length + 1, -1) on <img src="x"/> produces src="x"/ (trailing /). Old regex produced the same shape, and readAttr is regex-based and ignores the slash, so no functional impact — noting for anyone parsing attrs directly in the future.
  • decodeEntities: false — attribute values retain &amp; and &quot; unchanged. This matches the raw-source-slice contract the old regex code operated under, so no downstream consumer sees a behavior change. Only worth noting if a future rule ever starts comparing decoded values from readAttr against a decoded literal.

CI: current head b114db3b re-triggered the required run set — CLI smoke (required), Typecheck, Test, Build, and Windows lanes were in-progress at review time (prior Smoke: global install fail was on a superseded run that was cancelled, not a real regression). Verdict conditional on that set landing green; nothing in the diff suggests it wouldn't.

R1 by Via

@james-russo-rames-d-jusso james-russo-rames-d-jusso left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed at b114db3.

Parser swap is structurally sound and the srcdoc fix is well-motivated — htmlparser2 correctly treats iframe[srcdoc] content as opaque attribute text, not sub-document HTML, so the reported false-positive scripts/timelines/media findings do go away. decodeEntities: false is the right call for keeping script/style/attribute content byte-identical via source.slice(...).

One code-anchored concern in project.ts (see inline on L48) — the DOM-based querySelectorAll path silently narrows coverage vs. the old flat-regex scan for <template>-interior <link> / <style> / [style] elements. Verified empirically against linkedom 0.18.12. Affects template-shell sub-comps specifically; blast radius through lintProject's compositions/${file} iteration is non-trivial. Two nits inline in utils.ts for the attrs-extraction stray / and a letconst micro-simplification.

Non-blocking follow-ups worth flagging:

  • Parser divergence. Package now uses BOTH htmlparser2 (utils.ts / context.ts / hyperframeLinter.ts) AND linkedom (project.ts). They already disagree on <template> scoping (the blocker above). Any subtle disagreement on malformed-recovery / self-closing / namespaced-tag edge cases could produce inconsistent lint context across rule groups. Longer-term: consolidate.
  • Redundant reparses. parseHtmlStructure runs 2–4× per file per lint pass (buildLintContext initial + template-unwrap re-parse, extractMediaUrls, findRootTag fallback when called without parsedTags). Not urgent at typical file sizes, but a shared per-file cache would trim the redundancy — same shape as context.externalStyles threading.
  • Test-coverage gaps. The added tests cover iframe[srcdoc] scripts + srcdoc media (the intended fix), but no fixture exercises: (a) template-shell comp with template-interior <link> / <style> (would catch the blocker above), (b) nested <template> (blank-out reverse-iterate handles it, but no test locks that), (c) <div title="a > b"> — attribute with literal > (old regex broke, new parser handles; regression test would document the fix).

Nothing I couldn't reason through from the diff — I did not run the lint suite locally.

Review by Rames D Jusso

Comment thread packages/lint/src/project.ts Outdated
Comment thread packages/lint/src/utils.ts Outdated
Comment thread packages/lint/src/utils.ts Outdated
@miguel-heygen

Copy link
Copy Markdown
Collaborator Author

Two-template / first-only observation: evaluated and covered.

The new querySelectorAllIncludingTemplates recursively walks every template content fragment, including nested templates, so it does not retain a first-template-only behavior for the affected style/link collection path. project.test.ts now covers link + style + inline style inside a shell and a nested template. The existing context fallback behavior is separate and remains intentionally first-template-only for malformed/no-root unwrap cases.

@miguel-heygen miguel-heygen merged commit 05c3b55 into main Jul 11, 2026
45 checks passed
@miguel-heygen miguel-heygen deleted the investigate/srcdoc-timeline-lint branch July 11, 2026 04:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants