@orkestrel/markdown 0.0.1

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.
@@ -0,0 +1,362 @@
1
+ import type { EmphasisNode, InlineNode, LinkNode, ListItemParts, MarkdownDocument, MarkdownHandlers, MarkdownNode, MarkdownRewriteHandler, TableAlign } from './types.js';
2
+ /**
3
+ * Normalize line endings to `\n` and split a markdown document into its lines - CRLF
4
+ * (`\r\n`) and bare CR (`\r`) both collapse to `\n` first, so a Windows-origin
5
+ * document parses identically. A single trailing newline does not yield a final
6
+ * empty line.
7
+ *
8
+ * @param markdown - The raw markdown source
9
+ * @returns The document's lines, line-terminators stripped
10
+ */
11
+ export declare function splitLines(markdown: string): readonly string[];
12
+ /**
13
+ * The count of leading space / tab characters on `line` (a tab counts as one) - the
14
+ * indent that decides whether a list item's continuation belongs to the item.
15
+ *
16
+ * @param line - The line to measure
17
+ * @returns The number of leading space / tab characters
18
+ */
19
+ export declare function leadingIndent(line: string): number;
20
+ /**
21
+ * Extract an ATX heading line (`#` … `######` followed by text) into its
22
+ * `{ level, text }`, or `undefined` when `line` is not a heading. A run of more than 6
23
+ * `#`s, or `#`s not followed by whitespace + text, is not a
24
+ * heading; an optional closing `###` run is stripped.
25
+ *
26
+ * @param line - The candidate line
27
+ * @returns The heading level (1–6) and its raw inline text, or `undefined`
28
+ */
29
+ export declare function extractHeading(line: string): {
30
+ readonly level: number;
31
+ readonly text: string;
32
+ } | undefined;
33
+ /**
34
+ * Extract a fenced-code opening line (```` ``` ```` or `~~~`, optionally with an info
35
+ * string) into its `{ marker, lang }`, or `undefined` when `line` is not a fence
36
+ * opener. `marker` is the exact fence run (the closer must match the same character +
37
+ * at least the same length); `lang` is the first word of the info string.
38
+ *
39
+ * @param line - The candidate line
40
+ * @returns The fence marker run and its language tag, or `undefined`
41
+ */
42
+ export declare function extractFence(line: string): {
43
+ readonly marker: string;
44
+ readonly lang: string | undefined;
45
+ } | undefined;
46
+ /**
47
+ * Extract a list-item line (`-` / `*` / `+` bullet, or `1.` / `1)` ordinal, followed by
48
+ * a space) into its {@link ListItemParts}, or `undefined` when `line` is not a list
49
+ * item. `content` is the text after the marker; `marker` is the full marker-plus-space
50
+ * width (for measuring a continuation's indent).
51
+ *
52
+ * @param line - The candidate line
53
+ * @returns The list-item parts, or `undefined` when not a list item
54
+ */
55
+ export declare function extractListItem(line: string): ListItemParts | undefined;
56
+ /**
57
+ * Strip one level of blockquote marker (`>` plus one optional following space) from a
58
+ * blockquote line, so the de-quoted lines re-parse as nested blocks.
59
+ *
60
+ * @param line - A blockquote line (per {@link isQuote})
61
+ * @returns The line with its leading `>` (and one space) removed
62
+ */
63
+ export declare function stripQuote(line: string): string;
64
+ /**
65
+ * Split one GFM table row into its cell strings - outer pipes are optional, an escaped
66
+ * pipe (`\|`) inside a cell is NOT a separator (it becomes a literal `|`), and the
67
+ * empty leading / trailing cell produced by an outer `|` is dropped.
68
+ *
69
+ * @param row - The raw table row line
70
+ * @returns The row's cells, in column order
71
+ */
72
+ export declare function splitTableRow(row: string): readonly string[];
73
+ /**
74
+ * Derive the per-column {@link TableAlign} list from a GFM delimiter row - `:---`
75
+ * left, `---:` right, `:---:` center, `---` none.
76
+ *
77
+ * @param delimiter - The table's delimiter row
78
+ * @returns One alignment per column, in column order
79
+ */
80
+ export declare function tableAlignments(delimiter: string): readonly TableAlign[];
81
+ /**
82
+ * Whether the line at `index` starts a NEW block kind (heading / fence / thematic
83
+ * break / blockquote / list / table) - the paragraph collector stops at such a line
84
+ * so a block following a paragraph without a blank line still parses (a trusted-input
85
+ * caller writing a `##` heading directly under a paragraph, with no intervening blank
86
+ * line).
87
+ *
88
+ * @param lines - The document's lines
89
+ * @param index - The line index to test
90
+ * @returns `true` when the line begins a different block
91
+ */
92
+ export declare function startsBlock(lines: readonly string[], index: number): boolean;
93
+ /**
94
+ * Resolve backslash escapes in a raw string to their literal characters - used for a
95
+ * link `href` (which is not otherwise inline-parsed) and any plain text run.
96
+ *
97
+ * @param text - The raw text possibly carrying `\x` escapes
98
+ * @returns The text with escapable `\x` reduced to `x`
99
+ */
100
+ export declare function unescapeText(text: string): string;
101
+ /**
102
+ * Merge adjacent text nodes into one - the inline scanner emits a text node per
103
+ * unrecognized character, so coalescing keeps the AST clean and assertion-friendly.
104
+ *
105
+ * @param nodes - The inline nodes (possibly with adjacent text runs)
106
+ * @returns The nodes with consecutive text nodes concatenated
107
+ */
108
+ export declare function coalesceText(nodes: readonly InlineNode[]): readonly InlineNode[];
109
+ /**
110
+ * Scan an inline code span at `start` (a `` ` ``-run … a matching `` ` ``-run of the
111
+ * SAME length, the CommonMark rule that lets a span contain backticks). Returns the
112
+ * span's literal text + end index, or `undefined` when no matching closer exists (it
113
+ * then degrades to literal backticks).
114
+ *
115
+ * @param source - The inline source text
116
+ * @param start - The index of the opening backtick
117
+ * @param to - The exclusive end of the scan window
118
+ * @returns The span text + end index, or `undefined`
119
+ */
120
+ export declare function scanCode(source: string, start: number, to: number): {
121
+ readonly value: string;
122
+ readonly end: number;
123
+ } | undefined;
124
+ /**
125
+ * Scan a link `[text](href)` at `start` - the text runs to a BALANCED `]`, then `(`
126
+ * must immediately follow and the destination runs to the matching `)` (both respect
127
+ * nested delimiters + escapes). Returns the link node, or `undefined` when the shape
128
+ * does not hold (it then degrades to a literal `[`).
129
+ *
130
+ * @param source - The inline source text
131
+ * @param start - The index of the opening `[`
132
+ * @param to - The exclusive end of the scan window
133
+ * @param depth - The current inline-recursion depth (defaults to 0 at the entry point);
134
+ * at {@link MAX_DEPTH} the link's text children degrade to literal text instead of
135
+ * recursing further
136
+ * @returns The parsed {@link LinkNode} + end index, or `undefined`
137
+ */
138
+ export declare function scanLink(source: string, start: number, to: number, depth?: number): {
139
+ readonly node: LinkNode;
140
+ readonly end: number;
141
+ } | undefined;
142
+ /**
143
+ * Scan an emphasis run at `start` (`*` / `_`, doubled for strong) - finds the nearest
144
+ * matching closing run of the same marker + width, requiring non-space immediately
145
+ * inside both delimiters (the CommonMark flanking simplification that blocks `* x *`).
146
+ * Returns the emphasis node, or `undefined` when no valid closer exists (it then
147
+ * degrades to a literal marker).
148
+ *
149
+ * @param source - The inline source text
150
+ * @param start - The index of the opening marker
151
+ * @param to - The exclusive end of the scan window
152
+ * @param depth - The current inline-recursion depth (defaults to 0 at the entry point);
153
+ * at {@link MAX_DEPTH} the emphasis's children degrade to literal text instead of
154
+ * recursing further
155
+ * @returns The parsed {@link EmphasisNode} + end index, or `undefined`
156
+ */
157
+ export declare function scanEmphasis(source: string, start: number, to: number, depth?: number): {
158
+ readonly node: EmphasisNode;
159
+ readonly end: number;
160
+ } | undefined;
161
+ /**
162
+ * Scan the window `[from, to)` of `source` into inline nodes - the single recursive
163
+ * engine the inline phase runs on (emphasis / link text recurse through it). Linear:
164
+ * each character is consumed once; a failed construct emits its opening character as
165
+ * text and advances by one, so there is no re-scan (no ReDoS).
166
+ *
167
+ * @param source - The inline source text
168
+ * @param from - The inclusive start of the scan window
169
+ * @param to - The exclusive end of the scan window
170
+ * @param depth - The current inline-recursion depth (defaults to 0 at the entry point);
171
+ * incremented by one on every recursive descent through {@link scanLink} /
172
+ * {@link scanEmphasis}. At {@link MAX_DEPTH} the window is never scanned for markup -
173
+ * it emits as a single literal text node - so pathological nesting (`[[[[…`,
174
+ * `****…`) cannot exhaust the call stack.
175
+ * @returns The parsed inline nodes (NOT yet coalesced)
176
+ */
177
+ export declare function scanInline(source: string, from: number, to: number, depth?: number): readonly InlineNode[];
178
+ /**
179
+ * HTML-escape text content - `&` / `<` / `>` / `"` / `'` to their entities - so text
180
+ * from a markdown document can never inject markup. The renderer applies this to every
181
+ * text run, code body, and (escaped further) attribute value.
182
+ *
183
+ * @param text - The raw text
184
+ * @returns The HTML-escaped text
185
+ */
186
+ export declare function escapeHtml(text: string): string;
187
+ /**
188
+ * Sanitize + HTML-attribute-escape a link `href` - a destination whose scheme is not
189
+ * in {@link SAFE_URL_SCHEMES} (notably `javascript:` / `data:` / `vbscript:`), or that
190
+ * is protocol-relative (`//host/path`, or a backslash variant a browser normalizes to
191
+ * the same effect - `\\host`, `/\host`, `\/host` - inherits whatever scheme the
192
+ * embedding page is served over, including an unsafe one), is dropped to an empty
193
+ * string; a relative / anchor / scheme-less (and non-protocol-relative) destination
194
+ * (including a SINGLE leading `/` or `\`) is kept;
195
+ * the surviving value is then HTML-escaped. Defence-in-depth against an XSS `href`,
196
+ * even though the input is trusted.
197
+ *
198
+ * @param href - The raw link destination
199
+ * @returns A safe, escaped `href` (empty when the scheme is unsafe or protocol-relative)
200
+ */
201
+ export declare function sanitizeUrl(href: string): string;
202
+ /**
203
+ * Render a {@link MarkdownNode} (typically a {@link MarkdownDocument}) to a safe HTML
204
+ * string - the recursive AST → HTML engine (headings, paragraphs, lists, GFM tables,
205
+ * fenced code, blockquotes, links, emphasis, inline code), escaping every text run and
206
+ * sanitizing every link `href`.
207
+ *
208
+ * @remarks
209
+ * Total: never throws. At {@link MAX_DEPTH} a value-bearing node (`text` / `codeSpan`)
210
+ * degrades to its escaped `value`; any other node degrades to `''` instead of
211
+ * recursing further, so pathologically deep input cannot exhaust the call stack. The
212
+ * recursive engine and its per-shape sub-steps (inline concatenation, table cell,
213
+ * tight list-item) are nested inner functions - the only exported surface is
214
+ * `renderHTML` itself.
215
+ *
216
+ * @param node - The AST node to render (a full document, or any sub-node)
217
+ * @returns The rendered, XSS-safe HTML string
218
+ *
219
+ * @example
220
+ * ```ts
221
+ * renderHTML({ element: 'document', children: [
222
+ * { element: 'heading', level: 1, children: [{ element: 'text', value: 'Hi' }] },
223
+ * ] })
224
+ * // '<h1>Hi</h1>'
225
+ * ```
226
+ */
227
+ export declare function renderHTML(node: MarkdownNode): string;
228
+ /**
229
+ * Render a {@link MarkdownNode} to its CANONICAL markdown source - the inverse
230
+ * projection of `renderHTML`, and the serializer a `parse(renderMarkdown(doc))`
231
+ * round-trip is built on. Canonical forms: `*em*` / `**strong**` (underscore emphasis
232
+ * normalizes to asterisks), `- ` bullets, `N. ` sequential ordinals (from the list's
233
+ * `start`), `---` thematic breaks, fenced code blocks (backtick run widened past any
234
+ * 3+ backtick run inside the body), ATX headings, `> `-prefixed blockquote lines, GFM
235
+ * tables (1-space-padded cells, `\|`-escaped pipes, an alignment delimiter row), and
236
+ * `[text](href)` links. A `text` node's literal content is backslash-escaped wherever
237
+ * it would otherwise re-parse as markup (AGENTS §14 parse↔render soundness).
238
+ *
239
+ * @remarks
240
+ * Total: never throws. At {@link MAX_DEPTH} a value-bearing node degrades to its
241
+ * escaped `value`; any other node degrades to `''`. Blocks are joined by exactly one
242
+ * blank line; a document with zero blocks renders `''`.
243
+ *
244
+ * @param node - The AST node to render (a full document, or any sub-node)
245
+ * @returns The canonical markdown source
246
+ *
247
+ * @example
248
+ * ```ts
249
+ * renderMarkdown({ element: 'document', children: [
250
+ * { element: 'heading', level: 2, children: [{ element: 'text', value: 'Hi' }] },
251
+ * ] })
252
+ * // '## Hi'
253
+ * ```
254
+ */
255
+ export declare function renderMarkdown(node: MarkdownNode): string;
256
+ /**
257
+ * Depth-first, pre-order, root-inclusive traversal of a {@link MarkdownNode} - yields
258
+ * the node itself, then recurses into its children (block children, list items, table
259
+ * header/row cells' inline nodes) in walk order.
260
+ *
261
+ * @remarks
262
+ * Total: never throws. Descent stops at {@link MAX_DEPTH} (the node at the cap is
263
+ * still yielded; its children are not) so pathologically deep input cannot exhaust
264
+ * the call stack.
265
+ *
266
+ * @param node - The AST node to walk (a full document, or any sub-node)
267
+ * @returns A generator yielding every visited node, pre-order
268
+ *
269
+ * @example
270
+ * ```ts
271
+ * const doc = { element: 'document', children: [{ element: 'thematicBreak' }] } as const
272
+ * [...walkNodes(doc)].map((node) => node.element) // ['document', 'thematicBreak']
273
+ * ```
274
+ */
275
+ export declare function walkNodes(node: MarkdownNode): Generator<MarkdownNode>;
276
+ /**
277
+ * Fold a {@link MarkdownNode} into a `T` via a total catamorphism - children are
278
+ * folded first (post-order), then the node's own {@link MarkdownHandler} is invoked
279
+ * with the already-folded children.
280
+ *
281
+ * @remarks
282
+ * **Table contract.** A {@link TableNode} has no single `children` array - its cells
283
+ * live in `header` (one inline-node list per column) and `rows` (a list of such
284
+ * rows). The `table` handler receives ONE folded `T` per inline node, flattened in
285
+ * walk order across ALL cells - every header cell's inline nodes (column order), then
286
+ * every body row's cells' inline nodes (row order, then column order) - and reads
287
+ * `node.header[c].length` / `node.rows[r][c].length` off the table node itself to
288
+ * recover cell boundaries within the flat list.
289
+ *
290
+ * Total: never throws. At `depth >= {@link MAX_DEPTH}` the node's handler is invoked
291
+ * with an empty children list instead of recursing further.
292
+ *
293
+ * @param node - The AST node to fold
294
+ * @param handlers - The total {@link MarkdownHandlers} table, one handler per element
295
+ * @param depth - The starting recursion depth (pass `0` at the entry point)
296
+ * @returns The folded `T`
297
+ *
298
+ * @example
299
+ * ```ts
300
+ * const countHandlers: MarkdownHandlers<number> = {
301
+ * document: (_, children) => children.reduce((a, b) => a + b, 1),
302
+ * // ...one handler per element, each summing its folded children
303
+ * }
304
+ * foldNode(document, countHandlers, 0) // total node count
305
+ * ```
306
+ */
307
+ export declare function foldNode<T>(node: MarkdownNode, handlers: MarkdownHandlers<T>, depth: number): T;
308
+ /**
309
+ * Rewrite a {@link MarkdownDocument} bottom-up (copy-on-write) - each node's children
310
+ * are rewritten first (post-order), then `rewrite` is applied to the node itself; the
311
+ * document ROOT is never passed to `rewrite` (the `element: 'document'` invariant
312
+ * always holds). A table's inline cells and a list's items ARE rewritten.
313
+ *
314
+ * @remarks
315
+ * Never mutates `document` - every level is rebuilt into a fresh object/array, even
316
+ * when `rewrite` returns its input unchanged. When `rewrite` returns a node whose
317
+ * `element` does not fit the slot it was called for (a block slot handed a
318
+ * non-{@link BlockNode}, an inline slot handed a non-{@link InlineNode}, a list-item
319
+ * slot handed a non-`listItem`), the ill-fitting result is discarded and the
320
+ * freshly-rebuilt (unrewritten-at-this-level) node is kept instead - `rewriteDocument`
321
+ * stays total and never produces a structurally invalid document.
322
+ *
323
+ * Descent is capped at {@link MAX_DEPTH}, the same cap {@link walkNodes} and
324
+ * {@link foldNode} observe: at `depth >= MAX_DEPTH` the subtree is passed through
325
+ * UNCHANGED (by reference, not rebuilt, and `rewrite` is not invoked on it) instead of
326
+ * recursing further, so a pathologically deep adopted document cannot exhaust the
327
+ * call stack. {@link MarkdownInterface.map} inherits this cap since it delegates here.
328
+ *
329
+ * @param document - The document AST to rewrite
330
+ * @param rewrite - The bottom-up {@link MarkdownRewriteHandler}
331
+ * @returns A new, rewritten {@link MarkdownDocument}
332
+ *
333
+ * @example
334
+ * ```ts
335
+ * rewriteDocument(document, (node) =>
336
+ * node.element === 'text' ? { element: 'text', value: node.value.toUpperCase() } : node,
337
+ * )
338
+ * ```
339
+ */
340
+ export declare function rewriteDocument(document: MarkdownDocument, rewrite: MarkdownRewriteHandler): MarkdownDocument;
341
+ /**
342
+ * Concatenate the `value` / `code` content of every descendant text / code-span /
343
+ * code-block node under `node`, in walk order - the plain-text projection of an AST
344
+ * (search indexing, word counts, a text-only preview).
345
+ *
346
+ * @remarks
347
+ * Total: never throws. Descent stops at {@link MAX_DEPTH} (contributes `''` past the
348
+ * cap instead of recursing further).
349
+ *
350
+ * @param node - The AST node to flatten (a full document, or any sub-node)
351
+ * @returns The concatenated text content
352
+ *
353
+ * @example
354
+ * ```ts
355
+ * flattenText({ element: 'paragraph', children: [
356
+ * { element: 'text', value: 'a ' },
357
+ * { element: 'codeSpan', value: 'b' },
358
+ * ] })
359
+ * // 'a b'
360
+ * ```
361
+ */
362
+ export declare function flattenText(node: MarkdownNode): string;
@@ -0,0 +1,8 @@
1
+ export type * from './types.js';
2
+ export * from './constants.js';
3
+ export * from './helpers.js';
4
+ export * from './parsers.js';
5
+ export * from './shapers.js';
6
+ export * from './validators.js';
7
+ export * from './Markdown.js';
8
+ export * from './factories.js';