@topik/content 0.0.0-stage → 0.1.0-alpha.6
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 +267 -2
- package/dist/index.d.mts +783 -0
- package/dist/index.mjs +4529 -0
- package/package.json +62 -3
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,4529 @@
|
|
|
1
|
+
import { decodeString } from "micromark-util-decode-string";
|
|
2
|
+
import { fromMarkdown } from "mdast-util-from-markdown";
|
|
3
|
+
import { frontmatter } from "micromark-extension-frontmatter";
|
|
4
|
+
import { frontmatterFromMarkdown, frontmatterToMarkdown } from "mdast-util-frontmatter";
|
|
5
|
+
import { TagSyntaxError, tagFromMarkdown, tagSyntax, tagToMarkdown, writeTextTemplate } from "@topik/remark-tags";
|
|
6
|
+
import { gfmFootnote } from "micromark-extension-gfm-footnote";
|
|
7
|
+
import { gfmStrikethrough } from "micromark-extension-gfm-strikethrough";
|
|
8
|
+
import { gfmTable } from "micromark-extension-gfm-table";
|
|
9
|
+
import { gfmTaskListItem } from "micromark-extension-gfm-task-list-item";
|
|
10
|
+
import { gfmFootnoteFromMarkdown, gfmFootnoteToMarkdown } from "mdast-util-gfm-footnote";
|
|
11
|
+
import { gfmStrikethroughFromMarkdown, gfmStrikethroughToMarkdown } from "mdast-util-gfm-strikethrough";
|
|
12
|
+
import { gfmTableFromMarkdown, gfmTableToMarkdown } from "mdast-util-gfm-table";
|
|
13
|
+
import { gfmTaskListItemFromMarkdown, gfmTaskListItemToMarkdown } from "mdast-util-gfm-task-list-item";
|
|
14
|
+
import { defaultHandlers, toMarkdown } from "mdast-util-to-markdown";
|
|
15
|
+
import GithubSlugger from "github-slugger";
|
|
16
|
+
//#region src/extensions/accordion.ts
|
|
17
|
+
const accordionDefinition = {
|
|
18
|
+
kind: "block",
|
|
19
|
+
render: "TopikAccordion",
|
|
20
|
+
description: "A single disclosure section with a required title.",
|
|
21
|
+
attributes: {
|
|
22
|
+
title: {
|
|
23
|
+
type: "string",
|
|
24
|
+
required: true,
|
|
25
|
+
description: "Accordion title."
|
|
26
|
+
},
|
|
27
|
+
open: {
|
|
28
|
+
type: "boolean",
|
|
29
|
+
description: "Whether the accordion is expanded by default."
|
|
30
|
+
}
|
|
31
|
+
},
|
|
32
|
+
children: "blocks"
|
|
33
|
+
};
|
|
34
|
+
//#endregion
|
|
35
|
+
//#region src/extensions/badge.ts
|
|
36
|
+
const badgeDefinition = {
|
|
37
|
+
kind: "inline",
|
|
38
|
+
render: "TopikBadge",
|
|
39
|
+
description: "Small inline status or metadata label.",
|
|
40
|
+
attributes: { variant: {
|
|
41
|
+
type: "enum",
|
|
42
|
+
values: [
|
|
43
|
+
"neutral",
|
|
44
|
+
"info",
|
|
45
|
+
"success",
|
|
46
|
+
"warning",
|
|
47
|
+
"danger"
|
|
48
|
+
],
|
|
49
|
+
default: "neutral",
|
|
50
|
+
description: "Badge color/semantic variant."
|
|
51
|
+
} },
|
|
52
|
+
children: "inline"
|
|
53
|
+
};
|
|
54
|
+
//#endregion
|
|
55
|
+
//#region src/extensions/callout.ts
|
|
56
|
+
const calloutDefinition = {
|
|
57
|
+
kind: "block",
|
|
58
|
+
render: "TopikCallout",
|
|
59
|
+
description: "Highlighted contextual content such as a note, tip, warning, or danger message.",
|
|
60
|
+
attributes: {
|
|
61
|
+
variant: {
|
|
62
|
+
type: "enum",
|
|
63
|
+
values: [
|
|
64
|
+
"info",
|
|
65
|
+
"tip",
|
|
66
|
+
"warning",
|
|
67
|
+
"danger"
|
|
68
|
+
],
|
|
69
|
+
default: "info"
|
|
70
|
+
},
|
|
71
|
+
title: { type: "string" }
|
|
72
|
+
},
|
|
73
|
+
children: "blocks"
|
|
74
|
+
};
|
|
75
|
+
//#endregion
|
|
76
|
+
//#region src/extensions/cards.ts
|
|
77
|
+
const cardGridDefinition = {
|
|
78
|
+
kind: "block",
|
|
79
|
+
render: "TopikCardGrid",
|
|
80
|
+
description: "Responsive grid of card links or summary cards.",
|
|
81
|
+
attributes: { columns: {
|
|
82
|
+
type: "number",
|
|
83
|
+
min: 1,
|
|
84
|
+
max: 4,
|
|
85
|
+
integer: true,
|
|
86
|
+
description: "Preferred number of columns, from 1 to 4."
|
|
87
|
+
} },
|
|
88
|
+
children: { components: ["card"] }
|
|
89
|
+
};
|
|
90
|
+
const cardDefinition = {
|
|
91
|
+
kind: "block",
|
|
92
|
+
render: "TopikCard",
|
|
93
|
+
description: "A card within a card grid.",
|
|
94
|
+
attributes: {
|
|
95
|
+
title: {
|
|
96
|
+
type: "string",
|
|
97
|
+
required: true,
|
|
98
|
+
description: "Card title."
|
|
99
|
+
},
|
|
100
|
+
href: {
|
|
101
|
+
type: "string",
|
|
102
|
+
description: "Optional target URL."
|
|
103
|
+
},
|
|
104
|
+
icon: {
|
|
105
|
+
type: "string",
|
|
106
|
+
description: "Optional icon identifier."
|
|
107
|
+
}
|
|
108
|
+
},
|
|
109
|
+
children: "blocks",
|
|
110
|
+
selfClosing: true
|
|
111
|
+
};
|
|
112
|
+
//#endregion
|
|
113
|
+
//#region src/extensions/code.ts
|
|
114
|
+
const codeGroupDefinition = {
|
|
115
|
+
kind: "block",
|
|
116
|
+
render: "TopikCodeGroup",
|
|
117
|
+
description: "A set of labeled code tabs.",
|
|
118
|
+
attributes: {},
|
|
119
|
+
children: {
|
|
120
|
+
components: ["codeTab"],
|
|
121
|
+
min: 1
|
|
122
|
+
}
|
|
123
|
+
};
|
|
124
|
+
const codeTabDefinition = {
|
|
125
|
+
kind: "block",
|
|
126
|
+
render: "TopikCodeTab",
|
|
127
|
+
description: "A labeled code example inside a code group.",
|
|
128
|
+
attributes: {
|
|
129
|
+
title: {
|
|
130
|
+
type: "string",
|
|
131
|
+
required: true,
|
|
132
|
+
description: "Visible code tab label."
|
|
133
|
+
},
|
|
134
|
+
icon: {
|
|
135
|
+
type: "string",
|
|
136
|
+
description: "Optional icon identifier."
|
|
137
|
+
}
|
|
138
|
+
},
|
|
139
|
+
children: {
|
|
140
|
+
nodes: [
|
|
141
|
+
"code",
|
|
142
|
+
"topikCodeTemplate",
|
|
143
|
+
"topikCodePresentation"
|
|
144
|
+
],
|
|
145
|
+
min: 1
|
|
146
|
+
},
|
|
147
|
+
allowedParents: ["codeGroup"]
|
|
148
|
+
};
|
|
149
|
+
//#endregion
|
|
150
|
+
//#region src/extensions/figure.ts
|
|
151
|
+
const figureDefinition = {
|
|
152
|
+
kind: "block",
|
|
153
|
+
render: "TopikFigure",
|
|
154
|
+
description: "Media with an optional caption.",
|
|
155
|
+
attributes: {
|
|
156
|
+
src: {
|
|
157
|
+
type: "string",
|
|
158
|
+
required: true,
|
|
159
|
+
asset: true,
|
|
160
|
+
assetReference: {
|
|
161
|
+
slot: "figure.src",
|
|
162
|
+
role: "image-light"
|
|
163
|
+
},
|
|
164
|
+
description: "Default/light image source URL."
|
|
165
|
+
},
|
|
166
|
+
darkSrc: {
|
|
167
|
+
type: "string",
|
|
168
|
+
asset: true,
|
|
169
|
+
assetReference: {
|
|
170
|
+
slot: "figure.darkSrc",
|
|
171
|
+
role: "image-dark"
|
|
172
|
+
},
|
|
173
|
+
description: "Optional dark-mode image source URL."
|
|
174
|
+
},
|
|
175
|
+
alt: {
|
|
176
|
+
type: "string",
|
|
177
|
+
required: true,
|
|
178
|
+
description: "Accessible alternative text."
|
|
179
|
+
},
|
|
180
|
+
caption: {
|
|
181
|
+
type: "string",
|
|
182
|
+
description: "Optional figure caption."
|
|
183
|
+
}
|
|
184
|
+
},
|
|
185
|
+
children: "none"
|
|
186
|
+
};
|
|
187
|
+
//#endregion
|
|
188
|
+
//#region src/extensions/math.ts
|
|
189
|
+
const mathDefinition = {
|
|
190
|
+
kind: "block",
|
|
191
|
+
render: "TopikMath",
|
|
192
|
+
description: "Block math expression.",
|
|
193
|
+
attributes: { content: {
|
|
194
|
+
type: "string",
|
|
195
|
+
required: true,
|
|
196
|
+
escapedWhitespace: true,
|
|
197
|
+
description: "Math source."
|
|
198
|
+
} },
|
|
199
|
+
children: "none"
|
|
200
|
+
};
|
|
201
|
+
const mathInlineDefinition = {
|
|
202
|
+
kind: "inline",
|
|
203
|
+
render: "TopikMathInline",
|
|
204
|
+
description: "Inline math expression.",
|
|
205
|
+
attributes: { content: {
|
|
206
|
+
type: "string",
|
|
207
|
+
required: true,
|
|
208
|
+
escapedWhitespace: true,
|
|
209
|
+
description: "Math source."
|
|
210
|
+
} },
|
|
211
|
+
children: "none"
|
|
212
|
+
};
|
|
213
|
+
//#endregion
|
|
214
|
+
//#region src/extensions/native.ts
|
|
215
|
+
const nativeComponents = {
|
|
216
|
+
codeBlock: {
|
|
217
|
+
name: "codeBlock",
|
|
218
|
+
render: "TopikCodeBlock",
|
|
219
|
+
kind: "block",
|
|
220
|
+
description: "A fenced code block with an optional language.",
|
|
221
|
+
attributes: { language: {
|
|
222
|
+
type: "string",
|
|
223
|
+
description: "Code language identifier."
|
|
224
|
+
} }
|
|
225
|
+
},
|
|
226
|
+
inlineCode: {
|
|
227
|
+
name: "inlineCode",
|
|
228
|
+
render: "TopikInlineCode",
|
|
229
|
+
kind: "inline",
|
|
230
|
+
description: "Inline code text."
|
|
231
|
+
},
|
|
232
|
+
image: {
|
|
233
|
+
name: "image",
|
|
234
|
+
render: "TopikImage",
|
|
235
|
+
kind: "block",
|
|
236
|
+
description: "Markdown image rendered through Topik asset resolution.",
|
|
237
|
+
attributes: {
|
|
238
|
+
src: {
|
|
239
|
+
type: "string",
|
|
240
|
+
required: true,
|
|
241
|
+
description: "Image source URL.",
|
|
242
|
+
assetReference: {
|
|
243
|
+
slot: "image.src",
|
|
244
|
+
role: "image"
|
|
245
|
+
}
|
|
246
|
+
},
|
|
247
|
+
alt: {
|
|
248
|
+
type: "string",
|
|
249
|
+
description: "Accessible alternative text."
|
|
250
|
+
},
|
|
251
|
+
title: {
|
|
252
|
+
type: "string",
|
|
253
|
+
description: "Optional image title."
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
},
|
|
257
|
+
link: {
|
|
258
|
+
name: "link",
|
|
259
|
+
render: "TopikLink",
|
|
260
|
+
kind: "inline",
|
|
261
|
+
description: "Markdown link with optional application-level navigation interception.",
|
|
262
|
+
attributes: {
|
|
263
|
+
href: {
|
|
264
|
+
type: "string",
|
|
265
|
+
required: true,
|
|
266
|
+
description: "Link target URL.",
|
|
267
|
+
assetReference: {
|
|
268
|
+
slot: "link.href",
|
|
269
|
+
role: "download",
|
|
270
|
+
conditional: "proven-download"
|
|
271
|
+
}
|
|
272
|
+
},
|
|
273
|
+
title: {
|
|
274
|
+
type: "string",
|
|
275
|
+
description: "Optional link title."
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
},
|
|
279
|
+
mermaid: {
|
|
280
|
+
name: "mermaid",
|
|
281
|
+
render: "TopikMermaid",
|
|
282
|
+
kind: "block",
|
|
283
|
+
description: "Mermaid diagram rendered from a fenced mermaid code block.",
|
|
284
|
+
attributes: { content: {
|
|
285
|
+
type: "string",
|
|
286
|
+
required: true,
|
|
287
|
+
description: "Mermaid diagram source."
|
|
288
|
+
} }
|
|
289
|
+
},
|
|
290
|
+
table: {
|
|
291
|
+
name: "table",
|
|
292
|
+
render: "TopikTable",
|
|
293
|
+
kind: "block",
|
|
294
|
+
description: "Markdown table."
|
|
295
|
+
},
|
|
296
|
+
tableRow: {
|
|
297
|
+
name: "tableRow",
|
|
298
|
+
render: "TopikTableRow",
|
|
299
|
+
kind: "block",
|
|
300
|
+
description: "A row inside a Markdown table."
|
|
301
|
+
},
|
|
302
|
+
tableCell: {
|
|
303
|
+
name: "tableCell",
|
|
304
|
+
render: "TopikTableCell",
|
|
305
|
+
kind: "block",
|
|
306
|
+
description: "A cell inside a Markdown table."
|
|
307
|
+
},
|
|
308
|
+
tableHeader: {
|
|
309
|
+
name: "tableHeader",
|
|
310
|
+
render: "TopikTableHeader",
|
|
311
|
+
kind: "block",
|
|
312
|
+
description: "A header cell inside a Markdown table."
|
|
313
|
+
}
|
|
314
|
+
};
|
|
315
|
+
//#endregion
|
|
316
|
+
//#region src/extensions/quiz.ts
|
|
317
|
+
const quizDefinition = {
|
|
318
|
+
kind: "block",
|
|
319
|
+
render: "TopikQuiz",
|
|
320
|
+
description: "Self-check quiz made of question components.",
|
|
321
|
+
attributes: {},
|
|
322
|
+
children: {
|
|
323
|
+
components: ["question"],
|
|
324
|
+
min: 1
|
|
325
|
+
}
|
|
326
|
+
};
|
|
327
|
+
function validateQuestion(component, props) {
|
|
328
|
+
const choices = component.children.filter((child) => child.type === "topikComponent" && child.name === "choice");
|
|
329
|
+
const issues = [];
|
|
330
|
+
if (choices.length < 2) issues.push({
|
|
331
|
+
id: "topik-question-choice-count",
|
|
332
|
+
message: "question requires at least two choice children"
|
|
333
|
+
});
|
|
334
|
+
const correct = choices.filter((choice) => choice.props.correct === true).length;
|
|
335
|
+
if (props.type === "multiple-choice" && correct === 0) issues.push({
|
|
336
|
+
id: "topik-question-correct-choice-required",
|
|
337
|
+
message: "multiple-choice questions require at least one correct choice"
|
|
338
|
+
});
|
|
339
|
+
else if (props.type !== "multiple-choice" && correct !== 1) issues.push({
|
|
340
|
+
id: "topik-question-single-correct-choice",
|
|
341
|
+
message: "single-choice questions require exactly one correct choice"
|
|
342
|
+
});
|
|
343
|
+
return issues;
|
|
344
|
+
}
|
|
345
|
+
const questionDefinition = {
|
|
346
|
+
kind: "block",
|
|
347
|
+
render: "TopikQuestion",
|
|
348
|
+
description: "Quiz question containing at least two choices.",
|
|
349
|
+
attributes: { type: {
|
|
350
|
+
type: "enum",
|
|
351
|
+
values: ["single-choice", "multiple-choice"],
|
|
352
|
+
default: "single-choice",
|
|
353
|
+
description: "Question interaction model."
|
|
354
|
+
} },
|
|
355
|
+
children: { components: ["choice", "explanation"] },
|
|
356
|
+
allowedParents: ["quiz"],
|
|
357
|
+
validate: validateQuestion
|
|
358
|
+
};
|
|
359
|
+
const choiceDefinition = {
|
|
360
|
+
kind: "block",
|
|
361
|
+
render: "TopikChoice",
|
|
362
|
+
description: "Answer choice inside a quiz question.",
|
|
363
|
+
attributes: { correct: {
|
|
364
|
+
type: "boolean",
|
|
365
|
+
default: false,
|
|
366
|
+
description: "Whether this choice is correct."
|
|
367
|
+
} },
|
|
368
|
+
children: "blocks",
|
|
369
|
+
allowedParents: ["question"]
|
|
370
|
+
};
|
|
371
|
+
const explanationDefinition = {
|
|
372
|
+
kind: "block",
|
|
373
|
+
render: "TopikExplanation",
|
|
374
|
+
description: "Explanation shown after answering a quiz question.",
|
|
375
|
+
attributes: {},
|
|
376
|
+
children: "blocks",
|
|
377
|
+
allowedParents: ["question"]
|
|
378
|
+
};
|
|
379
|
+
//#endregion
|
|
380
|
+
//#region src/extensions/steps.ts
|
|
381
|
+
const stepsDefinition = {
|
|
382
|
+
kind: "block",
|
|
383
|
+
render: "TopikSteps",
|
|
384
|
+
description: "Ordered instructional steps.",
|
|
385
|
+
attributes: {},
|
|
386
|
+
children: {
|
|
387
|
+
components: ["step"],
|
|
388
|
+
min: 1
|
|
389
|
+
}
|
|
390
|
+
};
|
|
391
|
+
const stepDefinition = {
|
|
392
|
+
kind: "block",
|
|
393
|
+
render: "TopikStep",
|
|
394
|
+
description: "One step inside a steps component.",
|
|
395
|
+
attributes: { title: {
|
|
396
|
+
type: "string",
|
|
397
|
+
description: "Optional step heading."
|
|
398
|
+
} },
|
|
399
|
+
children: "blocks",
|
|
400
|
+
allowedParents: ["steps"]
|
|
401
|
+
};
|
|
402
|
+
//#endregion
|
|
403
|
+
//#region src/extensions/tabs.ts
|
|
404
|
+
const tabsDefinition = {
|
|
405
|
+
kind: "block",
|
|
406
|
+
render: "TopikTabs",
|
|
407
|
+
description: "A tab set containing one or more tab panels.",
|
|
408
|
+
attributes: {},
|
|
409
|
+
children: {
|
|
410
|
+
components: ["tab"],
|
|
411
|
+
min: 1
|
|
412
|
+
}
|
|
413
|
+
};
|
|
414
|
+
const tabDefinition = {
|
|
415
|
+
kind: "block",
|
|
416
|
+
render: "TopikTab",
|
|
417
|
+
description: "A tab panel inside a tabs component.",
|
|
418
|
+
attributes: { title: {
|
|
419
|
+
type: "string",
|
|
420
|
+
required: true,
|
|
421
|
+
description: "Visible tab label."
|
|
422
|
+
} },
|
|
423
|
+
children: "blocks",
|
|
424
|
+
allowedParents: ["tabs"]
|
|
425
|
+
};
|
|
426
|
+
//#endregion
|
|
427
|
+
//#region src/extensions/underline.ts
|
|
428
|
+
const underlineDefinition = {
|
|
429
|
+
kind: "inline",
|
|
430
|
+
render: "TopikUnderline",
|
|
431
|
+
description: "Inline underlined text.",
|
|
432
|
+
attributes: {},
|
|
433
|
+
children: "inline"
|
|
434
|
+
};
|
|
435
|
+
//#endregion
|
|
436
|
+
//#region src/registry.ts
|
|
437
|
+
const TOPIK_CONTENT_SCHEMA_VERSION = "0.2.2";
|
|
438
|
+
const CALLOUT_VARIANTS = [
|
|
439
|
+
"info",
|
|
440
|
+
"tip",
|
|
441
|
+
"warning",
|
|
442
|
+
"danger"
|
|
443
|
+
];
|
|
444
|
+
const BADGE_VARIANTS = [
|
|
445
|
+
"neutral",
|
|
446
|
+
"info",
|
|
447
|
+
"success",
|
|
448
|
+
"warning",
|
|
449
|
+
"danger"
|
|
450
|
+
];
|
|
451
|
+
const QUIZ_QUESTION_TYPES = ["single-choice", "multiple-choice"];
|
|
452
|
+
function deepFreeze(value) {
|
|
453
|
+
if (value !== null && typeof value === "object" && !Object.isFrozen(value)) {
|
|
454
|
+
for (const child of Object.values(value)) deepFreeze(child);
|
|
455
|
+
Object.freeze(value);
|
|
456
|
+
}
|
|
457
|
+
return value;
|
|
458
|
+
}
|
|
459
|
+
function textAttributes(definition, names) {
|
|
460
|
+
const attributes = { ...definition.attributes };
|
|
461
|
+
for (const name of names) {
|
|
462
|
+
const attribute = attributes[name];
|
|
463
|
+
if (attribute.type !== "string" || attribute.asset || attribute.assetReference) throw new Error("Template slots must be plain string attributes");
|
|
464
|
+
attributes[name] = {
|
|
465
|
+
...attribute,
|
|
466
|
+
interpolation: "text"
|
|
467
|
+
};
|
|
468
|
+
}
|
|
469
|
+
return {
|
|
470
|
+
...definition,
|
|
471
|
+
attributes
|
|
472
|
+
};
|
|
473
|
+
}
|
|
474
|
+
const authoredComponents = {
|
|
475
|
+
accordion: textAttributes(accordionDefinition, ["title"]),
|
|
476
|
+
badge: badgeDefinition,
|
|
477
|
+
callout: textAttributes(calloutDefinition, ["title"]),
|
|
478
|
+
card: textAttributes(cardDefinition, ["title"]),
|
|
479
|
+
cardGrid: cardGridDefinition,
|
|
480
|
+
codeGroup: codeGroupDefinition,
|
|
481
|
+
codeTab: {
|
|
482
|
+
...textAttributes(codeTabDefinition, ["title"]),
|
|
483
|
+
children: {
|
|
484
|
+
nodes: [
|
|
485
|
+
"code",
|
|
486
|
+
"topikCodeTemplate",
|
|
487
|
+
"topikCodePresentation"
|
|
488
|
+
],
|
|
489
|
+
min: 1
|
|
490
|
+
}
|
|
491
|
+
},
|
|
492
|
+
choice: choiceDefinition,
|
|
493
|
+
explanation: explanationDefinition,
|
|
494
|
+
figure: textAttributes(figureDefinition, ["alt", "caption"]),
|
|
495
|
+
math: mathDefinition,
|
|
496
|
+
mathInline: mathInlineDefinition,
|
|
497
|
+
question: questionDefinition,
|
|
498
|
+
quiz: quizDefinition,
|
|
499
|
+
step: textAttributes(stepDefinition, ["title"]),
|
|
500
|
+
steps: stepsDefinition,
|
|
501
|
+
tab: textAttributes(tabDefinition, ["title"]),
|
|
502
|
+
tabs: tabsDefinition,
|
|
503
|
+
underline: underlineDefinition
|
|
504
|
+
};
|
|
505
|
+
/** Canonical schemas are immutable; callers add custom names in a separate registry. */
|
|
506
|
+
const components = deepFreeze({
|
|
507
|
+
...authoredComponents,
|
|
508
|
+
u: underlineDefinition
|
|
509
|
+
});
|
|
510
|
+
function metadata(name, definition) {
|
|
511
|
+
const constraint = definition.children;
|
|
512
|
+
const allowedChildren = typeof constraint === "object" ? "components" in constraint ? constraint.components : constraint.nodes.map((node) => node === "code" ? "fence" : node) : void 0;
|
|
513
|
+
const requiredChildren = name === "question" ? ["choice"] : typeof constraint === "object" && constraint.min ? allowedChildren?.slice(0, 1) : void 0;
|
|
514
|
+
return {
|
|
515
|
+
name,
|
|
516
|
+
kind: definition.kind,
|
|
517
|
+
render: String(definition.render),
|
|
518
|
+
description: definition.description ?? "",
|
|
519
|
+
...Object.keys(definition.attributes).length ? { attributes: definition.attributes } : {},
|
|
520
|
+
...allowedChildren ? { allowedChildren } : {},
|
|
521
|
+
...requiredChildren ? { requiredChildren } : {}
|
|
522
|
+
};
|
|
523
|
+
}
|
|
524
|
+
/** Legacy catalog metadata, derived from authored definitions plus native mdast nodes. */
|
|
525
|
+
const topikComponents = deepFreeze({
|
|
526
|
+
...Object.fromEntries(Object.entries(authoredComponents).map(([name, definition]) => [name, metadata(name, definition)])),
|
|
527
|
+
...nativeComponents
|
|
528
|
+
});
|
|
529
|
+
/** Defaults are for reading/rendering; authored absence stays unchanged. */
|
|
530
|
+
function getEffectiveProps(component, registry = components) {
|
|
531
|
+
const definition = Object.hasOwn(registry, component.name) ? registry[component.name] : void 0;
|
|
532
|
+
if (!definition) throw new Error(`Unknown component ${component.name}`);
|
|
533
|
+
const defaults = {};
|
|
534
|
+
for (const [name, attribute] of Object.entries(definition.attributes)) if (attribute.default !== void 0) defaults[name] = attribute.default;
|
|
535
|
+
return {
|
|
536
|
+
...defaults,
|
|
537
|
+
...component.props
|
|
538
|
+
};
|
|
539
|
+
}
|
|
540
|
+
//#endregion
|
|
541
|
+
//#region src/limits.ts
|
|
542
|
+
/** Fixed admission limits, measured in UTF-16 code units and tree nodes. */
|
|
543
|
+
const CONTENT_LIMITS = Object.freeze({
|
|
544
|
+
sourceLength: 1e6,
|
|
545
|
+
/** Inline delimiter attempts; code-span attempts are weighted by delimiter run length. */
|
|
546
|
+
parserSteps: 4096,
|
|
547
|
+
treeDepth: 64,
|
|
548
|
+
treeNodes: 5e4,
|
|
549
|
+
dataDepth: 160,
|
|
550
|
+
dataEntries: 5e5,
|
|
551
|
+
dataStringLength: 1e6,
|
|
552
|
+
expressionLength: 16384,
|
|
553
|
+
expressionDepth: 32,
|
|
554
|
+
expressionNodes: 256,
|
|
555
|
+
/** Bounded escaped payload and row decorations, before reader allocation. */
|
|
556
|
+
presentationOutputLength: 8e6
|
|
557
|
+
});
|
|
558
|
+
var ContentLimitError = class extends Error {
|
|
559
|
+
id = "topik-content-limit";
|
|
560
|
+
constructor(message) {
|
|
561
|
+
super(message);
|
|
562
|
+
this.name = "ContentLimitError";
|
|
563
|
+
}
|
|
564
|
+
};
|
|
565
|
+
var ContentDataError = class extends Error {
|
|
566
|
+
id = "topik-node-invalid";
|
|
567
|
+
};
|
|
568
|
+
function limitDiagnostic(error) {
|
|
569
|
+
return {
|
|
570
|
+
id: error.id,
|
|
571
|
+
message: error.message
|
|
572
|
+
};
|
|
573
|
+
}
|
|
574
|
+
function assertSourceLimit(source) {
|
|
575
|
+
if (source.length > CONTENT_LIMITS.sourceLength) throw new ContentLimitError(`Content exceeds the source length limit of ${CONTENT_LIMITS.sourceLength}`);
|
|
576
|
+
}
|
|
577
|
+
/** Check before recursive transforms and return the number of admitted nodes. */
|
|
578
|
+
function assertTreeLimits(root) {
|
|
579
|
+
const active = /* @__PURE__ */ new Set();
|
|
580
|
+
const seen = /* @__PURE__ */ new Set();
|
|
581
|
+
const stack = [{
|
|
582
|
+
node: root,
|
|
583
|
+
depth: 0,
|
|
584
|
+
exit: false
|
|
585
|
+
}];
|
|
586
|
+
let count = 0;
|
|
587
|
+
while (stack.length) {
|
|
588
|
+
const { node, depth, exit } = stack.pop();
|
|
589
|
+
if (exit) {
|
|
590
|
+
active.delete(node);
|
|
591
|
+
continue;
|
|
592
|
+
}
|
|
593
|
+
if (!node || typeof node !== "object") continue;
|
|
594
|
+
if (active.has(node)) throw new ContentLimitError("Content tree contains a cycle");
|
|
595
|
+
if (seen.has(node)) throw new ContentDataError("Content nodes must not be shared between tree positions");
|
|
596
|
+
seen.add(node);
|
|
597
|
+
if (depth > CONTENT_LIMITS.treeDepth) throw new ContentLimitError(`Content exceeds the tree depth limit of ${CONTENT_LIMITS.treeDepth}`);
|
|
598
|
+
if (++count > CONTENT_LIMITS.treeNodes) throw new ContentLimitError(`Content exceeds the tree node limit of ${CONTENT_LIMITS.treeNodes}`);
|
|
599
|
+
const templates = (node.type === "topikCodeTemplate" || node.type === "tagCodeTemplate") && node.template ? [node.template] : [];
|
|
600
|
+
const properties = node.type === "topikComponent" ? node.props : [
|
|
601
|
+
"tagText",
|
|
602
|
+
"tagLeaf",
|
|
603
|
+
"tagContainer"
|
|
604
|
+
].includes(node.type) ? node.attributes : void 0;
|
|
605
|
+
for (const value of Object.values(properties ?? {})) if (value && typeof value === "object" && value.type === "topikTextTemplate") templates.push(value);
|
|
606
|
+
for (const template of templates) count += 1 + (Array.isArray(template.segments) ? template.segments.length : 0);
|
|
607
|
+
if (count > CONTENT_LIMITS.treeNodes) throw new ContentLimitError(`Content exceeds the tree node limit of ${CONTENT_LIMITS.treeNodes}`);
|
|
608
|
+
active.add(node);
|
|
609
|
+
stack.push({
|
|
610
|
+
node,
|
|
611
|
+
depth,
|
|
612
|
+
exit: true
|
|
613
|
+
});
|
|
614
|
+
if (Array.isArray(node.children)) {
|
|
615
|
+
if (node.children.length > CONTENT_LIMITS.treeNodes) throw new ContentLimitError(`Content exceeds the tree node limit of ${CONTENT_LIMITS.treeNodes}`);
|
|
616
|
+
for (let index = node.children.length - 1; index >= 0; index--) stack.push({
|
|
617
|
+
node: node.children[index],
|
|
618
|
+
depth: depth + 1,
|
|
619
|
+
exit: false
|
|
620
|
+
});
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
return count;
|
|
624
|
+
}
|
|
625
|
+
/** Include metadata and expressions so cloning cannot bypass the authoring-tree limits. */
|
|
626
|
+
function assertDataLimits(root, options = {}) {
|
|
627
|
+
const active = /* @__PURE__ */ new Set();
|
|
628
|
+
const stack = [{
|
|
629
|
+
value: root,
|
|
630
|
+
depth: 0,
|
|
631
|
+
exit: false
|
|
632
|
+
}];
|
|
633
|
+
let entries = 0;
|
|
634
|
+
let strings = 0;
|
|
635
|
+
while (stack.length) {
|
|
636
|
+
const { value, depth, exit, stringLength } = stack.pop();
|
|
637
|
+
if (exit) {
|
|
638
|
+
active.delete(value);
|
|
639
|
+
continue;
|
|
640
|
+
}
|
|
641
|
+
if (++entries > CONTENT_LIMITS.dataEntries) throw new ContentLimitError(`Content exceeds the data entry limit of ${CONTENT_LIMITS.dataEntries}`);
|
|
642
|
+
if (typeof value === "string") {
|
|
643
|
+
strings += stringLength ?? value.length;
|
|
644
|
+
if (strings > CONTENT_LIMITS.dataStringLength) throw new ContentLimitError(`Content exceeds the data string limit of ${CONTENT_LIMITS.dataStringLength}`);
|
|
645
|
+
}
|
|
646
|
+
if (options.plain && (typeof value === "function" || typeof value === "symbol" || typeof value === "bigint" || typeof value === "number" && !Number.isFinite(value))) throw new ContentDataError("Content must contain plain data values");
|
|
647
|
+
if (!value || typeof value !== "object") continue;
|
|
648
|
+
if (options.plain) {
|
|
649
|
+
const prototype = Object.getPrototypeOf(value);
|
|
650
|
+
if (Array.isArray(value) ? prototype !== Array.prototype : prototype !== Object.prototype && prototype !== null) throw new ContentDataError("Content must contain plain objects and arrays");
|
|
651
|
+
}
|
|
652
|
+
if (active.has(value)) throw new ContentLimitError("Content data contains a cycle");
|
|
653
|
+
if (depth > CONTENT_LIMITS.dataDepth) throw new ContentLimitError(`Content exceeds the data depth limit of ${CONTENT_LIMITS.dataDepth}`);
|
|
654
|
+
active.add(value);
|
|
655
|
+
stack.push({
|
|
656
|
+
value,
|
|
657
|
+
depth,
|
|
658
|
+
exit: true
|
|
659
|
+
});
|
|
660
|
+
const descriptors = Object.getOwnPropertyDescriptors(value);
|
|
661
|
+
for (const key of Reflect.ownKeys(descriptors)) {
|
|
662
|
+
const descriptor = descriptors[key];
|
|
663
|
+
if (options.plain && (typeof key === "symbol" || !descriptor.enumerable && !(Array.isArray(value) && key === "length"))) throw new ContentDataError("Content properties must be enumerable string keys");
|
|
664
|
+
if (!("value" in descriptor)) throw new ContentDataError("Content data must not contain accessors");
|
|
665
|
+
stack.push({
|
|
666
|
+
value: descriptor.value,
|
|
667
|
+
depth: depth + 1,
|
|
668
|
+
exit: false,
|
|
669
|
+
stringLength: options.stringLengths?.get(value)?.get(key)
|
|
670
|
+
});
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
//#endregion
|
|
675
|
+
//#region src/expressions.ts
|
|
676
|
+
const identifier = /^[A-Za-z_][A-Za-z0-9_]*/;
|
|
677
|
+
const number = /^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?/;
|
|
678
|
+
const unsafeKeys$1 = /* @__PURE__ */ new Set([
|
|
679
|
+
"__proto__",
|
|
680
|
+
"prototype",
|
|
681
|
+
"constructor"
|
|
682
|
+
]);
|
|
683
|
+
function validPath(path) {
|
|
684
|
+
return Array.isArray(path) && path.length > 0 && path.every((part) => typeof part === "string" && /^[A-Za-z_][A-Za-z0-9_]*$/.test(part) && !unsafeKeys$1.has(part));
|
|
685
|
+
}
|
|
686
|
+
function parseExpression(source) {
|
|
687
|
+
if (source.length > CONTENT_LIMITS.expressionLength) throw new ContentLimitError(`Content exceeds the expression length limit of ${CONTENT_LIMITS.expressionLength}`);
|
|
688
|
+
let index = 0;
|
|
689
|
+
let nodes = 0;
|
|
690
|
+
function fail(message) {
|
|
691
|
+
throw new Error(`${message} at expression offset ${index}`);
|
|
692
|
+
}
|
|
693
|
+
function space() {
|
|
694
|
+
while (/\s/.test(source[index] ?? "")) index++;
|
|
695
|
+
}
|
|
696
|
+
function readIdentifier() {
|
|
697
|
+
const match = identifier.exec(source.slice(index));
|
|
698
|
+
if (!match) fail("Expected identifier");
|
|
699
|
+
index += match[0].length;
|
|
700
|
+
return match[0];
|
|
701
|
+
}
|
|
702
|
+
function expression(depth = 0) {
|
|
703
|
+
if (depth > CONTENT_LIMITS.expressionDepth) throw new ContentLimitError(`Content exceeds the expression depth limit of ${CONTENT_LIMITS.expressionDepth}`);
|
|
704
|
+
if (++nodes > CONTENT_LIMITS.expressionNodes) throw new ContentLimitError(`Content exceeds the expression node limit of ${CONTENT_LIMITS.expressionNodes}`);
|
|
705
|
+
space();
|
|
706
|
+
const char = source[index];
|
|
707
|
+
if (char === "\"" || char === "'") {
|
|
708
|
+
index++;
|
|
709
|
+
let value = "";
|
|
710
|
+
while (index < source.length) {
|
|
711
|
+
const next = source[index++];
|
|
712
|
+
if (next === char) return {
|
|
713
|
+
type: "literal",
|
|
714
|
+
value
|
|
715
|
+
};
|
|
716
|
+
if (next === "\\") {
|
|
717
|
+
const escaped = source[index++];
|
|
718
|
+
if (escaped === void 0) fail("Unterminated string escape");
|
|
719
|
+
if (escaped === "n") value += "\n";
|
|
720
|
+
else if (escaped === "r") value += "\r";
|
|
721
|
+
else if (escaped === "t") value += " ";
|
|
722
|
+
else if (escaped === "b") value += "\b";
|
|
723
|
+
else if (escaped === "f") value += "\f";
|
|
724
|
+
else if (escaped === "u") {
|
|
725
|
+
const digits = source.slice(index, index + 4);
|
|
726
|
+
if (!/^[0-9a-fA-F]{4}$/.test(digits)) fail("Invalid Unicode escape");
|
|
727
|
+
value += String.fromCharCode(Number.parseInt(digits, 16));
|
|
728
|
+
index += 4;
|
|
729
|
+
} else if (escaped === char || escaped === "\\") value += escaped;
|
|
730
|
+
else fail("Unsupported string escape");
|
|
731
|
+
} else value += next;
|
|
732
|
+
}
|
|
733
|
+
fail("Unterminated string");
|
|
734
|
+
}
|
|
735
|
+
if (char === "$") {
|
|
736
|
+
index++;
|
|
737
|
+
const path = [readIdentifier()];
|
|
738
|
+
while (source[index] === ".") {
|
|
739
|
+
index++;
|
|
740
|
+
path.push(readIdentifier());
|
|
741
|
+
}
|
|
742
|
+
if (!validPath(path)) fail("Unsafe variable path");
|
|
743
|
+
return {
|
|
744
|
+
type: "variable",
|
|
745
|
+
path
|
|
746
|
+
};
|
|
747
|
+
}
|
|
748
|
+
const numeric = number.exec(source.slice(index));
|
|
749
|
+
if (numeric) {
|
|
750
|
+
index += numeric[0].length;
|
|
751
|
+
const value = Number(numeric[0]);
|
|
752
|
+
if (!Number.isFinite(value)) fail("Number must be finite");
|
|
753
|
+
return {
|
|
754
|
+
type: "literal",
|
|
755
|
+
value
|
|
756
|
+
};
|
|
757
|
+
}
|
|
758
|
+
const name = readIdentifier();
|
|
759
|
+
if (name === "true" || name === "false" || name === "null") return {
|
|
760
|
+
type: "literal",
|
|
761
|
+
value: name === "null" ? null : name === "true"
|
|
762
|
+
};
|
|
763
|
+
if (name !== "equals" && name !== "and" && name !== "or" && name !== "not") fail(`Unknown expression function ${name}`);
|
|
764
|
+
space();
|
|
765
|
+
if (source[index++] !== "(") fail(`Expected ( after ${name}`);
|
|
766
|
+
const args = [];
|
|
767
|
+
space();
|
|
768
|
+
if (source[index] !== ")") while (true) {
|
|
769
|
+
args.push(expression(depth + 1));
|
|
770
|
+
space();
|
|
771
|
+
if (source[index] !== ",") break;
|
|
772
|
+
index++;
|
|
773
|
+
}
|
|
774
|
+
if (source[index++] !== ")") fail(`Expected ) after ${name}`);
|
|
775
|
+
if (args.length !== (name === "not" ? 1 : 2)) fail(`${name} requires ${name === "not" ? 1 : 2} argument(s)`);
|
|
776
|
+
return {
|
|
777
|
+
type: "call",
|
|
778
|
+
name,
|
|
779
|
+
args
|
|
780
|
+
};
|
|
781
|
+
}
|
|
782
|
+
const parsed = expression();
|
|
783
|
+
space();
|
|
784
|
+
if (index !== source.length) fail("Unexpected expression text");
|
|
785
|
+
return parsed;
|
|
786
|
+
}
|
|
787
|
+
function writeExpression(expression) {
|
|
788
|
+
const active = /* @__PURE__ */ new Set();
|
|
789
|
+
let nodes = 0;
|
|
790
|
+
function write(expression, depth = 0) {
|
|
791
|
+
if (active.has(expression)) throw new ContentLimitError("Expression contains a cycle");
|
|
792
|
+
if (depth > CONTENT_LIMITS.expressionDepth) throw new ContentLimitError(`Content exceeds the expression depth limit of ${CONTENT_LIMITS.expressionDepth}`);
|
|
793
|
+
if (++nodes > CONTENT_LIMITS.expressionNodes) throw new ContentLimitError(`Content exceeds the expression node limit of ${CONTENT_LIMITS.expressionNodes}`);
|
|
794
|
+
active.add(expression);
|
|
795
|
+
try {
|
|
796
|
+
return writeNode(expression, depth);
|
|
797
|
+
} finally {
|
|
798
|
+
active.delete(expression);
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
function writeNode(expression, depth) {
|
|
802
|
+
if (!expression || typeof expression !== "object") throw new Error("Invalid expression");
|
|
803
|
+
if (expression.type === "literal") {
|
|
804
|
+
const value = expression.value;
|
|
805
|
+
if (value === null || typeof value === "boolean") return String(value);
|
|
806
|
+
if (typeof value === "number" && Number.isFinite(value)) return Object.is(value, -0) ? "-0" : String(value);
|
|
807
|
+
if (typeof value === "string") return JSON.stringify(value);
|
|
808
|
+
throw new Error("Invalid expression literal");
|
|
809
|
+
}
|
|
810
|
+
if (expression.type === "variable") {
|
|
811
|
+
if (!validPath(expression.path)) throw new Error("Unsafe variable path");
|
|
812
|
+
return `$${expression.path.join(".")}`;
|
|
813
|
+
}
|
|
814
|
+
if (expression.type !== "call" || ![
|
|
815
|
+
"equals",
|
|
816
|
+
"and",
|
|
817
|
+
"or",
|
|
818
|
+
"not"
|
|
819
|
+
].includes(expression.name) || !Array.isArray(expression.args) || expression.args.length !== (expression.name === "not" ? 1 : 2)) throw new Error("Invalid expression call");
|
|
820
|
+
return `${expression.name}(${expression.args.map((argument) => write(argument, depth + 1)).join(", ")})`;
|
|
821
|
+
}
|
|
822
|
+
const result = write(expression);
|
|
823
|
+
if (result.length > CONTENT_LIMITS.expressionLength) throw new ContentLimitError(`Content exceeds the expression length limit of ${CONTENT_LIMITS.expressionLength}`);
|
|
824
|
+
return result;
|
|
825
|
+
}
|
|
826
|
+
//#endregion
|
|
827
|
+
//#region src/node-grammar.ts
|
|
828
|
+
const blockTypes = /* @__PURE__ */ new Set([
|
|
829
|
+
"paragraph",
|
|
830
|
+
"heading",
|
|
831
|
+
"blockquote",
|
|
832
|
+
"list",
|
|
833
|
+
"code",
|
|
834
|
+
"topikCodeTemplate",
|
|
835
|
+
"topikCodePresentation",
|
|
836
|
+
"table",
|
|
837
|
+
"thematicBreak",
|
|
838
|
+
"definition",
|
|
839
|
+
"topikConditional"
|
|
840
|
+
]);
|
|
841
|
+
const inlineTypes = /* @__PURE__ */ new Set([
|
|
842
|
+
"text",
|
|
843
|
+
"emphasis",
|
|
844
|
+
"strong",
|
|
845
|
+
"delete",
|
|
846
|
+
"inlineCode",
|
|
847
|
+
"break",
|
|
848
|
+
"link",
|
|
849
|
+
"image",
|
|
850
|
+
"linkReference",
|
|
851
|
+
"imageReference",
|
|
852
|
+
"topikVariable"
|
|
853
|
+
]);
|
|
854
|
+
const inlineParentTypes = /* @__PURE__ */ new Set([
|
|
855
|
+
"paragraph",
|
|
856
|
+
"heading",
|
|
857
|
+
"tableCell",
|
|
858
|
+
"emphasis",
|
|
859
|
+
"strong",
|
|
860
|
+
"delete",
|
|
861
|
+
"link",
|
|
862
|
+
"linkReference"
|
|
863
|
+
]);
|
|
864
|
+
const parentTypes = /* @__PURE__ */ new Set([
|
|
865
|
+
...inlineParentTypes,
|
|
866
|
+
"root",
|
|
867
|
+
"blockquote",
|
|
868
|
+
"list",
|
|
869
|
+
"listItem",
|
|
870
|
+
"table",
|
|
871
|
+
"tableRow",
|
|
872
|
+
"topikComponent",
|
|
873
|
+
"topikConditional",
|
|
874
|
+
"topikBranch",
|
|
875
|
+
"topikCodePresentation"
|
|
876
|
+
]);
|
|
877
|
+
const nodeTypes = /* @__PURE__ */ new Set([
|
|
878
|
+
...parentTypes,
|
|
879
|
+
...blockTypes,
|
|
880
|
+
...inlineTypes,
|
|
881
|
+
"yaml"
|
|
882
|
+
]);
|
|
883
|
+
function isContentKind(node, kind, registry) {
|
|
884
|
+
if (node.type !== "topikComponent") return (kind === "inline" ? inlineTypes : blockTypes).has(node.type);
|
|
885
|
+
return typeof node.name === "string" && Object.hasOwn(registry, node.name) && registry[node.name].kind === kind;
|
|
886
|
+
}
|
|
887
|
+
function isInlineParent(node, registry) {
|
|
888
|
+
return inlineParentTypes.has(node.type) || node.type === "topikComponent" && isContentKind(node, "inline", registry);
|
|
889
|
+
}
|
|
890
|
+
//#endregion
|
|
891
|
+
//#region src/node-validation.ts
|
|
892
|
+
function record(value) {
|
|
893
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
894
|
+
}
|
|
895
|
+
function string(value) {
|
|
896
|
+
return typeof value === "string" && !/[\0\r]/.test(value);
|
|
897
|
+
}
|
|
898
|
+
function optionalString(value) {
|
|
899
|
+
return value === void 0 || value === null || string(value);
|
|
900
|
+
}
|
|
901
|
+
function dense(array) {
|
|
902
|
+
const keys = Object.keys(array);
|
|
903
|
+
return keys.length === array.length && keys.every((key, index) => key === String(index));
|
|
904
|
+
}
|
|
905
|
+
function controls(value, allowTab = false, includeSpace = false) {
|
|
906
|
+
for (let index = 0; index < value.length; index++) {
|
|
907
|
+
const unit = value.charCodeAt(index);
|
|
908
|
+
if (unit < (includeSpace ? 33 : 32) && !(allowTab && unit === 9) || unit === 127) return true;
|
|
909
|
+
}
|
|
910
|
+
return false;
|
|
911
|
+
}
|
|
912
|
+
/** Admission runs after descriptor-based plain-data checks, before cloning or callbacks. */
|
|
913
|
+
function textTemplateProblem(value, code = false, seen = /* @__PURE__ */ new Set()) {
|
|
914
|
+
const invalid = (message) => ({
|
|
915
|
+
id: "topik-template-syntax",
|
|
916
|
+
message
|
|
917
|
+
});
|
|
918
|
+
if (!record(value) || value.type !== "topikTextTemplate" || Object.keys(value).some((key) => key !== "type" && key !== "segments") || !Array.isArray(value.segments) || !dense(value.segments)) return invalid("Templates require a discriminator and an array of segments");
|
|
919
|
+
const claim = (object) => {
|
|
920
|
+
if (seen.has(object)) return false;
|
|
921
|
+
seen.add(object);
|
|
922
|
+
return true;
|
|
923
|
+
};
|
|
924
|
+
if (!claim(value) || !claim(value.segments)) return invalid("Template data must not be shared between authoring positions");
|
|
925
|
+
for (const segment of value.segments) {
|
|
926
|
+
if (!record(segment) || !claim(segment)) return invalid("Templates require independent plain-data segment objects");
|
|
927
|
+
if (segment.type === "literal") {
|
|
928
|
+
if (Object.keys(segment).some((key) => key !== "type" && key !== "value") || typeof segment.value !== "string") return invalid("Literal template segments require a string value");
|
|
929
|
+
if (code ? /[\0\r]/.test(segment.value) : controls(segment.value)) return {
|
|
930
|
+
id: "topik-template-control",
|
|
931
|
+
message: code ? "Code template literals cannot contain NUL or carriage returns" : "Attribute template literals must be single-line without controls"
|
|
932
|
+
};
|
|
933
|
+
} else if (segment.type === "variable") {
|
|
934
|
+
if (Object.keys(segment).some((key) => key !== "type" && key !== "path")) return invalid("Variable template segments require only a path");
|
|
935
|
+
if (Array.isArray(segment.path) && segment.path.length > CONTENT_LIMITS.treeNodes) return {
|
|
936
|
+
id: "topik-content-limit",
|
|
937
|
+
message: "Template path exceeds the item limit"
|
|
938
|
+
};
|
|
939
|
+
if (!Array.isArray(segment.path) || !dense(segment.path) || !validPath(segment.path)) return {
|
|
940
|
+
id: "topik-variable-path",
|
|
941
|
+
message: "Invalid template variable path"
|
|
942
|
+
};
|
|
943
|
+
if (!claim(segment.path)) return invalid("Template paths must not be shared between authoring positions");
|
|
944
|
+
} else return invalid("Unsupported template segment discriminator");
|
|
945
|
+
}
|
|
946
|
+
}
|
|
947
|
+
function codeTemplateProblem(node, seen = /* @__PURE__ */ new Set()) {
|
|
948
|
+
if (Object.keys(node).some((key) => ![
|
|
949
|
+
"type",
|
|
950
|
+
"lang",
|
|
951
|
+
"meta",
|
|
952
|
+
"template",
|
|
953
|
+
"position",
|
|
954
|
+
"data"
|
|
955
|
+
].includes(key))) return {
|
|
956
|
+
id: "topik-code-template-structure",
|
|
957
|
+
message: "Code templates have one authoritative template payload"
|
|
958
|
+
};
|
|
959
|
+
if (!optionalString(node.lang) || !optionalString(node.meta) || node.lang === "" || node.meta === "" || node.meta != null && !node.lang || typeof node.lang === "string" && controls(node.lang, false, true) || typeof node.meta === "string" && controls(node.meta, true)) return {
|
|
960
|
+
id: "topik-code-template-structure",
|
|
961
|
+
message: "Code template language and metadata must fit one fence header"
|
|
962
|
+
};
|
|
963
|
+
if (node.lang === "mermaid") return {
|
|
964
|
+
id: "topik-code-template-language",
|
|
965
|
+
message: "Code templates require a text code renderer"
|
|
966
|
+
};
|
|
967
|
+
return textTemplateProblem(node.template, true, seen);
|
|
968
|
+
}
|
|
969
|
+
/** Validate mdast fields and grammar before callbacks, cloning, or serialization. */
|
|
970
|
+
function nodeShapeProblem(value, registry) {
|
|
971
|
+
if (!record(value) || typeof value.type !== "string") return "Content nodes must have a string type";
|
|
972
|
+
const node = value;
|
|
973
|
+
const type = node.type;
|
|
974
|
+
const children = node.children;
|
|
975
|
+
if (node.position !== void 0) {
|
|
976
|
+
const position = node.position;
|
|
977
|
+
if (!record(position) || [position.start, position.end].some((point) => !record(point) || !Number.isInteger(point.line) || point.line < 1 || !Number.isInteger(point.column) || point.column < 1 || point.offset !== void 0 && (!Number.isInteger(point.offset) || point.offset < 0))) return "Content positions require valid start and end points";
|
|
978
|
+
}
|
|
979
|
+
if (node.data !== void 0 && !record(node.data)) return "Content metadata must be an object";
|
|
980
|
+
if (parentTypes.has(type)) {
|
|
981
|
+
if (!Array.isArray(children) || children.some((child) => !record(child) || typeof child.type !== "string")) return `${type} requires an array of content nodes`;
|
|
982
|
+
} else if (children !== void 0) return `${type} cannot contain children`;
|
|
983
|
+
const nodes = children ?? [];
|
|
984
|
+
if (type === "root" || type === "blockquote" || type === "listItem") {
|
|
985
|
+
if (nodes.some((child) => !(type === "root" && child.type === "yaml") && !isContentKind(child, "block", registry))) return `${type} requires block children`;
|
|
986
|
+
}
|
|
987
|
+
if (inlineParentTypes.has(type) && nodes.some((child) => !isContentKind(child, "inline", registry))) return `${type} requires inline children`;
|
|
988
|
+
if (inlineParentTypes.has(type) && type !== "link" && type !== "linkReference" && nodes.at(-1)?.type === "break") return "A trailing hard break has no faithful Markdown representation";
|
|
989
|
+
switch (type) {
|
|
990
|
+
case "text":
|
|
991
|
+
if (!string(node.value) || !node.value) return "Text requires nonempty content without NUL or carriage returns";
|
|
992
|
+
break;
|
|
993
|
+
case "yaml":
|
|
994
|
+
case "code":
|
|
995
|
+
if (!string(node.value)) return `${type} requires a string value without NUL or carriage returns`;
|
|
996
|
+
if (type === "yaml" && /^---[ \t]*$/m.test(node.value)) return "Frontmatter content cannot contain its closing fence";
|
|
997
|
+
if (type === "code" && (!optionalString(node.lang) || !optionalString(node.meta) || node.meta != null && !node.lang)) return "Code language and metadata must fit one fence header";
|
|
998
|
+
break;
|
|
999
|
+
case "inlineCode":
|
|
1000
|
+
if (!string(node.value) || !node.value || node.value.includes("\n")) return "Inline code requires nonempty single-line content";
|
|
1001
|
+
break;
|
|
1002
|
+
case "heading":
|
|
1003
|
+
if (!Number.isInteger(node.depth) || node.depth < 1 || node.depth > 6) return "Heading depth must be an integer from 1 to 6";
|
|
1004
|
+
if (record(node.data) && node.data.topikId !== void 0 && (typeof node.data.topikId !== "string" || !/^[A-Za-z0-9_.:-]+$/.test(node.data.topikId))) return "Explicit heading IDs must contain supported characters";
|
|
1005
|
+
break;
|
|
1006
|
+
case "emphasis":
|
|
1007
|
+
case "strong":
|
|
1008
|
+
case "delete":
|
|
1009
|
+
if (!nodes.length) return `${type} requires inline children`;
|
|
1010
|
+
break;
|
|
1011
|
+
case "list":
|
|
1012
|
+
if (!nodes.length || nodes.some((child) => child.type !== "listItem")) return "Lists require list item children";
|
|
1013
|
+
if (node.ordered != null && typeof node.ordered !== "boolean") return "List ordered must be a boolean";
|
|
1014
|
+
if (node.start != null && (!node.ordered || !Number.isInteger(node.start) || node.start < 0 || node.start > 999999999)) return "Ordered list start must be an integer from 0 to 999999999";
|
|
1015
|
+
if (node.spread != null && typeof node.spread !== "boolean") return "List spread must be a boolean";
|
|
1016
|
+
break;
|
|
1017
|
+
case "listItem":
|
|
1018
|
+
if (node.checked != null && typeof node.checked !== "boolean") return "List item checked must be a boolean";
|
|
1019
|
+
if (node.spread != null && typeof node.spread !== "boolean") return "List item spread must be a boolean";
|
|
1020
|
+
break;
|
|
1021
|
+
case "table": {
|
|
1022
|
+
if (!nodes.length || nodes.some((child) => child.type !== "tableRow" || !Array.isArray(child.children))) return "Tables require table row children";
|
|
1023
|
+
const width = nodes[0].children.length;
|
|
1024
|
+
if (!width) return "Table headers must have at least one column";
|
|
1025
|
+
if (nodes.some((row) => row.children.length !== width)) return "Table rows must match the header column count";
|
|
1026
|
+
if (node.align != null && (!Array.isArray(node.align) || node.align.length !== width || node.align.some((align) => align !== null && ![
|
|
1027
|
+
"left",
|
|
1028
|
+
"right",
|
|
1029
|
+
"center"
|
|
1030
|
+
].includes(align)))) return "Table alignment must match its columns";
|
|
1031
|
+
break;
|
|
1032
|
+
}
|
|
1033
|
+
case "tableRow":
|
|
1034
|
+
if (!nodes.length || nodes.some((child) => child.type !== "tableCell")) return "Table rows require table cells";
|
|
1035
|
+
break;
|
|
1036
|
+
case "image":
|
|
1037
|
+
case "link":
|
|
1038
|
+
case "definition":
|
|
1039
|
+
if (!string(node.url) || !optionalString(node.title)) return "Resources require a string URL and an optional string title";
|
|
1040
|
+
if (type === "image" && !optionalString(node.alt)) return "Image alt text must be a string";
|
|
1041
|
+
break;
|
|
1042
|
+
case "imageReference":
|
|
1043
|
+
if (!optionalString(node.alt)) return "Image alt text must be a string";
|
|
1044
|
+
break;
|
|
1045
|
+
case "topikComponent": if (typeof node.name !== "string") return "Components require a string name";
|
|
1046
|
+
}
|
|
1047
|
+
if (type === "definition" || type === "imageReference" || type === "linkReference") {
|
|
1048
|
+
if (!string(node.identifier) || !node.identifier.trim() || !optionalString(node.label)) return "References require a nonempty identifier and optional string label";
|
|
1049
|
+
if (type !== "definition" && ![
|
|
1050
|
+
"shortcut",
|
|
1051
|
+
"collapsed",
|
|
1052
|
+
"full"
|
|
1053
|
+
].includes(node.referenceType)) return "References require a supported reference type";
|
|
1054
|
+
}
|
|
1055
|
+
}
|
|
1056
|
+
//#endregion
|
|
1057
|
+
//#region src/code-presentation.ts
|
|
1058
|
+
/** Bounds are independent of ordinary, unpresented code admission. */
|
|
1059
|
+
const CODE_PRESENTATION_LIMITS = Object.freeze({
|
|
1060
|
+
metadataLength: 16384,
|
|
1061
|
+
selectionIntervals: 1024,
|
|
1062
|
+
rows: 2e4,
|
|
1063
|
+
decorativeOutput: 8e6
|
|
1064
|
+
});
|
|
1065
|
+
/** Fixed source order is part of the canonical writer policy. */
|
|
1066
|
+
const CODE_PRESENTATION_OPTION_KEYS = [
|
|
1067
|
+
"filename",
|
|
1068
|
+
"title",
|
|
1069
|
+
"lineNumbers",
|
|
1070
|
+
"startLine",
|
|
1071
|
+
"highlight",
|
|
1072
|
+
"focus",
|
|
1073
|
+
"collapse",
|
|
1074
|
+
"wrap",
|
|
1075
|
+
"added",
|
|
1076
|
+
"removed"
|
|
1077
|
+
];
|
|
1078
|
+
const selectionKeys = /* @__PURE__ */ new Set([
|
|
1079
|
+
"highlight",
|
|
1080
|
+
"focus",
|
|
1081
|
+
"collapse",
|
|
1082
|
+
"added",
|
|
1083
|
+
"removed"
|
|
1084
|
+
]);
|
|
1085
|
+
const unsafeKeys = /* @__PURE__ */ new Set([
|
|
1086
|
+
"__proto__",
|
|
1087
|
+
"constructor",
|
|
1088
|
+
"prototype"
|
|
1089
|
+
]);
|
|
1090
|
+
const authoredKeys = /* @__PURE__ */ new Map([
|
|
1091
|
+
["filename", "filename"],
|
|
1092
|
+
["title", "title"],
|
|
1093
|
+
["lines", "lineNumbers"],
|
|
1094
|
+
["startLine", "startLine"],
|
|
1095
|
+
["highlight", "highlight"],
|
|
1096
|
+
["focus", "focus"],
|
|
1097
|
+
["collapse", "collapse"],
|
|
1098
|
+
["wrap", "wrap"],
|
|
1099
|
+
["added", "added"],
|
|
1100
|
+
["removed", "removed"]
|
|
1101
|
+
]);
|
|
1102
|
+
var CodePresentationError = class extends Error {
|
|
1103
|
+
id;
|
|
1104
|
+
option;
|
|
1105
|
+
constructor(id, message, option) {
|
|
1106
|
+
super(message);
|
|
1107
|
+
this.id = id;
|
|
1108
|
+
this.name = "CodePresentationError";
|
|
1109
|
+
if (CODE_PRESENTATION_OPTION_KEYS.includes(option ?? "")) this.option = option;
|
|
1110
|
+
}
|
|
1111
|
+
};
|
|
1112
|
+
function fail(id, message, option) {
|
|
1113
|
+
throw new CodePresentationError(`topik-code-presentation-${id}`, message, option);
|
|
1114
|
+
}
|
|
1115
|
+
function withOption(option, action) {
|
|
1116
|
+
try {
|
|
1117
|
+
return action();
|
|
1118
|
+
} catch (error) {
|
|
1119
|
+
if (error instanceof CodePresentationError) throw new CodePresentationError(error.id, error.message, option);
|
|
1120
|
+
throw error;
|
|
1121
|
+
}
|
|
1122
|
+
}
|
|
1123
|
+
function ownRecord(value) {
|
|
1124
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) fail("metadata", "Code presentation options require a plain object");
|
|
1125
|
+
const prototype = Object.getPrototypeOf(value);
|
|
1126
|
+
if (prototype !== Object.prototype && prototype !== null) fail("metadata", "Code presentation options require a plain object");
|
|
1127
|
+
const descriptors = Object.getOwnPropertyDescriptors(value);
|
|
1128
|
+
for (const key of Reflect.ownKeys(descriptors)) {
|
|
1129
|
+
const descriptor = descriptors[key];
|
|
1130
|
+
if (typeof key !== "string" || unsafeKeys.has(key) || !descriptor.enumerable || !("value" in descriptor)) fail("metadata", "Code presentation requires enumerable own data properties");
|
|
1131
|
+
}
|
|
1132
|
+
return descriptors;
|
|
1133
|
+
}
|
|
1134
|
+
function ownArray(value, message) {
|
|
1135
|
+
if (!Array.isArray(value) || Object.getPrototypeOf(value) !== Array.prototype) fail("lines", message);
|
|
1136
|
+
const descriptors = Object.getOwnPropertyDescriptors(value);
|
|
1137
|
+
const keys = Reflect.ownKeys(descriptors);
|
|
1138
|
+
if (keys.length !== value.length + 1 || keys.some((key) => {
|
|
1139
|
+
if (key === "length") return false;
|
|
1140
|
+
const descriptor = descriptors[key];
|
|
1141
|
+
return typeof key !== "string" || !/^(0|[1-9][0-9]*)$/.test(key) || Number(key) >= value.length || !descriptor.enumerable || !("value" in descriptor);
|
|
1142
|
+
})) fail("lines", message);
|
|
1143
|
+
return value;
|
|
1144
|
+
}
|
|
1145
|
+
function positive(value) {
|
|
1146
|
+
return typeof value === "number" && Number.isSafeInteger(value) && value > 0;
|
|
1147
|
+
}
|
|
1148
|
+
function containsControls(value, allowTab = false) {
|
|
1149
|
+
for (let index = 0; index < value.length; index++) {
|
|
1150
|
+
const unit = value.charCodeAt(index);
|
|
1151
|
+
if (unit < 32 && !(allowTab && unit === 9) || unit === 127) return true;
|
|
1152
|
+
}
|
|
1153
|
+
return false;
|
|
1154
|
+
}
|
|
1155
|
+
function mergeIntervals(intervals) {
|
|
1156
|
+
intervals.sort((left, right) => left[0] - right[0] || left[1] - right[1]);
|
|
1157
|
+
const result = [];
|
|
1158
|
+
for (const [start, end] of intervals) {
|
|
1159
|
+
const previous = result.at(-1);
|
|
1160
|
+
if (previous && start <= previous[1] + 1) previous[1] = Math.max(previous[1], end);
|
|
1161
|
+
else result.push([start, end]);
|
|
1162
|
+
}
|
|
1163
|
+
return result;
|
|
1164
|
+
}
|
|
1165
|
+
/** Parse ranges without expanding endpoints into individual row allocations. */
|
|
1166
|
+
function parseCodeLineSelection(value, rowCount) {
|
|
1167
|
+
if (value.length > CODE_PRESENTATION_LIMITS.metadataLength) throw new ContentLimitError("Code selection exceeds the metadata length limit");
|
|
1168
|
+
if (!value || !/^[1-9][0-9]*(?:-[1-9][0-9]*)?(?:,[1-9][0-9]*(?:-[1-9][0-9]*)?)*$/.test(value)) fail("lines", "Line selections require positive numbers or closed ranges separated by commas");
|
|
1169
|
+
let count = 1;
|
|
1170
|
+
for (const character of value) if (character === ",") count++;
|
|
1171
|
+
if (count > CODE_PRESENTATION_LIMITS.selectionIntervals) throw new ContentLimitError("Code selections exceed the 1024 interval limit");
|
|
1172
|
+
const intervals = [];
|
|
1173
|
+
for (const range of value.split(",")) {
|
|
1174
|
+
const [first, last] = range.split("-");
|
|
1175
|
+
const start = Number(first);
|
|
1176
|
+
const end = last === void 0 ? start : Number(last);
|
|
1177
|
+
if (!positive(start) || !positive(end) || start > end || end > rowCount) fail("line-bounds", "Line selections must remain within the physical code rows");
|
|
1178
|
+
intervals.push([start, end]);
|
|
1179
|
+
}
|
|
1180
|
+
return mergeIntervals(intervals);
|
|
1181
|
+
}
|
|
1182
|
+
function normalizeSelection(value, rowCount) {
|
|
1183
|
+
const array = ownArray(value, "Normalized line selections require interval arrays");
|
|
1184
|
+
if (!array.length) fail("lines", "Line selections must not be empty");
|
|
1185
|
+
if (array.length > CODE_PRESENTATION_LIMITS.selectionIntervals) throw new ContentLimitError("Code selections exceed the 1024 interval limit");
|
|
1186
|
+
const seen = /* @__PURE__ */ new Set([array]);
|
|
1187
|
+
const intervals = [];
|
|
1188
|
+
for (const item of array) {
|
|
1189
|
+
const interval = ownArray(item, "Line selections require pairs of positive integers");
|
|
1190
|
+
if (seen.has(interval)) fail("metadata", "Code presentation data must not be shared");
|
|
1191
|
+
seen.add(interval);
|
|
1192
|
+
if (interval.length !== 2) fail("lines", "Line selections require pairs of positive integers");
|
|
1193
|
+
const [start, end] = interval;
|
|
1194
|
+
if (!positive(start) || !positive(end) || start > end || end > rowCount) fail("line-bounds", "Line selections must remain within the physical code rows");
|
|
1195
|
+
intervals.push([start, end]);
|
|
1196
|
+
}
|
|
1197
|
+
return mergeIntervals(intervals);
|
|
1198
|
+
}
|
|
1199
|
+
function overlap(left, right) {
|
|
1200
|
+
let first = 0;
|
|
1201
|
+
let second = 0;
|
|
1202
|
+
while (first < left.length && second < right.length) if (left[first][1] < right[second][0]) first++;
|
|
1203
|
+
else if (right[second][1] < left[first][0]) second++;
|
|
1204
|
+
else return true;
|
|
1205
|
+
return false;
|
|
1206
|
+
}
|
|
1207
|
+
function normalizeCodePresentationOptions(value, rowCount) {
|
|
1208
|
+
if (!positive(rowCount) || rowCount > CODE_PRESENTATION_LIMITS.rows) throw new ContentLimitError("Presented code exceeds the 20000 physical row limit");
|
|
1209
|
+
const descriptors = ownRecord(value);
|
|
1210
|
+
const result = {};
|
|
1211
|
+
const claimed = /* @__PURE__ */ new Set();
|
|
1212
|
+
for (const key of Object.keys(descriptors)) {
|
|
1213
|
+
if (!CODE_PRESENTATION_OPTION_KEYS.includes(key)) fail("option", "Code presentation contains an unsupported option");
|
|
1214
|
+
const input = descriptors[key].value;
|
|
1215
|
+
if (key === "filename" || key === "title") {
|
|
1216
|
+
if (typeof input !== "string" || !input.trim() || input.length > 256 || containsControls(input)) fail("option", `${key} must be a nonblank single-line string of at most 256 UTF-16 units`, key);
|
|
1217
|
+
result[key] = input;
|
|
1218
|
+
} else if (key === "lineNumbers" || key === "wrap") {
|
|
1219
|
+
if (typeof input !== "boolean") fail("option", `${key} must be a boolean`, key);
|
|
1220
|
+
result[key] = input;
|
|
1221
|
+
} else if (key === "startLine") {
|
|
1222
|
+
if (!positive(input) || input > 1e9 || !Number.isSafeInteger(input + rowCount - 1)) fail("option", "startLine must be an integer from 1 to 1000000000 with safe final labels", key);
|
|
1223
|
+
result.startLine = input;
|
|
1224
|
+
} else if (selectionKeys.has(key)) result[key] = withOption(key, () => {
|
|
1225
|
+
if (input && typeof input === "object") {
|
|
1226
|
+
if (claimed.has(input)) fail("metadata", "Code presentation data must not be shared");
|
|
1227
|
+
claimed.add(input);
|
|
1228
|
+
for (const item of ownArray(input, "Normalized line selections require interval arrays")) if (item && typeof item === "object") {
|
|
1229
|
+
if (claimed.has(item)) fail("metadata", "Code presentation data must not be shared");
|
|
1230
|
+
claimed.add(item);
|
|
1231
|
+
}
|
|
1232
|
+
}
|
|
1233
|
+
return normalizeSelection(input, rowCount);
|
|
1234
|
+
});
|
|
1235
|
+
}
|
|
1236
|
+
if (result.added && result.removed && overlap(result.added, result.removed)) fail("diff-conflict", "added and removed line selections must be disjoint", "added");
|
|
1237
|
+
return result;
|
|
1238
|
+
}
|
|
1239
|
+
/** The parser reads this raw source spelling before CommonMark unescaping. */
|
|
1240
|
+
function parseCodePresentationMetadata(rawMeta, rowCount) {
|
|
1241
|
+
if (rawMeta.length > CONTENT_LIMITS.sourceLength) throw new ContentLimitError("Code presentation metadata exceeds the source length limit");
|
|
1242
|
+
let cursor = 0;
|
|
1243
|
+
while (horizontal(rawMeta[cursor])) cursor++;
|
|
1244
|
+
const start = cursor;
|
|
1245
|
+
const values = {};
|
|
1246
|
+
let suffixStart = cursor;
|
|
1247
|
+
const bounded = () => {
|
|
1248
|
+
if (cursor - start > CODE_PRESENTATION_LIMITS.metadataLength) throw new ContentLimitError("Code presentation attributes exceed the 16384 UTF-16 unit limit");
|
|
1249
|
+
};
|
|
1250
|
+
while (cursor < rawMeta.length) {
|
|
1251
|
+
const nameStart = cursor;
|
|
1252
|
+
while (nameCharacter(rawMeta[cursor])) cursor++;
|
|
1253
|
+
const name = rawMeta.slice(nameStart, cursor);
|
|
1254
|
+
const key = authoredKeys.get(name);
|
|
1255
|
+
if (!key || cursor < rawMeta.length && !horizontal(rawMeta[cursor]) && rawMeta[cursor] !== "=") break;
|
|
1256
|
+
if (Object.hasOwn(values, key)) fail("metadata", "Code presentation contains a duplicate attribute", key);
|
|
1257
|
+
bounded();
|
|
1258
|
+
const afterName = cursor;
|
|
1259
|
+
while (horizontal(rawMeta[cursor])) cursor++;
|
|
1260
|
+
let input;
|
|
1261
|
+
if (rawMeta[cursor] !== "=") {
|
|
1262
|
+
if (key !== "lineNumbers" && key !== "wrap") fail("option", `${name} requires an assigned value`, key);
|
|
1263
|
+
cursor = afterName;
|
|
1264
|
+
if (cursor < rawMeta.length && !horizontal(rawMeta[cursor])) fail("metadata", "Code presentation attributes require whitespace separators", key);
|
|
1265
|
+
input = true;
|
|
1266
|
+
} else {
|
|
1267
|
+
cursor++;
|
|
1268
|
+
while (horizontal(rawMeta[cursor])) cursor++;
|
|
1269
|
+
bounded();
|
|
1270
|
+
const quote = rawMeta[cursor] === "\"" || rawMeta[cursor] === "'" ? rawMeta[cursor++] : void 0;
|
|
1271
|
+
const valueStart = cursor;
|
|
1272
|
+
if (quote) {
|
|
1273
|
+
let closed = false;
|
|
1274
|
+
while (cursor < rawMeta.length) {
|
|
1275
|
+
const character = rawMeta[cursor++];
|
|
1276
|
+
bounded();
|
|
1277
|
+
if (character === "\\") {
|
|
1278
|
+
if (cursor < rawMeta.length) cursor++;
|
|
1279
|
+
bounded();
|
|
1280
|
+
} else if (character === quote) {
|
|
1281
|
+
closed = true;
|
|
1282
|
+
break;
|
|
1283
|
+
}
|
|
1284
|
+
}
|
|
1285
|
+
if (!closed) fail("metadata", "Close the quoted code presentation attribute value", key);
|
|
1286
|
+
} else while (cursor < rawMeta.length && !horizontal(rawMeta[cursor])) {
|
|
1287
|
+
cursor++;
|
|
1288
|
+
bounded();
|
|
1289
|
+
}
|
|
1290
|
+
const valueEnd = quote ? cursor - 1 : cursor;
|
|
1291
|
+
if (valueEnd === valueStart && !quote) fail("option", `${name} requires an assigned value`, key);
|
|
1292
|
+
if (cursor < rawMeta.length && !horizontal(rawMeta[cursor])) fail("metadata", "Code presentation attributes require whitespace separators", key);
|
|
1293
|
+
const value = decodeString(rawMeta.slice(valueStart, valueEnd));
|
|
1294
|
+
if (key === "lineNumbers" || key === "wrap") {
|
|
1295
|
+
if (value !== "true" && value !== "false") fail("option", `${name} must be true or false`, key);
|
|
1296
|
+
input = value === "true";
|
|
1297
|
+
} else if (key === "startLine") {
|
|
1298
|
+
if (!/^[1-9][0-9]*$/.test(value)) fail("option", `${name} requires a positive decimal integer`, key);
|
|
1299
|
+
input = Number(value);
|
|
1300
|
+
} else if (selectionKeys.has(key)) input = withOption(key, () => parseCodeLineSelection(value, rowCount));
|
|
1301
|
+
else input = value;
|
|
1302
|
+
}
|
|
1303
|
+
values[key] = input;
|
|
1304
|
+
bounded();
|
|
1305
|
+
suffixStart = cursor;
|
|
1306
|
+
while (horizontal(rawMeta[cursor])) cursor++;
|
|
1307
|
+
}
|
|
1308
|
+
if (!Object.keys(values).length) return void 0;
|
|
1309
|
+
const opaqueMetaSuffix = decodeString(rawMeta.slice(suffixStart));
|
|
1310
|
+
assertCodePresentationSuffix(opaqueMetaSuffix);
|
|
1311
|
+
const options = normalizeCodePresentationOptions(values, rowCount);
|
|
1312
|
+
assertCanonicalAttributeLimit(options);
|
|
1313
|
+
return {
|
|
1314
|
+
options,
|
|
1315
|
+
opaqueMetaSuffix
|
|
1316
|
+
};
|
|
1317
|
+
}
|
|
1318
|
+
function horizontal(value) {
|
|
1319
|
+
return value === " " || value === " ";
|
|
1320
|
+
}
|
|
1321
|
+
function nameCharacter(value) {
|
|
1322
|
+
return value !== void 0 && /[A-Za-z0-9_-]/.test(value);
|
|
1323
|
+
}
|
|
1324
|
+
function assertCodePresentationSuffix(value) {
|
|
1325
|
+
if (typeof value !== "string" || value.length > 0 && value[0] !== " " && value[0] !== " " || containsControls(value, true)) fail("metadata", "Opaque metadata suffix must be single-line and retain separating whitespace");
|
|
1326
|
+
}
|
|
1327
|
+
/** Quote raw fence attributes before exactly one CommonMark decoding pass. */
|
|
1328
|
+
function quoteAttribute(value) {
|
|
1329
|
+
let result = "\"";
|
|
1330
|
+
for (let index = 0; index < value.length; index++) {
|
|
1331
|
+
const character = value[index];
|
|
1332
|
+
if (character === "\"") result += "\\\"";
|
|
1333
|
+
else if (character === "\\") result += "\";
|
|
1334
|
+
else if (character === "&" && entityAt(value, index)) result += "&";
|
|
1335
|
+
else if (character === "`") result += "`";
|
|
1336
|
+
else result += character;
|
|
1337
|
+
}
|
|
1338
|
+
return result + "\"";
|
|
1339
|
+
}
|
|
1340
|
+
function entityAt(value, index) {
|
|
1341
|
+
return /^&(?:#[0-9]+|#[xX][0-9A-Fa-f]+|[A-Za-z][A-Za-z0-9]*);/.test(value.slice(index));
|
|
1342
|
+
}
|
|
1343
|
+
/** A decoded opaque suffix may start with a word that otherwise becomes an option. */
|
|
1344
|
+
function suffixProtectedIndex(suffix) {
|
|
1345
|
+
let cursor = 0;
|
|
1346
|
+
while (horizontal(suffix[cursor])) cursor++;
|
|
1347
|
+
const start = cursor;
|
|
1348
|
+
while (nameCharacter(suffix[cursor])) cursor++;
|
|
1349
|
+
return authoredKeys.has(suffix.slice(start, cursor)) && (cursor === suffix.length || horizontal(suffix[cursor]) || suffix[cursor] === "=") ? start : void 0;
|
|
1350
|
+
}
|
|
1351
|
+
/** Single-pass CommonMark encoding also preserves entity-looking decoded text. */
|
|
1352
|
+
function encodeCodePresentationSuffix(suffix) {
|
|
1353
|
+
assertCodePresentationSuffix(suffix);
|
|
1354
|
+
if (encodedSuffixLength(suffix) > CONTENT_LIMITS.sourceLength) throw new ContentLimitError("Code presentation suffix exceeds the source length limit");
|
|
1355
|
+
const protectedIndex = suffixProtectedIndex(suffix);
|
|
1356
|
+
let result = "";
|
|
1357
|
+
for (let index = 0; index < suffix.length; index++) {
|
|
1358
|
+
const character = suffix[index];
|
|
1359
|
+
if (index === protectedIndex) result += `&#${character.charCodeAt(0)};`;
|
|
1360
|
+
else if (character === "&") result += "&";
|
|
1361
|
+
else if (character === "\\") result += "\";
|
|
1362
|
+
else if (character === "`") result += "`";
|
|
1363
|
+
else result += character;
|
|
1364
|
+
}
|
|
1365
|
+
return result;
|
|
1366
|
+
}
|
|
1367
|
+
function encodedSuffixLength(suffix) {
|
|
1368
|
+
let length = suffix.length;
|
|
1369
|
+
for (let index = 0; index < suffix.length; index++) if (suffix[index] === "&" || suffix[index] === "\\" || suffix[index] === "`") length += 4;
|
|
1370
|
+
const protectedIndex = suffixProtectedIndex(suffix);
|
|
1371
|
+
if (protectedIndex !== void 0) length += String(suffix.charCodeAt(protectedIndex)).length + 2;
|
|
1372
|
+
return length;
|
|
1373
|
+
}
|
|
1374
|
+
function selectionSource(value) {
|
|
1375
|
+
return value.map(([start, end]) => start === end ? String(start) : `${start}-${end}`).join(",");
|
|
1376
|
+
}
|
|
1377
|
+
/** Measure quoted attribute output without allocating its escaped spelling. */
|
|
1378
|
+
function quotedAttributeLength(value) {
|
|
1379
|
+
let length = 2;
|
|
1380
|
+
for (let index = 0; index < value.length; index++) {
|
|
1381
|
+
const unit = value.charCodeAt(index);
|
|
1382
|
+
if (unit === 34) length += 2;
|
|
1383
|
+
else if (unit === 92 || unit === 96 || unit === 38 && entityAt(value, index)) length += 5;
|
|
1384
|
+
else length++;
|
|
1385
|
+
}
|
|
1386
|
+
return length;
|
|
1387
|
+
}
|
|
1388
|
+
function canonicalAttributeLength(options) {
|
|
1389
|
+
let length = 0;
|
|
1390
|
+
let members = 0;
|
|
1391
|
+
for (const key of CODE_PRESENTATION_OPTION_KEYS) {
|
|
1392
|
+
if (!Object.hasOwn(options, key)) continue;
|
|
1393
|
+
if (members++) length++;
|
|
1394
|
+
length += (key === "lineNumbers" ? "lines" : key).length;
|
|
1395
|
+
const value = options[key];
|
|
1396
|
+
if (typeof value === "boolean" && value) continue;
|
|
1397
|
+
length++;
|
|
1398
|
+
if (Array.isArray(value)) {
|
|
1399
|
+
length += 2;
|
|
1400
|
+
for (let index = 0; index < value.length; index++) {
|
|
1401
|
+
if (index > 0) length++;
|
|
1402
|
+
const [start, end] = value[index];
|
|
1403
|
+
length += String(start).length;
|
|
1404
|
+
if (start !== end) length += 1 + String(end).length;
|
|
1405
|
+
}
|
|
1406
|
+
} else length += typeof value === "string" ? quotedAttributeLength(value) : String(value).length;
|
|
1407
|
+
}
|
|
1408
|
+
return length;
|
|
1409
|
+
}
|
|
1410
|
+
function assertCanonicalAttributeLimit(options) {
|
|
1411
|
+
const length = canonicalAttributeLength(options);
|
|
1412
|
+
if (!length) fail("metadata", "Code presentation requires at least one authored option");
|
|
1413
|
+
if (length > CODE_PRESENTATION_LIMITS.metadataLength) throw new ContentLimitError("Code presentation attributes exceed the 16384 UTF-16 unit limit");
|
|
1414
|
+
return length;
|
|
1415
|
+
}
|
|
1416
|
+
/** Source writers can reserve escaped header growth before any string emission. */
|
|
1417
|
+
function codePresentationMetadataLength(options, opaqueMetaSuffix) {
|
|
1418
|
+
const normalized = normalizeCodePresentationOptions(options, CODE_PRESENTATION_LIMITS.rows);
|
|
1419
|
+
assertCodePresentationSuffix(opaqueMetaSuffix);
|
|
1420
|
+
return assertCanonicalAttributeLimit(normalized) + encodedSuffixLength(opaqueMetaSuffix);
|
|
1421
|
+
}
|
|
1422
|
+
/** Returns a raw fence header. Do not pass it through Markdown unescaping or safe(). */
|
|
1423
|
+
function serializeCodePresentationMetadata(options, opaqueMetaSuffix) {
|
|
1424
|
+
const normalized = normalizeCodePresentationOptions(options, CODE_PRESENTATION_LIMITS.rows);
|
|
1425
|
+
const attributeLength = assertCanonicalAttributeLimit(normalized);
|
|
1426
|
+
assertCodePresentationSuffix(opaqueMetaSuffix);
|
|
1427
|
+
if (attributeLength + encodedSuffixLength(opaqueMetaSuffix) > CONTENT_LIMITS.sourceLength) throw new ContentLimitError("Code presentation metadata exceeds the source length limit");
|
|
1428
|
+
const members = [];
|
|
1429
|
+
for (const key of CODE_PRESENTATION_OPTION_KEYS) {
|
|
1430
|
+
if (!Object.hasOwn(normalized, key)) continue;
|
|
1431
|
+
const value = normalized[key];
|
|
1432
|
+
const name = key === "lineNumbers" ? "lines" : key;
|
|
1433
|
+
if (typeof value === "boolean" && value) {
|
|
1434
|
+
members.push(name);
|
|
1435
|
+
continue;
|
|
1436
|
+
}
|
|
1437
|
+
const encoded = Array.isArray(value) ? quoteAttribute(selectionSource(value)) : typeof value === "string" ? quoteAttribute(value) : String(value);
|
|
1438
|
+
members.push(`${name}=${encoded}`);
|
|
1439
|
+
}
|
|
1440
|
+
return members.join(" ") + encodeCodePresentationSuffix(opaqueMetaSuffix);
|
|
1441
|
+
}
|
|
1442
|
+
function effectiveCodePresentationOptions(options) {
|
|
1443
|
+
return {
|
|
1444
|
+
...options,
|
|
1445
|
+
lineNumbers: options.lineNumbers ?? false,
|
|
1446
|
+
startLine: options.startLine ?? 1,
|
|
1447
|
+
wrap: options.wrap ?? false
|
|
1448
|
+
};
|
|
1449
|
+
}
|
|
1450
|
+
function codePresentationRows(payload) {
|
|
1451
|
+
let count = 1;
|
|
1452
|
+
for (let index = 0; index < payload.length; index++) if (payload[index] === "\n") count++;
|
|
1453
|
+
return count;
|
|
1454
|
+
}
|
|
1455
|
+
/** Template substitutions are scalar single-line text, so literals define physical rows. */
|
|
1456
|
+
function codePresentationChildRows(child) {
|
|
1457
|
+
if (child.type === "code") return codePresentationRows(typeof child.value === "string" ? child.value : "");
|
|
1458
|
+
let count = 1;
|
|
1459
|
+
const segments = child.template?.segments;
|
|
1460
|
+
if (!Array.isArray(segments)) return count;
|
|
1461
|
+
for (const segment of segments) if (segment?.type === "literal" && typeof segment.value === "string") count += codePresentationRows(segment.value) - 1;
|
|
1462
|
+
return count;
|
|
1463
|
+
}
|
|
1464
|
+
/** Run after the document's descriptor/plain-data admission and before callbacks. */
|
|
1465
|
+
function codePresentationProblem(node, seen = /* @__PURE__ */ new Set()) {
|
|
1466
|
+
try {
|
|
1467
|
+
if (Object.keys(node).some((key) => ![
|
|
1468
|
+
"type",
|
|
1469
|
+
"children",
|
|
1470
|
+
"options",
|
|
1471
|
+
"opaqueMetaSuffix",
|
|
1472
|
+
"position",
|
|
1473
|
+
"data"
|
|
1474
|
+
].includes(key)) || !Array.isArray(node.children) || node.children.length !== 1 || !["code", "topikCodeTemplate"].includes(node.children[0]?.type)) fail("scope", "A code presentation must own exactly one code or code-template child");
|
|
1475
|
+
const child = node.children[0];
|
|
1476
|
+
if (child.meta != null) fail("metadata", "Presentation options and opaque suffix are authoritative; child metadata must be absent");
|
|
1477
|
+
if (child.lang != null && (typeof child.lang !== "string" || !child.lang || containsControls(child.lang) || child.lang.includes(" "))) fail("language", "Presented code language must fit one literal fence header word");
|
|
1478
|
+
if (child.lang === "mermaid") fail("language", "Presented code requires a text code renderer");
|
|
1479
|
+
if (child.type === "topikCodeTemplate") {
|
|
1480
|
+
const problem = codeTemplateProblem(child);
|
|
1481
|
+
if (problem) return problem;
|
|
1482
|
+
}
|
|
1483
|
+
assertCanonicalAttributeLimit(normalizeCodePresentationOptions(node.options, codePresentationChildRows(child)));
|
|
1484
|
+
const claim = (value) => {
|
|
1485
|
+
if (seen.has(value)) fail("metadata", "Code presentation data must not be shared between authoring positions");
|
|
1486
|
+
seen.add(value);
|
|
1487
|
+
};
|
|
1488
|
+
claim(node.options);
|
|
1489
|
+
for (const value of Object.values(node.options)) if (Array.isArray(value)) {
|
|
1490
|
+
claim(value);
|
|
1491
|
+
for (const interval of value) claim(interval);
|
|
1492
|
+
}
|
|
1493
|
+
assertCodePresentationSuffix(node.opaqueMetaSuffix);
|
|
1494
|
+
} catch (error) {
|
|
1495
|
+
if (error instanceof CodePresentationError || error instanceof ContentLimitError) return {
|
|
1496
|
+
id: error.id,
|
|
1497
|
+
message: error.message,
|
|
1498
|
+
...error instanceof CodePresentationError && error.option !== void 0 ? { option: error.option } : {}
|
|
1499
|
+
};
|
|
1500
|
+
throw error;
|
|
1501
|
+
}
|
|
1502
|
+
}
|
|
1503
|
+
//#endregion
|
|
1504
|
+
//#region src/presentation-output.ts
|
|
1505
|
+
/**
|
|
1506
|
+
* Reserve a conservative serialization budget before allocating reader rows.
|
|
1507
|
+
* Each text unit can expand to six HTML units; each row reserves 768 units for
|
|
1508
|
+
* its number, diff meaning, state classes and surrounding elements. Each
|
|
1509
|
+
* independent collapse region reserves another 768 units for its visible control. Syntax
|
|
1510
|
+
* highlighting is optional and must fall back when its tokens exceed a budget.
|
|
1511
|
+
*/
|
|
1512
|
+
function assertCodePresentationOutputLimit(document) {
|
|
1513
|
+
let length = 0;
|
|
1514
|
+
const stack = [document];
|
|
1515
|
+
while (stack.length) {
|
|
1516
|
+
const node = stack.pop();
|
|
1517
|
+
if (node.type === "topikCodePresentation") {
|
|
1518
|
+
const child = node.children?.[0];
|
|
1519
|
+
let rows = 1;
|
|
1520
|
+
let payloadLength = 0;
|
|
1521
|
+
function measure(value) {
|
|
1522
|
+
payloadLength += value.length;
|
|
1523
|
+
for (let index = 0; index < value.length; index++) if (value.charCodeAt(index) === 10) rows++;
|
|
1524
|
+
}
|
|
1525
|
+
if (child?.type === "code") measure(child.value ?? "");
|
|
1526
|
+
else for (const segment of child?.template?.segments ?? []) if (segment.type === "literal") measure(segment.value);
|
|
1527
|
+
const collapseRegions = node.options?.collapse ? normalizeCodePresentationOptions(node.options, rows).collapse.length : 0;
|
|
1528
|
+
length += payloadLength * 6 + (rows + collapseRegions) * 768 + 4096;
|
|
1529
|
+
length += (child?.lang?.length ?? 0) * 6;
|
|
1530
|
+
length += ((node.options?.title?.length ?? 0) + (node.options?.filename?.length ?? 0)) * 6;
|
|
1531
|
+
if (length > CONTENT_LIMITS.presentationOutputLength) throw new ContentLimitError("Presented code exceeds the generated markup limit");
|
|
1532
|
+
}
|
|
1533
|
+
for (const child of node.children ?? []) stack.push(child);
|
|
1534
|
+
}
|
|
1535
|
+
}
|
|
1536
|
+
//#endregion
|
|
1537
|
+
//#region src/document-index.ts
|
|
1538
|
+
function referenceIdentifier(identifier) {
|
|
1539
|
+
return identifier.replace(/[\t\n\r ]+/gu, " ").replace(/^ | $/gu, "").toLowerCase().toUpperCase().toLowerCase();
|
|
1540
|
+
}
|
|
1541
|
+
/** Keep source escapes intact: decoded labels can identify a different definition. */
|
|
1542
|
+
function referenceSourceLabel(node) {
|
|
1543
|
+
return (node.label && referenceIdentifier(node.label) === referenceIdentifier(node.identifier) ? node.label : node.identifier).replace(/[\t\n\r ]+/gu, " ").replace(/^ | $/gu, "");
|
|
1544
|
+
}
|
|
1545
|
+
/** One bounded traversal shared by validation, asset processing, and source analysis. */
|
|
1546
|
+
function indexDocument(document) {
|
|
1547
|
+
assertDataLimits(document, { plain: true });
|
|
1548
|
+
assertTreeLimits(document);
|
|
1549
|
+
const entries = [];
|
|
1550
|
+
const scopes = /* @__PURE__ */ new Map();
|
|
1551
|
+
const rootScope = { definitions: /* @__PURE__ */ new Map() };
|
|
1552
|
+
function visit(node, path, scope, parent) {
|
|
1553
|
+
if (node.type === "topikBranch") scope = {
|
|
1554
|
+
parent: scope,
|
|
1555
|
+
definitions: /* @__PURE__ */ new Map()
|
|
1556
|
+
};
|
|
1557
|
+
entries.push({
|
|
1558
|
+
node,
|
|
1559
|
+
path,
|
|
1560
|
+
scope,
|
|
1561
|
+
parent
|
|
1562
|
+
});
|
|
1563
|
+
scopes.set(node, scope);
|
|
1564
|
+
if (node.type === "definition") {
|
|
1565
|
+
const definition = node;
|
|
1566
|
+
const key = referenceIdentifier(definition.identifier);
|
|
1567
|
+
if (!scope.definitions.has(key)) scope.definitions.set(key, definition);
|
|
1568
|
+
}
|
|
1569
|
+
for (const [index, child] of (node.children ?? []).entries()) visit(child, [...path, index], scope, node);
|
|
1570
|
+
}
|
|
1571
|
+
visit(document, [], rootScope);
|
|
1572
|
+
return {
|
|
1573
|
+
entries,
|
|
1574
|
+
resolve(reference) {
|
|
1575
|
+
const key = referenceIdentifier(reference.identifier);
|
|
1576
|
+
for (let scope = scopes.get(reference); scope; scope = scope.parent) {
|
|
1577
|
+
const definition = scope.definitions.get(key);
|
|
1578
|
+
if (definition) return definition;
|
|
1579
|
+
}
|
|
1580
|
+
}
|
|
1581
|
+
};
|
|
1582
|
+
}
|
|
1583
|
+
function isReference(node) {
|
|
1584
|
+
return node.type === "imageReference" || node.type === "linkReference";
|
|
1585
|
+
}
|
|
1586
|
+
/** Bind before pruning branches so evaluation cannot change an authored destination. */
|
|
1587
|
+
function materializeReferences(document, index = indexDocument(document)) {
|
|
1588
|
+
for (const { node } of index.entries) {
|
|
1589
|
+
if (!isReference(node)) continue;
|
|
1590
|
+
const definition = index.resolve(node);
|
|
1591
|
+
if (!definition) throw new Error(`Reference ${node.identifier} has no definition in its scope`);
|
|
1592
|
+
const resource = node;
|
|
1593
|
+
resource.type = node.type === "imageReference" ? "image" : "link";
|
|
1594
|
+
resource.url = definition.url;
|
|
1595
|
+
resource.title = definition.title ?? null;
|
|
1596
|
+
delete resource.identifier;
|
|
1597
|
+
delete resource.label;
|
|
1598
|
+
delete resource.referenceType;
|
|
1599
|
+
}
|
|
1600
|
+
}
|
|
1601
|
+
//#endregion
|
|
1602
|
+
//#region src/validation.ts
|
|
1603
|
+
function issue(id, message) {
|
|
1604
|
+
return {
|
|
1605
|
+
id,
|
|
1606
|
+
message
|
|
1607
|
+
};
|
|
1608
|
+
}
|
|
1609
|
+
function kebab(name) {
|
|
1610
|
+
return name.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`);
|
|
1611
|
+
}
|
|
1612
|
+
function hasControl$1(value) {
|
|
1613
|
+
for (let index = 0; index < value.length; index++) {
|
|
1614
|
+
const code = value.charCodeAt(index);
|
|
1615
|
+
if (code < 32 || code === 127) return true;
|
|
1616
|
+
}
|
|
1617
|
+
return false;
|
|
1618
|
+
}
|
|
1619
|
+
/** Validate the literal source form and properties before tree-level checks. */
|
|
1620
|
+
function validateTag(tag, registry, templateData = /* @__PURE__ */ new Set()) {
|
|
1621
|
+
const definition = Object.hasOwn(registry, tag.name) ? registry[tag.name] : void 0;
|
|
1622
|
+
if (!definition) return issue("tag-undefined", `Unknown component ${tag.name}`);
|
|
1623
|
+
if (tag.close) return;
|
|
1624
|
+
if (!tag.props || typeof tag.props !== "object" || Array.isArray(tag.props)) return issue("attribute-type-invalid", `Invalid properties on ${tag.name}`);
|
|
1625
|
+
if (definition.kind === "inline" && tag.selfClosing && definition.children !== "none") return issue("topik-tag-children", `Inline component ${tag.name} requires content`);
|
|
1626
|
+
if (definition.kind === "inline" && !tag.selfClosing && definition.children === "none") return issue("topik-tag-children", `Inline component ${tag.name} must be self-closing`);
|
|
1627
|
+
if (definition.kind === "block" && tag.selfClosing && definition.children !== "none" && !definition.selfClosing) return issue("topik-tag-children", `Block component ${tag.name} cannot be self-closing`);
|
|
1628
|
+
if (definition.kind === "block" && !tag.selfClosing && definition.children === "none") return issue("topik-tag-children", `Block component ${tag.name} must be self-closing`);
|
|
1629
|
+
for (const [key, value] of Object.entries(tag.props)) {
|
|
1630
|
+
const attr = Object.hasOwn(definition.attributes, key) ? definition.attributes[key] : void 0;
|
|
1631
|
+
if (!attr) return issue("attribute-undefined", `Unknown attribute ${key} on ${tag.name}`);
|
|
1632
|
+
if (value !== null && typeof value === "object" && value.type === "topikTextTemplate") {
|
|
1633
|
+
const canonicalDefinition = Object.hasOwn(components, tag.name) ? components[tag.name] : void 0;
|
|
1634
|
+
const canonical = canonicalDefinition?.attributes[key];
|
|
1635
|
+
if (definition.kind !== canonicalDefinition?.kind || attr.type !== "string" || attr.interpolation !== "text" || attr.asset || attr.assetReference || canonical?.type !== "string" || canonical.interpolation !== "text") return issue("topik-template-location", `Attribute ${key} on ${tag.name} is literal-only`);
|
|
1636
|
+
const invalid = textTemplateProblem(value, false, templateData);
|
|
1637
|
+
if (invalid) return invalid;
|
|
1638
|
+
continue;
|
|
1639
|
+
}
|
|
1640
|
+
if (typeof value !== (attr.type === "enum" ? "string" : attr.type)) return issue("attribute-type-invalid", `Attribute ${key} on ${tag.name} must be a ${attr.type === "enum" ? "string" : attr.type}`);
|
|
1641
|
+
if (typeof value === "string" && hasControl$1(attr.type === "string" && attr.escapedWhitespace ? value.replace(/[\t\n\r]/g, "") : value)) return issue("attribute-value-invalid", `Control character in ${key} on ${tag.name}`);
|
|
1642
|
+
if (attr.type === "enum" && !attr.values.includes(value)) return issue("attribute-value-invalid", `Invalid ${key} on ${tag.name}`);
|
|
1643
|
+
if (attr.type === "number") {
|
|
1644
|
+
const numeric = value;
|
|
1645
|
+
if (!Number.isFinite(numeric)) return issue("attribute-value-invalid", `Invalid ${key} on ${tag.name}`);
|
|
1646
|
+
if (attr.integer && !Number.isInteger(numeric) || attr.min !== void 0 && numeric < attr.min || attr.max !== void 0 && numeric > attr.max) return issue(`topik-${kebab(key)}-range`, `Invalid ${key} on ${tag.name}`);
|
|
1647
|
+
}
|
|
1648
|
+
}
|
|
1649
|
+
for (const [key, attr] of Object.entries(definition.attributes)) if (attr.required && !Object.hasOwn(tag.props, key)) return issue("attribute-missing-required", `Missing attribute ${key} on ${tag.name}`);
|
|
1650
|
+
}
|
|
1651
|
+
function diagnostic(problem, marker, id) {
|
|
1652
|
+
return {
|
|
1653
|
+
...typeof problem === "string" ? {
|
|
1654
|
+
message: problem,
|
|
1655
|
+
...id ? { id } : {}
|
|
1656
|
+
} : problem,
|
|
1657
|
+
line: marker?.position?.start?.line,
|
|
1658
|
+
column: marker?.position?.start?.column
|
|
1659
|
+
};
|
|
1660
|
+
}
|
|
1661
|
+
function matchesConstraint(child, constraint) {
|
|
1662
|
+
if ("components" in constraint) return child.type === "topikComponent" && constraint.components.includes(child.name);
|
|
1663
|
+
return constraint.nodes.includes(child.type);
|
|
1664
|
+
}
|
|
1665
|
+
function validateChildren(component, registry) {
|
|
1666
|
+
const constraint = registry[component.name]?.children;
|
|
1667
|
+
if (!constraint) return [];
|
|
1668
|
+
const children = component.children;
|
|
1669
|
+
const name = component.name;
|
|
1670
|
+
if (constraint === "none") return children.length ? [issue(`topik-${kebab(name)}-children`, `${name} cannot contain children`)] : [];
|
|
1671
|
+
if (constraint === "blocks") return children.some((child) => !isContentKind(child, "block", registry)) ? [issue(`topik-${kebab(name)}-children`, `${name} may contain only block children`)] : [];
|
|
1672
|
+
if (constraint === "inline") {
|
|
1673
|
+
if (!children.length) return [issue(`topik-${kebab(name)}-children`, `${name} requires inline children`)];
|
|
1674
|
+
return children.some((child) => !isContentKind(child, "inline", registry)) ? [issue(`topik-${kebab(name)}-children`, `${name} may contain only inline children`)] : [];
|
|
1675
|
+
}
|
|
1676
|
+
const errors = [];
|
|
1677
|
+
if (children.some((child) => !matchesConstraint(child, constraint))) errors.push(issue(`topik-${kebab(name)}-children`, `${name} may contain only ${("components" in constraint ? constraint.components : constraint.nodes).join(", ")} children`));
|
|
1678
|
+
if (children.length < (constraint.min ?? 0)) {
|
|
1679
|
+
const required = "components" in constraint ? constraint.components[0] : constraint.nodes[0] === "code" ? "fence" : constraint.nodes[0];
|
|
1680
|
+
errors.push(issue(`topik-${kebab(name)}-requires-${kebab(required)}`, `${name} requires at least one ${required} child`));
|
|
1681
|
+
}
|
|
1682
|
+
return errors;
|
|
1683
|
+
}
|
|
1684
|
+
function validatePlacement(component, parent, registry) {
|
|
1685
|
+
const definition = registry[component.name];
|
|
1686
|
+
if (!definition) return [];
|
|
1687
|
+
const errors = [];
|
|
1688
|
+
if (definition.allowedParents && !(parent?.type === "topikComponent" && definition.allowedParents.includes(parent.name))) {
|
|
1689
|
+
const required = definition.allowedParents[0];
|
|
1690
|
+
errors.push(issue(`topik-${kebab(required)}-parent-required`, `${component.name} must be inside ${definition.allowedParents.join(", ")}`));
|
|
1691
|
+
}
|
|
1692
|
+
if (!parent) return errors;
|
|
1693
|
+
const parentIsInline = isInlineParent(parent, registry);
|
|
1694
|
+
if (definition.kind === "block" && parentIsInline) errors.push(issue("topik-tag-placement", `Block ${component.name} has invalid placement`));
|
|
1695
|
+
if (definition.kind === "inline" && !parentIsInline) errors.push(issue("topik-tag-placement", `Inline ${component.name} has invalid placement`));
|
|
1696
|
+
return errors;
|
|
1697
|
+
}
|
|
1698
|
+
function validateDocument(document, registry = components) {
|
|
1699
|
+
return validateDocumentWithIndex(document, registry).diagnostics;
|
|
1700
|
+
}
|
|
1701
|
+
/** @internal Keep the reference index available to the source admission pipeline. */
|
|
1702
|
+
function validateDocumentWithIndex(document, registry) {
|
|
1703
|
+
try {
|
|
1704
|
+
assertDataLimits(document, { plain: true });
|
|
1705
|
+
assertTreeLimits(document);
|
|
1706
|
+
} catch (error) {
|
|
1707
|
+
if (error instanceof ContentLimitError) return { diagnostics: [limitDiagnostic(error)] };
|
|
1708
|
+
if (error instanceof ContentDataError) return { diagnostics: [{
|
|
1709
|
+
id: error.id,
|
|
1710
|
+
message: error.message
|
|
1711
|
+
}] };
|
|
1712
|
+
throw error;
|
|
1713
|
+
}
|
|
1714
|
+
const errors = [];
|
|
1715
|
+
const templateData = /* @__PURE__ */ new Set();
|
|
1716
|
+
const presentationData = /* @__PURE__ */ new Set();
|
|
1717
|
+
const parents = /* @__PURE__ */ new WeakMap();
|
|
1718
|
+
const callbacks = [];
|
|
1719
|
+
function add(problem, node, id) {
|
|
1720
|
+
errors.push(diagnostic(problem, node, id));
|
|
1721
|
+
}
|
|
1722
|
+
if (document?.type !== "root") return { diagnostics: [{
|
|
1723
|
+
id: "topik-node-invalid",
|
|
1724
|
+
message: "Content documents require a root node"
|
|
1725
|
+
}] };
|
|
1726
|
+
function walk(node, parent, insideLink = false, marks = {
|
|
1727
|
+
emphasis: 0,
|
|
1728
|
+
strong: 0,
|
|
1729
|
+
delete: 0
|
|
1730
|
+
}) {
|
|
1731
|
+
if (parent) parents.set(node, parent);
|
|
1732
|
+
const shapeProblem = nodeShapeProblem(node, registry);
|
|
1733
|
+
if (shapeProblem) {
|
|
1734
|
+
add(shapeProblem, node, "topik-node-invalid");
|
|
1735
|
+
return;
|
|
1736
|
+
}
|
|
1737
|
+
if (!nodeTypes.has(node.type)) add(`Unsupported Markdown node ${node.type}`, node, "node-undefined");
|
|
1738
|
+
if (node.type === "emphasis" || node.type === "strong" || node.type === "delete") marks = {
|
|
1739
|
+
...marks,
|
|
1740
|
+
[node.type]: marks[node.type] + 1
|
|
1741
|
+
};
|
|
1742
|
+
if (marks.emphasis > 2 || marks.strong > 2 || marks.delete > 1) {
|
|
1743
|
+
add(marks.delete > 1 ? "Strikethrough marks cannot be nested" : "Emphasis and strong marks support at most two nesting levels per kind", node, "topik-mark-nesting");
|
|
1744
|
+
return;
|
|
1745
|
+
}
|
|
1746
|
+
if (node.type === "root" && parent) add("Root nodes cannot be nested", node, "topik-node-invalid");
|
|
1747
|
+
const link = node.type === "link" || node.type === "linkReference";
|
|
1748
|
+
if (link && insideLink) add("Links cannot contain other links", node, "topik-node-invalid");
|
|
1749
|
+
if (node.type === "yaml" && (parent?.type !== "root" || parent.children?.[0] !== node)) add("YAML frontmatter must be the first root child", node, "topik-frontmatter-placement");
|
|
1750
|
+
if (node.type === "paragraph" && !node.children?.length) add("Empty paragraphs have no faithful Markdown representation", node, "topik-empty-paragraph");
|
|
1751
|
+
if (node.type === "topikVariable") {
|
|
1752
|
+
if (!validPath(node.path)) add("Invalid variable path", node, "topik-variable-path");
|
|
1753
|
+
if (!parent || !isInlineParent(parent, registry)) add("Variable has invalid placement", node, "topik-variable-placement");
|
|
1754
|
+
}
|
|
1755
|
+
if (node.type === "topikCodeTemplate") {
|
|
1756
|
+
const invalid = codeTemplateProblem(node, templateData);
|
|
1757
|
+
if (invalid) add(invalid, node);
|
|
1758
|
+
}
|
|
1759
|
+
if (node.type === "topikConditional") {
|
|
1760
|
+
const branches = node.children ?? [];
|
|
1761
|
+
if (!parent || !(parent.type === "root" || parent.type === "blockquote" || parent.type === "listItem" || parent.type === "topikBranch" || parent.type === "topikComponent" && registry[parent.name]?.children === "blocks")) add("Conditional has invalid block placement", node, "topik-conditional-placement");
|
|
1762
|
+
if (branches.length < 1 || branches.length > 2 || branches[0]?.type !== "topikBranch" || branches[0]?.condition == null || branches[1] && (branches[1].type !== "topikBranch" || branches[1].condition !== null)) add("Invalid conditional branches", node, "topik-conditional-branches");
|
|
1763
|
+
}
|
|
1764
|
+
if (node.type === "topikBranch") {
|
|
1765
|
+
if (parent?.type !== "topikConditional") add("Branch must be inside a conditional", node, "topik-conditional-branches");
|
|
1766
|
+
const branch = node;
|
|
1767
|
+
if (branch.condition !== null) try {
|
|
1768
|
+
writeExpression(branch.condition);
|
|
1769
|
+
} catch (error) {
|
|
1770
|
+
add(error.message, node, error instanceof ContentLimitError ? error.id : "topik-expression-invalid");
|
|
1771
|
+
}
|
|
1772
|
+
if ((node.children ?? []).some((child) => !isContentKind(child, "block", registry))) add("Conditional branches may contain only block children", node, "topik-conditional-children");
|
|
1773
|
+
}
|
|
1774
|
+
const errorsBeforeChildren = errors.length;
|
|
1775
|
+
for (const child of node.children ?? []) walk(child, node, insideLink || link, marks);
|
|
1776
|
+
const childrenValid = errors.length === errorsBeforeChildren;
|
|
1777
|
+
if (node.type === "topikCodePresentation" && childrenValid) {
|
|
1778
|
+
const invalid = codePresentationProblem(node, presentationData);
|
|
1779
|
+
if (invalid) add(invalid, node);
|
|
1780
|
+
}
|
|
1781
|
+
if (node.type === "topikComponent") {
|
|
1782
|
+
const component = node;
|
|
1783
|
+
const invalid = validateTag({
|
|
1784
|
+
name: component.name,
|
|
1785
|
+
close: false,
|
|
1786
|
+
selfClosing: registry[component.name]?.children === "none",
|
|
1787
|
+
props: component.props
|
|
1788
|
+
}, registry, templateData);
|
|
1789
|
+
if (invalid) add(invalid, node);
|
|
1790
|
+
for (const problem of [...validateChildren(component, registry), ...validatePlacement(component, parent, registry)]) add(problem, node);
|
|
1791
|
+
const definition = registry[component.name];
|
|
1792
|
+
if (definition?.validate && !invalid && childrenValid) {
|
|
1793
|
+
const invoke = () => definition.validate(component, getEffectiveProps(component, registry));
|
|
1794
|
+
if (definition.validate === components[component.name]?.validate) for (const problem of invoke()) add(problem, node);
|
|
1795
|
+
else callbacks.push({
|
|
1796
|
+
node,
|
|
1797
|
+
invoke
|
|
1798
|
+
});
|
|
1799
|
+
}
|
|
1800
|
+
}
|
|
1801
|
+
}
|
|
1802
|
+
walk(document);
|
|
1803
|
+
if (errors.length === 0) try {
|
|
1804
|
+
assertCodePresentationOutputLimit(document);
|
|
1805
|
+
} catch (error) {
|
|
1806
|
+
if (!(error instanceof ContentLimitError)) throw error;
|
|
1807
|
+
errors.push(limitDiagnostic(error));
|
|
1808
|
+
}
|
|
1809
|
+
if (errors.length === 0) {
|
|
1810
|
+
const invalidSubtrees = /* @__PURE__ */ new Set();
|
|
1811
|
+
for (const { node, invoke } of callbacks) {
|
|
1812
|
+
if (invalidSubtrees.has(node)) continue;
|
|
1813
|
+
const problems = invoke();
|
|
1814
|
+
for (const problem of problems) add(problem, node);
|
|
1815
|
+
if (problems.length) {
|
|
1816
|
+
let ancestor = node;
|
|
1817
|
+
while (ancestor) {
|
|
1818
|
+
invalidSubtrees.add(ancestor);
|
|
1819
|
+
ancestor = parents.get(ancestor);
|
|
1820
|
+
}
|
|
1821
|
+
}
|
|
1822
|
+
}
|
|
1823
|
+
}
|
|
1824
|
+
let index;
|
|
1825
|
+
if (errors.length === 0) {
|
|
1826
|
+
index = indexDocument(document);
|
|
1827
|
+
for (const { node } of index.entries) {
|
|
1828
|
+
if (isReference(node)) {
|
|
1829
|
+
if (!index.resolve(node)) add("Reference has no definition in its conditional scope", node, "topik-reference-missing");
|
|
1830
|
+
}
|
|
1831
|
+
if (isReference(node) || node.type === "definition") {
|
|
1832
|
+
const label = referenceSourceLabel(node);
|
|
1833
|
+
if (!label || label.length > 999 || !/^(?:\\[\s\S]|[^[\]\\])+$/u.test(label)) add("Reference identifier has no faithful Markdown label", node, "topik-reference-label");
|
|
1834
|
+
}
|
|
1835
|
+
}
|
|
1836
|
+
}
|
|
1837
|
+
return {
|
|
1838
|
+
diagnostics: errors,
|
|
1839
|
+
index
|
|
1840
|
+
};
|
|
1841
|
+
}
|
|
1842
|
+
//#endregion
|
|
1843
|
+
//#region src/document-meaning.ts
|
|
1844
|
+
/** Compare admitted authoring trees, retaining explicit heading IDs and every authored branch.
|
|
1845
|
+
* Source positions, cosmetic spelling and derived metadata do not affect equality.
|
|
1846
|
+
* This compares meaning; it does not validate arbitrary caller-constructed trees.
|
|
1847
|
+
*/
|
|
1848
|
+
function sameDocumentMeaning(left, right) {
|
|
1849
|
+
return equal(meaning(left), meaning(right));
|
|
1850
|
+
}
|
|
1851
|
+
function meaning(node) {
|
|
1852
|
+
const fields = { ...node };
|
|
1853
|
+
delete fields.position;
|
|
1854
|
+
delete fields.data;
|
|
1855
|
+
delete fields.children;
|
|
1856
|
+
for (const key of Object.keys(fields)) if (fields[key] === void 0) delete fields[key];
|
|
1857
|
+
if (node.type === "heading") fields.topikId = node.data?.topikId ?? null;
|
|
1858
|
+
if (node.type === "link" || node.type === "image" || node.type === "definition") fields.title ??= null;
|
|
1859
|
+
if (node.type === "image" || node.type === "imageReference") fields.alt ??= "";
|
|
1860
|
+
if (node.type === "code" || node.type === "topikCodeTemplate") {
|
|
1861
|
+
fields.lang ??= null;
|
|
1862
|
+
fields.meta ??= null;
|
|
1863
|
+
}
|
|
1864
|
+
if (node.type === "topikCodeTemplate") fields.template = templateMeaning(node.template);
|
|
1865
|
+
if (node.type === "topikCodePresentation") fields.options = normalizeCodePresentationOptions(node.options, codePresentationChildRows(node.children[0]));
|
|
1866
|
+
if (node.type === "topikComponent" && node.props) fields.props = Object.fromEntries(Object.entries(node.props).map(([key, value]) => [key, typeof value === "object" ? templateMeaning(value) : value]));
|
|
1867
|
+
if ([
|
|
1868
|
+
"definition",
|
|
1869
|
+
"linkReference",
|
|
1870
|
+
"imageReference"
|
|
1871
|
+
].includes(node.type)) {
|
|
1872
|
+
fields.identifier = referenceIdentifier(fields.identifier);
|
|
1873
|
+
delete fields.label;
|
|
1874
|
+
delete fields.referenceType;
|
|
1875
|
+
}
|
|
1876
|
+
if (node.type === "list") {
|
|
1877
|
+
fields.ordered ??= false;
|
|
1878
|
+
fields.start = fields.ordered ? fields.start ?? 1 : null;
|
|
1879
|
+
fields.spread = fields.spread === true || (node.children ?? []).some((item) => {
|
|
1880
|
+
return item.spread ?? (item.children?.length ?? 0) > 1;
|
|
1881
|
+
});
|
|
1882
|
+
}
|
|
1883
|
+
if (node.type === "listItem") {
|
|
1884
|
+
delete fields.spread;
|
|
1885
|
+
fields.checked ??= null;
|
|
1886
|
+
}
|
|
1887
|
+
if (node.type === "table") fields.align ??= Array.from({ length: node.children?.[0]?.children?.length ?? 0 }, () => null);
|
|
1888
|
+
if (node.children) {
|
|
1889
|
+
const children = [];
|
|
1890
|
+
for (const child of node.children) {
|
|
1891
|
+
const next = meaning(child);
|
|
1892
|
+
const previous = children.at(-1);
|
|
1893
|
+
if (previous?.type === "text" && next.type === "text") previous.value = String(previous.value) + String(next.value);
|
|
1894
|
+
else children.push(next);
|
|
1895
|
+
}
|
|
1896
|
+
fields.children = children;
|
|
1897
|
+
}
|
|
1898
|
+
return fields;
|
|
1899
|
+
}
|
|
1900
|
+
function templateMeaning(template) {
|
|
1901
|
+
const segments = [];
|
|
1902
|
+
for (const segment of template.segments) if (segment.type === "literal") {
|
|
1903
|
+
if (!segment.value) continue;
|
|
1904
|
+
const previous = segments.at(-1);
|
|
1905
|
+
if (previous?.type === "literal") previous.value += segment.value;
|
|
1906
|
+
else segments.push({
|
|
1907
|
+
type: "literal",
|
|
1908
|
+
value: segment.value
|
|
1909
|
+
});
|
|
1910
|
+
} else segments.push({
|
|
1911
|
+
type: "variable",
|
|
1912
|
+
path: segment.path
|
|
1913
|
+
});
|
|
1914
|
+
return {
|
|
1915
|
+
type: "topikTextTemplate",
|
|
1916
|
+
segments
|
|
1917
|
+
};
|
|
1918
|
+
}
|
|
1919
|
+
function equal(left, right) {
|
|
1920
|
+
if (Object.is(left, right)) return true;
|
|
1921
|
+
if (!left || !right || typeof left !== "object" || typeof right !== "object") return false;
|
|
1922
|
+
if (Array.isArray(left) || Array.isArray(right)) return Array.isArray(left) && Array.isArray(right) && left.length === right.length && left.every((value, index) => equal(value, right[index]));
|
|
1923
|
+
const a = left;
|
|
1924
|
+
const b = right;
|
|
1925
|
+
const keys = Object.keys(a);
|
|
1926
|
+
return keys.length === Object.keys(b).length && keys.every((key) => Object.hasOwn(b, key) && equal(a[key], b[key]));
|
|
1927
|
+
}
|
|
1928
|
+
//#endregion
|
|
1929
|
+
//#region src/tag-options.ts
|
|
1930
|
+
/** Parser and writer share the exact registry attribute admission. */
|
|
1931
|
+
function tagOptions(registry) {
|
|
1932
|
+
return {
|
|
1933
|
+
expressions: true,
|
|
1934
|
+
escapedWhitespace: Object.fromEntries(Object.entries(registry).map(([name, definition]) => [name, Object.entries(definition.attributes).filter(([, attribute]) => attribute.type === "string" && attribute.escapedWhitespace).map(([key]) => key)]))
|
|
1935
|
+
};
|
|
1936
|
+
}
|
|
1937
|
+
//#endregion
|
|
1938
|
+
//#region src/gfm.ts
|
|
1939
|
+
function gfmSyntax() {
|
|
1940
|
+
return [
|
|
1941
|
+
gfmFootnote(),
|
|
1942
|
+
gfmStrikethrough(),
|
|
1943
|
+
gfmTable(),
|
|
1944
|
+
gfmTaskListItem()
|
|
1945
|
+
];
|
|
1946
|
+
}
|
|
1947
|
+
function gfmFromMarkdown() {
|
|
1948
|
+
return [
|
|
1949
|
+
gfmFootnoteFromMarkdown(),
|
|
1950
|
+
gfmStrikethroughFromMarkdown(),
|
|
1951
|
+
gfmTableFromMarkdown(),
|
|
1952
|
+
gfmTaskListItemFromMarkdown()
|
|
1953
|
+
];
|
|
1954
|
+
}
|
|
1955
|
+
function gfmToMarkdown() {
|
|
1956
|
+
return { extensions: [
|
|
1957
|
+
gfmFootnoteToMarkdown(),
|
|
1958
|
+
gfmStrikethroughToMarkdown(),
|
|
1959
|
+
gfmTableToMarkdown(),
|
|
1960
|
+
gfmTaskListItemToMarkdown()
|
|
1961
|
+
] };
|
|
1962
|
+
}
|
|
1963
|
+
//#endregion
|
|
1964
|
+
//#region src/heading-ids.ts
|
|
1965
|
+
const validId = /^[A-Za-z0-9_.:-]+$/;
|
|
1966
|
+
/** An independent inline construct, claimed before generic tag tokenization. */
|
|
1967
|
+
function headingIdSyntax() {
|
|
1968
|
+
const tokenize = (effects, ok, nok) => {
|
|
1969
|
+
let value = "";
|
|
1970
|
+
return start;
|
|
1971
|
+
function start(code) {
|
|
1972
|
+
if (code !== 123) return nok(code);
|
|
1973
|
+
effects.enter("topikHeadingId");
|
|
1974
|
+
effects.consume(code);
|
|
1975
|
+
return percent;
|
|
1976
|
+
}
|
|
1977
|
+
function percent(code) {
|
|
1978
|
+
if (code !== 37) return nok(code);
|
|
1979
|
+
effects.consume(code);
|
|
1980
|
+
return hash;
|
|
1981
|
+
}
|
|
1982
|
+
function hash(code) {
|
|
1983
|
+
if (code === 32 || code === -2 || code === -1) {
|
|
1984
|
+
effects.consume(code);
|
|
1985
|
+
return hash;
|
|
1986
|
+
}
|
|
1987
|
+
if (code !== 35) return nok(code);
|
|
1988
|
+
effects.consume(code);
|
|
1989
|
+
return id;
|
|
1990
|
+
}
|
|
1991
|
+
function id(code) {
|
|
1992
|
+
if (code !== null && code > 0 && /^[A-Za-z0-9_.:-]$/.test(String.fromCharCode(code))) {
|
|
1993
|
+
value += String.fromCharCode(code);
|
|
1994
|
+
effects.consume(code);
|
|
1995
|
+
return id;
|
|
1996
|
+
}
|
|
1997
|
+
if (!value) return nok(code);
|
|
1998
|
+
return closePercent(code);
|
|
1999
|
+
}
|
|
2000
|
+
function closePercent(code) {
|
|
2001
|
+
if (code === 32 || code === -2 || code === -1) {
|
|
2002
|
+
effects.consume(code);
|
|
2003
|
+
return closePercent;
|
|
2004
|
+
}
|
|
2005
|
+
if (code !== 37) return nok(code);
|
|
2006
|
+
effects.consume(code);
|
|
2007
|
+
return close;
|
|
2008
|
+
}
|
|
2009
|
+
function close(code) {
|
|
2010
|
+
if (code !== 125) return nok(code);
|
|
2011
|
+
effects.consume(code);
|
|
2012
|
+
effects.exit("topikHeadingId");
|
|
2013
|
+
return ok;
|
|
2014
|
+
}
|
|
2015
|
+
};
|
|
2016
|
+
return { text: { 123: { tokenize } } };
|
|
2017
|
+
}
|
|
2018
|
+
function headingIdFromMarkdown() {
|
|
2019
|
+
return {
|
|
2020
|
+
enter: { topikHeadingId(token) {
|
|
2021
|
+
const value = this.sliceSerialize(token).slice(2, -2).trim().slice(1);
|
|
2022
|
+
this.enter({
|
|
2023
|
+
type: "topikHeadingId",
|
|
2024
|
+
value
|
|
2025
|
+
}, token);
|
|
2026
|
+
} },
|
|
2027
|
+
exit: { topikHeadingId(token) {
|
|
2028
|
+
this.exit(token);
|
|
2029
|
+
} },
|
|
2030
|
+
transforms: [(root) => {
|
|
2031
|
+
function walk(node) {
|
|
2032
|
+
for (const child of node.children ?? []) walk(child);
|
|
2033
|
+
if (node.type !== "heading") return;
|
|
2034
|
+
const marker = node.children?.at(-1);
|
|
2035
|
+
if (marker?.type !== "topikHeadingId") return;
|
|
2036
|
+
const heading = node;
|
|
2037
|
+
heading.data = {
|
|
2038
|
+
...heading.data,
|
|
2039
|
+
topikId: marker.value
|
|
2040
|
+
};
|
|
2041
|
+
heading.children.pop();
|
|
2042
|
+
const preceding = heading.children.at(-1);
|
|
2043
|
+
if (preceding?.type === "text") {
|
|
2044
|
+
preceding.value = preceding.value.replace(/[ \t]+$/, "");
|
|
2045
|
+
if (!preceding.value) heading.children.pop();
|
|
2046
|
+
}
|
|
2047
|
+
}
|
|
2048
|
+
walk(root);
|
|
2049
|
+
return root;
|
|
2050
|
+
}]
|
|
2051
|
+
};
|
|
2052
|
+
}
|
|
2053
|
+
function headingIdToMarkdown() {
|
|
2054
|
+
return { handlers: {
|
|
2055
|
+
heading(node, parent, state, info) {
|
|
2056
|
+
const id = node.data?.topikId;
|
|
2057
|
+
if (id === void 0) return defaultHandlers.heading(node, parent, state, info);
|
|
2058
|
+
if (typeof id !== "string" || !validId.test(id)) throw new Error("Invalid heading ID");
|
|
2059
|
+
const children = [...node.children];
|
|
2060
|
+
if (children.length) children.push({
|
|
2061
|
+
type: "text",
|
|
2062
|
+
value: " "
|
|
2063
|
+
});
|
|
2064
|
+
children.push({
|
|
2065
|
+
type: "topikHeadingId",
|
|
2066
|
+
value: id
|
|
2067
|
+
});
|
|
2068
|
+
return defaultHandlers.heading({
|
|
2069
|
+
...node,
|
|
2070
|
+
children
|
|
2071
|
+
}, parent, state, info);
|
|
2072
|
+
},
|
|
2073
|
+
topikHeadingId(node) {
|
|
2074
|
+
return `{% #${node.value} %}`;
|
|
2075
|
+
}
|
|
2076
|
+
} };
|
|
2077
|
+
}
|
|
2078
|
+
//#endregion
|
|
2079
|
+
//#region src/reference-source.ts
|
|
2080
|
+
const references = /* @__PURE__ */ new WeakMap();
|
|
2081
|
+
function referenceSourceExtension() {
|
|
2082
|
+
return { enter: {
|
|
2083
|
+
resourceDestinationString(token) {
|
|
2084
|
+
references.set(this.stack.at(-1), this.sliceSerialize(token));
|
|
2085
|
+
this.buffer();
|
|
2086
|
+
},
|
|
2087
|
+
definitionDestinationString(token) {
|
|
2088
|
+
references.set(this.stack.at(-1), this.sliceSerialize(token));
|
|
2089
|
+
this.buffer();
|
|
2090
|
+
}
|
|
2091
|
+
} };
|
|
2092
|
+
}
|
|
2093
|
+
function exactReference(node, source) {
|
|
2094
|
+
const recorded = references.get(node);
|
|
2095
|
+
if (recorded !== void 0) return recorded;
|
|
2096
|
+
const start = node.position?.start.offset;
|
|
2097
|
+
const end = node.position?.end.offset;
|
|
2098
|
+
if (start === void 0 || end === void 0) return;
|
|
2099
|
+
const spelling = source.slice(start, end);
|
|
2100
|
+
if (spelling === `<${node.url}>`) return node.url;
|
|
2101
|
+
if (node.url.startsWith("mailto:") && spelling === `<${node.url.slice(7)}>`) return node.url;
|
|
2102
|
+
}
|
|
2103
|
+
function sourceKey(node) {
|
|
2104
|
+
const start = node.position?.start.offset;
|
|
2105
|
+
const end = node.position?.end.offset;
|
|
2106
|
+
if (start !== void 0 && end !== void 0) return `${node.type}:${start}:${end}`;
|
|
2107
|
+
}
|
|
2108
|
+
/** Recover copied nodes' evidence once, using the same parser and original source positions. */
|
|
2109
|
+
function createReferenceSourceLookup(source, reparse) {
|
|
2110
|
+
let recovered;
|
|
2111
|
+
return (node) => {
|
|
2112
|
+
const recorded = exactReference(node, source);
|
|
2113
|
+
if (recorded !== void 0) return recorded;
|
|
2114
|
+
const key = sourceKey(node);
|
|
2115
|
+
if (key === void 0) return "";
|
|
2116
|
+
if (recovered === void 0) {
|
|
2117
|
+
recovered = /* @__PURE__ */ new Map();
|
|
2118
|
+
try {
|
|
2119
|
+
for (const { node: original } of reparse().entries) {
|
|
2120
|
+
if (!("url" in original) || typeof original.url !== "string") continue;
|
|
2121
|
+
const key = sourceKey(original);
|
|
2122
|
+
const reference = exactReference(original, source);
|
|
2123
|
+
if (key !== void 0 && reference !== void 0) recovered.set(key, {
|
|
2124
|
+
url: original.url,
|
|
2125
|
+
reference
|
|
2126
|
+
});
|
|
2127
|
+
}
|
|
2128
|
+
} catch {}
|
|
2129
|
+
}
|
|
2130
|
+
const match = recovered.get(key);
|
|
2131
|
+
return match?.url === node.url ? match.reference : "";
|
|
2132
|
+
};
|
|
2133
|
+
}
|
|
2134
|
+
//#endregion
|
|
2135
|
+
//#region src/parser-limits.ts
|
|
2136
|
+
/** Refuse expensive syntax before micromark resolves containers and inline delimiters. */
|
|
2137
|
+
function parserLimitSyntax(source) {
|
|
2138
|
+
let steps = 0;
|
|
2139
|
+
const containers = /* @__PURE__ */ new WeakMap();
|
|
2140
|
+
const guard = function(_effects, _ok, nok) {
|
|
2141
|
+
return (code) => {
|
|
2142
|
+
let weight = 1;
|
|
2143
|
+
if (code === 96) {
|
|
2144
|
+
const offset = this.now().offset;
|
|
2145
|
+
while (source[offset + weight] === "`") weight++;
|
|
2146
|
+
}
|
|
2147
|
+
steps += weight;
|
|
2148
|
+
if (steps > CONTENT_LIMITS.parserSteps) throw new ContentLimitError(`Content exceeds the parser step limit of ${CONTENT_LIMITS.parserSteps}`);
|
|
2149
|
+
return nok(code);
|
|
2150
|
+
};
|
|
2151
|
+
};
|
|
2152
|
+
const containerGuard = function(_effects, _ok, nok) {
|
|
2153
|
+
return (code) => {
|
|
2154
|
+
const events = this.events;
|
|
2155
|
+
const previous = containers.get(this);
|
|
2156
|
+
const state = previous && previous.index <= events.length && events[previous.index - 1] === previous.last ? previous : {
|
|
2157
|
+
index: 0,
|
|
2158
|
+
last: void 0,
|
|
2159
|
+
depth: 0
|
|
2160
|
+
};
|
|
2161
|
+
for (; state.index < events.length; state.index++) {
|
|
2162
|
+
const [action, token] = events[state.index];
|
|
2163
|
+
const weight = token.type === "blockQuote" ? 1 : token.type === "listOrdered" || token.type === "listUnordered" ? 2 : 0;
|
|
2164
|
+
state.depth += action === "enter" ? weight : -weight;
|
|
2165
|
+
}
|
|
2166
|
+
state.last = events.at(-1);
|
|
2167
|
+
containers.set(this, state);
|
|
2168
|
+
if (state.depth >= CONTENT_LIMITS.treeDepth) throw new ContentLimitError(`Content exceeds the tree depth limit of ${CONTENT_LIMITS.treeDepth}`);
|
|
2169
|
+
return nok(code);
|
|
2170
|
+
};
|
|
2171
|
+
};
|
|
2172
|
+
return {
|
|
2173
|
+
document: Object.fromEntries("*+->0123456789".split("").map((marker) => [marker.charCodeAt(0), { tokenize: containerGuard }])),
|
|
2174
|
+
text: {
|
|
2175
|
+
...Object.fromEntries("!*[_~".split("").map((marker) => [marker.charCodeAt(0), { tokenize: guard }])),
|
|
2176
|
+
96: {
|
|
2177
|
+
tokenize: guard,
|
|
2178
|
+
previous(code) {
|
|
2179
|
+
return code !== 96 || this.events.at(-1)?.[1].type === "characterEscape";
|
|
2180
|
+
}
|
|
2181
|
+
}
|
|
2182
|
+
}
|
|
2183
|
+
};
|
|
2184
|
+
}
|
|
2185
|
+
//#endregion
|
|
2186
|
+
//#region src/line-endings.ts
|
|
2187
|
+
/** Normalize source syntax, leaving entity-decoded characters and original locations distinct. */
|
|
2188
|
+
function normalizeLineEndings(source) {
|
|
2189
|
+
const crlfOffsets = [];
|
|
2190
|
+
const normalized = source.replace(/\r\n?/g, (ending, offset) => {
|
|
2191
|
+
if (ending.length === 2) crlfOffsets.push(offset - crlfOffsets.length);
|
|
2192
|
+
return "\n";
|
|
2193
|
+
});
|
|
2194
|
+
function originalOffset(offset) {
|
|
2195
|
+
let low = 0;
|
|
2196
|
+
let high = crlfOffsets.length;
|
|
2197
|
+
while (low < high) {
|
|
2198
|
+
const middle = low + high >>> 1;
|
|
2199
|
+
if (crlfOffsets[middle] < offset) low = middle + 1;
|
|
2200
|
+
else high = middle;
|
|
2201
|
+
}
|
|
2202
|
+
return offset + low;
|
|
2203
|
+
}
|
|
2204
|
+
function restorePositions(node) {
|
|
2205
|
+
if (crlfOffsets.length === 0) return;
|
|
2206
|
+
const position = node.position;
|
|
2207
|
+
if (position) node.position = {
|
|
2208
|
+
start: {
|
|
2209
|
+
...position.start,
|
|
2210
|
+
...position.start.offset === void 0 ? {} : { offset: originalOffset(position.start.offset) }
|
|
2211
|
+
},
|
|
2212
|
+
end: {
|
|
2213
|
+
...position.end,
|
|
2214
|
+
...position.end.offset === void 0 ? {} : { offset: originalOffset(position.end.offset) }
|
|
2215
|
+
}
|
|
2216
|
+
};
|
|
2217
|
+
for (const child of node.children ?? []) restorePositions(child);
|
|
2218
|
+
}
|
|
2219
|
+
return {
|
|
2220
|
+
source: normalized,
|
|
2221
|
+
restorePositions
|
|
2222
|
+
};
|
|
2223
|
+
}
|
|
2224
|
+
//#endregion
|
|
2225
|
+
//#region src/parser.ts
|
|
2226
|
+
function declarations(registry) {
|
|
2227
|
+
return Object.fromEntries(Object.entries(registry).map(([name, definition]) => [name, { kind: definition.kind }]));
|
|
2228
|
+
}
|
|
2229
|
+
function convertTags(root, registry, rawHeaders) {
|
|
2230
|
+
const errors = [];
|
|
2231
|
+
let presentationFailed = false;
|
|
2232
|
+
function walk(node) {
|
|
2233
|
+
if (!node.children) return;
|
|
2234
|
+
node.children = node.children.map((originalChild) => {
|
|
2235
|
+
let child = originalChild;
|
|
2236
|
+
walk(child);
|
|
2237
|
+
if (child.type === "tagVariable") return {
|
|
2238
|
+
type: "topikVariable",
|
|
2239
|
+
path: String(child.path).split("."),
|
|
2240
|
+
position: child.position
|
|
2241
|
+
};
|
|
2242
|
+
if (child.type === "tagBranch") {
|
|
2243
|
+
if (typeof child.condition === "string") try {
|
|
2244
|
+
child.condition = parseExpression(child.condition);
|
|
2245
|
+
} catch (error) {
|
|
2246
|
+
if (error instanceof ContentLimitError) throw error;
|
|
2247
|
+
errors.push(diagnostic(error.message, child));
|
|
2248
|
+
}
|
|
2249
|
+
return {
|
|
2250
|
+
...child,
|
|
2251
|
+
type: "topikBranch"
|
|
2252
|
+
};
|
|
2253
|
+
}
|
|
2254
|
+
if (child.type === "tagConditional") return {
|
|
2255
|
+
...child,
|
|
2256
|
+
type: "topikConditional"
|
|
2257
|
+
};
|
|
2258
|
+
if (child.type === "tagCodeTemplate") child = {
|
|
2259
|
+
type: "topikCodeTemplate",
|
|
2260
|
+
lang: child.lang,
|
|
2261
|
+
meta: child.meta,
|
|
2262
|
+
template: child.template,
|
|
2263
|
+
position: child.position
|
|
2264
|
+
};
|
|
2265
|
+
if (child.type === "code" || child.type === "topikCodeTemplate") {
|
|
2266
|
+
const rawHeader = rawHeaders?.get(originalChild);
|
|
2267
|
+
if (rawHeader === void 0) return child;
|
|
2268
|
+
const unlabelled = /^(?:(?:lines|wrap)(?:[ \t=]|$)|(?:filename|title|startLine|highlight|focus|collapse|added|removed)[ \t]*=)/.test(rawHeader);
|
|
2269
|
+
const rawMeta = unlabelled ? rawHeader : rawHeader.replace(/^[^ \t]+[ \t]*/, "");
|
|
2270
|
+
try {
|
|
2271
|
+
const presentation = parseCodePresentationMetadata(rawMeta, codePresentationChildRows(child));
|
|
2272
|
+
if (!presentation) return child;
|
|
2273
|
+
if (unlabelled) child.lang = null;
|
|
2274
|
+
delete child.meta;
|
|
2275
|
+
return {
|
|
2276
|
+
type: "topikCodePresentation",
|
|
2277
|
+
...presentation,
|
|
2278
|
+
children: [child],
|
|
2279
|
+
position: child.position
|
|
2280
|
+
};
|
|
2281
|
+
} catch (error) {
|
|
2282
|
+
if (!(error instanceof CodePresentationError)) throw error;
|
|
2283
|
+
presentationFailed = true;
|
|
2284
|
+
errors.push(diagnostic({
|
|
2285
|
+
id: error.id,
|
|
2286
|
+
message: error.message,
|
|
2287
|
+
...error.option ? { option: error.option } : {}
|
|
2288
|
+
}, child));
|
|
2289
|
+
return child;
|
|
2290
|
+
}
|
|
2291
|
+
}
|
|
2292
|
+
if (![
|
|
2293
|
+
"tagText",
|
|
2294
|
+
"tagLeaf",
|
|
2295
|
+
"tagContainer"
|
|
2296
|
+
].includes(child.type)) return child;
|
|
2297
|
+
const props = {};
|
|
2298
|
+
for (const [key, value] of Object.entries(child.attributes ?? {})) if (value !== null && value !== void 0) props[key] = value;
|
|
2299
|
+
const definition = child.name && Object.hasOwn(registry, child.name) ? registry[child.name] : void 0;
|
|
2300
|
+
const invalid = validateTag({
|
|
2301
|
+
name: child.name ?? "",
|
|
2302
|
+
close: false,
|
|
2303
|
+
selfClosing: child.type === "tagLeaf" || child.type === "tagText" && definition?.children === "none",
|
|
2304
|
+
props
|
|
2305
|
+
}, registry);
|
|
2306
|
+
if (invalid) errors.push(diagnostic(invalid, child));
|
|
2307
|
+
if (child.type === "tagLeaf" && child.children?.length) errors.push(diagnostic(`Leaf component ${child.name} cannot contain children`, child));
|
|
2308
|
+
return {
|
|
2309
|
+
type: "topikComponent",
|
|
2310
|
+
name: child.name,
|
|
2311
|
+
props,
|
|
2312
|
+
children: child.children ?? [],
|
|
2313
|
+
position: child.position
|
|
2314
|
+
};
|
|
2315
|
+
});
|
|
2316
|
+
}
|
|
2317
|
+
walk(root);
|
|
2318
|
+
if (presentationFailed) throw new TagSyntaxError(errors);
|
|
2319
|
+
return errors;
|
|
2320
|
+
}
|
|
2321
|
+
const FORMAT_VERSION = 1;
|
|
2322
|
+
/** Parse and convert even when semantic validation fails; syntax failures omit the tree. */
|
|
2323
|
+
function parseDocumentTree(source, registry = components, formatVersion = 1) {
|
|
2324
|
+
if (formatVersion !== 1) return {
|
|
2325
|
+
diagnostics: [{
|
|
2326
|
+
id: "topik-format-version",
|
|
2327
|
+
message: `Unsupported format version ${formatVersion}`
|
|
2328
|
+
}],
|
|
2329
|
+
source
|
|
2330
|
+
};
|
|
2331
|
+
let tree;
|
|
2332
|
+
try {
|
|
2333
|
+
assertSourceLimit(source);
|
|
2334
|
+
const normalized = normalizeLineEndings(source);
|
|
2335
|
+
const rawHeaders = /* @__PURE__ */ new WeakMap();
|
|
2336
|
+
const syntaxOptions = {
|
|
2337
|
+
...tagOptions(registry),
|
|
2338
|
+
fencedCodeHeaders: rawHeaders
|
|
2339
|
+
};
|
|
2340
|
+
tree = fromMarkdown(normalized.source, {
|
|
2341
|
+
extensions: [
|
|
2342
|
+
frontmatter(),
|
|
2343
|
+
...gfmSyntax(),
|
|
2344
|
+
tagSyntax(declarations(registry), syntaxOptions),
|
|
2345
|
+
headingIdSyntax(),
|
|
2346
|
+
parserLimitSyntax(normalized.source)
|
|
2347
|
+
],
|
|
2348
|
+
mdastExtensions: [
|
|
2349
|
+
{ transforms: [(root) => {
|
|
2350
|
+
assertTreeLimits(root);
|
|
2351
|
+
return root;
|
|
2352
|
+
}] },
|
|
2353
|
+
...gfmFromMarkdown(),
|
|
2354
|
+
frontmatterFromMarkdown(),
|
|
2355
|
+
tagFromMarkdown(declarations(registry), syntaxOptions),
|
|
2356
|
+
{ transforms: [(root) => {
|
|
2357
|
+
assertTreeLimits(root);
|
|
2358
|
+
return root;
|
|
2359
|
+
}] },
|
|
2360
|
+
referenceSourceExtension(),
|
|
2361
|
+
headingIdFromMarkdown(),
|
|
2362
|
+
{ exit: {
|
|
2363
|
+
resourceTitle() {
|
|
2364
|
+
const node = this.stack.at(-1);
|
|
2365
|
+
if (node.title === null) node.title = "";
|
|
2366
|
+
},
|
|
2367
|
+
definitionTitle() {
|
|
2368
|
+
const node = this.stack.at(-1);
|
|
2369
|
+
if (node.title === null) node.title = "";
|
|
2370
|
+
}
|
|
2371
|
+
} }
|
|
2372
|
+
]
|
|
2373
|
+
});
|
|
2374
|
+
const nodeCount = assertTreeLimits(tree);
|
|
2375
|
+
normalized.restorePositions(tree);
|
|
2376
|
+
const tagErrors = convertTags(tree, registry, rawHeaders);
|
|
2377
|
+
normalizeTables(tree, CONTENT_LIMITS.treeNodes - nodeCount);
|
|
2378
|
+
const validated = validateDocumentWithIndex(tree, registry);
|
|
2379
|
+
const errors = tagErrors.concat(validated.diagnostics);
|
|
2380
|
+
return {
|
|
2381
|
+
document: tree,
|
|
2382
|
+
index: validated.index,
|
|
2383
|
+
diagnostics: errors,
|
|
2384
|
+
source
|
|
2385
|
+
};
|
|
2386
|
+
} catch (error) {
|
|
2387
|
+
if (error instanceof ContentLimitError) return {
|
|
2388
|
+
diagnostics: [limitDiagnostic(error)],
|
|
2389
|
+
source
|
|
2390
|
+
};
|
|
2391
|
+
if (error instanceof TagSyntaxError) return {
|
|
2392
|
+
diagnostics: error.diagnostics.map((item) => ({
|
|
2393
|
+
...item,
|
|
2394
|
+
..."id" in item && item.id === "tag-content-limit" ? { id: "topik-content-limit" } : {},
|
|
2395
|
+
...item.message.startsWith("Unknown tag ") ? { id: "tag-undefined" } : {},
|
|
2396
|
+
message: item.message.replace(/^Unknown tag /, "Unknown component ")
|
|
2397
|
+
})),
|
|
2398
|
+
source
|
|
2399
|
+
};
|
|
2400
|
+
throw error;
|
|
2401
|
+
}
|
|
2402
|
+
}
|
|
2403
|
+
/** GFM missing cells mean empty cells; overflow is retained for an explicit diagnostic. */
|
|
2404
|
+
function normalizeTables(root, remainingNodes) {
|
|
2405
|
+
function walk(node) {
|
|
2406
|
+
if (node.type === "table") {
|
|
2407
|
+
const width = node.children?.[0]?.children?.length ?? 0;
|
|
2408
|
+
for (const row of node.children ?? []) {
|
|
2409
|
+
if (!row.children) continue;
|
|
2410
|
+
const missing = Math.max(0, width - row.children.length);
|
|
2411
|
+
if (missing > remainingNodes) throw new ContentLimitError(`Content exceeds the tree node limit of ${CONTENT_LIMITS.treeNodes}`);
|
|
2412
|
+
remainingNodes -= missing;
|
|
2413
|
+
while (row.children.length < width) row.children.push({
|
|
2414
|
+
type: "tableCell",
|
|
2415
|
+
children: []
|
|
2416
|
+
});
|
|
2417
|
+
}
|
|
2418
|
+
}
|
|
2419
|
+
for (const child of node.children ?? []) walk(child);
|
|
2420
|
+
}
|
|
2421
|
+
walk(root);
|
|
2422
|
+
}
|
|
2423
|
+
//#endregion
|
|
2424
|
+
//#region src/markdown-marks.ts
|
|
2425
|
+
/** Choose delimiters before writing so handlers and their lookahead agree. */
|
|
2426
|
+
function markWriterExtension(root) {
|
|
2427
|
+
const markers = /* @__PURE__ */ new WeakMap();
|
|
2428
|
+
function walk(node, active = {}) {
|
|
2429
|
+
let previous;
|
|
2430
|
+
for (const child of node.children ?? []) {
|
|
2431
|
+
const kind = child.type === "emphasis" || child.type === "strong" ? child.type : void 0;
|
|
2432
|
+
const marker = (previous ?? (kind ? active[kind] : void 0) ?? (kind === "emphasis" && node.type === "strong" && node.children?.length === 1 ? markers.get(node) : void 0)) === "*" ? "_" : "*";
|
|
2433
|
+
if (kind) markers.set(child, marker);
|
|
2434
|
+
walk(child, kind ? {
|
|
2435
|
+
...active,
|
|
2436
|
+
[kind]: marker
|
|
2437
|
+
} : active);
|
|
2438
|
+
previous = kind ? marker : void 0;
|
|
2439
|
+
}
|
|
2440
|
+
}
|
|
2441
|
+
walk(root);
|
|
2442
|
+
function handler(kind) {
|
|
2443
|
+
const attention = defaultHandlers[kind].attention;
|
|
2444
|
+
const handle = (node, parent, state, info) => {
|
|
2445
|
+
const previous = state.options[kind];
|
|
2446
|
+
state.options[kind] = markers.get(node) ?? "*";
|
|
2447
|
+
try {
|
|
2448
|
+
return defaultHandlers[kind](node, parent, state, info);
|
|
2449
|
+
} finally {
|
|
2450
|
+
state.options[kind] = previous;
|
|
2451
|
+
}
|
|
2452
|
+
};
|
|
2453
|
+
const peek = (node) => markers.get(node) ?? "*";
|
|
2454
|
+
return Object.assign(handle, {
|
|
2455
|
+
peek,
|
|
2456
|
+
...attention && { attention: (node, state) => {
|
|
2457
|
+
const previous = state.options[kind];
|
|
2458
|
+
state.options[kind] = markers.get(node) ?? "*";
|
|
2459
|
+
try {
|
|
2460
|
+
return attention(node, state);
|
|
2461
|
+
} finally {
|
|
2462
|
+
state.options[kind] = previous;
|
|
2463
|
+
}
|
|
2464
|
+
} }
|
|
2465
|
+
});
|
|
2466
|
+
}
|
|
2467
|
+
return { handlers: {
|
|
2468
|
+
emphasis: handler("emphasis"),
|
|
2469
|
+
strong: handler("strong")
|
|
2470
|
+
} };
|
|
2471
|
+
}
|
|
2472
|
+
//#endregion
|
|
2473
|
+
//#region src/reference-writer.ts
|
|
2474
|
+
/** Escapes and entities are part of reference identity, not decoded Markdown text. */
|
|
2475
|
+
function referenceLabel(node, state) {
|
|
2476
|
+
const value = referenceSourceLabel(node);
|
|
2477
|
+
return state.stack.includes("tableCell") ? value.replaceAll("|", "\\|") : value;
|
|
2478
|
+
}
|
|
2479
|
+
function referenceWriterExtension() {
|
|
2480
|
+
const reference = (untyped, _parent, state, info) => {
|
|
2481
|
+
const node = untyped;
|
|
2482
|
+
const exit = state.enter(node.type);
|
|
2483
|
+
const labelExit = state.enter("label");
|
|
2484
|
+
const tracker = state.createTracker(info);
|
|
2485
|
+
const prefix = tracker.move(node.type === "imageReference" ? "![" : "[");
|
|
2486
|
+
const context = {
|
|
2487
|
+
before: prefix,
|
|
2488
|
+
after: "]",
|
|
2489
|
+
...tracker.current()
|
|
2490
|
+
};
|
|
2491
|
+
const text = node.type === "imageReference" ? state.safe(node.alt ?? "", context) : state.containerPhrasing(node, context);
|
|
2492
|
+
labelExit();
|
|
2493
|
+
const label = referenceLabel(node, state);
|
|
2494
|
+
exit();
|
|
2495
|
+
return `${prefix}${text}]${node.referenceType === "full" || !text || text !== label ? `[${label}]` : node.referenceType === "collapsed" ? "[]" : ""}`;
|
|
2496
|
+
};
|
|
2497
|
+
return { handlers: {
|
|
2498
|
+
linkReference: Object.assign(reference.bind(null), { peek: () => "[" }),
|
|
2499
|
+
imageReference: Object.assign(reference.bind(null), { peek: () => "!" })
|
|
2500
|
+
} };
|
|
2501
|
+
}
|
|
2502
|
+
//#endregion
|
|
2503
|
+
//#region src/resource-writer.ts
|
|
2504
|
+
const entity = /&(?:#(?:\d{1,7}|x[\da-f]{1,6})|[\da-z]{1,31});/giu;
|
|
2505
|
+
/** Escape Markdown syntax, without changing the spelling of ordinary URL bytes. */
|
|
2506
|
+
function destination(url, state) {
|
|
2507
|
+
const literal = !url || /[()<>\s]/u.test(url);
|
|
2508
|
+
const escaped = url.replaceAll("\\", "\\\\").replace(entity, (value) => decodeString(value) === value ? value : `\\${value}`).replace(/[<>\n\r]/gu, (value) => value === "\n" ? "
" : value === "\r" ? "
" : `\\${value}`);
|
|
2509
|
+
const tableSafe = state.stack.includes("tableCell") ? escaped.replaceAll("|", "\\|") : escaped;
|
|
2510
|
+
return literal ? `<${tableSafe}>` : tableSafe;
|
|
2511
|
+
}
|
|
2512
|
+
function title(value, state) {
|
|
2513
|
+
if (value == null) return "";
|
|
2514
|
+
const exit = state.enter("titleQuote");
|
|
2515
|
+
try {
|
|
2516
|
+
return ` "${state.safe(value, {
|
|
2517
|
+
before: "\"",
|
|
2518
|
+
after: "\""
|
|
2519
|
+
}).replaceAll("\n", "
")}"`;
|
|
2520
|
+
} finally {
|
|
2521
|
+
exit();
|
|
2522
|
+
}
|
|
2523
|
+
}
|
|
2524
|
+
/** A CommonMark autolink is the only lossless source form for some literal URLs. */
|
|
2525
|
+
function autolink(node, state) {
|
|
2526
|
+
if (node.title != null || node.children.length !== 1 || node.children[0].type !== "text") return;
|
|
2527
|
+
if (node.children[0].value !== node.url || !/^[a-z][a-z\d+.-]{1,31}:[^\s<>]*$/iu.test(node.url)) return;
|
|
2528
|
+
if (decodeString(node.url) === node.url) return;
|
|
2529
|
+
if (state.stack.includes("tableCell") && node.url.includes("|")) return;
|
|
2530
|
+
return `<${node.url}>`;
|
|
2531
|
+
}
|
|
2532
|
+
function resourceWriterExtension() {
|
|
2533
|
+
const link = (untyped, _parent, state, info) => {
|
|
2534
|
+
const node = untyped;
|
|
2535
|
+
const literal = autolink(node, state);
|
|
2536
|
+
if (literal !== void 0) return literal;
|
|
2537
|
+
const exit = state.enter("link");
|
|
2538
|
+
const labelExit = state.enter("label");
|
|
2539
|
+
const tracker = state.createTracker(info);
|
|
2540
|
+
let result = tracker.move("[");
|
|
2541
|
+
result += tracker.move(state.containerPhrasing(node, {
|
|
2542
|
+
before: result,
|
|
2543
|
+
after: "](",
|
|
2544
|
+
...tracker.current()
|
|
2545
|
+
}));
|
|
2546
|
+
labelExit();
|
|
2547
|
+
result += `](${destination(node.url, state)}${title(node.title, state)})`;
|
|
2548
|
+
exit();
|
|
2549
|
+
return result;
|
|
2550
|
+
};
|
|
2551
|
+
const image = (untyped, _parent, state, info) => {
|
|
2552
|
+
const node = untyped;
|
|
2553
|
+
const exit = state.enter("image");
|
|
2554
|
+
const labelExit = state.enter("label");
|
|
2555
|
+
const alt = state.safe(node.alt ?? "", {
|
|
2556
|
+
...info,
|
|
2557
|
+
before: "![",
|
|
2558
|
+
after: "]"
|
|
2559
|
+
});
|
|
2560
|
+
labelExit();
|
|
2561
|
+
const result = `}${title(node.title, state)})`;
|
|
2562
|
+
exit();
|
|
2563
|
+
return result;
|
|
2564
|
+
};
|
|
2565
|
+
const definition = (untyped, _parent, state) => {
|
|
2566
|
+
const node = untyped;
|
|
2567
|
+
const exit = state.enter("definition");
|
|
2568
|
+
const result = `[${referenceLabel(node, state)}]: ${destination(node.url, state)}${title(node.title, state)}`;
|
|
2569
|
+
exit();
|
|
2570
|
+
return result;
|
|
2571
|
+
};
|
|
2572
|
+
Object.assign(link, { peek: () => "[" });
|
|
2573
|
+
Object.assign(image, { peek: () => "!" });
|
|
2574
|
+
return { handlers: {
|
|
2575
|
+
link,
|
|
2576
|
+
image,
|
|
2577
|
+
definition
|
|
2578
|
+
} };
|
|
2579
|
+
}
|
|
2580
|
+
//#endregion
|
|
2581
|
+
//#region src/whitespace-writer.ts
|
|
2582
|
+
function boundaryWhitespaceWriterExtension() {
|
|
2583
|
+
return { handlers: { text(node, parent, state, info) {
|
|
2584
|
+
if (parent?.type !== "delete" && parent?.type !== "tableCell" && !node.value.includes("\n")) return defaultHandlers.text(node, parent, state, info);
|
|
2585
|
+
const characters = Array.from(node.value);
|
|
2586
|
+
let first = 0;
|
|
2587
|
+
let last = characters.length;
|
|
2588
|
+
while (first < last && /^\s$/u.test(characters[first])) first++;
|
|
2589
|
+
while (last > first && /^\s$/u.test(characters[last - 1])) last--;
|
|
2590
|
+
const blankLines = /\n[ \t]*\n/.test(node.value);
|
|
2591
|
+
if (first === 0 && last === characters.length && !blankLines) return defaultHandlers.text(node, parent, state, info);
|
|
2592
|
+
const entity = (character) => `&#x${character.codePointAt(0).toString(16)};`;
|
|
2593
|
+
const before = characters.slice(0, first).map(entity).join("");
|
|
2594
|
+
const after = characters.slice(last).map(entity).join("");
|
|
2595
|
+
const middleInfo = {
|
|
2596
|
+
...info,
|
|
2597
|
+
before: before ? ";" : info.before,
|
|
2598
|
+
after: after ? "&" : info.after
|
|
2599
|
+
};
|
|
2600
|
+
const value = characters.slice(first, last).join("");
|
|
2601
|
+
const parts = blankLines ? value.split("\n") : [value];
|
|
2602
|
+
return before + parts.map((part, index) => state.safe(part, {
|
|
2603
|
+
...middleInfo,
|
|
2604
|
+
before: index === 0 ? middleInfo.before : ";",
|
|
2605
|
+
after: index === parts.length - 1 ? middleInfo.after : "&"
|
|
2606
|
+
})).join("
") + after;
|
|
2607
|
+
} } };
|
|
2608
|
+
}
|
|
2609
|
+
//#endregion
|
|
2610
|
+
//#region src/code-writer.ts
|
|
2611
|
+
/*!
|
|
2612
|
+
* @license MIT
|
|
2613
|
+
* Header escaping follows mdast-util-to-markdown's safe utility, with linear
|
|
2614
|
+
* offset storage and emission rather than repeated array membership searches.
|
|
2615
|
+
* Copyright (c) Titus Wormer <tituswormer@gmail.com>
|
|
2616
|
+
*
|
|
2617
|
+
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
2618
|
+
* of this software and associated documentation files (the "Software"), to deal
|
|
2619
|
+
* in the Software without restriction, including without limitation the rights
|
|
2620
|
+
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
2621
|
+
* copies of the Software, and to permit persons to whom the Software is
|
|
2622
|
+
* furnished to do so, subject to the following conditions:
|
|
2623
|
+
*
|
|
2624
|
+
* The above copyright notice and this permission notice shall be included in
|
|
2625
|
+
* all copies or substantial portions of the Software.
|
|
2626
|
+
*
|
|
2627
|
+
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
2628
|
+
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
2629
|
+
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
2630
|
+
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
2631
|
+
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
2632
|
+
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
2633
|
+
* SOFTWARE.
|
|
2634
|
+
*/
|
|
2635
|
+
const punctuation = /[!-/:-@[-`{-~]/;
|
|
2636
|
+
const marked = 1;
|
|
2637
|
+
const before = 2;
|
|
2638
|
+
const after = 4;
|
|
2639
|
+
function overflow() {
|
|
2640
|
+
throw new ContentLimitError(`Content exceeds the source length limit of ${CONTENT_LIMITS.sourceLength}`);
|
|
2641
|
+
}
|
|
2642
|
+
/** Match the existing ordinary-code fence choice without allocating its spelling. */
|
|
2643
|
+
function ordinaryCodeFenceLength(value, marker = "`") {
|
|
2644
|
+
let length = 3;
|
|
2645
|
+
let run = 0;
|
|
2646
|
+
for (let index = 0; index < value.length; index++) {
|
|
2647
|
+
run = value[index] === marker ? run + 1 : 0;
|
|
2648
|
+
length = Math.max(length, run + 1);
|
|
2649
|
+
}
|
|
2650
|
+
return length;
|
|
2651
|
+
}
|
|
2652
|
+
function inScope(stack, scope, absent) {
|
|
2653
|
+
if (!scope || !scope.length) return absent;
|
|
2654
|
+
return (typeof scope === "string" ? [scope] : scope).some((name) => stack.includes(name));
|
|
2655
|
+
}
|
|
2656
|
+
/** Emit normal backslash escaping without constructing a separate expanded span. */
|
|
2657
|
+
function* literalChunks(value, start, end, next) {
|
|
2658
|
+
let chunk = start;
|
|
2659
|
+
for (let index = start; index < end; index++) {
|
|
2660
|
+
if (value[index] !== "\\" || !punctuation.test(index + 1 < end ? value[index + 1] : next[0] ?? "")) continue;
|
|
2661
|
+
yield value.slice(chunk, index + 1);
|
|
2662
|
+
yield "\\";
|
|
2663
|
+
chunk = index + 1;
|
|
2664
|
+
}
|
|
2665
|
+
yield value.slice(chunk, end);
|
|
2666
|
+
}
|
|
2667
|
+
function* headerChunks(value, flags, start, end, config) {
|
|
2668
|
+
let literal = start;
|
|
2669
|
+
for (let index = start; index < end; index++) {
|
|
2670
|
+
const current = flags[index];
|
|
2671
|
+
if (!(current & marked)) continue;
|
|
2672
|
+
const next = index + 1 < end ? flags[index + 1] : 0;
|
|
2673
|
+
const previous = index > 0 ? flags[index - 1] : 0;
|
|
2674
|
+
if (next & marked && current & after && !(next & 6) || previous & marked && current & before && !(previous & 6)) continue;
|
|
2675
|
+
yield* literalChunks(value, literal, index, "\\");
|
|
2676
|
+
const character = value[index];
|
|
2677
|
+
if (punctuation.test(character) && !config.encode?.includes(character)) {
|
|
2678
|
+
yield "\\";
|
|
2679
|
+
literal = index;
|
|
2680
|
+
} else {
|
|
2681
|
+
yield `&#x${value.charCodeAt(index).toString(16).toUpperCase()};`;
|
|
2682
|
+
literal = index + 1;
|
|
2683
|
+
}
|
|
2684
|
+
}
|
|
2685
|
+
yield* literalChunks(value, literal, end, config.after);
|
|
2686
|
+
}
|
|
2687
|
+
function escapedHeader(state, input, config, remaining) {
|
|
2688
|
+
const prefix = config.before || "";
|
|
2689
|
+
const payload = input || "";
|
|
2690
|
+
const suffix = config.after || "";
|
|
2691
|
+
if (prefix.length + payload.length + suffix.length > CONTENT_LIMITS.sourceLength) overflow();
|
|
2692
|
+
const value = prefix + payload + suffix;
|
|
2693
|
+
const flags = new Uint8Array(value.length);
|
|
2694
|
+
for (const pattern of state.unsafe) {
|
|
2695
|
+
if (!inScope(state.stack, pattern.inConstruct, true) || inScope(state.stack, pattern.notInConstruct, false)) continue;
|
|
2696
|
+
const expression = state.compilePattern(pattern);
|
|
2697
|
+
let match;
|
|
2698
|
+
while (match = expression.exec(value)) {
|
|
2699
|
+
const hasBefore = "before" in pattern || Boolean(pattern.atBreak);
|
|
2700
|
+
const offset = match.index + (hasBefore ? match[1].length : 0);
|
|
2701
|
+
const bits = marked | (hasBefore ? before : 0) | ("after" in pattern ? after : 0);
|
|
2702
|
+
flags[offset] = flags[offset] ? flags[offset] & bits : bits;
|
|
2703
|
+
}
|
|
2704
|
+
}
|
|
2705
|
+
const start = prefix.length;
|
|
2706
|
+
const end = value.length - suffix.length;
|
|
2707
|
+
let length = 0;
|
|
2708
|
+
for (const chunk of headerChunks(value, flags, start, end, config)) {
|
|
2709
|
+
length += chunk.length;
|
|
2710
|
+
if (length > remaining) overflow();
|
|
2711
|
+
}
|
|
2712
|
+
return Array.from(headerChunks(value, flags, start, end, config)).join("");
|
|
2713
|
+
}
|
|
2714
|
+
/** Keep the default handler's spelling and tracker, bounding only code headers. */
|
|
2715
|
+
function codeWriterExtension() {
|
|
2716
|
+
return { handlers: { code(node, parent, state, info) {
|
|
2717
|
+
const code = node;
|
|
2718
|
+
if (state.options.fences === false && !code.lang) return defaultHandlers.code(node, parent, state, info);
|
|
2719
|
+
const marker = state.options.fence || "`";
|
|
2720
|
+
if (marker !== "`" && marker !== "~") return defaultHandlers.code(node, parent, state, info);
|
|
2721
|
+
const raw = code.value || "";
|
|
2722
|
+
const base = 2 * ordinaryCodeFenceLength(raw, marker) + 1 + (raw ? raw.length + 1 : 0) + (code.lang && code.meta ? 1 : 0);
|
|
2723
|
+
if (base > CONTENT_LIMITS.sourceLength) overflow();
|
|
2724
|
+
let remaining = CONTENT_LIMITS.sourceLength - base;
|
|
2725
|
+
const previous = state.safe;
|
|
2726
|
+
state.safe = (input, config) => {
|
|
2727
|
+
let escaped = escapedHeader(state, input, config, remaining);
|
|
2728
|
+
if (typeof input === "string" && /^(?:(?:lines|wrap)(?=[ \t=]|$)|(?:filename|title|startLine|highlight|focus|collapse|added|removed)(?=[ \t=]|$))/.test(input) && escaped[0] === input[0]) escaped = `&#${input.charCodeAt(0)};${escaped.slice(1)}`;
|
|
2729
|
+
if (escaped.length > remaining) overflow();
|
|
2730
|
+
remaining -= escaped.length;
|
|
2731
|
+
return escaped;
|
|
2732
|
+
};
|
|
2733
|
+
try {
|
|
2734
|
+
return defaultHandlers.code(node, parent, state, info);
|
|
2735
|
+
} finally {
|
|
2736
|
+
state.safe = previous;
|
|
2737
|
+
}
|
|
2738
|
+
} } };
|
|
2739
|
+
}
|
|
2740
|
+
//#endregion
|
|
2741
|
+
//#region src/code-presentation-writer.ts
|
|
2742
|
+
/** Header source has already been encoded for exactly one CommonMark decoding pass. */
|
|
2743
|
+
function codePresentationWriterExtension() {
|
|
2744
|
+
return { handlers: { topikCodePresentation(node) {
|
|
2745
|
+
const presentation = node;
|
|
2746
|
+
const child = presentation.children[0];
|
|
2747
|
+
const templated = child.type === "tagCodeTemplate";
|
|
2748
|
+
const value = templated ? writeTextTemplate(child.template, true) : child.value;
|
|
2749
|
+
const metadata = serializeCodePresentationMetadata(presentation.options, presentation.opaqueMetaSuffix);
|
|
2750
|
+
const ticks = ordinaryCodeFenceLength(value);
|
|
2751
|
+
const tildes = ordinaryCodeFenceLength(value, "~");
|
|
2752
|
+
const marker = child.lang?.includes("`") || tildes < ticks ? "~" : "`";
|
|
2753
|
+
const fence = marker.repeat(marker === "`" ? ticks : tildes);
|
|
2754
|
+
let language = (child.lang ?? "").replace(/[&\\`]/g, (unit) => `&#${unit.charCodeAt(0)};`);
|
|
2755
|
+
if (/^(?:lines|wrap)$|^(?:filename|title|lines|startLine|highlight|focus|collapse|wrap|added|removed)=/.test(language)) language = `&#${language.charCodeAt(0)};${language.slice(1)}`;
|
|
2756
|
+
const body = `${fence}${language}${language ? " " : ""}${metadata}\n${value ? `${value}\n` : ""}${fence}`;
|
|
2757
|
+
const scope = templated ? `{% template code %}\n${body}\n{% /template code %}` : body;
|
|
2758
|
+
assertSourceLimit(scope);
|
|
2759
|
+
return scope;
|
|
2760
|
+
} } };
|
|
2761
|
+
}
|
|
2762
|
+
//#endregion
|
|
2763
|
+
//#region src/writer.ts
|
|
2764
|
+
/**
|
|
2765
|
+
* Count payload deltas from an empty-template/code skeleton. A cheap source
|
|
2766
|
+
* floor refuses obvious overflows before cloning or baseline serialization;
|
|
2767
|
+
* the existing writer then supplies exact ordinary markup and escaping.
|
|
2768
|
+
*/
|
|
2769
|
+
function sourceExpansion(document, registry) {
|
|
2770
|
+
let total = 0;
|
|
2771
|
+
let expansion = 0;
|
|
2772
|
+
function charge(length) {
|
|
2773
|
+
total += length;
|
|
2774
|
+
if (total > CONTENT_LIMITS.sourceLength) throw new ContentLimitError(`Content exceeds the source length limit of ${CONTENT_LIMITS.sourceLength}`);
|
|
2775
|
+
}
|
|
2776
|
+
function attributeEscape(unit, table, label) {
|
|
2777
|
+
return unit === "\"" || unit === "\\" || table && unit === "|" || label && (unit === "[" || unit === "]");
|
|
2778
|
+
}
|
|
2779
|
+
function measure(template, attribute, table, label, escapeOpeners = true) {
|
|
2780
|
+
let length = 0;
|
|
2781
|
+
let previous = "";
|
|
2782
|
+
let ticks = 0;
|
|
2783
|
+
let tildes = 0;
|
|
2784
|
+
let longestTicks = 0;
|
|
2785
|
+
let longestTildes = 0;
|
|
2786
|
+
let blankLines = 0;
|
|
2787
|
+
let nonblankLines = 0;
|
|
2788
|
+
let lineHasContent = false;
|
|
2789
|
+
for (const segment of template.segments) {
|
|
2790
|
+
if (segment.type === "variable") {
|
|
2791
|
+
length += 7 + segment.path.length - 1;
|
|
2792
|
+
for (const part of segment.path) length += part.length;
|
|
2793
|
+
lineHasContent = true;
|
|
2794
|
+
previous = "";
|
|
2795
|
+
ticks = 0;
|
|
2796
|
+
tildes = 0;
|
|
2797
|
+
continue;
|
|
2798
|
+
}
|
|
2799
|
+
for (const unit of segment.value) {
|
|
2800
|
+
length += unit.length;
|
|
2801
|
+
if (escapeOpeners && previous === "{" && unit === "%") length++;
|
|
2802
|
+
if (attribute && attributeEscape(unit, table, label)) length++;
|
|
2803
|
+
ticks = unit === "`" ? ticks + 1 : 0;
|
|
2804
|
+
tildes = unit === "~" ? tildes + 1 : 0;
|
|
2805
|
+
longestTicks = Math.max(longestTicks, ticks);
|
|
2806
|
+
longestTildes = Math.max(longestTildes, tildes);
|
|
2807
|
+
previous = unit;
|
|
2808
|
+
if (unit === "\n") {
|
|
2809
|
+
if (lineHasContent) nonblankLines++;
|
|
2810
|
+
else blankLines++;
|
|
2811
|
+
lineHasContent = false;
|
|
2812
|
+
} else lineHasContent = true;
|
|
2813
|
+
}
|
|
2814
|
+
}
|
|
2815
|
+
if (length) {
|
|
2816
|
+
if (lineHasContent) nonblankLines++;
|
|
2817
|
+
else blankLines++;
|
|
2818
|
+
}
|
|
2819
|
+
return {
|
|
2820
|
+
length,
|
|
2821
|
+
longestTicks,
|
|
2822
|
+
longestTildes,
|
|
2823
|
+
blankLines,
|
|
2824
|
+
nonblankLines
|
|
2825
|
+
};
|
|
2826
|
+
}
|
|
2827
|
+
function headerLength(value) {
|
|
2828
|
+
let length = value?.length ?? 0;
|
|
2829
|
+
for (const unit of value ?? "") length += unit === "&" || unit === " " ? 4 : unit === "\\" || unit === "`" ? 5 : 0;
|
|
2830
|
+
return length;
|
|
2831
|
+
}
|
|
2832
|
+
function prefixWidths(prefixes) {
|
|
2833
|
+
let blank = 0;
|
|
2834
|
+
let nonblank = 0;
|
|
2835
|
+
let empty = true;
|
|
2836
|
+
for (let index = prefixes.length - 1; index >= 0; index--) {
|
|
2837
|
+
const prefix = prefixes[index];
|
|
2838
|
+
nonblank += prefix === "quote" ? 2 : prefix;
|
|
2839
|
+
if (prefix === "quote") {
|
|
2840
|
+
blank += empty ? 1 : 2;
|
|
2841
|
+
empty = false;
|
|
2842
|
+
} else if (!empty) blank += prefix;
|
|
2843
|
+
}
|
|
2844
|
+
return {
|
|
2845
|
+
blank,
|
|
2846
|
+
nonblank
|
|
2847
|
+
};
|
|
2848
|
+
}
|
|
2849
|
+
function walk(node, table = false, label = false, prefixes = [], parent, index = 0) {
|
|
2850
|
+
if (node.type === "topikCodePresentation") charge(1 + (node.children?.[0]?.lang ? 1 : 0) + codePresentationMetadataLength(node.options, node.opaqueMetaSuffix));
|
|
2851
|
+
if (node.type === "topikCodeTemplate" || node.type === "code") {
|
|
2852
|
+
const explicit = node.type === "topikCodeTemplate";
|
|
2853
|
+
const presented = parent?.type === "topikCodePresentation";
|
|
2854
|
+
const measured = measure(explicit ? node.template : {
|
|
2855
|
+
type: "topikTextTemplate",
|
|
2856
|
+
segments: [{
|
|
2857
|
+
type: "literal",
|
|
2858
|
+
value: node.value
|
|
2859
|
+
}]
|
|
2860
|
+
}, false, false, false, explicit);
|
|
2861
|
+
const ticks = Math.max(3, measured.longestTicks + 1);
|
|
2862
|
+
const tildes = Math.max(3, measured.longestTildes + 1);
|
|
2863
|
+
const fence = explicit || presented ? node.lang?.includes("`") || node.meta?.includes("`") ? tildes : Math.min(ticks, tildes) : ordinaryCodeFenceLength(node.value);
|
|
2864
|
+
const prefix = prefixWidths(prefixes);
|
|
2865
|
+
const payload = (measured.length ? measured.length + 1 : 0) + measured.nonblankLines * prefix.nonblank + measured.blankLines * prefix.blank;
|
|
2866
|
+
expansion += payload + 2 * (fence - 3);
|
|
2867
|
+
charge((explicit ? 42 : 1) + 2 * fence + (explicit || presented ? headerLength(node.lang) : node.lang?.length ?? 0) + (node.meta ? 1 + (explicit ? headerLength(node.meta) + (node.meta.startsWith(" ") ? 5 : 0) : node.meta.length) : 0) + payload + (explicit ? 4 : 2) * prefix.nonblank);
|
|
2868
|
+
}
|
|
2869
|
+
if ([
|
|
2870
|
+
"text",
|
|
2871
|
+
"inlineCode",
|
|
2872
|
+
"html",
|
|
2873
|
+
"yaml"
|
|
2874
|
+
].includes(node.type) && typeof node.value === "string") charge(node.value.length);
|
|
2875
|
+
if (node.type === "topikComponent") {
|
|
2876
|
+
let templated = false;
|
|
2877
|
+
for (const [name, value] of Object.entries(node.props ?? {})) if (value && typeof value === "object") {
|
|
2878
|
+
const length = measure(value, true, table, label).length;
|
|
2879
|
+
expansion += length;
|
|
2880
|
+
charge(name.length + 5 + length);
|
|
2881
|
+
templated = true;
|
|
2882
|
+
} else if (typeof value === "string") {
|
|
2883
|
+
let length = name.length + 4 + value.length;
|
|
2884
|
+
for (const unit of value) if (attributeEscape(unit, table, label)) length++;
|
|
2885
|
+
charge(length);
|
|
2886
|
+
}
|
|
2887
|
+
if (templated) {
|
|
2888
|
+
const definition = registry[node.name];
|
|
2889
|
+
const leaf = (node.children?.length ?? 0) === 0 && (definition.kind === "inline" || definition.selfClosing) || definition.children === "none";
|
|
2890
|
+
charge((leaf ? 7 + node.name.length : 13 + 2 * node.name.length + (definition.kind === "block" ? 1 : 0)) + (leaf || definition.kind === "inline" ? 1 : 2) * prefixWidths(prefixes).nonblank);
|
|
2891
|
+
}
|
|
2892
|
+
}
|
|
2893
|
+
let childPrefixes = prefixes;
|
|
2894
|
+
if (node.type === "blockquote") childPrefixes = [...prefixes, "quote"];
|
|
2895
|
+
else if (node.type === "listItem") {
|
|
2896
|
+
const list = parent;
|
|
2897
|
+
const width = list?.ordered ? String((list.start ?? 1) + index).length + 2 : 2;
|
|
2898
|
+
childPrefixes = [...prefixes, width];
|
|
2899
|
+
}
|
|
2900
|
+
for (const [childIndex, child] of (node.children ?? []).entries()) {
|
|
2901
|
+
walk(child, table || node.type === "tableCell", label || node.type === "link" || node.type === "linkReference", childPrefixes, node, childIndex);
|
|
2902
|
+
const next = node.children?.[childIndex + 1];
|
|
2903
|
+
if (next && [child, next].some((sibling) => [
|
|
2904
|
+
"code",
|
|
2905
|
+
"topikCodeTemplate",
|
|
2906
|
+
"topikCodePresentation"
|
|
2907
|
+
].includes(sibling.type))) {
|
|
2908
|
+
const separator = node.spread === false ? 1 : 2;
|
|
2909
|
+
charge(separator + (separator - 1) * prefixWidths(childPrefixes).blank);
|
|
2910
|
+
}
|
|
2911
|
+
}
|
|
2912
|
+
}
|
|
2913
|
+
walk(document);
|
|
2914
|
+
return expansion;
|
|
2915
|
+
}
|
|
2916
|
+
function asTags(root, registry, emptyPayloads = false) {
|
|
2917
|
+
const cloned = structuredClone(root);
|
|
2918
|
+
function walk(node) {
|
|
2919
|
+
if (!node.children) return;
|
|
2920
|
+
node.children = node.children.map((child) => {
|
|
2921
|
+
walk(child);
|
|
2922
|
+
if (child.type === "topikVariable") return {
|
|
2923
|
+
type: "tagVariable",
|
|
2924
|
+
path: child.path.join(".")
|
|
2925
|
+
};
|
|
2926
|
+
if (child.type === "topikBranch") return {
|
|
2927
|
+
type: "tagBranch",
|
|
2928
|
+
condition: child.condition === null ? null : writeExpression(child.condition),
|
|
2929
|
+
children: child.children
|
|
2930
|
+
};
|
|
2931
|
+
if (child.type === "topikConditional") return {
|
|
2932
|
+
type: "tagConditional",
|
|
2933
|
+
children: child.children
|
|
2934
|
+
};
|
|
2935
|
+
if (child.type === "topikCodeTemplate") return {
|
|
2936
|
+
type: "tagCodeTemplate",
|
|
2937
|
+
lang: child.lang,
|
|
2938
|
+
meta: child.meta,
|
|
2939
|
+
template: emptyPayloads ? {
|
|
2940
|
+
type: "topikTextTemplate",
|
|
2941
|
+
segments: []
|
|
2942
|
+
} : child.template
|
|
2943
|
+
};
|
|
2944
|
+
if (child.type === "code" && emptyPayloads) child.value = "";
|
|
2945
|
+
if (child.type !== "topikComponent") return child;
|
|
2946
|
+
const component = child;
|
|
2947
|
+
const definition = registry[component.name];
|
|
2948
|
+
return {
|
|
2949
|
+
type: definition.kind === "inline" ? "tagText" : definition.children === "none" || definition.selfClosing && component.children.length === 0 ? "tagLeaf" : "tagContainer",
|
|
2950
|
+
name: component.name,
|
|
2951
|
+
attributes: emptyPayloads ? Object.fromEntries(Object.entries(component.props).map(([name, value]) => [name, typeof value === "object" ? {
|
|
2952
|
+
type: "topikTextTemplate",
|
|
2953
|
+
segments: []
|
|
2954
|
+
} : value])) : component.props,
|
|
2955
|
+
children: component.children
|
|
2956
|
+
};
|
|
2957
|
+
});
|
|
2958
|
+
}
|
|
2959
|
+
walk(cloned);
|
|
2960
|
+
return cloned;
|
|
2961
|
+
}
|
|
2962
|
+
function writeDocument(document, registry = components, formatVersion = 1) {
|
|
2963
|
+
const written = serializeDocument(document, registry, formatVersion);
|
|
2964
|
+
const parsed = parseDocumentTree(written, registry, formatVersion);
|
|
2965
|
+
if (!parsed.document || parsed.diagnostics.length || !sameDocumentMeaning(document, parsed.document)) throw new Error("Content cannot be serialized without changing its meaning");
|
|
2966
|
+
return written;
|
|
2967
|
+
}
|
|
2968
|
+
/** @internal Source operations reuse their output admission parse for the meaning check. */
|
|
2969
|
+
function serializeDocument(document, registry = components, formatVersion = 1) {
|
|
2970
|
+
if (formatVersion !== 1) throw new Error(`Unsupported format version ${formatVersion}`);
|
|
2971
|
+
const errors = validateDocument(document, registry);
|
|
2972
|
+
if (errors.length) throw new Error(errors.map((error) => error.message).join("; "));
|
|
2973
|
+
const expansion = sourceExpansion(document, registry);
|
|
2974
|
+
const baseline = writeMarkdown(asTags(document, registry, true), registry);
|
|
2975
|
+
assertSourceLimit(baseline);
|
|
2976
|
+
if (expansion === 0) return baseline;
|
|
2977
|
+
if (baseline.length + expansion > CONTENT_LIMITS.sourceLength) throw new ContentLimitError(`Content exceeds the source length limit of ${CONTENT_LIMITS.sourceLength}`);
|
|
2978
|
+
const written = writeMarkdown(asTags(document, registry), registry);
|
|
2979
|
+
assertSourceLimit(written);
|
|
2980
|
+
return written;
|
|
2981
|
+
}
|
|
2982
|
+
function writeMarkdown(tree, registry) {
|
|
2983
|
+
return toMarkdown(tree, {
|
|
2984
|
+
extensions: [
|
|
2985
|
+
gfmToMarkdown(),
|
|
2986
|
+
markWriterExtension(tree),
|
|
2987
|
+
frontmatterToMarkdown(),
|
|
2988
|
+
tagToMarkdown(Object.fromEntries(Object.entries(registry).map(([name, definition]) => [name, { kind: definition.kind }])), tagOptions(registry)),
|
|
2989
|
+
codeWriterExtension(),
|
|
2990
|
+
codePresentationWriterExtension(),
|
|
2991
|
+
resourceWriterExtension(),
|
|
2992
|
+
referenceWriterExtension(),
|
|
2993
|
+
boundaryWhitespaceWriterExtension(),
|
|
2994
|
+
headingIdToMarkdown()
|
|
2995
|
+
],
|
|
2996
|
+
bullet: "-",
|
|
2997
|
+
listItemIndent: "one",
|
|
2998
|
+
incrementListMarker: true,
|
|
2999
|
+
fences: true
|
|
3000
|
+
});
|
|
3001
|
+
}
|
|
3002
|
+
//#endregion
|
|
3003
|
+
//#region src/markdown.ts
|
|
3004
|
+
function parseDocument(source, registry = components, formatVersion = 1) {
|
|
3005
|
+
const result = parseDocumentTree(source, registry, formatVersion);
|
|
3006
|
+
return result.document && result.diagnostics.length === 0 ? {
|
|
3007
|
+
ok: true,
|
|
3008
|
+
document: result.document,
|
|
3009
|
+
source
|
|
3010
|
+
} : {
|
|
3011
|
+
ok: false,
|
|
3012
|
+
diagnostics: result.diagnostics,
|
|
3013
|
+
source
|
|
3014
|
+
};
|
|
3015
|
+
}
|
|
3016
|
+
function formatDocument(source, registry = components) {
|
|
3017
|
+
const result = parseDocument(source, registry);
|
|
3018
|
+
if (!result.ok) return result;
|
|
3019
|
+
try {
|
|
3020
|
+
return {
|
|
3021
|
+
...result,
|
|
3022
|
+
formatted: writeDocument(result.document, registry)
|
|
3023
|
+
};
|
|
3024
|
+
} catch {
|
|
3025
|
+
return {
|
|
3026
|
+
ok: false,
|
|
3027
|
+
source,
|
|
3028
|
+
diagnostics: [{
|
|
3029
|
+
id: "topik-write-failed",
|
|
3030
|
+
message: "Content could not be serialized faithfully"
|
|
3031
|
+
}]
|
|
3032
|
+
};
|
|
3033
|
+
}
|
|
3034
|
+
}
|
|
3035
|
+
//#endregion
|
|
3036
|
+
//#region src/assets.ts
|
|
3037
|
+
/** Rewrite asset definitions once, preserving reference identity and unrelated navigation. */
|
|
3038
|
+
function rewriteAssets(document, replace, registry = components) {
|
|
3039
|
+
const inputErrors = validateDocument(document, registry);
|
|
3040
|
+
if (inputErrors.length) throw new Error(inputErrors.map((error) => error.message).join("; "));
|
|
3041
|
+
const cloned = structuredClone(document);
|
|
3042
|
+
const index = indexDocument(cloned);
|
|
3043
|
+
const images = /* @__PURE__ */ new Set();
|
|
3044
|
+
const links = /* @__PURE__ */ new Set();
|
|
3045
|
+
for (const { node } of index.entries) {
|
|
3046
|
+
if (!isReference(node)) continue;
|
|
3047
|
+
const definition = index.resolve(node);
|
|
3048
|
+
if (definition) (node.type === "imageReference" ? images : links).add(definition);
|
|
3049
|
+
}
|
|
3050
|
+
for (const { node } of index.entries) if (node.type === "image") {
|
|
3051
|
+
const image = node;
|
|
3052
|
+
image.url = replace(image.url) ?? image.url;
|
|
3053
|
+
} else if (node.type === "definition" && images.has(node)) {
|
|
3054
|
+
const definition = node;
|
|
3055
|
+
const next = replace(definition.url);
|
|
3056
|
+
if (next !== void 0 && next !== definition.url && links.has(definition)) throw new Error(`Cannot rewrite definition ${definition.identifier}: it is shared by an image and a link`);
|
|
3057
|
+
definition.url = next ?? definition.url;
|
|
3058
|
+
} else if (node.type === "topikComponent") {
|
|
3059
|
+
const component = node;
|
|
3060
|
+
for (const [key, attribute] of Object.entries(registry[component.name].attributes)) {
|
|
3061
|
+
const value = component.props[key];
|
|
3062
|
+
if ((attribute.asset || attribute.assetReference) && typeof value === "string") component.props[key] = replace(value) ?? value;
|
|
3063
|
+
}
|
|
3064
|
+
}
|
|
3065
|
+
const errors = validateDocument(cloned, registry);
|
|
3066
|
+
if (errors.length) throw new Error(errors.map((error) => error.message).join("; "));
|
|
3067
|
+
return cloned;
|
|
3068
|
+
}
|
|
3069
|
+
//#endregion
|
|
3070
|
+
//#region src/traversal.ts
|
|
3071
|
+
function walkContent(node, visit, path = []) {
|
|
3072
|
+
assertDataLimits(node, { plain: true });
|
|
3073
|
+
assertTreeLimits(node);
|
|
3074
|
+
function walk(node, path) {
|
|
3075
|
+
visit(node, path);
|
|
3076
|
+
for (const [index, child] of (node.children ?? []).entries()) walk(child, [...path, index]);
|
|
3077
|
+
}
|
|
3078
|
+
walk(node, path);
|
|
3079
|
+
}
|
|
3080
|
+
function plainText(node) {
|
|
3081
|
+
if (node.type === "text" || node.type === "inlineCode") return node.value ?? "";
|
|
3082
|
+
if (node.type === "image" || node.type === "imageReference") return node.alt ?? "";
|
|
3083
|
+
if (node.type === "break") return "\n";
|
|
3084
|
+
return (node.children ?? []).map(plainText).join("");
|
|
3085
|
+
}
|
|
3086
|
+
//#endregion
|
|
3087
|
+
//#region src/headings.ts
|
|
3088
|
+
function assignTopikHeadingIds(document) {
|
|
3089
|
+
const headings = [];
|
|
3090
|
+
walkContent(document, (node) => {
|
|
3091
|
+
if (node.type === "heading") headings.push(node);
|
|
3092
|
+
});
|
|
3093
|
+
return assignHeadingIds(headings);
|
|
3094
|
+
}
|
|
3095
|
+
/** @internal Reuse headings collected by a bounded document index. */
|
|
3096
|
+
function assignHeadingIds(headings) {
|
|
3097
|
+
const slugger = new GithubSlugger();
|
|
3098
|
+
const explicitIds = /* @__PURE__ */ new Set();
|
|
3099
|
+
for (const heading of headings) if (typeof heading.data?.topikId === "string") explicitIds.add(heading.data.topikId);
|
|
3100
|
+
return headings.map((heading) => {
|
|
3101
|
+
const title = plainText(heading).trim().replace(/\s+/g, " ");
|
|
3102
|
+
let id = heading.data?.topikId;
|
|
3103
|
+
if (typeof id !== "string") do
|
|
3104
|
+
id = slugger.slug(title);
|
|
3105
|
+
while (explicitIds.has(id));
|
|
3106
|
+
heading.data = {
|
|
3107
|
+
...heading.data,
|
|
3108
|
+
hProperties: { id }
|
|
3109
|
+
};
|
|
3110
|
+
return {
|
|
3111
|
+
id,
|
|
3112
|
+
level: heading.depth,
|
|
3113
|
+
title
|
|
3114
|
+
};
|
|
3115
|
+
});
|
|
3116
|
+
}
|
|
3117
|
+
//#endregion
|
|
3118
|
+
//#region src/evaluate.ts
|
|
3119
|
+
var ContentEvaluationError = class extends Error {
|
|
3120
|
+
constructor(message) {
|
|
3121
|
+
super(message);
|
|
3122
|
+
this.name = "ContentEvaluationError";
|
|
3123
|
+
}
|
|
3124
|
+
};
|
|
3125
|
+
/** @internal Expected template failures retain their public diagnostic identity. */
|
|
3126
|
+
var TemplateEvaluationError = class extends ContentEvaluationError {
|
|
3127
|
+
id;
|
|
3128
|
+
attribute;
|
|
3129
|
+
variable;
|
|
3130
|
+
lines;
|
|
3131
|
+
type;
|
|
3132
|
+
constructor(id, node, message, attribute, variable) {
|
|
3133
|
+
super(message);
|
|
3134
|
+
this.id = id;
|
|
3135
|
+
this.attribute = attribute;
|
|
3136
|
+
this.variable = variable;
|
|
3137
|
+
this.name = "TemplateEvaluationError";
|
|
3138
|
+
this.lines = node.position ? [node.position.start.line] : [];
|
|
3139
|
+
this.type = node.type;
|
|
3140
|
+
}
|
|
3141
|
+
};
|
|
3142
|
+
function dataValue(value) {
|
|
3143
|
+
return value === null || typeof value === "string" || typeof value === "boolean" || typeof value === "number" && Number.isFinite(value) || Array.isArray(value) || typeof value === "object" && (Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null);
|
|
3144
|
+
}
|
|
3145
|
+
function lookup(context, path, fail = (_kind, message) => {
|
|
3146
|
+
throw new ContentEvaluationError(message);
|
|
3147
|
+
}) {
|
|
3148
|
+
if (!validPath(path)) fail("path", "Unsafe variable path");
|
|
3149
|
+
let current = context;
|
|
3150
|
+
for (const part of path) {
|
|
3151
|
+
if (!dataValue(current) || current === null || typeof current !== "object" || Array.isArray(current)) fail("type", `Cannot read $${path.join(".")}`);
|
|
3152
|
+
const descriptor = Object.getOwnPropertyDescriptor(current, part);
|
|
3153
|
+
if (!descriptor) fail("missing", `Missing variable $${path.join(".")}`);
|
|
3154
|
+
if (!("value" in descriptor)) fail("type", `Missing variable $${path.join(".")}`);
|
|
3155
|
+
current = descriptor.value;
|
|
3156
|
+
}
|
|
3157
|
+
if (!dataValue(current)) fail("type", `Non-data value $${path.join(".")}`);
|
|
3158
|
+
return current;
|
|
3159
|
+
}
|
|
3160
|
+
function truthy(value) {
|
|
3161
|
+
return value !== false && value !== null;
|
|
3162
|
+
}
|
|
3163
|
+
function hasControl(value) {
|
|
3164
|
+
for (let index = 0; index < value.length; index++) {
|
|
3165
|
+
const code = value.charCodeAt(index);
|
|
3166
|
+
if (code < 32 || code === 127) return true;
|
|
3167
|
+
}
|
|
3168
|
+
return false;
|
|
3169
|
+
}
|
|
3170
|
+
function evaluate(expression, context) {
|
|
3171
|
+
if (expression.type === "literal") return expression.value;
|
|
3172
|
+
if (expression.type === "variable") return lookup(context, expression.path);
|
|
3173
|
+
const [left, right] = expression.args;
|
|
3174
|
+
switch (expression.name) {
|
|
3175
|
+
case "not": return !truthy(evaluate(left, context));
|
|
3176
|
+
case "and": return truthy(evaluate(left, context)) && truthy(evaluate(right, context));
|
|
3177
|
+
case "or": return truthy(evaluate(left, context)) || truthy(evaluate(right, context));
|
|
3178
|
+
case "equals": {
|
|
3179
|
+
const first = evaluate(left, context);
|
|
3180
|
+
const second = evaluate(right, context);
|
|
3181
|
+
if (first !== null && typeof first === "object" || second !== null && typeof second === "object") throw new ContentEvaluationError("equals accepts only scalar values");
|
|
3182
|
+
return first === second;
|
|
3183
|
+
}
|
|
3184
|
+
}
|
|
3185
|
+
}
|
|
3186
|
+
/** Resolve into a new, writable document; the authored branches remain untouched. */
|
|
3187
|
+
function evaluateDocument(document, context, registry = components) {
|
|
3188
|
+
const resolved = resolveDocument(document, context, registry, false);
|
|
3189
|
+
try {
|
|
3190
|
+
writeDocument(resolved, registry);
|
|
3191
|
+
} catch {
|
|
3192
|
+
throw new ContentEvaluationError("Evaluated content cannot be written without changing its meaning");
|
|
3193
|
+
}
|
|
3194
|
+
return resolved;
|
|
3195
|
+
}
|
|
3196
|
+
/** @internal Allocate reader anchors on the authored clone before selecting branches. */
|
|
3197
|
+
function evaluateDocumentForRendering(document, context, registry) {
|
|
3198
|
+
return resolveDocument(document, context, registry, true);
|
|
3199
|
+
}
|
|
3200
|
+
function resolveDocument(document, context, registry, includeHeadingIds) {
|
|
3201
|
+
const errors = validateDocument(document, registry);
|
|
3202
|
+
if (errors.length) throw new ContentEvaluationError(errors.map((error) => error.message).join("; "));
|
|
3203
|
+
if (!dataValue(context) || context === null || typeof context !== "object" || Array.isArray(context)) throw new ContentEvaluationError("Evaluation context must be a plain data object");
|
|
3204
|
+
const resolved = structuredClone(document);
|
|
3205
|
+
const index = indexDocument(resolved);
|
|
3206
|
+
if (includeHeadingIds) assignHeadingIds(index.entries.filter(({ node }) => node.type === "heading").map(({ node }) => node));
|
|
3207
|
+
materializeReferences(resolved, index);
|
|
3208
|
+
let interpolatedLength = 0;
|
|
3209
|
+
let templateResolved = false;
|
|
3210
|
+
const strings = /* @__PURE__ */ new WeakMap();
|
|
3211
|
+
const stringLengths = /* @__PURE__ */ new WeakMap();
|
|
3212
|
+
function reserveString(target, key, plan) {
|
|
3213
|
+
const fields = strings.get(target) ?? /* @__PURE__ */ new Map();
|
|
3214
|
+
fields.set(key, plan);
|
|
3215
|
+
strings.set(target, fields);
|
|
3216
|
+
const lengths = stringLengths.get(target) ?? /* @__PURE__ */ new Map();
|
|
3217
|
+
lengths.set(key, plan.length);
|
|
3218
|
+
stringLengths.set(target, lengths);
|
|
3219
|
+
target[key] = plan.length ? "x" : "";
|
|
3220
|
+
}
|
|
3221
|
+
function resolveTemplate(template, node, attribute) {
|
|
3222
|
+
templateResolved = true;
|
|
3223
|
+
const parts = [];
|
|
3224
|
+
let length = 0;
|
|
3225
|
+
for (const segment of template.segments) {
|
|
3226
|
+
let text;
|
|
3227
|
+
if (segment.type === "literal") text = segment.value;
|
|
3228
|
+
else {
|
|
3229
|
+
const variable = segment.path.reduce((length, part) => length + part.length + 1, -1) <= 256 ? segment.path.join(".") : void 0;
|
|
3230
|
+
const value = lookup(context, segment.path, (kind) => {
|
|
3231
|
+
throw new TemplateEvaluationError(kind === "path" ? "topik-variable-path" : `topik-template-variable-${kind}`, node, "Template variable lookup failed", attribute, variable);
|
|
3232
|
+
});
|
|
3233
|
+
if (value !== null && typeof value !== "string" && typeof value !== "number" && typeof value !== "boolean") throw new TemplateEvaluationError("topik-template-variable-type", node, "Template variables require scalar values", attribute, variable);
|
|
3234
|
+
text = value === null ? "" : String(value);
|
|
3235
|
+
if (hasControl(text)) throw new TemplateEvaluationError("topik-template-control", node, "Template variable values must be single-line without controls", attribute, variable);
|
|
3236
|
+
interpolatedLength += text.length;
|
|
3237
|
+
if (interpolatedLength > CONTENT_LIMITS.dataStringLength) throw new TemplateEvaluationError("topik-content-limit", node, "Interpolated content exceeds the data string limit");
|
|
3238
|
+
}
|
|
3239
|
+
length += text.length;
|
|
3240
|
+
if (length > CONTENT_LIMITS.dataStringLength) throw new TemplateEvaluationError("topik-content-limit", node, "Resolved template exceeds the data string limit");
|
|
3241
|
+
parts.push(text);
|
|
3242
|
+
}
|
|
3243
|
+
return {
|
|
3244
|
+
parts,
|
|
3245
|
+
length
|
|
3246
|
+
};
|
|
3247
|
+
}
|
|
3248
|
+
function resolveChildren(children) {
|
|
3249
|
+
const result = [];
|
|
3250
|
+
function append(node) {
|
|
3251
|
+
const previous = result.at(-1);
|
|
3252
|
+
if (previous?.type === "text" && node.type === "text") {
|
|
3253
|
+
const before = strings.get(previous)?.get("value") ?? {
|
|
3254
|
+
parts: [previous.value ?? ""],
|
|
3255
|
+
length: previous.value?.length ?? 0
|
|
3256
|
+
};
|
|
3257
|
+
const after = strings.get(node)?.get("value") ?? {
|
|
3258
|
+
parts: [node.value ?? ""],
|
|
3259
|
+
length: node.value?.length ?? 0
|
|
3260
|
+
};
|
|
3261
|
+
for (const part of after.parts) before.parts.push(part);
|
|
3262
|
+
before.length += after.length;
|
|
3263
|
+
reserveString(previous, "value", before);
|
|
3264
|
+
delete previous.position;
|
|
3265
|
+
} else result.push(node);
|
|
3266
|
+
}
|
|
3267
|
+
for (const original of children) {
|
|
3268
|
+
if (original.type === "topikConditional") {
|
|
3269
|
+
const [first, alternate] = original.children;
|
|
3270
|
+
const selected = truthy(evaluate(first.condition, context)) ? first : alternate;
|
|
3271
|
+
if (selected) for (const child of resolveChildren(selected.children ?? [])) append(child);
|
|
3272
|
+
continue;
|
|
3273
|
+
}
|
|
3274
|
+
if (original.type === "topikVariable") {
|
|
3275
|
+
const value = lookup(context, original.path);
|
|
3276
|
+
if (value !== null && typeof value === "object") throw new ContentEvaluationError(`Cannot interpolate object $${original.path.join(".")}`);
|
|
3277
|
+
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
|
|
3278
|
+
const text = String(value);
|
|
3279
|
+
interpolatedLength += text.length;
|
|
3280
|
+
if (interpolatedLength > CONTENT_LIMITS.dataStringLength) {
|
|
3281
|
+
if (templateResolved) throw new TemplateEvaluationError("topik-content-limit", original, "Interpolated content exceeds the data string limit");
|
|
3282
|
+
throw new ContentEvaluationError("Interpolated content exceeds the data string limit");
|
|
3283
|
+
}
|
|
3284
|
+
if (typeof value === "string" && hasControl(value)) throw new ContentEvaluationError("Interpolated text must be single-line without controls");
|
|
3285
|
+
if (text) {
|
|
3286
|
+
const node = {
|
|
3287
|
+
type: "text",
|
|
3288
|
+
value: ""
|
|
3289
|
+
};
|
|
3290
|
+
reserveString(node, "value", {
|
|
3291
|
+
parts: [text],
|
|
3292
|
+
length: text.length
|
|
3293
|
+
});
|
|
3294
|
+
append(node);
|
|
3295
|
+
}
|
|
3296
|
+
}
|
|
3297
|
+
continue;
|
|
3298
|
+
}
|
|
3299
|
+
const node = original;
|
|
3300
|
+
if (node.type === "topikCodeTemplate") {
|
|
3301
|
+
reserveString(node, "value", resolveTemplate(node.template, node));
|
|
3302
|
+
node.type = "code";
|
|
3303
|
+
delete node.template;
|
|
3304
|
+
}
|
|
3305
|
+
if (node.type === "topikComponent") {
|
|
3306
|
+
for (const [name, value] of Object.entries(node.props)) if (typeof value === "object") reserveString(node.props, name, resolveTemplate(value, node, name));
|
|
3307
|
+
}
|
|
3308
|
+
if (node.children) node.children = resolveChildren(node.children);
|
|
3309
|
+
if (inlineParentTypes.has(node.type) && node.type !== "link" && node.type !== "linkReference") while (node.children?.at(-1)?.type === "break") node.children.pop();
|
|
3310
|
+
const definition = node.type === "topikComponent" ? registry[node.name] : void 0;
|
|
3311
|
+
if (node.children?.length === 0 && (node.type === "paragraph" || node.type === "strong" || node.type === "emphasis" || node.type === "delete" || definition?.kind === "inline" && definition.children === "inline")) continue;
|
|
3312
|
+
append(node);
|
|
3313
|
+
}
|
|
3314
|
+
return result;
|
|
3315
|
+
}
|
|
3316
|
+
resolved.children = resolveChildren(resolved.children);
|
|
3317
|
+
try {
|
|
3318
|
+
assertDataLimits(resolved, {
|
|
3319
|
+
plain: true,
|
|
3320
|
+
stringLengths
|
|
3321
|
+
});
|
|
3322
|
+
} catch (error) {
|
|
3323
|
+
if (error instanceof ContentLimitError) {
|
|
3324
|
+
if (templateResolved) throw new TemplateEvaluationError("topik-content-limit", resolved, error.message);
|
|
3325
|
+
throw new ContentEvaluationError(error.message);
|
|
3326
|
+
}
|
|
3327
|
+
throw error;
|
|
3328
|
+
}
|
|
3329
|
+
function materialize(value, seen = /* @__PURE__ */ new Set()) {
|
|
3330
|
+
if (!value || typeof value !== "object" || seen.has(value)) return;
|
|
3331
|
+
seen.add(value);
|
|
3332
|
+
const plans = strings.get(value);
|
|
3333
|
+
if (plans) for (const [key, plan] of plans) value[key] = plan.parts.join("");
|
|
3334
|
+
for (const child of Object.values(value)) materialize(child, seen);
|
|
3335
|
+
}
|
|
3336
|
+
materialize(resolved);
|
|
3337
|
+
const resolvedErrors = validateDocument(resolved, registry);
|
|
3338
|
+
if (resolvedErrors.length) {
|
|
3339
|
+
if (templateResolved && resolvedErrors[0].id === "topik-content-limit") throw new TemplateEvaluationError("topik-content-limit", resolved, "Resolved content exceeds the data limits");
|
|
3340
|
+
throw new ContentEvaluationError(resolvedErrors.map((error) => error.message).join("; "));
|
|
3341
|
+
}
|
|
3342
|
+
return resolved;
|
|
3343
|
+
}
|
|
3344
|
+
//#endregion
|
|
3345
|
+
//#region src/config.ts
|
|
3346
|
+
function plainObject(value) {
|
|
3347
|
+
return value !== null && typeof value === "object" && (Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null);
|
|
3348
|
+
}
|
|
3349
|
+
function ownEntries(value) {
|
|
3350
|
+
const descriptors = Object.getOwnPropertyDescriptors(value);
|
|
3351
|
+
if (Reflect.ownKeys(descriptors).some((key) => typeof key === "symbol")) throw new Error("Content configuration is invalid");
|
|
3352
|
+
return Object.entries(descriptors).map(([key, descriptor]) => {
|
|
3353
|
+
if (!("value" in descriptor) || key === "__proto__" || key === "constructor" || key === "prototype") throw new Error("Content configuration is invalid");
|
|
3354
|
+
return [key, descriptor.value];
|
|
3355
|
+
});
|
|
3356
|
+
}
|
|
3357
|
+
function validName(name) {
|
|
3358
|
+
return /^[a-z][A-Za-z0-9-]*$/.test(name);
|
|
3359
|
+
}
|
|
3360
|
+
function allowedFields(value, fields) {
|
|
3361
|
+
return Object.keys(value).every((field) => fields.includes(field));
|
|
3362
|
+
}
|
|
3363
|
+
function validAttribute(attribute) {
|
|
3364
|
+
if (plainObject(attribute) && Object.hasOwn(attribute, "interpolation")) return false;
|
|
3365
|
+
if (!plainObject(attribute) || !allowedFields(attribute, [
|
|
3366
|
+
"type",
|
|
3367
|
+
"required",
|
|
3368
|
+
"default",
|
|
3369
|
+
"description",
|
|
3370
|
+
"values",
|
|
3371
|
+
"min",
|
|
3372
|
+
"max",
|
|
3373
|
+
"integer",
|
|
3374
|
+
"asset",
|
|
3375
|
+
"assetReference",
|
|
3376
|
+
"escapedWhitespace"
|
|
3377
|
+
])) return false;
|
|
3378
|
+
if (attribute.required !== void 0 && typeof attribute.required !== "boolean") return false;
|
|
3379
|
+
if (attribute.description !== void 0 && typeof attribute.description !== "string") return false;
|
|
3380
|
+
if (attribute.asset !== void 0 && typeof attribute.asset !== "boolean") return false;
|
|
3381
|
+
if (attribute.escapedWhitespace !== void 0 && (attribute.type !== "string" || typeof attribute.escapedWhitespace !== "boolean")) return false;
|
|
3382
|
+
if ((attribute.asset || attribute.assetReference !== void 0) && attribute.type !== "string" && attribute.type !== "enum") return false;
|
|
3383
|
+
if (attribute.assetReference !== void 0) {
|
|
3384
|
+
const reference = attribute.assetReference;
|
|
3385
|
+
if (!plainObject(reference) || !allowedFields(reference, [
|
|
3386
|
+
"slot",
|
|
3387
|
+
"role",
|
|
3388
|
+
"conditional"
|
|
3389
|
+
]) || typeof reference.slot !== "string" || ![
|
|
3390
|
+
"image",
|
|
3391
|
+
"image-light",
|
|
3392
|
+
"image-dark",
|
|
3393
|
+
"media",
|
|
3394
|
+
"download"
|
|
3395
|
+
].includes(String(reference.role)) || reference.conditional !== void 0 && reference.conditional !== "proven-download") return false;
|
|
3396
|
+
}
|
|
3397
|
+
if (attribute.type === "string") return attribute.values === void 0 && attribute.min === void 0 && attribute.max === void 0 && attribute.integer === void 0 && (attribute.default === void 0 || typeof attribute.default === "string");
|
|
3398
|
+
if (attribute.type === "enum") return Array.isArray(attribute.values) && attribute.values.length > 0 && attribute.values.every((value) => typeof value === "string") && attribute.min === void 0 && attribute.max === void 0 && attribute.integer === void 0 && (attribute.default === void 0 || typeof attribute.default === "string" && attribute.values.includes(attribute.default));
|
|
3399
|
+
if (attribute.type === "boolean") return attribute.values === void 0 && attribute.min === void 0 && attribute.max === void 0 && attribute.integer === void 0 && (attribute.default === void 0 || typeof attribute.default === "boolean");
|
|
3400
|
+
if (attribute.type === "number") return attribute.values === void 0 && (attribute.min === void 0 || typeof attribute.min === "number" && Number.isFinite(attribute.min)) && (attribute.max === void 0 || typeof attribute.max === "number" && Number.isFinite(attribute.max)) && (attribute.integer === void 0 || typeof attribute.integer === "boolean") && (attribute.default === void 0 || typeof attribute.default === "number" && Number.isFinite(attribute.default) && (attribute.min === void 0 || attribute.default >= attribute.min) && (attribute.max === void 0 || attribute.default <= attribute.max) && (!attribute.integer || Number.isInteger(attribute.default))) && (attribute.min === void 0 || attribute.max === void 0 || attribute.min <= attribute.max);
|
|
3401
|
+
return false;
|
|
3402
|
+
}
|
|
3403
|
+
function validDefinition(definition) {
|
|
3404
|
+
if (!plainObject(definition) || !allowedFields(definition, [
|
|
3405
|
+
"kind",
|
|
3406
|
+
"render",
|
|
3407
|
+
"description",
|
|
3408
|
+
"attributes",
|
|
3409
|
+
"children",
|
|
3410
|
+
"selfClosing",
|
|
3411
|
+
"allowedParents"
|
|
3412
|
+
])) return false;
|
|
3413
|
+
if (definition.kind !== "block" && definition.kind !== "inline" || typeof definition.render !== "string" && typeof definition.render !== "boolean" || definition.description !== void 0 && typeof definition.description !== "string" || definition.selfClosing !== void 0 && typeof definition.selfClosing !== "boolean" || !plainObject(definition.attributes)) return false;
|
|
3414
|
+
if (Object.entries(definition.attributes).some(([name, attribute]) => !validName(name) || !validAttribute(attribute))) return false;
|
|
3415
|
+
if (definition.allowedParents !== void 0 && (!Array.isArray(definition.allowedParents) || !definition.allowedParents.every((name) => typeof name === "string" && validName(name)))) return false;
|
|
3416
|
+
const children = definition.children;
|
|
3417
|
+
if (children === "blocks" || children === "inline" || children === "none") return true;
|
|
3418
|
+
if (!plainObject(children) || !allowedFields(children, [
|
|
3419
|
+
"components",
|
|
3420
|
+
"nodes",
|
|
3421
|
+
"min"
|
|
3422
|
+
])) return false;
|
|
3423
|
+
const names = children.components ?? children.nodes;
|
|
3424
|
+
return children.components === void 0 !== (children.nodes === void 0) && Array.isArray(names) && names.length > 0 && names.every((name) => typeof name === "string" && validName(name)) && (children.min === void 0 || typeof children.min === "number" && Number.isInteger(children.min) && children.min >= 0);
|
|
3425
|
+
}
|
|
3426
|
+
function cloneSchema(value, active = /* @__PURE__ */ new Set()) {
|
|
3427
|
+
if (value === null || typeof value === "string" || typeof value === "boolean" || typeof value === "number" && Number.isFinite(value)) return value;
|
|
3428
|
+
if (typeof value !== "object") throw new Error("Content configuration is invalid");
|
|
3429
|
+
if (active.has(value) || !Array.isArray(value) && !plainObject(value)) throw new Error("Content configuration is invalid");
|
|
3430
|
+
active.add(value);
|
|
3431
|
+
let cloned;
|
|
3432
|
+
if (Array.isArray(value)) {
|
|
3433
|
+
const entries = ownEntries(value);
|
|
3434
|
+
if (entries.length !== value.length + 1 || entries.some(([key], index) => index < value.length && key !== String(index))) throw new Error("Content configuration is invalid");
|
|
3435
|
+
cloned = entries.slice(0, -1).map(([, entry]) => cloneSchema(entry, active));
|
|
3436
|
+
} else cloned = Object.fromEntries(ownEntries(value).map(([key, entry]) => [key, cloneSchema(entry, active)]));
|
|
3437
|
+
active.delete(value);
|
|
3438
|
+
return cloned;
|
|
3439
|
+
}
|
|
3440
|
+
/** Isolate schemas before validation; runtime variables are read without invoking accessors. */
|
|
3441
|
+
function mergeTopikContentConfig(extension = {}) {
|
|
3442
|
+
try {
|
|
3443
|
+
assertDataLimits(extension);
|
|
3444
|
+
} catch {
|
|
3445
|
+
throw new Error("Content configuration is invalid");
|
|
3446
|
+
}
|
|
3447
|
+
if (!plainObject(extension)) throw new Error("Content configuration is invalid");
|
|
3448
|
+
const fields = Object.fromEntries(ownEntries(extension));
|
|
3449
|
+
if (Object.keys(fields).some((key) => key !== "components" && key !== "variables")) throw new Error("Content configuration is invalid");
|
|
3450
|
+
const additional = fields.components ?? {};
|
|
3451
|
+
const variables = fields.variables ?? {};
|
|
3452
|
+
if (!plainObject(additional) || !plainObject(variables)) throw new Error("Content configuration is invalid");
|
|
3453
|
+
const registry = { ...components };
|
|
3454
|
+
for (const [name, schema] of ownEntries(additional)) {
|
|
3455
|
+
if (Object.hasOwn(components, name)) continue;
|
|
3456
|
+
if (!validName(name) || name === "if" || name === "else" || !plainObject(schema)) throw new Error("Content configuration is invalid");
|
|
3457
|
+
const definition = cloneSchema(schema);
|
|
3458
|
+
if (!validDefinition(definition)) throw new Error("Content configuration is invalid");
|
|
3459
|
+
registry[name] = definition;
|
|
3460
|
+
}
|
|
3461
|
+
return {
|
|
3462
|
+
components: registry,
|
|
3463
|
+
variables: cloneSchema(variables)
|
|
3464
|
+
};
|
|
3465
|
+
}
|
|
3466
|
+
//#endregion
|
|
3467
|
+
//#region src/content.ts
|
|
3468
|
+
/** Parse the authoring tree. Use validateTopikContent at publication boundaries. */
|
|
3469
|
+
function parseTopikContent(source, options = {}) {
|
|
3470
|
+
const result = parseDocumentTree(source, mergeTopikContentConfig(options.config).components);
|
|
3471
|
+
if (!result.document) throw new Error("Content could not be parsed");
|
|
3472
|
+
return result.document;
|
|
3473
|
+
}
|
|
3474
|
+
//#endregion
|
|
3475
|
+
//#region src/asset-reference-policy.ts
|
|
3476
|
+
const TOPIK_ASSET_REFERENCE_VERSION = "topik-asset-reference-v1";
|
|
3477
|
+
const TOPIK_GENERATED_ASSET_NAME_VALIDATOR = /^auto-v1-[a-z2-7]{51}[aq]$/u;
|
|
3478
|
+
/** Public grammar descriptor. Runtime admission uses an isolated boundary. */
|
|
3479
|
+
const TOPIK_GENERATED_ASSET_NAME_PATTERN = /^auto-v1-[a-z2-7]{51}[aq]$/u;
|
|
3480
|
+
function isTopikGeneratedAssetName(value) {
|
|
3481
|
+
return typeof value === "string" && TOPIK_GENERATED_ASSET_NAME_VALIDATOR.test(value);
|
|
3482
|
+
}
|
|
3483
|
+
function parseTopikGeneratedAssetName(value) {
|
|
3484
|
+
if (!isTopikGeneratedAssetName(value)) throw new TypeError("Generated Asset name is not canonical");
|
|
3485
|
+
return value;
|
|
3486
|
+
}
|
|
3487
|
+
/** Validate the URL-facing portion of topik-asset-reference-v1 without filesystem access. */
|
|
3488
|
+
function validateTopikAssetReference(reference) {
|
|
3489
|
+
if (containsUnsafeUnicode(reference) || reference.includes("\\")) return unsafe(reference);
|
|
3490
|
+
if (reference.startsWith("asset:")) {
|
|
3491
|
+
const name = reference.slice(6);
|
|
3492
|
+
return isTopikGeneratedAssetName(name) ? {
|
|
3493
|
+
valid: true,
|
|
3494
|
+
kind: "asset",
|
|
3495
|
+
name
|
|
3496
|
+
} : {
|
|
3497
|
+
valid: false,
|
|
3498
|
+
kind: "unsafe",
|
|
3499
|
+
failureKind: "local"
|
|
3500
|
+
};
|
|
3501
|
+
}
|
|
3502
|
+
if (/^https:\/\//iu.test(reference)) try {
|
|
3503
|
+
const url = new URL(reference);
|
|
3504
|
+
return url.protocol === "https:" && url.username === "" && url.password === "" ? {
|
|
3505
|
+
valid: true,
|
|
3506
|
+
kind: "external-https"
|
|
3507
|
+
} : {
|
|
3508
|
+
valid: false,
|
|
3509
|
+
kind: "unsafe",
|
|
3510
|
+
failureKind: "external"
|
|
3511
|
+
};
|
|
3512
|
+
} catch {
|
|
3513
|
+
return {
|
|
3514
|
+
valid: false,
|
|
3515
|
+
kind: "unsafe",
|
|
3516
|
+
failureKind: "external"
|
|
3517
|
+
};
|
|
3518
|
+
}
|
|
3519
|
+
if (reference.startsWith("//") || /^[a-z][a-z0-9+.-]*:/i.test(reference)) return {
|
|
3520
|
+
valid: false,
|
|
3521
|
+
kind: "unsafe",
|
|
3522
|
+
failureKind: "external"
|
|
3523
|
+
};
|
|
3524
|
+
if (reference.length === 0 || reference.startsWith("/") || reference.includes("?") || reference.includes("#") || containsNonAscii(reference)) return {
|
|
3525
|
+
valid: false,
|
|
3526
|
+
kind: "unsafe",
|
|
3527
|
+
failureKind: "local"
|
|
3528
|
+
};
|
|
3529
|
+
const bytes = [];
|
|
3530
|
+
for (let index = 0; index < reference.length; index++) {
|
|
3531
|
+
const character = reference[index];
|
|
3532
|
+
if (character === "/") {
|
|
3533
|
+
bytes.push(47);
|
|
3534
|
+
continue;
|
|
3535
|
+
}
|
|
3536
|
+
if (character === "%") {
|
|
3537
|
+
const pair = reference.slice(index + 1, index + 3);
|
|
3538
|
+
if (!/^[0-9A-F]{2}$/u.test(pair)) return {
|
|
3539
|
+
valid: false,
|
|
3540
|
+
kind: "unsafe",
|
|
3541
|
+
failureKind: "local"
|
|
3542
|
+
};
|
|
3543
|
+
const byte = Number.parseInt(pair, 16);
|
|
3544
|
+
if (byte === 47 || byte === 92 || byte === 37) return {
|
|
3545
|
+
valid: false,
|
|
3546
|
+
kind: "unsafe",
|
|
3547
|
+
failureKind: "local"
|
|
3548
|
+
};
|
|
3549
|
+
bytes.push(byte);
|
|
3550
|
+
index += 2;
|
|
3551
|
+
continue;
|
|
3552
|
+
}
|
|
3553
|
+
if (!/^[A-Za-z0-9._~-]$/u.test(character)) return {
|
|
3554
|
+
valid: false,
|
|
3555
|
+
kind: "unsafe",
|
|
3556
|
+
failureKind: "local"
|
|
3557
|
+
};
|
|
3558
|
+
bytes.push(character.charCodeAt(0));
|
|
3559
|
+
}
|
|
3560
|
+
let decodedPath;
|
|
3561
|
+
try {
|
|
3562
|
+
decodedPath = new TextDecoder("utf-8", { fatal: true }).decode(Uint8Array.from(bytes));
|
|
3563
|
+
} catch {
|
|
3564
|
+
return {
|
|
3565
|
+
valid: false,
|
|
3566
|
+
kind: "unsafe",
|
|
3567
|
+
failureKind: "local"
|
|
3568
|
+
};
|
|
3569
|
+
}
|
|
3570
|
+
if (!isSafeDecodedPath(decodedPath) || encodeLocalPath(decodedPath) !== reference) return {
|
|
3571
|
+
valid: false,
|
|
3572
|
+
kind: "unsafe",
|
|
3573
|
+
failureKind: "local"
|
|
3574
|
+
};
|
|
3575
|
+
return {
|
|
3576
|
+
valid: true,
|
|
3577
|
+
kind: "local",
|
|
3578
|
+
decodedPath
|
|
3579
|
+
};
|
|
3580
|
+
}
|
|
3581
|
+
function unsafe(reference) {
|
|
3582
|
+
return {
|
|
3583
|
+
valid: false,
|
|
3584
|
+
kind: "unsafe",
|
|
3585
|
+
failureKind: reference.startsWith("//") || /^[a-z][a-z0-9+.-]*:/i.test(reference) ? "external" : "local"
|
|
3586
|
+
};
|
|
3587
|
+
}
|
|
3588
|
+
function isSafeDecodedPath(path) {
|
|
3589
|
+
if (path.length === 0 || path.startsWith("/") || path.includes("\\") || path.includes("%") || path.normalize("NFC") !== path || containsUnsafeUnicode(path) || /[<>:"|?*]/u.test(path)) return false;
|
|
3590
|
+
return path.split("/").every((component) => component.length > 0 && (component === "." || component === ".." || !/[. ]$/u.test(component)));
|
|
3591
|
+
}
|
|
3592
|
+
function encodeLocalPath(path) {
|
|
3593
|
+
const encoder = new TextEncoder();
|
|
3594
|
+
return path.split("/").map((component) => [...encoder.encode(component)].map((byte) => {
|
|
3595
|
+
const character = String.fromCharCode(byte);
|
|
3596
|
+
return /^[A-Za-z0-9._~-]$/u.test(character) ? character : `%${byte.toString(16).toUpperCase().padStart(2, "0")}`;
|
|
3597
|
+
}).join("")).join("/");
|
|
3598
|
+
}
|
|
3599
|
+
function containsUnsafeUnicode(value) {
|
|
3600
|
+
return /[\p{Cc}\p{Cf}\p{Cs}\p{Co}\p{Cn}\p{Default_Ignorable_Code_Point}\p{Bidi_Control}\p{Noncharacter_Code_Point}]/u.test(value);
|
|
3601
|
+
}
|
|
3602
|
+
function containsNonAscii(value) {
|
|
3603
|
+
for (const character of value) if ((character.codePointAt(0) ?? 0) > 127) return true;
|
|
3604
|
+
return false;
|
|
3605
|
+
}
|
|
3606
|
+
//#endregion
|
|
3607
|
+
//#region src/resource-references.ts
|
|
3608
|
+
/** Version of the portable compiled-content resource reference contract. */
|
|
3609
|
+
const TOPIK_RESOURCE_REFERENCE_VERSION = 1;
|
|
3610
|
+
const RESOURCE_NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u;
|
|
3611
|
+
const RESOURCE_REFERENCE = /^ref:\/\/(wiki-page|guide|course-page)\/([^/?#]+)(?:\?[^#]*)?(?:#.*)?$/iu;
|
|
3612
|
+
/** Parse an explicit resource identity without consulting routes or fetching a catalogue. */
|
|
3613
|
+
function parseTopikResourceReference(href) {
|
|
3614
|
+
if (typeof href !== "string" || /[\p{Cc}\p{White_Space}]/u.test(href)) return null;
|
|
3615
|
+
const match = RESOURCE_REFERENCE.exec(href);
|
|
3616
|
+
if (!match) return null;
|
|
3617
|
+
try {
|
|
3618
|
+
const name = decodeURIComponent(match[2]);
|
|
3619
|
+
if (name.length > 63 || !RESOURCE_NAME.test(name)) return null;
|
|
3620
|
+
const url = new URL(href);
|
|
3621
|
+
decodeURIComponent(url.hash);
|
|
3622
|
+
return {
|
|
3623
|
+
type: match[1].toLowerCase() === "wiki-page" ? "WikiPage" : match[1].toLowerCase() === "guide" ? "Guide" : "CoursePage",
|
|
3624
|
+
name,
|
|
3625
|
+
search: url.search,
|
|
3626
|
+
hash: url.hash.slice(1)
|
|
3627
|
+
};
|
|
3628
|
+
} catch {
|
|
3629
|
+
return null;
|
|
3630
|
+
}
|
|
3631
|
+
}
|
|
3632
|
+
/** Serialize an identity as a canonical ref:// URI; this is not a browser-facing URL. */
|
|
3633
|
+
function serializeTopikResourceReference(reference) {
|
|
3634
|
+
if (reference.type !== "WikiPage" && reference.type !== "Guide" && reference.type !== "CoursePage" || typeof reference.name !== "string" || reference.name.length > 63 || !RESOURCE_NAME.test(reference.name) || typeof reference.search !== "string" || reference.search !== "" && !reference.search.startsWith("?") || reference.search.includes("#") || typeof reference.hash !== "string") throw new TypeError("Resource reference is not valid");
|
|
3635
|
+
const kind = reference.type === "WikiPage" ? "wiki-page" : reference.type === "Guide" ? "guide" : "course-page";
|
|
3636
|
+
const parsed = parseTopikResourceReference(`ref://${kind}/${reference.name}${reference.search}${reference.hash ? `#${reference.hash}` : ""}`);
|
|
3637
|
+
if (!parsed) throw new TypeError("Resource reference is not valid");
|
|
3638
|
+
return `ref://${kind}/${parsed.name}${parsed.search}${parsed.hash ? `#${parsed.hash}` : ""}`;
|
|
3639
|
+
}
|
|
3640
|
+
//#endregion
|
|
3641
|
+
//#region src/diagnostics.ts
|
|
3642
|
+
const TOPIK_LINK_DIAGNOSTIC_MESSAGES = {
|
|
3643
|
+
"link-asset-invalid": "Asset link target is not a canonical generated name.",
|
|
3644
|
+
"link-asset-navigation-unsupported": "Navigation targets cannot use generated Asset names.",
|
|
3645
|
+
"link-fragment-not-found": "Link target heading was not found.",
|
|
3646
|
+
"link-href-empty": "Link target is required.",
|
|
3647
|
+
"link-page-not-found": "Internal link target page was not found.",
|
|
3648
|
+
"link-reference-ambiguous": "Resource reference target is ambiguous.",
|
|
3649
|
+
"link-reference-invalid": "Resource link target is not a valid reference.",
|
|
3650
|
+
"link-reference-unresolved": "Resource link target requires a browser URL resolver.",
|
|
3651
|
+
"link-scheme-unsafe": "Link scheme is unsafe.",
|
|
3652
|
+
"link-scheme-unsupported": "Link scheme is unsupported.",
|
|
3653
|
+
"link-url-credentials": "Link URL credentials are not supported.",
|
|
3654
|
+
"link-url-invalid": "Link target is not a valid URL reference.",
|
|
3655
|
+
"link-url-protocol-relative": "Protocol-relative link targets are not supported."
|
|
3656
|
+
};
|
|
3657
|
+
const TOPIK_CONTENT_DIAGNOSTIC_MESSAGES = {
|
|
3658
|
+
...TOPIK_LINK_DIAGNOSTIC_MESSAGES,
|
|
3659
|
+
TOPIK_ASSET_PATH_INVALID: "Local Asset reference is not canonical.",
|
|
3660
|
+
TOPIK_ASSET_REFERENCE_MALFORMED: "Asset reference has an invalid generated name.",
|
|
3661
|
+
TOPIK_EXTERNAL_REFERENCE_UNSAFE: "External Asset reference requires credential-free HTTPS.",
|
|
3662
|
+
"attribute-missing-required": "A required attribute is missing.",
|
|
3663
|
+
"attribute-type-invalid": "An attribute has an invalid type.",
|
|
3664
|
+
"attribute-undefined": "An attribute is not supported.",
|
|
3665
|
+
"attribute-value-invalid": "An attribute has an invalid value.",
|
|
3666
|
+
"child-invalid": "A child node is not supported in this location.",
|
|
3667
|
+
"duplicate-attribute": "An attribute is specified more than once.",
|
|
3668
|
+
"fence-tag-error": "A fenced tag is invalid.",
|
|
3669
|
+
"function-undefined": "A referenced function is not defined.",
|
|
3670
|
+
"heading-id-duplicate": "Explicit heading IDs must be unique within a document.",
|
|
3671
|
+
"missing-closing": "Content has a missing closing delimiter.",
|
|
3672
|
+
"missing-opening": "Content has a missing opening delimiter.",
|
|
3673
|
+
"node-undefined": "A node is not supported.",
|
|
3674
|
+
"no-inline-annotations": "Inline annotations are not supported in this location.",
|
|
3675
|
+
"parameter-missing-required": "A required function parameter is missing.",
|
|
3676
|
+
"parameter-type-invalid": "A function parameter has an invalid type.",
|
|
3677
|
+
"parameter-undefined": "A function parameter is not supported.",
|
|
3678
|
+
"parse-error": "Content could not be parsed.",
|
|
3679
|
+
"slot-missing-required": "A required slot is missing.",
|
|
3680
|
+
"slot-undefined": "A slot is not supported.",
|
|
3681
|
+
"table-syntax": "Table syntax is invalid.",
|
|
3682
|
+
"tag-placement-invalid": "A tag is not supported in this location.",
|
|
3683
|
+
"tag-selfclosing-has-children": "A self-closing tag cannot contain children.",
|
|
3684
|
+
"tag-undefined": "A tag is not supported.",
|
|
3685
|
+
"topik-accordion-children": "An accordion may contain only block content.",
|
|
3686
|
+
"topik-badge-children": "A badge requires inline content.",
|
|
3687
|
+
"topik-callout-children": "A callout may contain only block content.",
|
|
3688
|
+
"topik-card-children": "A card may contain only block content.",
|
|
3689
|
+
"topik-choice-children": "A choice may contain only block content.",
|
|
3690
|
+
"topik-code-group-children": "A code group contains an unsupported child.",
|
|
3691
|
+
"topik-code-group-requires-code-tab": "A code group requires at least one code tab.",
|
|
3692
|
+
"topik-code-group-parent-required": "A code tab must be nested inside a code group.",
|
|
3693
|
+
"topik-tabs-parent-required": "A tab must be nested inside tabs.",
|
|
3694
|
+
"topik-steps-parent-required": "A step must be nested inside steps.",
|
|
3695
|
+
"topik-code-tab-parent-required": "A code tab must be nested inside a code group.",
|
|
3696
|
+
"topik-code-tab-children": "A code tab may contain only fenced code blocks.",
|
|
3697
|
+
"topik-code-tab-requires-fence": "A code tab requires a fenced code block.",
|
|
3698
|
+
"topik-columns-range": "Card grid columns must be an integer from 1 to 4.",
|
|
3699
|
+
"topik-config-invalid": "Content configuration is invalid.",
|
|
3700
|
+
"topik-content-limit": "Content exceeds the supported complexity limits.",
|
|
3701
|
+
"topik-node-invalid": "A Markdown node cannot be represented without changing its meaning.",
|
|
3702
|
+
"topik-reference-missing": "A reference has no definition in its conditional scope.",
|
|
3703
|
+
"topik-reference-label": "A reference identifier has no faithful Markdown label.",
|
|
3704
|
+
"topik-write-failed": "Content could not be written without changing its meaning.",
|
|
3705
|
+
"topik-extension-failed": "Content extension validation failed.",
|
|
3706
|
+
"topik-explanation-children": "An explanation may contain only block content.",
|
|
3707
|
+
"topik-figure-children": "A figure cannot contain children; use its alt and caption attributes.",
|
|
3708
|
+
"topik-math-children": "Block math cannot contain children; use its content attribute.",
|
|
3709
|
+
"topik-math-inline-children": "Inline math cannot contain children; use its content attribute.",
|
|
3710
|
+
"topik-mark-nesting": "Emphasis and strong marks allow at most two levels each; strikethrough cannot be nested.",
|
|
3711
|
+
"topik-question-choice-count": "A question requires at least two choices.",
|
|
3712
|
+
"topik-question-children": "A question contains an unsupported child.",
|
|
3713
|
+
"topik-question-correct-choice-required": "A multiple-choice question requires at least one correct choice.",
|
|
3714
|
+
"topik-question-parent-required": "Choices and explanations must be nested inside a question.",
|
|
3715
|
+
"topik-question-single-correct-choice": "A single-choice question requires exactly one correct choice.",
|
|
3716
|
+
"topik-partial-cycle": "Partial references must not be cyclic.",
|
|
3717
|
+
"topik-partial-invalid": "Partial content is invalid or unavailable.",
|
|
3718
|
+
"topik-quiz-children": "A quiz contains an unsupported child.",
|
|
3719
|
+
"topik-quiz-requires-question": "A quiz requires at least one question.",
|
|
3720
|
+
"topik-step-parent-required": "A step must be nested inside steps.",
|
|
3721
|
+
"topik-step-children": "A step may contain only block content.",
|
|
3722
|
+
"topik-steps-children": "Steps contain an unsupported child.",
|
|
3723
|
+
"topik-steps-requires-step": "Steps require at least one step.",
|
|
3724
|
+
"topik-tab-parent-required": "A tab must be nested inside tabs.",
|
|
3725
|
+
"topik-tab-children": "A tab may contain only block content.",
|
|
3726
|
+
"topik-tabs-children": "Tabs contain an unsupported child.",
|
|
3727
|
+
"topik-tabs-requires-tab": "Tabs require at least one tab.",
|
|
3728
|
+
"topik-transform-failed": "Content transformation failed.",
|
|
3729
|
+
"topik-empty-paragraph": "Empty paragraphs cannot be written as Markdown.",
|
|
3730
|
+
"topik-frontmatter-placement": "Frontmatter must be the first node in the document.",
|
|
3731
|
+
"topik-variable-path": "Variable paths must use supported property names.",
|
|
3732
|
+
"topik-variable-placement": "Variables must appear in inline content.",
|
|
3733
|
+
"topik-template-syntax": "Text templates require valid variable tokens or escaped openers.",
|
|
3734
|
+
"topik-template-location": "Text templates are not supported in this attribute.",
|
|
3735
|
+
"topik-code-template-structure": "A code template requires exactly one closed fenced code block in the same content scope.",
|
|
3736
|
+
"topik-code-template-language": "Code templates require a language that displays plain text.",
|
|
3737
|
+
"topik-code-presentation-scope": "A presentation requires one code or code-template child; remove nested or extra blocks.",
|
|
3738
|
+
"topik-code-presentation-metadata": "Use fence attributes such as filename=\"client.ts\", lines and highlight=\"1,3-5\"; quote values containing spaces.",
|
|
3739
|
+
"topik-code-presentation-option": "Use supported presentation options with valid labels, booleans and integer bounds.",
|
|
3740
|
+
"topik-code-presentation-lines": "Select physical rows using positive numbers or closed ranges, separated by commas without spaces.",
|
|
3741
|
+
"topik-code-presentation-line-bounds": "Keep line selections within the physical payload rows; startLine changes labels only.",
|
|
3742
|
+
"topik-code-presentation-diff-conflict": "Choose disjoint added and removed row selections.",
|
|
3743
|
+
"topik-code-presentation-language": "Use presentation attributes with text code languages rather than interpreted Mermaid diagrams.",
|
|
3744
|
+
"topik-template-variable-missing": "A template variable is not defined.",
|
|
3745
|
+
"topik-template-variable-type": "Template variables require strings, finite numbers, booleans, or null.",
|
|
3746
|
+
"topik-template-control": "Template text contains an unsupported control character.",
|
|
3747
|
+
"topik-expression-invalid": "The conditional expression is not supported.",
|
|
3748
|
+
"topik-conditional-placement": "Conditions must appear in ordinary block content.",
|
|
3749
|
+
"topik-conditional-branches": "A condition requires one branch and at most one alternative.",
|
|
3750
|
+
"topik-conditional-children": "Conditional branches must contain block content.",
|
|
3751
|
+
"topik-tag-children": "The component requires a different opening or closing form.",
|
|
3752
|
+
"topik-tag-placement": "The component cannot appear in this location.",
|
|
3753
|
+
"topik-card-grid-children": "A card grid may contain only cards.",
|
|
3754
|
+
"topik-quiz-parent-required": "A question must be nested inside a quiz.",
|
|
3755
|
+
"topik-underline-children": "Underline requires inline content.",
|
|
3756
|
+
"topik-u-children": "Underline requires inline content.",
|
|
3757
|
+
"variable-undefined": "A referenced variable is not defined."
|
|
3758
|
+
};
|
|
3759
|
+
const DIAGNOSTIC_URL_BASE = new URL("https://topik.invalid/");
|
|
3760
|
+
const EXPLICIT_URL_SCHEME = /^[a-z][a-z0-9+.-]*:/iu;
|
|
3761
|
+
const WINDOWS_DRIVE_PREFIX = /^[a-z]:/iu;
|
|
3762
|
+
/** Fixed public wording for link diagnostics; authored targets are never accepted as input. */
|
|
3763
|
+
function topikLinkDiagnosticMessage(id) {
|
|
3764
|
+
return Object.hasOwn(TOPIK_LINK_DIAGNOSTIC_MESSAGES, id) ? TOPIK_LINK_DIAGNOSTIC_MESSAGES[id] : void 0;
|
|
3765
|
+
}
|
|
3766
|
+
/** Re-apply fixed public messages and safe file labels when diagnostics cross package layers. */
|
|
3767
|
+
function sanitizeTopikContentDiagnostic(diagnostic) {
|
|
3768
|
+
const message = Object.hasOwn(TOPIK_CONTENT_DIAGNOSTIC_MESSAGES, diagnostic.id) ? TOPIK_CONTENT_DIAGNOSTIC_MESSAGES[diagnostic.id] : "Content validation failed.";
|
|
3769
|
+
const file = sanitizeTopikDiagnosticFile(diagnostic.file);
|
|
3770
|
+
const { file: _untrustedFile, message: _untrustedMessage, attribute, variable, option, column, ...safe } = diagnostic;
|
|
3771
|
+
const template = diagnostic.id.startsWith("topik-template-") || diagnostic.id === "topik-variable-path";
|
|
3772
|
+
return {
|
|
3773
|
+
...safe,
|
|
3774
|
+
message,
|
|
3775
|
+
...diagnostic.id.startsWith("topik-code-presentation-") && typeof option === "string" && [
|
|
3776
|
+
"filename",
|
|
3777
|
+
"title",
|
|
3778
|
+
"lineNumbers",
|
|
3779
|
+
"startLine",
|
|
3780
|
+
"highlight",
|
|
3781
|
+
"focus",
|
|
3782
|
+
"collapse",
|
|
3783
|
+
"wrap",
|
|
3784
|
+
"added",
|
|
3785
|
+
"removed"
|
|
3786
|
+
].includes(option) ? { option } : {},
|
|
3787
|
+
...diagnostic.id.startsWith("topik-code-presentation-") && typeof column === "number" && Number.isSafeInteger(column) && column > 0 && column <= 1e6 ? { column } : {},
|
|
3788
|
+
...file === void 0 ? {} : { file },
|
|
3789
|
+
...template && attribute !== void 0 && [
|
|
3790
|
+
"title",
|
|
3791
|
+
"alt",
|
|
3792
|
+
"caption"
|
|
3793
|
+
].includes(attribute) ? { attribute } : {},
|
|
3794
|
+
...template && typeof variable === "string" && variable.length <= 256 && /^[A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)*$/.test(variable) ? { variable } : {}
|
|
3795
|
+
};
|
|
3796
|
+
}
|
|
3797
|
+
function toTopikContentDiagnostic(error) {
|
|
3798
|
+
return sanitizeTopikContentDiagnostic({
|
|
3799
|
+
id: error.error.id,
|
|
3800
|
+
type: error.type,
|
|
3801
|
+
level: error.error.level,
|
|
3802
|
+
message: error.error.message,
|
|
3803
|
+
lines: error.lines,
|
|
3804
|
+
...error.location?.file ? { file: error.location.file } : {}
|
|
3805
|
+
});
|
|
3806
|
+
}
|
|
3807
|
+
/** Convert an untrusted diagnostic location to a browser-compatible safe label. */
|
|
3808
|
+
function sanitizeTopikDiagnosticFile(file) {
|
|
3809
|
+
if (file === void 0) return void 0;
|
|
3810
|
+
if (hasAmbiguousDiagnosticCharacter(file) || hasHtmlCharacterReference(file)) return "content";
|
|
3811
|
+
if (WINDOWS_DRIVE_PREFIX.test(file) || file.startsWith("\\")) return safeDiagnosticBasename(file);
|
|
3812
|
+
if (EXPLICIT_URL_SCHEME.test(file) || file.startsWith("//") || file.includes("?") || file.includes("#")) try {
|
|
3813
|
+
const url = new URL(file, DIAGNOSTIC_URL_BASE);
|
|
3814
|
+
return url.pathname.startsWith("/") ? safeDiagnosticBasename(url.pathname) : "content";
|
|
3815
|
+
} catch {
|
|
3816
|
+
return "content";
|
|
3817
|
+
}
|
|
3818
|
+
if (file.startsWith("/")) return safeDiagnosticBasename(file);
|
|
3819
|
+
return isSafeRelativeDiagnosticLabel(file) ? file : "content";
|
|
3820
|
+
}
|
|
3821
|
+
function hasAmbiguousDiagnosticCharacter(value) {
|
|
3822
|
+
return /[%\p{Cc}\p{Cf}\p{Separator}\p{White_Space}]/u.test(value);
|
|
3823
|
+
}
|
|
3824
|
+
function hasHtmlCharacterReference(value) {
|
|
3825
|
+
return /&(?:#(?:x[0-9a-f]*|[0-9]*)|[a-z][a-z0-9]*);?/iu.test(value);
|
|
3826
|
+
}
|
|
3827
|
+
function safeDiagnosticBasename(file) {
|
|
3828
|
+
const label = file.replaceAll("\\", "/").split("/").at(-1)?.split(/[?#]/u, 1)[0];
|
|
3829
|
+
return label !== void 0 && isSafeRelativeDiagnosticLabel(label) ? label : "content";
|
|
3830
|
+
}
|
|
3831
|
+
function isSafeRelativeDiagnosticLabel(value) {
|
|
3832
|
+
return /^(?!\.\.?$)[a-z0-9._-]+(?:[\\/][a-z0-9._-]+)*$/iu.test(value);
|
|
3833
|
+
}
|
|
3834
|
+
//#endregion
|
|
3835
|
+
//#region src/links.ts
|
|
3836
|
+
const ALLOWED_SCHEMES = /* @__PURE__ */ new Set([
|
|
3837
|
+
"asset",
|
|
3838
|
+
"http",
|
|
3839
|
+
"https",
|
|
3840
|
+
"mailto",
|
|
3841
|
+
"tel"
|
|
3842
|
+
]);
|
|
3843
|
+
const UNSAFE_SCHEMES = /* @__PURE__ */ new Set([
|
|
3844
|
+
"data",
|
|
3845
|
+
"javascript",
|
|
3846
|
+
"vbscript"
|
|
3847
|
+
]);
|
|
3848
|
+
const SCHEME = /^([a-z][a-z0-9+.-]*):/i;
|
|
3849
|
+
const TOPIK_BASE_URL = new URL("https://topik.local/");
|
|
3850
|
+
function analyzeTopikContent(source, options = {}) {
|
|
3851
|
+
return analyzeDocument(parseTopikContent(source, {
|
|
3852
|
+
file: options.file,
|
|
3853
|
+
config: options.config
|
|
3854
|
+
}), options.file);
|
|
3855
|
+
}
|
|
3856
|
+
/** @internal Share analysis with validation without reparsing source. */
|
|
3857
|
+
function analyzeDocument(ast, file, index = indexDocument(ast)) {
|
|
3858
|
+
const headingNodes = index.entries.filter(({ node }) => node.type === "heading").map(({ node }) => node);
|
|
3859
|
+
const explicitIdNodes = /* @__PURE__ */ new Map();
|
|
3860
|
+
for (const node of headingNodes) {
|
|
3861
|
+
const id = node.data?.topikId;
|
|
3862
|
+
if (!id) continue;
|
|
3863
|
+
const nodes = explicitIdNodes.get(id) ?? [];
|
|
3864
|
+
nodes.push(node);
|
|
3865
|
+
explicitIdNodes.set(id, nodes);
|
|
3866
|
+
}
|
|
3867
|
+
const headings = assignHeadingIds(headingNodes).map((heading, index) => ({
|
|
3868
|
+
...heading,
|
|
3869
|
+
...locationFields(headingNodes[index], file)
|
|
3870
|
+
}));
|
|
3871
|
+
const diagnostics = [];
|
|
3872
|
+
for (const [id, nodes] of explicitIdNodes) for (const node of nodes.slice(1)) diagnostics.push({
|
|
3873
|
+
id: "heading-id-duplicate",
|
|
3874
|
+
type: "heading",
|
|
3875
|
+
level: "error",
|
|
3876
|
+
message: `Explicit heading ID '${id}' is used more than once in this document.`,
|
|
3877
|
+
...locationFields(node, file)
|
|
3878
|
+
});
|
|
3879
|
+
const links = [];
|
|
3880
|
+
for (const { node } of index.entries) {
|
|
3881
|
+
const kind = linkKind(node);
|
|
3882
|
+
if (!kind) continue;
|
|
3883
|
+
const resource = node;
|
|
3884
|
+
const href = kind === "card" ? node.props?.href : isReference(node) ? index.resolve(node)?.url : resource.url;
|
|
3885
|
+
if (typeof href === "string") links.push({
|
|
3886
|
+
href,
|
|
3887
|
+
kind,
|
|
3888
|
+
...locationFields(node, file)
|
|
3889
|
+
});
|
|
3890
|
+
}
|
|
3891
|
+
return {
|
|
3892
|
+
diagnostics: diagnostics.map(sanitizeTopikContentDiagnostic),
|
|
3893
|
+
headings,
|
|
3894
|
+
links
|
|
3895
|
+
};
|
|
3896
|
+
}
|
|
3897
|
+
function validateTopikHref(value) {
|
|
3898
|
+
if (typeof value !== "string") return [];
|
|
3899
|
+
if (value.length === 0) return [linkError("link-href-empty")];
|
|
3900
|
+
if (value !== value.trim()) return [linkError("link-url-invalid")];
|
|
3901
|
+
if (hasAsciiControl(value)) return [linkError("link-url-invalid")];
|
|
3902
|
+
if (isNetworkPathReference(value)) return [linkError("link-url-protocol-relative")];
|
|
3903
|
+
const explicitScheme = SCHEME.exec(value)?.[1].toLowerCase();
|
|
3904
|
+
if (explicitScheme === "ref") return parseTopikResourceReference(value) ? [] : [linkError("link-reference-invalid")];
|
|
3905
|
+
if (explicitScheme === "asset") return isTopikGeneratedAssetName(value.slice(6)) ? [] : [linkError("link-asset-invalid")];
|
|
3906
|
+
try {
|
|
3907
|
+
const parsed = new URL(value, TOPIK_BASE_URL);
|
|
3908
|
+
const scheme = parsed.protocol.slice(0, -1).toLowerCase();
|
|
3909
|
+
if (!ALLOWED_SCHEMES.has(scheme)) return [linkError(UNSAFE_SCHEMES.has(scheme) ? "link-scheme-unsafe" : "link-scheme-unsupported")];
|
|
3910
|
+
if (!explicitScheme && parsed.origin !== TOPIK_BASE_URL.origin) return [linkError("link-url-protocol-relative")];
|
|
3911
|
+
if (parsed.username !== "" || parsed.password !== "") return [linkError("link-url-credentials")];
|
|
3912
|
+
if ((scheme === "http" || scheme === "https") && !parsed.hostname) return [linkError("link-url-invalid")];
|
|
3913
|
+
if ((scheme === "mailto" || scheme === "tel") && !parsed.pathname) return [linkError("link-url-invalid")];
|
|
3914
|
+
if (parsed.hash) decodeURIComponent(parsed.hash.slice(1));
|
|
3915
|
+
} catch {
|
|
3916
|
+
return [linkError("link-url-invalid")];
|
|
3917
|
+
}
|
|
3918
|
+
return [];
|
|
3919
|
+
}
|
|
3920
|
+
/** Admit only resolved hrefs at a boundary that can emit browser navigation. */
|
|
3921
|
+
function validateTopikBrowserHref(value) {
|
|
3922
|
+
if (typeof value === "string" && SCHEME.exec(value)?.[1].toLowerCase() === "ref") return [linkError("link-reference-unresolved")];
|
|
3923
|
+
return validateTopikHref(value);
|
|
3924
|
+
}
|
|
3925
|
+
function validateTopikNavigationHref(value) {
|
|
3926
|
+
if (typeof value === "string" && SCHEME.exec(value)?.[1].toLowerCase() === "asset") return [linkError("link-asset-navigation-unsupported")];
|
|
3927
|
+
return validateTopikHref(value);
|
|
3928
|
+
}
|
|
3929
|
+
/** Remove invalid navigation attributes before a renderer can emit a browser-facing URL. */
|
|
3930
|
+
function removeInvalidTopikNavigationReferences(root) {
|
|
3931
|
+
walkContent(root, (node) => {
|
|
3932
|
+
if (node.type !== "topikComponent" || node.name !== "card") return;
|
|
3933
|
+
const href = node.props?.href;
|
|
3934
|
+
if (href !== void 0 && validateTopikNavigationHref(href).length > 0) delete node.props.href;
|
|
3935
|
+
});
|
|
3936
|
+
}
|
|
3937
|
+
function linkKind(node) {
|
|
3938
|
+
if (node.type === "link" || node.type === "linkReference") return "link";
|
|
3939
|
+
if (node.type === "topikComponent" && node.name === "card") return "card";
|
|
3940
|
+
}
|
|
3941
|
+
function locationFields(node, file) {
|
|
3942
|
+
return {
|
|
3943
|
+
...file ? { file } : {},
|
|
3944
|
+
lines: node.position ? [node.position.start.line] : []
|
|
3945
|
+
};
|
|
3946
|
+
}
|
|
3947
|
+
function linkError(id) {
|
|
3948
|
+
const message = topikLinkDiagnosticMessage(id);
|
|
3949
|
+
if (message === void 0) throw new TypeError("Unknown link diagnostic identifier");
|
|
3950
|
+
return {
|
|
3951
|
+
id,
|
|
3952
|
+
level: "error",
|
|
3953
|
+
message
|
|
3954
|
+
};
|
|
3955
|
+
}
|
|
3956
|
+
function hasAsciiControl(value) {
|
|
3957
|
+
for (let index = 0; index < value.length; index++) {
|
|
3958
|
+
const code = value.charCodeAt(index);
|
|
3959
|
+
if (code <= 31 || code === 127) return true;
|
|
3960
|
+
}
|
|
3961
|
+
return false;
|
|
3962
|
+
}
|
|
3963
|
+
function isNetworkPathReference(value) {
|
|
3964
|
+
const isPathSeparator = (character) => character === "/" || character === "\\";
|
|
3965
|
+
return isPathSeparator(value[0]) && isPathSeparator(value[1]);
|
|
3966
|
+
}
|
|
3967
|
+
//#endregion
|
|
3968
|
+
//#region src/asset-references.ts
|
|
3969
|
+
/**
|
|
3970
|
+
* Closed registry for topik-asset-reference-v1. Extractors never infer a slot from an
|
|
3971
|
+
* attribute name and never recursively inspect arbitrary strings.
|
|
3972
|
+
*/
|
|
3973
|
+
const topikAssetReferenceSlots = Object.freeze([
|
|
3974
|
+
{
|
|
3975
|
+
node: "image",
|
|
3976
|
+
attribute: "src",
|
|
3977
|
+
slot: "image.src",
|
|
3978
|
+
role: "image"
|
|
3979
|
+
},
|
|
3980
|
+
{
|
|
3981
|
+
node: "tag",
|
|
3982
|
+
tag: "figure",
|
|
3983
|
+
attribute: "src",
|
|
3984
|
+
slot: "figure.src",
|
|
3985
|
+
role: "image-light"
|
|
3986
|
+
},
|
|
3987
|
+
{
|
|
3988
|
+
node: "tag",
|
|
3989
|
+
tag: "figure",
|
|
3990
|
+
attribute: "darkSrc",
|
|
3991
|
+
slot: "figure.darkSrc",
|
|
3992
|
+
role: "image-dark"
|
|
3993
|
+
},
|
|
3994
|
+
{
|
|
3995
|
+
node: "link",
|
|
3996
|
+
attribute: "href",
|
|
3997
|
+
slot: "link.href",
|
|
3998
|
+
role: "download",
|
|
3999
|
+
conditional: "proven-download"
|
|
4000
|
+
}
|
|
4001
|
+
]);
|
|
4002
|
+
for (const slot of topikAssetReferenceSlots) Object.freeze(slot);
|
|
4003
|
+
/** @internal Ordinary navigation shares URL policy, but is not an asset location. */
|
|
4004
|
+
function requiresAssetValidation(occurrence) {
|
|
4005
|
+
return occurrence.slot !== "link.href" || occurrence.kind === "asset" || occurrence.kind === "reserved-asset" || /^https?:/iu.test(occurrence.parsedReference);
|
|
4006
|
+
}
|
|
4007
|
+
function reservedScheme(value) {
|
|
4008
|
+
const decoded = value.replace(/%([0-9a-f]{2})/giu, (_, hex) => String.fromCharCode(Number.parseInt(hex, 16)));
|
|
4009
|
+
return /^asset:/iu.test(decoded);
|
|
4010
|
+
}
|
|
4011
|
+
function classify(reference, parsed) {
|
|
4012
|
+
if (reservedScheme(reference) || reservedScheme(parsed)) {
|
|
4013
|
+
const result = validateTopikAssetReference(reference);
|
|
4014
|
+
return reference === parsed && result.valid && result.kind === "asset" ? "asset" : "reserved-asset";
|
|
4015
|
+
}
|
|
4016
|
+
return reference === parsed ? validateTopikAssetReference(reference).kind : "unsafe";
|
|
4017
|
+
}
|
|
4018
|
+
/** @internal Keep the source node available for diagnostics without extending the occurrence protocol. */
|
|
4019
|
+
function locateDocumentAssetOccurrences(document, source, options, index = indexDocument(document)) {
|
|
4020
|
+
const proven = new Set(options.provenDownloadPaths ?? []);
|
|
4021
|
+
const referenceFromSource = source === void 0 ? void 0 : createReferenceSourceLookup(source, () => indexDocument(parseTopikContent(source, { config: options.config })));
|
|
4022
|
+
const result = [];
|
|
4023
|
+
for (const { node: untyped, path } of index.entries) {
|
|
4024
|
+
const node = untyped;
|
|
4025
|
+
const referenceForm = node.type === "imageReference" || node.type === "linkReference";
|
|
4026
|
+
const definition = isReference(node) ? index.resolve(node) : void 0;
|
|
4027
|
+
const image = node.type === "image" || node.type === "imageReference";
|
|
4028
|
+
const link = node.type === "link" || node.type === "linkReference";
|
|
4029
|
+
const figure = node.type === "topikComponent" && node.name === "figure";
|
|
4030
|
+
if (!image && !link && !figure) continue;
|
|
4031
|
+
for (const slot of topikAssetReferenceSlots) {
|
|
4032
|
+
if (slot.node === "image" ? !image : slot.node === "link" ? !link : !figure) continue;
|
|
4033
|
+
const resource = definition ?? node;
|
|
4034
|
+
const parsedReference = figure ? node.props?.[slot.attribute] : resource.url;
|
|
4035
|
+
if (typeof parsedReference !== "string") continue;
|
|
4036
|
+
const reference = figure || referenceFromSource === void 0 ? parsedReference : referenceFromSource(resource);
|
|
4037
|
+
const kind = classify(reference, parsedReference);
|
|
4038
|
+
if (link && !options.includeGenericLinkCandidates && kind !== "asset" && kind !== "reserved-asset") {
|
|
4039
|
+
const validation = validateTopikAssetReference(reference);
|
|
4040
|
+
if (!validation.valid || validation.kind !== "local" || !proven.has(validation.decodedPath)) continue;
|
|
4041
|
+
}
|
|
4042
|
+
const alt = figure ? node.props?.alt : node.alt;
|
|
4043
|
+
const title = resource.title;
|
|
4044
|
+
const caption = figure ? node.props?.caption : void 0;
|
|
4045
|
+
const occurrence = {
|
|
4046
|
+
position: `${path.map((index) => `/children/${index}`).join("")}/attributes/${slot.attribute}`,
|
|
4047
|
+
treePath: path,
|
|
4048
|
+
slot: slot.slot,
|
|
4049
|
+
role: slot.role,
|
|
4050
|
+
parsedReference,
|
|
4051
|
+
reference,
|
|
4052
|
+
kind,
|
|
4053
|
+
semantics: {
|
|
4054
|
+
...typeof alt === "string" ? {
|
|
4055
|
+
alt,
|
|
4056
|
+
decorative: alt.length === 0
|
|
4057
|
+
} : {},
|
|
4058
|
+
...alt && typeof alt === "object" ? { altTemplate: structuredClone(alt) } : {},
|
|
4059
|
+
...typeof title === "string" ? { title } : {},
|
|
4060
|
+
...typeof caption === "string" ? { caption } : {},
|
|
4061
|
+
...caption && typeof caption === "object" ? { captionTemplate: structuredClone(caption) } : {},
|
|
4062
|
+
...slot.slot === "figure.src" ? { lightDarkRole: "light" } : {},
|
|
4063
|
+
...slot.slot === "figure.darkSrc" ? { lightDarkRole: "dark" } : {},
|
|
4064
|
+
...link ? { linkLabel: plainText(node) } : {}
|
|
4065
|
+
}
|
|
4066
|
+
};
|
|
4067
|
+
result.push({
|
|
4068
|
+
occurrence,
|
|
4069
|
+
node,
|
|
4070
|
+
replace(value) {
|
|
4071
|
+
if (figure) {
|
|
4072
|
+
node.props[slot.attribute] = value;
|
|
4073
|
+
return;
|
|
4074
|
+
}
|
|
4075
|
+
if (referenceForm) {
|
|
4076
|
+
node.type = image ? "image" : "link";
|
|
4077
|
+
node.title = definition?.title ?? null;
|
|
4078
|
+
delete node.identifier;
|
|
4079
|
+
delete node.referenceType;
|
|
4080
|
+
delete node.label;
|
|
4081
|
+
}
|
|
4082
|
+
node.url = value;
|
|
4083
|
+
}
|
|
4084
|
+
});
|
|
4085
|
+
}
|
|
4086
|
+
}
|
|
4087
|
+
return result;
|
|
4088
|
+
}
|
|
4089
|
+
function extractTopikAssetOccurrences(source, options = {}) {
|
|
4090
|
+
return extractDocumentAssetOccurrences(parseTopikContent(source, { config: options.config }), source, options);
|
|
4091
|
+
}
|
|
4092
|
+
/** @internal Reuse the parsed document during source admission. */
|
|
4093
|
+
function extractDocumentAssetOccurrences(document, source, options = {}, index) {
|
|
4094
|
+
return locateDocumentAssetOccurrences(document, source, options, index).map(({ occurrence }) => occurrence);
|
|
4095
|
+
}
|
|
4096
|
+
/** @internal Validation must complete before user replacement callbacks run. */
|
|
4097
|
+
function rewriteDocumentAssetOccurrences(document, source, replace, options = {}, index) {
|
|
4098
|
+
for (const located of locateDocumentAssetOccurrences(document, source, options, index)) {
|
|
4099
|
+
const value = replace(located.occurrence);
|
|
4100
|
+
if (value !== void 0) located.replace(value);
|
|
4101
|
+
}
|
|
4102
|
+
}
|
|
4103
|
+
/** Remove unsafe destinations from a preview tree, retaining readable descriptions. */
|
|
4104
|
+
function removeInvalidTopikAssetReferences(document, source, options = {}) {
|
|
4105
|
+
const removed = /* @__PURE__ */ new Set();
|
|
4106
|
+
for (const { occurrence, node } of locateDocumentAssetOccurrences(document, source, {
|
|
4107
|
+
...options,
|
|
4108
|
+
includeGenericLinkCandidates: true
|
|
4109
|
+
})) {
|
|
4110
|
+
if (!(occurrence.slot === "link.href" && validateTopikHref(occurrence.parsedReference).length > 0 || requiresAssetValidation(occurrence) && (occurrence.kind === "unsafe" || occurrence.kind === "reserved-asset"))) continue;
|
|
4111
|
+
if (occurrence.slot === "figure.darkSrc") delete node.props.darkSrc;
|
|
4112
|
+
else removed.add(node);
|
|
4113
|
+
}
|
|
4114
|
+
if (removed.size === 0) return;
|
|
4115
|
+
function sanitize(node, inline = false) {
|
|
4116
|
+
const hadChildren = Boolean(node.children?.length);
|
|
4117
|
+
if (node.children) {
|
|
4118
|
+
const inlineChildren = inlineParentTypes.has(node.type) || node.type === "topikComponent" && inline;
|
|
4119
|
+
node.children = node.children.flatMap((child) => sanitize(child, inlineChildren));
|
|
4120
|
+
if (inlineParentTypes.has(node.type) && node.type !== "link" && node.type !== "linkReference") while (node.children.at(-1)?.type === "break") node.children.pop();
|
|
4121
|
+
}
|
|
4122
|
+
if (removed.has(node)) {
|
|
4123
|
+
if (node.type === "link" || node.type === "linkReference") return node.children ?? [];
|
|
4124
|
+
const figure = node.type === "topikComponent";
|
|
4125
|
+
const value = figure ? [node.props?.alt, node.props?.caption].filter((part) => typeof part === "string" && part.length > 0).join("\n") : node.alt ?? "";
|
|
4126
|
+
if (!value) return [];
|
|
4127
|
+
const text = {
|
|
4128
|
+
type: "text",
|
|
4129
|
+
value,
|
|
4130
|
+
position: node.position
|
|
4131
|
+
};
|
|
4132
|
+
return figure ? [{
|
|
4133
|
+
type: "paragraph",
|
|
4134
|
+
children: [text],
|
|
4135
|
+
position: node.position
|
|
4136
|
+
}] : [text];
|
|
4137
|
+
}
|
|
4138
|
+
if (hadChildren && node.children?.length === 0 && ([
|
|
4139
|
+
"paragraph",
|
|
4140
|
+
"emphasis",
|
|
4141
|
+
"strong",
|
|
4142
|
+
"delete"
|
|
4143
|
+
].includes(node.type) || node.type === "topikComponent" && inline)) return [];
|
|
4144
|
+
return [node];
|
|
4145
|
+
}
|
|
4146
|
+
sanitize(document);
|
|
4147
|
+
}
|
|
4148
|
+
//#endregion
|
|
4149
|
+
//#region src/custom-assets.ts
|
|
4150
|
+
/** Custom assets share URL admission, but do not extend the compiler's closed slot protocol. */
|
|
4151
|
+
function validateCustomAssetReferences(document, registry, allowCompiled, index = indexDocument(document)) {
|
|
4152
|
+
const issues = [];
|
|
4153
|
+
for (const { node } of index.entries) {
|
|
4154
|
+
if (node.type !== "topikComponent" || !node.name || Object.hasOwn(components, node.name)) continue;
|
|
4155
|
+
const definition = Object.hasOwn(registry, node.name) ? registry[node.name] : void 0;
|
|
4156
|
+
if (!definition) continue;
|
|
4157
|
+
const props = getEffectiveProps({
|
|
4158
|
+
name: node.name,
|
|
4159
|
+
props: node.props ?? {}
|
|
4160
|
+
}, registry);
|
|
4161
|
+
for (const [name, attribute] of Object.entries(definition.attributes)) {
|
|
4162
|
+
if (!attribute.asset && !attribute.assetReference) continue;
|
|
4163
|
+
const reference = props[name];
|
|
4164
|
+
if (typeof reference !== "string") continue;
|
|
4165
|
+
const result = validateTopikAssetReference(reference);
|
|
4166
|
+
if (result.valid && (result.kind !== "asset" || allowCompiled)) continue;
|
|
4167
|
+
issues.push({
|
|
4168
|
+
id: /^asset:/iu.test(reference) ? "TOPIK_ASSET_REFERENCE_MALFORMED" : !result.valid && result.failureKind === "external" ? "TOPIK_EXTERNAL_REFERENCE_UNSAFE" : "TOPIK_ASSET_PATH_INVALID",
|
|
4169
|
+
type: `${node.name}.${name}`,
|
|
4170
|
+
lines: node.position ? [node.position.start.line] : []
|
|
4171
|
+
});
|
|
4172
|
+
}
|
|
4173
|
+
}
|
|
4174
|
+
return issues;
|
|
4175
|
+
}
|
|
4176
|
+
//#endregion
|
|
4177
|
+
//#region src/source.ts
|
|
4178
|
+
function writeFailure(source, file) {
|
|
4179
|
+
return {
|
|
4180
|
+
ok: false,
|
|
4181
|
+
source,
|
|
4182
|
+
diagnostics: [sanitizeTopikContentDiagnostic({
|
|
4183
|
+
id: "topik-write-failed",
|
|
4184
|
+
type: "document",
|
|
4185
|
+
level: "error",
|
|
4186
|
+
message: "",
|
|
4187
|
+
lines: [],
|
|
4188
|
+
...file === void 0 ? {} : { file }
|
|
4189
|
+
})]
|
|
4190
|
+
};
|
|
4191
|
+
}
|
|
4192
|
+
function prepareSource(source, options = {}) {
|
|
4193
|
+
const errors = [];
|
|
4194
|
+
function add(id, type = "document", lines = [], option, column) {
|
|
4195
|
+
errors.push(sanitizeTopikContentDiagnostic({
|
|
4196
|
+
id,
|
|
4197
|
+
type,
|
|
4198
|
+
level: [
|
|
4199
|
+
"topik-config-invalid",
|
|
4200
|
+
"tag-undefined",
|
|
4201
|
+
"node-undefined",
|
|
4202
|
+
"parse-error",
|
|
4203
|
+
"topik-extension-failed"
|
|
4204
|
+
].includes(id) ? "critical" : "error",
|
|
4205
|
+
message: "",
|
|
4206
|
+
lines,
|
|
4207
|
+
...option === void 0 ? {} : { option },
|
|
4208
|
+
...column === void 0 ? {} : { column },
|
|
4209
|
+
...options.file === void 0 ? {} : { file: options.file }
|
|
4210
|
+
}));
|
|
4211
|
+
}
|
|
4212
|
+
function failure() {
|
|
4213
|
+
return {
|
|
4214
|
+
ok: false,
|
|
4215
|
+
source,
|
|
4216
|
+
diagnostics: [...new Map(errors.map((error) => [JSON.stringify(error), error])).values()]
|
|
4217
|
+
};
|
|
4218
|
+
}
|
|
4219
|
+
let config;
|
|
4220
|
+
try {
|
|
4221
|
+
config = mergeTopikContentConfig(options.config);
|
|
4222
|
+
} catch {
|
|
4223
|
+
add("topik-config-invalid");
|
|
4224
|
+
return failure();
|
|
4225
|
+
}
|
|
4226
|
+
try {
|
|
4227
|
+
const parsed = parseDocumentTree(source, config.components);
|
|
4228
|
+
for (const diagnostic of parsed.diagnostics) add(diagnostic.id ?? "parse-error", diagnostic.type, diagnostic.line ? [diagnostic.line] : [], diagnostic.option, diagnostic.column);
|
|
4229
|
+
if (!parsed.document) return failure();
|
|
4230
|
+
const index = parsed.index ?? indexDocument(parsed.document);
|
|
4231
|
+
for (const issue of validateCustomAssetReferences(parsed.document, config.components, options.allowCompiledAssetReferences === true, index)) add(issue.id, issue.type, issue.lines);
|
|
4232
|
+
const analysis = analyzeDocument(parsed.document, options.file, index);
|
|
4233
|
+
errors.push(...analysis.diagnostics);
|
|
4234
|
+
for (const link of analysis.links) {
|
|
4235
|
+
const validate = link.kind === "card" ? validateTopikNavigationHref : validateTopikHref;
|
|
4236
|
+
for (const issue of validate(link.href)) add(issue.id, link.kind, link.lines);
|
|
4237
|
+
}
|
|
4238
|
+
for (const { occurrence, node } of locateDocumentAssetOccurrences(parsed.document, source, { includeGenericLinkCandidates: true }, index)) {
|
|
4239
|
+
if (!requiresAssetValidation(occurrence)) continue;
|
|
4240
|
+
const validation = validateTopikAssetReference(occurrence.reference || occurrence.parsedReference);
|
|
4241
|
+
if (validation.valid && occurrence.kind !== "unsafe" && (validation.kind !== "asset" || options.allowCompiledAssetReferences)) continue;
|
|
4242
|
+
const reserved = occurrence.kind === "asset" || occurrence.kind === "reserved-asset";
|
|
4243
|
+
const external = validation.valid ? validation.kind === "external-https" : validation.failureKind === "external";
|
|
4244
|
+
add(reserved ? "TOPIK_ASSET_REFERENCE_MALFORMED" : external ? "TOPIK_EXTERNAL_REFERENCE_UNSAFE" : "TOPIK_ASSET_PATH_INVALID", occurrence.slot, node.position ? [node.position.start.line] : []);
|
|
4245
|
+
}
|
|
4246
|
+
if (errors.length === 0) return {
|
|
4247
|
+
ok: true,
|
|
4248
|
+
source,
|
|
4249
|
+
document: parsed.document,
|
|
4250
|
+
config,
|
|
4251
|
+
index,
|
|
4252
|
+
diagnostics: []
|
|
4253
|
+
};
|
|
4254
|
+
} catch {
|
|
4255
|
+
add("topik-extension-failed");
|
|
4256
|
+
}
|
|
4257
|
+
return failure();
|
|
4258
|
+
}
|
|
4259
|
+
//#endregion
|
|
4260
|
+
//#region src/validate.ts
|
|
4261
|
+
/** Source admission is the same boundary used by formatting and asset rewriting. */
|
|
4262
|
+
function validateTopikContent(source, options = {}) {
|
|
4263
|
+
const result = prepareSource(source, options);
|
|
4264
|
+
return {
|
|
4265
|
+
source,
|
|
4266
|
+
valid: result.ok,
|
|
4267
|
+
errors: result.diagnostics
|
|
4268
|
+
};
|
|
4269
|
+
}
|
|
4270
|
+
//#endregion
|
|
4271
|
+
//#region src/format.ts
|
|
4272
|
+
/** Validate source before formatting so unsupported input remains an exact opaque handoff. */
|
|
4273
|
+
function formatTopikContent(source, options = {}) {
|
|
4274
|
+
const prepared = prepareSource(source, options);
|
|
4275
|
+
if (!prepared.ok) return prepared;
|
|
4276
|
+
let formatted;
|
|
4277
|
+
try {
|
|
4278
|
+
formatted = serializeDocument(prepared.document, prepared.config.components);
|
|
4279
|
+
} catch {
|
|
4280
|
+
return writeFailure(source, options.file);
|
|
4281
|
+
}
|
|
4282
|
+
const output = prepareSource(formatted, {
|
|
4283
|
+
...options,
|
|
4284
|
+
config: prepared.config
|
|
4285
|
+
});
|
|
4286
|
+
if (!output.ok) return {
|
|
4287
|
+
ok: false,
|
|
4288
|
+
source,
|
|
4289
|
+
diagnostics: output.diagnostics
|
|
4290
|
+
};
|
|
4291
|
+
if (!sameDocumentMeaning(prepared.document, output.document)) return writeFailure(source, options.file);
|
|
4292
|
+
return {
|
|
4293
|
+
ok: true,
|
|
4294
|
+
source,
|
|
4295
|
+
diagnostics: [],
|
|
4296
|
+
formatted
|
|
4297
|
+
};
|
|
4298
|
+
}
|
|
4299
|
+
//#endregion
|
|
4300
|
+
//#region src/rewrite.ts
|
|
4301
|
+
function rewriteTopikAssetOccurrences(source, replace, options = {}) {
|
|
4302
|
+
const prepared = prepareSource(source, options);
|
|
4303
|
+
if (!prepared.ok) return prepared;
|
|
4304
|
+
rewriteDocumentAssetOccurrences(prepared.document, source, replace, options, prepared.index);
|
|
4305
|
+
let content;
|
|
4306
|
+
try {
|
|
4307
|
+
content = serializeDocument(prepared.document, prepared.config.components);
|
|
4308
|
+
} catch {
|
|
4309
|
+
return writeFailure(source, options.file);
|
|
4310
|
+
}
|
|
4311
|
+
const output = prepareSource(content, {
|
|
4312
|
+
file: options.file,
|
|
4313
|
+
config: prepared.config,
|
|
4314
|
+
allowCompiledAssetReferences: true
|
|
4315
|
+
});
|
|
4316
|
+
if (!output.ok) return {
|
|
4317
|
+
ok: false,
|
|
4318
|
+
source,
|
|
4319
|
+
diagnostics: output.diagnostics
|
|
4320
|
+
};
|
|
4321
|
+
if (!sameDocumentMeaning(prepared.document, output.document)) return writeFailure(source, options.file);
|
|
4322
|
+
return {
|
|
4323
|
+
ok: true,
|
|
4324
|
+
source,
|
|
4325
|
+
diagnostics: [],
|
|
4326
|
+
content
|
|
4327
|
+
};
|
|
4328
|
+
}
|
|
4329
|
+
//#endregion
|
|
4330
|
+
//#region src/navigation-rewrite.ts
|
|
4331
|
+
/** Rewrite actual navigation references, retaining definitions/scopes and every authored branch. */
|
|
4332
|
+
function rewriteTopikNavigationReferences(source, replace, options = {}) {
|
|
4333
|
+
const prepared = prepareSource(source, options);
|
|
4334
|
+
if (!prepared.ok) return prepared;
|
|
4335
|
+
const index = indexDocument(prepared.document);
|
|
4336
|
+
const changes = [];
|
|
4337
|
+
const targets = /* @__PURE__ */ new Map();
|
|
4338
|
+
const imageDefinitions = /* @__PURE__ */ new Set();
|
|
4339
|
+
for (const { node } of index.entries) {
|
|
4340
|
+
if (node.type !== "imageReference" || !isReference(node)) continue;
|
|
4341
|
+
const definition = index.resolve(node);
|
|
4342
|
+
if (definition) imageDefinitions.add(definition);
|
|
4343
|
+
}
|
|
4344
|
+
for (const { node, path } of index.entries) {
|
|
4345
|
+
const kind = node.type === "link" || node.type === "linkReference" ? "link" : node.type === "topikComponent" && node.name === "card" ? "card" : void 0;
|
|
4346
|
+
if (!kind) continue;
|
|
4347
|
+
const target = isReference(node) ? index.resolve(node) : node;
|
|
4348
|
+
if (!target) return writeFailure(source, options.file);
|
|
4349
|
+
const href = kind === "card" ? node.props?.href : target.url;
|
|
4350
|
+
if (typeof href !== "string") continue;
|
|
4351
|
+
const reference = {
|
|
4352
|
+
href,
|
|
4353
|
+
kind,
|
|
4354
|
+
position: path.join("/")
|
|
4355
|
+
};
|
|
4356
|
+
const replacement = replace(reference) ?? href;
|
|
4357
|
+
const prior = targets.get(target);
|
|
4358
|
+
if (prior !== void 0 && prior !== replacement || replacement !== href && imageDefinitions.has(target)) return writeFailure(source, options.file);
|
|
4359
|
+
targets.set(target, replacement);
|
|
4360
|
+
if (replacement !== href) changes.push({
|
|
4361
|
+
...reference,
|
|
4362
|
+
replacement
|
|
4363
|
+
});
|
|
4364
|
+
}
|
|
4365
|
+
if (!changes.length) return {
|
|
4366
|
+
ok: true,
|
|
4367
|
+
content: source,
|
|
4368
|
+
changes,
|
|
4369
|
+
diagnostics: []
|
|
4370
|
+
};
|
|
4371
|
+
for (const [node, replacement] of targets) if (node.type === "topikComponent") node.props.href = replacement;
|
|
4372
|
+
else node.url = replacement;
|
|
4373
|
+
let content;
|
|
4374
|
+
try {
|
|
4375
|
+
content = writeDocument(prepared.document, prepared.config.components);
|
|
4376
|
+
} catch {
|
|
4377
|
+
return writeFailure(source, options.file);
|
|
4378
|
+
}
|
|
4379
|
+
const output = prepareSource(content, {
|
|
4380
|
+
...options,
|
|
4381
|
+
config: prepared.config
|
|
4382
|
+
});
|
|
4383
|
+
if (!output.ok || !sameDocumentMeaning(prepared.document, output.document)) return writeFailure(source, options.file);
|
|
4384
|
+
return {
|
|
4385
|
+
ok: true,
|
|
4386
|
+
content,
|
|
4387
|
+
changes,
|
|
4388
|
+
diagnostics: []
|
|
4389
|
+
};
|
|
4390
|
+
}
|
|
4391
|
+
//#endregion
|
|
4392
|
+
//#region src/render.ts
|
|
4393
|
+
var ContentTag = class {
|
|
4394
|
+
name;
|
|
4395
|
+
attributes;
|
|
4396
|
+
children;
|
|
4397
|
+
type = "element";
|
|
4398
|
+
constructor(name, attributes = {}, children = []) {
|
|
4399
|
+
this.name = name;
|
|
4400
|
+
this.attributes = attributes;
|
|
4401
|
+
this.children = children;
|
|
4402
|
+
}
|
|
4403
|
+
};
|
|
4404
|
+
function isContentTag(value) {
|
|
4405
|
+
return value !== null && typeof value === "object" && "type" in value && value.type === "element" && "name" in value && typeof value.name === "string" && "attributes" in value && value.attributes !== null && typeof value.attributes === "object" && "children" in value && Array.isArray(value.children);
|
|
4406
|
+
}
|
|
4407
|
+
/** Render a validated authored document through a separate evaluated document. */
|
|
4408
|
+
function transformTopikContent(document, options = {}) {
|
|
4409
|
+
return transformDocument(document, mergeTopikContentConfig(options));
|
|
4410
|
+
}
|
|
4411
|
+
/** @internal Source compilation already owns an isolated configuration snapshot. */
|
|
4412
|
+
function transformDocument(document, config) {
|
|
4413
|
+
const resolved = evaluateDocumentForRendering(document, config.variables, config.components);
|
|
4414
|
+
assertCodePresentationOutputLimit(resolved);
|
|
4415
|
+
function render(node, looseList = false) {
|
|
4416
|
+
const children = () => "children" in node ? node.children.map((child) => render(child)) : [];
|
|
4417
|
+
const tag = (name, attributes = {}, body = children()) => new ContentTag(name, attributes, body);
|
|
4418
|
+
switch (node.type) {
|
|
4419
|
+
case "root": return tag("article");
|
|
4420
|
+
case "text": return node.value;
|
|
4421
|
+
case "paragraph": return tag("p");
|
|
4422
|
+
case "heading": return tag(`h${node.depth}`, { id: node.data?.hProperties?.id });
|
|
4423
|
+
case "strong": return tag("strong");
|
|
4424
|
+
case "emphasis": return tag("em");
|
|
4425
|
+
case "delete": return tag("s");
|
|
4426
|
+
case "inlineCode": return tag("TopikInlineCode", {}, [node.value]);
|
|
4427
|
+
case "code": return tag(node.lang === "mermaid" ? "TopikMermaid" : "TopikCodeBlock", {
|
|
4428
|
+
payload: node.value,
|
|
4429
|
+
content: `${node.value}\n`,
|
|
4430
|
+
language: node.lang ?? ""
|
|
4431
|
+
}, []);
|
|
4432
|
+
case "topikCodePresentation": {
|
|
4433
|
+
const code = node.children[0];
|
|
4434
|
+
if (code.type !== "code") throw new Error("Presented code must be evaluated first");
|
|
4435
|
+
return tag("TopikCodeBlock", {
|
|
4436
|
+
payload: code.value,
|
|
4437
|
+
content: `${code.value}\n`,
|
|
4438
|
+
language: code.lang ?? "",
|
|
4439
|
+
presentation: effectiveCodePresentationOptions(normalizeCodePresentationOptions(node.options, codePresentationRows(code.value)))
|
|
4440
|
+
}, []);
|
|
4441
|
+
}
|
|
4442
|
+
case "break": return tag("br", {}, []);
|
|
4443
|
+
case "thematicBreak": return tag("hr", {}, []);
|
|
4444
|
+
case "blockquote": return tag("blockquote");
|
|
4445
|
+
case "list": {
|
|
4446
|
+
const loose = node.spread === true || node.children.some((item) => item.spread ?? item.children.length > 1);
|
|
4447
|
+
return tag(node.ordered ? "ol" : "ul", node.ordered && node.start != null && node.start !== 1 ? { start: node.start } : {}, node.children.map((item) => render(item, loose)));
|
|
4448
|
+
}
|
|
4449
|
+
case "listItem": {
|
|
4450
|
+
const body = node.children.flatMap((child) => !looseList && child.type === "paragraph" ? child.children.map((item) => render(item)) : [render(child)]);
|
|
4451
|
+
if (typeof node.checked === "boolean") {
|
|
4452
|
+
const checkbox = new ContentTag("input", {
|
|
4453
|
+
type: "checkbox",
|
|
4454
|
+
checked: node.checked,
|
|
4455
|
+
disabled: true
|
|
4456
|
+
});
|
|
4457
|
+
if (looseList && isContentTag(body[0]) && body[0].name === "p") body[0].children.unshift(checkbox);
|
|
4458
|
+
else body.unshift(checkbox);
|
|
4459
|
+
}
|
|
4460
|
+
return tag("li", {}, body);
|
|
4461
|
+
}
|
|
4462
|
+
case "image": return tag("TopikImage", {
|
|
4463
|
+
src: node.url,
|
|
4464
|
+
alt: node.alt ?? "",
|
|
4465
|
+
...node.title != null ? { title: node.title } : {}
|
|
4466
|
+
}, []);
|
|
4467
|
+
case "link": return tag("TopikLink", {
|
|
4468
|
+
href: node.url,
|
|
4469
|
+
...node.title != null ? { title: node.title } : {}
|
|
4470
|
+
});
|
|
4471
|
+
case "yaml":
|
|
4472
|
+
case "definition": return null;
|
|
4473
|
+
case "table": {
|
|
4474
|
+
const width = node.children[0].children.length;
|
|
4475
|
+
const rows = node.children.map((row, rowIndex) => new ContentTag("TopikTableRow", {}, Array.from({ length: width }, (_, column) => new ContentTag(rowIndex === 0 ? "TopikTableHeader" : "TopikTableCell", node.align?.[column] ? { align: node.align[column] } : {}, (row.children[column]?.children ?? []).map((child) => render(child))))));
|
|
4476
|
+
return tag("TopikTable", {}, [new ContentTag("thead", {}, rows.slice(0, 1)), new ContentTag("tbody", {}, rows.slice(1))]);
|
|
4477
|
+
}
|
|
4478
|
+
case "topikComponent": {
|
|
4479
|
+
const definition = config.components[node.name];
|
|
4480
|
+
if (definition.render === false) return null;
|
|
4481
|
+
if (definition.render === true) return children();
|
|
4482
|
+
return tag(definition.render, getEffectiveProps(node, config.components));
|
|
4483
|
+
}
|
|
4484
|
+
default: throw new Error(`Unsupported evaluated content node ${node.type}`);
|
|
4485
|
+
}
|
|
4486
|
+
}
|
|
4487
|
+
return render(resolved);
|
|
4488
|
+
}
|
|
4489
|
+
//#endregion
|
|
4490
|
+
//#region src/compile.ts
|
|
4491
|
+
/** Admit source once, including compiled asset references, and produce a reader tree. */
|
|
4492
|
+
function compileTopikContent(source, options = {}) {
|
|
4493
|
+
const prepared = prepareSource(source, {
|
|
4494
|
+
file: options.file,
|
|
4495
|
+
config: options.config,
|
|
4496
|
+
allowCompiledAssetReferences: true
|
|
4497
|
+
});
|
|
4498
|
+
if (!prepared.ok) {
|
|
4499
|
+
for (const diagnostic of prepared.diagnostics) options.onDiagnostic?.(diagnostic);
|
|
4500
|
+
return prepared;
|
|
4501
|
+
}
|
|
4502
|
+
try {
|
|
4503
|
+
return {
|
|
4504
|
+
ok: true,
|
|
4505
|
+
source,
|
|
4506
|
+
diagnostics: [],
|
|
4507
|
+
tree: transformDocument(prepared.document, prepared.config)
|
|
4508
|
+
};
|
|
4509
|
+
} catch (error) {
|
|
4510
|
+
const diagnostic = sanitizeTopikContentDiagnostic({
|
|
4511
|
+
id: error instanceof TemplateEvaluationError || error instanceof ContentLimitError ? error.id : "topik-transform-failed",
|
|
4512
|
+
type: error instanceof TemplateEvaluationError ? error.type : "document",
|
|
4513
|
+
level: error instanceof TemplateEvaluationError ? "error" : "critical",
|
|
4514
|
+
message: "",
|
|
4515
|
+
lines: error instanceof TemplateEvaluationError ? error.lines : [],
|
|
4516
|
+
...error instanceof TemplateEvaluationError && error.attribute !== void 0 ? { attribute: error.attribute } : {},
|
|
4517
|
+
...error instanceof TemplateEvaluationError && error.variable !== void 0 ? { variable: error.variable } : {},
|
|
4518
|
+
...options.file === void 0 ? {} : { file: options.file }
|
|
4519
|
+
});
|
|
4520
|
+
options.onDiagnostic?.(diagnostic);
|
|
4521
|
+
return {
|
|
4522
|
+
ok: false,
|
|
4523
|
+
source,
|
|
4524
|
+
diagnostics: [diagnostic]
|
|
4525
|
+
};
|
|
4526
|
+
}
|
|
4527
|
+
}
|
|
4528
|
+
//#endregion
|
|
4529
|
+
export { BADGE_VARIANTS, CALLOUT_VARIANTS, CODE_PRESENTATION_LIMITS, CONTENT_LIMITS, ContentEvaluationError, ContentLimitError, ContentTag, FORMAT_VERSION, QUIZ_QUESTION_TYPES, TOPIK_ASSET_REFERENCE_VERSION, TOPIK_CONTENT_SCHEMA_VERSION, TOPIK_GENERATED_ASSET_NAME_PATTERN, TOPIK_RESOURCE_REFERENCE_VERSION, analyzeTopikContent, assignTopikHeadingIds, compileTopikContent, components, effectiveCodePresentationOptions, evaluateDocument, extractTopikAssetOccurrences, formatDocument, formatTopikContent, getEffectiveProps, isContentTag, isTopikGeneratedAssetName, mergeTopikContentConfig, parseDocument, parseTopikContent, parseTopikGeneratedAssetName, parseTopikResourceReference, removeInvalidTopikAssetReferences, removeInvalidTopikNavigationReferences, rewriteAssets, rewriteTopikAssetOccurrences, rewriteTopikNavigationReferences, sameDocumentMeaning, sanitizeTopikContentDiagnostic, sanitizeTopikDiagnosticFile, serializeTopikResourceReference, toTopikContentDiagnostic, topikAssetReferenceSlots, topikComponents, topikLinkDiagnosticMessage, transformTopikContent, validateDocument, validateTopikAssetReference, validateTopikBrowserHref, validateTopikContent, validateTopikHref, validateTopikNavigationHref, writeDocument };
|