@thomasfosterau/prosekit-utilities 0.0.0 → 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/LICENSE +21 -0
- package/README.md +446 -1
- package/dist/derive-BtbsOqS7.js +479 -0
- package/dist/derive.d.ts +2 -0
- package/dist/derive.js +1 -0
- package/dist/document-shape-CrKjPIEf.d.ts +195 -0
- package/dist/drizzle.d.ts +76 -0
- package/dist/drizzle.js +95 -0
- package/dist/envelope-DQS7tt8f.js +87 -0
- package/dist/index-DlgceBHe.d.ts +266 -0
- package/dist/index.d.ts +203 -0
- package/dist/index.js +723 -0
- package/dist/prosekit.d.ts +3218 -0
- package/dist/prosekit.js +827 -0
- package/dist/rolldown-runtime-D8jQpDG7.js +12 -0
- package/package.json +66 -6
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
|
+
* `` 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) ? `})` : "";
|
|
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 };
|