markdown-codec 3.1.1 → 4.0.0

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
@@ -2,9 +2,9 @@
2
2
 
3
3
  [![GitHub](https://img.shields.io/badge/GitHub-181717?logo=github&logoColor=white)](https://github.com/ExaDev/markdown-codec) [![npm](https://img.shields.io/badge/npm-CB3837?logo=npm&logoColor=white)](https://www.npmjs.com/package/markdown-codec) [![Release](https://img.shields.io/github/v/release/ExaDev/markdown-codec)](https://github.com/ExaDev/markdown-codec/releases/latest) [![CI](https://img.shields.io/github/actions/workflow/status/ExaDev/markdown-codec/ci.yml?branch=main)](https://github.com/ExaDev/markdown-codec/actions)
4
4
 
5
- > Hand-written CommonMark+GFM ⇄ `ContentDocument` codec, built on [document-schema.js](https://github.com/ExaDev/document-schema.js).
5
+ > Hand-written CommonMark+GFM ⇄ `DocumentPackage` codec, built on [document-schema.js](https://github.com/ExaDev/document-schema.js).
6
6
 
7
- The same "hand-write the format instead of wrapping a third-party library" bet as [`pdf-codec`](https://github.com/ExaDev/pdf-codec), aimed at CommonMark and GFM. No `micromark`/`remark`/`marked`/`markdown-it`/`commonmark`/`mdast`/`unified`/`turndown`/`showdown` dependency (enforced by eslint `no-restricted-imports`). Runtime dependencies: `document-schema.js` (the shared pivot) and `zod`. `readMarkdown`/`writeMarkdown` read and write that pivot's `ContentDocument` directly — the same model [`documents.js`](https://github.com/ExaDev/documents.js) builds docx/pptx/odt/odp conversions around.
7
+ The same "hand-write the format instead of wrapping a third-party library" bet as [`pdf-codec`](https://github.com/ExaDev/pdf-codec), aimed at CommonMark and GFM. No `micromark`/`remark`/`marked`/`markdown-it`/`commonmark`/`mdast`/`unified`/`turndown`/`showdown` dependency (enforced by eslint `no-restricted-imports`). Runtime dependencies: `document-schema.js` (the shared pivot) and `zod`. `readMarkdown`/`writeMarkdown` read and write that pivot's tree-form `DocumentPackage`; `readMarkdownContent`/`writeMarkdownContent` read and write the flat `ContentDocument` underneath it — the same model [`documents.js`](https://github.com/ExaDev/documents.js) builds docx/pptx/odt/odp conversions around. See [Two encodings](#two-encodings-documentpackage-and-contentdocument).
8
8
 
9
9
  ```mermaid
10
10
  graph TD
@@ -51,7 +51,7 @@ graph TD
51
51
 
52
52
  ## Status
53
53
 
54
- The scanner, block parser, and inline parser are complete hand-written implementations of CommonMark 0.31.2's two-phase algorithm plus GFM's table/strikethrough/autolink/task-list-item extensions and GitHub's footnotes (see [Footnotes](#footnotes)). `readMarkdown`/`writeMarkdown`/`markdownCodec` are wired and real. Conformance suites measure the full public surface (`readMarkdown` → `writeMarkdown` → reparse → render to HTML) against the vendored CommonMark/GFM corpora — see [Fidelity](#fidelity) for why the rate is below 100% (dominated by what `ContentDocument` can represent, not parsing gaps).
54
+ The scanner, block parser, and inline parser are complete hand-written implementations of CommonMark 0.31.2's two-phase algorithm plus GFM's table/strikethrough/autolink/task-list-item extensions and GitHub's footnotes (see [Footnotes](#footnotes)). Both encodings' read/write pairs and both `z.codec()` pairs are wired and real. Conformance suites measure the full public surface (`readMarkdownContent` → `writeMarkdownContent` → reparse → render to HTML) against the vendored CommonMark/GFM corpora — see [Fidelity](#fidelity) for why the rate is below 100% (dominated by what `ContentDocument` can represent, not parsing gaps).
55
55
 
56
56
  ## Getting started
57
57
 
@@ -78,20 +78,22 @@ Reading and writing markdown text:
78
78
  ```ts
79
79
  import { readMarkdown, writeMarkdown } from 'markdown-codec';
80
80
 
81
- const { document, diagnostics } = readMarkdown('# Title\n\nSome **bold** text with a [link](https://example.com).', {
82
- frontMatter: true, // parse a leading YAML front matter block into ContentDocument.metadata
81
+ const { documentPackage, diagnostics } = readMarkdown('# Title\n\nSome **bold** text with a [link](https://example.com).', {
82
+ frontMatter: true, // parse a leading YAML front matter block into the package's metadata
83
83
  footnotes: true, // recognise [^label] markers and [^label]: definitions (default; see Footnotes)
84
84
  images: (destination) => undefined, // a synchronous MarkdownImageResolver port for non-data: URI images
85
85
  });
86
86
 
87
- const markdown = writeMarkdown(document, {
87
+ const markdown = writeMarkdown(documentPackage, {
88
88
  bulletListMarker: '-',
89
89
  emphasisMarker: '_',
90
- frontMatter: true, // emit ContentDocument.metadata back out as a leading front matter block
90
+ frontMatter: true, // emit the package's metadata back out as a leading front matter block
91
91
  });
92
92
  ```
93
93
 
94
- Both accept an optional `signal` (`AbortSignal`) and `sink` (`MarkdownDiagnosticSink`, called once per recoverable issue or construct-mapping gap see [Gotchas](#gotchas-and-quirks)). `writeMarkdown` throws `MarkdownUnsupportedDocumentKindError` for a non-`'wordprocessing'` `ContentDocument`.
94
+ `documentPackage` is a `DocumentPackage` document-schema.js's tree form, with a minted styles table (see [Two encodings](#two-encodings-documentpackage-and-contentdocument)). The field is named `documentPackage` rather than `package` because `package` is a reserved word in strict mode, so `const { package } = readMarkdown(src)` would not parse.
95
+
96
+ Both accept an optional `signal` (`AbortSignal`) and `sink` (`MarkdownDiagnosticSink`, called once per recoverable issue or construct-mapping gap — see [Gotchas](#gotchas-and-quirks)). `writeMarkdown` throws `MarkdownUnsupportedDocumentKindError` for a package whose `kind` is not `'wordprocessing'`, checked before flattening so every non-`'wordprocessing'` package reaches it the same way regardless of what else about that package would have failed document-schema.js's own `flattenPackage`. A `'wordprocessing'` package can still fail to flatten — a group carrying a style reference the package's own `styles` table has no entry for — and that failure surfaces as `MarkdownPackageFlattenError`, not a bare `Error` from the dependency. A `DocumentPackage`'s own `definitions`/`layers`/`attachments`/`destinations`/`pages` tables have no flat-`ContentDocument` home to land in; `writeMarkdown` reports one `PACKAGE_TABLE_DROPPED` diagnostic per non-empty table it finds rather than dropping them without a trace.
95
97
 
96
98
  The same round trip as a schema-validated [`z.codec()`](https://zod.dev) pair, mirroring `pdf-codec`'s `pdfCodec`:
97
99
 
@@ -99,12 +101,34 @@ The same round trip as a schema-validated [`z.codec()`](https://zod.dev) pair, m
99
101
  import { z } from 'zod';
100
102
  import { markdownCodec, MarkdownBytesSchema } from 'markdown-codec';
101
103
 
102
- const document = z.decode(markdownCodec, bytes); // throws if bytes are not well-formed UTF-8
103
- const bytes2 = z.encode(markdownCodec, document);
104
+ const documentPackage = z.decode(markdownCodec, bytes); // throws if bytes are not well-formed UTF-8
105
+ const bytes2 = z.encode(markdownCodec, documentPackage);
104
106
  ```
105
107
 
106
108
  `MarkdownBytesSchema` checks for well-formed UTF-8. The no-options form only; `readMarkdown`/`writeMarkdown` remain the entry points for an `AbortSignal` or diagnostic sink. Every construct-mapping gap reports through the sink as a stable code (e.g. `md/nested-emphasis-flattened`) — see `MarkdownDiagnosticCodes` and [Gotchas](#gotchas-and-quirks).
107
109
 
110
+ ## Two encodings: `DocumentPackage` and `ContentDocument`
111
+
112
+ document-schema.js states one document in two shapes, and owns the transform between them: the flat `ContentDocument` every codec's lowering pipeline actually builds, and the tree-form `DocumentPackage` a serialised artefact carries — sections, headings, lists, and construct boundaries as real nested groups, plus a styles table minted over repeated property tuples. `assemblePackage` goes flat → tree (`decompose` then `factorStyles`), `flattenPackage` goes tree → flat. Only one direction is a genuine round trip: `flattenPackage(assemblePackage(document))` reproduces `document` exactly, for any `ContentDocument` this package's own read side produces (checked against the full CommonMark and GFM conformance corpora, not just a hand-picked fixture — see `src/conformance.test.ts`/`src/gfm-conformance.test.ts`'s own "tree pair matches the flat pair" suite). `assemblePackage(flattenPackage(documentPackage))` does not, in general, reproduce `documentPackage` — a package carrying `definitions`/`layers`/`attachments`/`destinations`/`pages` loses all of them on the way through `flattenPackage`, which carries forward only `metadata` and `symbolTable` (see [Gotchas](#gotchas-and-quirks)).
113
+
114
+ This package exposes a read/write pair and a codec at each level. The unsuffixed names are the tree-form ones and are what to reach for by default — a codec is a construction site, so the tree is what a caller gets unless they ask for otherwise. The `Content`-suffixed names are the flat pair one level down, mirroring the `readXlsx`/`readXlsxContent` naming already in [`ooxml.js`](https://github.com/ExaDev/ooxml.js):
115
+
116
+ | Level | Read | Write | Codec | Value type |
117
+ | --- | --- | --- | --- | --- |
118
+ | Tree (default) | `readMarkdown` | `writeMarkdown` | `markdownCodec` | `DocumentPackage` |
119
+ | Flat | `readMarkdownContent` | `writeMarkdownContent` | `markdownContentCodec` | `ContentDocument` |
120
+
121
+ The tree pair is exactly the flat pair with the transform composed on — `readMarkdown` is `assemblePackage` over `readMarkdownContent`, `writeMarkdown` is `flattenPackage` before `writeMarkdownContent` — so both render identical markdown from the same source, pinned in `src/package.test.ts`. Options, diagnostics, and error behaviour are identical at both levels.
122
+
123
+ Reach for the flat pair when composing a package boundary by hand (`decompose`/`flattenPackage` directly, or `factorStyles` with your own minting policy), when feeding a `ContentDocument`-consuming builder such as `documents.js`'s conversion pipeline, or when a layout stage needs to stamp frames onto content before it is decomposed. Everything else wants the tree.
124
+
125
+ ```ts
126
+ import { readMarkdownContent, writeMarkdownContent } from 'markdown-codec';
127
+
128
+ const { document } = readMarkdownContent(source); // a ContentDocument: kind, metadata, sections
129
+ const markdown = writeMarkdownContent(document);
130
+ ```
131
+
108
132
  ## Architecture
109
133
 
110
134
  Modelled on `pdf-codec`'s own layering, aimed at CommonMark+GFM instead of PDF:
@@ -120,7 +144,7 @@ Modelled on `pdf-codec`'s own layering, aimed at CommonMark+GFM instead of PDF:
120
144
  - **`src/shared/`** — string-shape conventions `src/lower`/`src/emit` agree on (`style-constants.ts`, `list-id.ts`'s opaque `numId`). Re-exported so `documents.js`'s `MarkdownEditor` reuses the identical grammar.
121
145
  - **`src/lower/`** — AST → `ContentDocument` lowering (thin adapter, not a second parser); top-of-file table maps each construct to its diagnostic gap.
122
146
  - **`src/emit/`** — `ContentDocument` → markdown text emission, the structural inverse of `src/lower`.
123
- - **`src/read.ts`** / **`src/write.ts`** / **`src/codec.ts`** — public `readMarkdown`/`writeMarkdown` entry points and `markdownCodec` (`z.codec()` pair).
147
+ - **`src/read.ts`** / **`src/write.ts`** / **`src/codec.ts`** — the public entry points at both levels: `readMarkdown`/`writeMarkdown`/`markdownCodec` over `DocumentPackage`, and `readMarkdownContent`/`writeMarkdownContent`/`markdownContentCodec` over `ContentDocument`. The tree-form functions are thin compositions of `document-schema.js`'s `assemblePackage`/`flattenPackage` onto the flat ones; no conversion logic of their own lives here.
124
148
 
125
149
  ## Vendored assets
126
150
 
@@ -151,7 +175,7 @@ To run a single test file: `pnpm vitest run src/path/to/file.test.ts`.
151
175
  - **Zod-first schema/type/guard**, matching `pdf-codec`/`documents.js`: every model type inferred from its Zod schema.
152
176
  - **No type assertions.** Every loosely-typed value narrowed through a type guard or Zod parse at the boundary.
153
177
  - **No markdown-parsing library dependency**, enforced by eslint `no-restricted-imports`.
154
- - **`z.codec()` for the round trip** (`markdownCodec`), matching `pdf-codec`'s `pdfCodec`: wraps the independently-tested `readMarkdown`/`writeMarkdown` with automatic two-way schema validation (no-options form only).
178
+ - **`z.codec()` for the round trip** (`markdownCodec`, `markdownContentCodec`), matching `pdf-codec`'s `pdfCodec`: each wraps the independently-tested read/write pair at its own level with automatic two-way schema validation (no-options form only).
155
179
  - **Shrink-only conformance exclusion list.** Every spec example the read → write → reparse → render pipeline does not reproduce byte for byte is named in `src/test-support/conformance-exclusions.ts`, with a test asserting it genuinely still fails — the list shrinks as gaps close, never quietly grows.
156
180
  - **Conventional commits**, enforced via commitlint + husky.
157
181
 
@@ -178,6 +202,7 @@ Every construct `src/lower`/`src/emit` cannot represent losslessly is a document
178
202
  - **`md/footnote-reference-preserved-as-text`** — a reference site is a marked run, not an `anchor` construct; see [Footnotes](#footnotes).
179
203
  - **`md/footnote-body-heading-flattened`** — a heading inside a definition body is carried as literal ATX text, since a construct extent may not open or close a heading scope.
180
204
  - **`md/construct-unrepresented`** — a construct kind markdown has no syntax for renders transparently: its extent still appears, the construct itself does not.
205
+ - **`md/package-table-dropped`** — `writeMarkdown` only, ahead of flattening: a `DocumentPackage`'s own `definitions`/`layers`/`attachments`/`destinations`/`pages` table has no flat-`ContentDocument` home (`flattenPackage`'s own envelope carries forward only `metadata` and `symbolTable`); fires once per non-empty table present.
181
206
 
182
207
  ## Footnotes
183
208
 
@@ -185,12 +210,12 @@ GitHub's footnote extension (`[^label]` markers, `[^label]: body` definitions) i
185
210
 
186
211
  The two halves of a footnote map onto **two different mechanisms**, and that split is structural rather than a choice:
187
212
 
188
- - **A definition becomes an `anchor` construct.** `readMarkdown` emits document-schema.js 4.2.0's construct boundary markers — a `constructStart` carrying `{ kind: 'anchor', anchorType: 'footnote', name }`, the definition's own lowered body blocks, and a `constructEnd`. The body rides the construct's extent rather than `AnchorDescriptor.definition`, which names a key in a package-level definitions table a flat `ContentDocument` has no root to carry; a body is genuinely block content (several paragraphs, a code block, a list) that a string field could not have held either way. A bodyless `[^1]:` lowers to the point anchor the same descriptor describes: a pair with nothing between it.
213
+ - **A definition becomes an `anchor` construct.** Lowering emits document-schema.js's construct boundary markers — a `constructStart` carrying `{ kind: 'anchor', anchorType: 'footnote', name }`, the definition's own lowered body blocks, and a `constructEnd` — which is what `readMarkdownContent` returns in its block flow, and what `decompose` promotes to a construct group of its own in the `DocumentPackage` `readMarkdown` returns (the descriptor rides the group's `node`, the body blocks its `children`). The body rides the construct's extent rather than `AnchorDescriptor.definition`, which names a key in a package-level definitions table: `DocumentPackage` does carry that table as a root (unlike the flat `ContentDocument`), but a table entry there is a flat descriptor record, not a container for block content, so a body that is genuinely several paragraphs, a code block, or a list still has nowhere to live as a table value either way — the construct's own bracketed extent is the one shape in this schema built to hold real block content. A bodyless `[^1]:` lowers to the point anchor the same descriptor describes: a pair with nothing between it.
189
214
  - **A reference site stays a marked run.** A construct's extent is block-scoped by document-schema.js's own definition, and a reference sits between two runs inside a paragraph, so no block-level boundary marker can bracket it without splitting the paragraph in two. The schema names this gap itself and parks the inline-anchor case on a run-level extent mechanism it has not shipped. Until it does, the reference is a `ContentRun` keeping its own `[^label]` spelling and carrying `FOOTNOTE_REFERENCE_FONT_MARKER`, reported through `md/footnote-reference-preserved-as-text`.
190
215
 
191
216
  Definitions are recognised only at the document's own top level. Inside a block quote or a list item, the pair's extent would sit inside a scope the enclosing container had already opened, which the marker contract forbids a producer from emitting — so the text stays an ordinary paragraph there. A heading inside a definition body is flattened to literal ATX text for the same reason.
192
217
 
193
- `writeMarkdown` is the inverse and validates first: a section's markers must pair as balanced brackets (checked through document-schema.js's own `findConstructMarkerImbalance`, the shared definition every codec and `decompose` agree on) or it throws `MarkdownUnbalancedConstructMarkersError`. A construct kind with no markdown syntax — a bookmark, a division, a tracked change — renders transparently: its extent still appears in place, only the construct's own identity is lost.
218
+ Emission is the inverse and validates first: a section's markers must pair as balanced brackets (checked through document-schema.js's own `findConstructMarkerImbalance`, the shared definition every codec and `decompose` agree on) or `writeMarkdownContent` throws `MarkdownUnbalancedConstructMarkersError`. A tree already satisfies that balance by construction — `decompose` refuses to build one from an unbalanced stream — so `writeMarkdown` reaches this check only on a hand-built package flattened back to an unbalanced flow. A construct kind with no markdown syntax — a bookmark, a division, a tracked change — renders transparently: its extent still appears in place, only the construct's own identity is lost.
194
219
 
195
220
  ## Fidelity
196
221
 
@@ -222,9 +247,9 @@ Conventional Commits enforced by commitlint (`commitlint.config.ts`) via a husky
222
247
 
223
248
  ## References
224
249
 
225
- - [document-schema.js](https://github.com/ExaDev/document-schema.js) — owns the shared `ContentDocument` pivot.
250
+ - [document-schema.js](https://github.com/ExaDev/document-schema.js) — owns both shared encodings (`ContentDocument`, `DocumentPackage`) and the `assemblePackage`/`flattenPackage` transform between them.
226
251
  - [pdf-codec](https://github.com/ExaDev/pdf-codec) — the sibling whose scaffold, tooling, and "hand-write the format" philosophy this project mirrors.
227
- - [documents.js](https://github.com/ExaDev/documents.js) — bridges markdown to docx/odt/PDF via this package's `ContentDocument`. Markdown has no presentation/spreadsheet/drawing variant, so pptx/odp/ods/odg are structurally out of reach.
252
+ - [documents.js](https://github.com/ExaDev/documents.js) — bridges markdown to docx/odt/PDF via this package's `ContentDocument` (the flat pair; its own conversion pipeline assembles the package itself). Markdown has no presentation/spreadsheet/drawing variant, so pptx/odp/ods/odg are structurally out of reach.
228
253
  - [CommonMark Spec](https://spec.commonmark.org/) — the base specification targeted.
229
254
  - [GitHub Flavored Markdown Spec](https://github.github.com/gfm/) — GFM extensions layered on top.
230
255
  - [WHATWG HTML § named character references](https://html.spec.whatwg.org/multipage/named-characters.html) — the entity table `assets/html-entities/` vendors.
@@ -1,6 +1,6 @@
1
1
  import { s as MarkdownDocumentNode } from "../ast-8XCbjRQT.cjs";
2
2
  import { r as FootnoteLabelSet } from "../footnote-CKk4JbLk.cjs";
3
- import { i as MarkdownDiagnosticSink } from "../diagnostics-BuO5-SW1.cjs";
3
+ import { i as MarkdownDiagnosticSink } from "../diagnostics-BWK1iGy7.cjs";
4
4
  import { n as LinkReferenceMap } from "../link-Dv4kxVjk.cjs";
5
5
  import { t as InlineParseOptions } from "../inline-CXVQWQnW.cjs";
6
6
  //#region src/block/block.d.ts
@@ -1,6 +1,6 @@
1
1
  import { s as MarkdownDocumentNode } from "../ast-8XCbjRQT.js";
2
2
  import { r as FootnoteLabelSet } from "../footnote-CKk4JbLk.js";
3
- import { i as MarkdownDiagnosticSink } from "../diagnostics-BuO5-SW1.js";
3
+ import { i as MarkdownDiagnosticSink } from "../diagnostics-BWK1iGy7.js";
4
4
  import { n as LinkReferenceMap } from "../link-Dv4kxVjk.js";
5
5
  import { t as InlineParseOptions } from "../inline-B_V7bs5j.js";
6
6
  //#region src/block/block.d.ts
@@ -1,4 +1,4 @@
1
- import { i as MarkdownDiagnosticSink } from "../diagnostics-BuO5-SW1.cjs";
1
+ import { i as MarkdownDiagnosticSink } from "../diagnostics-BWK1iGy7.cjs";
2
2
  import { t as LinkReferenceDefinition } from "../link-Dv4kxVjk.cjs";
3
3
  //#region src/block/definitions.d.ts
4
4
  declare function extractDefinitions(content: string, references: Map<string, LinkReferenceDefinition>, sink?: MarkdownDiagnosticSink, startLine?: number): string;
@@ -1,4 +1,4 @@
1
- import { i as MarkdownDiagnosticSink } from "../diagnostics-BuO5-SW1.js";
1
+ import { i as MarkdownDiagnosticSink } from "../diagnostics-BWK1iGy7.js";
2
2
  import { t as LinkReferenceDefinition } from "../link-Dv4kxVjk.js";
3
3
  //#region src/block/definitions.d.ts
4
4
  declare function extractDefinitions(content: string, references: Map<string, LinkReferenceDefinition>, sink?: MarkdownDiagnosticSink, startLine?: number): string;
package/dist/codec.cjs CHANGED
@@ -13,10 +13,15 @@ function isWellFormedUtf8Text(bytes) {
13
13
  }
14
14
  }
15
15
  const MarkdownBytesSchema = zod.z.instanceof(Uint8Array).refine(isWellFormedUtf8Text, { message: "not well-formed UTF-8 text" });
16
- const markdownCodec = zod.z.codec(MarkdownBytesSchema, document_schema_js.ContentDocumentSchema, {
17
- decode: (bytes) => require_read.readMarkdown(new TextDecoder().decode(bytes)).document,
18
- encode: (document) => new TextEncoder().encode(require_write.writeMarkdown(document))
16
+ const markdownCodec = zod.z.codec(MarkdownBytesSchema, document_schema_js.DocumentPackageSchema, {
17
+ decode: (bytes) => require_read.readMarkdown(new TextDecoder().decode(bytes)).documentPackage,
18
+ encode: (documentPackage) => new TextEncoder().encode(require_write.writeMarkdown(documentPackage))
19
+ });
20
+ const markdownContentCodec = zod.z.codec(MarkdownBytesSchema, document_schema_js.ContentDocumentSchema, {
21
+ decode: (bytes) => require_read.readMarkdownContent(new TextDecoder().decode(bytes)).document,
22
+ encode: (document) => new TextEncoder().encode(require_write.writeMarkdownContent(document))
19
23
  });
20
24
  //#endregion
21
25
  exports.MarkdownBytesSchema = MarkdownBytesSchema;
22
26
  exports.markdownCodec = markdownCodec;
27
+ exports.markdownContentCodec = markdownContentCodec;