@thomasfosterau/prosekit-utilities 0.0.0-stage → 0.1.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/dist/index.js ADDED
@@ -0,0 +1,723 @@
1
+ import { i as isSafeHref, n as toEditorDocument, r as DEFAULT_SAFE_SCHEMES, t as fromEditorDocument } from "./envelope-DQS7tt8f.js";
2
+ import * as Array from "effect/Array";
3
+ import * as Effect from "effect/Effect";
4
+ import * as HashSet from "effect/HashSet";
5
+ import * as Option from "effect/Option";
6
+ import * as Predicate from "effect/Predicate";
7
+ import * as Result from "effect/Result";
8
+ import * as Schema from "effect/Schema";
9
+ import * as String from "effect/String";
10
+ // src/allow-list.ts
11
+ /**
12
+ * Node / mark allow-lists: the narrowing a write seam applies on top of a
13
+ * document's grammar.
14
+ *
15
+ * ## Why an allow-list rather than another compiled schema
16
+ *
17
+ * A document's trust boundary (SEC-XSS-001) is the schema derived from the
18
+ * compiled ProseMirror grammar — one block grammar and one inline grammar,
19
+ * deliberately, so that the editor, the renderer and the column codec never
20
+ * disagree about what a node is. Named surfaces differ only in which subset
21
+ * of that vocabulary they admit: a public comment gets the minimal inline
22
+ * set, an article section gets tables and figures. Those are policy
23
+ * decisions, applied where the surface is known, not separate persistence
24
+ * formats.
25
+ *
26
+ * They are also cheap to apply as a tree-walk and expensive to apply as a
27
+ * schema: the derived schema is `Schema.suspend`-wrapped so the ProseMirror
28
+ * compile stays lazy, and `Schema.check` cannot be appended to a `Suspend`.
29
+ * {@link allowListFilter} is composable onto a `DocumentShape`'s schema
30
+ * (which is not suspended) for that reason; {@link checkAllowList} is the
31
+ * Effect-shaped seam for everything else.
32
+ *
33
+ * Link `href`s are additionally checked against {@link isSafeHref} unless a
34
+ * list opts out — an unsafe scheme is a violation at the write boundary, not
35
+ * merely something the renderer drops later.
36
+ *
37
+ * @module
38
+ */
39
+ /**
40
+ * Builds an {@link AllowList} from any iterables of node and mark type
41
+ * names — an array literal, a `Set`, another list's `HashSet`.
42
+ *
43
+ * `checkLinkHref` defaults to `true`: an allow-list that admits the `link`
44
+ * mark is opting into untrusted URLs, and the scheme check is the cheap half
45
+ * of that. Pass `false` only where something else already decides.
46
+ */
47
+ const defineAllowList = (options) => ({
48
+ label: options.label,
49
+ nodes: HashSet.fromIterable(options.nodes),
50
+ marks: HashSet.fromIterable(options.marks),
51
+ checkLinkHref: options.checkLinkHref ?? true
52
+ });
53
+ /**
54
+ * The first thing an allow-list walk found outside its vocabulary, as data
55
+ * rather than as a bare string.
56
+ *
57
+ * `kind` says which of the three checks failed and `name` says what tripped
58
+ * it (the offending node or mark type), so a caller can map a violation onto
59
+ * its own error envelope without re-parsing `message`. `message` carries the
60
+ * human-readable form both source applications emitted, unchanged, because
61
+ * it is what an API returns to the author.
62
+ */
63
+ var DocumentViolation = class extends (/* @__PURE__ */ Schema.TaggedError()("DocumentViolation", {
64
+ label: Schema.String,
65
+ kind: /* @__PURE__ */ Schema.Literals([
66
+ "node",
67
+ "mark",
68
+ "href"
69
+ ]),
70
+ name: Schema.String,
71
+ message: Schema.String
72
+ })) {};
73
+ /**
74
+ * A node's or mark's `type`, or `''` for anything that is not one at all.
75
+ *
76
+ * `''` is in no vocabulary, so a `null`, a stray string or a node missing
77
+ * its `type` reports as a violation rather than throwing halfway through the
78
+ * walk. The walk runs on values a structural check has only proved to be
79
+ * JSON, so that case is reachable.
80
+ */
81
+ const typeNameOf = (value) => Predicate.isObjectOrArray(value) && Predicate.isString(value.type) ? value.type : "";
82
+ const nodeViolationOf = (allowList, type) => new DocumentViolation({
83
+ label: allowList.label,
84
+ kind: "node",
85
+ name: type,
86
+ message: `node type "${type}" is not permitted in ${allowList.label}`
87
+ });
88
+ const markViolationOf = (allowList, type) => new DocumentViolation({
89
+ label: allowList.label,
90
+ kind: "mark",
91
+ name: type,
92
+ message: `mark type "${type}" is not permitted in ${allowList.label}`
93
+ });
94
+ const hrefViolationOf = (allowList) => new DocumentViolation({
95
+ label: allowList.label,
96
+ kind: "href",
97
+ name: "link",
98
+ message: `link href is not a permitted URL scheme`
99
+ });
100
+ const markViolation = (allowList) => (mark) => {
101
+ const type = typeNameOf(mark);
102
+ if (!HashSet.has(allowList.marks, type)) return Result.succeed(markViolationOf(allowList, type));
103
+ if (allowList.checkLinkHref && type === "link" && !isSafeHref(mark.attrs?.href)) return Result.succeed(hrefViolationOf(allowList));
104
+ return Result.failVoid;
105
+ };
106
+ const firstMarkViolation = (allowList, node) => {
107
+ const marks = node.marks;
108
+ if (!Array.isArray(marks)) return Option.none();
109
+ return Array.head(Array.filterMap(marks, markViolation(allowList)));
110
+ };
111
+ const firstViolation = (allowList, nodes) => Array.head(Array.filterMap(nodes, nodeViolation(allowList)));
112
+ const nodeViolation = (allowList) => (node) => {
113
+ const type = typeNameOf(node);
114
+ if (!HashSet.has(allowList.nodes, type)) return Result.succeed(nodeViolationOf(allowList, type));
115
+ const marks = firstMarkViolation(allowList, node);
116
+ if (Option.isSome(marks)) return Result.succeed(marks.value);
117
+ const content = node.content;
118
+ if (!Array.isArray(content)) return Result.failVoid;
119
+ const nested = firstViolation(allowList, content);
120
+ return Option.isNone(nested) ? Result.failVoid : Result.succeed(nested.value);
121
+ };
122
+ /**
123
+ * The first node or mark type outside `allowList`'s vocabulary — or the
124
+ * first unsafe `link` `href` — in an already shape-valid document.
125
+ *
126
+ * Depth-first in document order, so the violation names the earliest
127
+ * offender, and `Option.none()` when the whole tree is admissible. The
128
+ * envelope's own `type` is not checked against the node list; only its
129
+ * `content` children are.
130
+ */
131
+ const allowListViolation = (allowList, doc) => firstViolation(allowList, doc.content);
132
+ /**
133
+ * The Effect-shaped write seam: succeeds with the document it was given, or
134
+ * fails with the first {@link DocumentViolation}.
135
+ *
136
+ * Curried so an application can name the check once
137
+ * (`const checkComment = checkAllowList(COMMENT)`) and apply it per write,
138
+ * and generic in the document so the value that comes out is the exact type
139
+ * that went in — including a branded or narrowed one.
140
+ */
141
+ const checkAllowList = (allowList) => (doc) => Effect.gen(function* () {
142
+ const violation = allowListViolation(allowList, doc);
143
+ if (Option.isSome(violation)) return yield* violation.value;
144
+ return doc;
145
+ });
146
+ /**
147
+ * The same walk as a `Schema.check`-compatible filter, so an allow-list can
148
+ * be composed onto a schema rather than applied beside it.
149
+ *
150
+ * This is what `DocumentShape.narrow` uses. The failure message is the
151
+ * violation's own `message`, so a decode error reads exactly like the
152
+ * write-seam error for the same document.
153
+ */
154
+ const allowListFilter = (allowList) => Schema.makeFilter((value) => Option.match(allowListViolation(allowList, value), {
155
+ onNone: () => true,
156
+ onSome: (violation) => violation.message
157
+ }), {
158
+ identifier: "documentAllowList",
159
+ title: `${allowList.label} allow-list`,
160
+ description: `Rejects a document carrying a node or mark type outside ${allowList.label}'s vocabulary.`
161
+ });
162
+ // src/document-shape.ts
163
+ /**
164
+ * Document shapes: the ProseKit-free view onto a document wire schema.
165
+ *
166
+ * ## Why this module exists
167
+ *
168
+ * A schema derived from a compiled ProseMirror grammar (the ProseKit entry point's
169
+ * `defineDocumentSchema`) has to import the editor runtime — `createEditor`
170
+ * and the whole node / mark registry — to exist at all. A model layer that
171
+ * declares a document-valued attribute is genuinely browser-reachable (API
172
+ * clients, form components), so declaring it with the derived schema
173
+ * statically bundles the editor, the single largest client chunk, into every
174
+ * page including anonymous read-only ones.
175
+ *
176
+ * ## What a shape does instead
177
+ *
178
+ * A {@link DocumentShape} validates the ProseMirror JSON tree
179
+ * **structurally** — the top-level `type`, and that `content` is an array of
180
+ * JSON — with no ProseKit import at all. On top of that sits a pluggable
181
+ * grammar check: a server runtime calls {@link DocumentShape.install} once
182
+ * (the ProseKit entry point's `installDocumentGrammars` does it for a whole set), and
183
+ * from then on the shape rejects exactly what the derived schema rejects.
184
+ *
185
+ * The structural check is a strict superset of the grammar check — anything
186
+ * the compiled ProseMirror schema accepts passes it — so an uninstalled
187
+ * runtime is permissive, never spuriously rejecting. That direction matters:
188
+ * a client that wrongly rejected a valid document would break authoring,
189
+ * whereas a client that wrongly accepts an invalid one is caught by the
190
+ * server that actually decides. SEC-XSS-001's trust boundary is unchanged on
191
+ * the server, and the browser — which is not a trust boundary — gets the
192
+ * structural check alone.
193
+ *
194
+ * @module
195
+ */
196
+ /**
197
+ * The document's `content`: an array of ProseMirror node JSON, checked only
198
+ * as far as "it is JSON".
199
+ *
200
+ * **Deliberately shallow, and measured.** An earlier version of this check
201
+ * walked the node graph structurally — `type` / `attrs` / `marks` /
202
+ * `content` / `text` per node, recursively. That is redundant work on a
203
+ * server, where the installed grammar guard already checks strictly more
204
+ * than any structural walk can, and near-worthless in a browser, which
205
+ * validates documents the server produced rather than untrusted input. It
206
+ * was not free: on a 20-paragraph document the deep walk cost 0.157 ms
207
+ * against the grammar check's own 0.141 ms — a 1.76x decode, per document,
208
+ * on a path an API runs per row. Flattening to `Schema.Json` takes the
209
+ * structural walk to 0.039 ms (1.26x), and costs the browser only per-node
210
+ * shape errors it was never the right place to catch.
211
+ *
212
+ * `Schema.Json` rather than `Schema.Unknown` because an open leaf here would
213
+ * put a fresh `Unknown` inside the response schema of every endpoint
214
+ * carrying a document attribute, which a wire contract that forbids
215
+ * structurally open response schemas (JSONAPI-WIRE-001 in the source
216
+ * applications) rejects without explicit coercion. `Schema.Json` is closed
217
+ * and JSON-safe by construction — and a non-JSON value (a `Date`, a
218
+ * function) is still rejected rather than waved through.
219
+ */
220
+ const ContentShape = /* @__PURE__ */ Schema.Array(Schema.Json);
221
+ /**
222
+ * Splits a camel-case shape name into the words a violation message reads
223
+ * with: `blockDocument` → `block document`.
224
+ */
225
+ const wordsOf = (name) => name.replace(/([a-z0-9])([A-Z])/g, "$1 $2").replace(/[_-]+/g, " ").toLowerCase();
226
+ /**
227
+ * Declares a document surface.
228
+ *
229
+ * ```ts
230
+ * const blockDocument = defineDocumentShape({ name: 'blockDocument' });
231
+ * ```
232
+ *
233
+ * Each call gets its own guard slot, so two shapes never share an install.
234
+ * `label` defaults to the name split into words (`blockDocument` → `block
235
+ * document`), which is what the validation message reads with: `must be a
236
+ * valid block document`.
237
+ */
238
+ function defineDocumentShape(options) {
239
+ const name = options.name;
240
+ const label = options.label ?? wordsOf(name);
241
+ let grammar = Option.none();
242
+ const documentSchema = Schema.Struct({
243
+ type: Schema.Literal(name),
244
+ content: ContentShape
245
+ }).pipe(Schema.check(Schema.makeFilter((value) => {
246
+ const check = grammar;
247
+ return Option.isNone(check) || check.value(value) ? true : `must be a valid ${label}`;
248
+ }, {
249
+ identifier: `${name}Grammar`,
250
+ title: `${label} grammar`,
251
+ description: `Rejects a structurally valid ProseMirror tree that the installed ${label} grammar does not accept.`
252
+ })));
253
+ const guard = Schema.is(documentSchema);
254
+ return {
255
+ name,
256
+ label,
257
+ Schema: documentSchema,
258
+ is: (value) => Result.getOrElse(Result.try(() => guard(value)), () => false),
259
+ install: (installed) => {
260
+ grammar = Option.some(installed);
261
+ },
262
+ isInstalled: () => Option.isSome(grammar),
263
+ empty: () => ({
264
+ type: name,
265
+ content: []
266
+ }),
267
+ narrow: (allowList) => documentSchema.pipe(Schema.check(allowListFilter(allowList)))
268
+ };
269
+ }
270
+ // src/document-text.ts
271
+ /**
272
+ * Plain-text extraction from a ProseMirror document.
273
+ *
274
+ * Two joins, because two callers want different things. A search index or an
275
+ * embedding queue wants block text with word boundaries preserved across
276
+ * nodes ({@link extractDocumentText}, `' '`); a title rendered back into
277
+ * prose must not gain spaces its author never typed
278
+ * ({@link inlineDocumentText}, `''`).
279
+ *
280
+ * Both are pure, total and DOM-free, so they run anywhere — including a
281
+ * Worker with no document.
282
+ *
283
+ * @module
284
+ */
285
+ /**
286
+ * Extracts plain text from a document, joining the `text` leaves with
287
+ * `options.separator` (default `' '`).
288
+ *
289
+ * Tolerant by design: `null`, `undefined`, a document with no `content`, a
290
+ * stray string or `null` inside `content`, and a `text`-typed node whose
291
+ * `text` is not a string all contribute nothing rather than throwing. The
292
+ * callers are database columns and queue payloads, where a malformed row
293
+ * must degrade to empty text rather than fail a batch.
294
+ */
295
+ function extractDocumentText(doc, options) {
296
+ if (!doc || !doc.content) return "";
297
+ return textParts(doc.content).join(options?.separator ?? " ");
298
+ }
299
+ /** The `text` leaves of one node subtree, in document order. */
300
+ function textParts(nodes) {
301
+ return Array.flatMap(nodes, (node) => {
302
+ if (!Predicate.isObjectOrArray(node)) return [];
303
+ const own = Predicate.hasProperty(node, "type") && node.type === "text" && Predicate.hasProperty(node, "text") && Predicate.isString(node.text) ? [node.text] : [];
304
+ return Predicate.hasProperty(node, "content") && Array.isArray(node.content) ? [...own, ...textParts(node.content)] : own;
305
+ });
306
+ }
307
+ /**
308
+ * Extracts plain text from an inline document, joining the `text` leaves
309
+ * with nothing at all.
310
+ *
311
+ * This is the form a title or other flat field wants: `extractDocumentText`
312
+ * would turn `Rosa` + `canina` (two runs, one styled) into `Rosa canina`
313
+ * with a space that is already in the first run, or into `Rosacanina` with
314
+ * one missing — the `''` join keeps whatever whitespace the author wrote.
315
+ * Recurses into any node that carries `content`, so a wrapping inline node
316
+ * (a sidenote, a mention) still contributes its text.
317
+ */
318
+ function inlineDocumentText(doc) {
319
+ return inlineTextParts(doc).join("");
320
+ }
321
+ /** The `text` leaves of a document subtree, in document order. */
322
+ function inlineTextParts(node) {
323
+ return Array.flatMap(node?.content ?? [], (child) => child.text ? [child.text] : "content" in child ? inlineTextParts(child) : []);
324
+ }
325
+ // src/markdown/escape.ts
326
+ /**
327
+ * CommonMark escaping for literal text.
328
+ *
329
+ * @module
330
+ */
331
+ /** Every CommonMark inline metacharacter, in one class. */
332
+ const METACHARACTERS = /([\\`*_{}[\]()#+\-.!<>|~])/g;
333
+ /**
334
+ * Backslash-escapes the CommonMark inline metacharacters in literal text, so
335
+ * an author's `*` stays an asterisk instead of opening emphasis.
336
+ *
337
+ * Exported because an application's own node serialiser emits literal text
338
+ * too — a mention's `@label`, a caption — and must escape it the same way
339
+ * the stock serialisers do.
340
+ */
341
+ function escapeMarkdown(text) {
342
+ return text.replace(METACHARACTERS, "\\$1");
343
+ }
344
+ // src/markdown/stock.ts
345
+ /**
346
+ * The stock Markdown cases: every node and mark in the shared base grammar.
347
+ *
348
+ * These are what `createMarkdownSerialiser` starts from, and an application
349
+ * may replace any of them by name. They are exported individually so one can
350
+ * be reused inside a replacement (a custom `image` that adds alt text can
351
+ * still defer to `imageMarkdown` for the safe-URL check).
352
+ *
353
+ * @module
354
+ */
355
+ /**
356
+ * A node's children as an array, whether or not it has any.
357
+ *
358
+ * Shared by the engine and by every serialiser that renders its own
359
+ * children, so a missing `content` is one empty array rather than a
360
+ * different guard in each case.
361
+ */
362
+ const childrenOf = (node) => node.content ?? [];
363
+ /** `` `code` `` — verbatim, so the engine skips escaping and other emphasis. */
364
+ const codeMarkdown = (text) => `\`${text}\``;
365
+ /** `~~strike~~`. */
366
+ const strikeMarkdown = (text) => `~~${text}~~`;
367
+ /** `*italic*`. */
368
+ const italicMarkdown = (text) => `*${text}*`;
369
+ /** `**bold**`. */
370
+ const boldMarkdown = (text) => `**${text}**`;
371
+ /**
372
+ * `[text](href)`, but only for an `href` the safe-URL allow-list admits — an
373
+ * unsafe scheme degrades to the plain (still styled) link text rather than
374
+ * emitting an executable URI.
375
+ */
376
+ const linkMarkdown = (text, mark) => {
377
+ const href = mark.attrs?.href;
378
+ return isSafeHref(href) ? `[${text}](${href.trim()})` : text;
379
+ };
380
+ /**
381
+ * The marks with a Markdown rendering.
382
+ *
383
+ * Underline, highlight, subscript and superscript are deliberately absent:
384
+ * CommonMark has no form for them, and a mark with no entry passes its text
385
+ * through unchanged, which is what both source applications do.
386
+ */
387
+ const stockMarks = {
388
+ bold: boldMarkdown,
389
+ code: codeMarkdown,
390
+ italic: italicMarkdown,
391
+ link: linkMarkdown,
392
+ strike: strikeMarkdown
393
+ };
394
+ /**
395
+ * A hard break as a backslash line break, which is more robust than two
396
+ * trailing spaces (an editor or a diff tool that trims trailing whitespace
397
+ * cannot silently delete it).
398
+ */
399
+ const hardBreakMarkdown = () => "\\\n";
400
+ /** The inline nodes in the shared base grammar. */
401
+ const stockInlines = { hardBreak: hardBreakMarkdown };
402
+ /** A paragraph: its inline content, nothing more. */
403
+ const paragraphMarkdown = (node, context) => context.serialiseInline(childrenOf(node));
404
+ /** `## Heading`, clamped into the 1..6 range CommonMark defines. */
405
+ const headingMarkdown = (node, context) => {
406
+ const declared = Predicate.isNumber(node.attrs?.level) ? node.attrs.level : 1;
407
+ const level = Math.min(Math.max(declared, 1), 6);
408
+ return `${"#".repeat(level)} ${context.serialiseInline(childrenOf(node))}`;
409
+ };
410
+ /** `> quoted`, with a bare `>` on the blank lines between inner blocks. */
411
+ const blockquoteMarkdown = (node, context) => context.serialiseBlocks(childrenOf(node)).split("\n").map((line) => String.isNonEmpty(line) ? `> ${line}` : ">").join("\n");
412
+ /**
413
+ * A fenced code block.
414
+ *
415
+ * The language token is stripped of newlines and backticks so it cannot
416
+ * break out of the fence line, and the fence itself is at least one
417
+ * backtick longer than the longest run inside the code, so embedded triple
418
+ * backticks cannot close the block early.
419
+ */
420
+ const codeBlockMarkdown = (node) => {
421
+ const language = (Predicate.isString(node.attrs?.language) ? node.attrs.language : "").replace(/[\n\r`]/g, "");
422
+ const code = childrenOf(node).map((child) => child.text ?? "").join("");
423
+ const longestRun = Math.max(0, ...[...code.matchAll(/`+/g)].map((match) => match[0].length));
424
+ const fence = "`".repeat(Math.max(3, longestRun + 1));
425
+ return `${fence}${language}\n${code}\n${fence}`;
426
+ };
427
+ /** `---`. */
428
+ const horizontalRuleMarkdown = () => "---";
429
+ /**
430
+ * `![](src)` for a `src` the safe-URL allow-list admits; the empty string
431
+ * otherwise, which drops the block from the output entirely.
432
+ */
433
+ const imageMarkdown = (node) => {
434
+ const src = node.attrs?.src;
435
+ return isSafeHref(src) ? `![](${src.trim()})` : "";
436
+ };
437
+ /**
438
+ * A GitHub-flavoured pipe table, best-effort: the first row becomes the
439
+ * header, every row is padded out to the widest row's column count, and a
440
+ * cell's own block structure is flattened onto one line with its pipes
441
+ * escaped.
442
+ */
443
+ const tableMarkdown = (node, context) => {
444
+ const cellText = (cell) => context.serialiseBlocks(childrenOf(cell)).trim().replace(/\n+/g, " ").replace(/\|/g, "\\|");
445
+ return Array.match(childrenOf(node).filter((row) => row.type === "tableRow"), {
446
+ onEmpty: () => "",
447
+ onNonEmpty: (rows) => {
448
+ const [header, ...body] = Array.map(rows, (row) => childrenOf(row).map(cellText));
449
+ const columns = Math.max(header.length, ...body.map((row) => row.length), 1);
450
+ const pad = (cells) => `| ${Array.pad(cells, columns, "").join(" | ")} |`;
451
+ return [
452
+ pad(header),
453
+ `| ${Array.replicate("---", columns).join(" | ")} |`,
454
+ ...body.map(pad)
455
+ ].join("\n");
456
+ }
457
+ });
458
+ };
459
+ /**
460
+ * The block nodes in the shared base grammar.
461
+ *
462
+ * `list` is absent on purpose: consecutive `list` siblings are one list in
463
+ * the prosemirror-flat-list model, so the run, not the node, is the unit of
464
+ * serialisation and the engine owns it.
465
+ */
466
+ const stockBlocks = {
467
+ blockquote: blockquoteMarkdown,
468
+ codeBlock: codeBlockMarkdown,
469
+ heading: headingMarkdown,
470
+ horizontalRule: horizontalRuleMarkdown,
471
+ image: imageMarkdown,
472
+ paragraph: paragraphMarkdown,
473
+ table: tableMarkdown
474
+ };
475
+ // src/markdown/serialiser.ts
476
+ /**
477
+ * The Markdown serialiser engine: the dispatch, the mark ladder and the
478
+ * prosemirror-flat-list run model that every stock and application node
479
+ * serialiser plugs into.
480
+ *
481
+ * Walks the ProseMirror document JSON tree directly (no DOM) and emits
482
+ * clean, CommonMark-flavoured Markdown — the representation an agent-facing
483
+ * `Accept: text/markdown` surface serves. A pure, dependency-free tree-walk,
484
+ * so it runs on a Worker without a DOM shim, unlike the HTML serialiser in
485
+ * the ProseKit entry point.
486
+ *
487
+ * Security: link `href`s and image `src`s go through the same `isSafeHref`
488
+ * allow-list the HTML serialiser uses; an unsafe `href` degrades to plain
489
+ * link text, never an executable URI.
490
+ *
491
+ * @module
492
+ */
493
+ /**
494
+ * Emphasis marks in the order they wrap, innermost first.
495
+ *
496
+ * Fixed rather than document order, because a text run's `marks` array is in
497
+ * whatever order the editor happened to add them and `***~~x~~***` must come
498
+ * out the same way every time. A mark outside this list (an application's
499
+ * own) wraps after these and before the link.
500
+ */
501
+ const EMPHASIS_ORDER = [
502
+ "strike",
503
+ "italic",
504
+ "bold"
505
+ ];
506
+ /** One nesting level of list indentation. */
507
+ const LIST_INDENT = " ";
508
+ /**
509
+ * The serialiser registered for a type name: the application's own entry if
510
+ * it has one, the stock entry otherwise — the application's map merged over
511
+ * the stock one.
512
+ */
513
+ const serialiserLookup = (stock, overrides) => (type) => Option.fromNullishOr(overrides[type] ?? stock[type]);
514
+ /** Marker for one list item, given the list node and its position. */
515
+ const listMarker = (item, index) => {
516
+ const attrs = item.attrs;
517
+ if (attrs === void 0) return "- ";
518
+ if (attrs.kind === "ordered") return `${(Predicate.isNumber(attrs.order) ? attrs.order : 1) + index}. `;
519
+ if (attrs.kind === "task") return attrs.checked === true ? "- [x] " : "- [ ] ";
520
+ return "- ";
521
+ };
522
+ /** The first of `marks` whose type is `type`. */
523
+ const markOfType = (marks, type) => Array.findFirst(marks, (mark) => mark.type === type);
524
+ /**
525
+ * Builds a Markdown serialiser.
526
+ *
527
+ * The stock cases — paragraph, heading, blockquote, code block, horizontal
528
+ * rule, image, table, hard break and the five marks with a Markdown form —
529
+ * are built in, along with the flat-list run model and the fallback that
530
+ * emits an unknown node's inline text rather than dropping it. `blocks`,
531
+ * `inlines` and `marks` are merged **over** the stock maps, so an
532
+ * application adds its own nodes (and may replace a stock one) without
533
+ * restating any of this:
534
+ *
535
+ * ```ts
536
+ * const toMarkdown = createMarkdownSerialiser({
537
+ * inlines: { mention: mentionMarkdown },
538
+ * blocks: { figure: figureMarkdown },
539
+ * });
540
+ * ```
541
+ *
542
+ * `list` is the one type a `blocks` entry cannot override: consecutive
543
+ * `list` siblings are one list, so the run — not the node — is the unit of
544
+ * serialisation, and that grouping lives here.
545
+ */
546
+ function createMarkdownSerialiser(options) {
547
+ const blocks = serialiserLookup(stockBlocks, options?.blocks ?? {});
548
+ const inlines = serialiserLookup(stockInlines, options?.inlines ?? {});
549
+ const marks = serialiserLookup(stockMarks, options?.marks ?? {});
550
+ /** Wraps `text` in `mark`'s syntax when a serialiser exists for it. */
551
+ const applyMark = (text, mark) => Option.match(marks(mark.type), {
552
+ onNone: () => text,
553
+ onSome: (serialiser) => serialiser(text, mark)
554
+ });
555
+ /**
556
+ * Applies a text node's marks to its text.
557
+ *
558
+ * `code` is verbatim: its content is never escaped and the other emphasis
559
+ * marks do not wrap a code span, because they have no meaning inside one.
560
+ * A `link` always wraps outermost, so the styled text becomes the label.
561
+ * A mark with no serialiser (underline, highlight, sub- and superscript
562
+ * have no Markdown form) passes through as plain styled-or-unstyled text.
563
+ */
564
+ const applyMarks = (text, nodeMarks) => Array.match(nodeMarks, {
565
+ onEmpty: () => escapeMarkdown(text),
566
+ onNonEmpty: (present) => {
567
+ const verbatim = Option.zipWith(markOfType(present, "code"), marks("code"), (codeMark, serialiser) => serialiser(text, codeMark));
568
+ const styled = Option.getOrElse(verbatim, () => Array.reduce(present, Array.reduce(EMPHASIS_ORDER, escapeMarkdown(text), (out, type) => Option.match(markOfType(present, type), {
569
+ onNone: () => out,
570
+ onSome: (mark) => applyMark(out, mark)
571
+ })), (out, mark) => mark.type === "code" || mark.type === "link" || EMPHASIS_ORDER.includes(mark.type) ? out : applyMark(out, mark)));
572
+ return Option.getOrElse(Option.zipWith(markOfType(present, "link"), marks("link"), (link, serialiser) => serialiser(styled, link)), () => styled);
573
+ }
574
+ });
575
+ /** Serialise a run of inline nodes to a single line-ish string. */
576
+ const serialiseInline = (nodes) => Array.map(nodes, (node) => node.type === "text" && Predicate.isString(node.text) ? applyMarks(node.text, node.marks ?? []) : Option.match(inlines(node.type), {
577
+ onNone: () => "",
578
+ onSome: (serialiser) => serialiser(node, context)
579
+ })).join("");
580
+ /**
581
+ * Serialise a run of sibling `list` nodes. In the prosemirror-flat-list
582
+ * model each `list` node is one item and consecutive siblings form the
583
+ * list; a nested list is a `list` child inside an item's content, so it
584
+ * recurses with deeper indentation.
585
+ */
586
+ const serialiseListRun = (items, indent) => Array.flatMap(items, (item, index) => {
587
+ const marker = listMarker(item, index);
588
+ const itemBlocks = childrenOf(item);
589
+ const ownBlocks = itemBlocks.filter((child) => child.type !== "list");
590
+ const nested = itemBlocks.filter((child) => child.type === "list");
591
+ const body = serialiseBlocks(ownBlocks).trim();
592
+ const continuation = indent + " ".repeat(marker.length);
593
+ const bodyLines = body.split("\n");
594
+ const line = indent + marker + (bodyLines[0] ?? "") + bodyLines.slice(1).map((next) => "\n" + (String.isNonEmpty(next) ? continuation + next : "")).join("");
595
+ return Array.match(nested, {
596
+ onEmpty: () => [line],
597
+ onNonEmpty: (sublist) => [line, serialiseListRun(sublist, indent + LIST_INDENT)]
598
+ });
599
+ }).join("\n");
600
+ /** Serialise a single block node (no trailing blank line). */
601
+ const serialiseBlock = (node) => Option.match(blocks(node.type), {
602
+ onSome: (serialiser) => serialiser(node, context),
603
+ onNone: () => serialiseInline(childrenOf(node))
604
+ });
605
+ /**
606
+ * Serialise an array of block nodes, grouping consecutive `list` siblings
607
+ * into one list and separating blocks with blank lines. Every other block
608
+ * is a group of its own, and one that renders to nothing is dropped.
609
+ */
610
+ const serialiseBlocks = (nodes) => Array.match(nodes, {
611
+ onEmpty: () => "",
612
+ onNonEmpty: (present) => Array.filterMap(Array.groupWith(present, (left, right) => left.type === "list" && right.type === "list"), (group) => {
613
+ if (group[0].type === "list") return Result.succeed(serialiseListRun(group, ""));
614
+ const rendered = serialiseBlock(group[0]);
615
+ return String.isNonEmpty(rendered) ? Result.succeed(rendered) : Result.failVoid;
616
+ }).join("\n\n")
617
+ });
618
+ const context = {
619
+ serialiseInline,
620
+ serialiseBlocks,
621
+ escape: escapeMarkdown
622
+ };
623
+ return {
624
+ document: (doc) => serialiseBlocks(childrenOf(doc)).trim(),
625
+ inline: (doc) => serialiseInline(childrenOf(doc)).trim()
626
+ };
627
+ }
628
+ // src/markdown/optional.ts
629
+ /**
630
+ * Markdown cases for the opt-in nodes — the ones an application composes
631
+ * into its grammar rather than getting for free.
632
+ *
633
+ * Each is an ordinary {@link BlockSerialiser} / {@link InlineSerialiser}, so
634
+ * it is wired in through `createMarkdownSerialiser`'s option maps:
635
+ *
636
+ * ```ts
637
+ * const toMarkdown = createMarkdownSerialiser({
638
+ * blocks: { figure: figureMarkdown },
639
+ * inlines: { mention: mentionMarkdown, sidenote: sidenoteMarkdown },
640
+ * });
641
+ * ```
642
+ *
643
+ * They exist because a node with no faithful Markdown form must still emit
644
+ * its closest textual equivalent rather than vanish: the Markdown has to
645
+ * stay a truthful reflection of what the HTML renders.
646
+ *
647
+ * @module
648
+ */
649
+ /**
650
+ * A mention as its `@label`, escaped.
651
+ *
652
+ * The referenced account's id has no Markdown form and is dropped: a
653
+ * mention is a link to something only the application can resolve, so the
654
+ * text is all that survives. The label is escaped like any other literal
655
+ * text, so a display name containing `*` cannot open emphasis.
656
+ */
657
+ const mentionMarkdown = (node, context) => {
658
+ const label = Predicate.isString(node.attrs?.label) ? node.attrs.label : "";
659
+ return context.escape(`@${label}`);
660
+ };
661
+ /**
662
+ * A figure as its image line followed by the caption as an italic
663
+ * paragraph — Markdown has no figure element, and this is the form a reader
664
+ * renders closest to one.
665
+ *
666
+ * A figure whose image is dropped (an unsafe `src`) still emits its caption;
667
+ * a figure with no caption emits the image line alone.
668
+ */
669
+ const figureMarkdown = (node, context) => {
670
+ const [image, caption] = childrenOf(node);
671
+ const imageLine = image === void 0 ? "" : context.serialiseBlocks([image]);
672
+ const captionText = caption === void 0 ? "" : context.serialiseInline(childrenOf(caption)).trim();
673
+ return String.isNonEmpty(captionText) ? `${imageLine}\n_${captionText}_` : imageLine;
674
+ };
675
+ /**
676
+ * A sidenote as a Pandoc inline footnote (`^[note]`).
677
+ *
678
+ * This is the established Markdown form for a reference aside with no
679
+ * separate definition block: it keeps the note where it sits in the prose
680
+ * and renders as a footnote in Pandoc-flavoured pipelines, the closest
681
+ * faithful analogue to a margin note. `^[…]` is a Pandoc extension, not core
682
+ * CommonMark; a plain CommonMark reader shows the bracketed content inline,
683
+ * which still reads as a parenthetical aside.
684
+ *
685
+ * The note holds rich inline content, so its children go through the same
686
+ * inline pass body prose does — `^[…]` legitimately contains inline Markdown
687
+ * (`[t](u)`, `*em*`, `` `code` ``), and literal `[` / `]` in a text run is
688
+ * already backslash-escaped so it cannot close the note early. Any newline
689
+ * (a hard break renders as `\␤`) is collapsed to a space afterwards: a
690
+ * literal newline would terminate the inline footnote and split the
691
+ * surrounding paragraph.
692
+ *
693
+ * A nested sidenote is flattened to its body first. A grammar restricting a
694
+ * sidenote to `(text | hardBreak)*` rules one out, but a persistence guard
695
+ * checks the node registry rather than content expressions, so a
696
+ * hand-crafted or migrated document can still carry one — and `^[…^[…]…]`
697
+ * has no valid Pandoc rendering, since the inner `]` closes the outer note.
698
+ */
699
+ const sidenoteMarkdown = (node, context) => {
700
+ const inlineChildren = childrenOf(node).flatMap((child) => child.type === "sidenote" ? childrenOf(child) : [child]);
701
+ const body = context.serialiseInline(inlineChildren).replace(/\\?\n/g, " ").trim();
702
+ return String.isNonEmpty(body) ? `^[${body}]` : "";
703
+ };
704
+ // src/markdown/index.ts
705
+ /**
706
+ * Markdown serialisation for ProseKit documents.
707
+ *
708
+ * `createMarkdownSerialiser` builds one; `toMarkdown` is the ready-made
709
+ * instance over the shared base grammar; the entries in `./optional.ts`
710
+ * cover the opt-in nodes an application composes in.
711
+ *
712
+ * @module
713
+ */
714
+ /**
715
+ * The default serialiser: the shared base grammar and nothing else.
716
+ *
717
+ * An application whose grammar includes an opt-in node builds its own with
718
+ * `createMarkdownSerialiser` rather than reaching for this one, because a
719
+ * node with no serialiser falls back to its inline text — readable, but not
720
+ * a faithful rendering of a figure or a sidenote.
721
+ */
722
+ const toMarkdown = /* @__PURE__ */ createMarkdownSerialiser();
723
+ export { DEFAULT_SAFE_SCHEMES, DocumentViolation, allowListFilter, allowListViolation, checkAllowList, createMarkdownSerialiser, defineAllowList, defineDocumentShape, escapeMarkdown, extractDocumentText, figureMarkdown, fromEditorDocument, inlineDocumentText, isSafeHref, mentionMarkdown, sidenoteMarkdown, toEditorDocument, toMarkdown };