@orkestrel/markdown 0.0.6 → 0.0.8

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.
@@ -1,11 +1,17 @@
1
1
  import { BooleanShape } from '@orkestrel/contract';
2
+ import { CommentNode } from '@orkestrel/html';
2
3
  import { ContractInterface } from '@orkestrel/contract';
4
+ import { DoctypeNode } from '@orkestrel/html';
5
+ import { ElementNode } from '@orkestrel/html';
3
6
  import { Guard } from '@orkestrel/contract';
7
+ import { HTMLDocument } from '@orkestrel/html';
8
+ import { HTMLNode } from '@orkestrel/html';
4
9
  import { LiteralShape } from '@orkestrel/contract';
5
10
  import { NumberShape } from '@orkestrel/contract';
6
11
  import { ObjectShape } from '@orkestrel/contract';
7
12
  import { OptionalShape } from '@orkestrel/contract';
8
13
  import { StringShape } from '@orkestrel/contract';
14
+ import { TextNode as TextNode_2 } from '@orkestrel/html';
9
15
 
10
16
  /** A node that can appear at the block level of a document (or inside a list item / blockquote). */
11
17
  export declare type BlockNode = HeadingNode | ParagraphNode | ListNode | TableNode | CodeBlockNode | BlockquoteNode | ThematicBreakNode;
@@ -131,6 +137,20 @@ export declare function collectTable(lines: readonly string[], start: number): {
131
137
  readonly next: number;
132
138
  };
133
139
 
140
+ /**
141
+ * The count of leading space / tab characters on `line` (a tab counts as one) - the
142
+ * indent that decides whether a list item's continuation belongs to the item.
143
+ *
144
+ * @param line - The line to measure
145
+ * @returns The number of leading space / tab characters
146
+ *
147
+ * @example
148
+ * ```ts
149
+ * countIndent(' text') // 2
150
+ * ```
151
+ */
152
+ export declare function countIndent(line: string): number;
153
+
134
154
  /**
135
155
  * Compile the {@link codeBlockShape} into a {@link ContractInterface} for
136
156
  * {@link CodeBlockNode} - a guard, coercing parser, JSON Schema, and seeded
@@ -165,6 +185,21 @@ export declare function createCodeBlockContract(): ContractInterface<CodeBlockNo
165
185
  */
166
186
  export declare function createCodeSpanContract(): ContractInterface<CodeSpanNode>;
167
187
 
188
+ /**
189
+ * Compile the {@link lineBreakShape} into a {@link ContractInterface} for
190
+ * {@link LineBreakNode}.
191
+ *
192
+ * @returns A `LineBreakNode` contract bundling `schema` / `is` / `parse` / `generate`
193
+ *
194
+ * @example
195
+ * ```ts
196
+ * import { createLineBreakContract } from '@src/core'
197
+ *
198
+ * createLineBreakContract().is({ element: 'break' }) // true
199
+ * ```
200
+ */
201
+ export declare function createLineBreakContract(): ContractInterface<LineBreakNode>;
202
+
168
203
  /**
169
204
  * Create a stateful markdown handle from a markdown string or an already-parsed
170
205
  * {@link MarkdownDocument} - a typed AST plus the query, rewrite, and fold operations
@@ -173,7 +208,8 @@ export declare function createCodeSpanContract(): ContractInterface<CodeSpanNode
173
208
  * @remarks
174
209
  * Given a `string`, runs a block phase (headings / paragraphs / lists / GFM tables /
175
210
  * fenced code / blockquotes / thematic breaks) then an inline phase (emphasis /
176
- * inline code / links) to build a render-agnostic {@link MarkdownDocument}. Given a
211
+ * inline code / links / images / hard breaks) to build a render-agnostic
212
+ * {@link MarkdownDocument}. Given a
177
213
  * {@link MarkdownDocument}, adopts it AS-IS without re-validation - gate an untrusted
178
214
  * value with `isMarkdownDocument` first. Pure + total parse (malformed markdown
179
215
  * degrades to text, never throws) and zero-dependency - a hand-written scanner, no
@@ -192,6 +228,28 @@ export declare function createCodeSpanContract(): ContractInterface<CodeSpanNode
192
228
  */
193
229
  export declare function createMarkdown(input: string | MarkdownDocument): MarkdownInterface;
194
230
 
231
+ /**
232
+ * Create an HTML-to-markdown projection with absent fields defaulted from
233
+ * {@link EMPTY_PROJECTION} and the block/inline exclusivity invariant enforced.
234
+ *
235
+ * @remarks
236
+ * A block-bearing projection cannot also expose inline content. Callers may provide
237
+ * both views, but `inlines` is flushed whenever `blocks` is non-empty.
238
+ *
239
+ * @param parts - The projection fields to provide
240
+ * @returns A complete invariant-preserving projection
241
+ *
242
+ * @example
243
+ * ```ts
244
+ * createProjection({
245
+ * blocks: [{ element: 'thematicBreak' }],
246
+ * inlines: [{ element: 'text', value: 'discarded' }],
247
+ * })
248
+ * // { blocks: [{ element: 'thematicBreak' }], inlines: [], text: '', cells: [], rows: [] }
249
+ * ```
250
+ */
251
+ export declare function createProjection(parts?: Partial<MarkdownProjection>): MarkdownProjection;
252
+
195
253
  /**
196
254
  * Compile the {@link textShape} into a {@link ContractInterface} for
197
255
  * {@link TextNode} - a guard, coercing parser, JSON Schema, and seeded
@@ -226,6 +284,21 @@ export declare function createTextContract(): ContractInterface<TextNode>;
226
284
  */
227
285
  export declare function createThematicBreakContract(): ContractInterface<ThematicBreakNode>;
228
286
 
287
+ /**
288
+ * Derive the per-column {@link TableAlign} list from a GFM delimiter row - `:---`
289
+ * left, `---:` right, `:---:` center, and `---` as the explicit no-alignment
290
+ * marker represented by `null`.
291
+ *
292
+ * @param delimiter - The table's delimiter row
293
+ * @returns One alignment per column, in column order
294
+ *
295
+ * @example
296
+ * ```ts
297
+ * delimiterToAlignments('| :--- | ---: |') // ['left', 'right']
298
+ * ```
299
+ */
300
+ export declare function delimiterToAlignments(delimiter: string): readonly (TableAlign | null)[];
301
+
229
302
  /**
230
303
  * Emphasized inline content - `*italic*` / `_italic_` (`strong: false`) or
231
304
  * `**bold**` / `__bold__` (`strong: true`). `children` are the nested inline nodes,
@@ -241,19 +314,16 @@ export declare interface EmphasisNode {
241
314
  }
242
315
 
243
316
  /**
244
- * HTML-escape text content - `&` / `<` / `>` / `"` / `'` to their entities - so text
245
- * from a markdown document can never inject markup. The renderer applies this to every
246
- * text run, code body, and (escaped further) attribute value.
247
- *
248
- * @param text - The raw text
249
- * @returns The HTML-escaped text
317
+ * The frozen empty HTML-to-markdown projection from which projection factories
318
+ * default every absent field.
250
319
  *
251
320
  * @example
252
321
  * ```ts
253
- * escapeHtml('<a>&"\'') // '&lt;a&gt;&amp;&quot;&#39;'
322
+ * EMPTY_PROJECTION.blocks // []
323
+ * Object.isFrozen(EMPTY_PROJECTION) // true
254
324
  * ```
255
325
  */
256
- export declare function escapeHtml(text: string): string;
326
+ export declare const EMPTY_PROJECTION: MarkdownProjection;
257
327
 
258
328
  /**
259
329
  * Extract a fenced-code opening line (```` ``` ```` or `~~~`, optionally with an info
@@ -295,7 +365,7 @@ export declare function extractHeading(line: string): {
295
365
 
296
366
  /**
297
367
  * Extract a list-item line (`-` / `*` / `+` bullet, or `1.` / `1)` ordinal, followed by
298
- * a space) into its {@link ListItemParts}, or `undefined` when `line` is not a list
368
+ * a space) into its {@link ListItemMatch}, or `undefined` when `line` is not a list
299
369
  * item. `content` is the text after the marker; `marker` is the full marker-plus-space
300
370
  * width (for measuring a continuation's indent).
301
371
  *
@@ -307,12 +377,13 @@ export declare function extractHeading(line: string): {
307
377
  * extractListItem('- item') // { ordered: false, start: 1, content: 'item', indent: 0, marker: 2 }
308
378
  * ```
309
379
  */
310
- export declare function extractListItem(line: string): ListItemParts | undefined;
380
+ export declare function extractListItem(line: string): ListItemMatch | undefined;
311
381
 
312
382
  /**
313
383
  * Concatenate the `value` / `code` content of every descendant text / code-span /
314
- * code-block node under `node`, in walk order - the plain-text projection of an AST
315
- * (search indexing, word counts, a text-only preview).
384
+ * code-block node under `node`, including image alternative content, in walk order -
385
+ * the plain-text projection of an AST (search indexing, word counts, a text-only
386
+ * preview).
316
387
  *
317
388
  * @remarks
318
389
  * Total: never throws. Descent stops at {@link MAX_DEPTH} (contributes `''` past the
@@ -377,8 +448,66 @@ export declare interface HeadingNode {
377
448
  readonly children: readonly InlineNode[];
378
449
  }
379
450
 
451
+ /**
452
+ * Project an `@orkestrel/html` {@link HTMLNode} into a {@link MarkdownDocument} - the
453
+ * HTML→markdown direction, and the inverse of {@link markdownToHTML}.
454
+ *
455
+ * @remarks
456
+ * **Engine.** One total handler table - {@link projectHTMLNode} for the containers,
457
+ * {@link projectHTMLLeaf} for the leaves - folded by `@orkestrel/html`'s own `foldNode`, so
458
+ * depth capping, cycle safety, and bottom-up ordering are inherited rather than
459
+ * rebuilt. Total: hostile, cyclic, and pathologically deep input degrades instead of
460
+ * throwing.
461
+ *
462
+ * **Composed depth.** Both packages cap recursion at 64, and html's cap is reached
463
+ * first: a document nested past it projects to a chain bounded by THAT cap, with the
464
+ * content below it truncated before markdown ever sees it. Since the projected chain
465
+ * can be a level or two deeper than {@link MAX_DEPTH}, the serializer's own cap can
466
+ * then truncate again - so the anchor law below is a law within the depth budget, and
467
+ * beyond it only totality is promised.
468
+ *
469
+ * **Safety.** Every `href` and `src` is re-sanitized through
470
+ * `sanitizeURL(value, SAFE_URL_SCHEMES)` whether or not the AST was ever sanitized,
471
+ * because a hand-built one never was. A refused destination empties to `''` and the
472
+ * link or image is KEPT - `[text]()` - since a bad URL is no reason to lose the words
473
+ * around it. An `UNSAFE_ELEMENTS` subtree contributes nothing at all, text included, so
474
+ * a `script` body can never resurface as prose.
475
+ *
476
+ * **The anchor law.** HTML→markdown is lossy, so the fixpoint that matters is the
477
+ * PROJECTED AST, not the input bytes:
478
+ * `parseDocument(renderMarkdown(htmlToMarkdown(x)))` deep-equals `htmlToMarkdown(x)`.
479
+ * The projection therefore emits canonical markdown shapes rather than literal
480
+ * translations - whitespace collapsed, edges trimmed, a blank paragraph dropped, a hard
481
+ * break only where a line can end - because a shape markdown cannot write back is a
482
+ * shape this projection has no business producing.
483
+ *
484
+ * @param node - The HTML document or bare node to project
485
+ * @returns The projected markdown document
486
+ *
487
+ * @example
488
+ * ```ts
489
+ * import { parseDocument } from '@orkestrel/html'
490
+ *
491
+ * htmlToMarkdown(parseDocument('<h1>Title</h1>'))
492
+ * // { element: 'document', children: [{ element: 'heading', level: 1, children: [...] }] }
493
+ * ```
494
+ */
495
+ export declare function htmlToMarkdown(node: HTMLNode): MarkdownDocument;
496
+
497
+ /**
498
+ * An inline image - `![alt](src)`. `children` are the inline nodes of the
499
+ * alternative content and `src` is the image destination.
500
+ */
501
+ export declare interface ImageNode {
502
+ readonly element: 'image';
503
+ /** The image destination. */
504
+ readonly src: string;
505
+ /** The inline alternative content. */
506
+ readonly children: readonly InlineNode[];
507
+ }
508
+
380
509
  /** A node that can appear inside inline content (a heading / paragraph / cell / list item / link text). */
381
- export declare type InlineNode = TextNode | EmphasisNode | CodeSpanNode | LinkNode;
510
+ export declare type InlineNode = TextNode | EmphasisNode | CodeSpanNode | LineBreakNode | LinkNode | ImageNode;
382
511
 
383
512
  /**
384
513
  * Whether `line` is blank - empty, or containing only whitespace - the markdown
@@ -512,9 +641,19 @@ export declare function isFenceWhitespace(character: string | undefined): boolea
512
641
  /** Determine whether a node is a heading block. */
513
642
  export declare function isHeadingNode(node: MarkdownNode): node is HeadingNode;
514
643
 
644
+ /**
645
+ * Determine whether a node is an image.
646
+ *
647
+ * @example
648
+ * ```ts
649
+ * isImageNode({ element: 'image', src: 'x.png', children: [] }) // true
650
+ * ```
651
+ */
652
+ export declare function isImageNode(node: MarkdownNode): node is ImageNode;
653
+
515
654
  /**
516
655
  * Determine whether an arbitrary value is a valid {@link InlineNode} - a text
517
- * run, emphasis, code span, or link, recursively validated.
656
+ * run, emphasis, code span, hard break, link, or image, recursively validated.
518
657
  *
519
658
  * @remarks
520
659
  * Total: never throws, even on cyclic or pathologically deep input - every
@@ -534,6 +673,16 @@ export declare function isHeadingNode(node: MarkdownNode): node is HeadingNode;
534
673
  */
535
674
  export declare const isInlineNode: Guard<InlineNode>;
536
675
 
676
+ /**
677
+ * Determine whether a node is a GFM hard line break.
678
+ *
679
+ * @example
680
+ * ```ts
681
+ * isLineBreakNode({ element: 'break' }) // true
682
+ * ```
683
+ */
684
+ export declare function isLineBreakNode(node: MarkdownNode): node is LineBreakNode;
685
+
537
686
  /** Determine whether a node is a link. */
538
687
  export declare function isLinkNode(node: MarkdownNode): node is LinkNode;
539
688
 
@@ -688,24 +837,31 @@ export declare function isThematicBreakNode(node: MarkdownNode): node is Themati
688
837
  */
689
838
  export declare function isWhitespace(character: string): boolean;
690
839
 
840
+ /** A GFM hard line break - two or more trailing spaces before a newline. */
841
+ export declare interface LineBreakNode {
842
+ readonly element: 'break';
843
+ }
844
+
691
845
  /**
692
- * The count of leading space / tab characters on `line` (a tab counts as one) - the
693
- * indent that decides whether a list item's continuation belongs to the item.
694
- *
695
- * @param line - The line to measure
696
- * @returns The number of leading space / tab characters
846
+ * The shape of a {@link LineBreakNode} - a GFM hard line-break leaf.
697
847
  *
698
848
  * @example
699
849
  * ```ts
700
- * leadingIndent(' text') // 2
850
+ * import { createContract } from '@orkestrel/contract'
851
+ * import { lineBreakShape } from '@src/core'
852
+ *
853
+ * const lineBreak = createContract(lineBreakShape)
854
+ * lineBreak.is({ element: 'break' }) // true
701
855
  * ```
702
856
  */
703
- export declare function leadingIndent(line: string): number;
857
+ export declare const lineBreakShape: ObjectShape<{
858
+ element: LiteralShape<readonly ["break"]>;
859
+ }, false>;
704
860
 
705
861
  /**
706
- * An inline link - `[text](href)`. `children` are the inline nodes of the link text;
707
- * `href` is the destination, sanitized at render (a `javascript:` / other unsafe
708
- * scheme is dropped to an empty `href`, and the value is HTML-attribute-escaped).
862
+ * An inline link - `[text](href)`. `children` are the inline nodes of the link text.
863
+ * At render, html's floor removes a refused `href` attribute and the link keeps its
864
+ * text; {@link htmlToMarkdown} instead stores a refused destination as `''`.
709
865
  */
710
866
  export declare interface LinkNode {
711
867
  readonly element: 'link';
@@ -715,18 +871,11 @@ export declare interface LinkNode {
715
871
  readonly children: readonly InlineNode[];
716
872
  }
717
873
 
718
- /** One item of a {@link ListNode} - `children` the block content of the item (typically one paragraph, plus any nested list). */
719
- export declare interface ListItemNode {
720
- readonly element: 'listItem';
721
- /** The block content of the list item (its text as a paragraph, plus any nested list). */
722
- readonly children: readonly BlockNode[];
723
- }
724
-
725
874
  /**
726
875
  * The parsed parts of a single list-item line - the value the block phase's
727
876
  * list detector returns for a `-` / `*` / `+` bullet or a `1.` / `1)` ordinal line.
728
877
  */
729
- export declare interface ListItemParts {
878
+ export declare interface ListItemMatch {
730
879
  /** `true` for an ordered (`1.` / `1)`) item, `false` for a bullet (`-` / `*` / `+`). */
731
880
  readonly ordered: boolean;
732
881
  /** The ordinal of an ordered item (its number); `1` for a bullet. */
@@ -740,20 +889,20 @@ export declare interface ListItemParts {
740
889
  }
741
890
 
742
891
  /**
743
- * The shape of {@link ListItemParts} - the parsed parts of a single list-item
892
+ * The shape of {@link ListItemMatch} - the parsed parts of a single list-item
744
893
  * line the block phase's list detector returns. Fully non-recursive (no
745
894
  * nested node fields), so every field shapes directly.
746
895
  *
747
896
  * @example
748
897
  * ```ts
749
898
  * import { createContract } from '@orkestrel/contract'
750
- * import { listItemPartsShape } from '@src/core'
899
+ * import { listItemMatchShape } from '@src/core'
751
900
  *
752
- * const listItemParts = createContract(listItemPartsShape)
901
+ * const listItemParts = createContract(listItemMatchShape)
753
902
  * listItemParts.is({ ordered: false, start: 1, content: 'hi', indent: 0, marker: 2 }) // true
754
903
  * ```
755
904
  */
756
- export declare const listItemPartsShape: ObjectShape<{
905
+ export declare const listItemMatchShape: ObjectShape<{
757
906
  ordered: BooleanShape;
758
907
  start: NumberShape;
759
908
  content: StringShape;
@@ -761,6 +910,13 @@ export declare const listItemPartsShape: ObjectShape<{
761
910
  marker: NumberShape;
762
911
  }, false>;
763
912
 
913
+ /** One item of a {@link ListNode} - `children` the block content of the item (typically one paragraph, plus any nested list). */
914
+ export declare interface ListItemNode {
915
+ readonly element: 'listItem';
916
+ /** The block content of the list item (its text as a paragraph, plus any nested list). */
917
+ readonly children: readonly BlockNode[];
918
+ }
919
+
764
920
  /**
765
921
  * A list - bulleted (`-` / `*` / `+`, `ordered: false`) or numbered (`1.` / `1)`,
766
922
  * `ordered: true`). `start` is the first ordinal of an ordered list (usually `1`).
@@ -863,6 +1019,14 @@ export declare class Markdown implements MarkdownInterface {
863
1019
  stream(): ReadableStream<BlockNode>;
864
1020
  }
865
1021
 
1022
+ /** One projected table cell - the inline content and alignment of a `th` / `td`. */
1023
+ export declare interface MarkdownCell {
1024
+ /** The alignment the cell's `align` attribute declared; `undefined` when it declared none. */
1025
+ readonly align: TableAlign | undefined;
1026
+ /** The cell's inline content - a table cell is inline-only, so block content flattens to text. */
1027
+ readonly inlines: readonly InlineNode[];
1028
+ }
1029
+
866
1030
  /**
867
1031
  * The root of a parsed markdown AST - the ordered block children of the whole
868
1032
  * document. The value {@link MarkdownInterface.document} holds.
@@ -916,8 +1080,12 @@ export declare interface MarkdownHandlers<T> {
916
1080
  readonly emphasis: MarkdownHandler<EmphasisNode, T>;
917
1081
  /** Folds a {@link CodeSpanNode} (leaf - always called with an empty children list). */
918
1082
  readonly codeSpan: MarkdownHandler<CodeSpanNode, T>;
1083
+ /** Folds a {@link LineBreakNode} (leaf - always called with an empty children list). */
1084
+ readonly break: MarkdownHandler<LineBreakNode, T>;
919
1085
  /** Folds a {@link LinkNode} from its already-folded inline children. */
920
1086
  readonly link: MarkdownHandler<LinkNode, T>;
1087
+ /** Folds an {@link ImageNode} from its already-folded alternative content. */
1088
+ readonly image: MarkdownHandler<ImageNode, T>;
921
1089
  }
922
1090
 
923
1091
  /**
@@ -979,11 +1147,46 @@ export declare interface MarkdownInterface {
979
1147
 
980
1148
  /**
981
1149
  * Any node in a markdown AST - the {@link MarkdownDocument} root, a {@link BlockNode},
982
- * a {@link ListItemNode}, or an {@link InlineNode}. The exhaustive set the renderer's
983
- * `switch` covers.
1150
+ * a {@link ListItemNode}, or an {@link InlineNode}. The exhaustive set every
1151
+ * projection's `switch` covers.
984
1152
  */
985
1153
  export declare type MarkdownNode = MarkdownDocument | BlockNode | ListItemNode | InlineNode;
986
1154
 
1155
+ /**
1156
+ * What one HTML node projects to on the way to markdown - the fold value
1157
+ * `htmlToMarkdown` carries up the AST.
1158
+ *
1159
+ * @remarks
1160
+ * A node projects to several things at once because markdown decides late what a
1161
+ * given HTML subtree becomes: a `td`'s content is inline in a table and a paragraph
1162
+ * outside one, and a `code` body is a code span in prose and a verbatim code block
1163
+ * under a `pre`. Rather than guess, each node reports every view its ancestors could
1164
+ * need, and the ancestor that knows the context takes the one it wants.
1165
+ *
1166
+ * - `blocks` / `inlines` - the block and inline views. They are exclusive by
1167
+ * construction: as soon as a node contributes a block, the inline runs around it
1168
+ * are wrapped into paragraphs, so `blocks` being non-empty means `inlines` is
1169
+ * empty and no interleaving is ever lost.
1170
+ * - `text` - the raw, uncollapsed, unescaped subtree text a code span and a
1171
+ * `pre > code` body need verbatim. An `UNSAFE_ELEMENTS` subtree contributes none
1172
+ * of it, so a script body can never resurface as prose.
1173
+ * - `cells` / `rows` - table structure in flight. A cell travels up to its `tr` and a
1174
+ * row up to its `table`, passing through the `thead` / `tbody` wrappers between
1175
+ * them untouched; whatever never reaches a table degrades to paragraphs.
1176
+ */
1177
+ export declare interface MarkdownProjection {
1178
+ /** The node's block content, with any surrounding inline runs already wrapped into paragraphs. */
1179
+ readonly blocks: readonly BlockNode[];
1180
+ /** The node's inline content; empty whenever `blocks` is not. */
1181
+ readonly inlines: readonly InlineNode[];
1182
+ /** The raw subtree text, whitespace uncollapsed and escapes unresolved. */
1183
+ readonly text: string;
1184
+ /** The cells this node contributes to an enclosing row. */
1185
+ readonly cells: readonly MarkdownCell[];
1186
+ /** The rows this node contributes to an enclosing table - each its cells, in column order. */
1187
+ readonly rows: readonly (readonly MarkdownCell[])[];
1188
+ }
1189
+
987
1190
  /**
988
1191
  * A copy-on-write node rewrite applied bottom-up by {@link MarkdownInterface.map} -
989
1192
  * receives one node (its own children already rewritten) and returns its
@@ -991,17 +1194,92 @@ export declare type MarkdownNode = MarkdownDocument | BlockNode | ListItemNode |
991
1194
  */
992
1195
  export declare type MarkdownRewriteHandler = (node: MarkdownNode) => MarkdownNode;
993
1196
 
1197
+ /**
1198
+ * Project a {@link MarkdownNode} into an unsanitized {@link HTMLDocument}.
1199
+ *
1200
+ * @remarks
1201
+ * The projection is pure and iterative. Text and attribute values remain literal for
1202
+ * `@orkestrel/html` to encode, and URL values remain unsanitized so callers can choose
1203
+ * their own HTML policy. Projected HTML element depth, including generated `pre > code`
1204
+ * and table scaffolding, never exceeds {@link MAX_DEPTH}. At the cap a node carrying a
1205
+ * string `value` degrades to a text node and a structural node contributes nothing.
1206
+ *
1207
+ * @param node - The markdown document or bare node to project
1208
+ * @returns An unsanitized HTML document wrapping the projected node or nodes
1209
+ *
1210
+ * @example
1211
+ * ```ts
1212
+ * markdownToHTML({ element: 'text', value: 'a & b' })
1213
+ * // { category: 'document', children: [{ category: 'text', value: 'a & b' }] }
1214
+ * ```
1215
+ */
1216
+ export declare function markdownToHTML(node: MarkdownNode): HTMLDocument;
1217
+
994
1218
  /**
995
1219
  * The maximum recursion depth the parse pipeline (`parseDocument` and its
996
- * `parsers.ts` helpers) and the `helpers.ts` traversal / render functions
997
- * (`renderHTML`, `renderMarkdown`, `walkNodes`, `foldNode`) honor before degrading to
998
- * literal text - bounds blockquote nesting, inline nesting (emphasis / links), and
999
- * traversal/render recursion so pathological or hostile input (deeply nested
1000
- * blockquotes, runaway emphasis) cannot exhaust the call stack. Past this depth the
1001
- * parser treats the remaining content as literal text instead of recursing further.
1220
+ * `parsers.ts` helpers) and the `helpers.ts` traversal / projection functions
1221
+ * (`markdownToHTML`, `renderHTML`, `renderMarkdown`, `walkNodes`, `foldNode`,
1222
+ * `rewriteDocument`) honor before degrading. It bounds blockquote nesting, inline
1223
+ * nesting (emphasis / links), and traversal / projection recursion so pathological
1224
+ * or hostile input cannot exhaust the call stack. {@link htmlToMarkdown} is the
1225
+ * inherited exception: its fold and depth cap belong to `@orkestrel/html`.
1002
1226
  */
1003
1227
  export declare const MAX_DEPTH = 64;
1004
1228
 
1229
+ /**
1230
+ * Combine the projections of one node's children into the projection of that node -
1231
+ * the single place inline runs become paragraphs, so no ancestor has to decide it
1232
+ * twice.
1233
+ *
1234
+ * @remarks
1235
+ * A child is either inline or block, never both, so merging preserves source order
1236
+ * exactly: an inline run is held pending until a block arrives, then written out as a
1237
+ * paragraph BEFORE it. That is what keeps `<div>lead<p>a</p></div>` two paragraphs in
1238
+ * the order they were written rather than two lists that lost their interleaving. A
1239
+ * pending run carrying no text is dropped rather than becoming a blank paragraph.
1240
+ * Direct cells become one row before a later row, while cells/rows before a block
1241
+ * materialize as paragraphs at that exact source position.
1242
+ *
1243
+ * @param children - The children's projections, in source order
1244
+ * @returns Their combined projection
1245
+ *
1246
+ * @example
1247
+ * ```ts
1248
+ * mergeProjections([
1249
+ * createProjection({ inlines: [{ element: 'text', value: 'a' }], text: 'a' }),
1250
+ * createProjection({ blocks: [{ element: 'thematicBreak' }] }),
1251
+ * ]).blocks
1252
+ * // [{ element: 'paragraph', children: [...] }, { element: 'thematicBreak' }]
1253
+ * ```
1254
+ */
1255
+ export declare function mergeProjections(children: readonly MarkdownProjection[]): MarkdownProjection;
1256
+
1257
+ /**
1258
+ * Reduce an inline run to the shape markdown can actually write back: adjacent text
1259
+ * coalesced, empty text dropped, and every hard break either kept as a real line
1260
+ * ending or spent as a space.
1261
+ *
1262
+ * @remarks
1263
+ * A hard break is ` \n` in markdown source, so it survives a re-parse only BETWEEN
1264
+ * two lines of content and only with no whitespace touching it: a leading or trailing
1265
+ * break has no line to end, a run of breaks reads as one blank line (which would end
1266
+ * the paragraph), and a space beside one is eaten by the parser's line trimming. Where
1267
+ * a break cannot be written at all - a heading and a table cell are one line each - it
1268
+ * becomes the space it stood for.
1269
+ *
1270
+ * @param nodes - The inline run to normalize
1271
+ * @param breaks - Whether the target context can carry a hard break at all; `false` for
1272
+ * a heading or a table cell, where every break becomes a space
1273
+ * @returns The normalized run
1274
+ *
1275
+ * @example
1276
+ * ```ts
1277
+ * normalizeInlines([{ element: 'break' }, { element: 'text', value: 'a' }], true)
1278
+ * // [{ element: 'text', value: 'a' }] - a leading break has no line to end
1279
+ * ```
1280
+ */
1281
+ export declare function normalizeInlines(nodes: readonly InlineNode[], breaks: boolean): readonly InlineNode[];
1282
+
1005
1283
  /** A paragraph - a run of non-blank lines that is not another block; `children` its inline content. */
1006
1284
  export declare interface ParagraphNode {
1007
1285
  readonly element: 'paragraph';
@@ -1034,8 +1312,8 @@ export declare function parseBlocks(lines: readonly string[], depth: number): re
1034
1312
  export declare function parseDocument(markdown: string): MarkdownDocument;
1035
1313
 
1036
1314
  /**
1037
- * Parses inline markdown text (emphasis, code spans, links) into inline AST
1038
- * nodes, coalescing adjacent text runs.
1315
+ * Parses inline markdown text (emphasis, code spans, links, images, and hard
1316
+ * breaks) into inline AST nodes, coalescing adjacent text runs.
1039
1317
  *
1040
1318
  * @param text - The inline markdown text to parse.
1041
1319
  * @returns The parsed inline nodes.
@@ -1043,25 +1321,122 @@ export declare function parseDocument(markdown: string): MarkdownDocument;
1043
1321
  export declare function parseInline(text: string): readonly InlineNode[];
1044
1322
 
1045
1323
  /**
1046
- * Render a {@link MarkdownNode} (typically a {@link MarkdownDocument}) to a safe HTML
1047
- * string - the recursive AST → HTML engine (headings, paragraphs, lists, GFM tables,
1048
- * fenced code, blockquotes, links, emphasis, inline code), escaping every text run and
1049
- * sanitizing every link `href`.
1324
+ * Project one HTML leaf - a text node, a comment, or a doctype - to its
1325
+ * {@link MarkdownProjection}.
1050
1326
  *
1051
1327
  * @remarks
1052
- * Total: never throws. At {@link MAX_DEPTH} a value-bearing node (`text` / `codeSpan`)
1053
- * degrades to its escaped `value`; any other node degrades to `''` instead of
1054
- * recursing further, so pathologically deep input cannot exhaust the call stack.
1328
+ * Text collapses each whitespace run to one space, which is both what HTML means by it
1329
+ * and all markdown can write back; the raw value travels on in `text` for the two
1330
+ * places that need it verbatim, a code span and a `pre > code` body. A comment and a
1331
+ * doctype carry nothing into markdown and project to nothing.
1055
1332
  *
1056
- * @param node - The AST node to render (a full document, or any sub-node)
1057
- * @returns The rendered, XSS-safe HTML string
1333
+ * @param leaf - The leaf node to project
1334
+ * @returns Its projection
1058
1335
  *
1059
1336
  * @example
1060
1337
  * ```ts
1061
- * renderHTML({ element: 'document', children: [
1062
- * { element: 'heading', level: 1, children: [{ element: 'text', value: 'Hi' }] },
1063
- * ] })
1064
- * // '<h1>Hi</h1>'
1338
+ * projectHTMLLeaf({ category: 'text', value: 'a\n b' }).inlines
1339
+ * // [{ element: 'text', value: 'a b' }]
1340
+ * ```
1341
+ */
1342
+ export declare function projectHTMLLeaf(leaf: CommentNode | DoctypeNode | TextNode_2): MarkdownProjection;
1343
+
1344
+ /**
1345
+ * Project one HTML container - the document root or an element - from its children's
1346
+ * already-computed projections. THE element mapping, and the only place that decides
1347
+ * what an HTML tag becomes in markdown.
1348
+ *
1349
+ * @remarks
1350
+ * `h1`-`h6` become headings; `p` a paragraph; `strong` / `b` and `em` / `i` emphasis;
1351
+ * `code` a code span; `pre` a code block, verbatim through a first `code` element child
1352
+ * (its `language-` class naming the language) and through `renderText` otherwise; `a`
1353
+ * and `img` a link and an image, each destination re-sanitized; `br` and `hr` a hard
1354
+ * break and a thematic break; `blockquote` and `li` their block content, with bare
1355
+ * inline runs wrapped in paragraphs; `ul` / `ol` a list, ordered from the tag and
1356
+ * numbered from `start`; `th` / `td`, `tr`, and `table` a GFM table whose column
1357
+ * alignment comes from each header-position cell's `align` attribute. Every
1358
+ * `UNSAFE_ELEMENTS` subtree contributes nothing at all, text included. Every OTHER
1359
+ * element unwraps to its children, so wrapper soup melts while its content keeps its
1360
+ * shape - `<div><p>a</p><p>b</p></div>` stays two paragraphs.
1361
+ *
1362
+ * Three mappings read their own node rather than only their children's projections,
1363
+ * because HTML puts the fact in a position rather than in a value: a `pre` takes its
1364
+ * body from its `code` child's raw text, and a list takes one item per `li` child - so
1365
+ * an empty `<li>` is still an item, while the whitespace between two of them is not.
1366
+ * A `tr` accepts only its own direct cells, and a table derives the first `th`-bearing
1367
+ * row from its own source structure.
1368
+ *
1369
+ * @param node - The document root or element to project
1370
+ * @param children - Its children's projections, in source order
1371
+ * @returns Its projection
1372
+ *
1373
+ * @example
1374
+ * ```ts
1375
+ * projectHTMLNode({ category: 'element', name: 'hr', attributes: [], children: [] }, []).blocks
1376
+ * // [{ element: 'thematicBreak' }]
1377
+ * ```
1378
+ */
1379
+ export declare function projectHTMLNode(node: ElementNode | HTMLDocument, children: readonly MarkdownProjection[]): MarkdownProjection;
1380
+
1381
+ /**
1382
+ * Read a projection as BLOCK content - the view a document, a blockquote, and a list
1383
+ * item each need.
1384
+ *
1385
+ * @remarks
1386
+ * A bare inline run becomes one paragraph, and a run carrying no text becomes nothing
1387
+ * at all, because a blank paragraph is unwritable in markdown. A cell or a row that
1388
+ * never reached a table is unwrapped here rather than dropped: a stray `<td>` is still
1389
+ * someone's content.
1390
+ *
1391
+ * @param projection - The projection to read
1392
+ * @returns Its block content
1393
+ *
1394
+ * @example
1395
+ * ```ts
1396
+ * projectionToBlocks(createProjection({ inlines: [{ element: 'text', value: 'a' }], text: 'a' }))
1397
+ * // [{ element: 'paragraph', children: [{ element: 'text', value: 'a' }] }]
1398
+ * ```
1399
+ */
1400
+ export declare function projectionToBlocks(projection: MarkdownProjection): readonly BlockNode[];
1401
+
1402
+ /**
1403
+ * Read a projection as INLINE content - the view a link, an emphasis, and a table cell
1404
+ * each need.
1405
+ *
1406
+ * @remarks
1407
+ * Inline content passes through as itself. Block content cannot: markdown has no way to
1408
+ * put a paragraph inside a table cell, so it flattens to one text node of its own words,
1409
+ * joined and whitespace-collapsed. Content that carries no text flattens to nothing
1410
+ * rather than to an empty text node, which is a shape the parser never produces.
1411
+ *
1412
+ * @param projection - The projection to read
1413
+ * @returns Its inline content
1414
+ *
1415
+ * @example
1416
+ * ```ts
1417
+ * projectionToInlines(createProjection({ inlines: [{ element: 'break' }] }))
1418
+ * // [{ element: 'break' }]
1419
+ * ```
1420
+ */
1421
+ export declare function projectionToInlines(projection: MarkdownProjection): readonly InlineNode[];
1422
+
1423
+ /**
1424
+ * Render a {@link MarkdownNode} to sanitized canonical HTML.
1425
+ *
1426
+ * @remarks
1427
+ * Markdown widens `@orkestrel/html`'s attribute floor by exactly `src`, because image
1428
+ * syntax is meaningless without its source. `src` is still a URL attribute, so the
1429
+ * floor refuses `javascript:`, `data:`, `vbscript:`, and `file:` values. A stricter
1430
+ * consumer can compose {@link markdownToHTML} with `@orkestrel/html`'s `HTML` class
1431
+ * directly.
1432
+ *
1433
+ * @param node - The markdown document or bare node to render
1434
+ * @returns Sanitized canonical HTML
1435
+ *
1436
+ * @example
1437
+ * ```ts
1438
+ * renderHTML({ element: 'paragraph', children: [{ element: 'text', value: 'a & b' }] })
1439
+ * // '<p>a &amp; b</p>'
1065
1440
  * ```
1066
1441
  */
1067
1442
  export declare function renderHTML(node: MarkdownNode): string;
@@ -1069,13 +1444,15 @@ export declare function renderHTML(node: MarkdownNode): string;
1069
1444
  /**
1070
1445
  * Render a {@link MarkdownNode} to its CANONICAL markdown source - the inverse
1071
1446
  * projection of `renderHTML`, and the serializer a `parse(renderMarkdown(doc))`
1072
- * round-trip is built on. Canonical forms: `*em*` / `**strong**` (underscore emphasis
1073
- * normalizes to asterisks), `- ` bullets, `N. ` sequential ordinals (from the list's
1074
- * `start`), `---` thematic breaks, fenced code blocks (backtick run widened past any
1075
- * 3+ backtick run inside the body), ATX headings, `> `-prefixed blockquote lines, GFM
1076
- * tables (1-space-padded cells, `\|`-escaped pipes, an alignment delimiter row), and
1077
- * `[text](href)` links. A `text` node's literal content is backslash-escaped wherever
1078
- * it would otherwise re-parse as markup (AGENTS §14 parse↔render soundness).
1447
+ * round-trip is built on. Canonical forms: `*` / `**` emphasis at even emphasis
1448
+ * nesting depths and `_` / `__` at odd depths, `- ` bullets, `N. ` sequential
1449
+ * ordinals (from the list's `start`), `---` thematic breaks, fenced code blocks
1450
+ * (backtick run widened past any 3+ backtick run inside the body), ATX headings,
1451
+ * `> `-prefixed blockquote lines, GFM tables (1-space-padded cells, `\|`-escaped
1452
+ * pipes, an alignment delimiter row), `[text](href)` links, `![alt](src)` images,
1453
+ * and two-space hard breaks. A `text` node's literal content is backslash-escaped
1454
+ * wherever it would otherwise re-parse as markup (AGENTS §14 parse↔render
1455
+ * soundness).
1079
1456
  *
1080
1457
  * @remarks
1081
1458
  * Total: never throws. At {@link MAX_DEPTH} a value-bearing node degrades to its
@@ -1129,36 +1506,6 @@ export declare function renderMarkdown(node: MarkdownNode): string;
1129
1506
  */
1130
1507
  export declare function rewriteDocument(document: MarkdownDocument, rewrite: MarkdownRewriteHandler): MarkdownDocument;
1131
1508
 
1132
- /**
1133
- * The URL schemes `renderHTML` permits on a link `href` - anything else (notably
1134
- * `javascript:`, `data:`, `vbscript:`, `file:`) is dropped to an empty `href` so a
1135
- * hostile link can never execute. Frozen, lower-case; a relative / anchor /
1136
- * scheme-less `href` (no `scheme:` prefix) is always allowed.
1137
- */
1138
- export declare const SAFE_URL_SCHEMES: ReadonlySet<string>;
1139
-
1140
- /**
1141
- * Sanitize + HTML-attribute-escape a link `href` - a destination whose scheme is not
1142
- * in {@link SAFE_URL_SCHEMES} (notably `javascript:` / `data:` / `vbscript:`), or that
1143
- * is protocol-relative (`//host/path`, or a backslash variant a browser normalizes to
1144
- * the same effect - `\\host`, `/\host`, `\/host` - inherits whatever scheme the
1145
- * embedding page is served over, including an unsafe one), is dropped to an empty
1146
- * string; a relative / anchor / scheme-less (and non-protocol-relative) destination
1147
- * (including a SINGLE leading `/` or `\`) is kept;
1148
- * the surviving value is then HTML-escaped. Defence-in-depth against an XSS `href`,
1149
- * even though the input is trusted.
1150
- *
1151
- * @param href - The raw link destination
1152
- * @returns A safe, escaped `href` (empty when the scheme is unsafe or protocol-relative)
1153
- *
1154
- * @example
1155
- * ```ts
1156
- * sanitizeUrl('javascript:alert(1)') // ''
1157
- * sanitizeUrl('/path') // '/path'
1158
- * ```
1159
- */
1160
- export declare function sanitizeUrl(href: string): string;
1161
-
1162
1509
  /**
1163
1510
  * Scan an inline code span at `start` (a `` ` ``-run … a matching `` ` ``-run of the
1164
1511
  * SAME length, the CommonMark rule that lets a span contain backticks). Returns the
@@ -1182,10 +1529,11 @@ export declare function scanCode(source: string, start: number, to: number): {
1182
1529
 
1183
1530
  /**
1184
1531
  * Scan an emphasis run at `start` (`*` / `_`, doubled for strong) - finds the nearest
1185
- * matching closing run of the same marker + width, requiring non-space immediately
1186
- * inside both delimiters (the CommonMark flanking simplification that blocks `* x *`).
1187
- * Returns the emphasis node, or `undefined` when no valid closer exists (it then
1188
- * degrades to a literal marker).
1532
+ * matching closing run of the same marker + width while skipping complete nested
1533
+ * runs from the other marker family, and requires non-space immediately inside both
1534
+ * delimiters (the CommonMark flanking simplification that blocks `* x *`). Returns
1535
+ * the emphasis node, or `undefined` when no valid closer exists (it then degrades to
1536
+ * a literal marker).
1189
1537
  *
1190
1538
  * @param source - The inline source text
1191
1539
  * @param start - The index of the opening marker
@@ -1208,7 +1556,8 @@ export declare function scanEmphasis(source: string, start: number, to: number,
1208
1556
 
1209
1557
  /**
1210
1558
  * Scan the window `[from, to)` of `source` into inline nodes - the single recursive
1211
- * engine the inline phase runs on (emphasis / link text recurse through it). Linear:
1559
+ * engine the inline phase runs on (emphasis, link text, and image alternative
1560
+ * content recurse through it). Linear:
1212
1561
  * each character is consumed once; a failed construct emits its opening character as
1213
1562
  * text and advances by one, so there is no re-scan (no ReDoS).
1214
1563
  *
@@ -1319,24 +1668,12 @@ export declare function stripQuote(line: string): string;
1319
1668
 
1320
1669
  /**
1321
1670
  * The horizontal alignment of a GFM table column, as declared by its delimiter row
1322
- * (`:---` left, `---:` right, `:---:` center) - `'none'` when the delimiter carries
1323
- * no alignment colon. One entry per column, in column order.
1324
- */
1325
- export declare type TableAlign = 'none' | 'left' | 'right' | 'center';
1326
-
1327
- /**
1328
- * Derive the per-column {@link TableAlign} list from a GFM delimiter row - `:---`
1329
- * left, `---:` right, `:---:` center, `---` none.
1330
- *
1331
- * @param delimiter - The table's delimiter row
1332
- * @returns One alignment per column, in column order
1333
- *
1334
- * @example
1335
- * ```ts
1336
- * tableAlignments('| :--- | ---: |') // ['left', 'right']
1337
- * ```
1671
+ * (`:---` left, `---:` right, `:---:` center). A bare `---` delimiter is represented
1672
+ * by `null` in {@link TableNode.align}: the positional array requires one entry per
1673
+ * column, JSON cannot carry `undefined` in an array, and the bare delimiter is an
1674
+ * explicit no-alignment marker rather than an omitted value.
1338
1675
  */
1339
- export declare function tableAlignments(delimiter: string): readonly TableAlign[];
1676
+ export declare type TableAlign = 'left' | 'right' | 'center';
1340
1677
 
1341
1678
  /**
1342
1679
  * The shape of a {@link TableAlign} - the per-column GFM table alignment
@@ -1353,7 +1690,7 @@ export declare function tableAlignments(delimiter: string): readonly TableAlign[
1353
1690
  * tableAlign.is('top') // false
1354
1691
  * ```
1355
1692
  */
1356
- export declare const tableAlignShape: LiteralShape<readonly ["none", "left", "right", "center"]>;
1693
+ export declare const tableAlignShape: LiteralShape<readonly ["left", "right", "center"]>;
1357
1694
 
1358
1695
  /**
1359
1696
  * A GFM table - `header` the inline content of each header cell, `rows` the body
@@ -1367,14 +1704,20 @@ export declare interface TableNode {
1367
1704
  readonly header: readonly (readonly InlineNode[])[];
1368
1705
  /** The body rows - each a list of cells, each cell inline content. */
1369
1706
  readonly rows: readonly (readonly (readonly InlineNode[])[])[];
1370
- /** The per-column alignment from the delimiter row, in column order. */
1371
- readonly align: readonly TableAlign[];
1707
+ /**
1708
+ * The per-column alignment from the delimiter row, in column order. `null`
1709
+ * represents a bare `---` delimiter because this positional array requires one
1710
+ * entry per column, JSON cannot carry `undefined` in an array, and the delimiter
1711
+ * is an explicit no-alignment marker rather than an omitted value.
1712
+ */
1713
+ readonly align: readonly (TableAlign | null)[];
1372
1714
  }
1373
1715
 
1374
1716
  /**
1375
1717
  * A run of plain text - the leaf inline node. `value` is the decoded text with
1376
- * markdown escapes (`\*`, `\_`, …) already resolved to their literal characters; the
1377
- * renderer HTML-escapes it (`<` / `>` / `&` / `"`) on the way out.
1718
+ * markdown escapes (`\*`, `\_`, …) already resolved to their literal characters;
1719
+ * html's text encoder escapes `&`, `<`, `>` on the way out; `"` and `'` stay literal
1720
+ * in character data.
1378
1721
  */
1379
1722
  export declare interface TextNode {
1380
1723
  readonly element: 'text';
@@ -1421,6 +1764,26 @@ export declare const thematicBreakShape: ObjectShape<{
1421
1764
  element: LiteralShape<readonly ["thematicBreak"]>;
1422
1765
  }, false>;
1423
1766
 
1767
+ /**
1768
+ * Trim the whitespace at the two ends of an inline run - the leading whitespace of a
1769
+ * leading text node and the trailing whitespace of a trailing one - dropping either
1770
+ * node when nothing survives.
1771
+ *
1772
+ * @remarks
1773
+ * Markdown trims every line of a paragraph, a heading's text, and a table cell, so an
1774
+ * untrimmed run would come back from a re-parse a different AST. Expects a coalesced
1775
+ * run (see {@link coalesceText}): only the outermost node on each side is examined.
1776
+ *
1777
+ * @param nodes - The inline run to trim
1778
+ * @returns The run with its edge whitespace removed
1779
+ *
1780
+ * @example
1781
+ * ```ts
1782
+ * trimInlines([{ element: 'text', value: ' a ' }]) // [{ element: 'text', value: 'a' }]
1783
+ * ```
1784
+ */
1785
+ export declare function trimInlines(nodes: readonly InlineNode[]): readonly InlineNode[];
1786
+
1424
1787
  /**
1425
1788
  * Resolve backslash escapes in a raw string to their literal characters - used for a
1426
1789
  * link `href` (which is not otherwise inline-parsed) and any plain text run.
@@ -1437,8 +1800,8 @@ export declare function unescapeText(text: string): string;
1437
1800
 
1438
1801
  /**
1439
1802
  * Depth-first, pre-order, root-inclusive traversal of a {@link MarkdownNode} - yields
1440
- * the node itself, then recurses into its children (block children, list items, table
1441
- * header/row cells' inline nodes) in walk order.
1803
+ * the node itself, then recurses into its children (block children, list items,
1804
+ * image/link inline children, table header/row cells' inline nodes) in walk order.
1442
1805
  *
1443
1806
  * @remarks
1444
1807
  * Total: never throws. Descent stops at {@link MAX_DEPTH} (the node at the cap is