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

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 +17 -4
  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 +83 -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 +29 -2
  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 +8 -2
  43. package/dist/url-policy/shared.js +11 -6
  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
@@ -0,0 +1,44 @@
1
+ import type { HrefTrust } from '../url/safe-href.js';
2
+ import { type EditorIdSource } from './document.js';
3
+ import { type EditorDocument, type EditorInline, type EditorIssue, type EditorIssueCode, type EditorLimits, type EditorMark } from './types.js';
4
+ export interface EditorLoadOptions {
5
+ /** URL policy tier for link hrefs. Default `untrusted`. */
6
+ trust?: HrefTrust;
7
+ newId?: EditorIdSource;
8
+ /** Lower caps (a blocks `text` field holds 4,000 characters). */
9
+ limits?: Partial<EditorLimits>;
10
+ }
11
+ export interface EditorLoadResult {
12
+ doc: EditorDocument;
13
+ diagnostics: readonly EditorIssue[];
14
+ }
15
+ /** Caps shared by the loaders: blocks, characters and diagnostics. Internal. */
16
+ export declare class LoadBudget {
17
+ readonly diagnostics: EditorIssue[];
18
+ readonly limits: EditorLimits;
19
+ readonly trust: HrefTrust;
20
+ readonly newId: EditorIdSource;
21
+ blocks: number;
22
+ chars: number;
23
+ private readonly once;
24
+ constructor(options: EditorLoadOptions);
25
+ report(code: EditorIssueCode, message: string, onlyOnce?: boolean): void;
26
+ /** Reserve one block; false (reported) once the cap is reached. */
27
+ block(): boolean;
28
+ /** Keep as much of `text` as the character cap allows. */
29
+ text(text: string): string;
30
+ clean(text: string, keep: string): string;
31
+ /** A policy-checked href, or `undefined` (reported). */
32
+ href(value: unknown): string | undefined;
33
+ /** Inline content for plain text under the caps. */
34
+ inline(text: string, marks?: readonly EditorMark[]): readonly EditorInline[];
35
+ }
36
+ /**
37
+ * Load an untrusted document (stored JSON, the editor's own clipboard) into a
38
+ * valid version 1 document. Never throws: malformed parts are dropped,
39
+ * unknown blocks become paragraphs of their text, deep nesting and oversized
40
+ * input are capped, and every link href passes `safeHref` at `trust`
41
+ * (`untrusted` by default). Each degradation is reported once in
42
+ * `diagnostics`. Every block gets a fresh id.
43
+ */
44
+ export declare function loadEditorDocument(input: unknown, options?: EditorLoadOptions): EditorLoadResult;
@@ -0,0 +1,248 @@
1
+ import { safeHref } from "../url/safe-href.js";
2
+ import { LANG_PATTERN, nextEditorId, normalizeDocument, paragraph, } from "./document.js";
3
+ import { cleanText, normalizeInline, textToInline } from "./inline.js";
4
+ import { EDITOR_LIMITS, } from "./types.js";
5
+ const MAX_DIAGNOSTICS = 50;
6
+ /** Caps shared by the loaders: blocks, characters and diagnostics. Internal. */
7
+ export class LoadBudget {
8
+ diagnostics = [];
9
+ limits;
10
+ trust;
11
+ newId;
12
+ blocks = 0;
13
+ chars = 0;
14
+ once = new Set();
15
+ constructor(options) {
16
+ this.limits = { ...EDITOR_LIMITS, ...options.limits };
17
+ this.trust = options.trust === 'developer' ? 'developer' : 'untrusted';
18
+ this.newId = options.newId ?? nextEditorId;
19
+ }
20
+ report(code, message, onlyOnce = true) {
21
+ if (onlyOnce && this.once.has(code))
22
+ return;
23
+ this.once.add(code);
24
+ if (this.diagnostics.length < MAX_DIAGNOSTICS)
25
+ this.diagnostics.push({ code, message });
26
+ }
27
+ /** Reserve one block; false (reported) once the cap is reached. */
28
+ block() {
29
+ if (this.blocks >= this.limits.blocks) {
30
+ this.report('blocks-capped', `Blocks after the first ${this.limits.blocks} were dropped.`);
31
+ return false;
32
+ }
33
+ this.blocks += 1;
34
+ return true;
35
+ }
36
+ /** Keep as much of `text` as the character cap allows. */
37
+ text(text) {
38
+ const room = Math.max(0, this.limits.chars - this.chars);
39
+ if (text.length > room) {
40
+ this.report('text-truncated', `Text after ${this.limits.chars} characters was dropped.`);
41
+ text = text.slice(0, room);
42
+ }
43
+ this.chars += text.length;
44
+ return text;
45
+ }
46
+ clean(text, keep) {
47
+ const clean = cleanText(text, keep);
48
+ if (clean.stripped)
49
+ this.report('characters-stripped', 'Control and bidirectional override characters were removed.');
50
+ return clean.text;
51
+ }
52
+ /** A policy-checked href, or `undefined` (reported). */
53
+ href(value) {
54
+ const href = typeof value === 'string' && value.length <= EDITOR_LIMITS.href
55
+ ? safeHref(value, this.trust)
56
+ : undefined;
57
+ if (href === undefined)
58
+ this.report('link-rejected', 'A link URL was refused by the URL policy; its text was kept.');
59
+ return href;
60
+ }
61
+ /** Inline content for plain text under the caps. */
62
+ inline(text, marks = []) {
63
+ const { content, stripped } = textToInline(this.text(text), marks);
64
+ if (stripped)
65
+ this.report('characters-stripped', 'Control and bidirectional override characters were removed.');
66
+ return content;
67
+ }
68
+ }
69
+ const isRecord = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
70
+ /** Plain text found in an unknown value, depth- and size-bounded. */
71
+ function plainOf(value, depth = 0, out = []) {
72
+ if (depth > 8 || out.length > 256)
73
+ return out;
74
+ if (typeof value === 'string')
75
+ out.push(value);
76
+ else if (Array.isArray(value))
77
+ for (const entry of value.slice(0, 256))
78
+ plainOf(entry, depth + 1, out);
79
+ else if (isRecord(value)) {
80
+ for (const key of ['text', 'content', 'blocks', 'items', 'alt'])
81
+ if (key in value)
82
+ plainOf(value[key], depth + 1, out);
83
+ }
84
+ return out;
85
+ }
86
+ function loadMarks(raw, budget) {
87
+ if (!Array.isArray(raw))
88
+ return [];
89
+ const marks = [];
90
+ for (const mark of raw.slice(0, 8)) {
91
+ const type = isRecord(mark) ? mark.type : undefined;
92
+ if (type === 'strong' || type === 'em' || type === 'strike' || type === 'code')
93
+ marks.push({ type });
94
+ else if (type === 'link') {
95
+ const href = budget.href(mark.href);
96
+ if (href)
97
+ marks.push({ type: 'link', href });
98
+ }
99
+ else
100
+ budget.report('unknown-mark', 'Unknown marks were dropped.');
101
+ }
102
+ return marks;
103
+ }
104
+ function loadInline(raw, budget) {
105
+ if (typeof raw === 'string')
106
+ return budget.inline(raw);
107
+ if (!Array.isArray(raw))
108
+ return [];
109
+ const out = [];
110
+ for (const node of raw) {
111
+ if (budget.chars >= budget.limits.chars) {
112
+ budget.report('text-truncated', `Text after ${budget.limits.chars} characters was dropped.`);
113
+ break;
114
+ }
115
+ if (isRecord(node) && node.type === 'break') {
116
+ out.push({ type: 'break' });
117
+ budget.chars += 1;
118
+ }
119
+ else if (isRecord(node) && node.type === 'text' && typeof node.text === 'string') {
120
+ out.push(...budget.inline(node.text, loadMarks(node.marks, budget)));
121
+ }
122
+ else
123
+ budget.report('invalid-shape', 'Malformed inline nodes were dropped.');
124
+ }
125
+ return normalizeInline(out);
126
+ }
127
+ /** Paragraphs holding the plain text of a value that cannot be kept as is. */
128
+ function degrade(raw, budget) {
129
+ const text = plainOf(raw).join(' ').replace(/\s+/g, ' ').trim();
130
+ return text && budget.block() ? [paragraph(budget.inline(text), budget.newId())] : [];
131
+ }
132
+ function loadBlocks(raw, depth, budget) {
133
+ if (!Array.isArray(raw)) {
134
+ budget.report('invalid-shape', 'Blocks must be an array.');
135
+ return [];
136
+ }
137
+ const out = [];
138
+ for (const entry of raw) {
139
+ if (budget.blocks >= budget.limits.blocks) {
140
+ budget.block();
141
+ break;
142
+ }
143
+ out.push(...loadBlock(entry, depth, budget));
144
+ }
145
+ return out;
146
+ }
147
+ function loadBlock(raw, depth, budget) {
148
+ if (!isRecord(raw)) {
149
+ budget.report('invalid-shape', 'Malformed blocks were dropped.');
150
+ return [];
151
+ }
152
+ const type = raw.type;
153
+ if (type === 'list' || type === 'quote') {
154
+ if (depth >= budget.limits.depth) {
155
+ budget.report('depth-capped', `Lists and quotes deeper than ${budget.limits.depth} levels became text.`);
156
+ return degrade(raw, budget);
157
+ }
158
+ if (!budget.block())
159
+ return [];
160
+ const id = budget.newId();
161
+ if (type === 'quote') {
162
+ const blocks = loadBlocks(raw.blocks, depth + 1, budget);
163
+ return blocks.length ? [{ id, type: 'quote', blocks }] : [];
164
+ }
165
+ const kind = raw.kind === 'ordered' || raw.kind === 'todo' ? raw.kind : 'bullet';
166
+ const items = [];
167
+ for (const item of Array.isArray(raw.items) ? raw.items : []) {
168
+ if (!isRecord(item) || !budget.block())
169
+ continue;
170
+ const blocks = loadBlocks(item.blocks, depth + 1, budget);
171
+ items.push(kind === 'todo'
172
+ ? { id: budget.newId(), checked: item.checked === true, blocks }
173
+ : { id: budget.newId(), blocks });
174
+ }
175
+ const start = typeof raw.start === 'number' && Number.isInteger(raw.start) && raw.start >= 0
176
+ ? raw.start
177
+ : undefined;
178
+ return [
179
+ kind === 'ordered' && start !== undefined
180
+ ? { id, type: 'list', kind, start, items }
181
+ : { id, type: 'list', kind, items },
182
+ ];
183
+ }
184
+ if (type === 'paragraph' ||
185
+ type === 'heading' ||
186
+ type === 'code' ||
187
+ type === 'divider' ||
188
+ type === 'media') {
189
+ if (!budget.block())
190
+ return [];
191
+ const id = budget.newId();
192
+ if (type === 'divider')
193
+ return [{ id, type }];
194
+ if (type === 'code') {
195
+ const text = budget.text(budget.clean(typeof raw.text === 'string' ? raw.text : '', '\n\t'));
196
+ const lang = typeof raw.lang === 'string' ? raw.lang.toLowerCase() : undefined;
197
+ return [lang && LANG_PATTERN.test(lang) ? { id, type, lang, text } : { id, type, text }];
198
+ }
199
+ if (type === 'media') {
200
+ const ref = typeof raw.ref === 'string' && raw.ref.length <= EDITOR_LIMITS.href ? raw.ref.trim() : '';
201
+ if (!ref) {
202
+ budget.report('invalid-shape', 'Media without a usable ref was dropped.');
203
+ return [];
204
+ }
205
+ const alt = budget.text(budget.clean(typeof raw.alt === 'string' ? raw.alt : '', ''));
206
+ return [{ id, type, ref, alt }];
207
+ }
208
+ const content = loadInline(raw.content, budget);
209
+ if (type === 'heading') {
210
+ const level = typeof raw.level === 'number' && Number.isInteger(raw.level) ? raw.level : 0;
211
+ if (level >= 1 && level <= 6) {
212
+ if (level > 4)
213
+ budget.report('heading-clamped', 'Headings deeper than level 4 became level 4.');
214
+ return [{ id, type, level: Math.min(level, 4), content }];
215
+ }
216
+ budget.report('invalid-shape', 'Headings without a valid level became paragraphs.');
217
+ }
218
+ return [{ id, type: 'paragraph', content }];
219
+ }
220
+ budget.report('unknown-node', 'Unknown block types became plain paragraphs.');
221
+ return degrade(raw, budget);
222
+ }
223
+ /**
224
+ * Load an untrusted document (stored JSON, the editor's own clipboard) into a
225
+ * valid version 1 document. Never throws: malformed parts are dropped,
226
+ * unknown blocks become paragraphs of their text, deep nesting and oversized
227
+ * input are capped, and every link href passes `safeHref` at `trust`
228
+ * (`untrusted` by default). Each degradation is reported once in
229
+ * `diagnostics`. Every block gets a fresh id.
230
+ */
231
+ export function loadEditorDocument(input, options = {}) {
232
+ const budget = new LoadBudget(options);
233
+ const record = isRecord(input) ? input : null;
234
+ if (record && record.version !== undefined && record.version !== 1) {
235
+ budget.report('version-unsupported', `Document version ${String(record.version)} is not supported.`);
236
+ return {
237
+ doc: normalizeDocument({ version: 1, blocks: [] }, budget.newId),
238
+ diagnostics: budget.diagnostics,
239
+ };
240
+ }
241
+ if (!record || !Array.isArray(record.blocks))
242
+ budget.report('invalid-shape', 'A document is { version: 1, blocks: [...] }.');
243
+ const blocks = record ? loadBlocks(record.blocks ?? [], 0, budget) : [];
244
+ return {
245
+ doc: normalizeDocument({ version: 1, blocks }, budget.newId),
246
+ diagnostics: budget.diagnostics,
247
+ };
248
+ }
@@ -0,0 +1,33 @@
1
+ import { type EditorLoadOptions, type EditorLoadResult } from './load.js';
2
+ import type { EditorDocument } from './types.js';
3
+ /**
4
+ * The editor's Markdown codec (ADR-003): the same closed subset that
5
+ * `@texuli/blocks` text blocks and the catalog's rich text parse, read into
6
+ * and written from the editor model. Nothing here produces or keeps HTML: raw
7
+ * tags stay literal text, every link passes `safeHref`, and images become
8
+ * their alt text (or a media block when one stands alone in a paragraph).
9
+ */
10
+ export declare const MARKDOWN_CODEC_LIMITS: Readonly<{
11
+ /** Longest source read; the rest is dropped with a diagnostic. */
12
+ source: 100000;
13
+ /** Nested emphasis and links. */
14
+ inlineDepth: 6;
15
+ }>;
16
+ export interface MarkdownReadOptions extends EditorLoadOptions {
17
+ /** Override the source cap (a blocks `text` field holds 4,000 characters). */
18
+ maxSource?: number;
19
+ }
20
+ /**
21
+ * Read Markdown (untrusted by default) into a valid document. Never throws:
22
+ * nesting deeper than the depth cap and oversized input degrade to text,
23
+ * refused links keep their text, headings deeper than 4 become level 4, and
24
+ * each degradation is reported once in `diagnostics`.
25
+ */
26
+ export declare function fromMarkdown(source: unknown, options?: MarkdownReadOptions): EditorLoadResult;
27
+ /**
28
+ * Write a document as the closed Markdown subset. Reading the result back
29
+ * gives the same document, except for what the subset cannot spell: empty
30
+ * paragraphs, breaks in headings (written as spaces), breaks at the edges of
31
+ * a block, and whitespace at the edges of a paragraph or a mark.
32
+ */
33
+ export declare function toMarkdown(doc: EditorDocument): string;