@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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Thomas Foster
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,448 @@
1
- # Temporary Holding Version
1
+ # @thomasfosterau/prosekit-utilities
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ A ProseKit document grammar as data: browser-cheap document shapes and node/mark allow-lists, an [Effect Schema](https://effect.website) derived at runtime from a compiled ProseMirror schema, HTML and Markdown serialisation, and Drizzle `jsonb` document columns. Four entry points, split by what they cost to import: `.` and `./drizzle` never touch ProseKit, `./derive` only its types, and `./prosekit` is the one entry that pulls in the editor runtime — so a browser-reachable model layer can name a document type without shipping the editor to every page. Everything but the Svelte packages in this workspace is Effect-first: domain failures are `Schema.TaggedError` values, `Option`/`Result` stand in for `null`/exceptions, and a function that cannot fail stays a plain function.
4
+
5
+ ## Installation
6
+
7
+ ```sh
8
+ pnpm add @thomasfosterau/prosekit-utilities effect prosekit @prosekit/pm
9
+ ```
10
+
11
+ | Package | Range | Why |
12
+ | --- | --- | --- |
13
+ | `effect` | `^4.0.0` | Required. Every entry point returns or consumes `effect/Schema`, `Option`, `Result` or `Effect` values. |
14
+ | `prosekit` | `^0.22.2` | Required by `./prosekit` only. The node/mark definitions, `lazySchema` and `createHtmlRenderer` run the real editor runtime. |
15
+ | `@prosekit/pm` | `^0.1.19` | Required by `./prosekit` only (and by `./derive`'s types). ProseMirror's own model, which ProseKit re-exports. |
16
+ | `drizzle-orm` | `^1.0.0-rc.5` | Optional. Only `./drizzle` reaches for it. |
17
+ | `linkedom` | `^0.18.13` | Optional. Only `./prosekit`'s `createHtmlRenderer` reaches for it, and only when rendering on a server with no global `document` — see [`./prosekit` § HTML](#prosekit-html). |
18
+
19
+ A consumer that only ever imports the root entry (a browser-reachable model layer, say) needs `effect` alone; `prosekit` and `@prosekit/pm` are declared peers of the package as a whole because `./prosekit` is part of it, but nothing outside that entry imports them.
20
+
21
+ ## Entry points
22
+
23
+ | Entry | Holds | Imports ProseKit at runtime |
24
+ | --- | --- | --- |
25
+ | `.` | Document shapes, allow-lists, safe URLs, plain-text extraction, the envelope remap, the Markdown serialiser | **Never** |
26
+ | `./derive` | The derivation engine: an Effect Schema read back from a compiled ProseMirror schema | Never (`@prosekit/pm` types only) |
27
+ | `./prosekit` | Node and mark definitions, the extension presets, `lazySchema`, `defineDocumentSchema`, `installDocumentGrammars`, `createHtmlRenderer` | **Yes** — `prosekit/core`, `prosekit/extensions/*` |
28
+ | `./drizzle` | `jsonb` document columns and the SQL twin of text extraction | Never |
29
+
30
+ ## Declaring a document surface
31
+
32
+ A document surface is declared in layers, each one importing only as much as it needs. The example below threads all four entry points through one `blockDocument` surface that composes an opt-in `mention` node, and a narrower `COMMENT` allow-list applied where comments are written.
33
+
34
+ ```ts
35
+ // document-shapes.ts — browser-reachable, no ProseKit
36
+ import { defineDocumentShape } from '@thomasfosterau/prosekit-utilities';
37
+
38
+ export const blockDocument = defineDocumentShape({ name: 'blockDocument' });
39
+ ```
40
+
41
+ ```ts
42
+ // document-schemas.ts — server-only: the grammar, and the derived wire schema
43
+ import { createRegistryCache } from '@thomasfosterau/prosekit-utilities/derive';
44
+ import {
45
+ defineBlockDocumentExtension,
46
+ defineDocumentSchema,
47
+ defineMention,
48
+ MENTION_ATTRIBUTE_OVERRIDES,
49
+ mergeAttributeOverrides,
50
+ STANDARD_ATTRIBUTE_OVERRIDES,
51
+ } from '@thomasfosterau/prosekit-utilities/prosekit';
52
+ import { blockDocument } from './document-shapes.ts';
53
+
54
+ // Shared so a second document schema built from an overlapping extension
55
+ // derives the marks and `text` node once instead of once per schema, while
56
+ // each stays independently lazy. Share it only with a grammar that admits the
57
+ // same marks: a grammar with an extra mark (say `defineCitation()`) needs its
58
+ // own cache, or the first one forced decides `text`'s marks for both.
59
+ const registryCache = createRegistryCache();
60
+
61
+ export const blockDocumentSchema = defineDocumentSchema(blockDocument, {
62
+ extension: () => defineBlockDocumentExtension(defineMention()),
63
+ attributes: mergeAttributeOverrides(STANDARD_ATTRIBUTE_OVERRIDES, MENTION_ATTRIBUTE_OVERRIDES),
64
+ registryCache,
65
+ });
66
+ ```
67
+
68
+ ```ts
69
+ // db/schema/posts.ts — the persistence module: the install site and the column
70
+ import { pgTable, uuid } from 'drizzle-orm/pg-core';
71
+ import { documentColumn, documentTextSql } from '@thomasfosterau/prosekit-utilities/drizzle';
72
+ import { installDocumentGrammars } from '@thomasfosterau/prosekit-utilities/prosekit';
73
+ import { blockDocumentSchema } from '../document-schemas.ts';
74
+
75
+ // Every server runtime loads this module before handling a request, and no
76
+ // browser bundle can reach it — the one call site for the whole isolate.
77
+ installDocumentGrammars(blockDocumentSchema);
78
+
79
+ export const posts = pgTable('posts', {
80
+ id: uuid('id').primaryKey().defaultRandom(),
81
+ body: documentColumn(blockDocumentSchema)('body_jsonb').notNull(),
82
+ });
83
+
84
+ // The SQL twin of `extractDocumentText`, for a generated or materialised column.
85
+ export const postSearchText = documentTextSql(posts.body);
86
+ ```
87
+
88
+ ```ts
89
+ // posts.server.ts — rendering HTML in a server load
90
+ import { createHtmlRenderer } from '@thomasfosterau/prosekit-utilities/prosekit';
91
+ import { blockDocumentSchema } from './document-schemas.ts';
92
+
93
+ // One renderer per grammar, at module scope — it holds the resolved DOM.
94
+ const renderer = createHtmlRenderer({ schema: blockDocumentSchema.schema });
95
+
96
+ export async function loadPost(id: string) {
97
+ const post = await getPost(id);
98
+ return { post, bodyHtml: await renderer.renderPromise(post.body) };
99
+ }
100
+ ```
101
+
102
+ ```ts
103
+ // comments.ts — a narrower allow-list at the write seam
104
+ import {
105
+ checkAllowList,
106
+ defineAllowList,
107
+ type DocumentJSON,
108
+ } from '@thomasfosterau/prosekit-utilities';
109
+ import * as Effect from 'effect/Effect';
110
+
111
+ const COMMENT = defineAllowList({
112
+ label: 'a comment',
113
+ nodes: ['paragraph', 'text', 'hardBreak'],
114
+ marks: ['bold', 'italic', 'link'],
115
+ });
116
+ const checkComment = checkAllowList(COMMENT);
117
+
118
+ export const submitComment = Effect.fn(function* (body: DocumentJSON<'blockDocument'>) {
119
+ yield* checkComment(body); // fails with DocumentViolation
120
+ return yield* persistComment(body);
121
+ });
122
+ ```
123
+
124
+ ---
125
+
126
+ # `.` — the ProseKit-free surface
127
+
128
+ ```ts
129
+ import { defineDocumentShape, defineAllowList } from '@thomasfosterau/prosekit-utilities';
130
+ ```
131
+
132
+ Document shapes, allow-lists, safe URLs, plain-text extraction, the envelope remap and the Markdown serialiser — `effect` is the only dependency, and nothing here imports `prosekit` or `@prosekit/pm`, not even a type.
133
+
134
+ | Export | Signature | Notes |
135
+ | --- | --- | --- |
136
+ | `NodeJSON` | `interface NodeJSON { readonly type: string; readonly attrs?: Record<string, unknown> \| undefined; readonly content?: ReadonlyArray<NodeJSON> \| undefined; readonly marks?: ReadonlyArray<MarkJSON> \| undefined; readonly text?: string \| undefined }` | A structural twin of `prosekit/core`'s `NodeJSON`, restated so this entry needs no ProseKit type import. |
137
+ | `MarkJSON` | `interface MarkJSON { readonly type: string; readonly attrs?: Record<string, unknown> \| undefined }` | |
138
+ | `DocumentJSON<Name>` | `{ readonly type: Name; readonly content: ReadonlyArray<NodeJSON> }` | The persisted envelope. `Name` defaults to `string`. |
139
+ | `DocumentGrammarGuard` | `(value: unknown) => boolean` | What `./prosekit`'s `DocumentSchema.is` provides, and what `DocumentShape.install` accepts. |
140
+ | `defineDocumentShape` | `<Name extends string>(options: { readonly name: Name; readonly label?: string \| undefined }) => DocumentShape<Name>` | One closure per shape, so two shapes never share an install. `label` defaults to the name split into words (`blockDocument` → `block document`). |
141
+ | `DocumentShape<Name>` | `{ readonly name: Name; readonly label: string; readonly Schema: Schema.Codec<DocumentJSON<Name>>; readonly is: (v: unknown) => v is DocumentJSON<Name>; readonly install: (guard: DocumentGrammarGuard) => void; readonly isInstalled: () => boolean; readonly empty: () => DocumentJSON<Name>; readonly narrow: (allowList: AllowList) => Schema.Codec<DocumentJSON<Name>> }` | `Schema` checks the envelope's `type` and that `content` is an array of JSON — deliberately shallow, upgraded by `install` to the full grammar for the isolate's life. `is` keeps a try/catch defect net. |
142
+ | `AllowList` | `{ readonly label: string; readonly nodes: HashSet.HashSet<string>; readonly marks: HashSet.HashSet<string>; readonly checkLinkHref: boolean }` | One write surface's vocabulary. |
143
+ | `defineAllowList` | `(options: { readonly label: string; readonly nodes: Iterable<string>; readonly marks: Iterable<string>; readonly checkLinkHref?: boolean \| undefined }) => AllowList` | Any iterable of type names. `checkLinkHref` defaults to `true`. |
144
+ | `DocumentViolation` | `class DocumentViolation extends Schema.TaggedError<DocumentViolation>()('DocumentViolation', { label: Schema.String, kind: Schema.Literals(['node', 'mark', 'href']), name: Schema.String, message: Schema.String }) {}` | The three violation forms as data. Yieldable in an `Effect.gen`. |
145
+ | `allowListViolation` | `(allowList: AllowList, doc: { readonly content: ReadonlyArray<NodeJSON> }) => Option.Option<DocumentViolation>` | Depth-first, document order, first offender. The envelope's own `type` is not checked — only its `content`. |
146
+ | `checkAllowList` | `(allowList: AllowList) => <D extends { readonly content: ReadonlyArray<NodeJSON> }>(doc: D) => Effect.Effect<D, DocumentViolation>` | The Effect-shaped write seam. Curried so an app names the check once and applies it per write. |
147
+ | `allowListFilter` | `(allowList: AllowList) => SchemaAST.Filter<{ readonly content: ReadonlyArray<unknown> }>` | The same walk as a `Schema.check`-compatible filter. What `DocumentShape.narrow` uses; exported so it composes onto any schema carrying `content`. |
148
+ | `isSafeHref` | `(href: unknown, options?: { readonly schemes?: ReadonlyArray<string> \| undefined } \| undefined) => href is string` | Default schemes `http`, `https`, `mailto`; root-relative paths and fragment links always allowed. Parses with `URL` rather than a string prefix, so obfuscated schemes (`JaVaScRiPt:`, leading whitespace) are rejected. |
149
+ | `DEFAULT_SAFE_SCHEMES` | `ReadonlyArray<string>` | `['http', 'https', 'mailto']`. |
150
+ | `extractDocumentText` | `(doc: NodeJSON \| DocumentJSON \| null \| undefined, options?: { readonly separator?: string \| undefined } \| undefined) => string` | Default separator `' '`. Tolerant of `null`, a missing `content`, and non-object entries — a malformed row degrades to empty text rather than throwing. |
151
+ | `inlineDocumentText` | `(doc: NodeJSON \| DocumentJSON) => string` | The `''` join, so a title never gains a space its author did not type. |
152
+ | `toEditorDocument` | `<N extends string>(doc: DocumentJSON<N> \| NodeJSON) => NodeJSON` | Envelope `name` → `doc`. Returns a new object. |
153
+ | `fromEditorDocument` | `<N extends string>(name: N) => (doc: NodeJSON) => DocumentJSON<N>` | `doc` → envelope. A document with no `content` becomes an empty one. Returns a new object. |
154
+ | `createMarkdownSerialiser` | `(options?: { readonly blocks?: Record<string, BlockSerialiser> \| undefined; readonly inlines?: Record<string, InlineSerialiser> \| undefined; readonly marks?: Record<string, MarkSerialiser> \| undefined }) => MarkdownSerialiser` | The stock cases (paragraph, heading, blockquote, code block, horizontal rule, image, table, the flat-list model, hard break, five marks) are built in; the option maps merge **over** them. |
155
+ | `MarkdownSerialiser` | `{ readonly document: (doc: NodeJSON \| DocumentJSON) => string; readonly inline: (doc: NodeJSON \| DocumentJSON) => string }` | `document` is trimmed multi-line Markdown; `inline` is a trimmed single line. |
156
+ | `MarkdownContext` | `{ readonly serialiseInline: (nodes: ReadonlyArray<NodeJSON>) => string; readonly serialiseBlocks: (nodes: ReadonlyArray<NodeJSON>) => string; readonly escape: (text: string) => string }` | Handed to every block/inline serialiser so it renders its children exactly as the stock cases do. |
157
+ | `BlockSerialiser` | `(node: NodeJSON, context: MarkdownContext) => string` | Return `''` to drop the block. |
158
+ | `InlineSerialiser` | `(node: NodeJSON, context: MarkdownContext) => string` | Return `''` to contribute nothing. |
159
+ | `MarkSerialiser` | `(text: string, mark: MarkJSON) => string` | Wraps already-rendered text in the mark's syntax. |
160
+ | `toMarkdown` | `MarkdownSerialiser` | The default instance: the shared base grammar and nothing else. |
161
+ | `mentionMarkdown` | `InlineSerialiser` | A mention as its escaped `@label`; the id has no Markdown form. |
162
+ | `figureMarkdown` | `BlockSerialiser` | The image line followed by the caption as an italic paragraph. |
163
+ | `sidenoteMarkdown` | `InlineSerialiser` | The Pandoc inline footnote form, `^[…]`. A nested sidenote is flattened to its body first. |
164
+ | `escapeMarkdown` | `(text: string) => string` | Backslash-escapes the CommonMark inline metacharacters. Exported for an app's own serialisers. |
165
+
166
+ **Declaring and narrowing a shape.** A shape costs nothing to import — no ProseKit, no compiled ProseMirror schema — and is shared by the model layer, the persistence layer and the editor.
167
+
168
+ ```ts
169
+ import { defineAllowList, defineDocumentShape } from '@thomasfosterau/prosekit-utilities';
170
+
171
+ export const blockDocument = defineDocumentShape({ name: 'blockDocument' });
172
+ blockDocument.empty(); // { type: 'blockDocument', content: [] }
173
+
174
+ export const COMMENT = defineAllowList({
175
+ label: 'a comment',
176
+ nodes: ['paragraph', 'text', 'hardBreak'],
177
+ marks: ['bold', 'italic', 'strike', 'code', 'link'],
178
+ });
179
+
180
+ // Decode-time rejection: the allow-list composed onto the shape's codec.
181
+ const CommentBody = blockDocument.narrow(COMMENT);
182
+ Schema.decodeUnknownSync(CommentBody)(untrusted);
183
+ // → 'node type "heading" is not permitted in a comment'
184
+ ```
185
+
186
+ **Checking a write.**
187
+
188
+ ```ts
189
+ import { checkAllowList } from '@thomasfosterau/prosekit-utilities';
190
+
191
+ const checkComment = checkAllowList(COMMENT);
192
+
193
+ checkComment(body).pipe(
194
+ Effect.catchTag('DocumentViolation', (violation) =>
195
+ Effect.succeed({ field: 'body', kind: violation.kind, detail: violation.message }),
196
+ ),
197
+ );
198
+ ```
199
+
200
+ **Serialising Markdown with an application node.**
201
+
202
+ ```ts
203
+ import {
204
+ createMarkdownSerialiser,
205
+ figureMarkdown,
206
+ mentionMarkdown,
207
+ } from '@thomasfosterau/prosekit-utilities';
208
+
209
+ const toMarkdown = createMarkdownSerialiser({
210
+ blocks: { figure: figureMarkdown },
211
+ inlines: { mention: mentionMarkdown },
212
+ marks: { highlight: (text) => `==${text}==` },
213
+ });
214
+
215
+ toMarkdown.document(post.body); // multi-line Markdown
216
+ toMarkdown.inline(post.title); // one line
217
+ ```
218
+
219
+ **The envelope remap.** A persisted document's root `type` is the surface's own name; a compiled ProseMirror schema's top node is always `doc`. Feeding the stored shape straight into an editor throws `RangeError: Unknown node type: blockDocument`.
220
+
221
+ ```ts
222
+ import { fromEditorDocument, toEditorDocument } from '@thomasfosterau/prosekit-utilities';
223
+
224
+ const forEditor = toEditorDocument(stored); // { type: 'doc', … }
225
+ const forStore = fromEditorDocument('blockDocument')(forEditor);
226
+ ```
227
+
228
+ ---
229
+
230
+ # `./derive` — the derivation engine
231
+
232
+ ```ts
233
+ import { createEditor } from 'prosekit/core';
234
+ import { deriveDocumentSchema } from '@thomasfosterau/prosekit-utilities/derive';
235
+ import * as Schema from 'effect/Schema';
236
+
237
+ const { schema } = createEditor({ extension });
238
+ const Document = deriveDocumentSchema(schema, { topTypeName: 'blockDocument' });
239
+ const doc = Schema.decodeUnknownSync(Document)(json);
240
+ ```
241
+
242
+ Reads an Effect Schema back out of a compiled ProseMirror schema at runtime, so the grammar an editor already knows is never hand-transcribed a second time: node and mark **names**, **groups**, the **content model** (which children, whether it may be empty), and admissible **marks** are recovered exactly by walking the compiled `ContentMatch` automaton. ProseMirror does not expose attribute _value_ types — an `AttributeSpec` carries only a `default` and an optional `validate` hint — so attributes are recovered best-effort (`validate` grammar → `typeof` of a non-null `default` → `Schema.Unknown`), and the `AttributeOverlay` seam is where a caller supplies the rest. The derived schema complements, rather than replaces, a hand-written generative document contract, which still owns the attribute value types.
243
+
244
+ | Export | Signature | Notes |
245
+ | --- | --- | --- |
246
+ | `deriveDocumentSchema` | `(schema: PmSchema, options?: DeriveDocumentOptions) => Schema.Codec<DerivedNodeJSON>` | Rooted at `schema.topNodeType`; `topTypeName` renames it. |
247
+ | `deriveDocumentSchemas` | `<Specs extends Record<string, DocumentSchemaSpec>>(schemas: Specs, options?: DeriveOptions) => { readonly [K in keyof Specs]: Schema.Codec<DerivedNodeJSON> }` | Several related document schemas, sharing one `Registry` so common structure (a shared `text` node, shared marks) derives once. Results stay fully independent codecs — only the derivation is shared. Eager: every entry compiles up front. |
248
+ | `deriveNodeSchema` | `(nodeType: NodeType, options?: DeriveOptions) => Schema.Codec<DerivedNodeJSON>` | One node's recursive subtree. |
249
+ | `deriveMarkSchema` | `(markType: MarkType, options?: DeriveOptions) => Schema.Codec<DerivedMarkJSON>` | One mark's `{ type, attrs? }` shape. |
250
+ | `DocumentSchemaSpec` | `{ readonly schema: PmSchema; readonly topTypeName?: string }` | One entry in a `deriveDocumentSchemas` call. |
251
+ | `attributeOverlay` | `(overrides: Readonly<Record<string, Readonly<Record<string, Schema.Top \| RequiredAttribute>>>>) => AttributeOverlay` | Builds an overlay from a nested record keyed by owner name then attribute name — a convenience over writing the function by hand for static overrides. |
252
+ | `required` | `(schema: Schema.Top) => RequiredAttribute` | Wraps an overlay schema so the derived key is required, overriding ProseMirror's default-implies-optional rule. |
253
+ | `isRequiredAttribute` | `(value: unknown) => value is RequiredAttribute` | True for a value `required` produced. |
254
+ | `AttributeOverlay` | `(owner: AttributeOwner, attribute: string, spec: AttributeSpec) => Option.Option<Schema.Top \| RequiredAttribute>` | Consulted before deriving each attribute; `Option.some` overrides, `Option.none()` falls back to the automatic derivation. |
255
+ | `AttributeOwner` | `{ readonly kind: 'node' \| 'mark'; readonly name: string }` | Identifies the node or mark type an attribute being derived belongs to. |
256
+ | `DeriveOptions` | `{ readonly attributes?: AttributeOverlay; readonly includeMarks?: boolean; readonly nonEmptyRequiredContent?: boolean }` | Options common to every entry point — see the table below. |
257
+ | `DeriveDocumentOptions` | `DeriveOptions & { readonly topTypeName?: string }` | Options for `deriveDocumentSchema` / one entry of `deriveDocumentSchemas`. |
258
+ | `DerivedMarkJSON` | `{ readonly type: string; readonly attrs?: Record<string, unknown> }` | A derived mark's JSON shape. |
259
+ | `DerivedNodeJSON` | `{ readonly type: string; readonly attrs?: Record<string, unknown>; readonly content?: ReadonlyArray<DerivedNodeJSON>; readonly marks?: ReadonlyArray<DerivedMarkJSON>; readonly text?: string }` | A derived node's recursive JSON shape. |
260
+ | `RequiredAttribute` | `{ readonly schema: Schema.Top }` (branded with a private `unique symbol`) | Constructed only by `required`; detected only by `isRequiredAttribute`. |
261
+ | `Registry` | `class Registry { constructor(schema: PmSchema, options?: DeriveOptions, cache?: RegistryCache); mark(markType: MarkType): Schema.Codec<DerivedMarkJSON>; node(nodeType: NodeType): Schema.Codec<DerivedNodeJSON>; nodeAs(nodeType: NodeType, typeName: string): Schema.Codec<DerivedNodeJSON> }` | The memoised, mutually-recursive builder (`Schema.suspend`) every entry point reads from. `nodeAs` is not memoised — used for a renamed top node. |
262
+ | `createRegistryCache` | `() => RegistryCache` | A fresh, empty cache, private to one `Registry` unless explicitly shared. |
263
+ | `RegistryCache` | `{ readonly nodeCache: Map<string, Schema.Codec<DerivedNodeJSON>>; readonly markCache: Map<string, Schema.Codec<DerivedMarkJSON>> }` | Shared across `Registry` instances so structure two schemas have in common is built once — memoised by mark/node **name**, so only correct when same-named types across the schemas are structurally identical and the schemas admit the same marks (an inline node's `marks` field is the schema-wide mark union). |
264
+ | `Attribute`, `Content`, `Derive`, `Options` | Namespace re-exports (`export * as X`) | The lower-level modules the entry points above are built from — see [Layout](#derive-layout). |
265
+
266
+ ### Options
267
+
268
+ | Option | Applies to | Default | Effect |
269
+ | --- | --- | --- | --- |
270
+ | `attributes` | all | — | Consulted before deriving each attribute. |
271
+ | `includeMarks` | all | `true` | `false` omits the `marks` field from inline nodes entirely. |
272
+ | `nonEmptyRequiredContent` | all | `true` | Required content (`block+`) is a `NonEmptyArray`; `false` for a lenient possibly-empty array. |
273
+ | `topTypeName` | `deriveDocumentSchema` | — | Overrides the top node's `type` literal. |
274
+
275
+ ### Notes on fidelity
276
+
277
+ - **Marks live on inline nodes.** In ProseMirror JSON, marks serialise on the inline (text and inline-leaf) nodes; the permission to carry a mark comes from the containing textblock's `markSet`. The derivation attaches an optional `marks` field to inline nodes, admitting the union of marks any textblock in the schema allows.
278
+ - **Attribute key optionality follows ProseMirror.** An attribute with a `default` (any value, `null` included) is an optional key; one with no default is required — the same split `NodeType.hasRequiredAttrs()` reports.
279
+ - **Unknown keys are ignored, not rejected**, matching `Schema.Struct`'s default and ProseMirror's own `nodeFromJSON`. The derived schema is accept-superset on stray keys and strips them on decode.
280
+ - **Content is a set, not a sequence.** The derivation models content as an array of a child union (all admissible child types), not the exact ordered content expression — the granularity ProseKit's uniform group-based grammars need.
281
+
282
+ ### <a name="derive-layout"></a>Layout
283
+
284
+ | Module | Responsibility |
285
+ | --- | --- |
286
+ | `Derive` | Public `deriveNodeSchema` / `deriveMarkSchema` / `deriveDocumentSchema` / `deriveDocumentSchemas`. |
287
+ | `Registry` | The memoised, mutually-recursive builder; its cache can be shared across `Registry` instances (`RegistryCache`, `createRegistryCache`). |
288
+ | `Attribute` | Attribute schema and key-optionality derivation, the `validate`-grammar parser. |
289
+ | `Content` | Allowed child types and cardinality, by walking the `ContentMatch` automaton. |
290
+ | `Options` | Option and result types, the `attributeOverlay` helper. |
291
+
292
+ **Deriving several related documents at once.** When two document schemas are assembled from overlapping ProseKit extensions — a block and an inline editor that both compose the same marks and `text` node — `deriveDocumentSchemas` derives them together, sharing one registry:
293
+
294
+ ```ts
295
+ import { deriveDocumentSchemas } from '@thomasfosterau/prosekit-utilities/derive';
296
+
297
+ const { block, inline } = deriveDocumentSchemas(
298
+ {
299
+ block: { schema: blockSchema, topTypeName: 'blockDocument' },
300
+ inline: { schema: inlineSchema, topTypeName: 'inlineDocument' },
301
+ },
302
+ { attributes: ATTRIBUTE_OVERLAY },
303
+ );
304
+ ```
305
+
306
+ A caller that instead needs each document to stay independently lazy — never compiling schema B just because schema A was validated — shares a `RegistryCache` across separate `Registry` instances directly, one per schema, each built inside its own deferred call site. `./prosekit`'s `defineDocumentSchema` (below) is that pattern.
307
+
308
+ **Supplying attribute value types.**
309
+
310
+ ```ts
311
+ const Document = deriveDocumentSchema(schema, {
312
+ topTypeName: 'blockDocument',
313
+ attributes: attributeOverlay({
314
+ heading: { level: Schema.Literals([1, 2, 3, 4, 5, 6]) },
315
+ image: { src: Schema.NullOr(Schema.String) },
316
+ }),
317
+ });
318
+ ```
319
+
320
+ ---
321
+
322
+ # `./prosekit` — the ProseKit runtime half
323
+
324
+ The only entry that imports `prosekit` at the value level, so a browser-reachable model layer imports `.` and leaves this one to server code and editor routes.
325
+
326
+ ## Nodes
327
+
328
+ | Export | Signature | Notes |
329
+ | --- | --- | --- |
330
+ | `defineDoc`, `defineText`, `defineParagraph`, `defineHeading`, `defineList`, `defineBlockquote`, `defineImage`, `defineHorizontalRule`, `defineHardBreak`, `defineTable`, `defineCodeBlock` | `() => Extension` (+ `DocExtension`, `TextExtension`, `ParagraphExtension`, `HeadingExtension`, `ListExtension`, `BlockquoteExtension`, `ImageExtension`, `HorizontalRuleExtension`, `HardBreakExtension`, `TableExtension`, `CodeBlockExtension` types) | ProseKit's stock node extensions, re-exported so one grammar comes from one import. |
331
+ | `defineInlineDoc` | `() => Extension` | A `doc` top node with `content: "inline*"` — the shape a single-line rich-text field wants. |
332
+ | `defineMention` | `() => Extension` | **Opt-in.** Inline atom, attrs `id`/`label`, `parseDOM` on `span[data-mention-id]`, `toDOM` to `<span data-mention-id data-mention-label class="mention">@label</span>`. Not ProseKit's stock `mention` — its attribute shape differs from what already-persisted documents carry. |
333
+ | `defineFigcaption` | `() => Extension` | **Opt-in.** `content: "inline*"`, so a caption can never nest a block. |
334
+ | `defineFigureNode` | `() => Extension` | **Opt-in.** `group: "block"`, `content: "image figcaption"`, `defining`. Unusable without `defineFigcaption` and the stock `image` in the same extension. |
335
+ | `defineFigure` | `() => Extension` | **Opt-in.** `defineFigureNode()` + `defineFigcaption()`. |
336
+ | `defineSidenote` | `() => Extension` | **Opt-in.** Inline, non-atom, `selectable`, `defining`, `content: "(text \| hardBreak)*"` — rich editable inline content that **cannot nest another sidenote**. `toDOM` to `<span data-sidenote>`; placement is CSS-only and ships no styles. |
337
+
338
+ ## Marks
339
+
340
+ | Export | Signature | Notes |
341
+ | --- | --- | --- |
342
+ | `defineBold`, `defineItalic`, `defineUnderline`, `defineStrike`, `defineCode`, `defineLink` | `() => Extension` (+ `BoldExtension`, `ItalicExtension`, `UnderlineExtension`, `StrikeExtension`, `CodeExtension`, `LinkExtension` types) | ProseKit's stock mark extensions, re-exported. |
343
+ | `defineHighlight`, `defineSubscript`, `defineSuperscript` | `() => Extension` | This library's own `<mark>`/`<sub>`/`<sup>` specs, wire-identical to already-persisted documents; `subscript` and `superscript` mutually exclude. ProseKit's same-named stock extensions carry different attribute shapes and are deliberately not used. |
344
+ | `defineCitation` | `() => Extension` | **Opt-in.** Mark with attr `workId`, `parseDOM` on `cite[data-work-id]`, `toDOM` to `<cite data-work-id>`. A mark, not a node, so a citation can never swallow structure. |
345
+ | `defineStandardMarks` | `() => Extension` | The nine: the six stock marks plus highlight, subscript, superscript. `citation` is not in the set. |
346
+
347
+ ## Presets
348
+
349
+ | Export | Signature | Notes |
350
+ | --- | --- | --- |
351
+ | `defineEditorBehaviour` | `() => Extension` | Base keymap, base commands, history, gap cursor, virtual selection, mod-click prevention. Registers no node and no mark, so it never affects a derived wire schema. Compose it at most once per extension — ProseMirror rejects two instances of the same keyed plugin. |
352
+ | `defineBlockDocumentExtension` | `(...extra: ReadonlyArray<Extension>) => Extension` | `doc` + `text` + the stock block set + `defineStandardMarks()` + `defineEditorBehaviour()` + `extra`. |
353
+ | `defineInlineDocumentExtension` | `(...extra: ReadonlyArray<Extension>) => Extension` | `defineInlineDoc()` + `text` + `defineStandardMarks()` + `defineEditorBehaviour()` + `extra`. No block node is registered at all — a paragraph fails to decode, not merely unreachable from a toolbar. |
354
+ | `BlockDocumentExtension`, `InlineDocumentExtension` | `ReturnType<typeof defineBlockDocumentExtension>` / `ReturnType<typeof defineInlineDocumentExtension>` | The extension each preset returns with no extras. |
355
+
356
+ ## Schemas and attributes
357
+
358
+ | Export | Signature | Notes |
359
+ | --- | --- | --- |
360
+ | `lazySchema` | `(define: () => Extension) => () => PmSchema` | Compile-once, cache-forever (`Option`-backed). Never call `createEditor` on a per-row or per-render path — PERF-CPU-001. |
361
+ | `AttributeOverrides` | `Readonly<Record<string, Readonly<Record<string, Schema.Top \| RequiredAttribute>>>>` | The record `attributeOverlay()` (`./derive`) consumes. |
362
+ | `STANDARD_ATTRIBUTE_OVERRIDES` | `AttributeOverrides` | `heading.level`, `list.kind`/`order`, `image.src`/`width`/`height`, `codeBlock.language`, `tableCell`/`tableHeaderCell.colwidth`. Attributes a live editor always serialises (`heading.level`, `list.kind`, `image.src`/`width`/`height`, `codeBlock.language`) are wrapped in `required(…)`; `colwidth` is not, because an unresized cell genuinely has none. |
363
+ | `MENTION_ATTRIBUTE_OVERRIDES` | `AttributeOverrides` | `mention: { id: required(Schema.String), label: required(Schema.String) }` — merge in when the grammar includes `defineMention()`. |
364
+ | `mergeAttributeOverrides` | `(...overrides: ReadonlyArray<AttributeOverrides>) => AttributeOverrides` | Shallow per node, then per attribute; later wins. Non-mutating. |
365
+ | `defineDocumentSchema` | `<Name extends string>(shape: DocumentShape<Name>, options: DocumentSchemaOptions) => DocumentSchema<Name>` | The centrepiece: `Schema.suspend` + `Registry.nodeAs(topNodeType, shape.name)`. Nothing compiles or derives at declaration time. |
366
+ | `DocumentSchemaOptions` | `{ readonly extension: () => Extension; readonly attributes?: AttributeOverrides \| undefined; readonly registryCache?: RegistryCache \| undefined }` | `attributes` defaults to `STANDARD_ATTRIBUTE_OVERRIDES`; `registryCache` defaults to a cache private to this schema; share one only between grammars with matching mark sets. |
367
+ | `DocumentSchema<Name>` | `{ readonly shape: DocumentShape<Name>; readonly extension: () => Extension; readonly schema: () => PmSchema; readonly Document: Schema.Codec<DocumentJSON<Name>>; readonly is: (v: unknown) => v is DocumentJSON<Name>; readonly decode: (v: unknown) => Effect.Effect<DocumentJSON<Name>, DocumentDecodeError>; readonly install: () => void }` | `is` keeps a try/catch defect net; `install()` is `shape.install(is)`. Satisfies `./drizzle`'s `HasDocumentSchema<A>` structurally. |
368
+ | `installDocumentGrammars` | `(...schemas: ReadonlyArray<DocumentSchema<string>>) => void` | The persistence module's one call. Forces no derivation — installing just stores a function reference. |
369
+ | `DocumentDecodeError` | `class DocumentDecodeError extends Schema.TaggedError<DocumentDecodeError>()('DocumentDecodeError', { document: Schema.String, issue: Schema.Defect() }) {}` | Wraps the underlying `SchemaError` for a caller that wants a tagged channel; `Document` itself stays a plain codec. |
370
+
371
+ ## <a name="prosekit-html"></a>HTML
372
+
373
+ | Export | Signature | Notes |
374
+ | --- | --- | --- |
375
+ | `sanitiseDocument` | `(node: NodeJSON) => NodeJSON` | Recursive, non-mutating: drops `link` marks with an unsafe `href` and `image` children with an unsafe `src`. Run automatically by every render; exported for other boundaries. |
376
+ | `createHtmlRenderer` | `(options: HtmlRendererOptions) => HtmlRenderer` | Sanitises, rewrites the envelope's `type` back to `doc`, then `htmlFromJSON`. Cheap to create; holds the resolved `Document` — create one per grammar at module scope, never per render. |
377
+ | `HtmlRendererOptions` | `{ readonly schema: () => PmSchema; readonly postProcess?: ((html: string, document: Document) => string) \| undefined; readonly document?: (() => Promise<Document>) \| undefined }` | `document` defaults to capability detection — the global `document` if defined, otherwise `linkedom`'s `parseHTML(…).document` — resolved once per renderer. |
378
+ | `HtmlRenderer` | `{ readonly render: (doc: NodeJSON \| DocumentJSON) => Effect.Effect<string, HtmlRenderError>; readonly renderPromise: (doc: NodeJSON \| DocumentJSON) => Promise<string> }` | Two shapes over one implementation; `renderPromise` rejects with the tagged error itself. |
379
+ | `HtmlRenderError` | `class HtmlRenderError extends Schema.TaggedError<HtmlRenderError>()('HtmlRenderError', { reason: Schema.Literals(['dom-unavailable', 'serialise']), cause: Schema.Defect() }) {}` | `dom-unavailable` when no `Document` could be obtained; `serialise` when the document or the `postProcess` hook could not produce HTML. |
380
+
381
+ **Composing an extension and linking it to a shape** — see [Declaring a document surface](#declaring-a-document-surface) above for the full worked example. **Rendering HTML**, with a placeholder-filling hook:
382
+
383
+ ```ts
384
+ import { createHtmlRenderer } from '@thomasfosterau/prosekit-utilities/prosekit';
385
+ import { blockDocumentSchema } from './document-schemas.ts';
386
+
387
+ export const blockRenderer = createHtmlRenderer({
388
+ schema: blockDocumentSchema.schema,
389
+ postProcess: (html, document) => expandPlaceholders(html, document),
390
+ });
391
+
392
+ // Inside Effect, with the failure in the error channel:
393
+ const program = Effect.gen(function* () {
394
+ const html = yield* blockRenderer.render(post.body);
395
+ return html;
396
+ }).pipe(
397
+ Effect.catchTag('HtmlRenderError', (error) =>
398
+ Effect.succeed(error.reason === 'dom-unavailable' ? '' : '<p>Could not render.</p>'),
399
+ ),
400
+ );
401
+ ```
402
+
403
+ On the server this resolves to `linkedom`, never `happy-dom`: happy-dom pulls in `node:child_process` and `node:vm`, which a Worker runtime does not provide, so the import would fail at request time and take down every page that renders prose. The branch is chosen by capability detection (is there a global `document`?) rather than an `import.meta.env.SSR`-style flag, because that flag reads `false` under a node test environment and would pick the browser path exactly where it must not.
404
+
405
+ ---
406
+
407
+ # `./drizzle`
408
+
409
+ | Export | Signature | Notes |
410
+ | --- | --- | --- |
411
+ | `jsonbSchemaColumn` | `<A>(schema: Schema.Codec<A, A>) => (name: string) => PgCustomColumnBuilder<{ data: A; driverData: string }>` | Generalises the apps' `customType` body over any codec: `dataType() = 'jsonb'`, `toDriver` = `Schema.encodeSync` over the wire form, `fromDriver` = a three-branch decode (string driver value / already-parsed / non-string) that validates. `null` passes through untouched. Validates on read only — the write path is trusted upstream. |
412
+ | `documentColumn` | `<A>(schema: HasDocumentSchema<A>) => (name: string) => PgCustomColumnBuilder<{ data: A; driverData: string }>` | `jsonbSchemaColumn(schema.Document)` — the strict, ProseKit-derived schema, SEC-XSS-001 on the read direction. Pass a `DocumentSchema` from `./prosekit`. |
413
+ | `HasDocumentSchema<A>` | `{ readonly Document: Schema.Codec<A, A> }` | The one piece of `./prosekit`'s `DocumentSchema` this entry needs, written structurally so `./drizzle` never imports `./prosekit` (or ProseKit) at all — a consumer can depend on this entry alone. |
414
+ | `documentTextSql` | `(column: AnyPgColumn \| SQL) => SQL<string>` | The `jsonb_path_query_array(... 'strict $.** ? (@.type == "text").text')` fragment, unnested and joined with `' '` — the SQL twin of `extractDocumentText` (the root entry), tested against the same fixtures. |
415
+
416
+ ```ts
417
+ import { pgTable, uuid } from 'drizzle-orm/pg-core';
418
+ import {
419
+ documentColumn,
420
+ documentTextSql,
421
+ jsonbSchemaColumn,
422
+ } from '@thomasfosterau/prosekit-utilities/drizzle';
423
+ import { blockDocumentSchema } from './document-schemas.ts';
424
+
425
+ export const posts = pgTable('posts', {
426
+ id: uuid('id').primaryKey().defaultRandom(),
427
+ body: documentColumn(blockDocumentSchema)('body_jsonb').notNull(),
428
+ });
429
+
430
+ // A generated/materialised text column for search, from the same jsonpath
431
+ // fragment `extractDocumentText` walks in JS.
432
+ export const postSearchText = documentTextSql(posts.body);
433
+
434
+ // A column backed by a plain Effect Schema, no ProseKit document involved:
435
+ export const settings = pgTable('settings', {
436
+ preferences: jsonbSchemaColumn(PreferencesSchema)('preferences_jsonb'),
437
+ });
438
+ ```
439
+
440
+ ---
441
+
442
+ ## Wire compatibility
443
+
444
+ **The derivation is the contract.** `./derive` reads node and mark names, groups, the content model and admissible marks off the compiled ProseMirror schema; `./prosekit`'s node and mark specs and `STANDARD_ATTRIBUTE_OVERRIDES` decide the rest. Stored documents are content-addressed and immutable in the consuming apps, so a change to any of these — a spec's shape, an attribute override, which marks a node admits — changes what already-stored rows decode as. This workspace pins `prosemirror-model`/`prosemirror-view` exactly for the same reason.
445
+
446
+ **The envelope is not versioned.** There is no `version` field on `DocumentJSON` and no normalisation or migration hook: this library decodes exactly what the current derivation accepts, nothing more permissive. A grammar change that needs already-stored rows to keep decoding is a **major** release, not a runtime compatibility shim.
447
+
448
+ **The golden-fixture suite is the proof.** `tests/prosekit/document-models.test.ts` pins the wire shape of every node and mark this library defines, so a change that would silently alter what a stored document decodes as fails a test rather than shipping quietly.