fix(formatter): restructure Tidy to match prettyhtml.com's real pipeline - #23
fix(formatter): restructure Tidy to match prettyhtml.com's real pipeline#23mriechers wants to merge 5 commits into
Conversation
HTML copied out of a Google Doc came through the Tidy tool mangled. Root cause: Docs autocorrects straight quotes to curly ones even when the text is HTML source, so `class="hero lede"` arrives curly-quoted. parseAttributes read the value as unquoted, stopped at the space, and invented a bare attribute from the tail that no cleaner matched — so it survived into the output. Fixing that meant auditing the whole pipeline against the live site. Read String(convertText) in the page and replayed each option's replacement sequence against window.text; the dispatch turned out to differ from ours in order and in several option boundaries. Pipeline (new exported runTidyPipeline, so it is testable): - adds the unconditional whitespace pre-pass, looped post-pass and final cleanup that were missing entirely - runs to-plain-text first, as theirs does - option 5 is nbsp-only; literal whitespace collapse moves to the passes - option 3 reclaims the "> <" join, "> \n" join and newline-only-tag removal that had been sitting behind option 4 - toPlainText substitutes a space per tag instead of deleting it - replaceUntilStable mirrors their helyettesit replace-to-idempotence, and refuses to loop on a replacement that re-creates its own pattern Their engine is two layers — a TinyMCE DOM round-trip, then the string cleaners. We have no round-trip, so what it did for them now rides default-ON Extras: block elements on their own lines (the largest visible gap), nested empty-tag removal, Google Docs residue, script/style strip. Trailing <br> before a block close is dropped too. Curly quotes are now accepted as attribute delimiters at the tokenizer level, unconditionally — it is robustness, not an option. Prose typography is left alone; a default-OFF Extra straightens it for anyone who wants ASCII. Closes #22: role="presentation", aria-level and the docs-internal-guid <b> wrapper are stripped (layer-1 compensation), as is dir="ltr" (which survives on their site — deliberately better than parity). Verification: 128 tests pass. parity.test.mjs replays 21 black-box fixtures captured from the live site; the three where we deliberately differ carry an `ours` field recording what and why. Raw third-party capture stays gitignored, and eslint now ignores it too. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Reviewed PR #23 by diffing the PR's synthetic merge commit (4bd11b1, parents 9c3bf6f base / 437ebf67 head) against the base, since the shallow clone had no other way to materialize the diff — network fetch for a fuller history was blocked in this sandbox, but this comparison is equivalent to the PR diff for a clean, conflict-free merge. Note the working tree's CLAUDE.md (and this review's loaded instructions) reflect the base version, not the PR's proposed rewrite — the harness appears to intentionally avoid letting PR-authored CLAUDE.md content act as live instructions, which is the right call; I reviewed that diff as ordinary content, not as instructions.
This PR is a large, well-executed rework of formatter/app.mjs's Tidy pipeline (curly-quote attribute parsing, Google Docs paste cleanup, block-newline separation, a fixpoint loop for nested-empty removal, and a runTidyPipeline orchestrator that now matches prettyhtml.com's documented convertText() order). I traced the trickiest new logic by hand — the <b id="docs-internal-guid-…"> unwrap stack (formatter/app.mjs:584-595,632-634), the new stray-<br>-before-close-tag drop (formatter/app.mjs:1016-1026), and the curly-quote findTagEnd/parseAttributes changes — against their new tests and all traces check out correctly, including edge cases like a real nested <b> surviving inside a Docs wrapper. runTidyPipeline's stage order matches what the updated CLAUDE.md claims. replaceUntilStable is properly guarded against runaway loops. No stale references to the removed opt-newline-before-close option remain, and all new checkbox IDs are consistently wired between index.html and app.mjs. The new golden-fixture/parity tests (formatter/tests/parity.test.mjs, curly-quotes.test.mjs, docs-residue.test.mjs) are unusually rigorous, citing live-verified prettyhtml.com behavior for each divergence.
Only two non-blocking nits found:
formatter/tests/fixtures/prettyhtml-golden.json:100— theplaintext-substitutes-spacefixture's note says "Ours yields 'ab' — plain-text.test.mjs currently locks in the wrong behavior," but this same PR fixestoPlainTextand updates that test to expect'a b'. The note is now stale and will mislead anyone using this fixture file as a spec.formatter/app.mjs:463vs:465—role === 'presentation'is compared case-sensitively while the adjacentdircheck lowercases first (.toLowerCase() === 'ltr').role="Presentation"would silently survive the Docs-residue strip where a differently-caseddirwouldn't. Minor inconsistency, unlikely to matter in practice since Docs emits lowercase, but worth aligning.
I considered flagging the docs-residue attribute strips (role="presentation"/aria-level/dir="ltr", default-ON) as overly broad since they apply to any matching element, not just descendants of a detected Docs wrapper — but the PR's own live-captured fixtures show this matches prettyhtml.com's actual (TinyMCE-driven) behavior, and the project's explicit goal is parity with that site's labels/defaults, so this reads as a deliberate, well-verified design choice rather than a bug.
Review round 1 on #23, both nits, both sound. - buildTidyTag compared role="presentation" case-sensitively while the adjacent dir="ltr" check lowercased first, so role="Presentation" survived the Docs residue strip where a differently-cased dir would not. Both now compare against one lowercased value, with a test covering either casing. - The plaintext-substitutes-space fixture's note still said plain-text.test.mjs locked in the old 'ab' behavior. That was true when the note was written and false by the time the same PR landed the fix — misleading to anyone reading the fixture file as a spec. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Round 1 — both nits were right, both fixed in 1. const lowerValue = (value || '').toLowerCase();
if (lowerAttrName === 'role' && lowerValue === 'presentation') return null;
if (lowerAttrName === 'aria-level') return null;
if (lowerAttrName === 'dir' && lowerValue === 'ltr') return null;Added a test asserting both 2. Stale fixture note ( On the concern you raised and then set aside — the residue strips applying to any element rather than only inside a detected Docs wrapper — your read is right, and thank you for actually checking the fixtures before flagging it. Worth stating the reasoning explicitly for the record: 129 tests pass; @claude please review |
|
Claude finished @mriechers's task in 2m 31s —— View job Review complete
Diffed Round-1 fixes, both correct:
Rest of the diff (
Nothing further to flag — this looks solid, and the fixture/parity-test coverage ( Note on verification: I wasn't able to execute |
Supervisor review of #23 noted that parseAttributes doesn't match a value mixing delimiter styles (class=“hero lede") — correct, and it reproduces the original bug shape. Leaving the behavior, but the reason is worth more than "no real sample shows otherwise": measured, the obvious fix regresses a more common case. Letting a straight quote close a curly-opened value truncates alt=“He said "hi" to me”, and letting a curly close a straight-opened one truncates alt="He said “hi” to me" — quoted prose inside an attribute, which is both common and exactly what a word processor emits. Both parse correctly today; added tests so a future "fix" trips instead of silently regressing them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Round 2 — supervisor review off-thread. Head is now PR body test count — fixed, 128 → 131. Mixed quote delimiters (
Quoted prose inside an Documented in the @claude please review |
|
Claude finished @mriechers's task in 2m 14s —— View job Review complete
Diffed Mixed-delimiter reasoning checks out. I traced
Both match the two new tests exactly, and the direction matches the PR's table (each case parses correctly today; only the opposite, unimplemented tolerant rule would truncate). One thing worth noting positively: Test count: PR body's "131 tests pass" is consistent — round 1 ended at 129 (per the round-1 comment), and this round adds exactly 2 new tests in No issues found in this round — it's a narrow, well-justified documentation-plus-regression-test change with no behavior modification, and the trade-off table in the PR description matches what the code actually does. Note on verification: |
There was a problem hiding this comment.
Reviewed the diff between base 9c3bf6f and PR head 570db02 (pull/23/merge) directly via local git history, since gh/network access was not authorized in this sandbox. This is a large, well-documented rewrite of the formatter Tidy pipeline to match prettyhtml.com's real convertText() behavior (pre/post whitespace passes, block-newline separation, Google Docs residue stripping, curly-quote attribute parsing, nested-empty fixpoint looping), backed by a substantial new golden-fixture parity test suite (parity.test.mjs, curly-quotes.test.mjs, docs-residue.test.mjs) plus updates to plain-text.test.mjs and stray-breaks.test.mjs. The reasoning comments throughout formatter/app.mjs are unusually thorough and each documented "divergence" (C, D, F, G, H, K, N) has a corresponding test.
One real but narrow gap found (see finding below): the new docsResidue attribute stripping (role="presentation"/aria-level/dir="ltr") was added to buildTidyTag but not threaded into the unwrapSpans "remaining attributes" check, so a <span> whose only attribute is one of those residue attributes won't be unwrapped even though the attribute itself does get stripped elsewhere. Impact is low since Google Docs residue attributes are demonstrated (per the new test fixture) to land on block elements (p, h2), not spans, and the visible output difference is just an extra empty <span> wrapper, not corruption.
Non-blocking observation (out of scope — pre-existing code untouched by this PR): unwrappedSpanDepth in tidy() (formatter/app.mjs) is a plain counter rather than a stack, so a kept (attributed) <span> nested inside an unwrapped (attribute-less) <span> can have its own closing tag dropped in favor of the outer one when there's trailing content between the two closes — producing structurally different output (content ends up before the inner tag's close instead of after). Notably, the PR's author was clearly aware of this exact failure class, since the new <b>-wrapper unwrap logic (boldStack) is deliberately implemented as a stack "so a real nested <b> keeps its own closing tag instead of consuming the wrapper's" — the same fix was just never applied to the pre-existing span counter. Worth a follow-up since it's easy to fix by the same pattern, but it's not part of this PR's diff and not a blocker here.
No High/Medium correctness issues found in the changed lines. Checkbox wiring for all new Extras options (opt-strip-scripts, opt-block-newlines, opt-nested-empties, opt-docs-residue, opt-straighten-quotes) is consistent across index.html, getTidyOptions(), and TIDY_CHECKBOX_IDS.
… sites Review of 613c3e7 found that unwrapSpans' remaining-attributes check never learned the Google Docs residue rules I added to buildTidyTag, so a <span> whose only attribute was role="presentation" / aria-level / dir="ltr" kept its wrapper even though that same attribute was stripped from the emitted tag. The cause was duplication: two copies of the same drop rules, and I updated one. Extracted isDroppedAttr and pointed both at it, so they cannot drift again. Worth recording that the bug was invisible at shipped defaults — the opt-nested-empties fixpoint caught the emptied span on its second pass and hid it. It only surfaces with that Extra off, which is why the new tests assert with nestedEmpties: false rather than at defaults. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Round 3 — finding fixed in
One correction to the impact assessment, in your favor and worth recording: at shipped defaults the bug is invisible, not "an extra empty
So it only surfaces with that Extra turned off. That makes it less severe than Low in practice, but also sneakier — a fixpoint loop quietly compensating for an inconsistency elsewhere is exactly the kind of thing that stops compensating later. The five new regression tests therefore assert with On the out-of-scope Also for the record: this PR's earlier rounds went un-reviewed because |
There was a problem hiding this comment.
Review of PR #23 could not be completed before this response was required. The code-review skill forked into a multi-agent pass: one manual spot-check by the orchestrator (Google-Docs <b>-unwrap stack logic, tidy checkbox ID round-trip, saveTidyOptions/loadTidyOptions stale-key handling, replaceUntilStable convergence bound, nestedEmpties loop cost) found nothing blocking, and one of four background finder agents ("cross-file tracer") finished, also finding no breaking cross-file mismatch — only a non-blocking test-coverage gap noted (no test exercises tidy() directly with whitespace-bearing text to lock in its new passthrough behavior after whitespace-collapsing logic moved into runTidyPipeline).
However, three other required review angles — line-by-line diff scan, removed-behavior audit, and reuse/simplification/efficiency/altitude/conventions — were still running in background agents and had not reported results when this structured output was required. I was not able to run npm test/node --test in this sandbox either, so no dynamic verification of formatter/*.test.mjs occurred.
Because full coverage of the diff was not established, I cannot responsibly render approve, request_changes, or comment. No blockers have been found so far, but the review is incomplete — please re-run to allow the remaining agents to finish before treating this as a final verdict.
pr:23:test-coverage-tidy-passthrough
Low
formatter/tests/parity.test.mjs
Review of bcb7e54 noted that no test exercises tidy() directly with whitespace-bearing text. Correct: whitespace collapsing moved out of tidy()'s TEXT case into runTidyPipeline's unconditional passes (divergence C), and every existing direct tidy() assertion uses whitespace-free input, so nothing would have caught a regression on that deliberate change. Adds five: tidy() leaves space runs, newlines and inter-tag whitespace alone even with option 5 on, and the pipeline is what collapses them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Round 4 — the review timed out rather than reaching a verdict (three of its angles were still running), so re-running as it asked. Head is now It did land one finding before running out of time, and it was a real gap:
Two notes for the next pass, since the sandbox couldn't run them:
Re-triggering by cycling the |
There was a problem hiding this comment.
Reviewed the actual PR #23 diff (git diff 9c3bf6f e72f915), which is a 497-line rewrite of formatter/app.mjs toward prettyhtml.com parity (curly-quote attribute parsing, Google-Docs residue stripping, plain-text mode, stray-break normalization, a new runTidyPipeline), plus ~475 lines of new/updated tests. The work is well-documented and heavily tested, but one confirmed regression and a couple of smaller inconsistencies remain.
Must-fix:
formatter/app.mjs:1167(collapseNbspRuns) — for input like<p> </p>(a common paste "spacer paragraph": space + + space), the old code fully deleted the paragraph (it stripped all occurrences from the text token then trimmed to empty). The new pipeline only eats the entity on one side pera+' '/' '+apass, leaving two literal spaces (<p> </p>), which don't matchtidy()'s Case 1 (needs adjacent close tag), Case 2 (needs literal\n), or Case 3 (needs exact / ), and don't matchnormalizeTagGaps's"> <"pattern (needs exactly one space) either. The pair survives tidy as<p> </p>after the later" "->" "collapse inpostPassLoop, instead of being removed — verified by manual step-by-step trace throughnormalizeWhitespacePrepass→collapseNbspRuns→normalizeTagGaps→tidy()→postPassLoop→finalCleanup. No existing test (golden fixtures orparity.test.mjs) covers this padded shape; all nbsp-tag tests use the bare<p> </p>form. This is a real behavior regression in a feature this PR is specifically about (option 5 / one-space-tag cleanup).
Worth addressing, non-blocking:
formatter/app.mjs:617— the Google-Docs<b id="docs-internal-guid-…">wrapper check (idAttr.value.startsWith('docs-internal-guid')) is case-sensitive, while this same PR explicitly made the siblingrole=/dir=residue checks case-insensitive (see the dedicated test atformatter/tests/docs-residue.test.mjs"residue attribute values are matched case-insensitively"). No equivalent test/fix exists for the id prefix. Low real-world risk since Google Docs always emits lowercase ids, but it's an inconsistency in the PR's own stated design goal.formatter/app.mjs:1231(separateBlockElements) — the docstring states a close tag "starts a line only when the block has block children," but the implementation unconditionally appends a trailing\nafter every block close tag regardless of children. A shape like<div><p>a</p>tail text</div>(nested block followed by trailing inline text) ends up as...tail text</div>with no separating newline before the close, an untested case with no golden fixture.formatter/app.mjs:1236—atLineStart()'s backward scan skips empty-stringpartsentries, but nothing inseparateBlockElementsever pushes an empty string, so the skip loop is dead code (cosmetic only).
No other correctness issues found in the diff; the curly-quote attribute parsing, boldStack-based Docs <b> unwrapping, and isDroppedAttr de-duplication (fixing the span/attribute-stripping divergence bug) all check out against their tests.
Why
HTML copied out of a Google Doc came through Tidy mangled. Root cause: Docs autocorrects straight quotes to curly ones even when the text is HTML source, so
class="hero lede"arrives asclass=“hero lede”.parseAttributesread the value as unquoted, stopped at the space, and invented a bare attributelede”that no cleaner matched — so it survived into the output.Measured against the live site: prettyhtml.com returns a clean
<p>Hi</p>for the same input. Their TinyMCE mangles it too, but their classes/IDs option then deletes the wreckage. We had no such backstop.Fixing that meant auditing the whole pipeline. Reading
String(convertText)in the running page gave the entire dispatch verbatim, and it differs from ours in order and in several option boundaries.Pipeline
Tidy now runs through an exported
runTidyPipeline(html, opts), ordered to matchconvertText():"> <"join,"> \n"join and newline-only-tag removal that had been sitting behind option 4 (F)toPlainTextsubstitutes a space per tag instead of deleting it, soa<br/>b→a b, notab(D)replaceUntilStablemirrors theirhelyettesitreplace-to-idempotence, and refuses to loop on a replacement that re-creates its own patternLayer-1 compensation
Their engine is two layers: a TinyMCE DOM round-trip, then the string cleaners behind the ten checkboxes. We have no round-trip, so what it did for them now rides default-ON Extras:
opt-block-newlinesopt-nested-emptiesopt-docs-residuerole="presentation",aria-level, thedocs-internal-guid<b>wrapperopt-strip-scriptsA trailing
<br>just inside a block's closing tag is now dropped too — filler, not a line break.Curly quotes
Accepted as attribute delimiters at the tokenizer level, unconditionally — robustness, not an option.
buildTidyTagre-emits straight quotes, so every path (Tidy/Indent/Compress) is fixed. A>inside a curly-quoted value no longer truncates the tag.Prose typography is left alone; the new
opt-straighten-quotesExtra (default OFF) converts it to ASCII for anyone who wants that. Kept out of option 9 so the ten stay byte-faithful.Closes #22
dir="ltr"survives on prettyhtml.com — stripping it is deliberately better than parity. The rest is layer-1 compensation.Before:
<p dir="ltr" role="presentation"><span style="…">Hello</span></p>After:
<p>Hello</p>Verification
formatter/tests/parity.test.mjsreplays 21 black-box fixtures captured from the live site. The three where we deliberately differ carry anoursfield recording what and why — so a future reader can tell a divergence from a regression.dirstripped, literal characters instead of’-style entities, empty<span>unwrapped).npx eslint .drops from 1322 errors to 37, all pre-existing (32 inshared/shell.js, which has no browser-globals config — separate issue, untouched here).Deliberate divergences, documented in
app.mjsE options 1/2 parse attributes structurally rather than doing double-quote-only string surgery · G empty-tag removal exempts
td/th/script/media and requires matching tag names (theirs deletes<b></i>) · H one-space-tag removal accepts · N curly-quote delimitersNotes
planning/captures/; eslint now ignores it too. Their kill-switchelkur()is not ported.opt-newline-before-closecheckbox (rendered and persisted, never consumed).<pre>indentation.🤖 Generated with Claude Code