@kumwe/studio-rich-text 0.1.0-alpha.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +22 -0
- package/THIRD_PARTY_NOTICES.md +19 -0
- package/dist/first-party-tools.d.ts +173 -0
- package/dist/first-party-tools.d.ts.map +1 -0
- package/dist/first-party-tools.js +1012 -0
- package/dist/first-party-tools.js.map +1 -0
- package/dist/index.d.ts +62 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +648 -0
- package/dist/index.js.map +1 -0
- package/dist/markdown.d.ts +14 -0
- package/dist/markdown.d.ts.map +1 -0
- package/dist/markdown.js +272 -0
- package/dist/markdown.js.map +1 -0
- package/dist/profiles.d.ts +19 -0
- package/dist/profiles.d.ts.map +1 -0
- package/dist/profiles.js +87 -0
- package/dist/profiles.js.map +1 -0
- package/dist/safe-html.d.ts +16 -0
- package/dist/safe-html.d.ts.map +1 -0
- package/dist/safe-html.js +249 -0
- package/dist/safe-html.js.map +1 -0
- package/dist/strict-csp-surface.d.ts +10 -0
- package/dist/strict-csp-surface.d.ts.map +1 -0
- package/dist/strict-csp-surface.js +298 -0
- package/dist/strict-csp-surface.js.map +1 -0
- package/dist/studio-rich-text-editor.d.ts +55 -0
- package/dist/studio-rich-text-editor.d.ts.map +1 -0
- package/dist/studio-rich-text-editor.js +121 -0
- package/dist/studio-rich-text-editor.js.map +1 -0
- package/package.json +42 -0
- package/third-party-licenses/codex-notifier-1.1.2.txt +21 -0
- package/third-party-licenses/codex-tooltip-1.0.6.txt +7 -0
- package/third-party-licenses/editorjs__caret-1.1.0.txt +21 -0
- package/third-party-licenses/editorjs__dom-1.1.0.txt +21 -0
- package/third-party-licenses/editorjs__editorjs-2.31.6.txt +190 -0
- package/third-party-licenses/editorjs__helpers-1.2.2.txt +21 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,648 @@
|
|
|
1
|
+
export { EditorJsRichTextEditorFactory, StudioRichTextEditorFactory, } from './studio-rich-text-editor.js';
|
|
2
|
+
export { StudioStrictCspRichTextSurfaceAdapter } from './strict-csp-surface.js';
|
|
3
|
+
export { exportStudioMarkdown, importStudioMarkdown, } from './markdown.js';
|
|
4
|
+
export { importSafeHtml, STUDIO_SAFE_HTML_POLICY } from './safe-html.js';
|
|
5
|
+
export { DOCUMENTATION_RICH_TEXT_PROFILE, MARKETING_RICH_TEXT_PROFILE, PORTABLE_RICH_TEXT_PROFILE, resolveContainerRichTextProfile, resolveRichTextProfile, } from './profiles.js';
|
|
6
|
+
const PORTABLE_MARKS = Object.freeze(['bold', 'code', 'highlight', 'italic', 'strike']);
|
|
7
|
+
const PORTABLE_NODES = Object.freeze([
|
|
8
|
+
'blockquote',
|
|
9
|
+
'bulletList',
|
|
10
|
+
'callout',
|
|
11
|
+
'checklist',
|
|
12
|
+
'checklistItem',
|
|
13
|
+
'codeBlock',
|
|
14
|
+
'doc',
|
|
15
|
+
'hardBreak',
|
|
16
|
+
'heading',
|
|
17
|
+
'horizontalRule',
|
|
18
|
+
'listItem',
|
|
19
|
+
'orderedList',
|
|
20
|
+
'paragraph',
|
|
21
|
+
'table',
|
|
22
|
+
'tableCell',
|
|
23
|
+
'tableRow',
|
|
24
|
+
'text',
|
|
25
|
+
]);
|
|
26
|
+
const DEFAULT_HEADING_LEVELS = Object.freeze([2, 3, 4]);
|
|
27
|
+
const DEFAULT_MAXIMUM_DOCUMENT_BYTES = 1_048_576;
|
|
28
|
+
const DEFAULT_MAXIMUM_MARKS = 20_000;
|
|
29
|
+
const DEFAULT_MAXIMUM_MARKS_PER_NODE = PORTABLE_MARKS.length;
|
|
30
|
+
const RICH_TEXT_HARD_LIMITS = Object.freeze({
|
|
31
|
+
maximumDepth: 128,
|
|
32
|
+
maximumDocumentBytes: 10_485_760,
|
|
33
|
+
maximumMarks: 400_000,
|
|
34
|
+
maximumMarksPerNode: PORTABLE_MARKS.length,
|
|
35
|
+
maximumNodes: 100_000,
|
|
36
|
+
maximumTextLength: 10_485_760,
|
|
37
|
+
});
|
|
38
|
+
const ATTRIBUTE_HARD_LIMITS = Object.freeze({
|
|
39
|
+
maximumDepth: 32,
|
|
40
|
+
maximumItemsPerArray: 10_000,
|
|
41
|
+
maximumPropertiesPerObject: 1_000,
|
|
42
|
+
maximumStringLength: 1_048_576,
|
|
43
|
+
maximumTotalBytes: RICH_TEXT_HARD_LIMITS.maximumDocumentBytes,
|
|
44
|
+
});
|
|
45
|
+
export const DEFAULT_RICH_TEXT_ATTRIBUTE_LIMITS = Object.freeze({
|
|
46
|
+
maximumDepth: 8,
|
|
47
|
+
maximumItemsPerArray: 256,
|
|
48
|
+
maximumPropertiesPerObject: 64,
|
|
49
|
+
maximumStringLength: 4_096,
|
|
50
|
+
maximumTotalBytes: 65_536,
|
|
51
|
+
});
|
|
52
|
+
export const DEFAULT_RICH_TEXT_PROFILE = Object.freeze({
|
|
53
|
+
allowedAttributes: Object.freeze({
|
|
54
|
+
callout: Object.freeze(['tone']),
|
|
55
|
+
checklistItem: Object.freeze(['checked', 'level']),
|
|
56
|
+
codeBlock: Object.freeze(['language']),
|
|
57
|
+
heading: Object.freeze(['level']),
|
|
58
|
+
'mark:highlight': Object.freeze(['tone']),
|
|
59
|
+
orderedList: Object.freeze(['start']),
|
|
60
|
+
table: Object.freeze(['header']),
|
|
61
|
+
}),
|
|
62
|
+
allowedMarks: PORTABLE_MARKS,
|
|
63
|
+
allowedNodes: PORTABLE_NODES,
|
|
64
|
+
attributeLimits: DEFAULT_RICH_TEXT_ATTRIBUTE_LIMITS,
|
|
65
|
+
headingLevels: DEFAULT_HEADING_LEVELS,
|
|
66
|
+
maximumDepth: 32,
|
|
67
|
+
maximumDocumentBytes: DEFAULT_MAXIMUM_DOCUMENT_BYTES,
|
|
68
|
+
maximumMarks: DEFAULT_MAXIMUM_MARKS,
|
|
69
|
+
maximumMarksPerNode: DEFAULT_MAXIMUM_MARKS_PER_NODE,
|
|
70
|
+
maximumNodes: 5_000,
|
|
71
|
+
maximumTextLength: 250_000,
|
|
72
|
+
});
|
|
73
|
+
export function parseRichTextDocument(value, profile = DEFAULT_RICH_TEXT_PROFILE) {
|
|
74
|
+
validateProfile(profile);
|
|
75
|
+
const state = { attributeBytes: 0, markCount: 0, nodeCount: 0, textLength: 0 };
|
|
76
|
+
const attributeLimits = resolveAttributeLimits(profile);
|
|
77
|
+
const node = parseNode(value, '$', 1, profile, attributeLimits, state);
|
|
78
|
+
if (node.type !== 'doc') {
|
|
79
|
+
throw new TypeError('Rich-text document root must have type "doc".');
|
|
80
|
+
}
|
|
81
|
+
const document = { ...node, content: node.content ?? [], type: 'doc' };
|
|
82
|
+
const serialized = JSON.stringify(document);
|
|
83
|
+
if (utf8ByteLength(serialized) > maximumDocumentBytes(profile)) {
|
|
84
|
+
throw new RangeError('Rich-text document exceeds its total-byte limit.');
|
|
85
|
+
}
|
|
86
|
+
return document;
|
|
87
|
+
}
|
|
88
|
+
export function isRichTextDocumentEmpty(document) {
|
|
89
|
+
return document.content.every((node) => nodeText(node).trim().length === 0);
|
|
90
|
+
}
|
|
91
|
+
export function projectRichText(document) {
|
|
92
|
+
const projections = [];
|
|
93
|
+
for (const block of document.content) {
|
|
94
|
+
collectBlockProjections(block, projections);
|
|
95
|
+
}
|
|
96
|
+
return projections;
|
|
97
|
+
}
|
|
98
|
+
function collectBlockProjections(node, projections) {
|
|
99
|
+
switch (node.type) {
|
|
100
|
+
case 'checklistItem':
|
|
101
|
+
case 'heading':
|
|
102
|
+
case 'paragraph':
|
|
103
|
+
case 'tableCell':
|
|
104
|
+
projections.push(projectLeafBlock(node));
|
|
105
|
+
break;
|
|
106
|
+
case 'codeBlock':
|
|
107
|
+
projections.push({ embeds: [], spans: [], text: node.text ?? '', type: 'codeBlock' });
|
|
108
|
+
break;
|
|
109
|
+
case 'horizontalRule':
|
|
110
|
+
projections.push({ embeds: [], spans: [], text: '', type: 'horizontalRule' });
|
|
111
|
+
break;
|
|
112
|
+
case 'blockquote':
|
|
113
|
+
case 'bulletList':
|
|
114
|
+
case 'callout':
|
|
115
|
+
case 'checklist':
|
|
116
|
+
case 'listItem':
|
|
117
|
+
case 'orderedList':
|
|
118
|
+
case 'table':
|
|
119
|
+
case 'tableRow':
|
|
120
|
+
for (const child of node.content ?? []) {
|
|
121
|
+
collectBlockProjections(child, projections);
|
|
122
|
+
}
|
|
123
|
+
break;
|
|
124
|
+
default:
|
|
125
|
+
throw new TypeError(`Node type "${node.type}" has no renderer projection.`);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
function projectLeafBlock(node) {
|
|
129
|
+
const embeds = [];
|
|
130
|
+
const spans = [];
|
|
131
|
+
let text = '';
|
|
132
|
+
let offset = 0;
|
|
133
|
+
for (const inline of node.content ?? []) {
|
|
134
|
+
if (inline.type === 'text') {
|
|
135
|
+
const value = inline.text ?? '';
|
|
136
|
+
const length = codePointLength(value);
|
|
137
|
+
const marks = (inline.marks ?? []).map((mark) => mark.type).sort();
|
|
138
|
+
if (marks.length > 0 && length > 0) {
|
|
139
|
+
const previous = spans.at(-1);
|
|
140
|
+
if (previous?.end === offset && sameMarkNames(previous.marks, marks)) {
|
|
141
|
+
previous.end = offset + length;
|
|
142
|
+
}
|
|
143
|
+
else {
|
|
144
|
+
spans.push({ end: offset + length, marks, start: offset });
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
text += value;
|
|
148
|
+
offset += length;
|
|
149
|
+
}
|
|
150
|
+
else {
|
|
151
|
+
embeds.push({ index: offset, kind: inline.type });
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
return { embeds, spans, text, type: node.type };
|
|
155
|
+
}
|
|
156
|
+
function sameMarkNames(left, right) {
|
|
157
|
+
return left.length === right.length && left.every((name, index) => name === right[index]);
|
|
158
|
+
}
|
|
159
|
+
function codePointLength(value) {
|
|
160
|
+
let length = 0;
|
|
161
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
162
|
+
const code = value.charCodeAt(index);
|
|
163
|
+
if (code >= 0xd800 && code <= 0xdbff) {
|
|
164
|
+
const next = value.charCodeAt(index + 1);
|
|
165
|
+
if (next >= 0xdc00 && next <= 0xdfff) {
|
|
166
|
+
index += 1;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
length += 1;
|
|
170
|
+
}
|
|
171
|
+
return length;
|
|
172
|
+
}
|
|
173
|
+
function parseNode(value, path, depth, profile, attributeLimits, state) {
|
|
174
|
+
if (!isRecord(value)) {
|
|
175
|
+
throw new TypeError(`${path} must be a rich-text node with a non-empty type.`);
|
|
176
|
+
}
|
|
177
|
+
assertKnownKeys(value, path, ['attrs', 'content', 'marks', 'text', 'type']);
|
|
178
|
+
if (typeof value.type !== 'string' || value.type.length === 0) {
|
|
179
|
+
throw new TypeError(`${path} must be a rich-text node with a non-empty type.`);
|
|
180
|
+
}
|
|
181
|
+
if (!profile.allowedNodes.includes(value.type)) {
|
|
182
|
+
throw new TypeError(`${path} uses disallowed node type "${value.type}".`);
|
|
183
|
+
}
|
|
184
|
+
if (depth > profile.maximumDepth) {
|
|
185
|
+
throw new RangeError(`${path} exceeds the rich-text depth limit.`);
|
|
186
|
+
}
|
|
187
|
+
state.nodeCount += 1;
|
|
188
|
+
if (state.nodeCount > profile.maximumNodes) {
|
|
189
|
+
throw new RangeError('Rich-text document exceeds its node limit.');
|
|
190
|
+
}
|
|
191
|
+
const node = { type: value.type };
|
|
192
|
+
if (value.text !== undefined) {
|
|
193
|
+
if (typeof value.text !== 'string') {
|
|
194
|
+
throw new TypeError(`${path}.text must be a string.`);
|
|
195
|
+
}
|
|
196
|
+
node.text = value.text;
|
|
197
|
+
state.textLength += value.text.length;
|
|
198
|
+
if (state.textLength > profile.maximumTextLength) {
|
|
199
|
+
throw new RangeError('Rich-text document exceeds its text-length limit.');
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
if (value.attrs !== undefined) {
|
|
203
|
+
node.attrs = parseAttributes(value.attrs, `${path}.attrs`, value.type, profile, attributeLimits, state);
|
|
204
|
+
}
|
|
205
|
+
if (value.content !== undefined) {
|
|
206
|
+
if (!Array.isArray(value.content)) {
|
|
207
|
+
throw new TypeError(`${path}.content must be an array.`);
|
|
208
|
+
}
|
|
209
|
+
assertStructuralArray(value.content, `${path}.content`);
|
|
210
|
+
node.content = value.content.map((child, index) => parseNode(child, `${path}.content[${index}]`, depth + 1, profile, attributeLimits, state));
|
|
211
|
+
}
|
|
212
|
+
if (value.marks !== undefined) {
|
|
213
|
+
if (!Array.isArray(value.marks)) {
|
|
214
|
+
throw new TypeError(`${path}.marks must be an array.`);
|
|
215
|
+
}
|
|
216
|
+
assertStructuralArray(value.marks, `${path}.marks`);
|
|
217
|
+
const maximumPerNode = profile.maximumMarksPerNode ?? DEFAULT_MAXIMUM_MARKS_PER_NODE;
|
|
218
|
+
const maximumMarks = profile.maximumMarks ?? DEFAULT_MAXIMUM_MARKS;
|
|
219
|
+
if (value.marks.length > maximumPerNode) {
|
|
220
|
+
throw new RangeError(`${path}.marks exceeds the per-node mark limit.`);
|
|
221
|
+
}
|
|
222
|
+
if (state.markCount + value.marks.length > maximumMarks) {
|
|
223
|
+
throw new RangeError('Rich-text document exceeds its aggregate mark limit.');
|
|
224
|
+
}
|
|
225
|
+
state.markCount += value.marks.length;
|
|
226
|
+
node.marks = value.marks.map((mark, index) => parseMark(mark, `${path}.marks[${index}]`, profile, attributeLimits, state));
|
|
227
|
+
assertPortableMarkSet(node.marks, `${path}.marks`);
|
|
228
|
+
}
|
|
229
|
+
assertNodeGrammar(node, path, profile);
|
|
230
|
+
return node;
|
|
231
|
+
}
|
|
232
|
+
function parseMark(value, path, profile, attributeLimits, state) {
|
|
233
|
+
if (!isRecord(value)) {
|
|
234
|
+
throw new TypeError(`${path} must be a mark with a non-empty type.`);
|
|
235
|
+
}
|
|
236
|
+
assertKnownKeys(value, path, ['attrs', 'type']);
|
|
237
|
+
if (typeof value.type !== 'string' || value.type.length === 0) {
|
|
238
|
+
throw new TypeError(`${path} must be a mark with a non-empty type.`);
|
|
239
|
+
}
|
|
240
|
+
if (!profile.allowedMarks.includes(value.type)) {
|
|
241
|
+
throw new TypeError(`${path} uses disallowed mark type "${value.type}".`);
|
|
242
|
+
}
|
|
243
|
+
const mark = { type: value.type };
|
|
244
|
+
if (value.attrs !== undefined) {
|
|
245
|
+
mark.attrs = parseAttributes(value.attrs, `${path}.attrs`, `mark:${value.type}`, profile, attributeLimits, state);
|
|
246
|
+
}
|
|
247
|
+
if (mark.type === 'highlight') {
|
|
248
|
+
const tone = mark.attrs?.tone;
|
|
249
|
+
if (typeof tone !== 'string' ||
|
|
250
|
+
!['accent', 'danger', 'info', 'success', 'warning'].includes(tone)) {
|
|
251
|
+
throw new TypeError(`${path}.attrs.tone must be a configured highlight tone.`);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
else if (mark.attrs !== undefined) {
|
|
255
|
+
throw new TypeError(`${path} cannot carry attributes in the portable rich-text grammar.`);
|
|
256
|
+
}
|
|
257
|
+
return mark;
|
|
258
|
+
}
|
|
259
|
+
function assertNodeGrammar(node, path, profile) {
|
|
260
|
+
switch (node.type) {
|
|
261
|
+
case 'doc':
|
|
262
|
+
assertNoNodeFields(node, path, ['attrs', 'marks', 'text']);
|
|
263
|
+
if (node.content === undefined || node.content.length === 0) {
|
|
264
|
+
throw new TypeError(`${path}.content must contain at least one block node.`);
|
|
265
|
+
}
|
|
266
|
+
assertChildTypes(node.content, path, blockNodeTypes);
|
|
267
|
+
break;
|
|
268
|
+
case 'text':
|
|
269
|
+
assertNoNodeFields(node, path, ['attrs', 'content']);
|
|
270
|
+
if (node.text === undefined) {
|
|
271
|
+
throw new TypeError(`${path}.text is required for a text node.`);
|
|
272
|
+
}
|
|
273
|
+
if (node.text.length === 0) {
|
|
274
|
+
throw new TypeError(`${path}.text cannot be empty.`);
|
|
275
|
+
}
|
|
276
|
+
break;
|
|
277
|
+
case 'paragraph':
|
|
278
|
+
assertNoNodeFields(node, path, ['attrs', 'marks', 'text']);
|
|
279
|
+
assertChildTypes(node.content ?? [], path, inlineNodeTypes);
|
|
280
|
+
break;
|
|
281
|
+
case 'heading': {
|
|
282
|
+
assertNoNodeFields(node, path, ['marks', 'text']);
|
|
283
|
+
assertChildTypes(node.content ?? [], path, inlineNodeTypes);
|
|
284
|
+
const level = node.attrs?.level;
|
|
285
|
+
const levels = profile.headingLevels ?? DEFAULT_HEADING_LEVELS;
|
|
286
|
+
if (typeof level !== 'number' ||
|
|
287
|
+
!Number.isInteger(level) ||
|
|
288
|
+
!levels.includes(level)) {
|
|
289
|
+
throw new TypeError(`${path}.attrs.level must be a configured heading level.`);
|
|
290
|
+
}
|
|
291
|
+
break;
|
|
292
|
+
}
|
|
293
|
+
case 'orderedList': {
|
|
294
|
+
assertNoNodeFields(node, path, ['marks', 'text']);
|
|
295
|
+
assertNonEmptyChildTypes(node.content, path, listItemNodeTypes);
|
|
296
|
+
const start = node.attrs?.start;
|
|
297
|
+
if (start !== undefined && (!Number.isSafeInteger(start) || Number(start) < 1)) {
|
|
298
|
+
throw new TypeError(`${path}.attrs.start must be a positive integer.`);
|
|
299
|
+
}
|
|
300
|
+
break;
|
|
301
|
+
}
|
|
302
|
+
case 'bulletList':
|
|
303
|
+
assertNoNodeFields(node, path, ['attrs', 'marks', 'text']);
|
|
304
|
+
assertNonEmptyChildTypes(node.content, path, listItemNodeTypes);
|
|
305
|
+
break;
|
|
306
|
+
case 'listItem':
|
|
307
|
+
assertNoNodeFields(node, path, ['attrs', 'marks', 'text']);
|
|
308
|
+
assertNonEmptyChildTypes(node.content, path, blockNodeTypes);
|
|
309
|
+
if (node.content?.[0]?.type !== 'paragraph') {
|
|
310
|
+
throw new TypeError(`${path}.content must begin with a paragraph node.`);
|
|
311
|
+
}
|
|
312
|
+
break;
|
|
313
|
+
case 'blockquote':
|
|
314
|
+
assertNoNodeFields(node, path, ['attrs', 'marks', 'text']);
|
|
315
|
+
assertNonEmptyChildTypes(node.content, path, blockNodeTypes);
|
|
316
|
+
break;
|
|
317
|
+
case 'callout':
|
|
318
|
+
assertNoNodeFields(node, path, ['marks', 'text']);
|
|
319
|
+
assertNonEmptyChildTypes(node.content, path, blockNodeTypes);
|
|
320
|
+
if (typeof node.attrs?.tone !== 'string' ||
|
|
321
|
+
!['danger', 'info', 'success', 'warning'].includes(node.attrs.tone)) {
|
|
322
|
+
throw new TypeError(`${path}.attrs.tone must be a configured callout tone.`);
|
|
323
|
+
}
|
|
324
|
+
break;
|
|
325
|
+
case 'checklist':
|
|
326
|
+
assertNoNodeFields(node, path, ['attrs', 'marks', 'text']);
|
|
327
|
+
assertNonEmptyChildTypes(node.content, path, checklistItemNodeTypes);
|
|
328
|
+
break;
|
|
329
|
+
case 'checklistItem':
|
|
330
|
+
assertNoNodeFields(node, path, ['marks', 'text']);
|
|
331
|
+
assertChildTypes(node.content ?? [], path, inlineNodeTypes);
|
|
332
|
+
if (typeof node.attrs?.checked !== 'boolean') {
|
|
333
|
+
throw new TypeError(`${path}.attrs.checked must be a boolean.`);
|
|
334
|
+
}
|
|
335
|
+
if (!Number.isSafeInteger(node.attrs.level) ||
|
|
336
|
+
Number(node.attrs.level) < 0 ||
|
|
337
|
+
Number(node.attrs.level) > 4) {
|
|
338
|
+
throw new TypeError(`${path}.attrs.level must be an integer from zero through four.`);
|
|
339
|
+
}
|
|
340
|
+
break;
|
|
341
|
+
case 'table':
|
|
342
|
+
assertNoNodeFields(node, path, ['marks', 'text']);
|
|
343
|
+
assertNonEmptyChildTypes(node.content, path, tableRowNodeTypes);
|
|
344
|
+
if (typeof node.attrs?.header !== 'boolean') {
|
|
345
|
+
throw new TypeError(`${path}.attrs.header must be a boolean.`);
|
|
346
|
+
}
|
|
347
|
+
assertRectangularTable(node.content, path);
|
|
348
|
+
break;
|
|
349
|
+
case 'tableRow':
|
|
350
|
+
assertNoNodeFields(node, path, ['attrs', 'marks', 'text']);
|
|
351
|
+
assertNonEmptyChildTypes(node.content, path, tableCellNodeTypes);
|
|
352
|
+
break;
|
|
353
|
+
case 'tableCell':
|
|
354
|
+
assertNoNodeFields(node, path, ['attrs', 'marks', 'text']);
|
|
355
|
+
assertChildTypes(node.content ?? [], path, inlineNodeTypes);
|
|
356
|
+
break;
|
|
357
|
+
case 'codeBlock':
|
|
358
|
+
assertNoNodeFields(node, path, ['content', 'marks']);
|
|
359
|
+
if (node.text === undefined) {
|
|
360
|
+
throw new TypeError(`${path}.text is required for a code block.`);
|
|
361
|
+
}
|
|
362
|
+
if (typeof node.attrs?.language !== 'string' ||
|
|
363
|
+
!/^[A-Za-z0-9][A-Za-z0-9+_.#-]{0,63}$/u.test(node.attrs.language)) {
|
|
364
|
+
throw new TypeError(`${path}.attrs.language must be a bounded language identifier.`);
|
|
365
|
+
}
|
|
366
|
+
break;
|
|
367
|
+
case 'hardBreak':
|
|
368
|
+
case 'horizontalRule':
|
|
369
|
+
assertNoNodeFields(node, path, ['attrs', 'content', 'marks', 'text']);
|
|
370
|
+
break;
|
|
371
|
+
default:
|
|
372
|
+
throw new TypeError(`${path} uses a node without a portable grammar.`);
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
function assertPortableMarkSet(marks, path) {
|
|
376
|
+
const types = new Set();
|
|
377
|
+
for (const mark of marks) {
|
|
378
|
+
if (types.has(mark.type)) {
|
|
379
|
+
throw new TypeError(`${path} cannot contain duplicate ${mark.type} marks.`);
|
|
380
|
+
}
|
|
381
|
+
types.add(mark.type);
|
|
382
|
+
}
|
|
383
|
+
if (types.has('code') && types.size > 1) {
|
|
384
|
+
throw new TypeError(`${path} cannot combine code with another mark.`);
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
const blockNodeTypes = new Set([
|
|
388
|
+
'blockquote',
|
|
389
|
+
'bulletList',
|
|
390
|
+
'callout',
|
|
391
|
+
'checklist',
|
|
392
|
+
'codeBlock',
|
|
393
|
+
'heading',
|
|
394
|
+
'horizontalRule',
|
|
395
|
+
'orderedList',
|
|
396
|
+
'paragraph',
|
|
397
|
+
'table',
|
|
398
|
+
]);
|
|
399
|
+
const inlineNodeTypes = new Set(['hardBreak', 'text']);
|
|
400
|
+
const listItemNodeTypes = new Set(['listItem']);
|
|
401
|
+
const checklistItemNodeTypes = new Set(['checklistItem']);
|
|
402
|
+
const tableRowNodeTypes = new Set(['tableRow']);
|
|
403
|
+
const tableCellNodeTypes = new Set(['tableCell']);
|
|
404
|
+
function assertRectangularTable(rows, path) {
|
|
405
|
+
const width = rows?.[0]?.content?.length ?? 0;
|
|
406
|
+
const invalid = rows?.findIndex((row) => row.content?.length !== width) ?? -1;
|
|
407
|
+
if (width < 1 || invalid >= 0) {
|
|
408
|
+
throw new TypeError(`${path}.content must be a non-empty rectangular table.`);
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
function assertNoNodeFields(node, path, fields) {
|
|
412
|
+
const present = fields.find((field) => node[field] !== undefined);
|
|
413
|
+
if (present !== undefined) {
|
|
414
|
+
throw new TypeError(`${path}.${present} is not valid on a ${node.type} node.`);
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
function assertChildTypes(content, path, allowed) {
|
|
418
|
+
const invalidIndex = content.findIndex((child) => !allowed.has(child.type));
|
|
419
|
+
if (invalidIndex >= 0) {
|
|
420
|
+
throw new TypeError(`${path}.content[${invalidIndex}] is not valid inside this node.`);
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
function assertNonEmptyChildTypes(content, path, allowed) {
|
|
424
|
+
if (content === undefined || content.length === 0) {
|
|
425
|
+
throw new TypeError(`${path}.content must contain at least one child node.`);
|
|
426
|
+
}
|
|
427
|
+
assertChildTypes(content, path, allowed);
|
|
428
|
+
}
|
|
429
|
+
function parseAttributes(value, path, ownerType, profile, limits, state) {
|
|
430
|
+
const attributes = parseJsonObject(value, path, 1, limits, state);
|
|
431
|
+
const allowed = profile.allowedAttributes[ownerType] ?? [];
|
|
432
|
+
for (const key of Object.keys(attributes)) {
|
|
433
|
+
if (!allowed.includes(key)) {
|
|
434
|
+
throw new TypeError(`${path}.${key} is not allowed for ${ownerType}.`);
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
return attributes;
|
|
438
|
+
}
|
|
439
|
+
function parseJsonObject(value, path, depth, limits, state) {
|
|
440
|
+
if (!isRecord(value)) {
|
|
441
|
+
throw new TypeError(`${path} must be an object.`);
|
|
442
|
+
}
|
|
443
|
+
assertAttributeDepth(depth, path, limits);
|
|
444
|
+
const entries = Object.entries(value);
|
|
445
|
+
if (entries.length > limits.maximumPropertiesPerObject) {
|
|
446
|
+
throw new RangeError(`${path} exceeds the attribute property limit.`);
|
|
447
|
+
}
|
|
448
|
+
addAttributeBytes(state, limits, 2);
|
|
449
|
+
const parsed = {};
|
|
450
|
+
for (const [index, [key, entry]] of entries.entries()) {
|
|
451
|
+
assertAttributeKey(key, path, limits);
|
|
452
|
+
addAttributeBytes(state, limits, (index === 0 ? 0 : 1) + jsonByteLength(key) + 1);
|
|
453
|
+
parsed[key] = parseJsonValue(entry, `${path}.${key}`, depth + 1, limits, state);
|
|
454
|
+
}
|
|
455
|
+
return parsed;
|
|
456
|
+
}
|
|
457
|
+
function parseJsonValue(value, path, depth, limits, state) {
|
|
458
|
+
if (typeof value === 'string') {
|
|
459
|
+
if (value.length > limits.maximumStringLength) {
|
|
460
|
+
throw new RangeError(`${path} exceeds the attribute string limit.`);
|
|
461
|
+
}
|
|
462
|
+
addAttributeBytes(state, limits, jsonByteLength(value));
|
|
463
|
+
return value;
|
|
464
|
+
}
|
|
465
|
+
if (value === null || typeof value === 'boolean') {
|
|
466
|
+
addAttributeBytes(state, limits, jsonByteLength(value));
|
|
467
|
+
return value;
|
|
468
|
+
}
|
|
469
|
+
if (typeof value === 'number' && Number.isFinite(value)) {
|
|
470
|
+
addAttributeBytes(state, limits, jsonByteLength(value));
|
|
471
|
+
return value;
|
|
472
|
+
}
|
|
473
|
+
if (Array.isArray(value)) {
|
|
474
|
+
assertAttributeDepth(depth, path, limits);
|
|
475
|
+
assertJsonArray(value, path, limits);
|
|
476
|
+
addAttributeBytes(state, limits, 2);
|
|
477
|
+
return value.map((entry, index) => {
|
|
478
|
+
if (index > 0) {
|
|
479
|
+
addAttributeBytes(state, limits, 1);
|
|
480
|
+
}
|
|
481
|
+
return parseJsonValue(entry, `${path}[${index}]`, depth + 1, limits, state);
|
|
482
|
+
});
|
|
483
|
+
}
|
|
484
|
+
if (isRecord(value)) {
|
|
485
|
+
return parseJsonObject(value, path, depth, limits, state);
|
|
486
|
+
}
|
|
487
|
+
throw new TypeError(`${path} is not JSON-compatible.`);
|
|
488
|
+
}
|
|
489
|
+
function addAttributeBytes(state, limits, count) {
|
|
490
|
+
state.attributeBytes += count;
|
|
491
|
+
if (state.attributeBytes > limits.maximumTotalBytes) {
|
|
492
|
+
throw new RangeError('Rich-text attributes exceed the total-byte limit.');
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
function assertAttributeDepth(depth, path, limits) {
|
|
496
|
+
if (depth > limits.maximumDepth) {
|
|
497
|
+
throw new RangeError(`${path} exceeds the attribute depth limit.`);
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
function assertAttributeKey(key, path, limits) {
|
|
501
|
+
if (key === '__proto__' || key === 'constructor' || key === 'prototype') {
|
|
502
|
+
throw new TypeError(`${path}.${key} is a forbidden object key.`);
|
|
503
|
+
}
|
|
504
|
+
if (key.length > limits.maximumStringLength) {
|
|
505
|
+
throw new RangeError(`${path} contains an attribute key that exceeds the string limit.`);
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
function assertJsonArray(value, path, limits) {
|
|
509
|
+
if (value.length > limits.maximumItemsPerArray) {
|
|
510
|
+
throw new RangeError(`${path} exceeds the attribute item limit.`);
|
|
511
|
+
}
|
|
512
|
+
const keys = Object.keys(value);
|
|
513
|
+
for (const key of keys) {
|
|
514
|
+
if (key === '__proto__' || key === 'constructor' || key === 'prototype') {
|
|
515
|
+
throw new TypeError(`${path}.${key} is a forbidden object key.`);
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
if (keys.length !== value.length || keys.some((key, index) => key !== String(index))) {
|
|
519
|
+
throw new TypeError(`${path} must be a dense JSON array without extra properties.`);
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
function assertStructuralArray(value, path) {
|
|
523
|
+
if (Object.getPrototypeOf(value) !== Array.prototype ||
|
|
524
|
+
Object.getOwnPropertySymbols(value).length) {
|
|
525
|
+
throw new TypeError(`${path} must be a dense JSON array without extra properties.`);
|
|
526
|
+
}
|
|
527
|
+
const names = Object.getOwnPropertyNames(value);
|
|
528
|
+
if (names.length !== value.length + 1 ||
|
|
529
|
+
names[value.length] !== 'length' ||
|
|
530
|
+
names.slice(0, -1).some((name, index) => name !== String(index))) {
|
|
531
|
+
throw new TypeError(`${path} must be a dense JSON array without extra properties.`);
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
function assertKnownKeys(value, path, allowedKeys) {
|
|
535
|
+
const allowed = new Set(allowedKeys);
|
|
536
|
+
const unknown = Object.keys(value).find((key) => !allowed.has(key));
|
|
537
|
+
if (unknown !== undefined) {
|
|
538
|
+
throw new TypeError(`${path}.${unknown} is not a recognized rich-text key.`);
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
function jsonByteLength(value) {
|
|
542
|
+
const serialized = JSON.stringify(value);
|
|
543
|
+
if (serialized === undefined) {
|
|
544
|
+
throw new TypeError('Attribute value is not JSON-compatible.');
|
|
545
|
+
}
|
|
546
|
+
return utf8ByteLength(serialized);
|
|
547
|
+
}
|
|
548
|
+
function utf8ByteLength(value) {
|
|
549
|
+
let bytes = 0;
|
|
550
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
551
|
+
const code = value.charCodeAt(index);
|
|
552
|
+
if (code <= 0x7f) {
|
|
553
|
+
bytes += 1;
|
|
554
|
+
}
|
|
555
|
+
else if (code <= 0x7ff) {
|
|
556
|
+
bytes += 2;
|
|
557
|
+
}
|
|
558
|
+
else if (code >= 0xd800 && code <= 0xdbff) {
|
|
559
|
+
const next = value.charCodeAt(index + 1);
|
|
560
|
+
if (next >= 0xdc00 && next <= 0xdfff) {
|
|
561
|
+
bytes += 4;
|
|
562
|
+
index += 1;
|
|
563
|
+
}
|
|
564
|
+
else {
|
|
565
|
+
bytes += 3;
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
else {
|
|
569
|
+
bytes += 3;
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
return bytes;
|
|
573
|
+
}
|
|
574
|
+
function nodeText(node) {
|
|
575
|
+
return `${node.text ?? ''}${(node.content ?? []).map((child) => nodeText(child)).join('')}`;
|
|
576
|
+
}
|
|
577
|
+
function isRecord(value) {
|
|
578
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
|
579
|
+
return false;
|
|
580
|
+
}
|
|
581
|
+
const prototype = Object.getPrototypeOf(value);
|
|
582
|
+
return prototype === Object.prototype || prototype === null;
|
|
583
|
+
}
|
|
584
|
+
function validateProfile(profile) {
|
|
585
|
+
for (const [name, value] of [
|
|
586
|
+
['maximumDepth', profile.maximumDepth],
|
|
587
|
+
['maximumNodes', profile.maximumNodes],
|
|
588
|
+
['maximumTextLength', profile.maximumTextLength],
|
|
589
|
+
]) {
|
|
590
|
+
assertBoundedProfileLimit(name, value, RICH_TEXT_HARD_LIMITS[name]);
|
|
591
|
+
}
|
|
592
|
+
for (const [name, value] of [
|
|
593
|
+
['maximumDocumentBytes', maximumDocumentBytes(profile)],
|
|
594
|
+
['maximumMarks', profile.maximumMarks ?? DEFAULT_MAXIMUM_MARKS],
|
|
595
|
+
['maximumMarksPerNode', profile.maximumMarksPerNode ?? DEFAULT_MAXIMUM_MARKS_PER_NODE],
|
|
596
|
+
]) {
|
|
597
|
+
assertBoundedProfileLimit(name, value, RICH_TEXT_HARD_LIMITS[name]);
|
|
598
|
+
}
|
|
599
|
+
if ((profile.maximumMarksPerNode ?? DEFAULT_MAXIMUM_MARKS_PER_NODE) >
|
|
600
|
+
(profile.maximumMarks ?? DEFAULT_MAXIMUM_MARKS)) {
|
|
601
|
+
throw new RangeError('maximumMarksPerNode cannot exceed maximumMarks.');
|
|
602
|
+
}
|
|
603
|
+
if (!profile.allowedNodes.includes('doc') || !profile.allowedNodes.includes('text')) {
|
|
604
|
+
throw new TypeError('Rich-text profile must allow doc and text nodes.');
|
|
605
|
+
}
|
|
606
|
+
if (new Set(profile.allowedNodes).size !== profile.allowedNodes.length) {
|
|
607
|
+
throw new TypeError('Rich-text profile node names must be unique.');
|
|
608
|
+
}
|
|
609
|
+
if (new Set(profile.allowedMarks).size !== profile.allowedMarks.length) {
|
|
610
|
+
throw new TypeError('Rich-text profile mark names must be unique.');
|
|
611
|
+
}
|
|
612
|
+
const unsupportedNode = profile.allowedNodes.find((name) => !PORTABLE_NODES.includes(name));
|
|
613
|
+
if (unsupportedNode !== undefined) {
|
|
614
|
+
throw new TypeError(`Rich-text profile node "${unsupportedNode}" has no portable grammar.`);
|
|
615
|
+
}
|
|
616
|
+
const unsupportedMark = profile.allowedMarks.find((name) => !PORTABLE_MARKS.includes(name));
|
|
617
|
+
if (unsupportedMark !== undefined) {
|
|
618
|
+
throw new TypeError(`Rich-text profile mark "${unsupportedMark}" has no portable grammar.`);
|
|
619
|
+
}
|
|
620
|
+
validateHeadingLevels(profile.headingLevels ?? DEFAULT_HEADING_LEVELS);
|
|
621
|
+
resolveAttributeLimits(profile);
|
|
622
|
+
}
|
|
623
|
+
function assertBoundedProfileLimit(name, value, hardMaximum) {
|
|
624
|
+
if (!Number.isInteger(value) || value < 1) {
|
|
625
|
+
throw new RangeError(`${name} must be a positive integer.`);
|
|
626
|
+
}
|
|
627
|
+
if (value > hardMaximum) {
|
|
628
|
+
throw new RangeError(`${name} exceeds the immutable safety ceiling of ${hardMaximum}.`);
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
function validateHeadingLevels(levels) {
|
|
632
|
+
if (levels.length === 0 ||
|
|
633
|
+
new Set(levels).size !== levels.length ||
|
|
634
|
+
levels.some((level) => !Number.isInteger(level) || level < 1 || level > 6)) {
|
|
635
|
+
throw new RangeError('headingLevels must contain unique integer levels from 1 through 6.');
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
function maximumDocumentBytes(profile) {
|
|
639
|
+
return profile.maximumDocumentBytes ?? DEFAULT_MAXIMUM_DOCUMENT_BYTES;
|
|
640
|
+
}
|
|
641
|
+
function resolveAttributeLimits(profile) {
|
|
642
|
+
const limits = { ...DEFAULT_RICH_TEXT_ATTRIBUTE_LIMITS, ...profile.attributeLimits };
|
|
643
|
+
for (const [name, value] of Object.entries(limits)) {
|
|
644
|
+
assertBoundedProfileLimit(name, value, ATTRIBUTE_HARD_LIMITS[name]);
|
|
645
|
+
}
|
|
646
|
+
return limits;
|
|
647
|
+
}
|
|
648
|
+
//# sourceMappingURL=index.js.map
|