@protoyard/blueprint 0.0.0-stage → 1.1.1
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 +1145 -2
- package/dist/aggregate.d.ts +83 -0
- package/dist/aggregate.js +268 -0
- package/dist/app-model.d.ts +76 -0
- package/dist/app-model.js +287 -0
- package/dist/archetypes/index.d.ts +1 -0
- package/dist/archetypes/index.js +1 -0
- package/dist/archetypes/schema.d.ts +126 -0
- package/dist/archetypes/schema.js +77 -0
- package/dist/audit.d.ts +52 -0
- package/dist/audit.js +149 -0
- package/dist/authoring/deck-lint.d.ts +32 -0
- package/dist/authoring/deck-lint.js +567 -0
- package/dist/authoring/index.d.ts +1 -0
- package/dist/authoring/index.js +1 -0
- package/dist/authoring/select-deck-layouts.d.ts +50 -0
- package/dist/authoring/select-deck-layouts.js +213 -0
- package/dist/authoring-apis.d.ts +13 -0
- package/dist/authoring-apis.js +13 -0
- package/dist/authoring-validation.d.ts +65 -0
- package/dist/authoring-validation.js +267 -0
- package/dist/avatar.d.ts +25 -0
- package/dist/avatar.js +30 -0
- package/dist/brand.d.ts +70 -0
- package/dist/brand.js +294 -0
- package/dist/brief/archetypes.d.ts +28 -0
- package/dist/brief/archetypes.js +798 -0
- package/dist/brief/content-synthesize.d.ts +31 -0
- package/dist/brief/content-synthesize.js +210 -0
- package/dist/brief/deck-outlines.d.ts +96 -0
- package/dist/brief/deck-outlines.js +561 -0
- package/dist/brief/draft-bodies.d.ts +29 -0
- package/dist/brief/draft-bodies.js +224 -0
- package/dist/brief/fields.d.ts +19 -0
- package/dist/brief/fields.js +229 -0
- package/dist/brief/index.d.ts +8 -0
- package/dist/brief/index.js +8 -0
- package/dist/brief/kind-detection.d.ts +69 -0
- package/dist/brief/kind-detection.js +353 -0
- package/dist/brief/limits.d.ts +117 -0
- package/dist/brief/limits.js +217 -0
- package/dist/brief/page-doc-outlines.d.ts +80 -0
- package/dist/brief/page-doc-outlines.js +485 -0
- package/dist/brief/synthesize.d.ts +59 -0
- package/dist/brief/synthesize.js +511 -0
- package/dist/brief/text.d.ts +13 -0
- package/dist/brief/text.js +59 -0
- package/dist/canvas-screen.d.ts +31 -0
- package/dist/canvas-screen.js +33 -0
- package/dist/canvas-seed.d.ts +58 -0
- package/dist/canvas-seed.js +82 -0
- package/dist/computed/evaluate.d.ts +11 -0
- package/dist/computed/evaluate.js +101 -0
- package/dist/computed/index.d.ts +4 -0
- package/dist/computed/index.js +3 -0
- package/dist/computed/public.d.ts +14 -0
- package/dist/computed/public.js +19 -0
- package/dist/computed/schema.d.ts +10 -0
- package/dist/computed/schema.js +140 -0
- package/dist/computed/types.d.ts +70 -0
- package/dist/computed/types.js +1 -0
- package/dist/computed/validate.d.ts +6 -0
- package/dist/computed/validate.js +146 -0
- package/dist/csv-guard.d.ts +15 -0
- package/dist/csv-guard.js +29 -0
- package/dist/csv.d.ts +85 -0
- package/dist/csv.js +288 -0
- package/dist/files.d.ts +35 -0
- package/dist/files.js +246 -0
- package/dist/form-schema.d.ts +124 -0
- package/dist/form-schema.js +300 -0
- package/dist/generator-version.d.ts +11 -0
- package/dist/generator-version.js +51 -0
- package/dist/generator.d.ts +34 -0
- package/dist/generator.js +445 -0
- package/dist/identity.d.ts +34 -0
- package/dist/identity.js +91 -0
- package/dist/import/csv-shape.d.ts +40 -0
- package/dist/import/csv-shape.js +265 -0
- package/dist/import/json-shape.d.ts +55 -0
- package/dist/import/json-shape.js +491 -0
- package/dist/import/limits.d.ts +13 -0
- package/dist/import/limits.js +14 -0
- package/dist/import/sqlite-schema.d.ts +36 -0
- package/dist/import/sqlite-schema.js +729 -0
- package/dist/index.d.ts +60 -0
- package/dist/index.js +58 -0
- package/dist/kinds/anchors.d.ts +18 -0
- package/dist/kinds/anchors.js +46 -0
- package/dist/kinds/bodies.d.ts +6 -0
- package/dist/kinds/bodies.js +32 -0
- package/dist/kinds/brand-runtime.d.ts +19 -0
- package/dist/kinds/brand-runtime.js +35 -0
- package/dist/kinds/deck.d.ts +8 -0
- package/dist/kinds/deck.js +34 -0
- package/dist/kinds/doc.d.ts +19 -0
- package/dist/kinds/doc.js +41 -0
- package/dist/kinds/embed/index.d.ts +39 -0
- package/dist/kinds/embed/index.js +91 -0
- package/dist/kinds/envelope.d.ts +159 -0
- package/dist/kinds/envelope.js +78 -0
- package/dist/kinds/index.d.ts +11 -0
- package/dist/kinds/index.js +11 -0
- package/dist/kinds/kind.d.ts +36 -0
- package/dist/kinds/kind.js +55 -0
- package/dist/kinds/limits.d.ts +64 -0
- package/dist/kinds/limits.js +190 -0
- package/dist/kinds/page-doc-runtime.d.ts +37 -0
- package/dist/kinds/page-doc-runtime.js +55 -0
- package/dist/kinds/page.d.ts +16 -0
- package/dist/kinds/page.js +67 -0
- package/dist/kinds/parse.d.ts +18 -0
- package/dist/kinds/parse.js +27 -0
- package/dist/kinds/runtime.d.ts +128 -0
- package/dist/kinds/runtime.js +206 -0
- package/dist/kinds/services.d.ts +49 -0
- package/dist/kinds/services.js +249 -0
- package/dist/kinds/testing.d.ts +9 -0
- package/dist/kinds/testing.js +16 -0
- package/dist/migrations.d.ts +97 -0
- package/dist/migrations.js +351 -0
- package/dist/motion.d.ts +50 -0
- package/dist/motion.js +52 -0
- package/dist/notifications.d.ts +19 -0
- package/dist/notifications.js +177 -0
- package/dist/patch.d.ts +122 -0
- package/dist/patch.js +322 -0
- package/dist/policy.d.ts +23 -0
- package/dist/policy.js +105 -0
- package/dist/runtime-definition.d.ts +39 -0
- package/dist/runtime-definition.js +74 -0
- package/dist/scenarios.d.ts +9 -0
- package/dist/scenarios.js +49 -0
- package/dist/schema/blueprint.schema.json +10191 -0
- package/dist/schema-json.d.ts +6 -0
- package/dist/schema-json.js +36 -0
- package/dist/schema.d.ts +6348 -0
- package/dist/schema.js +1269 -0
- package/dist/search.d.ts +16 -0
- package/dist/search.js +96 -0
- package/dist/services.d.ts +89 -0
- package/dist/services.js +898 -0
- package/dist/share.d.ts +78 -0
- package/dist/share.js +234 -0
- package/dist/sheet-screen.d.ts +49 -0
- package/dist/sheet-screen.js +91 -0
- package/dist/storage.d.ts +43 -0
- package/dist/storage.js +179 -0
- package/dist/stream.d.ts +26 -0
- package/dist/stream.js +173 -0
- package/dist/untrusted.d.ts +63 -0
- package/dist/untrusted.js +502 -0
- package/dist/validation-tasks.d.ts +178 -0
- package/dist/validation-tasks.js +199 -0
- package/dist/validation.d.ts +35 -0
- package/dist/validation.js +312 -0
- package/dist/variants.d.ts +112 -0
- package/dist/variants.js +283 -0
- package/dist/workflows/index.d.ts +3 -0
- package/dist/workflows/index.js +3 -0
- package/dist/workflows/schema.d.ts +50 -0
- package/dist/workflows/schema.js +34 -0
- package/dist/workflows/transition.d.ts +15 -0
- package/dist/workflows/transition.js +25 -0
- package/dist/workflows/validate.d.ts +17 -0
- package/dist/workflows/validate.js +140 -0
- package/package.json +120 -3
|
@@ -0,0 +1,567 @@
|
|
|
1
|
+
import { forEachBlock } from '@protoyard/blocks';
|
|
2
|
+
import { checkDeckMediaSrc } from '@protoyard/decks';
|
|
3
|
+
import { DECK_LAYOUT_BY_ID, parseLayoutProps, stripStepMarkers } from '@protoyard/decks/layouts';
|
|
4
|
+
import { checkDeckStatic } from '@protoyard/decks/quality';
|
|
5
|
+
import { safeParseDeck } from '@protoyard/decks/schema';
|
|
6
|
+
const FRAGMENT_EFFECTS = new Set([
|
|
7
|
+
'fade-up',
|
|
8
|
+
'in',
|
|
9
|
+
'out',
|
|
10
|
+
'in-then-out',
|
|
11
|
+
'grow',
|
|
12
|
+
'shrink',
|
|
13
|
+
'highlight',
|
|
14
|
+
'strike',
|
|
15
|
+
]);
|
|
16
|
+
const REVEAL_EFFECTS = new Set(['fade', 'fade-up', 'grow', 'highlight', 'strike']);
|
|
17
|
+
function jsonPath(path) {
|
|
18
|
+
return path.reduce((result, part) => typeof part === 'number' ? `${result}[${part}]` : `${result}[${JSON.stringify(part)}]`, '$');
|
|
19
|
+
}
|
|
20
|
+
function diagnostic(code, path, message, severity = 'error') {
|
|
21
|
+
return { code, severity, path, jsonPath: jsonPath(path), message };
|
|
22
|
+
}
|
|
23
|
+
function schemaDiagnosticCode(path) {
|
|
24
|
+
if (path.at(-1) === 'alt')
|
|
25
|
+
return 'image-alt';
|
|
26
|
+
if (path.at(-1) === 'label' && (path.includes('cta') || path.includes('actions'))) {
|
|
27
|
+
return 'cta-label';
|
|
28
|
+
}
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
/** A hard capacity from the layout catalog: the deck model's `too-many` / `too-long`, or zod's "Too big". */
|
|
32
|
+
function isCapacity(issue) {
|
|
33
|
+
return (issue.message.startsWith('Too big:') || issue.code === 'too-many' || issue.code === 'too-long');
|
|
34
|
+
}
|
|
35
|
+
function schemaDiagnostics(issues) {
|
|
36
|
+
return issues.map((issue) => {
|
|
37
|
+
const path = issue.path.filter((segment) => typeof segment === 'string' || typeof segment === 'number');
|
|
38
|
+
const isSlideId = path[0] === 'slides' && path.at(-1) === 'id';
|
|
39
|
+
const duplicate = issue.message.startsWith('Duplicate slide id') ||
|
|
40
|
+
(isSlideId && issue.code === 'duplicate-id');
|
|
41
|
+
const missing = (isSlideId || (path[0] === 'slides' && path.length === 2)) &&
|
|
42
|
+
issue.code === 'invalid_type' &&
|
|
43
|
+
!duplicate;
|
|
44
|
+
const invalidStep = path.at(-1) === 'step';
|
|
45
|
+
if (duplicate) {
|
|
46
|
+
return diagnostic('duplicate-slide-id', path, 'Slide ids must be unique within a deck.');
|
|
47
|
+
}
|
|
48
|
+
if (missing) {
|
|
49
|
+
const missingPath = isSlideId ? path : [...path, 'id'];
|
|
50
|
+
return diagnostic('missing-slide-id', missingPath, 'Every slide needs a valid id.');
|
|
51
|
+
}
|
|
52
|
+
const contentCode = schemaDiagnosticCode(path);
|
|
53
|
+
if (contentCode === 'image-alt') {
|
|
54
|
+
return diagnostic(contentCode, path, 'Describe the image in alt text, or mark it decorative.');
|
|
55
|
+
}
|
|
56
|
+
if (contentCode === 'cta-label') {
|
|
57
|
+
return diagnostic(contentCode, path, 'Every call to action needs a visible label.');
|
|
58
|
+
}
|
|
59
|
+
if (invalidStep) {
|
|
60
|
+
return diagnostic('invalid-fragment-order', path, 'Fragment steps must be valid integers from 0 through 32.');
|
|
61
|
+
}
|
|
62
|
+
if (isCapacity(issue)) {
|
|
63
|
+
return diagnostic('layout-capacity', path, 'This slide exceeds a hard capacity declared by its layout catalog entry.');
|
|
64
|
+
}
|
|
65
|
+
// DeckSchema enforces the layout catalog; keep its findings under the lint's specific codes.
|
|
66
|
+
if (path[0] === 'slides' && path.length === 3 && path[2] === 'layout') {
|
|
67
|
+
return diagnostic('unknown-layout', path, issue.message);
|
|
68
|
+
}
|
|
69
|
+
if (path[0] === 'slides' && path[2] === 'props') {
|
|
70
|
+
return diagnostic('invalid-layout-props', path.slice(0, 3), issue.message);
|
|
71
|
+
}
|
|
72
|
+
return diagnostic('invalid-deck-structure', path, 'This value does not match the deck schema.');
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
function missingSlideIds(input) {
|
|
76
|
+
if (input === null || typeof input !== 'object' || Array.isArray(input))
|
|
77
|
+
return [];
|
|
78
|
+
const slidesDescriptor = Object.getOwnPropertyDescriptor(input, 'slides');
|
|
79
|
+
const slides = slidesDescriptor && 'value' in slidesDescriptor ? slidesDescriptor.value : null;
|
|
80
|
+
if (!Array.isArray(slides))
|
|
81
|
+
return [];
|
|
82
|
+
const diagnostics = [];
|
|
83
|
+
slides.forEach((slide, index) => {
|
|
84
|
+
if (slide === null || typeof slide !== 'object' || Array.isArray(slide))
|
|
85
|
+
return;
|
|
86
|
+
const idDescriptor = Object.getOwnPropertyDescriptor(slide, 'id');
|
|
87
|
+
if (!idDescriptor || !('value' in idDescriptor) || idDescriptor.value === undefined) {
|
|
88
|
+
diagnostics.push(diagnostic('missing-slide-id', ['slides', index, 'id'], 'Every slide needs a valid id.'));
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
return diagnostics;
|
|
92
|
+
}
|
|
93
|
+
/** Preserve useful CTA paths when a nested schema union reports only its parent. */
|
|
94
|
+
function missingCtaLabels(input) {
|
|
95
|
+
const diagnostics = [];
|
|
96
|
+
const stack = [{ value: input, path: [] }];
|
|
97
|
+
let visited = 0;
|
|
98
|
+
while (stack.length > 0 && visited < 20_000) {
|
|
99
|
+
const current = stack.pop();
|
|
100
|
+
if (!current || current.value === null || typeof current.value !== 'object')
|
|
101
|
+
continue;
|
|
102
|
+
visited += 1;
|
|
103
|
+
const node = current.value;
|
|
104
|
+
if (Array.isArray(node)) {
|
|
105
|
+
for (let index = node.length - 1; index >= 0; index -= 1) {
|
|
106
|
+
const property = Object.getOwnPropertyDescriptor(node, String(index));
|
|
107
|
+
if (property && 'value' in property) {
|
|
108
|
+
stack.push({ value: property.value, path: [...current.path, index] });
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
const record = node;
|
|
114
|
+
const descriptor = (key) => {
|
|
115
|
+
const property = Object.getOwnPropertyDescriptor(node, key);
|
|
116
|
+
return property && 'value' in property ? property.value : undefined;
|
|
117
|
+
};
|
|
118
|
+
const type = descriptor('type');
|
|
119
|
+
const actions = descriptor('actions');
|
|
120
|
+
if (type === 'cta' && Array.isArray(actions)) {
|
|
121
|
+
actions.forEach((action, index) => {
|
|
122
|
+
if (action === null || typeof action !== 'object')
|
|
123
|
+
return;
|
|
124
|
+
const label = Object.getOwnPropertyDescriptor(action, 'label');
|
|
125
|
+
if (!label ||
|
|
126
|
+
!('value' in label) ||
|
|
127
|
+
typeof label.value !== 'string' ||
|
|
128
|
+
!label.value.trim()) {
|
|
129
|
+
diagnostics.push(diagnostic('cta-label', [...current.path, 'actions', index, 'label'], 'Every call to action needs a visible label.'));
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
const cta = descriptor('cta');
|
|
134
|
+
if (cta !== null && typeof cta === 'object' && !Array.isArray(cta)) {
|
|
135
|
+
const label = Object.getOwnPropertyDescriptor(cta, 'label');
|
|
136
|
+
if (!label || !('value' in label) || typeof label.value !== 'string' || !label.value.trim()) {
|
|
137
|
+
diagnostics.push(diagnostic('cta-label', [...current.path, 'cta', 'label'], 'Every call to action needs a visible label.'));
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
for (const key of Object.keys(record)) {
|
|
141
|
+
const property = Object.getOwnPropertyDescriptor(node, key);
|
|
142
|
+
if (property && 'value' in property) {
|
|
143
|
+
stack.push({ value: property.value, path: [...current.path, key] });
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return diagnostics;
|
|
148
|
+
}
|
|
149
|
+
/** Walk JSON data in author order without invoking getters or changing it. */
|
|
150
|
+
function marks(value, base) {
|
|
151
|
+
const fragments = [];
|
|
152
|
+
const links = [];
|
|
153
|
+
const fragmentProblems = [];
|
|
154
|
+
const stack = [{ value, path: base }];
|
|
155
|
+
while (stack.length > 0) {
|
|
156
|
+
const current = stack.pop();
|
|
157
|
+
if (!current)
|
|
158
|
+
continue;
|
|
159
|
+
const node = current.value;
|
|
160
|
+
if (Array.isArray(node)) {
|
|
161
|
+
for (let index = node.length - 1; index >= 0; index -= 1) {
|
|
162
|
+
stack.push({ value: node[index], path: [...current.path, index] });
|
|
163
|
+
}
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
if (node === null || typeof node !== 'object')
|
|
167
|
+
continue;
|
|
168
|
+
const record = node;
|
|
169
|
+
if (typeof record.href === 'string') {
|
|
170
|
+
links.push({ href: record.href, path: [...current.path, 'href'] });
|
|
171
|
+
}
|
|
172
|
+
const hasStep = Object.hasOwn(record, 'step');
|
|
173
|
+
const hasFragment = Object.hasOwn(record, 'fragment');
|
|
174
|
+
const hasReveal = Object.hasOwn(record, 'reveal');
|
|
175
|
+
if (hasStep && typeof record.step === 'number') {
|
|
176
|
+
fragments.push({ step: record.step, path: [...current.path, 'step'] });
|
|
177
|
+
}
|
|
178
|
+
else if (hasStep) {
|
|
179
|
+
fragmentProblems.push({
|
|
180
|
+
path: [...current.path, 'step'],
|
|
181
|
+
message: 'Fragment steps must be integers from 0 through 32.',
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
else if (hasFragment || hasReveal) {
|
|
185
|
+
fragmentProblems.push({
|
|
186
|
+
path: [...current.path, hasFragment ? 'fragment' : 'reveal'],
|
|
187
|
+
message: 'A fragment effect requires a step value.',
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
if (hasStep && hasFragment && !FRAGMENT_EFFECTS.has(String(record.fragment))) {
|
|
191
|
+
fragmentProblems.push({
|
|
192
|
+
path: [...current.path, 'fragment'],
|
|
193
|
+
message: 'Fragment effect is not supported by the deck motion model.',
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
if (hasStep && hasReveal && !REVEAL_EFFECTS.has(String(record.reveal))) {
|
|
197
|
+
fragmentProblems.push({
|
|
198
|
+
path: [...current.path, 'reveal'],
|
|
199
|
+
message: 'Reveal effect is not supported by the block vocabulary.',
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
if (record.type === 'code' && Array.isArray(record.highlights)) {
|
|
203
|
+
const firstStep = typeof record.step === 'number' ? record.step : 0;
|
|
204
|
+
record.highlights.forEach((_, index) => {
|
|
205
|
+
fragments.push({
|
|
206
|
+
step: firstStep + index,
|
|
207
|
+
path: [...current.path, 'highlights', index],
|
|
208
|
+
});
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
for (const key of Object.keys(record).reverse()) {
|
|
212
|
+
if (key === 'step' || key === 'fragment' || key === 'reveal' || key === 'href')
|
|
213
|
+
continue;
|
|
214
|
+
stack.push({ value: record[key], path: [...current.path, key] });
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
return { fragments, links, fragmentProblems };
|
|
218
|
+
}
|
|
219
|
+
function slideMarks(slide, index) {
|
|
220
|
+
const content = 'blocks' in slide ? slide.blocks : slide.props;
|
|
221
|
+
const base = ['slides', index, 'blocks' in slide ? 'blocks' : 'props'];
|
|
222
|
+
return marks(content, base);
|
|
223
|
+
}
|
|
224
|
+
function stepCount(slide, fragments) {
|
|
225
|
+
const authored = new Set(fragments.map(({ step }) => step).filter((step) => step > 0));
|
|
226
|
+
return Math.min(32, Math.max(slide.steps?.length ?? 0, authored.size));
|
|
227
|
+
}
|
|
228
|
+
function slideLink(href) {
|
|
229
|
+
if (href === '#/')
|
|
230
|
+
return null;
|
|
231
|
+
const match = /^#\/([a-z0-9][a-z0-9-]{0,63})(?:\/(\d{1,2}))?$/.exec(href);
|
|
232
|
+
if (!match)
|
|
233
|
+
return href.startsWith('#/') ? { slideId: '', step: -1 } : null;
|
|
234
|
+
const step = match[2] === undefined ? 0 : Number(match[2]);
|
|
235
|
+
return step <= 32 ? { slideId: match[1], step } : { slideId: '', step };
|
|
236
|
+
}
|
|
237
|
+
/**
|
|
238
|
+
* Check the structure and navigation references in an untrusted deck body.
|
|
239
|
+
* This function is deterministic, does not mutate input, render content or
|
|
240
|
+
* access the network. Diagnostics use paths relative to the deck object.
|
|
241
|
+
*/
|
|
242
|
+
export function lintDeckStructure(input) {
|
|
243
|
+
const missingIds = missingSlideIds(input);
|
|
244
|
+
if (missingIds.length > 0)
|
|
245
|
+
return { valid: false, diagnostics: missingIds };
|
|
246
|
+
const parsed = safeParseDeck(input);
|
|
247
|
+
if (!parsed.success) {
|
|
248
|
+
const diagnostics = schemaDiagnostics(parsed.issues);
|
|
249
|
+
return { valid: false, diagnostics };
|
|
250
|
+
}
|
|
251
|
+
const deck = parsed.deck;
|
|
252
|
+
const diagnostics = [];
|
|
253
|
+
const slidesById = new Map(deck.slides.map((slide, index) => [slide.id, { slide, index }]));
|
|
254
|
+
const perSlide = deck.slides.map((slide, index) => slideMarks(slide, index));
|
|
255
|
+
deck.slides.forEach((slide, index) => {
|
|
256
|
+
const layoutPath = ['slides', index, 'layout'];
|
|
257
|
+
// A custom slide renders an owned component (checked by the schema and checkDeckStatic).
|
|
258
|
+
if (slide.layout === 'custom') {
|
|
259
|
+
// Nothing to check against the catalog.
|
|
260
|
+
}
|
|
261
|
+
else if (!Object.hasOwn(DECK_LAYOUT_BY_ID, slide.layout)) {
|
|
262
|
+
diagnostics.push(diagnostic('unknown-layout', layoutPath, `Unknown layout "${slide.layout}". Choose a layout from the deck catalog.`));
|
|
263
|
+
}
|
|
264
|
+
else {
|
|
265
|
+
const propsResult = parseLayoutProps(slide.layout, 'blocks' in slide ? { blocks: slide.blocks } : stripStepMarkers(slide.props));
|
|
266
|
+
if (!propsResult.success) {
|
|
267
|
+
for (const issue of propsResult.issues) {
|
|
268
|
+
const issuePath = issue.path[0] === 'layout' ? layoutPath : ['slides', index, 'props', ...issue.path];
|
|
269
|
+
diagnostics.push(diagnostic(isCapacity(issue) ? 'layout-capacity' : 'invalid-layout-props', issuePath, isCapacity(issue)
|
|
270
|
+
? 'This slide exceeds a hard capacity declared by its layout catalog entry.'
|
|
271
|
+
: 'Slide props do not match this layout.'));
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
let previousStep = -1;
|
|
276
|
+
for (const fragment of perSlide[index]?.fragments ?? []) {
|
|
277
|
+
if (!Number.isInteger(fragment.step) || fragment.step < 0 || fragment.step > 32) {
|
|
278
|
+
diagnostics.push(diagnostic('invalid-fragment-order', fragment.path, 'Fragment steps must be integers from 0 through 32.'));
|
|
279
|
+
continue;
|
|
280
|
+
}
|
|
281
|
+
if (fragment.step < previousStep) {
|
|
282
|
+
diagnostics.push(diagnostic('invalid-fragment-order', fragment.path, 'Fragment steps must follow non-decreasing visual order.'));
|
|
283
|
+
}
|
|
284
|
+
previousStep = fragment.step;
|
|
285
|
+
}
|
|
286
|
+
for (const problem of perSlide[index]?.fragmentProblems ?? []) {
|
|
287
|
+
diagnostics.push(diagnostic('invalid-fragment-order', problem.path, problem.message));
|
|
288
|
+
}
|
|
289
|
+
});
|
|
290
|
+
if (deck.outline) {
|
|
291
|
+
let previousIndex = -1;
|
|
292
|
+
const assigned = new Set();
|
|
293
|
+
deck.outline.sections.forEach((section, sectionIndex) => {
|
|
294
|
+
section.slideIds.forEach((slideId, slideIndex) => {
|
|
295
|
+
const referencePath = ['outline', 'sections', sectionIndex, 'slideIds', slideIndex];
|
|
296
|
+
const targetIndex = slidesById.get(slideId)?.index;
|
|
297
|
+
if (targetIndex === undefined)
|
|
298
|
+
return;
|
|
299
|
+
if (assigned.has(slideId)) {
|
|
300
|
+
diagnostics.push(diagnostic('duplicate-outline-slide', referencePath, 'A slide may appear only once in the outline.'));
|
|
301
|
+
}
|
|
302
|
+
if (targetIndex < previousIndex) {
|
|
303
|
+
diagnostics.push(diagnostic('invalid-slide-order', referencePath, 'Outline sections must follow the deck slide order.'));
|
|
304
|
+
}
|
|
305
|
+
assigned.add(slideId);
|
|
306
|
+
previousIndex = targetIndex;
|
|
307
|
+
});
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
deck.slides.forEach((_slide, index) => {
|
|
311
|
+
for (const link of perSlide[index]?.links ?? []) {
|
|
312
|
+
const parsedLink = slideLink(link.href);
|
|
313
|
+
if (!parsedLink)
|
|
314
|
+
continue;
|
|
315
|
+
const target = slidesById.get(parsedLink.slideId);
|
|
316
|
+
if (!target || parsedLink.step < 0 || target.slide.hidden === true) {
|
|
317
|
+
diagnostics.push(diagnostic('broken-slide-link', link.path, 'Internal slide link does not resolve to a visible slide.'));
|
|
318
|
+
continue;
|
|
319
|
+
}
|
|
320
|
+
if (parsedLink.step > stepCount(target.slide, perSlide[target.index]?.fragments ?? [])) {
|
|
321
|
+
diagnostics.push(diagnostic('broken-slide-link', link.path, 'Internal slide link points to a step that does not exist.'));
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
});
|
|
325
|
+
return { valid: diagnostics.length === 0, diagnostics };
|
|
326
|
+
}
|
|
327
|
+
const MIN_CHART_DESCRIPTION_LENGTH = 40;
|
|
328
|
+
/**
|
|
329
|
+
* Check accessible content and static readability guidance for an untrusted
|
|
330
|
+
* deck. Schema omissions are errors; word-count estimates and missing speaker
|
|
331
|
+
* notes are warnings. Rendered contrast, font size and fit still need visual QA.
|
|
332
|
+
*/
|
|
333
|
+
export function lintDeckContent(input) {
|
|
334
|
+
const structure = lintDeckStructure(input);
|
|
335
|
+
if (!structure.valid) {
|
|
336
|
+
const diagnostics = [...structure.diagnostics];
|
|
337
|
+
const existing = new Set(diagnostics.map((item) => `${item.code}:${item.jsonPath}`));
|
|
338
|
+
for (const issue of missingCtaLabels(input)) {
|
|
339
|
+
const key = `${issue.code}:${issue.jsonPath}`;
|
|
340
|
+
if (!existing.has(key))
|
|
341
|
+
diagnostics.push(issue);
|
|
342
|
+
}
|
|
343
|
+
return { valid: false, diagnostics };
|
|
344
|
+
}
|
|
345
|
+
const parsed = safeParseDeck(input);
|
|
346
|
+
if (!parsed.success)
|
|
347
|
+
return structure;
|
|
348
|
+
const diagnostics = [...structure.diagnostics];
|
|
349
|
+
const quality = checkDeckStatic(parsed.deck);
|
|
350
|
+
for (const issue of quality.issues) {
|
|
351
|
+
diagnostics.push(diagnostic(issue.rule, issue.path, issue.message, issue.severity));
|
|
352
|
+
}
|
|
353
|
+
const checkChartDescription = (description, path) => {
|
|
354
|
+
if (!description || description.trim().length === 0) {
|
|
355
|
+
diagnostics.push(diagnostic('chart-description', path, 'Add a concise accessible description that explains the chart’s takeaway.'));
|
|
356
|
+
}
|
|
357
|
+
else if (description.trim().length < MIN_CHART_DESCRIPTION_LENGTH) {
|
|
358
|
+
diagnostics.push(diagnostic('chart-description-short', path, `Chart descriptions under ${MIN_CHART_DESCRIPTION_LENGTH} characters may not explain the data clearly.`, 'warning'));
|
|
359
|
+
}
|
|
360
|
+
};
|
|
361
|
+
parsed.deck.slides.forEach((slide, slideIndex) => {
|
|
362
|
+
const base = ['slides', slideIndex];
|
|
363
|
+
if ('props' in slide && slide.layout === 'chart') {
|
|
364
|
+
const props = slide.props;
|
|
365
|
+
const spec = props.spec;
|
|
366
|
+
const description = spec !== null && typeof spec === 'object' && !Array.isArray(spec)
|
|
367
|
+
? spec.description
|
|
368
|
+
: undefined;
|
|
369
|
+
checkChartDescription(typeof description === 'string' ? description : undefined, [
|
|
370
|
+
...base,
|
|
371
|
+
'props',
|
|
372
|
+
'spec',
|
|
373
|
+
'description',
|
|
374
|
+
]);
|
|
375
|
+
}
|
|
376
|
+
if ('blocks' in slide) {
|
|
377
|
+
forEachBlock(slide.blocks, (block, path) => {
|
|
378
|
+
if (block.type === 'chart' || block.type === 'chart-controls') {
|
|
379
|
+
checkChartDescription(block.spec.description, [
|
|
380
|
+
...base,
|
|
381
|
+
'blocks',
|
|
382
|
+
...path,
|
|
383
|
+
'spec',
|
|
384
|
+
'description',
|
|
385
|
+
]);
|
|
386
|
+
}
|
|
387
|
+
});
|
|
388
|
+
}
|
|
389
|
+
});
|
|
390
|
+
return {
|
|
391
|
+
valid: diagnostics.every((item) => item.severity !== 'error'),
|
|
392
|
+
diagnostics,
|
|
393
|
+
};
|
|
394
|
+
}
|
|
395
|
+
const MAX_ASSET_URL_LENGTH = 2_048;
|
|
396
|
+
const MAX_ASSET_SCAN_NODES = 20_000;
|
|
397
|
+
const MAX_ASSET_SCAN_DEPTH = 32;
|
|
398
|
+
function ownDataValue(record, key) {
|
|
399
|
+
const descriptor = Object.getOwnPropertyDescriptor(record, key);
|
|
400
|
+
return descriptor && 'value' in descriptor ? descriptor.value : undefined;
|
|
401
|
+
}
|
|
402
|
+
function assetUrlProblem(value, kind) {
|
|
403
|
+
if (typeof value !== 'string' || value.length === 0)
|
|
404
|
+
return 'Asset reference needs a URL or local path.';
|
|
405
|
+
// Bounded base64 media is part of the deck media policy (DECKS.md, Limits): the
|
|
406
|
+
// schema's own check decides, so lint and schema never disagree.
|
|
407
|
+
if (/^data:/i.test(value)) {
|
|
408
|
+
const check = checkDeckMediaSrc(value, kind);
|
|
409
|
+
return check.ok ? null : check.reason;
|
|
410
|
+
}
|
|
411
|
+
if (value.length > MAX_ASSET_URL_LENGTH)
|
|
412
|
+
return 'Asset references must be at most 2,048 characters.';
|
|
413
|
+
if (value !== value.trim())
|
|
414
|
+
return 'Asset references cannot start or end with whitespace.';
|
|
415
|
+
for (const character of value) {
|
|
416
|
+
const code = character.codePointAt(0) ?? 0;
|
|
417
|
+
if (code <= 0x20 ||
|
|
418
|
+
(code >= 0x7f && code <= 0x9f) ||
|
|
419
|
+
(code >= 0x202a && code <= 0x202e) ||
|
|
420
|
+
(code >= 0x2066 && code <= 0x2069)) {
|
|
421
|
+
return 'Asset references cannot contain control or bidirectional characters.';
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
if (value.includes('\\'))
|
|
425
|
+
return 'Asset references cannot contain backslashes.';
|
|
426
|
+
const scheme = /^([a-z][a-z\d+.-]*):/i.exec(value)?.[1]?.toLowerCase();
|
|
427
|
+
if (scheme === 'https') {
|
|
428
|
+
if (!/^https:\/\//i.test(value))
|
|
429
|
+
return 'Use a complete HTTPS URL with a host.';
|
|
430
|
+
try {
|
|
431
|
+
const parsed = new URL(value);
|
|
432
|
+
if (parsed.protocol !== 'https:' || !parsed.hostname || parsed.username || parsed.password) {
|
|
433
|
+
return 'Use HTTPS with a hostname and without embedded credentials.';
|
|
434
|
+
}
|
|
435
|
+
return null;
|
|
436
|
+
}
|
|
437
|
+
catch {
|
|
438
|
+
return 'Asset URL is not valid.';
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
if (scheme === 'avatar') {
|
|
442
|
+
return kind === 'image' && /^avatar:av-(?:00[1-9]|0[1-9]\d|1[0-6]\d|17[0-5])$/.test(value)
|
|
443
|
+
? null
|
|
444
|
+
: 'Use a valid image avatar reference.';
|
|
445
|
+
}
|
|
446
|
+
if (scheme !== undefined)
|
|
447
|
+
return `The ${scheme}: protocol is not allowed for slide assets.`;
|
|
448
|
+
if (value.startsWith('//'))
|
|
449
|
+
return 'Protocol-relative asset URLs are not allowed.';
|
|
450
|
+
let decoded = value;
|
|
451
|
+
try {
|
|
452
|
+
decoded = decodeURIComponent(value);
|
|
453
|
+
}
|
|
454
|
+
catch {
|
|
455
|
+
return 'Local asset path has invalid percent encoding.';
|
|
456
|
+
}
|
|
457
|
+
const path = decoded.split(/[?#]/, 1)[0] ?? '';
|
|
458
|
+
const localPath = path.startsWith('./')
|
|
459
|
+
? path.slice(2)
|
|
460
|
+
: path.startsWith('/')
|
|
461
|
+
? path.slice(1)
|
|
462
|
+
: path;
|
|
463
|
+
if (localPath.length === 0 ||
|
|
464
|
+
path.includes('\\') ||
|
|
465
|
+
path.startsWith('//') ||
|
|
466
|
+
localPath.split('/').some((segment) => segment === '..' || segment === '.' || segment === '')) {
|
|
467
|
+
return 'Use a local asset path without parent-directory traversal.';
|
|
468
|
+
}
|
|
469
|
+
return null;
|
|
470
|
+
}
|
|
471
|
+
/**
|
|
472
|
+
* Inspect media references in raw deck JSON without fetching, parsing remote
|
|
473
|
+
* content, or invoking object accessors. HTTPS is syntax-checked only; runtime
|
|
474
|
+
* hosts apply their own media policy. Embedded-app data is walked recursively.
|
|
475
|
+
*/
|
|
476
|
+
export function lintDeckAssets(input) {
|
|
477
|
+
const diagnostics = [];
|
|
478
|
+
const stack = [{ value: input, path: [], key: null, inheritedCredit: undefined, depth: 0 }];
|
|
479
|
+
let visited = 0;
|
|
480
|
+
const inspect = (value, kind, path, credit) => {
|
|
481
|
+
const problem = assetUrlProblem(value, kind);
|
|
482
|
+
if (problem) {
|
|
483
|
+
diagnostics.push(diagnostic(problem.startsWith('The ') ? 'unsafe-asset-url' : 'invalid-asset-url', path, problem));
|
|
484
|
+
return;
|
|
485
|
+
}
|
|
486
|
+
if (kind === 'image' && typeof value === 'string' && /^https:\/\//i.test(value)) {
|
|
487
|
+
const creditText = typeof credit === 'string' ? credit.trim() : '';
|
|
488
|
+
if (!creditText) {
|
|
489
|
+
diagnostics.push(diagnostic('image-credit', path, 'Add an attribution credit for this external image.', 'warning'));
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
};
|
|
493
|
+
while (stack.length > 0) {
|
|
494
|
+
const current = stack.pop();
|
|
495
|
+
if (!current)
|
|
496
|
+
continue;
|
|
497
|
+
visited += 1;
|
|
498
|
+
if (visited > MAX_ASSET_SCAN_NODES || current.depth > MAX_ASSET_SCAN_DEPTH) {
|
|
499
|
+
diagnostics.push(diagnostic('asset-scan-limit', current.path, 'Asset scanning stopped at its depth or node limit to keep validation bounded.'));
|
|
500
|
+
break;
|
|
501
|
+
}
|
|
502
|
+
const node = current.value;
|
|
503
|
+
if (node === null || typeof node !== 'object') {
|
|
504
|
+
if (current.key === 'image' && typeof node === 'string') {
|
|
505
|
+
inspect(node, 'image', current.path, current.inheritedCredit);
|
|
506
|
+
}
|
|
507
|
+
else if (current.key === 'poster' && typeof node === 'string') {
|
|
508
|
+
inspect(node, 'image', current.path, current.inheritedCredit);
|
|
509
|
+
}
|
|
510
|
+
continue;
|
|
511
|
+
}
|
|
512
|
+
if (Array.isArray(node)) {
|
|
513
|
+
for (let index = node.length - 1; index >= 0; index -= 1) {
|
|
514
|
+
const descriptor = Object.getOwnPropertyDescriptor(node, String(index));
|
|
515
|
+
if (descriptor && 'value' in descriptor) {
|
|
516
|
+
stack.push({
|
|
517
|
+
value: descriptor.value,
|
|
518
|
+
path: [...current.path, index],
|
|
519
|
+
key: index,
|
|
520
|
+
inheritedCredit: current.inheritedCredit,
|
|
521
|
+
depth: current.depth + 1,
|
|
522
|
+
});
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
continue;
|
|
526
|
+
}
|
|
527
|
+
const record = node;
|
|
528
|
+
const type = ownDataValue(node, 'type');
|
|
529
|
+
const credit = ownDataValue(node, 'credit') ?? current.inheritedCredit;
|
|
530
|
+
const isImageObject = type === 'image' || current.key === 'image';
|
|
531
|
+
const isVideoObject = type === 'video' || current.key === 'video';
|
|
532
|
+
if (isImageObject || isVideoObject) {
|
|
533
|
+
const mediaKind = isVideoObject ? 'video' : 'image';
|
|
534
|
+
const src = ownDataValue(node, 'src');
|
|
535
|
+
inspect(src, mediaKind, [...current.path, 'src'], credit);
|
|
536
|
+
if (type === 'image') {
|
|
537
|
+
const width = ownDataValue(node, 'width');
|
|
538
|
+
const height = ownDataValue(node, 'height');
|
|
539
|
+
if (typeof width !== 'number' || width <= 0 || typeof height !== 'number' || height <= 0) {
|
|
540
|
+
diagnostics.push(diagnostic('image-dimensions', current.path, 'Images need positive width and height values.'));
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
if (type === 'image' && ownDataValue(node, 'src') === undefined) {
|
|
545
|
+
diagnostics.push(diagnostic('invalid-asset-url', [...current.path, 'src'], 'Image block needs a source URL.'));
|
|
546
|
+
}
|
|
547
|
+
if (type === 'video' && ownDataValue(node, 'src') === undefined) {
|
|
548
|
+
diagnostics.push(diagnostic('invalid-asset-url', [...current.path, 'src'], 'Video block needs a source URL.'));
|
|
549
|
+
}
|
|
550
|
+
for (const key of Object.keys(record).reverse()) {
|
|
551
|
+
const descriptor = Object.getOwnPropertyDescriptor(node, key);
|
|
552
|
+
if (!descriptor || !('value' in descriptor))
|
|
553
|
+
continue;
|
|
554
|
+
stack.push({
|
|
555
|
+
value: descriptor.value,
|
|
556
|
+
path: [...current.path, key],
|
|
557
|
+
key,
|
|
558
|
+
inheritedCredit: credit,
|
|
559
|
+
depth: current.depth + 1,
|
|
560
|
+
});
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
return {
|
|
564
|
+
valid: diagnostics.every((item) => item.severity !== 'error'),
|
|
565
|
+
diagnostics,
|
|
566
|
+
};
|
|
567
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { type DeckLayoutSelection, type DeckLayoutSlideInput, propsForSelectedLayout, type SelectDeckLayoutsOptions, selectDeckLayouts, } from './select-deck-layouts.ts';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { propsForSelectedLayout, selectDeckLayouts, } from "./select-deck-layouts.js";
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deterministic deck layout selection from slide roles, pacing, block contents
|
|
3
|
+
* and optional author overrides. Pure: no Math.random, no Date.now.
|
|
4
|
+
*
|
|
5
|
+
* Uses the read-only `@protoyard/decks/layouts` catalog (AUTHORING + DECKS).
|
|
6
|
+
*/
|
|
7
|
+
import { type DeckLayoutEntry, type LayoutPacing } from '@protoyard/decks/layouts';
|
|
8
|
+
/** One slide plan row (from deck-outlines or an authoring tool). */
|
|
9
|
+
export type DeckLayoutSlideInput = {
|
|
10
|
+
id: string;
|
|
11
|
+
/** Semantic role from the outline (`title`, `metrics`, `close`, …). */
|
|
12
|
+
role: string;
|
|
13
|
+
/** Punch / idea / diagram / story. */
|
|
14
|
+
pacing: LayoutPacing | string;
|
|
15
|
+
purpose?: string;
|
|
16
|
+
/**
|
|
17
|
+
* Explicit layout id. When set and present in the catalog, it wins and is
|
|
18
|
+
* never replaced to avoid three-in-a-row (author intent).
|
|
19
|
+
*/
|
|
20
|
+
layout?: string;
|
|
21
|
+
/** Block `type` values on a free slide, used as capacity signals. */
|
|
22
|
+
blockTypes?: readonly string[];
|
|
23
|
+
};
|
|
24
|
+
export type DeckLayoutSelection = {
|
|
25
|
+
id: string;
|
|
26
|
+
layout: string;
|
|
27
|
+
/** Why this layout was chosen (stable wording for the same inputs). */
|
|
28
|
+
rationale: string;
|
|
29
|
+
/** True when the author override was used. */
|
|
30
|
+
override: boolean;
|
|
31
|
+
};
|
|
32
|
+
export type SelectDeckLayoutsOptions = {
|
|
33
|
+
/** Deck aspect (`16:9`, `9:16`, …). Portrait slightly prefers free/centered. */
|
|
34
|
+
aspect?: string;
|
|
35
|
+
/** Catalog override for tests. */
|
|
36
|
+
catalog?: readonly DeckLayoutEntry[];
|
|
37
|
+
};
|
|
38
|
+
/**
|
|
39
|
+
* Select one catalog layout per slide. Deterministic for the same inputs.
|
|
40
|
+
* Avoids three identical layouts in a row when another compatible layout fits.
|
|
41
|
+
*/
|
|
42
|
+
export declare function selectDeckLayouts(slides: readonly DeckLayoutSlideInput[], options?: SelectDeckLayoutsOptions): DeckLayoutSelection[];
|
|
43
|
+
/**
|
|
44
|
+
* Minimal valid catalog `props` for a layout, seeded from the slide title.
|
|
45
|
+
* Used when rewriting free outline slides to catalog slides.
|
|
46
|
+
*/
|
|
47
|
+
export declare function propsForSelectedLayout(layoutId: string, slide: {
|
|
48
|
+
title?: string;
|
|
49
|
+
purpose?: string;
|
|
50
|
+
}): Record<string, unknown>;
|