@markup-carve/carve-wasm 0.1.2 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -27,9 +27,29 @@ import policy and canonical writer as carve-rs. Modes are `safe` (default),
27
27
  const { value, report } = htmlToCarve('<p>Hello <strong>world</strong></p>', 'safe')
28
28
  ```
29
29
 
30
+ `htmlToAst(html, mode)` is the same importer returning the TREE instead of the
31
+ source, so a host that wants a tree does not re-parse what the importer just
32
+ wrote. It returns the same `{ value, report }` shape, with `value` holding AST
33
+ JSON.
34
+
35
+ ```js
36
+ const { value, report } = htmlToAst('<p>Hello <b>world</b></p>', 'safe')
37
+ astJsonToHtml(value)
38
+ ```
39
+
40
+ > **The report is not always identical, and the difference is the point.**
41
+ > A loss only a WRITER takes is not reported by `htmlToAst`, because no writer
42
+ > ran (PART 12 §16). A `<figure>` wrapping a table, or a table with an explicit
43
+ > head/body/foot grouping, has no Carve spelling: `htmlToCarve` reports
44
+ > `structure-unspellable` for it and `htmlToAst` does not, because the tree
45
+ > keeps the thing the source would have lost.
46
+
30
47
  Portable migration code can use `fromHtml(html, mode)` and
31
- `fromMarkdown(markdown)`. Both return `{ value, report }`; Markdown carries an
32
- empty diagnostics list because the engine migrates the document whole.
48
+ `fromMarkdown(markdown)`. Both return `{ value, report }`. Version 2 reports use
49
+ the shared fidelity vocabulary. Markdown, Djot, and BBCode conservatively emit
50
+ `fidelity-unverified` as `dropped` / `fallback` until their importers expose
51
+ construct-level outcomes; an empty diagnostic list is therefore never used to
52
+ imply fidelity that was not assessed.
33
53
 
34
54
  ### Core renderer
35
55
 
@@ -42,12 +62,27 @@ call.
42
62
  ```js
43
63
  import { toHtml } from '@markup-carve/carve-wasm'
44
64
 
45
- // The other core targets are toMarkdown, toPlainText, toAnsi, and toCarve.
65
+ // The other core targets are toMarkdown, toPlainText, toAnsi, and toCarve,
66
+ // each with a *WithOptions form that takes the options object below.
46
67
 
47
68
  const html = toHtml('# Hello, Carve!')
48
69
  document.body.innerHTML = html
49
70
  ```
50
71
 
72
+ ### Source-preserving patches
73
+
74
+ Tools can prepare canonical formatting as stale-safe UTF-8 byte edits:
75
+
76
+ ```js
77
+ const patch = toCarvePatch(source)
78
+ const formatted = applySourcePatch(source, patch)
79
+ ```
80
+
81
+ `createSourcePatch(source, replacement, kind, code)` prepares the same wire
82
+ shape for another complete replacement. Its ranges are UTF-8 byte offsets. The
83
+ fingerprint and byte length catch accidental staleness; they are not a
84
+ cryptographic signature. Treat a patch as trusted edit instructions.
85
+
51
86
  ### Extensions
52
87
 
53
88
  `extensions()` reports every extension this build accepts. The list comes from
@@ -151,12 +186,17 @@ defaults, so the three shorthands above remain the zero-config forms.
151
186
  | `smartTypography` | `glyph` | `source` keeps the author's run instead of the glyph |
152
187
  | `lowercaseHeadingIds` | `false` | Lowercase the generated heading ids |
153
188
  | `asciiHeadingIds` | `off` | `fold` transliterates, `strict` guarantees ASCII |
189
+ | `mentionUrl` | none | URL template for `@mention`; `{name}` / `{user}` take the encoded name |
190
+ | `tagUrl` | none | URL template for `#tag`; `{name}` takes the encoded name |
154
191
 
155
192
  An unrecognized key is ignored, because the object is configuration and a typo
156
193
  should not break a render. A recognized key with the wrong type throws a
157
194
  `TypeError` instead of being coerced: JS truthiness would read
158
195
  `{ sections: 'false' }` as `true`, the opposite of what was written.
159
196
 
197
+ `renderers` is the one recognized key `toHtmlWithOptions` refuses; it belongs to
198
+ [`toHtmlWithRenderers`](#static-renderers).
199
+
160
200
  This exists for a host whose CSS or JS assumes rendered blocks are direct
161
201
  children of the content container - the `.stack > * + *` spacing idiom,
162
202
  `:first-child`, `nth-child()` counting, `element.children` walks - all of which
@@ -212,6 +252,131 @@ try {
212
252
  }
213
253
  ```
214
254
 
255
+ **Every target takes the profile, not only HTML.** `toMarkdownWithOptions`,
256
+ `toPlainTextWithOptions`, `toAnsiWithOptions`, `toCarveWithOptions` and
257
+ `parseJsonWithOptions` read the same options object, so a document held to a
258
+ profile on its way to HTML is held to it on its way to Markdown or into a stored
259
+ tree. Without them a host could export the same untrusted document unfiltered.
260
+
261
+ ```js
262
+ toMarkdownWithOptions(fromTheReader, { profile: 'comment' })
263
+ ```
264
+
265
+ What those targets actually read is narrower than HTML's list: `profile` and
266
+ `smartTypography` change their output, extensions run, and `symbols`, `labels`,
267
+ `sections`, `sourceLine`, `mode` and the heading-id switches are HTML-side
268
+ concerns the engine's other renderers do not consult. `renderers` is refused
269
+ there as it is on `toHtmlWithOptions`.
270
+
271
+ `toCarveWithOptions` is narrower again and reads `profile` alone. The canonical
272
+ writer is parse-only by contract, so extensions and `smartTypography` are inert
273
+ there; they are accepted rather than refused so that one options object can be
274
+ handed to every target.
275
+
276
+ `applyProfile` runs the same filter over an AST-JSON document and hands back the
277
+ filtered tree instead of HTML, for a host that wants to store, diff or re-render
278
+ what the filter left. `violations` reports what it degraded or stripped, which
279
+ the HTML path discards.
280
+
281
+ ```js
282
+ const { json, violations } = applyProfile(parseJson(fromTheReader), 'comment')
283
+ if (violations.length > 0) tell(violations.map((v) => v.message))
284
+ const html = astJsonToHtml(json)
285
+ ```
286
+
287
+ It reads `profileBaseHost` and `smartTypography` from its options object and
288
+ nothing else. It does **not** enforce the profile's `max_length`, which the
289
+ engine applies to the SOURCE bytes before a parse - a host filtering untrusted
290
+ input still needs that bound on the way in.
291
+
292
+ ### Static renderers
293
+
294
+ `mode: 'static'` renders the self-contained form, which carries no client
295
+ scripts - so a formula in the document has to be typeset while the HTML is being
296
+ written, by the host. `toHtmlWithRenderers(source, options)` takes the same
297
+ options object plus a `renderers.math` callback and returns
298
+ `{ html, rendererErrors }`.
299
+
300
+ ```js
301
+ import katex from 'katex'
302
+ import { toHtmlWithRenderers } from '@markup-carve/carve-wasm'
303
+
304
+ const { html, rendererErrors } = toHtmlWithRenderers(source, {
305
+ mode: 'static',
306
+ extensions: ['math-block'],
307
+ renderers: {
308
+ math: (tex, display) => katex.renderToString(tex, { displayMode: display }),
309
+ },
310
+ })
311
+ ```
312
+
313
+ The callback is called once per ` ```math ` fence, with the TeX source and a
314
+ display flag. Without it, static output keeps the `\[…\]` source for a client to
315
+ typeset later - never blank.
316
+
317
+ > **Security: what a renderer returns is TRUSTED RAW HTML.**
318
+ > The string is inserted **unescaped**, the same trust class as a `symbols`
319
+ > value. There is one difference worth stating: a symbol value is host
320
+ > configuration keyed by a **name**, while a renderer is host configuration that
321
+ > is **handed document content** and typically echoes some of it back. A host
322
+ > rendering documents it did not author is accepting whatever its renderer makes
323
+ > of that input, so the escaping is the renderer's job.
324
+
325
+ **The callback must be synchronous.** wasm-bindgen cannot await across it, so an
326
+ `async` renderer returns a Promise the engine has no way to resolve. That is
327
+ reported as a failure rather than stringified into the document.
328
+
329
+ `renderers.diagrams` is the same callback one level down, keyed by the fence's
330
+ css class:
331
+
332
+ ```js
333
+ const { html, rendererErrors } = toHtmlWithRenderers(source, {
334
+ mode: 'static',
335
+ extensions: ['fenced-render', 'fenced-render-graphviz'],
336
+ renderers: {
337
+ diagrams: {
338
+ mermaid: (source) => prerendered.get(source),
339
+ graphviz: (source) => dotToSvg(source),
340
+ },
341
+ },
342
+ })
343
+ ```
344
+
345
+ Mermaid itself cannot be passed here: its `render` returns a Promise from v10
346
+ on. What a browser host can do is render its diagrams beforehand, by whatever
347
+ async means it likes, and pass the lookup as the one-line callback above.
348
+
349
+ A fence whose class nobody configured degrades to an escaped source block, and
350
+ that is reported by nothing - the callback is never called, so the binding
351
+ cannot see the miss. A configured key the document never uses is silent for the
352
+ same reason. Keys are checked for shape only: an unknown one is accepted,
353
+ because validating it would mean keeping the list of fence classes by hand here
354
+ until [markup-carve/carve-rs#1670](https://github.com/markup-carve/carve-rs/issues/1670)
355
+ puts the class on the registry entry.
356
+
357
+ A callback that throws, or returns anything but a string, does not abort the
358
+ render. The node it was called for emits nothing and the failure is reported:
359
+
360
+ ```js
361
+ const { html, rendererErrors } = toHtmlWithRenderers(source, {
362
+ mode: 'static',
363
+ extensions: ['math-block'],
364
+ renderers: { math: () => { throw new Error('bad TeX') } },
365
+ })
366
+ // rendererErrors: [{ renderer: 'math', display: true, source: 'E = mc^2', message: '…bad TeX' }]
367
+ ```
368
+
369
+ A diagram failure is an entry in the same array, with `renderer` naming the css
370
+ class and no `display` - that flag is the math callback's second argument.
371
+
372
+ That is why this is a separate entry point: `toHtmlWithOptions` returns a bare
373
+ string with nowhere to report a failing callback, and a silently empty figure is
374
+ the degradation `lintCarve` exists to warn about. Every other check is at READ
375
+ time, matching the rest of the options object - a non-callable `math`, a
376
+ non-object `renderers` and a non-callable `diagrams` value all throw a
377
+ `TypeError` before the render starts, so a misconfigured host finds out without
378
+ needing a document that happens to contain a formula.
379
+
215
380
  ### Editing a tree, and reading one back
216
381
 
217
382
  `parseJson` serializes a document out. `astJsonToHtml` renders one back, and
@@ -225,15 +390,234 @@ const html = astJsonToHtml(JSON.stringify(tree), { full: true })
225
390
  const carve = astJsonToCarve(JSON.stringify(tree))
226
391
  ```
227
392
 
393
+ `astJsonToMarkdown`, `astJsonToPlainText` and `astJsonToAnsi` are the same seam
394
+ for the other targets. Each takes the options object, and the profile filter and
395
+ the `before_render` hooks run as they do on the HTML path. Without them a host
396
+ holding a tree reached Markdown through `astJsonToCarve` and `toMarkdown`: a
397
+ canonical write, a re-parse and a second render for one answer.
398
+
228
399
  A tree carrying something no Carve source can spell is refused by
229
400
  `astJsonToCarve` rather than written approximately, and an invalid tree throws
230
401
  from either.
231
402
 
403
+ Ordering follows the tree. Section 7 orders collected definitions by source
404
+ position, so a tree carrying spans prints its footnote and link definitions in
405
+ source order and a tree carrying none prints them in label order.
406
+ `toMarkdown` parses with positions on and always gets the first; these entry
407
+ points get whatever their producer built.
408
+
232
409
  `lintCarve` returns the degradation diagnostics as
233
410
  `{ line, column, rule, message, start, end }`, with the rule ids carve-js and
234
411
  carve-php use for the same triggers. Offsets are BYTE offsets into the source,
235
412
  matching the engine.
236
413
 
414
+ ### Include expansion
415
+
416
+ `expandIncludes(source, options)` runs the `{{ path }}` pass (spec PART 9 §19)
417
+ over a document and hands back the expanded tree as AST JSON, alongside the
418
+ warnings and the dependency list a host needs for reporting and file watching.
419
+
420
+ ```js
421
+ import { expandIncludes, astJsonToHtml } from '@markup-carve/carve-wasm'
422
+
423
+ const files = new Map([['child.crv', 'Included body.\n']])
424
+
425
+ const { json, warnings, dependencies } = expandIncludes(source, {
426
+ resolve: (path, ctx) => files.get(path) ?? null,
427
+ })
428
+ document.body.innerHTML = astJsonToHtml(json)
429
+ ```
430
+
431
+ **The resolver has to be synchronous, and that decides who can use this.** A
432
+ browser host that would resolve a path over the network cannot: `fetch` returns
433
+ a Promise and wasm-bindgen cannot await across the call. A host whose files are
434
+ already in memory can - an editor with its open buffers, a bundler, a VFS, a
435
+ test harness. An `async` resolver is not silently ignored: the Promise is
436
+ reported in `resolverErrors` and the directive stays literal, the same way an
437
+ `async` `renderers.math` is handled.
438
+
439
+ `resolve` returns the child's source as a string, or `{ source, id }` when it
440
+ can name the file canonically. **Supply the id where you have one** - it is what
441
+ the cycle guard compares, so two spellings of one file (`b.crv`, `./b.crv`)
442
+ defeat it without one and only the depth limit stops the recursion. `null`
443
+ refuses the directive as `not-found`, and `{ denial }` refuses it in one of the
444
+ classes §19 names: `outside-root`, `not-found`, `no-root`, `include-denied`,
445
+ `include-unresolved`. The class reaches the caller on the dependency.
446
+
447
+ `ctx` carries `{ sourcePath, stack, depth }`. `stack` is the include chain, root
448
+ first, and its last entry is the file containing the directive - which is what
449
+ relative resolution keys off.
450
+
451
+ | Field | Default | What it does |
452
+ |---|---|---|
453
+ | `resolve` | required | `(path, ctx) => source \| { source, id } \| { denial } \| null` |
454
+ | `sourcePath` | none | Identity of the root document, for cycle detection and warning attribution |
455
+ | `extensions` | none | Registry names to parse each CHILD with; pass the set the parent was parsed with |
456
+ | `maxDepth` | `16` | Transitive include depth |
457
+ | `maxBytes` | `max(1 MB, 8 x source bytes)` | Expanded-source byte budget |
458
+ | `maxResolverCalls` | `1000` | Resolver calls for one expansion |
459
+ | `maxWarnings` | `100` | Warnings retained; one per distinct rule always survives |
460
+
461
+ The budgets are part of the contract rather than a detail. They bound what a
462
+ document of directives can make a host do, and a host serving documents it did
463
+ not write is the one that should lower them.
464
+
465
+ The result is `{ json, warnings, suppressedWarnings, dependencies,
466
+ chargedBytes, resolverErrors }`. `dependencies` lists every target touched,
467
+ including the ones that did not resolve: a host watching only the files it read
468
+ would never learn that a missing target now exists, so the preview would stay
469
+ stale at the moment the author fixes it. `chargedBytes` is what the budget was
470
+ charged, which is otherwise unobservable from outside.
471
+
472
+ The tree carries no positions. Expansion merges nodes from several files, and a
473
+ span on a node that came from a child would point into a source the caller
474
+ never passed.
475
+ ### Re-parsing while someone types
476
+
477
+ `parseSnapshot(source)` parses a document and keeps what a later `reparse` needs.
478
+ `reparse(source, changes)` applies a batch of edits and parses the result. Both
479
+ return one JSON string.
480
+
481
+ ```js
482
+ import { parseSnapshot, reparse } from '@markup-carve/carve-wasm'
483
+
484
+ const first = JSON.parse(parseSnapshot('# One\n'))
485
+ const next = JSON.parse(reparse(first.source, JSON.stringify([
486
+ { range: [2, 5], replacement: 'Two' },
487
+ ])))
488
+ next.source // '# Two\n'
489
+ ```
490
+
491
+ Each result is `{ source, document, sourceLayout, changedSource,
492
+ reusedPreviousTree }`. `document` is the PART 12 tree, `sourceLayout` is what
493
+ `parseSourceLayoutJson` produces, and `changedSource` is the byte ranges this
494
+ parse covered - the whole document on a first parse, the edits on a re-parse.
495
+
496
+ The snapshot crosses as JSON rather than as a handle. Every other entry point
497
+ in this package is a pure function that owns nothing, and the engine's snapshot
498
+ holds only the source, so there is no tree kept alive in wasm memory for a
499
+ handle to point at.
500
+
501
+ > **The offsets are UTF-8 byte offsets.**
502
+ > That is what `parseJson` positions and `createSourcePatch` ranges already
503
+ > mean. A browser editor counts UTF-16 code units, so a host holding a
504
+ > `selectionStart` converts before calling. An offset landing inside a
505
+ > multi-byte character is refused rather than guessed at: `é` is two bytes, and
506
+ > `[0, 1]` throws.
507
+
508
+ A malformed change throws: overlapping ranges, an end past the source, and a
509
+ range splitting a code point are all a broken caller contract rather than a
510
+ result the caller asked for. An empty change list is not one - it re-parses the
511
+ source unchanged, which is what a host batching keystrokes will hit.
512
+
513
+ `reusedPreviousTree` is the engine's own answer about whether any of the
514
+ previous parse was reused. **The pinned engine always reports `false`**: it
515
+ validates and applies the edits and then parses the whole source. Read the flag
516
+ rather than assuming work was saved.
517
+ ### Tree patches
518
+
519
+ `createAstPatch(before, after)` is the difference between two PART 12 trees,
520
+ and `applyAstPatch(ast, patch)` replays it. Trees and patches both cross as
521
+ JSON strings, the same way `parseJson` and `astJsonToHtml` hand a tree across.
522
+
523
+ ```js
524
+ import { parseJson, createAstPatch, applyAstPatch } from '@markup-carve/carve-wasm'
525
+
526
+ const patch = createAstPatch(parseJson('# One\n'), parseJson('# Two\n'))
527
+ applyAstPatch(parseJson('# One\n'), patch) // the '# Two' tree, as JSON
528
+ ```
529
+
530
+ The operations are the position-independent `{ op, path, value }` wire shape,
531
+ so a patch stays meaningful against a tree that moved underneath it. That is
532
+ the opposite trade from `createSourcePatch`, which pins byte ranges and
533
+ fingerprints them against staleness.
534
+
535
+ The engine's `ast_patch_to_json` and `ast_patch_from_json` have no binding of
536
+ their own. With the patch crossing as JSON they are exactly this pair's
537
+ encoding, and calling them would convert JSON a host already holds.
538
+
539
+ `createReversibleAstPatch(before, after)` returns
540
+ `{ forward, inverse, beforeFingerprint, afterFingerprint }` as one JSON string,
541
+ and `applyReversibleAstPatch(ast, patch, inverse)` replays either direction.
542
+
543
+ ```js
544
+ const step = createReversibleAstPatch(before, after)
545
+ const redone = applyReversibleAstPatch(before, step)
546
+ const undone = applyReversibleAstPatch(after, step, true)
547
+ ```
548
+
549
+ **The undo stack is yours.** Every entry point here is a pure function that
550
+ owns nothing, so there is no history kept in this package to undo against, and
551
+ a browser host already has somewhere to keep one. What the reversible pair adds
552
+ over two `createAstPatch` calls is the precondition: applying a step to a tree
553
+ whose fingerprint is not the one it was made against throws, so an undo cannot
554
+ land on a document that has moved on.
555
+
556
+ ### Three-way merge
557
+
558
+ `mergeAst(base, ours, theirs, options)` merges two edits of one document. Two
559
+ people editing one document is a browser problem, so the audience is here.
560
+
561
+ ```js
562
+ import { parseJson, mergeAst } from '@markup-carve/carve-wasm'
563
+
564
+ const { ok, ast, conflicts } = JSON.parse(mergeAst(base, ours, theirs))
565
+ ```
566
+
567
+ **A conflict is a value, not a throw.** It is the result you asked for: `ok` is
568
+ false, `ast` is null, and `conflicts` says where and why. A throw is for a
569
+ contract you broke, and an unparseable tree still gets one, naming which of the
570
+ three it was.
571
+
572
+ Each conflict is `{ path, reason, base, ours, theirs }`, with `reason` one of
573
+ `both-changed`, `delete-edit` or `concurrent-sequence-edit`. That shape and
574
+ those names are carve-js's, so a host merging with either engine reads one
575
+ contract. The Rust engine carries no `deleted` flags, so carve-js's optional
576
+ `deleted` field is absent here rather than guessed at.
577
+
578
+ `options.resolve` answers conflicts while the merge runs:
579
+
580
+ ```js
581
+ const merged = JSON.parse(mergeAst(base, ours, theirs, {
582
+ resolve: (conflict) => (conflict.path.startsWith('/children/0') ? 'ours' : null),
583
+ }))
584
+ ```
585
+
586
+ It returns `'base'`, `'ours'`, `'theirs'`, `{ value }` to replace the field
587
+ outright, or `null` to leave that conflict standing.
588
+
589
+ > **The resolver must be synchronous.**
590
+ > A resolver that asks a server, or asks the user, returns a Promise the merge
591
+ > cannot await. That is reported in `resolverErrors` with the conflict's path,
592
+ > and the conflict is left unresolved - never swallowed, and never stringified
593
+ > into the tree. Returning `null` is the supported way to decline; a Promise is
594
+ > a mistake.
595
+
596
+ ### ProseMirror
597
+
598
+ `toProseMirror(source)` converts Carve to the ProseMirror document shape, and
599
+ `fromProseMirror(doc)` writes one back as canonical Carve source. ProseMirror
600
+ runs in a browser and nowhere else, so this binding is the whole audience for
601
+ the engine's bridge - without it a host wiring a Carve editor either round-trips
602
+ to a server or reimplements the node mapping in JS, where it drifts from the
603
+ engine's.
604
+
605
+ ```js
606
+ const { json, dropped, degraded } = toProseMirror(source)
607
+ editor.commands.setContent(JSON.parse(json))
608
+ const saved = fromProseMirror(JSON.stringify(editor.getJSON()))
609
+ ```
610
+
611
+ `json` is a JSON string, the same choice `parseJson` makes. The two maps are
612
+ `Carve node type -> reason`: `dropped` where the content is gone (an
613
+ abbreviation definition has no editor node), `degraded` where the text survives
614
+ without its node type (a soft break becomes whitespace, smart typography
615
+ resolves to the glyph). Both are empty for a document the model holds exactly.
616
+
617
+ A payload the schema map does not describe is refused rather than written
618
+ approximately. Round-tripping normalizes the source the way `toCarve` does, and
619
+ the reported degradations do not come back - `a ... b` returns as `a … b`.
620
+
237
621
  ### TypeScript
238
622
 
239
623
  The package ships `.d.ts` declarations. Types are inferred automatically when
@@ -254,6 +638,12 @@ const html: string = toHtml('_Hello_')
254
638
  | `toHtmlWithSymbols` | `(source: string, symbols?: object \| null) => string` | Core renderer + a `:name:` -> value symbols map (values are raw, see above) |
255
639
  | `toHtmlFull` | `(source: string, symbols?: object \| null) => string` | Core + common extensions (matches playground), optional symbols map |
256
640
  | `toHtmlWithOptions` | `(source: string, options?: object \| null) => string` | General form; see the options table above. Throws `ProfileViolationError` when a profile rejects the document |
641
+ | `toHtmlWithRenderers` | `(source: string, options?: object \| null) => StaticRenderResult` | The options object plus `renderers.math` and `renderers.diagrams`, for `mode: 'static'`; returns `{ html, rendererErrors }` |
642
+ | `toMarkdownWithOptions` | `(source: string, options?: object \| null) => string` | Markdown under the same options object. Throws `ProfileViolationError` when a profile rejects the document |
643
+ | `toPlainTextWithOptions` | `(source: string, options?: object \| null) => string` | Plain text under the same options object |
644
+ | `toAnsiWithOptions` | `(source: string, options?: object \| null) => string` | ANSI text under the same options object |
645
+ | `toCarveWithOptions` | `(source: string, options?: object \| null) => string` | Canonical Carve under the same options object; reads `profile` only |
646
+ | `parseJsonWithOptions` | `(source: string, options?: object \| null) => string` | The AST as JSON under the same options object; positions are always on |
257
647
  | `toHtmlWithReport` | `(source: string, strict?: boolean, maximum?: number) => RenderResult` | HTML plus bounded `raw-format-dropped` losses; strict mode throws `RenderLossError` |
258
648
  | `toMarkdownWithReport` | `(source: string, strict?: boolean, maximum?: number) => RenderResult` | Checked Markdown render |
259
649
  | `toPlainTextWithReport` | `(source: string, strict?: boolean, maximum?: number) => RenderResult` | Checked plain-text render |
@@ -262,11 +652,38 @@ const html: string = toHtml('_Hello_')
262
652
  | `parseJson` | `(source: string) => string` | The parsed AST as JSON (PART 12 exchange shape) |
263
653
  | `astJsonToHtml` | `(json: string, options?: object \| null) => string` | Render an AST-JSON document; takes the same options object |
264
654
  | `astJsonToCarve` | `(json: string) => string` | Write an AST-JSON document back as canonical Carve source |
655
+ | `astJsonToMarkdown` | `(json: string, options?: object \| null) => string` | Render an AST-JSON document to Markdown; takes the same options object |
656
+ | `astJsonToPlainText` | `(json: string, options?: object \| null) => string` | Render an AST-JSON document to plain text |
657
+ | `astJsonToAnsi` | `(json: string, options?: object \| null) => string` | Render an AST-JSON document to ANSI-styled text |
658
+ | `applyProfile` | `(json: string, profile: string, options?: object \| null) => ProfileFilterResult` | Filter an AST-JSON document through a profile, keeping the tree and what the filter did |
265
659
  | `lintCarve` | `(source: string) => LintWarning[]` | Degradation diagnostics, with the rule ids carve-js and carve-php share |
660
+ | `lintCarveWithOptions` | `(source: string, options?: object \| null) => LintWarning[]` | The same linter for the extension set the host renders with |
661
+ | `lintAccessibility` | `(source: string) => AccessibilityDiagnostic[]` | The second diagnostic family, with its own rule ids and a severity |
662
+ | `stampCarve` | `(formatted: string, generatedBy: string, form?: "line" \| "block") => string` | Write the provenance marker `readStamp` reads |
663
+ | `sanitizeSvg` | `(source: string, options?: object \| null) => SanitizeResult` | Sanitize an SVG document; strict unless an option says otherwise |
664
+ | `parseLocator` | `(loc: string) => ParsedLocator` | Parse a citation locator into label, value and suffix |
665
+ | `parseSourceLayoutJson` | `(source: string) => string` | The PART 12 §13 source-layout sidecar |
666
+ | `markdownToAstJson` | `(source: string) => string` | Import Markdown straight to the tree, skipping the Carve-source round trip |
667
+ | `htmlToAst` | `(html: string, mode?: string) => MigrationResult` | Import HTML straight to the tree; `{ value, report }` with the tree in `value` |
668
+ | `expandIncludes` | `(source: string, options: object) => IncludeExpansion` | Expand `{{ path }}` through a SYNCHRONOUS `resolve`; returns the tree plus warnings, dependencies and resolver failures |
669
+ | `parseSnapshot` | `(source: string) => string` | Parse and keep what a `reparse` needs; JSON `{ source, document, sourceLayout, changedSource, reusedPreviousTree }` |
670
+ | `reparse` | `(source: string, changes: string) => string` | Apply a JSON array of `{ range: [start, end], replacement }` in UTF-8 BYTE offsets and re-parse |
671
+ | `mergeAst` | `(base: string, ours: string, theirs: string, options?: object) => string` | Three-way merge; JSON `{ ok, ast, conflicts, resolverErrors }`, a conflict being a value |
672
+ | `createAstPatch` | `(before: string, after: string) => string` | The difference between two PART 12 trees, as `{ op, path, value }` patch JSON |
673
+ | `applyAstPatch` | `(ast: string, patch: string) => string` | Replay patch JSON onto a tree |
674
+ | `createReversibleAstPatch` | `(before: string, after: string) => string` | The same difference with its inverse and both fingerprints |
675
+ | `applyReversibleAstPatch` | `(ast: string, patch: string, inverse?: boolean) => string` | Replay a reversible patch either way; a fingerprint mismatch throws |
676
+ | `toProseMirror` | `(source: string) => ProseMirrorResult` | Convert to the ProseMirror document shape, with what the model could not hold |
677
+ | `fromProseMirror` | `(doc: string) => string` | Convert a ProseMirror document back to canonical Carve source |
266
678
  | `readStamp` | `(source: string) => { version, generatedBy } \| null` | The document's provenance marker, if it carries one |
267
679
  | `needsReview` | `(source: string, currentVersion: string) => boolean` | Whether the stamp predates `currentVersion`; unstamped counts as yes |
268
680
  | `fromDjot` | `(source: string) => string` | Convert Djot source to Carve |
681
+ | `migrateDjot` | `(source: string) => { value, report }` | Convert Djot with a v2 fidelity report |
269
682
  | `fromBbcode` | `(source: string) => string` | Convert BBCode source to Carve; throws past the engine's size cap |
683
+ | `migrateBbcode` | `(source: string) => { value, report }` | Convert BBCode with a v2 fidelity report; throws past the engine's size cap |
684
+ | `createSourcePatch` | `(source, replacement, kind, code) => SourcePatch` | Build a minimal UTF-8 byte-range patch |
685
+ | `toCarvePatch` | `(source: string) => SourcePatch` | Preview canonical formatting as a patch |
686
+ | `applySourcePatch` | `(source: string, patch: SourcePatch) => string` | Validate and apply trusted patch instructions |
270
687
  | `version` | `() => string` | Returns the carve-wasm package version |
271
688
 
272
689
  ### The parsed AST
@@ -297,151 +714,14 @@ browser does natively and faster than building the object graph across the wasm
297
714
  boundary one property at a time - and it keeps the bytes available for a caller
298
715
  that stores or forwards them.
299
716
 
300
- ## carve-rs dependency pin
717
+ ## Development
301
718
 
302
- `Cargo.toml` pins an exact carve-rs commit, and `Cargo.lock` is committed
303
- alongside it:
304
-
305
- ```toml
306
- carve = { package = "carve-lang", git = "https://github.com/markup-carve/carve-rs", rev = "..." }
307
- ```
308
-
309
- Read the current revision out of `Cargo.toml` rather than from a copy here - a
310
- revision quoted in prose goes stale the first time someone bumps the manifest
311
- without noticing the duplicate.
312
-
313
- The engine is published as `carve-lang` (carve-rs renamed it from `carve`), so a
314
- pin at any revision past that rename needs `package = "carve-lang"` as above.
315
-
316
- The crate previously tracked carve-rs' default branch with no committed lock.
317
- That never went stale, but it went the other way: every build resolved whatever
318
- had landed upstream since, so the published package could carry an engine no CI
319
- run here had ever built, and two clones a day apart could disagree. The pin
320
- makes an engine change a reviewable line in a diff.
321
-
322
- When bumping the `rev`, regenerate and commit `Cargo.lock` in the same change.
323
- The lock records the resolved revision plus the rest of the tree; leaving it
324
- behind gives every fresh clone a dirty working tree on its first build and lets
325
- the package resolve to an engine other than the one that was tested.
326
-
327
- ```sh
328
- cargo update -p carve-lang --precise <sha> # or edit the rev and re-lock
329
- cargo test && wasm-pack build --target nodejs && node tests/smoke.mjs
330
- CARVE_SPEC_CORPUS=/path/to/carve/tests/corpus node tests/corpus.mjs
331
- ```
332
-
333
- `scripts/check-engine-floor.py` is what notices a pin left behind. CI runs it
334
- against the revision carve-rb embeds:
335
-
336
- ```sh
337
- python3 scripts/check-engine-floor.py \
338
- --engine <carve-rs checkout> --manifest Cargo.toml --lock Cargo.lock \
339
- --sibling-name carve-rb --sibling-manifest <carve-rb>/ext/carve/Cargo.toml \
340
- --changelog CHANGELOG.md
341
- ```
342
-
343
- It fails when this pin is a strict ancestor of the sibling's, and it also fails
344
- when the `[Unreleased]` changelog section names a revision the build does not
345
- embed - a revision quoted in prose is a second copy of the pin, and the second
346
- copy is the one nothing else reads. A floor rather than a leash: it is
347
- deliberately not a distance check against carve-rs `main`, which merges
348
- continuously and would be red from the moment any pull request opens there.
349
-
350
- That last line is the one that can tell a drifted pin from a current one.
351
- `smoke.mjs` asserts hand-written expectations, which a stale engine satisfies
352
- happily; `corpus.mjs` renders all ~530 mandatory spec documents through the
353
- **built** artifact and requires byte-identical HTML. Without `CARVE_SPEC_CORPUS`
354
- it prints a notice and exits 0, so a checkout without the spec repo still runs
355
- the suite. CI always sets it.
356
-
357
- It measures the binding as much as the engine: carve-rs is corpus-checked
358
- upstream, but that says nothing about whether the wasm-bindgen layer drops a
359
- field or mangles an option on the way through.
360
-
361
- Regenerate the whole lock MSRV-aware, or it will quietly break the `rust-version`
362
- this crate advertises. A plain `cargo generate-lockfile` on a current toolchain
363
- picks the newest `wasm-bindgen`, which needs a newer Rust than the 1.75 declared
364
- above - fine while CI only runs `stable`, and a hard failure for anyone actually
365
- building on the floor:
366
-
367
- ```sh
368
- CARGO_RESOLVER_INCOMPATIBLE_RUST_VERSIONS=fallback cargo generate-lockfile
369
- ```
370
-
371
- Nothing in CI catches this today, since the workflow uses `stable` only. Adding
372
- a 1.75 job (carve-rs has one) would turn it from a review question into a build
373
- failure.
374
-
375
- ## Build
376
-
377
- The published package uses the **bundler** target (matching the release
378
- workflow):
379
-
380
- ```bash
381
- cargo test
382
- wasm-pack build --target bundler --scope markup-carve
383
- ```
384
-
385
- `wasm-pack` emits the package into `pkg/`. For a no-bundler / `<script type=module>`
386
- setup use `--target web` (which exports a default `init()` you must `await`
387
- before calling the renderers); for Node use `--target nodejs`.
388
-
389
- Point the test suite at any built package with `CARVE_WASM_PKG`, rather than
390
- only at `pkg/`:
391
-
392
- ```sh
393
- CARVE_WASM_PKG=some/other/pkg node tests/smoke.mjs
394
- ```
395
-
396
- Both wasm-pack targets load. Unset, it is `pkg/`, so nothing changes for the
397
- ordinary loop.
398
-
399
- ### Rendering-only browser build
400
-
401
- All capabilities are enabled by default, so ordinary builds and the published
402
- package keep their complete API. A browser host that only renders Carve to HTML
403
- can leave the importers, reports, AST serialization, and other output formats
404
- out of its artifact:
405
-
406
- ```sh
407
- wasm-pack build --target web --release --out-dir pkg-render . --no-default-features
408
- ```
409
-
410
- The basic HTML rendering exports, including `toHtml`, `toHtmlFull`, and
411
- `toHtmlWithOptions`, remain available. Checked rendering through
412
- `toHtmlWithReport` is part of the omitted `reports` capability. The optional
413
- features are:
414
-
415
- | Feature | Exports |
416
- | --- | --- |
417
- | `html-import` | `htmlToCarve`, `fromHtml` |
418
- | `markdown-import` | `fromMarkdown` |
419
- | `reports` | all five `*WithReport` renderers |
420
- | `ast-json` | `parseJson`, `astJsonToHtml`, `astJsonToCarve` |
421
- | `other-renderers` | the basic Markdown, plain-text, ANSI, and Carve renderers |
422
- | `lint` | `lintCarve` |
423
- | `stamp` | `readStamp`, `needsReview` |
424
- | `other-imports` | `fromDjot`, `fromBbcode` |
425
-
426
- Use a separate output directory as above if a full build also exists in the
427
- checkout.
428
-
429
- ## Release
430
-
431
- Publishing is gated. `.github/workflows/release.yml` has two jobs, and
432
- `publish` declares `verify` in `needs:`, so it cannot start while the gate
433
- fails. The gate is `scripts/verify-release-artifact.mjs`, and it is runnable by
434
- hand against a local build:
435
-
436
- ```sh
437
- wasm-pack build --target bundler --scope markup-carve
438
- CARVE_SPEC_CORPUS=/path/to/carve/tests/corpus node scripts/verify-release-artifact.mjs
439
- ```
719
+ The reference material a contributor needs lives in `docs/`, so this page can
720
+ stay about USING the package:
440
721
 
441
- It runs `npm pack` and drives `tests/smoke.mjs` and `tests/corpus.mjs` at the
442
- UNPACKED tarball, not at `pkg/`. The difference matters: npm uploads what the
443
- generated `files` list names, so a payload file left out of it would never
444
- reach the registry and would never have been tested either. The corpus
445
- population comes from the spec's example pages, so a truncated corpus fails
446
- here instead of passing over a subset, and an unset `CARVE_SPEC_CORPUS` is
447
- refused rather than skipped.
722
+ - [Building](docs/building.md) - the wasm-pack targets, pointing the tests at a
723
+ built package, and the rendering-only browser profile.
724
+ - [The carve-rs pin](docs/engine-pin.md) - why the engine is pinned to an exact
725
+ commit, and how to move it.
726
+ - [Releasing](docs/releasing.md) - the gate, what it refuses, and the runbook
727
+ for cutting a version.