svedit 0.11.1 → 0.13.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.
Files changed (93) hide show
  1. package/README.md +260 -82
  2. package/dist/Command.svelte.d.ts +54 -29
  3. package/dist/Command.svelte.d.ts.map +1 -1
  4. package/dist/Command.svelte.js +286 -283
  5. package/dist/CustomProperty.svelte +3 -5
  6. package/dist/CustomProperty.svelte.d.ts +3 -6
  7. package/dist/CustomProperty.svelte.d.ts.map +1 -1
  8. package/dist/KeyMapper.svelte.d.ts +19 -6
  9. package/dist/KeyMapper.svelte.d.ts.map +1 -1
  10. package/dist/KeyMapper.svelte.js +95 -112
  11. package/dist/Node.svelte +40 -9
  12. package/dist/Node.svelte.d.ts +3 -5
  13. package/dist/Node.svelte.d.ts.map +1 -1
  14. package/dist/NodeArrayProperty.svelte +142 -18
  15. package/dist/NodeArrayProperty.svelte.d.ts +3 -5
  16. package/dist/NodeArrayProperty.svelte.d.ts.map +1 -1
  17. package/dist/NodeCaret.svelte +1 -1
  18. package/dist/NodeCaret.svelte.d.ts +6 -14
  19. package/dist/NodeCaret.svelte.d.ts.map +1 -1
  20. package/dist/NodeGap.svelte +60 -12
  21. package/dist/NodeGap.svelte.d.ts +7 -14
  22. package/dist/NodeGap.svelte.d.ts.map +1 -1
  23. package/dist/NodeGapMarkers.svelte +19 -10
  24. package/dist/NodeGapMarkers.svelte.d.ts +6 -11
  25. package/dist/NodeGapMarkers.svelte.d.ts.map +1 -1
  26. package/dist/NodeSelectionMarkers.svelte +20 -6
  27. package/dist/NodeSelectionMarkers.svelte.d.ts +2 -5
  28. package/dist/NodeSelectionMarkers.svelte.d.ts.map +1 -1
  29. package/dist/Session.svelte.d.ts +85 -116
  30. package/dist/Session.svelte.d.ts.map +1 -1
  31. package/dist/Session.svelte.js +543 -571
  32. package/dist/Svedit.svelte +243 -216
  33. package/dist/Svedit.svelte.d.ts +30 -10
  34. package/dist/Svedit.svelte.d.ts.map +1 -1
  35. package/dist/{AnnotatedTextProperty.svelte → TextProperty.svelte} +71 -56
  36. package/dist/TextProperty.svelte.d.ts +5 -0
  37. package/dist/TextProperty.svelte.d.ts.map +1 -0
  38. package/dist/Transaction.svelte.d.ts +101 -126
  39. package/dist/Transaction.svelte.d.ts.map +1 -1
  40. package/dist/Transaction.svelte.js +843 -827
  41. package/dist/UnknownNode.svelte +4 -3
  42. package/dist/UnknownNode.svelte.d.ts +5 -9
  43. package/dist/UnknownNode.svelte.d.ts.map +1 -1
  44. package/dist/doc_utils.d.ts +109 -122
  45. package/dist/doc_utils.d.ts.map +1 -1
  46. package/dist/doc_utils.js +726 -698
  47. package/dist/index.d.ts +20 -17
  48. package/dist/index.d.ts.map +1 -1
  49. package/dist/index.js +2 -18
  50. package/dist/node_visibility.svelte.d.ts +132 -5
  51. package/dist/node_visibility.svelte.d.ts.map +1 -1
  52. package/dist/node_visibility.svelte.js +535 -642
  53. package/dist/paste_utils.d.ts +11 -43
  54. package/dist/paste_utils.d.ts.map +1 -1
  55. package/dist/paste_utils.js +111 -149
  56. package/dist/transforms.svelte.d.ts +9 -7
  57. package/dist/transforms.svelte.d.ts.map +1 -1
  58. package/dist/transforms.svelte.js +126 -152
  59. package/dist/types.d.ts +329 -291
  60. package/dist/types.d.ts.map +1 -0
  61. package/dist/types.js +1 -0
  62. package/dist/utils.d.ts +79 -95
  63. package/dist/utils.d.ts.map +1 -1
  64. package/dist/utils.js +392 -305
  65. package/package.json +69 -67
  66. package/src/lib/{Command.svelte.js → Command.svelte.ts} +111 -41
  67. package/src/lib/CustomProperty.svelte +3 -5
  68. package/src/lib/{KeyMapper.svelte.js → KeyMapper.svelte.ts} +28 -11
  69. package/src/lib/Node.svelte +40 -9
  70. package/src/lib/NodeArrayProperty.svelte +142 -18
  71. package/src/lib/NodeCaret.svelte +1 -1
  72. package/src/lib/NodeGap.svelte +60 -12
  73. package/src/lib/NodeGapMarkers.svelte +19 -10
  74. package/src/lib/NodeSelectionMarkers.svelte +20 -6
  75. package/src/lib/Session.svelte.ts +656 -0
  76. package/src/lib/Svedit.svelte +243 -216
  77. package/src/lib/{AnnotatedTextProperty.svelte → TextProperty.svelte} +71 -56
  78. package/src/lib/Transaction.svelte.ts +1020 -0
  79. package/src/lib/UnknownNode.svelte +4 -3
  80. package/src/lib/doc_utils.ts +1120 -0
  81. package/src/lib/{index.js → index.ts} +48 -1
  82. package/src/lib/node_visibility.svelte.ts +658 -0
  83. package/src/lib/{paste_utils.js → paste_utils.ts} +51 -59
  84. package/src/lib/{transforms.svelte.js → transforms.svelte.ts} +44 -27
  85. package/src/lib/{types.d.ts → types.ts} +250 -103
  86. package/src/lib/utils.ts +660 -0
  87. package/dist/AnnotatedTextProperty.svelte.d.ts +0 -7
  88. package/dist/AnnotatedTextProperty.svelte.d.ts.map +0 -1
  89. package/src/lib/Session.svelte.js +0 -575
  90. package/src/lib/Transaction.svelte.js +0 -846
  91. package/src/lib/doc_utils.js +0 -807
  92. package/src/lib/node_visibility.svelte.js +0 -718
  93. package/src/lib/utils.js +0 -465
@@ -0,0 +1,1120 @@
1
+ /**
2
+ * Shared document utilities used by both Session and Transaction.
3
+ *
4
+ * These functions operate on the core document state (schema, doc, selection, config)
5
+ * without any history management or transaction tracking.
6
+ */
7
+
8
+ import {
9
+ assert_path_string_segment,
10
+ is_path_string_segment_valid,
11
+ get_selection_range,
12
+ get_char_length,
13
+ serialize_path,
14
+ are_ranges_exclusive
15
+ } from './utils.js';
16
+ import type {
17
+ NodeId,
18
+ DocumentPath,
19
+ PrimitiveType,
20
+ PropertyDefinition,
21
+ NodeProperty,
22
+ NodeArrayProperty,
23
+ NodeSchema,
24
+ NodeKind,
25
+ DocumentSchema,
26
+ Selection,
27
+ Attachment,
28
+ Mark,
29
+ Annotation,
30
+ Document,
31
+ DocumentNode,
32
+ TextProperty,
33
+ Text,
34
+ ValidateDocumentSchema,
35
+ Inspection,
36
+ DocumentOperation,
37
+ SessionConfig
38
+ } from './types.js';
39
+
40
+ /**
41
+ * Identity function — keeps schema at runtime & makes IDE infer types.
42
+ * Similar to your define_schema pattern but for document schemas.
43
+ */
44
+ export function define_document_schema<S extends Record<string, NodeSchema>>(
45
+ schema: S & ValidateDocumentSchema<S>
46
+ ): S {
47
+ return schema;
48
+ }
49
+
50
+ /**
51
+ * Check if a string represents a valid primitive type.
52
+ */
53
+ export function is_primitive_type(type: string): type is PrimitiveType {
54
+ return [
55
+ 'string',
56
+ 'number',
57
+ 'boolean',
58
+ 'integer',
59
+ 'datetime',
60
+ 'text',
61
+ 'string_array',
62
+ 'number_array',
63
+ 'boolean_array',
64
+ 'integer_array'
65
+ ].includes(type);
66
+ }
67
+
68
+ /**
69
+ * Get the default node type for a property that references nodes.
70
+ * Returns null if none specified.
71
+ */
72
+ export function get_default_node_type(
73
+ property_definition: NodeProperty | NodeArrayProperty
74
+ ): string | null {
75
+ if (!property_definition || !property_definition.node_types) {
76
+ return null;
77
+ }
78
+
79
+ return (
80
+ property_definition.default_node_type ||
81
+ (property_definition.node_types.length === 1 ? property_definition.node_types[0] : null)
82
+ );
83
+ }
84
+
85
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
86
+ export function get_property_default(property_definition: PropertyDefinition): any {
87
+ if ('default' in property_definition) return structuredClone(property_definition.default);
88
+
89
+ if (property_definition.type === 'string') return '';
90
+ if (property_definition.type === 'integer') return 0;
91
+ if (property_definition.type === 'number') return 0;
92
+ if (property_definition.type === 'boolean') return false;
93
+ if (property_definition.type === 'text') return { content: '', marks: [], annotations: [] };
94
+ if (property_definition.type === 'node_array') return { nodes: [], marks: [], annotations: [] };
95
+ if (
96
+ property_definition.type === 'string_array' ||
97
+ property_definition.type === 'number_array' ||
98
+ property_definition.type === 'boolean_array' ||
99
+ property_definition.type === 'integer_array'
100
+ ) {
101
+ return [];
102
+ }
103
+
104
+ return undefined;
105
+ }
106
+
107
+ /**
108
+ * Fill omitted properties with schema defaults and Svedit's built-in type defaults.
109
+ *
110
+ * This is a convenience helper for schema evolution, not a complete document
111
+ * migration system. Callers are still responsible for proper migrations when
112
+ * schema changes cannot be represented by defaults, such as property renames or
113
+ * data transformations.
114
+ *
115
+ * @returns A shallow copy of the node with cloned default values filled in
116
+ */
117
+ export function fill_node_defaults(node: DocumentNode, schema: DocumentSchema): DocumentNode {
118
+ const node_schema = schema[node.type];
119
+ if (!node_schema) return { ...node };
120
+
121
+ const node_with_defaults = { ...node };
122
+
123
+ for (const [property_name, property_definition] of Object.entries(node_schema.properties)) {
124
+ if (node_with_defaults[property_name] === undefined) {
125
+ const property_default = get_property_default(property_definition);
126
+ if (property_default !== undefined) node_with_defaults[property_name] = property_default;
127
+ }
128
+ }
129
+
130
+ return node_with_defaults;
131
+ }
132
+
133
+ /**
134
+ * Fill omitted properties with schema defaults and Svedit's built-in type defaults across a document.
135
+ *
136
+ * This does not infer values, rename fields, or make an invalid migration valid
137
+ * by itself. Call this as one step in an explicit document migration when it is
138
+ * appropriate.
139
+ *
140
+ * @returns A document copy with cloned default values filled in
141
+ */
142
+ export function fill_document_defaults(doc: Document, schema: DocumentSchema): Document {
143
+ const nodes: Record<string, DocumentNode> = {};
144
+
145
+ for (const [node_id, node] of Object.entries(doc.nodes)) {
146
+ nodes[node_id] = fill_node_defaults(node, schema);
147
+ }
148
+
149
+ return {
150
+ ...doc,
151
+ nodes
152
+ };
153
+ }
154
+
155
+ /**
156
+ * Validate a document schema to ensure all referenced node types exist.
157
+ * @throws {Error} Throws if the document schema is invalid
158
+ */
159
+ export function validate_document_schema(document_schema: DocumentSchema): void {
160
+ // Check that all referenced node types exist
161
+ for (const [node_type, node_schema] of Object.entries(document_schema)) {
162
+ for (const [prop_name, prop_def] of Object.entries(node_schema.properties)) {
163
+ assert_path_string_segment(prop_name, `Property name "${prop_name}"`);
164
+ if (prop_def.type === 'string' && prop_def.values !== undefined) {
165
+ if (
166
+ !Array.isArray(prop_def.values) ||
167
+ prop_def.values.some((value) => typeof value !== 'string')
168
+ ) {
169
+ throw new Error(
170
+ `Node type "${node_type}" property "${prop_name}" values must be an array of strings.`
171
+ );
172
+ }
173
+ if (prop_def.values.length === 0) {
174
+ throw new Error(
175
+ `Node type "${node_type}" property "${prop_name}" values must not be empty.`
176
+ );
177
+ }
178
+ if (new Set(prop_def.values).size !== prop_def.values.length) {
179
+ throw new Error(
180
+ `Node type "${node_type}" property "${prop_name}" values must be unique.`
181
+ );
182
+ }
183
+ if (prop_def.default !== undefined && !prop_def.values.includes(prop_def.default)) {
184
+ throw new Error(
185
+ `Node type "${node_type}" property "${prop_name}" default must be one of its allowed values.`
186
+ );
187
+ }
188
+ }
189
+ if (prop_def.type === 'integer') {
190
+ if (prop_def.default !== undefined && !Number.isInteger(prop_def.default)) {
191
+ throw new Error(
192
+ `Node type "${node_type}" property "${prop_name}" default must be an integer.`
193
+ );
194
+ }
195
+ if (prop_def.min !== undefined && !Number.isInteger(prop_def.min)) {
196
+ throw new Error(
197
+ `Node type "${node_type}" property "${prop_name}" min must be an integer.`
198
+ );
199
+ }
200
+ if (prop_def.max !== undefined && !Number.isInteger(prop_def.max)) {
201
+ throw new Error(
202
+ `Node type "${node_type}" property "${prop_name}" max must be an integer.`
203
+ );
204
+ }
205
+ if (
206
+ prop_def.min !== undefined &&
207
+ prop_def.max !== undefined &&
208
+ prop_def.min > prop_def.max
209
+ ) {
210
+ throw new Error(
211
+ `Node type "${node_type}" property "${prop_name}" min must not be greater than max.`
212
+ );
213
+ }
214
+ if (
215
+ prop_def.default !== undefined &&
216
+ ((prop_def.min !== undefined && prop_def.default < prop_def.min) ||
217
+ (prop_def.max !== undefined && prop_def.default > prop_def.max))
218
+ ) {
219
+ throw new Error(
220
+ `Node type "${node_type}" property "${prop_name}" default must be within its min/max range.`
221
+ );
222
+ }
223
+ }
224
+ if (prop_def.type === 'node' || prop_def.type === 'node_array') {
225
+ const missing_types = prop_def.node_types.filter(
226
+ (ref_type) => !(ref_type in document_schema)
227
+ );
228
+ if (missing_types.length > 0) {
229
+ throw new Error(
230
+ `Node type "${node_type}" property "${prop_name}" references unknown node types: ${missing_types.join(', ')}. Available node types: ${Object.keys(document_schema).join(', ')}`
231
+ );
232
+ }
233
+ }
234
+ if (prop_def.type === 'text' || prop_def.type === 'node_array') {
235
+ // mark_types must reference kind 'mark', annotation_types kind 'annotation'
236
+ for (const [key, expected_kind] of [
237
+ ['mark_types', 'mark'],
238
+ ['annotation_types', 'annotation']
239
+ ] as const) {
240
+ const invalid_types = (prop_def[key] ?? []).filter(
241
+ (ref_type) => document_schema[ref_type]?.kind !== expected_kind
242
+ );
243
+ if (invalid_types.length > 0) {
244
+ throw new Error(
245
+ `Node type "${node_type}" property "${prop_name}" ${key} must reference node types of kind '${expected_kind}', got: ${invalid_types.join(', ')}.`
246
+ );
247
+ }
248
+ }
249
+ }
250
+ }
251
+ }
252
+ }
253
+
254
+ /**
255
+ * Validate that no annotation node type has a registered component.
256
+ *
257
+ * Marks render in-place via components; annotations are data-only overlay
258
+ * ranges that must be interpreted by the app (via CSS classes or props).
259
+ *
260
+ * @throws {Error} Throws if a kind 'annotation' type has a registered component
261
+ */
262
+ export function validate_config_components(schema: DocumentSchema, config: SessionConfig): void {
263
+ for (const [node_type, node_schema] of Object.entries(schema)) {
264
+ if (node_schema.kind === 'annotation' && config?.node_components?.[node_type]) {
265
+ throw new Error(
266
+ `Annotation type "${node_type}" must not have a registered component. Annotations are data-only; use kind 'mark' for in-place rendered ranges.`
267
+ );
268
+ }
269
+ }
270
+ }
271
+
272
+ /**
273
+ * Validate a primitive value against its schema type.
274
+ */
275
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
276
+ function validate_primitive_value(type: PrimitiveType, value: any): boolean {
277
+ switch (type) {
278
+ case 'string':
279
+ return typeof value === 'string';
280
+ case 'number':
281
+ return typeof value === 'number' && !isNaN(value);
282
+ case 'boolean':
283
+ return typeof value === 'boolean';
284
+ case 'integer':
285
+ return Number.isInteger(value);
286
+ case 'datetime':
287
+ return typeof value === 'string' && !isNaN(Date.parse(value));
288
+ case 'text':
289
+ return (
290
+ typeof value === 'object' &&
291
+ value !== null &&
292
+ typeof value.content === 'string' &&
293
+ Array.isArray(value.marks) &&
294
+ Array.isArray(value.annotations)
295
+ );
296
+ case 'string_array':
297
+ return Array.isArray(value) && value.every((v) => typeof v === 'string');
298
+ case 'number_array':
299
+ return Array.isArray(value) && value.every((v) => typeof v === 'number' && !isNaN(v));
300
+ case 'boolean_array':
301
+ return Array.isArray(value) && value.every((v) => typeof v === 'boolean');
302
+ case 'integer_array':
303
+ return Array.isArray(value) && value.every((v) => Number.isInteger(v));
304
+ default:
305
+ return false;
306
+ }
307
+ }
308
+
309
+ /**
310
+ * Validate ranges (marks or annotations) for bounds, references, and allowed types.
311
+ *
312
+ * @throws {Error} Throws if ranges are invalid
313
+ */
314
+ function validate_range_array(
315
+ node_id: string,
316
+ prop_name: string,
317
+ label: 'mark' | 'annotation',
318
+ ranges: Array<Attachment>,
319
+ container_length: number,
320
+ allowed_types: Array<string> | null | undefined,
321
+ all_nodes: Record<string, DocumentNode>,
322
+ require_references: boolean
323
+ ): void {
324
+ for (const [index, range] of ranges.entries()) {
325
+ if (
326
+ typeof range !== 'object' ||
327
+ range === null ||
328
+ !Number.isInteger(range.start_offset) ||
329
+ !Number.isInteger(range.end_offset) ||
330
+ !is_id_valid(range.node_id)
331
+ ) {
332
+ throw new Error(
333
+ `Node ${node_id} property ${prop_name} has an invalid ${label} at index ${index}. Ranges must have integer start_offset/end_offset and a valid node_id.`
334
+ );
335
+ }
336
+
337
+ if (range.start_offset < 0 || range.end_offset > container_length) {
338
+ throw new Error(
339
+ `Node ${node_id} property ${prop_name} ${label} ${range.node_id} is out of bounds: ${range.start_offset}-${range.end_offset}, container length is ${container_length}.`
340
+ );
341
+ }
342
+
343
+ if (range.start_offset >= range.end_offset) {
344
+ throw new Error(
345
+ `Node ${node_id} property ${prop_name} ${label} ${range.node_id} must not be empty or reversed: ${range.start_offset}-${range.end_offset}.`
346
+ );
347
+ }
348
+
349
+ const referenced_node = all_nodes[range.node_id];
350
+ if (!referenced_node) {
351
+ if (require_references) {
352
+ throw new Error(
353
+ `Node ${node_id} property ${prop_name} ${label} references missing node ${range.node_id}.`
354
+ );
355
+ }
356
+ continue;
357
+ }
358
+
359
+ if (allowed_types?.length && !allowed_types.includes(referenced_node.type)) {
360
+ throw new Error(
361
+ `Node ${node_id} property ${prop_name} ${label} references node ${range.node_id} of type ${referenced_node.type}, but only types [${allowed_types.join(', ')}] are allowed.`
362
+ );
363
+ }
364
+ }
365
+ }
366
+
367
+ /**
368
+ * Validate marks and annotations of an annotated container (text or node array).
369
+ *
370
+ * Marks must be mutually exclusive; annotations may overlap.
371
+ *
372
+ * @throws {Error} Throws if marks or annotations are invalid
373
+ */
374
+ function validate_marks_and_annotations(
375
+ node_id: string,
376
+ prop_name: string,
377
+ value: { marks: Array<Mark>; annotations: Array<Annotation> },
378
+ prop_def: { mark_types?: string[]; annotation_types?: string[] },
379
+ container_length: number,
380
+ all_nodes: Record<string, DocumentNode>,
381
+ require_references: boolean
382
+ ): void {
383
+ validate_range_array(
384
+ node_id,
385
+ prop_name,
386
+ 'mark',
387
+ value.marks,
388
+ container_length,
389
+ prop_def.mark_types,
390
+ all_nodes,
391
+ require_references
392
+ );
393
+
394
+ if (!are_ranges_exclusive(value.marks, container_length)) {
395
+ throw new Error(
396
+ `Node ${node_id} property ${prop_name} has overlapping marks. Marks must be mutually exclusive.`
397
+ );
398
+ }
399
+
400
+ validate_range_array(
401
+ node_id,
402
+ prop_name,
403
+ 'annotation',
404
+ value.annotations,
405
+ container_length,
406
+ prop_def.annotation_types,
407
+ all_nodes,
408
+ require_references
409
+ );
410
+ }
411
+
412
+ /**
413
+ * Validate text property marks and annotations for bounds, references, allowed
414
+ * types, and mark exclusivity.
415
+ *
416
+ * @throws {Error} Throws if marks or annotations are invalid
417
+ */
418
+ function validate_text_property(
419
+ node_id: string,
420
+ prop_name: string,
421
+ value: Text,
422
+ prop_def: TextProperty,
423
+ all_nodes: Record<string, DocumentNode>,
424
+ require_references: boolean
425
+ ): void {
426
+ const char_length = get_char_length(value.content);
427
+ validate_marks_and_annotations(
428
+ node_id,
429
+ prop_name,
430
+ value,
431
+ prop_def,
432
+ char_length,
433
+ all_nodes,
434
+ require_references
435
+ );
436
+ }
437
+
438
+ export function is_id_valid(id: string): boolean {
439
+ return typeof id === 'string' && id.length > 0 && is_path_string_segment_valid(id);
440
+ }
441
+
442
+ /**
443
+ * Validate a node against its schema.
444
+ *
445
+ * @param node - The node to validate
446
+ * @param schema - The document schema
447
+ * @param all_nodes - All nodes in the document to check references
448
+ * @param options - Validation options
449
+ * @throws {Error} Throws if the node is invalid
450
+ */
451
+ export function validate_node(
452
+ node: DocumentNode,
453
+ schema: DocumentSchema,
454
+ all_nodes: Record<string, DocumentNode> = {},
455
+ options: { require_references?: boolean } = {}
456
+ ): void {
457
+ const require_references = options.require_references ?? true;
458
+ if (!is_id_valid(node.id)) {
459
+ throw new Error(`Node ${node.id} has an invalid id.`);
460
+ }
461
+
462
+ if (!node.type || !schema[node.type]) {
463
+ throw new Error(`Node ${node.id} has an invalid type: ${node.type}`);
464
+ }
465
+
466
+ const node_schema = schema[node.type];
467
+
468
+ for (const [prop_name, prop_def] of Object.entries(node_schema.properties)) {
469
+ const value = node[prop_name];
470
+
471
+ // Check primitive types
472
+ if (is_primitive_type(prop_def.type)) {
473
+ if (!validate_primitive_value(prop_def.type, value)) {
474
+ throw new Error(
475
+ `Node ${node.id} has an invalid property: ${prop_name} must be of type ${prop_def.type}.`
476
+ );
477
+ }
478
+ if (prop_def.type === 'string' && prop_def.values && !prop_def.values.includes(value)) {
479
+ throw new Error(
480
+ `Node ${node.id} has an invalid property: ${prop_name} must be one of [${prop_def.values.join(', ')}].`
481
+ );
482
+ }
483
+ if (
484
+ prop_def.type === 'integer' &&
485
+ ((prop_def.min !== undefined && value < prop_def.min) ||
486
+ (prop_def.max !== undefined && value > prop_def.max))
487
+ ) {
488
+ throw new Error(
489
+ `Node ${node.id} has an invalid property: ${prop_name} must be between ${prop_def.min ?? '-Infinity'} and ${prop_def.max ?? 'Infinity'}.`
490
+ );
491
+ }
492
+ }
493
+ if (prop_def.type === 'text') {
494
+ validate_text_property(
495
+ node.id,
496
+ prop_name,
497
+ value,
498
+ prop_def as TextProperty,
499
+ all_nodes,
500
+ require_references
501
+ );
502
+ }
503
+ // Check node references
504
+ if (prop_def.type === 'node') {
505
+ if (!is_id_valid(value)) {
506
+ throw new Error(
507
+ `Node ${node.id} has an invalid property: ${prop_name} must be a valid node id.`
508
+ );
509
+ }
510
+ // Check if referenced node exists and is of allowed type
511
+ const referenced_node = all_nodes[value];
512
+ if (!referenced_node) {
513
+ if (require_references) {
514
+ throw new Error(
515
+ `Node ${node.id} property ${prop_name} references missing node ${value}.`
516
+ );
517
+ }
518
+ continue;
519
+ }
520
+ if (!prop_def.node_types.includes(referenced_node.type)) {
521
+ throw new Error(
522
+ `Node ${node.id} property ${prop_name} references node ${value} of type ${referenced_node.type}, but only types [${(prop_def as NodeProperty).node_types.join(', ')}] are allowed.`
523
+ );
524
+ }
525
+ }
526
+ // Check node arrays
527
+ else if (prop_def.type === 'node_array') {
528
+ if (
529
+ !value ||
530
+ typeof value !== 'object' ||
531
+ !Array.isArray(value.nodes) ||
532
+ !Array.isArray(value.marks) ||
533
+ !Array.isArray(value.annotations)
534
+ ) {
535
+ throw new Error(
536
+ `Node ${node.id} has an invalid property: ${prop_name} must be an object with nodes, marks and annotations.`
537
+ );
538
+ }
539
+
540
+ const node_array_nodes = value.nodes;
541
+
542
+ if (!node_array_nodes.every((id: unknown) => typeof id === 'string' && is_id_valid(id))) {
543
+ throw new Error(
544
+ `Node ${node.id} has an invalid property: ${prop_name} must contain valid node ids.`
545
+ );
546
+ }
547
+
548
+ // Check if all referenced nodes exist and are of allowed types
549
+ for (const ref_id of node_array_nodes) {
550
+ const referenced_node = all_nodes[ref_id];
551
+ if (!referenced_node) {
552
+ if (require_references) {
553
+ throw new Error(
554
+ `Node ${node.id} property ${prop_name} references missing node ${ref_id}.`
555
+ );
556
+ }
557
+ continue;
558
+ }
559
+ if (!prop_def.node_types.includes(referenced_node.type)) {
560
+ throw new Error(
561
+ `Node ${node.id} property ${prop_name} references node ${ref_id} of type ${referenced_node.type}, but only types [${prop_def.node_types.join(', ')}] are allowed.`
562
+ );
563
+ }
564
+ }
565
+ validate_marks_and_annotations(
566
+ node.id,
567
+ prop_name,
568
+ value,
569
+ prop_def,
570
+ node_array_nodes.length,
571
+ all_nodes,
572
+ require_references
573
+ );
574
+ }
575
+ }
576
+ }
577
+
578
+ /**
579
+ * Validate a document against its schema.
580
+ *
581
+ * @throws {Error} Throws if the document is invalid
582
+ */
583
+ export function validate_document(doc: Document, schema: DocumentSchema): void {
584
+ if (!is_id_valid(doc.document_id)) {
585
+ throw new Error(`Document ${doc.document_id} has an invalid id.`);
586
+ }
587
+ for (const [node_id, node] of Object.entries(doc.nodes)) {
588
+ if (!is_id_valid(node_id)) {
589
+ throw new Error(`Document node map contains an invalid id: ${node_id}.`);
590
+ }
591
+ if (node.id !== node_id) {
592
+ throw new Error(`Document node map key ${node_id} does not match node id ${node.id}.`);
593
+ }
594
+ validate_node(node, schema, doc.nodes);
595
+ }
596
+ }
597
+
598
+ /**
599
+ * Gets a value from the document at the specified path.
600
+ *
601
+ * @param schema - The document schema
602
+ * @param doc - The document containing nodes
603
+ * @param path - Array path to the value, or a string node ID
604
+ * @returns The value at the specified path
605
+ */
606
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
607
+ export function get(schema: DocumentSchema, doc: Document, path: DocumentPath | string): any {
608
+ if (typeof path === 'string') {
609
+ path = [path];
610
+ }
611
+ if (!(Array.isArray(path) && path.length >= 1)) {
612
+ throw new Error(`Invalid path provided ${JSON.stringify(path)}`);
613
+ }
614
+
615
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
616
+ let val: any = doc.nodes[path[0]];
617
+ let val_type = 'node';
618
+
619
+ for (let i = 1; i < path.length; i++) {
620
+ const path_segment = path[i];
621
+ const path_segment_str = String(path_segment);
622
+ if (val_type === 'node') {
623
+ if (property_type(schema, val.type, path_segment_str) === 'node_array') {
624
+ val = val[path_segment];
625
+ val_type = 'node_array';
626
+ } else if (property_type(schema, val.type, path_segment_str) === 'text') {
627
+ val = val[path_segment];
628
+ val_type = 'text';
629
+ } else if (property_type(schema, val.type, path_segment_str) === 'node') {
630
+ val = doc.nodes[val[path_segment]];
631
+ val_type = 'node';
632
+ } else if (
633
+ ['string_array', 'integer_array'].includes(
634
+ property_type(schema, val.type, path_segment_str)
635
+ )
636
+ ) {
637
+ val = val[path_segment];
638
+ val_type = 'value_array';
639
+ } else {
640
+ val = val[path_segment];
641
+ val_type = 'value';
642
+ }
643
+ } else if (val_type === 'node_array') {
644
+ if (path_segment === 'nodes') {
645
+ val = val.nodes;
646
+ val_type = 'node_id_array';
647
+ } else if (path_segment === 'marks') {
648
+ val = val.marks;
649
+ val_type = 'range_array';
650
+ } else if (path_segment === 'annotations') {
651
+ val = val.annotations;
652
+ val_type = 'range_array';
653
+ } else if (typeof path_segment === 'number' || /^\d+$/.test(String(path_segment))) {
654
+ val = doc.nodes[val.nodes[path_segment]];
655
+ val_type = 'node';
656
+ } else {
657
+ throw new Error(
658
+ `Invalid path segment "${path_segment}" for node_array. Use "nodes", "marks" or "annotations".`
659
+ );
660
+ }
661
+ } else if (val_type === 'node_id_array') {
662
+ val = doc.nodes[val[path_segment]];
663
+ val_type = 'node';
664
+ } else if (val_type === 'value_array') {
665
+ val = val[path_segment];
666
+ val_type = 'value';
667
+ } else if (val_type === 'text') {
668
+ if (path_segment === 'content') {
669
+ val = val.content;
670
+ val_type = 'value';
671
+ } else if (path_segment === 'marks') {
672
+ val = val.marks;
673
+ val_type = 'range_array';
674
+ } else if (path_segment === 'annotations') {
675
+ val = val.annotations;
676
+ val_type = 'range_array';
677
+ } else {
678
+ throw new Error(
679
+ `Invalid path segment "${path_segment}" for text. Use "content", "marks" or "annotations".`
680
+ );
681
+ }
682
+ } else if (val_type === 'range_array') {
683
+ val = val[path_segment];
684
+ val_type = 'range';
685
+ } else if (val_type === 'range') {
686
+ if (path_segment === 'node_id') {
687
+ val = doc.nodes[val.node_id];
688
+ val_type = 'node';
689
+ } else if (path_segment === 'start_offset') {
690
+ val = val.start_offset;
691
+ val_type = 'value';
692
+ } else if (path_segment === 'end_offset') {
693
+ val = val.end_offset;
694
+ val_type = 'value';
695
+ } else {
696
+ throw new Error(
697
+ `Invalid path segment "${path_segment}" for range. Use "start_offset", "end_offset", or "node_id".`
698
+ );
699
+ }
700
+ }
701
+ }
702
+ return val;
703
+ }
704
+
705
+ /**
706
+ * Gets the type of a property from the schema.
707
+ */
708
+ export function property_type(schema: DocumentSchema, type: string, property: string): string {
709
+ if (typeof type !== 'string') throw new Error(`Invalid type ${type} provided`);
710
+ if (typeof property !== 'string') throw new Error(`Invalid property ${property} provided`);
711
+
712
+ if (property === 'type') return 'string';
713
+ if (property === 'id') return 'string';
714
+
715
+ if (!schema[type]) throw new Error(`Type ${type} not found in schema`);
716
+ if (!schema[type].properties[property])
717
+ throw new Error(`Property ${property} not found in type ${type}`);
718
+
719
+ return schema[type].properties[property].type;
720
+ }
721
+
722
+ /**
723
+ * Determines the kind of a node ('document', 'block', 'text', 'mark', or 'annotation').
724
+ */
725
+ export function kind(schema: DocumentSchema, node: DocumentNode): NodeKind {
726
+ return schema[node.type].kind;
727
+ }
728
+
729
+ /**
730
+ * Inspects a path to get metadata about the value at that location.
731
+ */
732
+ export function inspect(schema: DocumentSchema, doc: Document, path: DocumentPath): Inspection {
733
+ const parent = path.length > 1 ? get(schema, doc, path.slice(0, -1)) : undefined;
734
+ if (parent?.type) {
735
+ const property_name = path.at(-1) as string;
736
+ return {
737
+ kind: 'property',
738
+ name: property_name,
739
+ ...schema[parent.type].properties[property_name]
740
+ };
741
+ } else {
742
+ const node = get(schema, doc, path);
743
+ return {
744
+ kind: 'node',
745
+ id: node.id,
746
+ type: node.type,
747
+ properties: schema[node.type]
748
+ };
749
+ }
750
+ }
751
+
752
+ /**
753
+ * Creates a mutable draft of a document: a new document object with a new
754
+ * nodes map, sharing the (immutable) node objects with the original.
755
+ *
756
+ * apply_op_to_draft mutates the draft's nodes map but copies node objects
757
+ * before changing them, so node identity is preserved for unchanged nodes
758
+ * (fine-grained rendering relies on this) and the original document is
759
+ * never touched.
760
+ *
761
+ * @returns A draft document safe to mutate via apply_op_to_draft
762
+ */
763
+ export function create_document_draft(doc: Document): Document {
764
+ return {
765
+ ...doc,
766
+ nodes: { ...doc.nodes }
767
+ };
768
+ }
769
+
770
+ /**
771
+ * Applies an operation to a document draft in place.
772
+ *
773
+ * The draft's nodes map is mutated, but node objects are copied on write —
774
+ * one nodes-map copy per draft (see create_document_draft) replaces the
775
+ * previous one-copy-per-op behavior, which made transactions with many ops
776
+ * (paste, cascade delete) O(N·ops).
777
+ *
778
+ * @param draft - A draft created via create_document_draft
779
+ * @param op - The operation to apply [type, ...args]
780
+ * @returns The same draft, for convenience
781
+ */
782
+ export function apply_op_to_draft(draft: Document, op: DocumentOperation): Document {
783
+ const [type, ...args] = op;
784
+ if (type === 'set') {
785
+ const [node_id, property] = args[0];
786
+ draft.nodes[node_id] = {
787
+ ...draft.nodes[node_id],
788
+ [property]: structuredClone(args[1])
789
+ };
790
+ } else if (type === 'create') {
791
+ draft.nodes[args[0].id] = structuredClone(args[0]);
792
+ } else if (type === 'delete') {
793
+ delete draft.nodes[args[0]];
794
+ }
795
+ return draft;
796
+ }
797
+
798
+ /**
799
+ * Applies an operation to a document and returns the new document.
800
+ * Uses copy-on-write semantics.
801
+ *
802
+ * @param doc - The document to apply the operation to
803
+ * @param op - The operation to apply [type, ...args]
804
+ * @returns The new document with the operation applied
805
+ */
806
+ export function apply_op(doc: Document, op: DocumentOperation): Document {
807
+ return apply_op_to_draft(create_document_draft(doc), op);
808
+ }
809
+
810
+ /**
811
+ * Counts how many times a node is referenced in the document.
812
+ */
813
+ export function count_references(schema: DocumentSchema, doc: Document, node_id: NodeId): number {
814
+ let count = 0;
815
+
816
+ for (const node of Object.values(doc.nodes)) {
817
+ for (const [property, value] of Object.entries(node)) {
818
+ if (property === 'id' || property === 'type') continue;
819
+
820
+ const prop_type = property_type(schema, node.type, property);
821
+
822
+ if (prop_type === 'node_array') {
823
+ count += value.nodes.filter((id: NodeId) => id === node_id).length;
824
+ } else if (prop_type === 'node' && value === node_id) {
825
+ count += 1;
826
+ }
827
+
828
+ if ((prop_type === 'text' || prop_type === 'node_array') && value) {
829
+ count += [...value.marks, ...value.annotations].filter(
830
+ (range) => range.node_id === node_id
831
+ ).length;
832
+ }
833
+ }
834
+ }
835
+
836
+ return count;
837
+ }
838
+
839
+ /**
840
+ * Whether a mark can be switched between these types.
841
+ */
842
+ export function can_switch_mark_type(
843
+ schema: DocumentSchema,
844
+ from_type: string,
845
+ to_type: string
846
+ ): boolean {
847
+ const from_schema = schema[from_type];
848
+ const to_schema = schema[to_type];
849
+ return (
850
+ from_schema?.kind === 'mark' &&
851
+ to_schema?.kind === 'mark' &&
852
+ Object.keys(from_schema.properties ?? {}).length === 0 &&
853
+ Object.keys(to_schema.properties ?? {}).length === 0
854
+ );
855
+ }
856
+
857
+ /**
858
+ * An attachment (mark or annotation) enriched with its index in the parent
859
+ * attachment array and the resolved payload node.
860
+ */
861
+ export type SelectedAttachment = Attachment & { index: number; node: DocumentNode };
862
+
863
+ /**
864
+ * Gets ranges of the given key ('marks' or 'annotations') touched by the
865
+ * current selection.
866
+ *
867
+ * Non-collapsed selections use strict half-open range intersection, so merely
868
+ * adjacent boundaries do not count as touching. Collapsed text carets are only
869
+ * inside a range when they are strictly between start and end; a caret at
870
+ * either boundary is not active.
871
+ */
872
+ function get_selected_ranges(
873
+ schema: DocumentSchema,
874
+ doc: Document,
875
+ selection: Selection | null | undefined,
876
+ key: 'marks' | 'annotations'
877
+ ): SelectedAttachment[] {
878
+ if (selection?.type !== 'text' && selection?.type !== 'node') return [];
879
+ const range = get_selection_range(selection);
880
+ if (!range) return [];
881
+
882
+ const annotated_prop = get(schema, doc, selection.path);
883
+ const ranges = annotated_prop?.[key] ?? [];
884
+ const is_collapsed = range.start_offset === range.end_offset;
885
+
886
+ return ranges
887
+ .map((attachment: Attachment, index: number) => {
888
+ const node = doc.nodes[attachment.node_id];
889
+ return {
890
+ ...attachment,
891
+ index,
892
+ node
893
+ };
894
+ })
895
+ .filter(({ start_offset, end_offset }: SelectedAttachment) => {
896
+ if (is_collapsed) {
897
+ return start_offset < range.start_offset && end_offset > range.start_offset;
898
+ }
899
+ return start_offset < range.end_offset && end_offset > range.start_offset;
900
+ });
901
+ }
902
+
903
+ /**
904
+ * Gets marks touched by the current selection.
905
+ */
906
+ export function get_selected_marks(
907
+ schema: DocumentSchema,
908
+ doc: Document,
909
+ selection?: Selection | null
910
+ ): SelectedAttachment[] {
911
+ return get_selected_ranges(schema, doc, selection, 'marks');
912
+ }
913
+
914
+ /**
915
+ * Gets annotations touched by the current selection.
916
+ */
917
+ export function get_selected_annotations(
918
+ schema: DocumentSchema,
919
+ doc: Document,
920
+ selection?: Selection | null
921
+ ): SelectedAttachment[] {
922
+ return get_selected_ranges(schema, doc, selection, 'annotations');
923
+ }
924
+
925
+ /**
926
+ * Node types represented in the selected ranges.
927
+ */
928
+ export function get_selected_range_types(selected_ranges: SelectedAttachment[]): Set<string> {
929
+ return new Set(
930
+ selected_ranges.map(({ node }) => node?.type).filter((type): type is string => Boolean(type))
931
+ );
932
+ }
933
+
934
+ /**
935
+ * Counts references to a node, excluding nodes marked for deletion.
936
+ */
937
+ export function count_references_excluding_deleted(
938
+ schema: DocumentSchema,
939
+ doc: Document,
940
+ target_node_id: NodeId,
941
+ nodes_to_delete: Record<NodeId, boolean>
942
+ ): number {
943
+ let count = 0;
944
+
945
+ for (const node of Object.values(doc.nodes)) {
946
+ if (nodes_to_delete[node.id]) continue;
947
+
948
+ for (const [property, value] of Object.entries(node)) {
949
+ if (property === 'id' || property === 'type') continue;
950
+
951
+ const prop_type = property_type(schema, node.type, property);
952
+
953
+ if (prop_type === 'node_array') {
954
+ count += value.nodes.filter((id: NodeId) => id === target_node_id).length;
955
+ } else if (prop_type === 'node' && value === target_node_id) {
956
+ count += 1;
957
+ }
958
+
959
+ if ((prop_type === 'text' || prop_type === 'node_array') && value) {
960
+ count += [...value.marks, ...value.annotations].filter(
961
+ (range) => range.node_id === target_node_id
962
+ ).length;
963
+ }
964
+ }
965
+ }
966
+
967
+ return count;
968
+ }
969
+
970
+ /**
971
+ * Iterates all node references going out of a node, invoking the visitor
972
+ * once per occurrence (multiplicity matters for reference counting).
973
+ * Covers the same reference kinds as count_references_excluding_deleted:
974
+ * a `node` property, `node_array` .nodes ids, and the mark/annotation
975
+ * node_ids carried on `text` and `node_array` properties.
976
+ */
977
+ export function visit_node_references(
978
+ schema: DocumentSchema,
979
+ node: DocumentNode,
980
+ visit: (referenced_id: NodeId) => void
981
+ ): void {
982
+ const node_schema = schema[node.type];
983
+ if (!node_schema) return;
984
+
985
+ for (const [property, prop_def] of Object.entries(node_schema.properties)) {
986
+ const value = node[property];
987
+ if (value === undefined || value === null) continue;
988
+
989
+ const prop_type = prop_def.type;
990
+
991
+ if (prop_type === 'node_array') {
992
+ for (const id of value.nodes) visit(id);
993
+ } else if (prop_type === 'node' && typeof value === 'string') {
994
+ visit(value);
995
+ }
996
+
997
+ if ((prop_type === 'text' || prop_type === 'node_array') && value) {
998
+ for (const range of value.marks) visit(range.node_id);
999
+ for (const range of value.annotations) visit(range.node_id);
1000
+ }
1001
+ }
1002
+ }
1003
+
1004
+ /**
1005
+ * Builds reference counts for every referenced node in one full-document
1006
+ * scan. Used by cascade deletion so the fixpoint can decrement counts
1007
+ * incrementally instead of re-scanning the document per candidate node.
1008
+ */
1009
+ export function build_reference_counts(schema: DocumentSchema, doc: Document): Map<NodeId, number> {
1010
+ const counts: Map<NodeId, number> = new Map();
1011
+ for (const node of Object.values(doc.nodes)) {
1012
+ visit_node_references(schema, node, (id) => {
1013
+ counts.set(id, (counts.get(id) || 0) + 1);
1014
+ });
1015
+ }
1016
+ return counts;
1017
+ }
1018
+
1019
+ /**
1020
+ * Gets ids of nodes that reference any of the target nodes.
1021
+ */
1022
+ export function get_referencing_node_ids(
1023
+ schema: DocumentSchema,
1024
+ doc: Document,
1025
+ target_node_ids: Iterable<NodeId>
1026
+ ): NodeId[] {
1027
+ const target_ids = new Set(target_node_ids);
1028
+ const referencing_node_ids: Set<NodeId> = new Set();
1029
+ if (target_ids.size === 0) return [];
1030
+
1031
+ for (const node of Object.values(doc.nodes)) {
1032
+ for (const [property, value] of Object.entries(node)) {
1033
+ if (property === 'id' || property === 'type') continue;
1034
+
1035
+ const prop_type = property_type(schema, node.type, property);
1036
+
1037
+ if (prop_type === 'node_array') {
1038
+ if (value.nodes.some((id: NodeId) => target_ids.has(id))) {
1039
+ referencing_node_ids.add(node.id);
1040
+ }
1041
+ } else if (prop_type === 'node' && target_ids.has(value)) {
1042
+ referencing_node_ids.add(node.id);
1043
+ }
1044
+
1045
+ if (
1046
+ (prop_type === 'text' || prop_type === 'node_array') &&
1047
+ value &&
1048
+ [...value.marks, ...value.annotations].some((range) => target_ids.has(range.node_id))
1049
+ ) {
1050
+ referencing_node_ids.add(node.id);
1051
+ }
1052
+ }
1053
+ }
1054
+
1055
+ return [...referencing_node_ids];
1056
+ }
1057
+
1058
+ /**
1059
+ * Validates a selection against the current document state.
1060
+ * Works with any object that implements get() and inspect() (Session or Transaction).
1061
+ *
1062
+ * @throws {Error} Throws if the selection is invalid
1063
+ */
1064
+ export function validate_selection(
1065
+ selection: Selection | null | undefined,
1066
+ session_or_transaction: {
1067
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1068
+ get: (path: DocumentPath) => any;
1069
+ inspect: (path: DocumentPath) => Inspection;
1070
+ }
1071
+ ): void {
1072
+ if (!selection) return;
1073
+
1074
+ const selection_type = selection.type;
1075
+ if (!['node', 'text', 'property'].includes(selection_type)) {
1076
+ throw new Error(`Invalid selection type: ${selection_type}`);
1077
+ }
1078
+
1079
+ if (selection_type === 'node') {
1080
+ const node_array_prop = session_or_transaction.get(selection.path);
1081
+
1082
+ if (!node_array_prop || !Array.isArray(node_array_prop.nodes)) {
1083
+ throw new Error('Node selection path must point to a node_array');
1084
+ }
1085
+
1086
+ const max_offset = node_array_prop.nodes.length;
1087
+ if (selection.anchor_offset < 0 || selection.anchor_offset > max_offset) {
1088
+ throw new Error(
1089
+ `Node selection anchor_offset (${selection.anchor_offset}) is out of bounds. Max is ${max_offset}.`
1090
+ );
1091
+ }
1092
+ if (selection.focus_offset < 0 || selection.focus_offset > max_offset) {
1093
+ throw new Error(
1094
+ `Node selection focus_offset (${selection.focus_offset}) is out of bounds. Max is ${max_offset}.`
1095
+ );
1096
+ }
1097
+ } else if (selection_type === 'text') {
1098
+ const text = session_or_transaction.get(selection.path);
1099
+
1100
+ if (!text || typeof text.content !== 'string') {
1101
+ throw new Error('Text selection path must point to text');
1102
+ }
1103
+
1104
+ const char_length = get_char_length(text.content);
1105
+ if (selection.anchor_offset < 0 || selection.anchor_offset > char_length) {
1106
+ throw new Error(
1107
+ `Text selection anchor_offset (${selection.anchor_offset}) is out of bounds. Max is ${char_length}.`
1108
+ );
1109
+ }
1110
+ if (selection.focus_offset < 0 || selection.focus_offset > char_length) {
1111
+ throw new Error(
1112
+ `Text selection focus_offset (${selection.focus_offset}) is out of bounds. Max is ${char_length}.`
1113
+ );
1114
+ }
1115
+ } else if (selection_type === 'property') {
1116
+ if (!session_or_transaction.inspect(selection.path)) {
1117
+ throw new Error(`Property selection path not found: ${serialize_path(selection.path)}`);
1118
+ }
1119
+ }
1120
+ }