provident-editable
v0.2.0
Published
Rich-text HTML → provident tree converter + structural diff, for the provident-ssr framework. A separate, importable, framework-agnostic package.
Maintainers
Readme
provident-editable
Rich-text HTML → provident tree converter + structural diff, for the
provident-ssr framework. A separate, importable, framework-agnostic
TypeScript library: it parses the innerHTML of a contenteditable element
into a provident tree (a root node + its owned subtree) and diffs that tree
against an existing subtree. No DOM in the core (plain-tree parsing via
parse5), no network egress — pure, deterministic functions over plain data.
It is a DATA MODEL producer: it contains no renderer and imports no engine code,
and the render-order contract stays the adopter's — but the emitted shape is
DESIGNED to render faithfully under provident-ssr >=0.4.0 <1.0.0 (see
"Render boundary" below).
Adoption status
There is currently NO adopter of this package. The adjacent consumer repo
that originated the requirement (Astrographer) built an in-house equivalent
instead and never installed this package: the [email protected]
import plan was REPLACED by its own pure decomposeRichHtml module
(src/main/rich-decompose.ts), recorded as RICH-TEXT-EDITING-GATE in
../Astrographer/docs/decisions.md (see also its docs/pending.md
"contenteditable rich inline editing" row and
docs/specs/editing-mode-toggle-review.md:3 — "adopted in design but NOT
installed"; the consumer's feature request for an imported converter is
RESOLVED / SUPERSEDED 2026-09-08). There is no dependency in either
direction — this package does not import the consumer, and the consumer does
not depend on this package.
Consequence, stated plainly: the public API is stable but UNEXERCISED by any
real adopter — the contract and its tests are green, but no production
consumer validates the shape in anger, and the consumer's own model differs
where it matters (its inline children are strong/em/a/img with
offsets, and a text child is a validation fail-state for it — see the
text note below). Treat the emitted shape as this package's contract per
docs/specs/html-to-provident-tree.md, not as a proven integration.
UPGRADE NOTE — 0.2.0 is a BREAKING emitted-shape release
[email protected] (2026-09-17) adopts the provident-ssr 0.4
BARE-TEXT-EMIT mechanism and the per-node XOR rule; 0.1.0 consumers must
migrate. Three changes:
- The text-run union member was renamed
span→text— it is now the ENGINE's own text-node type, not a package-introduced stand-in (the union still has 19 members; the 5 exported types are unchanged). - A node WITH children now carries
content:''(the per-node XOR rule): a node carries EITHER its own scalarcontentOR children, never both — the engine rendersescapeText(content) + children, so carrying both duplicated the text. A block with NO inline formatting is now a LEAF carrying its own text and emitting NO run children (it used to get a singlespanchild). Consequence for diff consumers: because an EMITTED node with children storescontent:'', a plain block's text edit now reports the block only and no longer the root alongside it (see "Which node gets theupdate?" below). - A new helper,
providentPlainText(node), derives the plain text that the oldcontentfield stored — the value did not change, its LOCATION did.
Migration, concretely: rename every type:'span' node to type:'text'; set
content:'' on every node that has children; and read the plain-text projection
with providentPlainText(node) instead of node.content.
Release
- Current version:
0.2.0— release-ready and packed (provident-editable-0.2.0.tgz, 41.2 kB,dist/-only, runtime dependencyparse5). The registry's published version is still0.1.0;npm publishis a deliberate, separate step. - Engine compatibility: floor
>=0.4.0 <1.0.0(an OPTIONAL peer range of the same span). The emitted shape requires provident-ssr's bare-textchild emit, which upstream documents for>=0.4.0 <1.0.0; the engine is never a runtime dependency of the shippeddist/. - Release gate:
prepublishOnlyrunsnpm test && npm run typecheck. The engine-backed render oracle is the separatenpm run verify:engineleg, kept out of publishing on purpose. - Upgrade note:
0.2.0renames the public union memberspan→textand emitscontent:''on nodes that have children (withprovidentPlainTextas the derived projection) — see the upgrade note below.
Install
npm install provident-editablePackage identity: [email protected], type: module (ESM), main →
dist/index.js, types → dist/index.d.ts, files: ["dist"], license
AGPL-3.0. Runtime dependency: parse5 only — the engine is a test-only
devDependency ([email protected], exact) plus an optional peer range
>=0.4.0 <1.0.0; the shipped dist/ imports no engine code. See
docs/decisions.md (DECIDED: PACKAGE-IDENTITY,
DECIDED: ENGINE-COMPATIBILITY-FLOOR).
Usage
import { htmlToTree, diffTrees, providentPlainText } from 'provident-editable'
/** Parse rich-text HTML into a provident tree. Pure, deterministic, no DOM, no
* network. */
declare function htmlToTree(html: string, opts?: ConvertOptions): ProvidentTree
/** Produce a structural diff between two provident trees. Pure, deterministic. */
declare function diffTrees(prev: ProvidentTree, next: ProvidentTree): StructuralDiff
/** Derive the plain-text projection of a node's subtree (SPEC PASS H): the
* node's own scalar `content` plus every descendant's, in document order.
* Pure, deterministic, total — never throws. */
declare function providentPlainText(node: ProvidentNode): stringconst prev = htmlToTree('<p>Some <strong>bold</strong> text</p>')
// The emitted shape is PER-NODE XOR: a node with children carries content:''
// and its text runs are `text` children, in document order.
// { id:'n-1', type:'div', content:'', children:[
// { id:'n-2', type:'p', content:'', children:[
// { id:'n-3', type:'text', content:'Some ' },
// { id:'n-4', type:'strong', content:'bold' }, // collapsed leaf
// { id:'n-5', type:'text', content:' text' } ]}]}
providentPlainText(prev.root) // → 'Some bold text' (the value the old
// contract stored in the node's `content`)
const next = htmlToTree('<p>Some <strong>bold</strong> words</p>')
const diff = diffTrees(prev, next)
diff.nodeChanges.update
// → [ { prevId: 'n-5', nextId: 'n-5', content: ' words' } ]
// exactly ONE update — the EDITED RUN NODE.Which node gets the update? An ancestor is reported only when the
ancestor's OWN scalar content changes — not merely because a descendant
changed. So an edit inside an INTERLEAVED block (a block with inline formatting,
whose content is '') reports the run node only, and an edit to a PLAIN
block (which stores its own text and is a childless LEAF) reports the block
only — NO ancestor update, because an emitted root with children also stores
content:'' on both sides. (A caller-built tree whose root carries its OWN text
does report the root as well — that two-update case is pinned separately, spec
§5.8 state 56.) A consumer typically applies the reported updates and
re-traverses.
htmlToTree returns a ProvidentTree — { root: ProvidentNode }, the root is
always a div, and nodes carry id/type/content/props/children
(ids are minted deterministically in pre-order: ${idPrefix}-${n}, default
idPrefix 'n'; every node counts, including a text run). diffTrees
returns a StructuralDiff —
{ nodeChanges: { add, remove, update }, edgeChanges: { add, remove } }:
add/remove are subtree-granular, update is field-granular
({ prevId, nextId, type?, content?, props? }). providentPlainText(node)
returns the derived string projection. All three signatures, return shapes
and throw patterns are the contract in docs/specs/html-to-provident-tree.md
§5.1/§5.2.
text is the engine's own text-run type
A text run (e.g. the "Some " and " text" around a <strong>bold</strong>)
is emitted as a type:'text' child node — the provident-ssr framework's
OWN text-node type, which the engine renders bare (escaped literal text, no
wrapper, positioned by childOrder) since engine 0.4.0. The former
package-introduced span stand-in, its drift note and its upstream
feature-request framing are gone (the rename landed in 0.2.0; see the
UPGRADE NOTE above).
Two consequences worth knowing:
- A
textnode is a CONTENT-ONLY LEAF — nochildren, noprops. The engine enforces content-only by filtering silently at emit (itstextPROP is the only thing emitted; acss:*/prop:*/on:*/data:mapping on atextnode never reaches the adapter and no warning fires at emit — the engine'stext-content-onlywarning is TRANSLATE-time and legacy-JSON-only), and the converter never emits any. textruns are ORDINARY diff-visible nodes: a run can be added/removed (subtree-granular) and a run's text edit is reported as anupdateON THE RUN. A runupdatecarries the converter's minted id, and atextchild has no rendered element identity upstream — so an adopter maps a runupdateonto its own materialized tree, not onto a rendered element.
The upstream contract for this emit — SETTLED (UR-1, landed 2026-09-22;
residuals cleared 2026-09-23). The bare-text emit BEHAVIOUR shipped in engine
0.4.0; its CONTRACT is now written into the engine's own behaviour specs,
version-scoped >=0.4.0 <1.0.0 with the 1.0.0 revisit named
(../Preempt-Providence/docs/specs/render.md §3.2b — the owning home;
docs/specs/adapters.md §3.1/§3.2/§4.1/§4.3/§4.4/§6; docs/specs/node.md §2.1),
and the five upstream docs that used to deny it carry SUPERSEDED/AMENDED
annotations. That was our upstream request UR-1 (docs/upstream-request.md,
which carries the dated RESPONSE/STATUS section; adjudicated in
../Preempt-Providence/docs/specs/ur-2026-09-17-review.md), and it CLOSED the
ENG-GAP-HTML-1 release-evidence blocker (docs/defects.md). The behaviour is
ALSO verified here by an engine oracle executed on the exact
[email protected] — so the floor is stated in its two halves (CONTRACT
documented + EXECUTED on 0.5.1), and no part of our release evidence is
blocked; see "Render boundary" below.
Render boundary
This package emits a DATA MODEL. htmlToTree returns a plain-data provident
tree; it contains no renderer, imports no engine code in its shipped dist/, and
has no option/type that produces a render encoding. The render-order contract
belongs to the adopter — the package makes no promise about how an adopter's
own transformation renders (DECIDED: ENGINE-RENDER-CONTRACT, docs/decisions.md;
full contract: docs/specs/html-to-provident-tree.md §5.7 C1-C6).
What the package DOES now promise (2026-09-17): the DEFAULT emitted shape
renders faithfully on the probed surfaces, because a text child is emitted
BARE and positioned by childOrder, and because a parent with children carries
content:'' (the per-node XOR rule — no duplication):
input HTML <p>Some <strong>bold</strong> text</p>
emitted tree div{content:'', children:[p{content:'', children:[text('Some '), strong('bold'), text(' text')]}]}
rendered output Some <strong>bold</strong> text — once, in order, no wrapperDeclared engine floor: provident-ssr >=0.4.0 <1.0.0
(DECIDED: ENGINE-COMPATIBILITY-FLOOR). The <1.0.0 upper bound is the
announced 1.0.0 XOR major, which CHANGES this shape's render — and the direction
is favourable: the adopted per-node XOR shape already moves toward "children
win" and emits no bodyRuns (docs/defects.md ENG-1.0.0-XOR-MAJOR — the
package's exit-plan trigger). That upper bound now also matches a versioned
upstream ruling: the bodyRuns removability ruling (branch (b)) makes the
1.0.0 major the point at which upstream REMOVES bodyRuns, so the declared peer
range and the upstream removal point are the same event.
Evidence, honestly labelled (the floor's two halves as of 2026-09-23):
- CONTRACT (the
0.4.0lower bound) — DOCUMENTED UPSTREAM: the emit rule is written into the engine's own BEHAVIOUR specs, version-scoped>=0.4.0 <1.0.0with the 1.0.0 revisit named (../Preempt-Providence/docs/specs/render.md§3.2b — the owning home,BT-EMIT-1..7;docs/specs/adapters.md§3.1/§3.2/§4.1/§4.3/§4.4/§6 + its §10.1/§10.2 tables;docs/specs/node.md§2.1), and the five denial sites are annotated SUPERSEDED/AMENDED. This is UR-1 landed —ENG-GAP-HTML-1is RESOLVED and the former release-evidence blocker is CLEARED (docs/defects.md;docs/upstream-request.md§"RESPONSE / STATUS (2026-09-23)"). The residual: the lower bound has no EXECUTED 0.4.0 run (we never installed 0.4.0). - EXECUTED on
[email protected]: the engine render oracle + a recorded render fixture cover the canonical interleaved case, animginside a run, a plain leaf block beside an interleaved block, apreleaf, and the duplicated-text control (the same shape with the parent'scontentretained renders the text TWICE). The oracle lives behindverify:engine, is SKIPPED explicitly when the engine devDependency is absent, and is kept OUT ofprepublishOnly— publishing never needs the engine. The oracle asserts the bare-text emit + document order (not a version string), so its render assertions pass UNCHANGED across0.5.0→0.5.1. - NOT PROBED (the two remaining limitations — UNCHANGED by the
0.5.1landing): the placement-routed / path-state root class, and the markdown / lineMap adapter. Both are documented limitations in spec §5.7 C6 and DEFERRED rows indocs/pending.md. (0.5.1's warn-and-drop diagnostic makes a dropped{child}run observable for adopters who authorbodyRuns— we author none, and it probes neither of these surfaces.) - PROHIBITION: no test outside the engine oracle may assert rendered output.
There is no
renderCarrieroption —ConvertOptionsis exactly{ idPrefix?: string };bodyRunsis out of scope entirely (the package emits no{child}runs), and the former FOLD/CONVERT adopter strategies described the superseded shape (they are the OLD-SHAPE UPGRADE NOTE in spec §5.7 C4).
Development
npm test # vitest — full suite: 304 tests across 7 files
npm run typecheck # tsc --noEmit
npm run build # tsc -p tsconfig.build.json — emits dist/
npm run test:engine # the engine render oracle only (needs [email protected])
npm run verify:engine # npm run build && npm run test:engine — the full engine legThe verify:engine leg (2026-09-17; engine pin updated 2026-09-23): it renders
the emitted per-node-XOR
shape with the real engine and asserts document order + single occurrence per
run, against a recorded render fixture. It requires the test-only exact
devDependency [email protected], SKIPS explicitly when that dependency is
absent (an explicit skip, never a silent pass), and is deliberately NOT part of
prepublishOnly — publishing never needs the engine, and the shipped dist/
imports no engine code. This is the test leg that backs the engine floor
>=0.4.0 <1.0.0 (spec §5.7 C3; DECIDED: ENGINE-COMPATIBILITY-FLOOR); no other
test may assert rendered output. Evidence strength, honestly stated — the floor's
TWO HALVES, re-stated 2026-09-23: the emit CONTRACT is documented upstream,
version-scoped >=0.4.0 <1.0.0 (UR-1 landed — ENG-GAP-HTML-1 RESOLVED), and
the oracle and the recorded fixture are EXECUTED on the installed
[email protected] (38/38 at this revision; the oracle is
engine-version-agnostic by design — it asserts the bare-text emit and document
order, not a version string — so the render assertions pass UNCHANGED from
0.5.0), so the 0.4.0 half is contract-backed and the executed half is on
0.5.1. The one residual: no EXECUTED 0.4.0 run exists (we never installed
0.4.0; its 0.4.1/0.5.0 tag/date provenance is still UNVERIFIED, while
0.5.1 and 0.4.0 both resolve — docs/HANDOFF.md compatibility matrix).
Blind greens verification (needs a current dist/, so run npm run build
first — dist/ is gitignored): node scripts/blind-greens.mjs →
PASS 313 / FAIL 0 = 313/313 assertion records over 100 documented scenario
records (groups A 36 converter + B 19 diff + C 32 contract + D 2 projection +
E 11 trim-endpoint/discriminator/helper-domain); the assertion records split
A 52 / B 40 / C 114 / D 43 / E 64.
Test-count note (re-measured 2026-09-17 after the 0.2.0 XOR redirect, at HEAD
ff29dbd; re-measured again 2026-09-23 after the concurrent test/fixture
re-certification pass landed): the
suite is green — 304 passed / 304 across 7 files, with npm run typecheck
clean, npm run build emitting an engine-free dist/, and the separate engine
leg npm run test:engine at 38/38. Every earlier figure (69, 113, 151,
179, 181, 182, 211, 281, 302) is SUPERSEDED.
The per-file split — **74 tests/html-to-tree.test.ts + 114 tests/diff.test.ts
- 6
tests/live-edit.test.ts+ 25tests/plain-text.test.ts+ 35tests/engine-render-oracle.test.ts+ 34tests/properties.test.ts+ 16tests/html-to-tree-trim-red.test.ts** (summing to exactly 304) — was measured by counting the leading-whitespaceit(declarations per file. The oracle file moved33→35and the total302→304when the 2026-09-23 re-certification pass added the two fixture-provenance assertions (S8bthe fixture's 0.5.1 identity + pin equality,S8cthepreviousPinsuperseded history) and renamed the fixture totests/fixtures/provident-ssr-0.5.1-render.json; no other file's count moved:
grep -c -E '^[[:space:]]*it\(' tests/*.test.ts # per-file shape (see note)(The bare it( pattern overcounts — it also matches describe( lines and one
it(-bearing test name — so the anchored ^[[:space:]]*it\( form is the figure
to use. A file can additionally run generated cases inside loops, so the anchored
grep describes the per-file SHAPE: the authoritative total is the one
npx vitest run reports, and at this revision it is 304 across 7 files —
which the per-file list above happens to match exactly.)
tests/properties.test.ts is the §5.12 property layer (the PBT gate):
fast-check ^4.10.1 as a devDependency, one pinned seed 20260917, 9
register rows at ~44 attempts per row / ≤400 total, stop-after-5 failures,
all 9 rows HELD (P-SM1's three clauses, the P-SM4 projection row, and
the edge-run / own-text / deep-chain generator strategies). It carries 13 it() blocks — the per-row properties (with the
GEN-HTML-BASIC/GEN-HTML-INLINE/GEN-IDS-HOSTILE trio, the JSON-props pair,
GEN-PROPS-CYCLIC, GEN-TREE-WELLFORMED and the projection algebra), the U2b
GEN-HTML-EDGE-RUN matrix, the GEN-DEPTH-BOUNDARY 512/513 boundary, plus a
verdict-summary it(), a dist-freshness guard and a generator
self-certification (anti-vacuity) it() (the anchored grep
counts this file at 34 because several of those it() blocks run generated
cases inside loops). The pre-0.2.0 executed record — 8 rows, 400/400
attempts at 50/row — is HISTORICAL: with the register's ceiling at 9 rows
(P-SM1 re-derived to the per-node-XOR three-clause form + the NEW P-SM4) the
allocation falls to ≈44/row to keep the ≤400 total (spec §5.12).
The behavior contract is docs/specs/html-to-provident-tree.md (its §5.10
census banner is the authoritative source for every count above); the
blind-test
greens are docs/specs/html-to-provident-tree-greens.md; the active trackers
are docs/decisions.md, docs/next-steps.md, docs/defects.md,
docs/pending.md, docs/HANDOFF.md.
