Skip to content

feat: add htmlToHast for parsing HTML into HAST (#60)#140

Merged
Princesseuh merged 13 commits into
bruits:mainfrom
IEvangelist:dapine/hast-from-html
Jul 21, 2026
Merged

feat: add htmlToHast for parsing HTML into HAST (#60)#140
Princesseuh merged 13 commits into
bruits:mainfrom
IEvangelist:dapine/hast-from-html

Conversation

@IEvangelist

Copy link
Copy Markdown
Contributor

Summary

Prototype for #60 — a built-in HTML → HAST parser, the Sätteri equivalent of hast-util-from-html / rehype-raw's parsing step.

Adds htmlToHast(html: string): HastNode to the npm package (and create_hast_handle_from_html at the NAPI boundary). It parses an HTML string into a materialized HAST tree — elements, text, comments, and doctype — using html5ever's spec-compliant tree builder, in document mode (result is a root wrapping the implied <html> subtree, matching hast-util-from-html's default).

import { htmlToHast } from "satteri";

const tree = htmlToHast("<p>hi</p>");
// { type: "root", children: [{ type: "element", tagName: "html", ... }] }

Approach

Follows the parser recommendation from my benchmark writeup on the issue: #60 (comment)html5ever driving a custom, arena-friendly TreeSink rather than pulling in rcdom.

  • crates/satteri-ast/src/hast/from_html.rs: an index-addressed TreeSink (Handle = usize into a Vec<Node>) that mirrors rcdom's tree-mutation semantics (append + text coalescing, foster parenting, adoption-agency reparenting, add_attrs_if_missing, template contents). After parsing, the flat node list is walked once into an ArenaBuilder<Hast>, reusing the existing HAST codec so serialize/materialize/render all work unchanged.
  • Attributes are stored verbatim as string properties; the existing renderer and materializer pass them straight through, so class/href/etc. round-trip.

Feature gating

  • from-html is opt-in on satteri-ast (not in its default features) so size-conscious consumers can drop html5ever entirely.
  • The NAPI binding enables it by default (like mdx): the full native build ships it, and lite --no-default-features builds drop it.

Happy to flip this either way — e.g. make it fully opt-in on the binding too — depending on how you'd like to weigh the binary-size cost.

Tests

  • Unit (Rust): 11 #[test]s in from_html.rs covering document wrapping, attributes, text/comment/doctype emission, character references, raw-text elements, foster parenting, and adoption-agency reparenting.
  • End-to-end (vitest): packages/satteri/test/from-html.test.ts — asserts the materialized tree shape (root → html/head/body, tag names, text/comment/doctype, verbatim properties) and a full round-trip through the unified + rehype-stringify ecosystem, which is the actual interop story from Add a built-in way to get HAST from HTML #60.

Known follow-ups (deliberately out of scope for this prototype)

  • Property-information normalization — properties are kept verbatim (class, not className: [...]; no boolean/number coercion). Full property-information handling (space/comma-separated lists, booleans, SVG casing) would be the natural next step for exact hast-util-from-html parity.
  • Boolean / namespaced attributes — e.g. disabled currently renders as disabled=""; SVG/xmlns prefixes aren't specialized yet.
  • Fragment mode — only document mode is implemented; a fragment entry point (hast-util-from-html's fragment: true) could be added later.

A minor Sampo changeset is included.

Refs #60

@Princesseuh Princesseuh added this to the 0.10.0 milestone Jul 1, 2026
@codspeed-hq

codspeed-hq Bot commented Jul 1, 2026

Copy link
Copy Markdown

Merging this PR will not alter performance

✅ 30 untouched benchmarks
⏩ 10 skipped benchmarks1


Comparing IEvangelist:dapine/hast-from-html (8f1dfb9) with main (63fbb77)

Open in CodSpeed

Footnotes

  1. 10 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports.

@Princesseuh

Copy link
Copy Markdown
Member

No need to keep this updated, I'll be taking it over very soon. I have some changes for it in local. It overalls looks good!

@IEvangelist

Copy link
Copy Markdown
Contributor Author

I'll be taking it over very soon.

Thanks for letting me know! I was a little surprised, since I didn’t realize you already had related changes in progress. When you say you'll be "taking it over," could you clarify what you mean? Should I pause work on this PR, or are you planning to incorporate parts of it into your own changes? I just want to make sure I understand.

@Princesseuh

Copy link
Copy Markdown
Member

I'll just push a few things to your branch, I'm currently travelling so I'm a bit offline. I currently have some changes committed in local that I just haven't pushed yet.

Of course, your commits are still part of it and you'll get the appropriate credits both in the changelog and in the commit log.

@IEvangelist

Copy link
Copy Markdown
Contributor Author

@Princesseuh — nice work on the rawHtml feature and the property-info port, this is a great addition. One conformance gap I want to flag in coerce_value (crates/satteri-ast/src/hast/from_html.rs:612) before this lands.

The boolean coercion diverges from hast-util-from-html. Its rule (via hastscript's parsePrimitive) coerces a boolean/overloaded-boolean attribute to true only when the value is empty or equals the property name case-insensitively — otherwise it keeps the string:

if ((info.boolean || info.overloadedBoolean) &&
    (value === '' || normalize(value) === normalize(name))) return true
return value

Our current arms are:

PropKind::Boolean => (PROP_BOOL_TRUE, StringRef::empty()),
PropKind::OverloadedBoolean if value.is_empty() => (PROP_BOOL_TRUE, StringRef::empty()),

So two cases diverge from rehype-raw:

  1. Plain boolean always returns true, ignoring the value:
    • <input disabled="false"> → we emit disabled: true; reference keeps disabled: "false"
    • <input checked="0"> → we emit true; reference keeps "0"
  2. Overloaded boolean only handles the empty case, missing value-equals-name:
    • <a download="download"> → we emit download: "download"; reference gives download: true
    • <div hidden="hidden"> → we emit "hidden"; reference gives true

The XHTML-style x="x" and empty forms already match both ways — only non-empty, non-name values differ. The current raw-html-conformance.test.ts misses this because its boolean cases all use empty values.

Suggested fix: pass the resolved property name into coerce_value and, in the Boolean/OverloadedBoolean arms, return true only when value.is_empty() || value.eq_ignore_ascii_case(name), else fall through to PROP_STRING. Happy to push that + a conformance case if you'd like.

(Verified empirically against hastscript's h(); the divergence persists into the materialized JS tree.)

@Princesseuh
Princesseuh force-pushed the dapine/hast-from-html branch from 90df983 to 35c1ca3 Compare July 20, 2026 20:20
IEvangelist and others added 12 commits July 21, 2026 00:20
Adds an html5ever-backed parser that turns an HTML string into a HAST tree (elements, text, comments, doctype), mirroring hast-util-from-html in document mode. Exposed as `htmlToHast` from the npm package and `create_hast_handle_from_html` at the NAPI boundary.

The Rust parser lives behind an opt-in `from-html` feature on satteri-ast; the NAPI binding enables it by default (like `mdx`) so the full build ships it and lite (--no-default-features) builds drop it.

Refs bruits#60

Co-authored-by: Copilot App <[email protected]>
Co-authored-by: Copilot App <[email protected]>

Copilot-Session: 11c16117-e051-412e-be36-084082adeb2c
…owed ones

Stitch markers now carry a per-reparse random nonce and are claimed by the
tree sink as they parse, so document content can neither forge nor collide
with them. Markers the parser swallows as text (unclosed raw-text element,
split tag, unterminated comment) are detected and scrubbed from the output
instead of leaking. Also emits arena subtrees in a single pass.
…onversion

The reparse moves from the hast-handle constructor into ConvertOptions and
runs as the final conversion step, so the markdownToHtml fast path, the
plugin pipelines, and the *ToHast entry points all reparse identically and
hast plugins always see reparsed elements.
…in rawHtml

Four conformance fixes surfaced by a differential audit against
remark-rehype + rehype-raw, all now locked in by conformance cases:

- boolean attributes coerce to true only when the value is empty or
  repeats the attribute name case-insensitively (disabled="false" stays
  a string, download="download" becomes true)
- numeric coercion follows JavaScript Number() acceptance: hex/octal/
  binary literals and exactly-spelled Infinity coerce; inf/NaN/units stay
  strings
- comma-separated lists keep interior empty items and drop only a
  trailing one
- coords items materialize as numbers via a new PROP_COMMA_SEP_NUM wire
  kind (coords was also misclassed as a plain number in the tables)
- raw fragments parse in a template context, the most permissive mode,
  so table parts and other context-sensitive elements survive outside
  their usual parents
@Princesseuh
Princesseuh force-pushed the dapine/hast-from-html branch from cc1266a to 6ee0462 Compare July 20, 2026 22:22

@Princesseuh Princesseuh left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Looks great, thank you!

@Princesseuh
Princesseuh merged commit d8639d6 into bruits:main Jul 21, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

2 participants