@texuli/core 0.1.0-next.0 → 0.1.0-next.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/README.md +9 -1
  2. package/dist/editor/document.d.ts +65 -0
  3. package/dist/editor/document.js +394 -0
  4. package/dist/editor/edit.d.ts +7 -0
  5. package/dist/editor/edit.js +7 -0
  6. package/dist/editor/graphemes.d.ts +17 -0
  7. package/dist/editor/graphemes.js +71 -0
  8. package/dist/editor/history.d.ts +57 -0
  9. package/dist/editor/history.js +77 -0
  10. package/dist/editor/inline.d.ts +56 -0
  11. package/dist/editor/inline.js +166 -0
  12. package/dist/editor/input-rules.d.ts +17 -0
  13. package/dist/editor/input-rules.js +151 -0
  14. package/dist/editor/keymap.d.ts +30 -0
  15. package/dist/editor/keymap.js +69 -0
  16. package/dist/editor/load.d.ts +44 -0
  17. package/dist/editor/load.js +248 -0
  18. package/dist/editor/markdown.d.ts +33 -0
  19. package/dist/editor/markdown.js +705 -0
  20. package/dist/editor/model.d.ts +13 -0
  21. package/dist/editor/model.js +13 -0
  22. package/dist/editor/selection.d.ts +27 -0
  23. package/dist/editor/selection.js +85 -0
  24. package/dist/editor/transforms.d.ts +93 -0
  25. package/dist/editor/transforms.js +847 -0
  26. package/dist/editor/tree.d.ts +35 -0
  27. package/dist/editor/tree.js +103 -0
  28. package/dist/editor/types.d.ts +147 -0
  29. package/dist/editor/types.js +39 -0
  30. package/dist/index.d.ts +1 -0
  31. package/dist/index.js +1 -0
  32. package/dist/url/safe-href.d.ts +1 -20
  33. package/dist/url/safe-href.js +24 -1
  34. package/dist/url-policy/index.d.ts +1 -0
  35. package/dist/url-policy/index.js +1 -0
  36. package/dist/url-policy/safe-image-src.d.ts +1 -1
  37. package/dist/url-policy/safe-image-src.js +4 -4
  38. package/dist/url-policy/safe-image-srcset.d.ts +1 -1
  39. package/dist/url-policy/safe-image-srcset.js +3 -1
  40. package/dist/url-policy/safe-media-src.d.ts +1 -1
  41. package/dist/url-policy/safe-media-src.js +4 -4
  42. package/dist/url-policy/shared.d.ts +0 -2
  43. package/dist/url-policy/shared.js +0 -3
  44. package/dist/url-policy/trust.d.ts +8 -0
  45. package/dist/url-policy/trust.js +9 -0
  46. package/package.json +22 -6
package/README.md CHANGED
@@ -18,6 +18,7 @@ rest.
18
18
  | Controllable state | `createControllableState` | [core-controllable](docs/core-controllable.md) |
19
19
  | Direction and locale | `directionOf`, `inlineSideToPhysical`, `createCollator`, `compareLocale`, `resolveMessages` | [core-dir-locale](docs/core-dir-locale.md) |
20
20
  | Dismiss | `bindDismissLayer`, `isPointerOutside`, `isFocusOutside`, `createDismissOutsideEvent`, `createOpenGestureGuard` | [core-dismiss](docs/core-dismiss.md) |
21
+ | Editor model | `loadEditorDocument`, `normalizeDocument`, `validateEditorDocument`, `caret`, `clampSelection`, `inlineToTree`; transforms (`insertText`, `splitBlock`, `deleteBackward`, `toggleMark`, `setLink`, `replaceSelection`, `applyInputRules`…), `editorHistoryReducer`, `createEditorKeymap`, `fromMarkdown`/`toMarkdown` (subpaths `@texuli/core/editor-model`, `editor-transforms`, `editor-history`, `editor-keymap`, `editor-markdown`; not in the root barrel) | [core-editor](docs/core-editor.md) |
21
22
  | Environment | `isBrowser`, `getOwnerDocument`, `getOwnerWindow`, `listen`, `matchesMedia`, `requestFrame` | [core-env](docs/core-env.md) |
22
23
  | Focus scope | `createFocusScope` | [core-focus-scope](docs/core-focus-scope.md) |
23
24
  | Form bridge | `createFormBridge` | [core-form-bridge](docs/core-form-bridge.md) |
@@ -37,6 +38,13 @@ rest.
37
38
  | URL policy (links) | `safeHref`, `type HrefTrust` | [core-url-policy](docs/core-url-policy.md) |
38
39
  | URL policy (media) | `safeImageSrc`, `safeImageSrcset`, `safeMediaSrc`, `MAX_DATA_IMAGE_BYTES` | [core-url-policy](docs/core-url-policy.md) |
39
40
 
41
+ The editor texels are not in the root barrel: the barrel measures 22.63 of
42
+ its 24 KiB budget. `pnpm size --filter core/editor` (2026-10-25) measures
43
+ `editor-history` at 754 B and `editor-keymap` at 2.49 KiB, both within the 4
44
+ KiB per-texel default. `editor-markdown` (7.03 KiB), `editor-model` (7.81
45
+ KiB) and `editor-transforms` (10.36 KiB) are over it, so their budget rows
46
+ are pending with the owner.
47
+
40
48
  The tables above name the headline exports per texel, not the full surface:
41
49
  read `src/index.ts` (and the per-area `index.ts`) for every exported symbol
42
50
  and type. The threat model behind the URL policy lives in
@@ -100,7 +108,7 @@ packages/core/
100
108
  ├── docs/ one contract doc per texel (core-*.md) + overlay-engine.md
101
109
  ├── src/index.ts the public barrel (only what is exported here is public)
102
110
  ├── src/<area>/ one directory per texel (announcer, roving, layer-stack, …)
103
- └── tests/ 22 test files, one or more per texel, including hostile
111
+ └── tests/ 26 test files, one or more per texel, including hostile
104
112
  inputs for every policy that guards a sink
105
113
  ```
106
114
 
@@ -0,0 +1,65 @@
1
+ import { type EditorBlock, type EditorBlockId, type EditorDocument, type EditorInline, type EditorIssue, type EditorList, type EditorListItem, type EditorParagraph, type EditorQuote, type EditorTextBlock } from './types.js';
2
+ /** A source of fresh block ids. */
3
+ export type EditorIdSource = () => EditorBlockId;
4
+ /** The default id source: unique for the page's lifetime. */
5
+ export declare const nextEditorId: EditorIdSource;
6
+ /** A deterministic id source (`b1`, `b2`…), for tests and server rendering. */
7
+ export declare function createEditorIds(prefix?: string): EditorIdSource;
8
+ export declare const LANG_PATTERN: RegExp;
9
+ export declare const paragraph: (content?: readonly EditorInline[], id?: EditorBlockId) => EditorParagraph;
10
+ /** A document with one empty paragraph. */
11
+ export declare const emptyEditorDocument: (newId?: EditorIdSource) => EditorDocument;
12
+ export declare const isTextBlock: (block: EditorBlock) => block is EditorTextBlock;
13
+ export declare const isVoidBlock: (block: EditorBlock) => boolean;
14
+ /** Length of a text block in UTF-16 code units (a break counts 1). */
15
+ export declare const blockLength: (block: EditorTextBlock) => number;
16
+ /** Text of a text block; a break is `\n`. */
17
+ export declare const blockText: (block: EditorTextBlock) => string;
18
+ /** What contains a block, outermost first. */
19
+ export type EditorAncestor = {
20
+ kind: 'quote';
21
+ quote: EditorQuote;
22
+ } | {
23
+ kind: 'item';
24
+ list: EditorList;
25
+ item: EditorListItem;
26
+ itemIndex: number;
27
+ };
28
+ export interface EditorLocation {
29
+ block: EditorBlock;
30
+ /** Index among `siblings`. */
31
+ index: number;
32
+ siblings: readonly EditorBlock[];
33
+ ancestors: readonly EditorAncestor[];
34
+ }
35
+ /** Where block `id` sits in the tree, or `null`. */
36
+ export declare function locateBlock(doc: EditorDocument, id: EditorBlockId): EditorLocation | null;
37
+ /** The leaves (text and void blocks) in document order, with their nesting depth. */
38
+ export declare function editorLeaves(doc: EditorDocument): {
39
+ block: EditorBlock;
40
+ depth: number;
41
+ }[];
42
+ /** Replace block `id` with `replace(block)` (any number of blocks). Unchanged parts keep identity. */
43
+ export declare function replaceBlock(doc: EditorDocument, id: EditorBlockId, replace: (block: EditorBlock) => readonly EditorBlock[]): EditorDocument;
44
+ /** Replace list item `id` with `replace(item, list)` (any number of items). */
45
+ export declare function replaceItem(doc: EditorDocument, id: EditorBlockId, replace: (item: EditorListItem, list: EditorList) => readonly EditorListItem[]): EditorDocument;
46
+ /** Remove every block whose id is in `ids`, then prune emptied containers. */
47
+ export declare function removeBlocks(doc: EditorDocument, ids: ReadonlySet<EditorBlockId>, newId?: EditorIdSource): EditorDocument;
48
+ /**
49
+ * The model invariants, repaired: inline content canonical, empty containers
50
+ * removed, list items starting with a paragraph, `checked` only on todo items,
51
+ * `start` only on ordered lists, at least one block. Unchanged blocks keep
52
+ * their identity (structural sharing).
53
+ */
54
+ export declare function normalizeDocument(doc: EditorDocument, newId?: EditorIdSource): EditorDocument;
55
+ /** The plain text of a document: blocks joined by blank lines, breaks as `\n`. */
56
+ export declare function editorPlainText(doc: EditorDocument): string;
57
+ /** Characters of text in a document. */
58
+ export declare const editorCharCount: (doc: EditorDocument) => number;
59
+ /** Blocks in a document, containers and list items included. */
60
+ export declare function editorBlockCount(blocks: readonly EditorBlock[]): number;
61
+ /**
62
+ * Every broken invariant of `doc`, or `[]` for a valid document. Never throws,
63
+ * whatever the input; transforms are tested against it.
64
+ */
65
+ export declare function validateEditorDocument(doc: unknown): EditorIssue[];
@@ -0,0 +1,394 @@
1
+ import { cleanText, inlineLength, inlineText, markKey, normalizeInline, sortMarks, } from "./inline.js";
2
+ import { EDITOR_LIMITS, } from "./types.js";
3
+ let counter = 0;
4
+ /** The default id source: unique for the page's lifetime. */
5
+ export const nextEditorId = () => `tx-ed-${(++counter).toString(36)}`;
6
+ /** A deterministic id source (`b1`, `b2`…), for tests and server rendering. */
7
+ export function createEditorIds(prefix = 'b') {
8
+ let n = 0;
9
+ return () => `${prefix}${++n}`;
10
+ }
11
+ export const LANG_PATTERN = /^[a-z][a-z0-9+-]{0,31}$/;
12
+ export const paragraph = (content = [], id = nextEditorId()) => ({ id, type: 'paragraph', content });
13
+ /** A document with one empty paragraph. */
14
+ export const emptyEditorDocument = (newId = nextEditorId) => ({
15
+ version: 1,
16
+ blocks: [paragraph([], newId())],
17
+ });
18
+ export const isTextBlock = (block) => block.type === 'paragraph' || block.type === 'heading' || block.type === 'code';
19
+ export const isVoidBlock = (block) => block.type === 'divider' || block.type === 'media';
20
+ /** Length of a text block in UTF-16 code units (a break counts 1). */
21
+ export const blockLength = (block) => block.type === 'code' ? block.text.length : inlineLength(block.content);
22
+ /** Text of a text block; a break is `\n`. */
23
+ export const blockText = (block) => block.type === 'code' ? block.text : inlineText(block.content);
24
+ /** Where block `id` sits in the tree, or `null`. */
25
+ export function locateBlock(doc, id) {
26
+ const visit = (blocks, ancestors) => {
27
+ for (let index = 0; index < blocks.length; index += 1) {
28
+ const block = blocks[index];
29
+ if (block.id === id)
30
+ return { block, index, siblings: blocks, ancestors };
31
+ if (block.type === 'quote') {
32
+ const found = visit(block.blocks, [...ancestors, { kind: 'quote', quote: block }]);
33
+ if (found)
34
+ return found;
35
+ }
36
+ else if (block.type === 'list') {
37
+ for (let itemIndex = 0; itemIndex < block.items.length; itemIndex += 1) {
38
+ const item = block.items[itemIndex];
39
+ const found = visit(item.blocks, [
40
+ ...ancestors,
41
+ { kind: 'item', list: block, item, itemIndex },
42
+ ]);
43
+ if (found)
44
+ return found;
45
+ }
46
+ }
47
+ }
48
+ return null;
49
+ };
50
+ return visit(doc.blocks, []);
51
+ }
52
+ /** The leaves (text and void blocks) in document order, with their nesting depth. */
53
+ export function editorLeaves(doc) {
54
+ const out = [];
55
+ const visit = (blocks, depth) => {
56
+ for (const block of blocks) {
57
+ if (block.type === 'quote')
58
+ visit(block.blocks, depth + 1);
59
+ else if (block.type === 'list')
60
+ for (const item of block.items)
61
+ visit(item.blocks, depth + 1);
62
+ else
63
+ out.push({ block, depth });
64
+ }
65
+ };
66
+ visit(doc.blocks, 0);
67
+ return out;
68
+ }
69
+ const replaceAt = (list, index, items) => [
70
+ ...list.slice(0, index),
71
+ ...items,
72
+ ...list.slice(index + 1),
73
+ ];
74
+ function rewrite(blocks, id, how) {
75
+ for (let i = 0; i < blocks.length; i += 1) {
76
+ const block = blocks[i];
77
+ if (block.id === id && how.block)
78
+ return replaceAt(blocks, i, how.block(block));
79
+ if (block.type === 'quote') {
80
+ const inner = rewrite(block.blocks, id, how);
81
+ if (inner)
82
+ return replaceAt(blocks, i, [{ ...block, blocks: inner }]);
83
+ }
84
+ else if (block.type === 'list') {
85
+ for (let j = 0; j < block.items.length; j += 1) {
86
+ const item = block.items[j];
87
+ if (item.id === id && how.item) {
88
+ return replaceAt(blocks, i, [
89
+ { ...block, items: replaceAt(block.items, j, how.item(item, block)) },
90
+ ]);
91
+ }
92
+ const inner = rewrite(item.blocks, id, how);
93
+ if (inner) {
94
+ const items = replaceAt(block.items, j, [{ ...item, blocks: inner }]);
95
+ return replaceAt(blocks, i, [{ ...block, items }]);
96
+ }
97
+ }
98
+ }
99
+ }
100
+ return null;
101
+ }
102
+ /** Replace block `id` with `replace(block)` (any number of blocks). Unchanged parts keep identity. */
103
+ export function replaceBlock(doc, id, replace) {
104
+ const blocks = rewrite(doc.blocks, id, { block: replace });
105
+ return blocks ? { ...doc, blocks } : doc;
106
+ }
107
+ /** Replace list item `id` with `replace(item, list)` (any number of items). */
108
+ export function replaceItem(doc, id, replace) {
109
+ const blocks = rewrite(doc.blocks, id, { item: replace });
110
+ return blocks ? { ...doc, blocks } : doc;
111
+ }
112
+ /** Remove every block whose id is in `ids`, then prune emptied containers. */
113
+ export function removeBlocks(doc, ids, newId = nextEditorId) {
114
+ const strip = (blocks) => {
115
+ let changed = false;
116
+ const out = [];
117
+ for (const block of blocks) {
118
+ if (ids.has(block.id)) {
119
+ changed = true;
120
+ continue;
121
+ }
122
+ let next = block;
123
+ if (block.type === 'quote') {
124
+ const inner = strip(block.blocks);
125
+ if (inner !== block.blocks)
126
+ next = { ...block, blocks: inner };
127
+ }
128
+ else if (block.type === 'list') {
129
+ let itemsChanged = false;
130
+ const items = block.items.map((item) => {
131
+ const inner = strip(item.blocks);
132
+ if (inner === item.blocks)
133
+ return item;
134
+ itemsChanged = true;
135
+ return { ...item, blocks: inner };
136
+ });
137
+ if (itemsChanged)
138
+ next = { ...block, items };
139
+ }
140
+ if (next !== block)
141
+ changed = true;
142
+ out.push(next);
143
+ }
144
+ return changed ? out : blocks;
145
+ };
146
+ return normalizeDocument({ ...doc, blocks: strip(doc.blocks) }, newId);
147
+ }
148
+ /**
149
+ * The model invariants, repaired: inline content canonical, empty containers
150
+ * removed, list items starting with a paragraph, `checked` only on todo items,
151
+ * `start` only on ordered lists, at least one block. Unchanged blocks keep
152
+ * their identity (structural sharing).
153
+ */
154
+ export function normalizeDocument(doc, newId = nextEditorId) {
155
+ const blocks = normalizeBlocks(doc.blocks, newId);
156
+ if (blocks.length === 0)
157
+ return { version: 1, blocks: [paragraph([], newId())] };
158
+ return blocks === doc.blocks && doc.version === 1 ? doc : { version: 1, blocks };
159
+ }
160
+ function normalizeBlocks(blocks, newId) {
161
+ let changed = false;
162
+ const out = [];
163
+ for (const block of blocks) {
164
+ const next = normalizeBlock(block, newId);
165
+ if (next !== block)
166
+ changed = true;
167
+ if (next)
168
+ out.push(next);
169
+ }
170
+ return changed ? out : blocks;
171
+ }
172
+ function normalizeBlock(block, newId) {
173
+ switch (block.type) {
174
+ case 'paragraph':
175
+ case 'heading': {
176
+ const content = normalizeInline(block.content);
177
+ return content === block.content ? block : { ...block, content };
178
+ }
179
+ case 'code':
180
+ return block.lang === undefined || LANG_PATTERN.test(block.lang)
181
+ ? block
182
+ : { id: block.id, type: 'code', text: block.text };
183
+ case 'quote': {
184
+ const inner = normalizeBlocks(block.blocks, newId);
185
+ if (inner.length === 0)
186
+ return null;
187
+ return inner === block.blocks ? block : { ...block, blocks: inner };
188
+ }
189
+ case 'list': {
190
+ let changed = false;
191
+ const items = [];
192
+ for (const item of block.items) {
193
+ const next = normalizeItem(item, block.kind, newId);
194
+ if (next !== item)
195
+ changed = true;
196
+ if (next)
197
+ items.push(next);
198
+ }
199
+ if (items.length === 0)
200
+ return null;
201
+ const start = block.kind === 'ordered' ? block.start : undefined;
202
+ const startOk = start === undefined || (Number.isInteger(start) && start >= 0);
203
+ if (!changed && startOk && start === block.start && start !== 1)
204
+ return block;
205
+ const list = { id: block.id, type: 'list', kind: block.kind, items };
206
+ if (startOk && start !== undefined && start !== 1)
207
+ list.start = start;
208
+ return list;
209
+ }
210
+ default:
211
+ return block;
212
+ }
213
+ }
214
+ function normalizeItem(item, kind, newId) {
215
+ let blocks = normalizeBlocks(item.blocks, newId);
216
+ if (blocks.length === 0)
217
+ return null;
218
+ const first = blocks[0];
219
+ if (first.type === 'heading')
220
+ blocks = [paragraph(first.content, first.id), ...blocks.slice(1)];
221
+ else if (first.type !== 'paragraph')
222
+ blocks = [paragraph([], newId()), ...blocks];
223
+ const checked = kind === 'todo' ? item.checked === true : undefined;
224
+ if (blocks === item.blocks && checked === item.checked)
225
+ return item;
226
+ return checked === undefined ? { id: item.id, blocks } : { id: item.id, checked, blocks };
227
+ }
228
+ /** The plain text of a document: blocks joined by blank lines, breaks as `\n`. */
229
+ export function editorPlainText(doc) {
230
+ return editorLeaves(doc)
231
+ .map(({ block }) => isTextBlock(block) ? blockText(block) : block.type === 'media' ? block.alt : '')
232
+ .filter(Boolean)
233
+ .join('\n\n');
234
+ }
235
+ /** Characters of text in a document. */
236
+ export const editorCharCount = (doc) => editorLeaves(doc).reduce((sum, { block }) => sum + (isTextBlock(block) ? blockLength(block) : 0), 0);
237
+ /** Blocks in a document, containers and list items included. */
238
+ export function editorBlockCount(blocks) {
239
+ let count = 0;
240
+ for (const block of blocks) {
241
+ count += 1;
242
+ if (block.type === 'quote')
243
+ count += editorBlockCount(block.blocks);
244
+ else if (block.type === 'list')
245
+ for (const item of block.items)
246
+ count += 1 + editorBlockCount(item.blocks);
247
+ }
248
+ return count;
249
+ }
250
+ const issue = (code, message, block) => block === undefined ? { code, message } : { code, message, block };
251
+ /**
252
+ * Every broken invariant of `doc`, or `[]` for a valid document. Never throws,
253
+ * whatever the input; transforms are tested against it.
254
+ */
255
+ export function validateEditorDocument(doc) {
256
+ const issues = [];
257
+ const record = doc;
258
+ if (!record || typeof record !== 'object' || !Array.isArray(record.blocks)) {
259
+ return [issue('invalid-shape', 'A document is { version: 1, blocks: [...] }.')];
260
+ }
261
+ if (record.version !== 1)
262
+ issues.push(issue('version-unsupported', 'Only version 1 is known.'));
263
+ if (record.blocks.length === 0)
264
+ issues.push(issue('invalid-shape', 'A document has at least one block.'));
265
+ const ids = new Set();
266
+ let chars = 0;
267
+ const seen = (id) => {
268
+ if (typeof id !== 'string' || id === '') {
269
+ issues.push(issue('invalid-shape', 'Every block and item has a non-empty string id.'));
270
+ return false;
271
+ }
272
+ if (ids.has(id))
273
+ issues.push(issue('duplicate-id', `Id "${id}" is used twice.`, id));
274
+ ids.add(id);
275
+ return true;
276
+ };
277
+ const text = (value, id, keep) => {
278
+ if (typeof value !== 'string') {
279
+ issues.push(issue('invalid-shape', 'Text must be a string.', id));
280
+ return;
281
+ }
282
+ chars += value.length;
283
+ if (cleanText(value, keep).text !== value) {
284
+ issues.push(issue('characters-stripped', 'Text holds characters the model never stores.', id));
285
+ }
286
+ };
287
+ const inline = (content, id) => {
288
+ if (!Array.isArray(content)) {
289
+ issues.push(issue('invalid-shape', 'Inline content must be an array.', id));
290
+ return;
291
+ }
292
+ for (const node of content) {
293
+ if (node?.type === 'break') {
294
+ chars += 1;
295
+ continue;
296
+ }
297
+ if (node?.type !== 'text' || !Array.isArray(node.marks)) {
298
+ issues.push(issue('invalid-shape', 'Inline nodes are text runs or breaks.', id));
299
+ return;
300
+ }
301
+ text(node.text, id, '');
302
+ for (const mark of node.marks) {
303
+ if (mark?.type === 'link' && (typeof mark.href !== 'string' || mark.href === '')) {
304
+ issues.push(issue('invalid-shape', 'A link mark has an href.', id));
305
+ }
306
+ else if (!['link', 'strong', 'em', 'strike', 'code'].includes(mark?.type)) {
307
+ issues.push(issue('unknown-mark', `Unknown mark "${String(mark?.type)}".`, id));
308
+ }
309
+ }
310
+ if (sortMarks(node.marks) !== node.marks ||
311
+ new Set(node.marks.map(markKey)).size !== node.marks.length) {
312
+ issues.push(issue('not-normalized', 'Marks are unique and in canonical order.', id));
313
+ }
314
+ }
315
+ if (normalizeInline(content) !== content) {
316
+ issues.push(issue('not-normalized', 'Runs are non-empty and neighbors differ in marks.', id));
317
+ }
318
+ };
319
+ const visit = (blocks, depth) => {
320
+ if (!Array.isArray(blocks)) {
321
+ issues.push(issue('invalid-shape', 'Blocks must be an array.'));
322
+ return;
323
+ }
324
+ for (const raw of blocks) {
325
+ const block = raw;
326
+ if (!block || typeof block !== 'object' || !seen(block.id))
327
+ continue;
328
+ const id = block.id;
329
+ switch (block.type) {
330
+ case 'paragraph':
331
+ inline(block.content, id);
332
+ break;
333
+ case 'heading':
334
+ if (![1, 2, 3, 4].includes(block.level))
335
+ issues.push(issue('invalid-shape', 'Heading levels are 1 to 4.', id));
336
+ inline(block.content, id);
337
+ break;
338
+ case 'code':
339
+ text(block.text, id, '\n\t');
340
+ if (block.lang !== undefined && !LANG_PATTERN.test(block.lang)) {
341
+ issues.push(issue('invalid-shape', 'A code language is a short lowercase name.', id));
342
+ }
343
+ break;
344
+ case 'quote':
345
+ case 'list': {
346
+ if (depth >= EDITOR_LIMITS.depth)
347
+ issues.push(issue('depth-capped', 'Lists and quotes nest at most 4 levels.', id));
348
+ if (block.type === 'quote') {
349
+ if (!Array.isArray(block.blocks) || block.blocks.length === 0) {
350
+ issues.push(issue('invalid-shape', 'A quote holds blocks.', id));
351
+ }
352
+ visit(block.blocks, depth + 1);
353
+ break;
354
+ }
355
+ if (!['bullet', 'ordered', 'todo'].includes(block.kind))
356
+ issues.push(issue('invalid-shape', 'Unknown list kind.', id));
357
+ if (block.start !== undefined && block.kind !== 'ordered')
358
+ issues.push(issue('not-normalized', 'Only ordered lists have a start.', id));
359
+ if (!Array.isArray(block.items) || block.items.length === 0) {
360
+ issues.push(issue('invalid-shape', 'A list holds items.', id));
361
+ break;
362
+ }
363
+ for (const item of block.items) {
364
+ if (!item || !seen(item.id))
365
+ continue;
366
+ if ((block.kind === 'todo') !== (typeof item.checked === 'boolean')) {
367
+ issues.push(issue('not-normalized', 'Todo items, and only they, are checked or not.', item.id));
368
+ }
369
+ if (!Array.isArray(item.blocks) || item.blocks[0]?.type !== 'paragraph') {
370
+ issues.push(issue('invalid-shape', 'A list item starts with a paragraph.', item.id));
371
+ }
372
+ visit(item.blocks, depth + 1);
373
+ }
374
+ break;
375
+ }
376
+ case 'divider':
377
+ break;
378
+ case 'media':
379
+ if (typeof block.ref !== 'string' || block.ref === '')
380
+ issues.push(issue('invalid-shape', 'Media has a ref.', id));
381
+ text(block.alt, id, '');
382
+ break;
383
+ default:
384
+ issues.push(issue('unknown-node', `Unknown block type "${String(block.type)}".`, id));
385
+ }
386
+ }
387
+ };
388
+ visit(record.blocks, 0);
389
+ if (ids.size > EDITOR_LIMITS.blocks)
390
+ issues.push(issue('blocks-capped', 'Too many blocks.'));
391
+ if (chars > EDITOR_LIMITS.chars)
392
+ issues.push(issue('text-truncated', 'Too much text.'));
393
+ return issues;
394
+ }
@@ -0,0 +1,7 @@
1
+ /**
2
+ * `@texuli/core/editor-transforms`: pure editor transforms and Markdown input
3
+ * rules (ADR-003). Each takes an `EditorState` and returns a new state or the
4
+ * issues that refused the edit; unchanged blocks keep their identity.
5
+ */
6
+ export { applyInputRules } from './input-rules.js';
7
+ export { applyComposition, deleteBackward, deleteForward, deleteSelection, type EditorDeleteUnit, type EditorTransformOptions, type EditorVoidInsert, editorTextBlock, insertBlock, insertBreak, insertText, liftListItem, moveBlock, removeLink, replaceSelection, type SetLinkOptions, setBlockType, setLink, sinkListItem, splitBlock, toggleList, toggleMark, toggleQuote, toggleTodo, } from './transforms.js';
@@ -0,0 +1,7 @@
1
+ /**
2
+ * `@texuli/core/editor-transforms`: pure editor transforms and Markdown input
3
+ * rules (ADR-003). Each takes an `EditorState` and returns a new state or the
4
+ * issues that refused the edit; unchanged blocks keep their identity.
5
+ */
6
+ export { applyInputRules } from "./input-rules.js";
7
+ export { applyComposition, deleteBackward, deleteForward, deleteSelection, editorTextBlock, insertBlock, insertBreak, insertText, liftListItem, moveBlock, removeLink, replaceSelection, setBlockType, setLink, sinkListItem, splitBlock, toggleList, toggleMark, toggleQuote, toggleTodo, } from "./transforms.js";
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Grapheme and word boundaries for caret movement and deletion, from
3
+ * `Intl.Segmenter` when the platform has it, else code points (a surrogate
4
+ * pair is never split). Offsets are UTF-16 code units.
5
+ */
6
+ /** Every grapheme boundary of `text`, ascending, from 0 to `text.length`. */
7
+ export declare function graphemeBoundaries(text: string): number[];
8
+ /** The nearest grapheme boundary at or before `offset`. */
9
+ export declare function snapToGrapheme(text: string, offset: number): number;
10
+ /** The grapheme boundary before `offset` (0 at the start). */
11
+ export declare function previousGrapheme(text: string, offset: number): number;
12
+ /** The grapheme boundary after `offset` (`text.length` at the end). */
13
+ export declare function nextGrapheme(text: string, offset: number): number;
14
+ /** Start of the word before `offset`, skipping spaces and punctuation first. */
15
+ export declare function previousWord(text: string, offset: number): number;
16
+ /** End of the word after `offset`, skipping spaces and punctuation first. */
17
+ export declare function nextWord(text: string, offset: number): number;
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Grapheme and word boundaries for caret movement and deletion, from
3
+ * `Intl.Segmenter` when the platform has it, else code points (a surrogate
4
+ * pair is never split). Offsets are UTF-16 code units.
5
+ */
6
+ const cache = new Map();
7
+ function segmenter(granularity) {
8
+ if (!cache.has(granularity)) {
9
+ const Ctor = Intl
10
+ .Segmenter;
11
+ cache.set(granularity, Ctor ? new Ctor(undefined, { granularity }) : null);
12
+ }
13
+ return cache.get(granularity) ?? null;
14
+ }
15
+ /** Every grapheme boundary of `text`, ascending, from 0 to `text.length`. */
16
+ export function graphemeBoundaries(text) {
17
+ const seg = segmenter('grapheme');
18
+ const out = [];
19
+ if (seg)
20
+ for (const part of seg.segment(text))
21
+ out.push(part.index);
22
+ else
23
+ for (let i = 0; i < text.length; i += (text.codePointAt(i) ?? 0) > 0xffff ? 2 : 1)
24
+ out.push(i);
25
+ out.push(text.length);
26
+ return out;
27
+ }
28
+ const clamp = (text, offset) => Math.max(0, Math.min(text.length, Math.trunc(offset) || 0));
29
+ /** The nearest grapheme boundary at or before `offset`. */
30
+ export function snapToGrapheme(text, offset) {
31
+ const at = clamp(text, offset);
32
+ return graphemeBoundaries(text).findLast((boundary) => boundary <= at) ?? 0;
33
+ }
34
+ /** The grapheme boundary before `offset` (0 at the start). */
35
+ export function previousGrapheme(text, offset) {
36
+ const at = clamp(text, offset);
37
+ return graphemeBoundaries(text).findLast((boundary) => boundary < at) ?? 0;
38
+ }
39
+ /** The grapheme boundary after `offset` (`text.length` at the end). */
40
+ export function nextGrapheme(text, offset) {
41
+ const at = clamp(text, offset);
42
+ return graphemeBoundaries(text).find((boundary) => boundary > at) ?? text.length;
43
+ }
44
+ function words(text) {
45
+ const seg = segmenter('word');
46
+ if (seg) {
47
+ return [...seg.segment(text)].map((part) => ({
48
+ start: part.index,
49
+ end: part.index + part.segment.length,
50
+ word: part.isWordLike === true,
51
+ }));
52
+ }
53
+ return [...text.matchAll(/[\p{L}\p{N}_]+|[^\p{L}\p{N}_]+/gu)].map((match) => ({
54
+ start: match.index ?? 0,
55
+ end: (match.index ?? 0) + match[0].length,
56
+ word: /[\p{L}\p{N}_]/u.test(match[0]),
57
+ }));
58
+ }
59
+ /** Start of the word before `offset`, skipping spaces and punctuation first. */
60
+ export function previousWord(text, offset) {
61
+ const at = clamp(text, offset);
62
+ const before = words(text).filter((part) => part.start < at);
63
+ const word = before.findLast((part) => part.word);
64
+ return word ? word.start : 0;
65
+ }
66
+ /** End of the word after `offset`, skipping spaces and punctuation first. */
67
+ export function nextWord(text, offset) {
68
+ const at = clamp(text, offset);
69
+ const word = words(text).find((part) => part.end > at && part.word);
70
+ return word ? word.end : text.length;
71
+ }
@@ -0,0 +1,57 @@
1
+ import { type EditorIssue, type EditorSelection, type EditorState, type EditorTransformResult } from './types.js';
2
+ /**
3
+ * What an edit was, for undo grouping. Consecutive `typing` (or `deletion`)
4
+ * in one block within `EDITOR_HISTORY_MERGE_MS` is one undo step; an
5
+ * `input-rule` is always its own step, so undo right after it restores the
6
+ * characters the user typed.
7
+ */
8
+ export type EditorEditKind = 'typing' | 'deletion' | 'input-rule' | 'other';
9
+ /** Pause after which typing starts a new undo step. */
10
+ export declare const EDITOR_HISTORY_MERGE_MS = 500;
11
+ export interface EditorHistory {
12
+ past: readonly EditorState[];
13
+ present: EditorState;
14
+ future: readonly EditorState[];
15
+ /** Why the last refused edit was refused (cleared by the next accepted action). */
16
+ errors: readonly EditorIssue[];
17
+ /** Snapshots kept in `past` (default 200). */
18
+ limit: number;
19
+ /** The last accepted edit, while it can still merge. */
20
+ last: {
21
+ kind: EditorEditKind;
22
+ block: string | null;
23
+ at: number;
24
+ } | null;
25
+ }
26
+ export type EditorHistoryAction = {
27
+ type: 'apply';
28
+ result: EditorTransformResult;
29
+ kind?: EditorEditKind;
30
+ at?: number;
31
+ }
32
+ /** A selection change: no undo step, and the next edit starts a new one. */
33
+ | {
34
+ type: 'select';
35
+ selection: EditorSelection;
36
+ } | {
37
+ type: 'undo';
38
+ } | {
39
+ type: 'redo';
40
+ }
41
+ /** A new document from outside: history restarts. */
42
+ | {
43
+ type: 'replace';
44
+ state: EditorState;
45
+ };
46
+ /** A history whose present is `state`. */
47
+ export declare function createEditorHistory(state: EditorState, options?: {
48
+ limit?: number;
49
+ }): EditorHistory;
50
+ /**
51
+ * Snapshot-stack undo and redo (ADR-003). States are immutable and share
52
+ * unchanged blocks, so a snapshot costs only what changed. A refused edit
53
+ * keeps `present` and records `errors`.
54
+ */
55
+ export declare function editorHistoryReducer(history: EditorHistory, action: EditorHistoryAction): EditorHistory;
56
+ export declare const canUndo: (history: EditorHistory) => boolean;
57
+ export declare const canRedo: (history: EditorHistory) => boolean;