Skip to content

feat: add standalone HTML support - #147

Open
marcellmanfrin wants to merge 42 commits into
firecrawl:mainfrom
marcellmanfrin:feature/html-support-pr
Open

feat: add standalone HTML support#147
marcellmanfrin wants to merge 42 commits into
firecrawl:mainfrom
marcellmanfrin:feature/html-support-pr

Conversation

@marcellmanfrin

@marcellmanfrin marcellmanfrin commented Aug 28, 2026

Copy link
Copy Markdown

Scope

Adds standalone HTML support (.html / .htm) for the HTML portion of #52. MHTML/MHT remains out of scope here and is handled separately in #149.

Summary

  • adds Format::Html, extension mapping, UTF-8/UTF-16 content detection, charset sniffing, and tolerant HTML5 parsing via scraper/html5ever
  • reuses the shared semantic HTML conversion path used by EPUB
  • preserves CSS semantics, links, anchors, and non-empty absolute/protocol-relative/relative image references without fetching local or remote resources
  • enforces pre-DOM node/depth limits plus post-parse defense-in-depth checks
  • exposes HTML through Rust, Node, Python, WASM and CLI bindings
  • includes controlled fixtures plus real LibreOffice DOCX/XLSX HTML exports and snapshot coverage

Review follow-ups

The current head includes the earlier charset, detector-precedence, malformed-HTML repair, list, heading, foreign-content, and anchor-preflight fixes.

The latest Cubic findings discovered while reviewing #149 were reproduced against the previous #147 head and fixed here because they belong to the shared HTML frontend:

  • valid HTML5 frameset documents no longer fail solely because they have no <body>; they convert to an empty document when there is no renderable body content
  • non-empty relative <img src> values are preserved as image references, just like absolute and protocol-relative references; anydoc still does not fetch or load those resources
  • the LibreOffice corpus snapshot now intentionally preserves the two relative image references that were previously dropped

TDD evidence

RED branch: audit/html-cubic-frameset-relative-red, based on previous head c6b7bb18608d4bde426a71d9a5300070d8f653fc.

GitHub Actions run 33300432395, job 99227423622 reproduced both findings:

  • frameset conversion returned Malformed("HTML parser produced no body element")
  • the relative-image document produced no block because the image source was discarded

Validated functional SHA: ce7948287c8b4a62666f851a5845beabb091ef38.

The functional diff from the previous head is limited to src/formats/html.rs, tests/html.rs, and the expected LibreOffice snapshot update.

Fresh full validation

Temporary CI-only branch: verify/html-cubic-frameset-relative-full-v2.

CI-only commit: 56cf2e57fc02f17c20f1ede780524b99e4acaed2. Its sole parent is the validated functional SHA ce7948287c8b4a62666f851a5845beabb091ef38; every job explicitly checks out that functional SHA detached.

Authoritative GitHub Actions run: 33300809474 — all 6 jobs passed.

  • latest frameset and relative-image regressions: passed
  • full html, html_list, html_corpus, snapshots, and content-detection regressions: passed
  • real HTML root extracted from the supplied SEI/TRE-PB MHTML fixture: passed
  • Rust fmt, workspace/all-targets/all-features Clippy with -D warnings, and cargo test --locked: passed
  • Rust MSRV 1.88 check and HTML test compilation: passed
  • Node npm ci, build, tests, and committed binding determinism: passed
  • WASM wasm32 Clippy, release build, and node --test wasm/test.mjs: passed
  • Python locked Maturin release wheel, installed compiled site-packages module, and full unittest suite: passed

Real validation fixture:

  • MHTML input: 505,883 bytes; SHA-256 387c6f2d7223da224a8f55962b97eac947734c97fc84888e1e5619e93745837c
  • extracted HTML root: 200,381 bytes; SHA-256 32fca03d52dc62518bf4419e2a4b1be96160195cd99f613a2d3230f1bb1e817c
  • standalone HTML Markdown: 78,154 bytes; SHA-256 6119ae4bb085acabe21149492020e159e431de4f7a169350b1b619f2add61c1a
  • expected Portuguese content preserved; no , Ã, or  mojibake markers

The current PR head is exactly the validated functional SHA above.

@cubic-dev-ai cubic-dev-ai Bot 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.

All reported issues were addressed across 16 files

Tip: instead of fixing issues one by one fix them all with cubic

Re-trigger cubic

Comment thread src/formats/html.rs Outdated
Comment thread src/formats/detect.rs Outdated
Comment thread src/formats/detect.rs
Comment thread src/formats/detect.rs Outdated
Comment thread src/formats/detect.rs

@cubic-dev-ai cubic-dev-ai Bot 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.

All reported issues were addressed across 5 files (changes from recent commits).

Tip: Review your code locally with the cubic CLI to iterate faster.

Fix all with cubic | Re-trigger cubic

Comment thread src/formats/html.rs Outdated
Comment thread src/formats/html.rs

@cubic-dev-ai cubic-dev-ai Bot 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.

2 issues found across 5 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="src/formats/html.rs">

<violation number="1" location="src/formats/html.rs:179">
P1: For HTML-style `<div/>` chains, this condition undercounts nesting because non-void self-closing flags are ignored by the HTML tree builder. Track those elements as open, while handling foreign-content self-closing elements separately, so pathological depth is rejected before DOM construction.</violation>

<violation number="2" location="src/formats/html.rs:222">
P2: When malformed HTML relies on HTML5 heading repair, the preflight rejects it as too deep even though html5ever would produce sibling headings. Add the heading implied-closing rule (and the other HTML5 tree-builder closures) before applying the depth limit.</violation>
</file>

Tip: Review your code locally with the cubic CLI to iterate faster.

Fix all with cubic | Re-trigger cubic

Comment thread src/formats/html.rs Outdated
Comment thread src/formats/html.rs Outdated

@cubic-dev-ai cubic-dev-ai Bot 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.

2 issues found across 5 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="src/formats/detect.rs">

<violation number="1" location="src/formats/detect.rs:76">
P2: A UTF-16 doctype with more than 51 whitespace code units between `DOCTYPE` and `html` is not detected because the fixed prefix ends before the name. Scan the doctype marker state instead of truncating detection at 64 code units.</violation>
</file>

<file name="Cargo.toml">

<violation number="1" location="Cargo.toml:38">
P3: This change drops the trailing newline at the end of Cargo.toml (`strip = "symbols"` now ends the file with no newline). The target branch ends the file with a trailing newline. Restore the trailing newline to keep the file clean and avoid noisy diffs.</violation>
</file>

Tip: Review your code locally with the cubic CLI to iterate faster.

Fix all with cubic | Re-trigger cubic

Comment thread src/formats/detect.rs Outdated
Comment thread Cargo.toml Outdated
marcellmanfrin added a commit to marcellmanfrin/anydoc that referenced this pull request Aug 29, 2026
marcellmanfrin added a commit to marcellmanfrin/anydoc that referenced this pull request Aug 29, 2026

@cubic-dev-ai cubic-dev-ai Bot 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.

All reported issues were addressed across 16 files (changes from recent commits).

Tip: Review your code locally with the cubic CLI to iterate faster.

Fix all with cubic | Re-trigger cubic

Comment thread src/shared/html.rs
Marcell Manfrin added 2 commits August 31, 2026 22:29
Cubic findings (PR 149 review run ed78d83a, shared HTML frontend):

1. The complexity preflight did not model HTML5's implicit <p> closure
   on block-level start tags, so repeated <p><div> pairs accumulated
   phantom nesting depth: documents html5ever repairs into shallow trees
   were rejected and the depth accounting diverged from the DOM the
   limits are meant to model. Close an innermost open <p> when a
   block-level start tag arrives; deeper arrangements remain
   over-counted, keeping the preflight fail-closed.

2. href="#" produced LinkTarget::Anchor(""), which the Markdown
   renderer cannot resolve, so the link was dropped to plain text.
   Preserve the empty fragment as a relative "#" URL instead.

Regressions: 200 <p><div> pairs now convert (depth 200 < limit), 300
pairs are still rejected before DOM construction, and a bare-hash link
renders as [top](#).
Cubic (PR 149, src/formats/html.rs:308) claimed dialog and summary do
not implicitly close an open <p>; html5ever's in-body mode
(tree_builder/rules.rs) proves they do. Keep both and complete the
preflight list to the parser's full close_p_element_in_button_scope
set: center, dir, listing, plaintext, and search were missing. table
is retained: html5ever closes <p> for <table> outside quirks mode,
and the preflight assumes standards mode.
@marcellmanfrin

Copy link
Copy Markdown
Author

@cubic-dev-ai ultrareview: focus on the HTML complexity preflight (implied-end-tag modeling for paragraphs, headings, anchors, lists), charset/detection handling, and the standalone HTML conversion path.

@cubic-dev-ai

cubic-dev-ai Bot commented Sep 1, 2026

Copy link
Copy Markdown

@cubic-dev-ai ultrareview: focus on the HTML complexity preflight (implied-end-tag modeling for paragraphs, headings, anchors, lists), charset/detection handling, and the standalone HTML conversion path.

@marcellmanfrin Couldn't start the ultrareview: PR author marcellmanfrin has no assigned seat on this installation. Please, assign one at https://www.cubic.dev/settings?tab=subscription.

… stray list text

Ultrareview findings (fork staging PR #8):

- P1: after a foreign-content breakout (<svg><p>), the preflight kept
  the stale svg entry, so subsequent HTML self-closing tags were
  honored as foreign and skipped depth accounting; malformed input
  could force construction of an arbitrarily deep DOM before any
  limit fired. Pop through the nearest svg/math ancestor when an HTML
  breakout tag arrives, before classifying self-closing tags.
- optgroup: model the HTML5 select insertion rule (close an open
  option and the enclosing optgroup).
- table row groups: tbody/thead/tfoot starts close the previous row
  group.
- headings: keep the parser-faithful single pop; html5ever pops only
  when the current node is already a heading (verified against
  html5ever 0.39's in-body arm), so headings separated by unclosed
  inlines genuinely nest and are now pinned by a preflight regression.
- shared list parsing preserves visible text left directly under
  ul/ol by malformed markup instead of dropping it.
- python: add "html" to the public Format alias in __init__.py (the
  private stub already had it).
- README: list HTML in the overview and the detection bullet.
- tests: drop the exact golden copy already recorded by the snapshot
  harness; add regressions for all behavioral changes.
Marcell Manfrin added 12 commits September 5, 2026 13:11
Cubic finding (staging PR #10, src/formats/html.rs):
the complexity preflight switched the tokenizer to RCDATA/RawText for
title, textarea, style, xmp, iframe, noembed, noframes, and plaintext
regardless of context. Inside foreign content those names are ordinary
elements (svg title/desc/foreignObject are HTML integration points, so
their children are parsed as markup), and the preflight therefore
missed real element depth and node counts: malformed input such as
<svg><title> followed by hundreds of unclosed <div> passed the
pre-DOM limit while html5ever built the deep tree anyway.

Decide the tokenizer state from the foreign-content context computed
before the element is pushed; SVG script remains the one foreign case
that switches the tokenizer. Factor the context scan out of
html5_self_closing_is_honored into in_foreign_content.

Regressions: <svg><title> + 300 unclosed <div> is rejected before DOM
construction; an HTML <title> still swallows markup as raw text.
Cubic finding (staging PR #11, src/formats/html.rs):
the preflight switched to ScriptData for every script start tag. html5ever
0.39 does not switch the tokenizer for scripts inside foreign content (its
foreign_start_tag has no ScriptData transition), so markup after
<svg><script> or <math><script> really is tokenized as elements while the
preflight swallowed it as raw text, letting deeply nested input bypass the
pre-DOM limits.

Gate the ScriptData transition on HTML content like the other raw-text
states. Regressions: <svg><script> followed by 300 unclosed <div> is
rejected before DOM construction; an HTML <script> still swallows markup
as script data.
…n roots

Review findings (staging PR #11, src/formats/html.rs):

- The preflight applied HTML implied-end-tag rules (p, li, option, tr,
  headings, anchors) inside foreign content too. There those names are
  ordinary elements, so applying the rules undercounted real nesting and
  deeply nested foreign markup could reach DOM construction. Skip
  close_implied_before_start when the stack is in foreign content.
- On a foreign-content breakout, pop through the OUTERMOST svg/math root
  reachable without crossing an HTML integration point, matching
  html5ever's pop-until-HTML behavior. Truncating only the innermost root
  left an outer svg on the stack, so later HTML self-closing tags were
  still honored as foreign and bypassed the depth guard.
- Pin the template behavior with a regression: a <body> inside <template>
  must not capture document conversion (html5ever keeps template contents
  out of the main tree, verified empirically).

Regressions: 300 nested <option> inside <svg> are rejected before DOM
construction; <svg><math><p> breakout followed by 300 <path/> is rejected
before DOM construction; template body converts the real body.
Review findings (staging PR #11 and #10):

- Void HTML names inside foreign content are ordinary foreign elements
  that html5ever pushes (input, param, source, ...); only void names in
  HTML content skip the stack, and they still run the implied-end-tag
  rules now, so <hr> closes an open <p> as the parser does.
- Depth is counted the way the post-parse walk sees it: adapt_element
  treats body as depth 1 and html5ever auto-inserts html/body wrappers
  that may never appear as tokens. The preflight now computes the
  body-relative depth (last body token, else last html token, else the
  implicit wrapper), which removes both the off-by-one rejection of
  documents at exactly the limit and the undercount for documents
  without wrapper tokens.
- End tags now respect scope: html5ever ignores an end tag whose element
  is not in scope, while the preflight used to truncate the stack at any
  matching name, popping real nesting and undercounting depth. Table
  family end tags use the narrower table scope.
- Strengthen the repeated-anchors regression to require a successful
  conversion, not merely the absence of a depth error.

Regressions: 255 nested divs at the exact boundary convert while 256 are
rejected before DOM construction; 300 <p>x<hr> pairs convert; 300
<input> inside <svg> are rejected before DOM construction; an
out-of-scope </div> above 300 nested spans no longer pops them.
Review findings (staging PRs #11 and #10):

- Duplicate <html>/<body> start tags are ignored by html5ever; pushing
  them moved the body-relative depth baseline upward and undercounted
  every later descendant. Skip a wrapper token when that wrapper is
  already on the modeled stack.
- End-tag scope rules are now tag-specific like the parser's: the table
  family uses table scope (html, table, template), </li> uses list-item
  scope (adds ol/ul/button), </p> uses button scope (adds button), and
  svg/math foreign roots end every HTML scope search. Previously an end
  tag crossing a template, ol/ul, button, or foreign root truncated the
  modeled stack that html5ever leaves intact, undercounting depth.
- Refactor the repeated inline depth-error assertions in tests/html.rs
  to the existing assert_preflight_depth_limit helper.

Regressions: a duplicate <body> no longer moves the baseline (260 divs
rejected before DOM construction); </p> blocked by button scope keeps
150+150 nested divs counted; </table> blocked by a template keeps 260
nested spans counted.
- close_element: </body> and </html> never truncate the open-element
  stack; html5ever only switches insertion modes and later start tags
  keep nesting, so truncating undercounted real open depth.
- duplicate html/body wrapper suppression now skips foreign content,
  where <html> is an ordinary foreign element, not a duplicate wrapper.
- is_end_tag_scope_marker: drop "button" from li list-item scope
  markers per the HTML5 spec; </li> must still close the item past an
  open button.
- refresh stale hr comment: void tags reach the implied-close hook via
  close_implied, so <hr> does close an open <p>.

Adds regression tests for all three behavior fixes.
Preflight fidelity fixes for the HTML complexity sink (cubic round C):

- close_element: any-other end tags now stop at html5ever's full special
  category (plus svg/math foreign roots), not just the 9 generic scope
  markers. Previously </em> below <em><div><span> truncated real nesting
  the parser keeps open, undercounting depth and defeating the
  reject-before-DOM guarantee. The div family, button, form, headings,
  and template keep their generic-scope rule; dd/dt use button scope and
  rt/rp/rb/rtc use ruby scope, matching the parser.
- close_element: </body>/</html> only stay no-ops in HTML content; inside
  foreign content they are ordinary foreign elements whose end tags pop.
- close_element: option/optgroup end tags only check the current node,
  like html5ever; deeper searches truncated real nesting.
- close_implied_before_start: li/dd/dt walk from the innermost element
  and stop at any special element other than address/div/p; p closes
  only within button scope; rt/rp pop only the current node; option
  pops only the current node and optgroup additionally closes an
  innermost optgroup inside a select context.
- close_implied_before_start: table-family start tags (caption/col/
  colgroup/tbody/td/tfoot/th/thead/tr) outside a table are ignored
  entirely, matching body-context parse errors; pushing them let a later
  implied close truncate divs opened between stray cells (undercount).
  Inside a table the row/body-context clears are unchanged.

Adds 8 regression tests (66 html integration tests total).
Cubic (run bad77c8a, conf6) proposed treating bare svg/math as
pass-through in is_end_tag_scope_marker, aligning the markers with the
in-body default_scope set. That proposal is rejected: while a bare
svg/math root is reachable, html5ever resolves end tags in the
foreign-content phase, whose walk stops at the first HTML element or
integration point below the root and ignores the token — the same
outcome as the modeled root stop. Integration points always sit below
their root, so both marker sets stop identically; dropping the root
stop instead would let scope-based end tags truncate through an open
foreign root that the parser ignores, undercounting depth.

Verified empirically: with svg/math made pass-through, this test fails
(the deep foreign nesting passes preflight); with the root stop it
rejects before DOM construction, matching the parser.
Cubic (run d03d9e02, tests/html.rs pin) proved — and html5ever 0.39
source confirms — that the previous root-stop model was wrong:

- step_foreign (rules.rs 1652-1684): an end tag in foreign content walks
  the stack for a name match in ANY namespace and truncates there; on
  reaching an HTML element below the root without a match it REPROCESSES
  the token in the current insertion mode.
- default_scope (tag_sets.rs): html_default_scope (incl. select) plus the
  MathML text and SVG HTML integration points; bare svg/math are NOT
  markers, so reprocessed scope searches pop through open foreign roots
  to an in-scope target (e.g. </p> under <p><svg><g> closes the p).
- process_end_tag_in_body (mod.rs 1563): any-other end tags stop at
  special_tag (incl. isindex; integration points are not special).

Changes:
- close_element: unified walk without the svg/math root stop; body/html
  and option/optgroup use a foreign-region match (>= foreign_root_index)
  because their in-body rules never pop, while foreign-region matches do.
- GENERIC_SCOPE_MARKERS: add select and the integration-point names
  (matched without namespaces; HTML title collision is harmless).
- is_special_element: align exactly with html5ever special_tag.
- pop_foreign_breakout refactored onto the new foreign_root_index helper.
- Replace the wrong-premise pin test with two source-verified ones:
  scope end tags pop through foreign roots to their target (converts),
  and unmatched end tags still stop at special elements below roots
  (preflight rejects). All 68 html integration tests pass; fixtures
  byte-identical.

Supersedes the round-C N2 rejection: the rejection was wrong about the
mechanism (foreign-phase name-match pop is real); its undercount warning
applies only to dropping the stop WITHOUT the region logic, which this
commit implements properly.
Cubic round D (PR #10 run 10ff97ec, P2 conf8): html5ever ignores a
stray frame start tag in body context and inserts-then-immediately-pops
it in frameset context, so frames never nest in the real DOM. The
preflight stacked every frame, falsely reporting max_xml_depth for
framesets with more than 256 sibling frames. Treat frame like the void
elements in HTML content (it still pushes inside foreign content, where
it is an ordinary foreign element).

Verified discriminating: without the fix, the new frameset test fails;
with it, 70/70 html tests pass.

Also pins two round-D rejections grounded in html5ever 0.39 source:
- markup inside <select> really nests (html5ever has no in-select
  insertion mode; InBody inserts it), so the depth guard must fire —
  ignoring select content would undercount the real DOM.
- the unconditional title/textarea raw-text switch cannot hide deep
  nesting: every mode that accepts those tags inserts them, and the
  frameset modes that ignore them also ignore all later start tags
  (comment only, no behavior change).
Cubic round E (PR #11 run e74d240e, P2 conf9): html5ever's InTable arms
for caption, col, colgroup, tbody/tfoot/thead, and td/th/tr all begin
with pop_until_current(table_scope) — stray foster-parented content
above the table leaves the open-element stack. The preflight kept it,
so repaired table markup with deep stray content accumulated phantom
depth and could be falsely rejected at max_xml_depth. Model the clear
(stopping at the innermost table/template/html, exactly the table_scope
set); the phantom tbody/tr wrappers html5ever inserts afterwards remain
unmodeled (bounded, documented deviation). The previous per-tag
rposition truncation is subsumed by the clear and was removed — it
could also reach below the current table for stale matches.

Also strengthens the raw-text tests (P3 conf7): the script test moves
into the body and asserts the script source is NOT rendered, so it
actually distinguishes ScriptData swallowing from data-state parsing;
the title test gains the same negative assertion.

Regression test: table_family_starts_clear_stray_content_above_the_table
(253 stray divs then tbody/tr/td must convert). Discrimination verified:
without the clear the test fails; with it, 71/71 html tests pass.
Cubic round F (PR #11 run 7cf7afee, P2 conf9): when a block start tag
arrived with an open <p> holding inline descendants, the preflight left
the paragraph (and its descendants) on the modeled stack — html5ever
closes any button-scope p first (close_p_element_in_button_scope,
mod.rs), popping the descendants with it.

The stale p was worse than an overcount: the next p start's button-scope
walk would find it BELOW intervening blocks opened since (blockquote,
etc.) and truncate through them, undercounting the nesting the parser
really keeps open — <blockquote><p><b> repeated stays flat in the model
while html5ever nests the blockquotes without bound, defeating the
reject-before-DOM guarantee.

Walk with the button-scope markers for every paragraph-closing block
start (p included, so the duplicate p-start branch is removed).

Regression test block_starts_close_paragraphs_with_inline_descendants:
255 iterations must reject at preflight depth. Discrimination verified
against the exact previous state (innermost-only block close + p-start
walk): the test fails there and passes with the fix. 72/72 html tests.

@cubic-dev-ai cubic-dev-ai Bot 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.

3 issues found across 6 files (changes from recent commits).

Confidence score: 3/5

  • src/formats/html.rs drops modeled tbody/tr contexts before <td> handling, allowing deeply nested tables to bypass the preflight depth limit and potentially fail during DOM construction — preserve implicit table contexts in the shadow stack.
  • src/shared/html.rs emits separate paragraphs when visible list text is split by a non-rendering node, which loses whitespace and inline continuity in rendered output — accumulate adjacent direct text into one block.
  • tests/html.rs does not actually verify RCDATA semantics for a non-foreign <title> because parse only adapts <body> in src/formats/html.rs; strengthen the test or adjust the conversion path so this behavior is covered.
Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="src/shared/html.rs">

<violation number="1" location="src/shared/html.rs:595">
P2: When visible list text is split by a non-rendering node, `text_blocks` renders each text node as a separate paragraph, losing inter-node whitespace and inline continuity. Accumulate adjacent direct text in one block context and flush it only at a real rendered block or list-item boundary.</violation>
</file>

<file name="src/formats/html.rs">

<violation number="1" location="src/formats/html.rs:653">
P2: Normal table markup discards the modeled `tbody`/`tr` depth at each `<td>`, so the preflight no longer enforces the depth limit before DOM construction. Preserve the implicit table contexts in the shadow stack, or otherwise account for their depth before accepting the input.</violation>
</file>

<file name="tests/html.rs">

<violation number="1" location="tests/html.rs:451">
P3: This test claims to verify that a non-foreign <title> keeps RCDATA semantics, but its assertions cannot detect that behavior. The conversion path only adapts the <body> element (src/formats/html.rs `parse` adapts `body` and passes it to `to_blocks`), so `<title>` content in `<head>` is never rendered to markdown. The `!markdown.contains("not markup")` assertion therefore passes whether the title is parsed as RCDATA text or as nested markup, and the `contains("ok")` assertion is unrelated. The test offers no regression protection for the RCDATA behavior it names. Mirror the sibling foreign-content test: nest many `<div>`s inside the title and assert the document still converts (if title were parsed as markup the preflight would reject at max_xml_depth, so this distinguishes the two cases).</violation>
</file>

Tip: Review your code locally with the cubic CLI to iterate faster.

Fix all with cubic | Re-trigger cubic

Comment thread src/shared/html.rs
let child = match child {
Node::Elem(child) => child,
Node::Text(text) => {
let text_blocks = self.text_blocks(text, delta);

@cubic-dev-ai cubic-dev-ai Bot Sep 7, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2: When visible list text is split by a non-rendering node, text_blocks renders each text node as a separate paragraph, losing inter-node whitespace and inline continuity. Accumulate adjacent direct text in one block context and flush it only at a real rendered block or list-item boundary.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/shared/html.rs, line 595:

<comment>When visible list text is split by a non-rendering node, `text_blocks` renders each text node as a separate paragraph, losing inter-node whitespace and inline continuity. Accumulate adjacent direct text in one block context and flush it only at a real rendered block or list-item boundary.</comment>

<file context>
@@ -558,13 +573,40 @@ impl Builder<'_> {
+                let child = match child {
+                    Node::Elem(child) => child,
+                    Node::Text(text) => {
+                        let text_blocks = self.text_blocks(text, delta);
+                        if text_blocks.is_empty() {
+                            continue;
</file context>
Fix with cubic

Comment thread src/formats/html.rs
// html5ever inserts afterwards are not modeled (a bounded,
// documented deviation), and a template above the table stops the
// clear exactly as table_scope does.
if let Some(clear_to) = open

@cubic-dev-ai cubic-dev-ai Bot Sep 7, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2: Normal table markup discards the modeled tbody/tr depth at each <td>, so the preflight no longer enforces the depth limit before DOM construction. Preserve the implicit table contexts in the shadow stack, or otherwise account for their depth before accepting the input.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/formats/html.rs, line 653:

<comment>Normal table markup discards the modeled `tbody`/`tr` depth at each `<td>`, so the preflight no longer enforces the depth limit before DOM construction. Preserve the implicit table contexts in the shadow stack, or otherwise account for their depth before accepting the input.</comment>

<file context>
@@ -216,45 +365,322 @@ impl TokenSink for HtmlComplexitySink {
+        // html5ever inserts afterwards are not modeled (a bounded,
+        // documented deviation), and a template above the table stops the
+        // clear exactly as table_scope does.
+        if let Some(clear_to) = open
+            .iter()
+            .rposition(|candidate| matches!(candidate.as_ref(), "table" | "template" | "html"))
</file context>
Fix with cubic

Comment thread tests/html.rs
Comment on lines +451 to +456
fn html_title_still_swallows_markup_as_raw_text() {
// The HTML (non-foreign) <title> keeps RCDATA semantics: nested markup is
// text, not elements, and the document converts normally.
let html = br#"<!doctype html><html><head><title><div>not markup</div></title></head><body><p>ok</p></body></html>"#;
let markdown = to_markdown_bytes(html, Some(Format::Html)).unwrap();
assert!(markdown.contains("ok"), "got: {markdown:?}");

@cubic-dev-ai cubic-dev-ai Bot Sep 7, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P3: This test claims to verify that a non-foreign <title> keeps RCDATA semantics, but its assertions cannot detect that behavior. The conversion path only adapts the element (src/formats/html.rs parse adapts body and passes it to to_blocks), so <title> content in <head> is never rendered to markdown. The !markdown.contains("not markup") assertion therefore passes whether the title is parsed as RCDATA text or as nested markup, and the contains("ok") assertion is unrelated. The test offers no regression protection for the RCDATA behavior it names. Mirror the sibling foreign-content test: nest many <div>s inside the title and assert the document still converts (if title were parsed as markup the preflight would reject at max_xml_depth, so this distinguishes the two cases).

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At tests/html.rs, line 451:

<comment>This test claims to verify that a non-foreign <title> keeps RCDATA semantics, but its assertions cannot detect that behavior. The conversion path only adapts the <body> element (src/formats/html.rs `parse` adapts `body` and passes it to `to_blocks`), so `<title>` content in `<head>` is never rendered to markdown. The `!markdown.contains("not markup")` assertion therefore passes whether the title is parsed as RCDATA text or as nested markup, and the `contains("ok")` assertion is unrelated. The test offers no regression protection for the RCDATA behavior it names. Mirror the sibling foreign-content test: nest many `<div>`s inside the title and assert the document still converts (if title were parsed as markup the preflight would reject at max_xml_depth, so this distinguishes the two cases).</comment>

<file context>
@@ -374,3 +374,505 @@ fn bare_hash_link_is_preserved_as_relative_url() {
+}
+
+#[test]
+fn html_title_still_swallows_markup_as_raw_text() {
+    // The HTML (non-foreign) <title> keeps RCDATA semantics: nested markup is
+    // text, not elements, and the document converts normally.
</file context>
Suggested change
fn html_title_still_swallows_markup_as_raw_text() {
// The HTML (non-foreign) <title> keeps RCDATA semantics: nested markup is
// text, not elements, and the document converts normally.
let html = br#"<!doctype html><html><head><title><div>not markup</div></title></head><body><p>ok</p></body></html>"#;
let markdown = to_markdown_bytes(html, Some(Format::Html)).unwrap();
assert!(markdown.contains("ok"), "got: {markdown:?}");
#[test]
fn html_title_still_swallows_markup_as_raw_text() {
// HTML <title> keeps RCDATA semantics: nested markup is text, so the
// preflight must not count it and the document converts.
let mut html = String::from("<!doctype html><html><head><title>");
for _ in 0..300 {
html.push_str("<div>");
}
html.push_str("</title></head><body><p>ok</p></body></html>");
let markdown = to_markdown_bytes(html.as_bytes(), Some(Format::Html)).unwrap();
assert!(markdown.contains("ok"), "got: {markdown:?}");
}
Fix with cubic

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.

1 participant