@texuli/deck 0.0.0-stage → 0.5.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 +183 -2
- package/dist/authoring/builder.d.ts +351 -0
- package/dist/authoring/builder.js +64 -0
- package/dist/authoring/index.d.ts +6 -0
- package/dist/authoring/index.js +6 -0
- package/dist/authoring/lint.d.ts +36 -0
- package/dist/authoring/lint.js +325 -0
- package/dist/authoring/outlines.d.ts +34 -0
- package/dist/authoring/outlines.js +255 -0
- package/dist/authoring/patch.d.ts +23 -0
- package/dist/authoring/patch.js +166 -0
- package/dist/authoring/select-layouts.d.ts +19 -0
- package/dist/authoring/select-layouts.js +90 -0
- package/dist/engine/auto-slide.d.ts +32 -0
- package/dist/engine/auto-slide.js +67 -0
- package/dist/engine/canvas.d.ts +56 -0
- package/dist/engine/canvas.js +48 -0
- package/dist/engine/controller.d.ts +126 -0
- package/dist/engine/controller.js +248 -0
- package/dist/engine/controls.d.ts +128 -0
- package/dist/engine/controls.js +460 -0
- package/dist/engine/hash.d.ts +42 -0
- package/dist/engine/hash.js +86 -0
- package/dist/engine/index.d.ts +88 -0
- package/dist/engine/index.js +88 -0
- package/dist/engine/messages.d.ts +138 -0
- package/dist/engine/messages.js +140 -0
- package/dist/engine/motion.d.ts +64 -0
- package/dist/engine/motion.js +112 -0
- package/dist/engine/navigation.d.ts +87 -0
- package/dist/engine/navigation.js +156 -0
- package/dist/engine/presenter.d.ts +58 -0
- package/dist/engine/presenter.js +157 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.js +9 -0
- package/dist/markdown/attributes.d.ts +24 -0
- package/dist/markdown/attributes.js +94 -0
- package/dist/markdown/attrs-closed.d.ts +52 -0
- package/dist/markdown/attrs-closed.js +520 -0
- package/dist/markdown/blocks.d.ts +112 -0
- package/dist/markdown/blocks.js +766 -0
- package/dist/markdown/frontmatter.d.ts +30 -0
- package/dist/markdown/frontmatter.js +165 -0
- package/dist/markdown/index.d.ts +29 -0
- package/dist/markdown/index.js +29 -0
- package/dist/markdown/issues.d.ts +68 -0
- package/dist/markdown/issues.js +83 -0
- package/dist/markdown/json.d.ts +21 -0
- package/dist/markdown/json.js +236 -0
- package/dist/markdown/limits.d.ts +32 -0
- package/dist/markdown/limits.js +32 -0
- package/dist/markdown/parse.d.ts +31 -0
- package/dist/markdown/parse.js +283 -0
- package/dist/markdown/scan.d.ts +26 -0
- package/dist/markdown/scan.js +138 -0
- package/dist/markdown/slides.d.ts +32 -0
- package/dist/markdown/slides.js +854 -0
- package/dist/markdown/text.d.ts +76 -0
- package/dist/markdown/text.js +447 -0
- package/dist/markdown/twin.d.ts +41 -0
- package/dist/markdown/twin.js +374 -0
- package/dist/markdown/write.d.ts +17 -0
- package/dist/markdown/write.js +688 -0
- package/dist/math/index.d.ts +14 -0
- package/dist/math/index.js +14 -0
- package/dist/math/parse.d.ts +9 -0
- package/dist/math/parse.js +648 -0
- package/dist/math/symbols.d.ts +44 -0
- package/dist/math/symbols.js +333 -0
- package/dist/math/types.d.ts +59 -0
- package/dist/math/types.js +69 -0
- package/dist/model/backgrounds/contrast.d.ts +77 -0
- package/dist/model/backgrounds/contrast.js +186 -0
- package/dist/model/backgrounds/index.d.ts +7 -0
- package/dist/model/backgrounds/index.js +7 -0
- package/dist/model/backgrounds/resolve.d.ts +86 -0
- package/dist/model/backgrounds/resolve.js +215 -0
- package/dist/model/backgrounds/schema.d.ts +178 -0
- package/dist/model/backgrounds/schema.js +203 -0
- package/dist/model/codes.d.ts +73 -0
- package/dist/model/codes.js +67 -0
- package/dist/model/deck.d.ts +392 -0
- package/dist/model/deck.js +338 -0
- package/dist/model/index.d.ts +19 -0
- package/dist/model/index.js +19 -0
- package/dist/model/layouts/catalog.d.ts +433 -0
- package/dist/model/layouts/catalog.js +495 -0
- package/dist/model/layouts/index.d.ts +5 -0
- package/dist/model/layouts/index.js +5 -0
- package/dist/model/layouts/props.d.ts +302 -0
- package/dist/model/layouts/props.js +272 -0
- package/dist/model/loader.d.ts +37 -0
- package/dist/model/loader.js +317 -0
- package/dist/model/migrate.d.ts +34 -0
- package/dist/model/migrate.js +453 -0
- package/dist/model/motion/index.d.ts +6 -0
- package/dist/model/motion/index.js +6 -0
- package/dist/model/motion/transition.d.ts +96 -0
- package/dist/model/motion/transition.js +127 -0
- package/dist/model/parse.d.ts +57 -0
- package/dist/model/parse.js +307 -0
- package/dist/model/plain.d.ts +40 -0
- package/dist/model/plain.js +275 -0
- package/dist/model/shared.d.ts +63 -0
- package/dist/model/shared.js +86 -0
- package/dist/model/steps/index.d.ts +7 -0
- package/dist/model/steps/index.js +7 -0
- package/dist/model/steps/phase.d.ts +34 -0
- package/dist/model/steps/phase.js +50 -0
- package/dist/model/steps/slide-steps.d.ts +129 -0
- package/dist/model/steps/slide-steps.js +256 -0
- package/dist/model/style/index.d.ts +8 -0
- package/dist/model/style/index.js +8 -0
- package/dist/model/style/resolve.d.ts +87 -0
- package/dist/model/style/resolve.js +174 -0
- package/dist/model/style/schema.d.ts +85 -0
- package/dist/model/style/schema.js +53 -0
- package/dist/model/style/theme.d.ts +69 -0
- package/dist/model/style/theme.js +84 -0
- package/dist/schema/builders.d.ts +146 -0
- package/dist/schema/builders.js +285 -0
- package/dist/schema/diagnostics.d.ts +78 -0
- package/dist/schema/diagnostics.js +81 -0
- package/dist/schema/index.d.ts +15 -0
- package/dist/schema/index.js +14 -0
- package/dist/schema/internal.d.ts +40 -0
- package/dist/schema/internal.js +209 -0
- package/dist/schema/json-schema.d.ts +12 -0
- package/dist/schema/json-schema.js +213 -0
- package/dist/schema/types.d.ts +166 -0
- package/dist/schema/types.js +1 -0
- package/dist/schema/validate.d.ts +51 -0
- package/dist/schema/validate.js +765 -0
- package/dist/templates/index.d.ts +2 -0
- package/dist/templates/index.js +2 -0
- package/dist/templates/templates.d.ts +11 -0
- package/dist/templates/templates.js +47 -0
- package/package.json +60 -4
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Slide transitions and fragment motion under a motion level (ported from
|
|
3
|
+
* Protoyard's `motion/transition.ts`, plus v1's `fade-up` transition).
|
|
4
|
+
*
|
|
5
|
+
* - The deck's `style.motion` (`full` | `subtle` | `none`) is the ceiling;
|
|
6
|
+
* an explicit `motion` option replaces it.
|
|
7
|
+
* - The viewer's reduced-motion preference only lowers it, never raises it:
|
|
8
|
+
* with it, every transition is `none` or a short fade, and moving
|
|
9
|
+
* fragments become plain fades.
|
|
10
|
+
* - Durations are opacity and transform only and never exceed
|
|
11
|
+
* `MAX_MOTION_MS`.
|
|
12
|
+
*
|
|
13
|
+
* Inputs are structural plain objects; unknown values fall through to the
|
|
14
|
+
* next source (slide → deck → `fade`) instead of throwing.
|
|
15
|
+
*/
|
|
16
|
+
import { DECK_TRANSITIONS } from "../shared.js";
|
|
17
|
+
export const MOTION_LEVELS = ['full', 'subtle', 'none'];
|
|
18
|
+
export const MAX_MOTION_MS = 400;
|
|
19
|
+
/** A reduced-motion fade: short enough to read as a cut, long enough not to flash. */
|
|
20
|
+
export const REDUCED_FADE_MS = 150;
|
|
21
|
+
/** Subtle motion runs at this share of the full duration. */
|
|
22
|
+
export const SUBTLE_SCALE = 0.6;
|
|
23
|
+
/** A fragment's full-motion duration. */
|
|
24
|
+
export const FRAGMENT_MS = 300;
|
|
25
|
+
export const DEFAULT_TRANSITION = 'fade';
|
|
26
|
+
export const DEFAULT_MOTION = 'full';
|
|
27
|
+
/** Full-motion transition durations, in milliseconds. */
|
|
28
|
+
export const TRANSITION_MS = Object.freeze({
|
|
29
|
+
none: 0,
|
|
30
|
+
fade: 300,
|
|
31
|
+
'fade-up': 300,
|
|
32
|
+
slide: 400,
|
|
33
|
+
zoom: 350,
|
|
34
|
+
morph: 400,
|
|
35
|
+
});
|
|
36
|
+
export function isDeckTransition(value) {
|
|
37
|
+
return typeof value === 'string' && DECK_TRANSITIONS.includes(value);
|
|
38
|
+
}
|
|
39
|
+
export function isMotionLevel(value) {
|
|
40
|
+
return typeof value === 'string' && MOTION_LEVELS.includes(value);
|
|
41
|
+
}
|
|
42
|
+
/** The requested level (option, then deck style, then `full`), lowered by reduced motion. */
|
|
43
|
+
export function effectiveMotion(options = {}) {
|
|
44
|
+
const style = options.deck?.style;
|
|
45
|
+
const level = isMotionLevel(options.motion)
|
|
46
|
+
? options.motion
|
|
47
|
+
: isMotionLevel(style?.motion)
|
|
48
|
+
? style.motion
|
|
49
|
+
: DEFAULT_MOTION;
|
|
50
|
+
if (level === 'none')
|
|
51
|
+
return 'none';
|
|
52
|
+
return options.reducedMotion ? 'subtle' : level;
|
|
53
|
+
}
|
|
54
|
+
function clampMs(ms) {
|
|
55
|
+
return Math.max(0, Math.min(MAX_MOTION_MS, Math.round(ms)));
|
|
56
|
+
}
|
|
57
|
+
/** The transition into `slide`: slide override → deck default → `fade`, under the motion level. */
|
|
58
|
+
export function resolveSlideTransition(input = {}) {
|
|
59
|
+
const own = input.slide?.transition;
|
|
60
|
+
const deckDefault = input.deck?.style?.transition;
|
|
61
|
+
const [requested, source] = isDeckTransition(own)
|
|
62
|
+
? [own, 'slide-override']
|
|
63
|
+
: isDeckTransition(deckDefault)
|
|
64
|
+
? [deckDefault, 'deck-default']
|
|
65
|
+
: [DEFAULT_TRANSITION, 'engine-default'];
|
|
66
|
+
const motion = effectiveMotion(input);
|
|
67
|
+
if (motion === 'none' || requested === 'none') {
|
|
68
|
+
return {
|
|
69
|
+
name: 'none',
|
|
70
|
+
durationMs: 0,
|
|
71
|
+
requested,
|
|
72
|
+
motion,
|
|
73
|
+
reason: motion === 'none' ? 'motion-none' : source,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
if (input.reducedMotion) {
|
|
77
|
+
return {
|
|
78
|
+
name: 'fade',
|
|
79
|
+
durationMs: REDUCED_FADE_MS,
|
|
80
|
+
requested,
|
|
81
|
+
motion,
|
|
82
|
+
reason: 'reduced-motion',
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
if (motion === 'subtle') {
|
|
86
|
+
// Subtle keeps direction (slide, fade-up, morph) but drops scaling and shortens everything.
|
|
87
|
+
const name = requested === 'zoom' ? 'fade' : requested;
|
|
88
|
+
return {
|
|
89
|
+
name,
|
|
90
|
+
durationMs: clampMs(TRANSITION_MS[name] * SUBTLE_SCALE),
|
|
91
|
+
requested,
|
|
92
|
+
motion,
|
|
93
|
+
reason: 'subtle',
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
return {
|
|
97
|
+
name: requested,
|
|
98
|
+
durationMs: clampMs(TRANSITION_MS[requested]),
|
|
99
|
+
requested,
|
|
100
|
+
motion,
|
|
101
|
+
reason: source,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
/** The transition into every slide of a deck, in order. */
|
|
105
|
+
export function resolveDeckTransitions(deck, options = {}) {
|
|
106
|
+
const slides = Array.isArray(deck?.slides) ? deck.slides : [];
|
|
107
|
+
return slides.map((slide, index) => ({
|
|
108
|
+
index,
|
|
109
|
+
...(typeof slide?.id === 'string' ? { slideId: slide.id } : {}),
|
|
110
|
+
transition: resolveSlideTransition({ ...options, slide, deck }),
|
|
111
|
+
}));
|
|
112
|
+
}
|
|
113
|
+
const MOVING = new Set(['fade-up', 'grow', 'shrink']);
|
|
114
|
+
/** How a fragment animates under the motion level. */
|
|
115
|
+
export function resolveFragmentMotion(effect, options = {}) {
|
|
116
|
+
const motion = effectiveMotion(options);
|
|
117
|
+
const still = MOVING.has(effect) ? 'in' : effect;
|
|
118
|
+
if (motion === 'none')
|
|
119
|
+
return { effect: still, durationMs: 0, motion, reason: 'motion-none' };
|
|
120
|
+
if (options.reducedMotion) {
|
|
121
|
+
return { effect: still, durationMs: REDUCED_FADE_MS, motion, reason: 'reduced-motion' };
|
|
122
|
+
}
|
|
123
|
+
if (motion === 'subtle') {
|
|
124
|
+
return { effect, durationMs: clampMs(FRAGMENT_MS * SUBTLE_SCALE), motion, reason: 'subtle' };
|
|
125
|
+
}
|
|
126
|
+
return { effect, durationMs: clampMs(FRAGMENT_MS), motion, reason: 'full' };
|
|
127
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `parseDeck`: validate a v2 deck and collect every issue (model-v2 §3.4).
|
|
3
|
+
*
|
|
4
|
+
* 1. The structural pass (the schema kit): every field, limit and union.
|
|
5
|
+
* 2. The cross-field passes, which run even when the structural pass failed
|
|
6
|
+
* (on a bounded plain copy of the input), so authors see all problems at
|
|
7
|
+
* once: unique slide, section and block ids, outline references, stacks,
|
|
8
|
+
* `navigation.start`, hidden slides, and the steps of every slide.
|
|
9
|
+
* 3. The host phase: the optional `validateBlock`, `validateComponent` and
|
|
10
|
+
* `knownComponents` options check block bodies and component props, which
|
|
11
|
+
* this L0 package cannot know.
|
|
12
|
+
*
|
|
13
|
+
* Issues are capped (default 200, `truncated: true` past it). Never throws.
|
|
14
|
+
*/
|
|
15
|
+
import { type DeckDiagnostic, type PathSegment } from '../schema/index.js';
|
|
16
|
+
import { type Deck, type JsonObject, type SlideBlock } from './deck.js';
|
|
17
|
+
import { type SlideSteps } from './steps/slide-steps.js';
|
|
18
|
+
/** A whole-slide component or an inline component item, as the host validator sees it. */
|
|
19
|
+
export interface DeckComponentInput {
|
|
20
|
+
component: string;
|
|
21
|
+
props: JsonObject;
|
|
22
|
+
}
|
|
23
|
+
/** What a host hook returns: issues with absolute paths (or nothing when valid). */
|
|
24
|
+
export type DeckHookResult = readonly DeckDiagnostic[] | undefined;
|
|
25
|
+
export interface ParseDeckOptions {
|
|
26
|
+
/**
|
|
27
|
+
* Validate one top-level block of a free slide (its step fields removed),
|
|
28
|
+
* e.g. with `@texuli/blocks` `safeParseBlock`. `path` is absolute
|
|
29
|
+
* (`['slides', 2, 'blocks', 0]`); return issues with absolute paths.
|
|
30
|
+
*/
|
|
31
|
+
validateBlock?(block: SlideBlock, path: PathSegment[]): DeckHookResult;
|
|
32
|
+
/** Validate a component's props with the host registry (inline items and component slides). */
|
|
33
|
+
validateComponent?(component: DeckComponentInput, path: PathSegment[]): DeckHookResult;
|
|
34
|
+
/** Component ids the host can paint; any other id is `unknown-component`. */
|
|
35
|
+
knownComponents?: readonly string[];
|
|
36
|
+
/** Issues kept before `truncated` (default 200). */
|
|
37
|
+
maxIssues?: number;
|
|
38
|
+
}
|
|
39
|
+
export interface DeckParseResult {
|
|
40
|
+
/** True when no error was found (warnings may remain in `issues`). */
|
|
41
|
+
ok: boolean;
|
|
42
|
+
/** The validated, trimmed copy; only when `ok`. */
|
|
43
|
+
deck?: Deck;
|
|
44
|
+
issues: DeckDiagnostic[];
|
|
45
|
+
/** The steps of each slide, in order (computed on partial input too). */
|
|
46
|
+
steps: SlideSteps[];
|
|
47
|
+
/** More issues existed than `maxIssues`. */
|
|
48
|
+
truncated: boolean;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* A copy of a block without the deck's step fields (at every nesting level,
|
|
52
|
+
* including chart `series` entries, which the chart block parser refuses
|
|
53
|
+
* with step fields).
|
|
54
|
+
*/
|
|
55
|
+
export declare function stripStepFields<T>(block: T): T;
|
|
56
|
+
/** Validate a v2 deck: every structural, cross-field and host issue. Never throws. */
|
|
57
|
+
export declare function parseDeck(input: unknown, options?: ParseDeckOptions): DeckParseResult;
|
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `parseDeck`: validate a v2 deck and collect every issue (model-v2 §3.4).
|
|
3
|
+
*
|
|
4
|
+
* 1. The structural pass (the schema kit): every field, limit and union.
|
|
5
|
+
* 2. The cross-field passes, which run even when the structural pass failed
|
|
6
|
+
* (on a bounded plain copy of the input), so authors see all problems at
|
|
7
|
+
* once: unique slide, section and block ids, outline references, stacks,
|
|
8
|
+
* `navigation.start`, hidden slides, and the steps of every slide.
|
|
9
|
+
* 3. The host phase: the optional `validateBlock`, `validateComponent` and
|
|
10
|
+
* `knownComponents` options check block bodies and component props, which
|
|
11
|
+
* this L0 package cannot know.
|
|
12
|
+
*
|
|
13
|
+
* Issues are capped (default 200, `truncated: true` past it). Never throws.
|
|
14
|
+
*/
|
|
15
|
+
import { createDiagnostic, MAX_DIAGNOSTICS, toJsonPath, validate, } from "../schema/index.js";
|
|
16
|
+
import { DECK_MODEL_LIMITS, deckSchema, forEachNested, STEP_FIELD_KEYS, } from "./deck.js";
|
|
17
|
+
import { layoutSteppablePaths } from "./layouts/catalog.js";
|
|
18
|
+
import { copyPlainData } from "./plain.js";
|
|
19
|
+
import { slideSteps } from "./steps/slide-steps.js";
|
|
20
|
+
function isBag(value) {
|
|
21
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
22
|
+
}
|
|
23
|
+
/** The issue list with its cap; errors are counted even past the cap. */
|
|
24
|
+
class Sink {
|
|
25
|
+
issues = [];
|
|
26
|
+
errors = 0;
|
|
27
|
+
truncated = false;
|
|
28
|
+
max;
|
|
29
|
+
constructor(max) {
|
|
30
|
+
this.max = max;
|
|
31
|
+
}
|
|
32
|
+
add(issue) {
|
|
33
|
+
if (issue.severity === 'error')
|
|
34
|
+
this.errors++;
|
|
35
|
+
if (this.issues.length >= this.max) {
|
|
36
|
+
this.truncated = true;
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
this.issues.push(issue);
|
|
40
|
+
}
|
|
41
|
+
report(code, message, path, extra) {
|
|
42
|
+
this.add(createDiagnostic(code, message, path, extra));
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* A copy of a block without the deck's step fields (at every nesting level,
|
|
47
|
+
* including chart `series` entries, which the chart block parser refuses
|
|
48
|
+
* with step fields).
|
|
49
|
+
*/
|
|
50
|
+
export function stripStepFields(block) {
|
|
51
|
+
if (!isBag(block))
|
|
52
|
+
return block;
|
|
53
|
+
const out = {};
|
|
54
|
+
for (const key of Object.keys(block)) {
|
|
55
|
+
if (STEP_FIELD_KEYS.includes(key))
|
|
56
|
+
continue;
|
|
57
|
+
out[key] = block[key];
|
|
58
|
+
}
|
|
59
|
+
for (const key of ['blocks', 'children', 'series']) {
|
|
60
|
+
if (Array.isArray(out[key]))
|
|
61
|
+
out[key] = out[key].map(stripStepFields);
|
|
62
|
+
}
|
|
63
|
+
if (Array.isArray(out.columns)) {
|
|
64
|
+
out.columns = out.columns.map((column) => Array.isArray(column) ? column.map(stripStepFields) : column);
|
|
65
|
+
}
|
|
66
|
+
if (Array.isArray(out.panels)) {
|
|
67
|
+
out.panels = out.panels.map((panel) => isBag(panel) && Array.isArray(panel.blocks)
|
|
68
|
+
? { ...panel, blocks: panel.blocks.map(stripStepFields) }
|
|
69
|
+
: panel);
|
|
70
|
+
}
|
|
71
|
+
return out;
|
|
72
|
+
}
|
|
73
|
+
function runHook(sink, code, path, call) {
|
|
74
|
+
let result;
|
|
75
|
+
try {
|
|
76
|
+
result = call();
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
sink.report(code, 'The host validator failed on this value.', path, {
|
|
80
|
+
fix: 'Check the value against the host registry.',
|
|
81
|
+
});
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
if (!Array.isArray(result))
|
|
85
|
+
return;
|
|
86
|
+
for (const issue of result) {
|
|
87
|
+
if (isBag(issue) && typeof issue.code === 'string' && typeof issue.message === 'string') {
|
|
88
|
+
sink.add(issue);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
function checkComponent(sink, options, value, path) {
|
|
93
|
+
const id = value.component;
|
|
94
|
+
if (typeof id !== 'string')
|
|
95
|
+
return;
|
|
96
|
+
if (options.knownComponents !== undefined && !options.knownComponents.includes(id)) {
|
|
97
|
+
sink.report('unknown-component', `The host has no component "${id.slice(0, 64)}".`, [...path, 'component'], {
|
|
98
|
+
fix: 'Use a component id from the host catalog, or a block.',
|
|
99
|
+
});
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
if (options.validateComponent && isBag(value.props)) {
|
|
103
|
+
const input = { component: id, props: value.props };
|
|
104
|
+
runHook(sink, 'component-props', path, () => options.validateComponent?.(input, [...path]));
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
/** Block ids (deck-wide), nested component items and the host block hook. */
|
|
108
|
+
function walkItem(pass, item, path, top, depth) {
|
|
109
|
+
if (!isBag(item) || depth > 16)
|
|
110
|
+
return;
|
|
111
|
+
const id = item.id;
|
|
112
|
+
if (typeof id === 'string') {
|
|
113
|
+
const first = pass.blockIds.get(id);
|
|
114
|
+
if (first !== undefined) {
|
|
115
|
+
pass.sink.report('duplicate-block-id', `The block id "${id}" is already used at ${first}.`, [...path, 'id'], { fix: 'Give every block a unique id (they key interactive state and deep links).' });
|
|
116
|
+
}
|
|
117
|
+
else
|
|
118
|
+
pass.blockIds.set(id, toJsonPath(path));
|
|
119
|
+
}
|
|
120
|
+
if (item.type === 'component') {
|
|
121
|
+
checkComponent(pass.sink, pass.options, item, path);
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
if (top && typeof item.type === 'string' && pass.options.validateBlock) {
|
|
125
|
+
const block = stripStepFields(item);
|
|
126
|
+
runHook(pass.sink, 'block-props', path, () => pass.options.validateBlock?.(block, [...path]));
|
|
127
|
+
}
|
|
128
|
+
forEachNested(item, path, (child, childPath) => {
|
|
129
|
+
walkItem(pass, child, childPath, false, depth + 1);
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
function crossField(tree, options, sink) {
|
|
133
|
+
const deck = isBag(tree) ? tree : {};
|
|
134
|
+
const slides = Array.isArray(deck.slides)
|
|
135
|
+
? deck.slides.slice(0, DECK_MODEL_LIMITS.slides)
|
|
136
|
+
: [];
|
|
137
|
+
// Slide ids
|
|
138
|
+
const slideIndex = new Map();
|
|
139
|
+
const hidden = new Set();
|
|
140
|
+
let visible = 0;
|
|
141
|
+
slides.forEach((slide, i) => {
|
|
142
|
+
if (!isBag(slide))
|
|
143
|
+
return;
|
|
144
|
+
if (slide.hidden !== true)
|
|
145
|
+
visible++;
|
|
146
|
+
const id = slide.id;
|
|
147
|
+
if (typeof id !== 'string')
|
|
148
|
+
return;
|
|
149
|
+
const first = slideIndex.get(id);
|
|
150
|
+
if (first !== undefined) {
|
|
151
|
+
sink.report('duplicate-id', `The slide id "${id}" is already used by slides[${first}].`, ['slides', i, 'id'], { fix: 'Give every slide a unique id.' });
|
|
152
|
+
}
|
|
153
|
+
else {
|
|
154
|
+
slideIndex.set(id, i);
|
|
155
|
+
if (slide.hidden === true)
|
|
156
|
+
hidden.add(id);
|
|
157
|
+
}
|
|
158
|
+
});
|
|
159
|
+
if (slides.length > 0 && visible === 0) {
|
|
160
|
+
sink.report('all-hidden', 'Every slide is hidden, so the deck presents nothing.', ['slides'], {
|
|
161
|
+
fix: 'Show at least one slide (remove `hidden: true`).',
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
// Navigation start
|
|
165
|
+
const navigation = isBag(deck.navigation) ? deck.navigation : undefined;
|
|
166
|
+
const start = navigation?.start;
|
|
167
|
+
if (typeof start === 'string') {
|
|
168
|
+
if (!slideIndex.has(start)) {
|
|
169
|
+
sink.report('unknown-slide', `No slide has the id "${start}".`, ['navigation', 'start'], {
|
|
170
|
+
fix: 'Use the id of a slide in `slides`.',
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
else if (hidden.has(start)) {
|
|
174
|
+
sink.report('start-hidden', `The start slide "${start}" is hidden.`, ['navigation', 'start'], {
|
|
175
|
+
fix: 'Start on a visible slide, or show this one.',
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
// Outline
|
|
180
|
+
const outline = isBag(deck.outline) ? deck.outline : undefined;
|
|
181
|
+
const sections = Array.isArray(outline?.sections)
|
|
182
|
+
? outline.sections.slice(0, DECK_MODEL_LIMITS.sections)
|
|
183
|
+
: [];
|
|
184
|
+
const sectionIds = new Map();
|
|
185
|
+
const owner = new Map();
|
|
186
|
+
sections.forEach((section, s) => {
|
|
187
|
+
if (!isBag(section))
|
|
188
|
+
return;
|
|
189
|
+
const base = ['outline', 'sections', s];
|
|
190
|
+
const sectionId = typeof section.id === 'string' ? section.id : `#${s}`;
|
|
191
|
+
if (typeof section.id === 'string') {
|
|
192
|
+
const first = sectionIds.get(section.id);
|
|
193
|
+
if (first !== undefined) {
|
|
194
|
+
sink.report('duplicate-id', `The section id "${section.id}" is already used by sections[${first}].`, [...base, 'id'], { fix: 'Give every section a unique id.' });
|
|
195
|
+
}
|
|
196
|
+
else
|
|
197
|
+
sectionIds.set(section.id, s);
|
|
198
|
+
}
|
|
199
|
+
const ids = Array.isArray(section.slideIds)
|
|
200
|
+
? section.slideIds.slice(0, DECK_MODEL_LIMITS.sectionSlides)
|
|
201
|
+
: [];
|
|
202
|
+
const seen = new Set();
|
|
203
|
+
const indexes = [];
|
|
204
|
+
ids.forEach((id, k) => {
|
|
205
|
+
if (typeof id !== 'string')
|
|
206
|
+
return;
|
|
207
|
+
const path = [...base, 'slideIds', k];
|
|
208
|
+
if (seen.has(id)) {
|
|
209
|
+
sink.report('duplicate-id', `"${id}" is listed twice in this section.`, path, {
|
|
210
|
+
fix: 'List each slide once.',
|
|
211
|
+
});
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
seen.add(id);
|
|
215
|
+
const index = slideIndex.get(id);
|
|
216
|
+
if (index === undefined) {
|
|
217
|
+
sink.report('unknown-slide', `No slide has the id "${id}".`, path, {
|
|
218
|
+
fix: 'Use the id of a slide in `slides`, or remove it from the section.',
|
|
219
|
+
});
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
indexes.push(index);
|
|
223
|
+
const other = owner.get(id);
|
|
224
|
+
if (other !== undefined) {
|
|
225
|
+
sink.report('section-overlap', `The slide "${id}" is already in section "${other}"; a slide belongs to one section.`, path, { fix: 'Remove the slide from one of the sections.' });
|
|
226
|
+
}
|
|
227
|
+
else
|
|
228
|
+
owner.set(id, sectionId);
|
|
229
|
+
});
|
|
230
|
+
if (section.stack === true && indexes.length > 1) {
|
|
231
|
+
const lowest = Math.min(...indexes);
|
|
232
|
+
const highest = Math.max(...indexes);
|
|
233
|
+
if (highest - lowest + 1 !== indexes.length) {
|
|
234
|
+
sink.report('stack-not-contiguous', 'A vertical stack lists slides that are not adjacent in `slides`.', [...base, 'slideIds'], { fix: 'Move the stacked slides next to each other, or drop `stack`.' });
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
});
|
|
238
|
+
// Blocks, components and steps, per slide
|
|
239
|
+
const pass = { sink, options, blockIds: new Map() };
|
|
240
|
+
const steps = [];
|
|
241
|
+
slides.forEach((slide, i) => {
|
|
242
|
+
const path = ['slides', i];
|
|
243
|
+
if (isBag(slide)) {
|
|
244
|
+
if (slide.layout === 'free' && Array.isArray(slide.blocks)) {
|
|
245
|
+
slide.blocks.slice(0, DECK_MODEL_LIMITS.freeBlocks).forEach((item, b) => {
|
|
246
|
+
walkItem(pass, item, [...path, 'blocks', b], true, 0);
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
else if (slide.layout === 'component' && isBag(slide.component)) {
|
|
250
|
+
checkComponent(sink, options, slide.component, [...path, 'component']);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
const result = slideSteps(isBag(slide) ? slide : {}, {
|
|
254
|
+
steppablePaths: isBag(slide) ? layoutSteppablePaths(slide.layout) : [],
|
|
255
|
+
path,
|
|
256
|
+
});
|
|
257
|
+
for (const issue of result.issues)
|
|
258
|
+
sink.add(issue);
|
|
259
|
+
steps.push(result);
|
|
260
|
+
});
|
|
261
|
+
return steps;
|
|
262
|
+
}
|
|
263
|
+
/** Fill `slideId` on issues under `slides[n]` (for editors and agent feedback). */
|
|
264
|
+
function tagSlides(issues, tree) {
|
|
265
|
+
const slides = isBag(tree) && Array.isArray(tree.slides) ? tree.slides : [];
|
|
266
|
+
for (const issue of issues) {
|
|
267
|
+
if (issue.slideId !== undefined || issue.path[0] !== 'slides')
|
|
268
|
+
continue;
|
|
269
|
+
const index = issue.path[1];
|
|
270
|
+
if (typeof index !== 'number')
|
|
271
|
+
continue;
|
|
272
|
+
const slide = slides[index];
|
|
273
|
+
if (isBag(slide) && typeof slide.id === 'string')
|
|
274
|
+
issue.slideId = slide.id;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
/** Validate a v2 deck: every structural, cross-field and host issue. Never throws. */
|
|
278
|
+
export function parseDeck(input, options = {}) {
|
|
279
|
+
const max = options.maxIssues !== undefined && options.maxIssues > 0
|
|
280
|
+
? Math.floor(options.maxIssues)
|
|
281
|
+
: MAX_DIAGNOSTICS;
|
|
282
|
+
const sink = new Sink(max);
|
|
283
|
+
const structural = validate(deckSchema, input, { maxIssues: max });
|
|
284
|
+
for (const issue of structural.issues)
|
|
285
|
+
sink.add(issue);
|
|
286
|
+
if (structural.truncated)
|
|
287
|
+
sink.truncated = true;
|
|
288
|
+
if (!structural.ok)
|
|
289
|
+
sink.errors = Math.max(sink.errors, 1);
|
|
290
|
+
// Partial input still gets the cross-field passes, on a bounded plain copy.
|
|
291
|
+
const tree = structural.ok
|
|
292
|
+
? structural.value
|
|
293
|
+
: copyPlainData(input, { maxDepth: 64, maxBytes: 16 * 1024 * 1024, maxIssues: 0 }).value;
|
|
294
|
+
let steps = [];
|
|
295
|
+
try {
|
|
296
|
+
steps = crossField(tree, options, sink);
|
|
297
|
+
}
|
|
298
|
+
catch {
|
|
299
|
+
sink.report('invalid-input', 'The deck could not be checked as plain data.', []);
|
|
300
|
+
}
|
|
301
|
+
tagSlides(sink.issues, tree);
|
|
302
|
+
const ok = structural.ok && sink.errors === 0;
|
|
303
|
+
const result = { ok, issues: sink.issues, steps, truncated: sink.truncated };
|
|
304
|
+
if (ok)
|
|
305
|
+
result.deck = structural.value;
|
|
306
|
+
return result;
|
|
307
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A bounded plain-data copy of untrusted input (ported from Protoyard's
|
|
3
|
+
* `untrusted.ts` sanitizer). Only JSON data survives: own enumerable data
|
|
4
|
+
* properties of plain objects and dense arrays, strings, finite numbers,
|
|
5
|
+
* booleans and `null`. Accessors, class instances, functions, symbols,
|
|
6
|
+
* cycles, prototype keys and anything past the depth or byte budget are
|
|
7
|
+
* dropped and reported, never followed. Optionally strips characters that
|
|
8
|
+
* hide or reorder text. Walked with an explicit stack, so hostile nesting
|
|
9
|
+
* never costs native recursion. Never throws.
|
|
10
|
+
*/
|
|
11
|
+
import { type DeckDiagnostic } from '../schema/index.js';
|
|
12
|
+
export interface PlainCopyOptions {
|
|
13
|
+
/** Containers along one path (the root object is depth 1). Default 64. */
|
|
14
|
+
maxDepth?: number;
|
|
15
|
+
/** An estimate of the JSON size in UTF-8 bytes; the copy stops past it. Default unbounded. */
|
|
16
|
+
maxBytes?: number;
|
|
17
|
+
/** Strip hidden characters from strings and keys (and report them). Default false. */
|
|
18
|
+
strip?: boolean;
|
|
19
|
+
/** Issues kept (default 200); later ones only set `truncated`. */
|
|
20
|
+
maxIssues?: number;
|
|
21
|
+
}
|
|
22
|
+
export interface PlainCopyResult {
|
|
23
|
+
/** The copy; `undefined` when the root itself was refused. */
|
|
24
|
+
value: unknown;
|
|
25
|
+
issues: DeckDiagnostic[];
|
|
26
|
+
/** True when an error was reported (the copy is partial). */
|
|
27
|
+
failed: boolean;
|
|
28
|
+
truncated: boolean;
|
|
29
|
+
/** Estimated UTF-8 JSON size of the copy. */
|
|
30
|
+
bytes: number;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Remove hidden characters (see `isHidden`); U+2028 and U+2029 become a
|
|
34
|
+
* space. Returns the string unchanged (same reference) when nothing is hidden.
|
|
35
|
+
*/
|
|
36
|
+
export declare function stripHiddenCharacters(value: string): string;
|
|
37
|
+
/** UTF-8 byte length without allocating. */
|
|
38
|
+
export declare function utf8Length(value: string): number;
|
|
39
|
+
/** Copy `input` as plain JSON data; see the module comment for what is dropped. */
|
|
40
|
+
export declare function copyPlainData(input: unknown, options?: PlainCopyOptions): PlainCopyResult;
|