@texuli/blocks 0.0.0-stage → 0.1.0-next.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +77 -2
- package/dist/code/CodeBlockView.svelte +135 -0
- package/dist/code/CodeBlockView.svelte.d.ts +5 -0
- package/dist/code/check.d.ts +15 -0
- package/dist/code/check.js +14 -0
- package/dist/code/code.css +201 -0
- package/dist/code/code.css.d.ts +1 -0
- package/dist/code/engine-entry.d.ts +10 -0
- package/dist/code/engine-entry.js +10 -0
- package/dist/code/engine.d.ts +18 -0
- package/dist/code/engine.js +18 -0
- package/dist/code/family-engine.d.ts +23 -0
- package/dist/code/family-engine.js +4 -0
- package/dist/code/family.d.ts +7 -0
- package/dist/code/family.js +5 -0
- package/dist/code/grammars.d.ts +12 -0
- package/dist/code/grammars.js +83 -0
- package/dist/code/highlight.d.ts +15 -0
- package/dist/code/highlight.js +53 -0
- package/dist/code/index.d.ts +13 -0
- package/dist/code/index.js +12 -0
- package/dist/code/labels.d.ts +27 -0
- package/dist/code/labels.js +24 -0
- package/dist/code/languages/bash.d.ts +3 -0
- package/dist/code/languages/bash.js +48 -0
- package/dist/code/languages/css.d.ts +10 -0
- package/dist/code/languages/css.js +67 -0
- package/dist/code/languages/html.d.ts +6 -0
- package/dist/code/languages/html.js +35 -0
- package/dist/code/languages/javascript.d.ts +3 -0
- package/dist/code/languages/javascript.js +113 -0
- package/dist/code/languages/json.d.ts +3 -0
- package/dist/code/languages/json.js +24 -0
- package/dist/code/languages/python.d.ts +3 -0
- package/dist/code/languages/python.js +35 -0
- package/dist/code/languages/shared.d.ts +22 -0
- package/dist/code/languages/shared.js +37 -0
- package/dist/code/languages/sql.d.ts +3 -0
- package/dist/code/languages/sql.js +32 -0
- package/dist/code/languages/yaml.d.ts +7 -0
- package/dist/code/languages/yaml.js +36 -0
- package/dist/code/renderers.d.ts +5 -0
- package/dist/code/renderers.js +5 -0
- package/dist/code/token-styles.d.ts +25 -0
- package/dist/code/token-styles.js +51 -0
- package/dist/code/tokenize-code.d.ts +17 -0
- package/dist/code/tokenize-code.js +34 -0
- package/dist/code/tokenizer.d.ts +76 -0
- package/dist/code/tokenizer.js +171 -0
- package/dist/core/catalog/entries.d.ts +10 -0
- package/dist/core/catalog/entries.js +589 -0
- package/dist/core/catalog/index.d.ts +4 -0
- package/dist/core/catalog/index.js +2 -0
- package/dist/core/catalog/types.d.ts +36 -0
- package/dist/core/catalog/types.js +1 -0
- package/dist/core/catalog/validate.d.ts +15 -0
- package/dist/core/catalog/validate.js +46 -0
- package/dist/core/formula/evaluate.d.ts +14 -0
- package/dist/core/formula/evaluate.js +181 -0
- package/dist/core/formula/index.d.ts +8 -0
- package/dist/core/formula/index.js +6 -0
- package/dist/core/formula/parse.d.ts +19 -0
- package/dist/core/formula/parse.js +452 -0
- package/dist/core/formula/types.d.ts +76 -0
- package/dist/core/formula/types.js +7 -0
- package/dist/core/index.d.ts +14 -0
- package/dist/core/index.js +13 -0
- package/dist/core/limits.d.ts +27 -0
- package/dist/core/limits.js +26 -0
- package/dist/core/safety.d.ts +30 -0
- package/dist/core/safety.js +110 -0
- package/dist/core/schema/blocks.d.ts +138 -0
- package/dist/core/schema/blocks.js +181 -0
- package/dist/core/schema/code-family.d.ts +4 -0
- package/dist/core/schema/code-family.js +75 -0
- package/dist/core/schema/line-spec.d.ts +7 -0
- package/dist/core/schema/line-spec.js +19 -0
- package/dist/core/schema/media-family.d.ts +4 -0
- package/dist/core/schema/media-family.js +260 -0
- package/dist/core/schema/parse-family.d.ts +14 -0
- package/dist/core/schema/parse-family.js +37 -0
- package/dist/core/schema/primitives.d.ts +73 -0
- package/dist/core/schema/primitives.js +140 -0
- package/dist/core/schema/text-family.d.ts +4 -0
- package/dist/core/schema/text-family.js +121 -0
- package/dist/core/schema/vocabulary.d.ts +20 -0
- package/dist/core/schema/vocabulary.js +33 -0
- package/dist/core/text.d.ts +31 -0
- package/dist/core/text.js +139 -0
- package/dist/core/untrusted.d.ts +20 -0
- package/dist/core/untrusted.js +377 -0
- package/dist/css-modules.d.ts +1 -0
- package/dist/css.d.ts +1 -0
- package/dist/data/ChartBlockView.svelte +77 -0
- package/dist/data/ChartBlockView.svelte.d.ts +10 -0
- package/dist/data/DataBlockView.svelte +109 -0
- package/dist/data/DataBlockView.svelte.d.ts +5 -0
- package/dist/data/check.d.ts +10 -0
- package/dist/data/check.js +9 -0
- package/dist/data/engine.d.ts +18 -0
- package/dist/data/engine.js +10 -0
- package/dist/data/family.d.ts +6 -0
- package/dist/data/family.js +4 -0
- package/dist/data/index.d.ts +9 -0
- package/dist/data/index.js +8 -0
- package/dist/data/labels.d.ts +8 -0
- package/dist/data/labels.js +8 -0
- package/dist/data/renderers.d.ts +2 -0
- package/dist/data/renderers.js +2 -0
- package/dist/data/schema.d.ts +84 -0
- package/dist/data/schema.js +385 -0
- package/dist/diagram/BoardDiagram.svelte +48 -0
- package/dist/diagram/BoardDiagram.svelte.d.ts +8 -0
- package/dist/diagram/DiagramFamily.svelte +46 -0
- package/dist/diagram/DiagramFamily.svelte.d.ts +10 -0
- package/dist/diagram/FlowDiagram.svelte +108 -0
- package/dist/diagram/FlowDiagram.svelte.d.ts +8 -0
- package/dist/diagram/TerminalDiagram.svelte +38 -0
- package/dist/diagram/TerminalDiagram.svelte.d.ts +8 -0
- package/dist/diagram/TreeDiagram.svelte +149 -0
- package/dist/diagram/TreeDiagram.svelte.d.ts +8 -0
- package/dist/diagram/diagram.css +287 -0
- package/dist/diagram/diagram.css.d.ts +1 -0
- package/dist/diagram/engine.d.ts +16 -0
- package/dist/diagram/engine.js +14 -0
- package/dist/diagram/family.d.ts +7 -0
- package/dist/diagram/family.js +5 -0
- package/dist/diagram/index.d.ts +10 -0
- package/dist/diagram/index.js +9 -0
- package/dist/diagram/layout.d.ts +26 -0
- package/dist/diagram/layout.js +165 -0
- package/dist/diagram/schema.d.ts +74 -0
- package/dist/diagram/schema.js +358 -0
- package/dist/diagram/tones.d.ts +6 -0
- package/dist/diagram/tones.js +16 -0
- package/dist/embed/EmbedApp.svelte +138 -0
- package/dist/embed/EmbedApp.svelte.d.ts +17 -0
- package/dist/embed/EmbedBlockView.svelte +32 -0
- package/dist/embed/EmbedBlockView.svelte.d.ts +5 -0
- package/dist/embed/LinkPreview.svelte +48 -0
- package/dist/embed/LinkPreview.svelte.d.ts +10 -0
- package/dist/embed/engine.d.ts +30 -0
- package/dist/embed/engine.js +17 -0
- package/dist/embed/family.d.ts +7 -0
- package/dist/embed/family.js +5 -0
- package/dist/embed/index.d.ts +5 -0
- package/dist/embed/index.js +3 -0
- package/dist/embed/renderers.d.ts +3 -0
- package/dist/embed/renderers.js +3 -0
- package/dist/embed/sandbox.d.ts +86 -0
- package/dist/embed/sandbox.js +114 -0
- package/dist/embed/schema.d.ts +30 -0
- package/dist/embed/schema.js +217 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.js +15 -0
- package/dist/interactive/CalculatorBlock.svelte +61 -0
- package/dist/interactive/CalculatorBlock.svelte.d.ts +10 -0
- package/dist/interactive/ChecklistBlock.svelte +44 -0
- package/dist/interactive/ChecklistBlock.svelte.d.ts +10 -0
- package/dist/interactive/InteractiveBlockView.svelte +31 -0
- package/dist/interactive/InteractiveBlockView.svelte.d.ts +5 -0
- package/dist/interactive/PollBlock.svelte +111 -0
- package/dist/interactive/PollBlock.svelte.d.ts +10 -0
- package/dist/interactive/QuizBlock.svelte +75 -0
- package/dist/interactive/QuizBlock.svelte.d.ts +10 -0
- package/dist/interactive/check.d.ts +10 -0
- package/dist/interactive/check.js +9 -0
- package/dist/interactive/engine.d.ts +19 -0
- package/dist/interactive/engine.js +12 -0
- package/dist/interactive/family.d.ts +6 -0
- package/dist/interactive/family.js +4 -0
- package/dist/interactive/index.d.ts +13 -0
- package/dist/interactive/index.js +13 -0
- package/dist/interactive/interactive.css +97 -0
- package/dist/interactive/interactive.css.d.ts +1 -0
- package/dist/interactive/labels.d.ts +16 -0
- package/dist/interactive/labels.js +16 -0
- package/dist/interactive/renderers.d.ts +9 -0
- package/dist/interactive/renderers.js +9 -0
- package/dist/interactive/schema.d.ts +76 -0
- package/dist/interactive/schema.js +324 -0
- package/dist/map/MapBlockView.svelte +465 -0
- package/dist/map/MapBlockView.svelte.d.ts +5 -0
- package/dist/map/check.d.ts +11 -0
- package/dist/map/check.js +6 -0
- package/dist/map/engine.d.ts +19 -0
- package/dist/map/engine.js +8 -0
- package/dist/map/family.d.ts +7 -0
- package/dist/map/family.js +5 -0
- package/dist/map/index.d.ts +11 -0
- package/dist/map/index.js +10 -0
- package/dist/map/labels.d.ts +27 -0
- package/dist/map/labels.js +21 -0
- package/dist/map/renderers.d.ts +2 -0
- package/dist/map/renderers.js +2 -0
- package/dist/map/schema.d.ts +41 -0
- package/dist/map/schema.js +176 -0
- package/dist/map/view-model.d.ts +55 -0
- package/dist/map/view-model.js +168 -0
- package/dist/media/MediaBlockView.svelte +258 -0
- package/dist/media/MediaBlockView.svelte.d.ts +5 -0
- package/dist/media/check.d.ts +14 -0
- package/dist/media/check.js +13 -0
- package/dist/media/engine.d.ts +18 -0
- package/dist/media/engine.js +10 -0
- package/dist/media/family.d.ts +7 -0
- package/dist/media/family.js +5 -0
- package/dist/media/index.d.ts +11 -0
- package/dist/media/index.js +10 -0
- package/dist/media/labels.d.ts +7 -0
- package/dist/media/labels.js +12 -0
- package/dist/media/renderers.d.ts +5 -0
- package/dist/media/renderers.js +5 -0
- package/dist/media/resolve.d.ts +6 -0
- package/dist/media/resolve.js +15 -0
- package/dist/structure/NestedBlocks.svelte +65 -0
- package/dist/structure/NestedBlocks.svelte.d.ts +11 -0
- package/dist/structure/SafeBody.svelte +13 -0
- package/dist/structure/SafeBody.svelte.d.ts +7 -0
- package/dist/structure/StructureFamily.svelte +157 -0
- package/dist/structure/StructureFamily.svelte.d.ts +5 -0
- package/dist/structure/check.d.ts +14 -0
- package/dist/structure/check.js +27 -0
- package/dist/structure/engine.d.ts +18 -0
- package/dist/structure/engine.js +18 -0
- package/dist/structure/family.d.ts +7 -0
- package/dist/structure/family.js +5 -0
- package/dist/structure/index.d.ts +11 -0
- package/dist/structure/index.js +10 -0
- package/dist/structure/labels.d.ts +11 -0
- package/dist/structure/labels.js +11 -0
- package/dist/structure/renderers.d.ts +5 -0
- package/dist/structure/renderers.js +5 -0
- package/dist/structure/schema.d.ts +71 -0
- package/dist/structure/schema.js +327 -0
- package/dist/structure/structure.css +182 -0
- package/dist/structure/structure.css.d.ts +1 -0
- package/dist/text/RichText.svelte +169 -0
- package/dist/text/RichText.svelte.d.ts +13 -0
- package/dist/text/TextBlockView.svelte +177 -0
- package/dist/text/TextBlockView.svelte.d.ts +5 -0
- package/dist/text/check.d.ts +14 -0
- package/dist/text/check.js +13 -0
- package/dist/text/engine.d.ts +17 -0
- package/dist/text/engine.js +10 -0
- package/dist/text/family.d.ts +7 -0
- package/dist/text/family.js +5 -0
- package/dist/text/index.d.ts +12 -0
- package/dist/text/index.js +11 -0
- package/dist/text/labels.d.ts +22 -0
- package/dist/text/labels.js +29 -0
- package/dist/text/markdown.d.ts +80 -0
- package/dist/text/markdown.js +361 -0
- package/dist/text/renderers.d.ts +6 -0
- package/dist/text/renderers.js +6 -0
- package/package.json +167 -4
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dependency-light safety predicates shared by block schemas and the
|
|
3
|
+
* untrusted-input loader. No Svelte, no schema graph.
|
|
4
|
+
*/
|
|
5
|
+
// C0/C1 controls, bidi marks/embeddings/overrides/isolates ("Trojan Source"),
|
|
6
|
+
// BOMs, invisible zero-width space and word joiner, Unicode line/paragraph
|
|
7
|
+
// separators. ZWJ/ZWNJ stay for emoji and some scripts.
|
|
8
|
+
const UNSAFE_TEXT_CODE_POINT_RANGES = [
|
|
9
|
+
[0x00, 0x1f],
|
|
10
|
+
[0x7f, 0x9f],
|
|
11
|
+
[0x061c, 0x061c],
|
|
12
|
+
[0x200b, 0x200b],
|
|
13
|
+
[0x200e, 0x200f],
|
|
14
|
+
[0x2028, 0x2029],
|
|
15
|
+
[0x202a, 0x202e],
|
|
16
|
+
[0x2060, 0x2060],
|
|
17
|
+
[0x2066, 0x2069],
|
|
18
|
+
[0xfeff, 0xfeff],
|
|
19
|
+
];
|
|
20
|
+
/**
|
|
21
|
+
* Turns U+2028/U+2029 into a line break (multiline) or a space so loaders keep
|
|
22
|
+
* the word boundary before the schema refuses them.
|
|
23
|
+
*/
|
|
24
|
+
export function normalizeLineSeparators(value, keepNewlines) {
|
|
25
|
+
return value.replace(/[\u2028\u2029]/g, keepNewlines ? '\n' : ' ');
|
|
26
|
+
}
|
|
27
|
+
export function isUnsafeTextCharacter(character, allowNewlines = false, allowTabs = false) {
|
|
28
|
+
const code = character.codePointAt(0) ?? 0;
|
|
29
|
+
if (code === 0x0a && allowNewlines)
|
|
30
|
+
return false;
|
|
31
|
+
if (code === 0x0d && allowNewlines)
|
|
32
|
+
return false;
|
|
33
|
+
if (code === 0x09 && allowTabs)
|
|
34
|
+
return false;
|
|
35
|
+
return UNSAFE_TEXT_CODE_POINT_RANGES.some(([start, end]) => code >= start && code <= end);
|
|
36
|
+
}
|
|
37
|
+
/** True when any code point in `value` is unsafe under the given rules. */
|
|
38
|
+
export function hasUnsafeText(value, allowNewlines = false, allowTabs = false) {
|
|
39
|
+
return [...value].some((character) => isUnsafeTextCharacter(character, allowNewlines, allowTabs));
|
|
40
|
+
}
|
|
41
|
+
/** Decoded bytes allowed in one `data:` source. */
|
|
42
|
+
export const MEDIA_DATA_URI_MAX_BYTES = 192 * 1024;
|
|
43
|
+
const RELATIVE_MAX_LENGTH = 512;
|
|
44
|
+
const DATA_URI_MAX_LENGTH = Math.ceil(MEDIA_DATA_URI_MAX_BYTES / 3) * 4 + 64;
|
|
45
|
+
const DATA_TYPES = {
|
|
46
|
+
image: ['image/png', 'image/jpeg', 'image/webp', 'image/gif'],
|
|
47
|
+
video: ['video/mp4', 'video/webm'],
|
|
48
|
+
};
|
|
49
|
+
const EXTENSIONS = {
|
|
50
|
+
image: /\.(?:png|jpe?g|webp|gif|avif)$/i,
|
|
51
|
+
video: /\.(?:mp4|webm)$/i,
|
|
52
|
+
};
|
|
53
|
+
const RELATIVE = /^(?:\.\/)?(?:[A-Za-z0-9_][A-Za-z0-9._-]*\/)*[A-Za-z0-9_][A-Za-z0-9._-]*$/;
|
|
54
|
+
const DATA_URI = /^data:([a-z]+\/[a-z0-9.+-]+);base64,([A-Za-z0-9+/]*={0,2})$/;
|
|
55
|
+
/** Fictional portrait ids (av-001 … av-175), same shape as the planned avatars pack. */
|
|
56
|
+
const AVATAR_ID = /^av-(?:0[0-9]{2}|1[0-6][0-9]|17[0-5])$/;
|
|
57
|
+
/** Decoded size of a base64 payload. */
|
|
58
|
+
export function base64DecodedBytes(payload) {
|
|
59
|
+
const padding = payload.endsWith('==') ? 2 : payload.endsWith('=') ? 1 : 0;
|
|
60
|
+
return Math.floor((payload.length * 3) / 4) - padding;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Classifies a media source, or explains why it is refused. Hosts use the kind
|
|
64
|
+
* to apply level rules and per-document byte caps. No remote URLs.
|
|
65
|
+
*/
|
|
66
|
+
export function classifyMediaSource(src, media) {
|
|
67
|
+
if (src.startsWith('avatar:')) {
|
|
68
|
+
if (media !== 'image')
|
|
69
|
+
return { ok: false, message: 'Avatars are images, not videos' };
|
|
70
|
+
return AVATAR_ID.test(src.slice('avatar:'.length))
|
|
71
|
+
? { ok: true, kind: 'avatar', bytes: 0 }
|
|
72
|
+
: { ok: false, message: 'Use an avatar id from av-001 to av-175, as avatar:av-007' };
|
|
73
|
+
}
|
|
74
|
+
if (src.startsWith('data:')) {
|
|
75
|
+
if (src.length > DATA_URI_MAX_LENGTH) {
|
|
76
|
+
return {
|
|
77
|
+
ok: false,
|
|
78
|
+
message: `Embedded media must be at most ${MEDIA_DATA_URI_MAX_BYTES / 1024} KB`,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
const match = DATA_URI.exec(src);
|
|
82
|
+
if (!match)
|
|
83
|
+
return { ok: false, message: 'Use a base64 data URI, as data:image/png;base64,…' };
|
|
84
|
+
const type = match[1] ?? '';
|
|
85
|
+
const payload = match[2] ?? '';
|
|
86
|
+
if (!DATA_TYPES[media].includes(type)) {
|
|
87
|
+
return { ok: false, message: `Embedded ${media}s must be ${DATA_TYPES[media].join(', ')}` };
|
|
88
|
+
}
|
|
89
|
+
if (payload.length === 0 || payload.length % 4 !== 0) {
|
|
90
|
+
return { ok: false, message: 'The base64 payload is empty or malformed' };
|
|
91
|
+
}
|
|
92
|
+
const bytes = base64DecodedBytes(payload);
|
|
93
|
+
if (bytes > MEDIA_DATA_URI_MAX_BYTES) {
|
|
94
|
+
return {
|
|
95
|
+
ok: false,
|
|
96
|
+
message: `Embedded media must be at most ${MEDIA_DATA_URI_MAX_BYTES / 1024} KB`,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
return { ok: true, kind: 'data', bytes };
|
|
100
|
+
}
|
|
101
|
+
if (src.length <= RELATIVE_MAX_LENGTH && RELATIVE.test(src) && EXTENSIONS[media].test(src)) {
|
|
102
|
+
return { ok: true, kind: 'relative', bytes: 0 };
|
|
103
|
+
}
|
|
104
|
+
return {
|
|
105
|
+
ok: false,
|
|
106
|
+
message: media === 'image'
|
|
107
|
+
? 'Use avatar:av-001…175, a png/jpeg/webp/gif data URI, or a relative image path (no SVG or remote URLs)'
|
|
108
|
+
: 'Use an mp4/webm data URI or a relative video path (no remote URLs)',
|
|
109
|
+
};
|
|
110
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { type ChartBlock, type KpiBlock, type StatBlock, type TableBlock } from '../../data/schema.js';
|
|
2
|
+
import { type CalculatorBlock, type ChecklistBlock, type PollBlock, type QuizBlock } from '../../interactive/schema.js';
|
|
3
|
+
import { type AccordionBlock, type CardsBlock, type GridBlock, type TabsBlock } from '../../structure/schema.js';
|
|
4
|
+
import { type BlockBase, type ParseResult, toJsonPath } from './primitives.js';
|
|
5
|
+
/**
|
|
6
|
+
* Closed core vocabulary for the untrusted guard and catalog (T-blocks-core).
|
|
7
|
+
* Family-specific blocks (charts, diagrams, interactive) land in later tasks.
|
|
8
|
+
*/
|
|
9
|
+
export type HeadingBlock = BlockBase & {
|
|
10
|
+
type: 'heading';
|
|
11
|
+
level: 1 | 2 | 3 | 4;
|
|
12
|
+
text: string;
|
|
13
|
+
eyebrow?: string;
|
|
14
|
+
subtitle?: string;
|
|
15
|
+
};
|
|
16
|
+
export type TextBlock = BlockBase & {
|
|
17
|
+
type: 'text';
|
|
18
|
+
markdown: string;
|
|
19
|
+
};
|
|
20
|
+
export type ListItem = {
|
|
21
|
+
text: string;
|
|
22
|
+
};
|
|
23
|
+
export type ListBlock = BlockBase & {
|
|
24
|
+
type: 'list';
|
|
25
|
+
ordered?: boolean;
|
|
26
|
+
items: ListItem[];
|
|
27
|
+
};
|
|
28
|
+
export type QuoteBlock = BlockBase & {
|
|
29
|
+
type: 'quote';
|
|
30
|
+
quote: string;
|
|
31
|
+
cite?: string;
|
|
32
|
+
};
|
|
33
|
+
export type CalloutBlock = BlockBase & {
|
|
34
|
+
type: 'callout';
|
|
35
|
+
title?: string;
|
|
36
|
+
body: string;
|
|
37
|
+
};
|
|
38
|
+
export type DividerBlock = BlockBase & {
|
|
39
|
+
type: 'divider';
|
|
40
|
+
};
|
|
41
|
+
export type CodeBlock = BlockBase & {
|
|
42
|
+
type: 'code';
|
|
43
|
+
lang?: string;
|
|
44
|
+
code: string;
|
|
45
|
+
filename?: string;
|
|
46
|
+
/** Plain text shown under the code. */
|
|
47
|
+
caption?: string;
|
|
48
|
+
/** Show line numbers (drawn by the renderer, never part of the code text). */
|
|
49
|
+
lineNumbers?: boolean;
|
|
50
|
+
/**
|
|
51
|
+
* Stepped line highlights, one line spec per step (`["1-2", "4,6-8"]`).
|
|
52
|
+
* In a deck, the block's `step` is where they start: deck step `step + i`
|
|
53
|
+
* shows `highlights[i]`. Elsewhere the first step shows.
|
|
54
|
+
*/
|
|
55
|
+
highlights?: string[];
|
|
56
|
+
};
|
|
57
|
+
export type CtaAction = {
|
|
58
|
+
label: string;
|
|
59
|
+
href: string;
|
|
60
|
+
};
|
|
61
|
+
export type CtaBlock = BlockBase & {
|
|
62
|
+
type: 'cta';
|
|
63
|
+
actions: CtaAction[];
|
|
64
|
+
};
|
|
65
|
+
export type ColumnsBlock = BlockBase & {
|
|
66
|
+
type: 'columns';
|
|
67
|
+
columns: Block[][];
|
|
68
|
+
};
|
|
69
|
+
export type ImageFit = 'contain' | 'cover';
|
|
70
|
+
/** Single image (optional caption). */
|
|
71
|
+
export type ImageBlock = BlockBase & {
|
|
72
|
+
type: 'image';
|
|
73
|
+
src: string;
|
|
74
|
+
/** Required unless decorative. */
|
|
75
|
+
alt?: string;
|
|
76
|
+
decorative?: boolean;
|
|
77
|
+
width: number;
|
|
78
|
+
height: number;
|
|
79
|
+
caption?: string;
|
|
80
|
+
credit?: string;
|
|
81
|
+
fit?: ImageFit;
|
|
82
|
+
};
|
|
83
|
+
/** Figure with a required caption (and optional credit). */
|
|
84
|
+
export type FigureBlock = BlockBase & {
|
|
85
|
+
type: 'figure';
|
|
86
|
+
src: string;
|
|
87
|
+
alt?: string;
|
|
88
|
+
decorative?: boolean;
|
|
89
|
+
width: number;
|
|
90
|
+
height: number;
|
|
91
|
+
caption: string;
|
|
92
|
+
credit?: string;
|
|
93
|
+
fit?: ImageFit;
|
|
94
|
+
};
|
|
95
|
+
export type VideoBlock = BlockBase & {
|
|
96
|
+
type: 'video';
|
|
97
|
+
src: string;
|
|
98
|
+
/** Accessible name of the video. */
|
|
99
|
+
label: string;
|
|
100
|
+
poster?: string;
|
|
101
|
+
width: number;
|
|
102
|
+
height: number;
|
|
103
|
+
caption?: string;
|
|
104
|
+
autoplay?: boolean;
|
|
105
|
+
loop?: boolean;
|
|
106
|
+
muted?: boolean;
|
|
107
|
+
controls?: boolean;
|
|
108
|
+
};
|
|
109
|
+
export type GalleryItem = {
|
|
110
|
+
src: string;
|
|
111
|
+
alt?: string;
|
|
112
|
+
decorative?: boolean;
|
|
113
|
+
width: number;
|
|
114
|
+
height: number;
|
|
115
|
+
caption?: string;
|
|
116
|
+
};
|
|
117
|
+
export type GalleryBlock = BlockBase & {
|
|
118
|
+
type: 'gallery';
|
|
119
|
+
title?: string;
|
|
120
|
+
/** Preferred column count on wide viewports (2–4). */
|
|
121
|
+
columns?: 2 | 3 | 4;
|
|
122
|
+
items: GalleryItem[];
|
|
123
|
+
};
|
|
124
|
+
export type Block = HeadingBlock | TextBlock | ListBlock | QuoteBlock | CalloutBlock | DividerBlock | CodeBlock | CtaBlock | ColumnsBlock | TabsBlock | AccordionBlock | CardsBlock | GridBlock | ImageBlock | FigureBlock | VideoBlock | GalleryBlock | TableBlock | StatBlock | KpiBlock | ChartBlock | PollBlock | QuizBlock | CalculatorBlock | ChecklistBlock;
|
|
125
|
+
export type BlockType = Block['type'];
|
|
126
|
+
export declare const BLOCK_TYPES: readonly ["heading", "text", "list", "quote", "callout", "divider", "code", "cta", "columns", "tabs", "accordion", "cards", "grid", "image", "figure", "video", "gallery", "table", "stat", "kpi", "chart", "poll", "quiz", "calculator", "checklist"];
|
|
127
|
+
export type { AccordionBlock, CardsBlock, GridBlock, TabsBlock };
|
|
128
|
+
export type BlockParseResult<T> = ParseResult<T>;
|
|
129
|
+
export declare function parseBlockList(value: unknown, path?: (string | number)[], containerDepth?: number): ParseResult<Block[]>;
|
|
130
|
+
export declare function safeParseBlocks(value: unknown): BlockParseResult<Block[]>;
|
|
131
|
+
export declare function safeParseBlock(value: unknown): BlockParseResult<Block>;
|
|
132
|
+
/**
|
|
133
|
+
* Depth-first walk of every block, including nested columns.
|
|
134
|
+
* Callback receives the block and its path from the root list.
|
|
135
|
+
*/
|
|
136
|
+
export declare function forEachBlock(blocks: readonly Block[], visit: (block: Block, path: readonly (string | number)[]) => void, path?: readonly (string | number)[]): void;
|
|
137
|
+
export type { ChartBlock, ChartKind, ChartSeries, DataBlock, KpiBlock, StatBlock, TableBlock, TableCellValue, TableColumn, TableRow, } from '../../data/schema.js';
|
|
138
|
+
export { toJsonPath };
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { safeHref } from '@texuli/core/url-policy';
|
|
2
|
+
import { parseDataBlock, } from "../../data/schema.js";
|
|
3
|
+
import { parseInteractiveBlock, } from "../../interactive/schema.js";
|
|
4
|
+
import { parseStructureBlock, } from "../../structure/schema.js";
|
|
5
|
+
import { parseCodeFamilyBlock } from "./code-family.js";
|
|
6
|
+
import { parseMediaFamilyBlock } from "./media-family.js";
|
|
7
|
+
import { plainObject } from "./parse-family.js";
|
|
8
|
+
import { BLOCK_CONTENT_LIMITS, fail, failMany, ok, parseBlockBase, parsePlainText, toJsonPath, } from "./primitives.js";
|
|
9
|
+
import { parseTextFamilyBlock } from "./text-family.js";
|
|
10
|
+
export const BLOCK_TYPES = [
|
|
11
|
+
'heading',
|
|
12
|
+
'text',
|
|
13
|
+
'list',
|
|
14
|
+
'quote',
|
|
15
|
+
'callout',
|
|
16
|
+
'divider',
|
|
17
|
+
'code',
|
|
18
|
+
'cta',
|
|
19
|
+
'columns',
|
|
20
|
+
'tabs',
|
|
21
|
+
'accordion',
|
|
22
|
+
'cards',
|
|
23
|
+
'grid',
|
|
24
|
+
'image',
|
|
25
|
+
'figure',
|
|
26
|
+
'video',
|
|
27
|
+
'gallery',
|
|
28
|
+
'table',
|
|
29
|
+
'stat',
|
|
30
|
+
'kpi',
|
|
31
|
+
'chart',
|
|
32
|
+
'poll',
|
|
33
|
+
'quiz',
|
|
34
|
+
'calculator',
|
|
35
|
+
'checklist',
|
|
36
|
+
];
|
|
37
|
+
function parseCta(record, path, base) {
|
|
38
|
+
if (!Array.isArray(record.actions) || record.actions.length === 0) {
|
|
39
|
+
return fail([...path, 'actions'], 'cta needs at least one action');
|
|
40
|
+
}
|
|
41
|
+
if (record.actions.length > BLOCK_CONTENT_LIMITS.ctaActions) {
|
|
42
|
+
return fail([...path, 'actions'], `cta may have at most ${BLOCK_CONTENT_LIMITS.ctaActions} actions`);
|
|
43
|
+
}
|
|
44
|
+
const actions = [];
|
|
45
|
+
for (let i = 0; i < record.actions.length; i += 1) {
|
|
46
|
+
const actionPath = [...path, 'actions', i];
|
|
47
|
+
const action = plainObject(record.actions[i], actionPath);
|
|
48
|
+
if (!action.success)
|
|
49
|
+
return action;
|
|
50
|
+
const label = parsePlainText(action.data.label, [...actionPath, 'label'], BLOCK_CONTENT_LIMITS.plain);
|
|
51
|
+
if (!label.success)
|
|
52
|
+
return label;
|
|
53
|
+
if (typeof action.data.href !== 'string') {
|
|
54
|
+
return fail([...actionPath, 'href'], 'href must be a string');
|
|
55
|
+
}
|
|
56
|
+
if (action.data.href.length > BLOCK_CONTENT_LIMITS.href) {
|
|
57
|
+
return fail([...actionPath, 'href'], `href must be at most ${BLOCK_CONTENT_LIMITS.href} characters`);
|
|
58
|
+
}
|
|
59
|
+
const href = safeHref(action.data.href, 'untrusted');
|
|
60
|
+
if (!href) {
|
|
61
|
+
return fail([...actionPath, 'href'], 'href must be an https, mailto or same-document link (untrusted tier)');
|
|
62
|
+
}
|
|
63
|
+
actions.push({ label: label.data, href });
|
|
64
|
+
}
|
|
65
|
+
return ok({ ...base, type: 'cta', actions });
|
|
66
|
+
}
|
|
67
|
+
function parseColumns(record, path, base, containerDepth) {
|
|
68
|
+
if (containerDepth >= BLOCK_CONTENT_LIMITS.containerDepth) {
|
|
69
|
+
return fail(path, `containers nest at most ${BLOCK_CONTENT_LIMITS.containerDepth} deep`);
|
|
70
|
+
}
|
|
71
|
+
if (!Array.isArray(record.columns) || record.columns.length < 2) {
|
|
72
|
+
return fail([...path, 'columns'], 'columns needs at least two columns');
|
|
73
|
+
}
|
|
74
|
+
if (record.columns.length > BLOCK_CONTENT_LIMITS.columns) {
|
|
75
|
+
return fail([...path, 'columns'], `at most ${BLOCK_CONTENT_LIMITS.columns} columns`);
|
|
76
|
+
}
|
|
77
|
+
const columns = [];
|
|
78
|
+
for (let c = 0; c < record.columns.length; c += 1) {
|
|
79
|
+
const column = record.columns[c];
|
|
80
|
+
if (!Array.isArray(column)) {
|
|
81
|
+
return fail([...path, 'columns', c], 'each column must be a block array');
|
|
82
|
+
}
|
|
83
|
+
if (column.length > BLOCK_CONTENT_LIMITS.blocksPerColumn) {
|
|
84
|
+
return fail([...path, 'columns', c], `at most ${BLOCK_CONTENT_LIMITS.blocksPerColumn} blocks per column`);
|
|
85
|
+
}
|
|
86
|
+
const parsed = parseBlockList(column, [...path, 'columns', c], containerDepth + 1);
|
|
87
|
+
if (!parsed.success)
|
|
88
|
+
return parsed;
|
|
89
|
+
columns.push(parsed.data);
|
|
90
|
+
}
|
|
91
|
+
return ok({ ...base, type: 'columns', columns });
|
|
92
|
+
}
|
|
93
|
+
function parseOneBlock(value, path, containerDepth) {
|
|
94
|
+
const object = plainObject(value, path);
|
|
95
|
+
if (!object.success)
|
|
96
|
+
return object;
|
|
97
|
+
const record = object.data;
|
|
98
|
+
if (typeof record.type !== 'string')
|
|
99
|
+
return fail([...path, 'type'], 'block type is required');
|
|
100
|
+
const base = parseBlockBase(record, path);
|
|
101
|
+
if (!base.success)
|
|
102
|
+
return base;
|
|
103
|
+
switch (record.type) {
|
|
104
|
+
case 'divider':
|
|
105
|
+
return ok({ ...base.data, type: 'divider' });
|
|
106
|
+
case 'cta':
|
|
107
|
+
return parseCta(record, path, base.data);
|
|
108
|
+
case 'columns':
|
|
109
|
+
return parseColumns(record, path, base.data, containerDepth);
|
|
110
|
+
default: {
|
|
111
|
+
const family = parseTextFamilyBlock(record.type, record, path, base.data) ??
|
|
112
|
+
parseCodeFamilyBlock(record.type, record, path, base.data) ??
|
|
113
|
+
parseMediaFamilyBlock(record.type, record, path, base.data);
|
|
114
|
+
if (family)
|
|
115
|
+
return family;
|
|
116
|
+
const structure = parseStructureBlock(record.type, record, path, base.data, containerDepth, parseBlockList);
|
|
117
|
+
if (structure)
|
|
118
|
+
return structure;
|
|
119
|
+
const data = parseDataBlock(record.type, record, path, base.data);
|
|
120
|
+
if (data)
|
|
121
|
+
return data;
|
|
122
|
+
const interactive = parseInteractiveBlock(record.type, record, path, base.data);
|
|
123
|
+
if (interactive)
|
|
124
|
+
return interactive;
|
|
125
|
+
return fail([...path, 'type'], `Unknown block type "${record.type}"`);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
export function parseBlockList(value, path = [], containerDepth = 0) {
|
|
130
|
+
if (!Array.isArray(value))
|
|
131
|
+
return fail(path, 'Expected a block array');
|
|
132
|
+
const blocks = [];
|
|
133
|
+
const issues = [];
|
|
134
|
+
for (let i = 0; i < value.length; i += 1) {
|
|
135
|
+
const parsed = parseOneBlock(value[i], [...path, i], containerDepth);
|
|
136
|
+
if (!parsed.success) {
|
|
137
|
+
issues.push(...parsed.issues);
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
blocks.push(parsed.data);
|
|
141
|
+
}
|
|
142
|
+
if (issues.length > 0)
|
|
143
|
+
return failMany(issues);
|
|
144
|
+
return ok(blocks);
|
|
145
|
+
}
|
|
146
|
+
export function safeParseBlocks(value) {
|
|
147
|
+
return parseBlockList(value, []);
|
|
148
|
+
}
|
|
149
|
+
export function safeParseBlock(value) {
|
|
150
|
+
return parseOneBlock(value, [], 0);
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Depth-first walk of every block, including nested columns.
|
|
154
|
+
* Callback receives the block and its path from the root list.
|
|
155
|
+
*/
|
|
156
|
+
export function forEachBlock(blocks, visit, path = []) {
|
|
157
|
+
for (let i = 0; i < blocks.length; i += 1) {
|
|
158
|
+
const block = blocks[i];
|
|
159
|
+
if (!block)
|
|
160
|
+
continue;
|
|
161
|
+
const blockPath = [...path, i];
|
|
162
|
+
visit(block, blockPath);
|
|
163
|
+
if (block.type === 'columns') {
|
|
164
|
+
for (let c = 0; c < block.columns.length; c += 1) {
|
|
165
|
+
const column = block.columns[c];
|
|
166
|
+
if (!column)
|
|
167
|
+
continue;
|
|
168
|
+
forEachBlock(column, visit, [...blockPath, 'columns', c]);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
else if (block.type === 'tabs') {
|
|
172
|
+
for (let p = 0; p < block.panels.length; p += 1) {
|
|
173
|
+
const panel = block.panels[p];
|
|
174
|
+
if (!panel)
|
|
175
|
+
continue;
|
|
176
|
+
forEachBlock(panel.blocks, visit, [...blockPath, 'panels', p, 'blocks']);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
export { toJsonPath };
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { CodeBlock } from './blocks.js';
|
|
2
|
+
import { type BlockBase, type ParseResult } from './primitives.js';
|
|
3
|
+
/** Code family: code blocks. */
|
|
4
|
+
export declare function parseCodeFamilyBlock(type: string, record: Record<string, unknown>, path: (string | number)[], base: BlockBase): ParseResult<CodeBlock> | null;
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { parseLineSpec } from "./line-spec.js";
|
|
2
|
+
import { BLOCK_CONTENT_LIMITS, fail, ok, parseMultilineText, parsePlainText, } from "./primitives.js";
|
|
3
|
+
function parseCode(record, path, base) {
|
|
4
|
+
const code = parseMultilineText(record.code, [...path, 'code'], BLOCK_CONTENT_LIMITS.code, true);
|
|
5
|
+
if (!code.success)
|
|
6
|
+
return code;
|
|
7
|
+
const lines = code.data.split('\n').length;
|
|
8
|
+
if (lines > BLOCK_CONTENT_LIMITS.codeLines) {
|
|
9
|
+
return fail([...path, 'code'], `code may have at most ${BLOCK_CONTENT_LIMITS.codeLines} lines`);
|
|
10
|
+
}
|
|
11
|
+
let lang;
|
|
12
|
+
if (record.lang !== undefined) {
|
|
13
|
+
if (typeof record.lang !== 'string' || !/^[a-z][a-z0-9+-]{0,31}$/.test(record.lang)) {
|
|
14
|
+
return fail([...path, 'lang'], 'lang must be a short lowercase token');
|
|
15
|
+
}
|
|
16
|
+
lang = record.lang;
|
|
17
|
+
}
|
|
18
|
+
let filename;
|
|
19
|
+
if (record.filename !== undefined) {
|
|
20
|
+
const parsed = parsePlainText(record.filename, [...path, 'filename'], BLOCK_CONTENT_LIMITS.plain);
|
|
21
|
+
if (!parsed.success)
|
|
22
|
+
return parsed;
|
|
23
|
+
filename = parsed.data;
|
|
24
|
+
}
|
|
25
|
+
let caption;
|
|
26
|
+
if (record.caption !== undefined) {
|
|
27
|
+
const parsed = parsePlainText(record.caption, [...path, 'caption'], BLOCK_CONTENT_LIMITS.codeCaption);
|
|
28
|
+
if (!parsed.success)
|
|
29
|
+
return parsed;
|
|
30
|
+
caption = parsed.data;
|
|
31
|
+
}
|
|
32
|
+
if (record.lineNumbers !== undefined && typeof record.lineNumbers !== 'boolean') {
|
|
33
|
+
return fail([...path, 'lineNumbers'], 'lineNumbers must be a boolean');
|
|
34
|
+
}
|
|
35
|
+
const highlights = parseCodeHighlights(record.highlights, [...path, 'highlights'], lines);
|
|
36
|
+
if (!highlights.success)
|
|
37
|
+
return highlights;
|
|
38
|
+
return ok({
|
|
39
|
+
...base,
|
|
40
|
+
type: 'code',
|
|
41
|
+
code: code.data,
|
|
42
|
+
...(lang !== undefined ? { lang } : {}),
|
|
43
|
+
...(filename !== undefined ? { filename } : {}),
|
|
44
|
+
...(caption !== undefined ? { caption } : {}),
|
|
45
|
+
...(record.lineNumbers !== undefined ? { lineNumbers: record.lineNumbers } : {}),
|
|
46
|
+
...(highlights.data !== undefined ? { highlights: highlights.data } : {}),
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
/** 1 to 16 line specs (`"1-2,4"`), each within the code's lines. */
|
|
50
|
+
function parseCodeHighlights(value, path, lines) {
|
|
51
|
+
if (value === undefined)
|
|
52
|
+
return ok(undefined);
|
|
53
|
+
const max = BLOCK_CONTENT_LIMITS.codeHighlightSteps;
|
|
54
|
+
if (!Array.isArray(value) || value.length === 0 || value.length > max) {
|
|
55
|
+
return fail(path, `highlights must list 1 to ${max} line specs`);
|
|
56
|
+
}
|
|
57
|
+
for (const [index, spec] of value.entries()) {
|
|
58
|
+
const ranges = typeof spec === 'string' && spec.length <= BLOCK_CONTENT_LIMITS.codeHighlightSpec
|
|
59
|
+
? parseLineSpec(spec)
|
|
60
|
+
: null;
|
|
61
|
+
if (!ranges || ranges.some(([, end]) => end > lines)) {
|
|
62
|
+
return fail([...path, index], `Use line specs like "1-2,4" within the ${lines} lines`);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return ok([...value]);
|
|
66
|
+
}
|
|
67
|
+
/** Code family: code blocks. */
|
|
68
|
+
export function parseCodeFamilyBlock(type, record, path, base) {
|
|
69
|
+
switch (type) {
|
|
70
|
+
case 'code':
|
|
71
|
+
return parseCode(record, path, base);
|
|
72
|
+
default:
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Line specs for code highlights: `"1-2,4"` names lines 1, 2 and 4 (1-based).
|
|
3
|
+
* Shared by the code block schema and the code renderer, so data that
|
|
4
|
+
* validates always highlights the same lines.
|
|
5
|
+
*/
|
|
6
|
+
/** Parses `"1-2,4"` into `[[1, 2], [4, 4]]`, or `null` when malformed or reversed. */
|
|
7
|
+
export declare function parseLineSpec(spec: string): [number, number][] | null;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Line specs for code highlights: `"1-2,4"` names lines 1, 2 and 4 (1-based).
|
|
3
|
+
* Shared by the code block schema and the code renderer, so data that
|
|
4
|
+
* validates always highlights the same lines.
|
|
5
|
+
*/
|
|
6
|
+
const LINE_SPEC = /^\d{1,4}(?:-\d{1,4})?(?:,\d{1,4}(?:-\d{1,4})?)*$/;
|
|
7
|
+
/** Parses `"1-2,4"` into `[[1, 2], [4, 4]]`, or `null` when malformed or reversed. */
|
|
8
|
+
export function parseLineSpec(spec) {
|
|
9
|
+
if (!LINE_SPEC.test(spec))
|
|
10
|
+
return null;
|
|
11
|
+
const ranges = [];
|
|
12
|
+
for (const part of spec.split(',')) {
|
|
13
|
+
const [a, b = a] = part.split('-').map(Number);
|
|
14
|
+
if (a < 1 || b < a)
|
|
15
|
+
return null;
|
|
16
|
+
ranges.push([a, b]);
|
|
17
|
+
}
|
|
18
|
+
return ranges;
|
|
19
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { FigureBlock, GalleryBlock, ImageBlock, VideoBlock } from './blocks.js';
|
|
2
|
+
import { type BlockBase, type ParseResult } from './primitives.js';
|
|
3
|
+
/** Media family: image, figure, video and gallery. */
|
|
4
|
+
export declare function parseMediaFamilyBlock(type: string, record: Record<string, unknown>, path: (string | number)[], base: BlockBase): ParseResult<ImageBlock | FigureBlock | VideoBlock | GalleryBlock> | null;
|