@texuli/deck 0.0.0-stage → 0.4.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 +182 -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,36 @@
|
|
|
1
|
+
import { type Deck } from '../model/index.js';
|
|
2
|
+
import { type DeckDiagnostic } from '../schema/index.js';
|
|
3
|
+
export declare const DECK_LINT_RULES: Readonly<{
|
|
4
|
+
readonly errors: readonly ["unknown-layout", "layout-props", "image-alt", "image-size", "step-order"];
|
|
5
|
+
readonly warnings: readonly ["empty-steps", "unknown-background", "custom-slide", "step-unrevealed", "notes-missing", "notes-duplicate", "notes-boilerplate", "layout-repetition", "too-many-words"];
|
|
6
|
+
readonly suggestions: readonly ["prefer-layout"];
|
|
7
|
+
}>;
|
|
8
|
+
export type DeckLintRule = (typeof DECK_LINT_RULES.errors)[number] | (typeof DECK_LINT_RULES.warnings)[number] | 'prefer-layout' | (string & {});
|
|
9
|
+
export interface DeckLintIssue extends DeckDiagnostic {
|
|
10
|
+
rule: DeckLintRule;
|
|
11
|
+
}
|
|
12
|
+
export interface DeckLintReport {
|
|
13
|
+
ok: boolean;
|
|
14
|
+
/** Every distinct error found, also past the `issues` cap. */
|
|
15
|
+
errors: number;
|
|
16
|
+
/** Every distinct warning found, also past the `issues` cap. */
|
|
17
|
+
warnings: number;
|
|
18
|
+
/** At most `DECK_LINT_MAX_ISSUES`, one per rule and path. */
|
|
19
|
+
issues: DeckLintIssue[];
|
|
20
|
+
/** True when issues past the cap were counted but not listed. */
|
|
21
|
+
truncated: boolean;
|
|
22
|
+
}
|
|
23
|
+
/** Issues listed in one report (the model's diagnostics cap); the rest are counted. */
|
|
24
|
+
export declare const DECK_LINT_MAX_ISSUES = 200;
|
|
25
|
+
/**
|
|
26
|
+
* Static quality checks. It accepts untrusted input and never throws. Work is
|
|
27
|
+
* bounded: one issue per rule and path (a set, not a scan), at most
|
|
28
|
+
* `DECK_LINT_MAX_ISSUES` listed, and a deck over the model's structural
|
|
29
|
+
* limits (`too-many`, `too-deep`) reports the model issues without the
|
|
30
|
+
* quality pass.
|
|
31
|
+
*/
|
|
32
|
+
export declare function lintDeck(value: unknown): DeckLintReport;
|
|
33
|
+
export declare const DECK_AUTHORING_LIMITS: Readonly<{
|
|
34
|
+
slides: 200;
|
|
35
|
+
}>;
|
|
36
|
+
export type { Deck };
|
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
import { DECK_BACKGROUND_PRESET_IDS } from "../model/backgrounds/index.js";
|
|
2
|
+
import { DECK_MODEL_LIMITS, DECK_SLIDE_LAYOUTS, parseDeck, slideSteps, } from "../model/index.js";
|
|
3
|
+
import { validateLayoutProps } from "../model/layouts/index.js";
|
|
4
|
+
import { createDiagnostic } from "../schema/index.js";
|
|
5
|
+
export const DECK_LINT_RULES = Object.freeze({
|
|
6
|
+
errors: ['unknown-layout', 'layout-props', 'image-alt', 'image-size', 'step-order'],
|
|
7
|
+
warnings: [
|
|
8
|
+
'empty-steps',
|
|
9
|
+
'unknown-background',
|
|
10
|
+
'custom-slide',
|
|
11
|
+
'step-unrevealed',
|
|
12
|
+
'notes-missing',
|
|
13
|
+
'notes-duplicate',
|
|
14
|
+
'notes-boilerplate',
|
|
15
|
+
'layout-repetition',
|
|
16
|
+
'too-many-words',
|
|
17
|
+
],
|
|
18
|
+
suggestions: ['prefer-layout'],
|
|
19
|
+
});
|
|
20
|
+
/** Issues listed in one report (the model's diagnostics cap); the rest are counted. */
|
|
21
|
+
export const DECK_LINT_MAX_ISSUES = 200;
|
|
22
|
+
/** How deep `wordCount` reads a value (the model refuses deeper values anyway). */
|
|
23
|
+
const WORD_DEPTH = 32;
|
|
24
|
+
const asRecord = (value) => value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
25
|
+
? value
|
|
26
|
+
: undefined;
|
|
27
|
+
/**
|
|
28
|
+
* Keys whose strings are text a viewer reads: titles, bodies, list items,
|
|
29
|
+
* labels and captions. Everything else (chart kinds, ids, URLs, alt text,
|
|
30
|
+
* code, sizes, widths, tones, step fields) is a setting, not words on the
|
|
31
|
+
* slide. A string inside an array counts when the array's key does
|
|
32
|
+
* (`bullets`, comparison `columns` and `values`).
|
|
33
|
+
*/
|
|
34
|
+
const HUMAN_TEXT_KEYS = new Set([
|
|
35
|
+
'title',
|
|
36
|
+
'subtitle',
|
|
37
|
+
'eyebrow',
|
|
38
|
+
'meta',
|
|
39
|
+
'heading',
|
|
40
|
+
'body',
|
|
41
|
+
'text',
|
|
42
|
+
'markdown',
|
|
43
|
+
'quote',
|
|
44
|
+
'cite',
|
|
45
|
+
'caption',
|
|
46
|
+
'credit',
|
|
47
|
+
'label',
|
|
48
|
+
'description',
|
|
49
|
+
'context',
|
|
50
|
+
'footnote',
|
|
51
|
+
'name',
|
|
52
|
+
'role',
|
|
53
|
+
'number',
|
|
54
|
+
'bullets',
|
|
55
|
+
'items',
|
|
56
|
+
'columns',
|
|
57
|
+
'values',
|
|
58
|
+
'time',
|
|
59
|
+
'summary',
|
|
60
|
+
'content',
|
|
61
|
+
]);
|
|
62
|
+
const WORD = /[\p{L}\p{N}]+(?:['’][\p{L}\p{N}]+)*/gu;
|
|
63
|
+
/** Link destinations in Markdown text: `](https://…)` is not words on the slide. */
|
|
64
|
+
const LINK_DESTINATION = /\]\([^)\s]*\)/g;
|
|
65
|
+
/**
|
|
66
|
+
* Words of human text in a value (see `HUMAN_TEXT_KEYS`). `all` counts every
|
|
67
|
+
* string below (the cells of a table block's `rows`, keyed by column id).
|
|
68
|
+
*/
|
|
69
|
+
function wordCount(value, human = false, all = false, depth = 0) {
|
|
70
|
+
if (typeof value === 'string') {
|
|
71
|
+
if (!human && !all)
|
|
72
|
+
return 0;
|
|
73
|
+
return (value.replace(LINK_DESTINATION, ']').match(WORD) ?? []).length;
|
|
74
|
+
}
|
|
75
|
+
if (depth >= WORD_DEPTH)
|
|
76
|
+
return 0;
|
|
77
|
+
if (Array.isArray(value))
|
|
78
|
+
return value.reduce((sum, item) => sum + wordCount(item, human, all, depth + 1), 0);
|
|
79
|
+
const record = asRecord(value);
|
|
80
|
+
if (!record)
|
|
81
|
+
return 0;
|
|
82
|
+
let sum = 0;
|
|
83
|
+
for (const [key, child] of Object.entries(record)) {
|
|
84
|
+
const rows = all || (record.type === 'table' && key === 'rows');
|
|
85
|
+
sum += wordCount(child, all || HUMAN_TEXT_KEYS.has(key), rows, depth + 1);
|
|
86
|
+
}
|
|
87
|
+
return sum;
|
|
88
|
+
}
|
|
89
|
+
/** Boilerplate notes phrases, matched inside normalized notes (lowercase, punctuation as spaces). */
|
|
90
|
+
const BOILERPLATE_NOTES = [
|
|
91
|
+
'do not read the slide',
|
|
92
|
+
"don't read the slide",
|
|
93
|
+
'dont read the slide',
|
|
94
|
+
'no leer la diapositiva',
|
|
95
|
+
];
|
|
96
|
+
/** Notes lowercased with punctuation as single spaces (`’` read as `'`). */
|
|
97
|
+
const normalizeNotes = (notes) => notes
|
|
98
|
+
.toLowerCase()
|
|
99
|
+
.replace(/’/g, "'")
|
|
100
|
+
.replace(/[^\p{L}\p{N}']+/gu, ' ')
|
|
101
|
+
.trim();
|
|
102
|
+
/** How a message names a slide: `slide "pricing"`, or its position when it has no id. */
|
|
103
|
+
const slideName = (item, index) => typeof item.id === 'string' && item.id !== ''
|
|
104
|
+
? `slide ${JSON.stringify(item.id.slice(0, 64))}`
|
|
105
|
+
: `slide ${index + 1}`;
|
|
106
|
+
const quoted = (text, max = 60) => JSON.stringify(text.length > max ? `${text.slice(0, max - 1)}…` : text);
|
|
107
|
+
/**
|
|
108
|
+
* Static quality checks. It accepts untrusted input and never throws. Work is
|
|
109
|
+
* bounded: one issue per rule and path (a set, not a scan), at most
|
|
110
|
+
* `DECK_LINT_MAX_ISSUES` listed, and a deck over the model's structural
|
|
111
|
+
* limits (`too-many`, `too-deep`) reports the model issues without the
|
|
112
|
+
* quality pass.
|
|
113
|
+
*/
|
|
114
|
+
export function lintDeck(value) {
|
|
115
|
+
const parsed = parseDeck(value);
|
|
116
|
+
const issues = [];
|
|
117
|
+
const seen = new Set();
|
|
118
|
+
let errors = 0;
|
|
119
|
+
let warnings = 0;
|
|
120
|
+
const add = (rule, severity, message, path, fix) => {
|
|
121
|
+
const key = JSON.stringify([rule, path]);
|
|
122
|
+
if (seen.has(key))
|
|
123
|
+
return;
|
|
124
|
+
seen.add(key);
|
|
125
|
+
if (severity === 'error')
|
|
126
|
+
errors++;
|
|
127
|
+
else
|
|
128
|
+
warnings++;
|
|
129
|
+
if (issues.length >= DECK_LINT_MAX_ISSUES)
|
|
130
|
+
return;
|
|
131
|
+
issues.push({
|
|
132
|
+
...createDiagnostic(rule, message, path, fix ? { fix } : undefined),
|
|
133
|
+
rule,
|
|
134
|
+
severity,
|
|
135
|
+
});
|
|
136
|
+
};
|
|
137
|
+
const report = () => ({
|
|
138
|
+
ok: errors === 0,
|
|
139
|
+
errors,
|
|
140
|
+
warnings,
|
|
141
|
+
issues,
|
|
142
|
+
truncated: errors + warnings > issues.length || parsed.truncated === true,
|
|
143
|
+
});
|
|
144
|
+
const raw = asRecord(value);
|
|
145
|
+
const slides = Array.isArray(raw?.slides) ? raw.slides : [];
|
|
146
|
+
for (const issue of parsed.issues) {
|
|
147
|
+
if (issue.code === 'unknown-background' || issue.code === 'step-unrevealed')
|
|
148
|
+
continue;
|
|
149
|
+
const path = issue.path;
|
|
150
|
+
const layout = path.at(-1) === 'layout' || path.at(-2) === 'layout';
|
|
151
|
+
const rule = issue.code === 'unknown-layout' || layout
|
|
152
|
+
? 'unknown-layout'
|
|
153
|
+
: path.includes('props')
|
|
154
|
+
? 'layout-props'
|
|
155
|
+
: issue.code;
|
|
156
|
+
add(rule, issue.severity, issue.message, path, issue.fix);
|
|
157
|
+
}
|
|
158
|
+
// Over a structural limit: the model issues say what to fix; a quality pass
|
|
159
|
+
// over 16,000 slides or a 5,000-deep value would only add noise and work.
|
|
160
|
+
if (parsed.issues.some((issue) => issue.code === 'too-many' || issue.code === 'too-deep')) {
|
|
161
|
+
return report();
|
|
162
|
+
}
|
|
163
|
+
const notesSeen = new Map();
|
|
164
|
+
let previousLayout = '';
|
|
165
|
+
let run = 0;
|
|
166
|
+
slides.forEach((rawSlide, index) => {
|
|
167
|
+
const item = asRecord(rawSlide);
|
|
168
|
+
if (!item)
|
|
169
|
+
return;
|
|
170
|
+
const base = ['slides', index];
|
|
171
|
+
const layout = item.layout;
|
|
172
|
+
if (typeof layout === 'string' &&
|
|
173
|
+
DECK_SLIDE_LAYOUTS.includes(layout) &&
|
|
174
|
+
layout !== 'free' &&
|
|
175
|
+
layout !== 'component' &&
|
|
176
|
+
layout !== 'custom' &&
|
|
177
|
+
asRecord(item.props)) {
|
|
178
|
+
const result = validateLayoutProps(layout, item.props, { path: [...base, 'props'] });
|
|
179
|
+
for (const problem of result.issues)
|
|
180
|
+
add('layout-props', 'error', problem.message, problem.path, problem.fix);
|
|
181
|
+
}
|
|
182
|
+
if (layout === 'custom')
|
|
183
|
+
add('custom-slide', 'warning', 'Custom slides depend on a host renderer and may not travel with the deck.', [...base, 'props', 'component']);
|
|
184
|
+
if (typeof item.background === 'string' &&
|
|
185
|
+
!DECK_BACKGROUND_PRESET_IDS.includes(item.background)) {
|
|
186
|
+
add('unknown-background', 'warning', `Unknown background "${item.background}".`, [
|
|
187
|
+
...base,
|
|
188
|
+
'background',
|
|
189
|
+
]);
|
|
190
|
+
}
|
|
191
|
+
const props = asRecord(item.props);
|
|
192
|
+
const imageCheck = (record, path, isImage) => {
|
|
193
|
+
if (!isImage || !Object.hasOwn(record, 'src'))
|
|
194
|
+
return;
|
|
195
|
+
if (typeof record.alt !== 'string' || !record.alt.trim())
|
|
196
|
+
add('image-alt', 'error', 'Images need descriptive alt text.', [...path, 'alt']);
|
|
197
|
+
if (typeof record.width !== 'number' || typeof record.height !== 'number')
|
|
198
|
+
add('image-size', 'error', 'Images need intrinsic width and height.', path);
|
|
199
|
+
};
|
|
200
|
+
if (props)
|
|
201
|
+
walk(props, [...base, 'props'], (record, path) => imageCheck(record, path, path.at(-1) === 'image'));
|
|
202
|
+
if (Array.isArray(item.blocks))
|
|
203
|
+
walk(item.blocks, [...base, 'blocks'], (record, path) => imageCheck(record, path, record.type === 'image'));
|
|
204
|
+
const name = slideName(item, index);
|
|
205
|
+
const labels = Array.isArray(item.steps) ? item.steps : [];
|
|
206
|
+
const stepResult = slideSteps(item);
|
|
207
|
+
// A custom slide's host component reveals its own steps: its labels are the steps.
|
|
208
|
+
if (layout !== 'custom') {
|
|
209
|
+
labels.forEach((label, i) => {
|
|
210
|
+
const step = stepResult.steps[i + 1];
|
|
211
|
+
if (step && step.fragments.length > 0)
|
|
212
|
+
return;
|
|
213
|
+
const text = typeof label === 'string' ? ` (${quoted(label)})` : '';
|
|
214
|
+
add('empty-steps', 'warning', `The ${name} labels step ${i + 1}${text} in "steps", but nothing reveals on it.`, [...base, 'steps', i], `Add a fragment with "step": ${i + 1}, or remove this label.`);
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
for (const problem of stepResult.issues)
|
|
218
|
+
if (problem.code === 'step-unrevealed')
|
|
219
|
+
add('step-unrevealed', 'warning', problem.message, [...base, ...problem.path], problem.fix);
|
|
220
|
+
let latest = 0;
|
|
221
|
+
const authoredSteps = [];
|
|
222
|
+
const collectSteps = (value, path) => walk(value, path, (record, current) => {
|
|
223
|
+
if (typeof record.step === 'number' && Number.isInteger(record.step) && record.step > 0)
|
|
224
|
+
authoredSteps.push({ value: record.step, path: [...current, 'step'] });
|
|
225
|
+
});
|
|
226
|
+
if (Array.isArray(item.blocks))
|
|
227
|
+
collectSteps(item.blocks, [...base, 'blocks']);
|
|
228
|
+
if (props)
|
|
229
|
+
collectSteps(props, [...base, 'props']);
|
|
230
|
+
for (const fragment of authoredSteps) {
|
|
231
|
+
if (fragment.value < latest)
|
|
232
|
+
add('step-order', 'error', 'A later element is revealed before an earlier element.', fragment.path);
|
|
233
|
+
latest = Math.max(latest, fragment.value);
|
|
234
|
+
}
|
|
235
|
+
const notes = typeof item.notes === 'string' ? item.notes.trim() : '';
|
|
236
|
+
const visible = item.hidden !== true;
|
|
237
|
+
if (visible && notes.length < 40)
|
|
238
|
+
add('notes-missing', 'warning', 'Visible slides should include useful speaker notes.', [
|
|
239
|
+
...base,
|
|
240
|
+
'notes',
|
|
241
|
+
]);
|
|
242
|
+
if (visible && notes) {
|
|
243
|
+
const normalized = normalizeNotes(notes);
|
|
244
|
+
const prior = notesSeen.get(normalized);
|
|
245
|
+
if (prior !== undefined)
|
|
246
|
+
add('notes-duplicate', 'warning', `Notes repeat slide ${prior + 1}.`, [...base, 'notes']);
|
|
247
|
+
else
|
|
248
|
+
notesSeen.set(normalized, index);
|
|
249
|
+
const padded = ` ${normalized} `;
|
|
250
|
+
const phrase = BOILERPLATE_NOTES.find((text) => padded.includes(` ${text} `));
|
|
251
|
+
if (phrase)
|
|
252
|
+
add('notes-boilerplate', 'warning', `The ${name} "notes" contain the boilerplate phrase "${phrase}".`, [...base, 'notes'], 'Replace it with what to say: the point, the evidence and the transition.');
|
|
253
|
+
}
|
|
254
|
+
if (visible) {
|
|
255
|
+
if (typeof layout === 'string' && layout === previousLayout)
|
|
256
|
+
run++;
|
|
257
|
+
else
|
|
258
|
+
run = 1;
|
|
259
|
+
if (run > 2)
|
|
260
|
+
add('layout-repetition', 'warning', `Layout "${layout}" repeats more than twice in a row.`, [...base, 'layout']);
|
|
261
|
+
previousLayout = typeof layout === 'string' ? layout : '';
|
|
262
|
+
const allowed = layout === 'title-hero' || layout === 'big-stat' ? 60 : 55;
|
|
263
|
+
// Words per part: free blocks, layout props by top-level key, or component props.
|
|
264
|
+
const parts = [];
|
|
265
|
+
if (layout === 'free' && Array.isArray(item.blocks)) {
|
|
266
|
+
item.blocks.forEach((block, b) => {
|
|
267
|
+
const type = asRecord(block)?.type;
|
|
268
|
+
parts.push({
|
|
269
|
+
path: [...base, 'blocks', b],
|
|
270
|
+
field: `blocks[${b}]${typeof type === 'string' ? ` (${type.slice(0, 32)})` : ''}`,
|
|
271
|
+
words: wordCount(block),
|
|
272
|
+
});
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
else if (layout === 'component') {
|
|
276
|
+
const component = asRecord(item.component);
|
|
277
|
+
parts.push({
|
|
278
|
+
path: [...base, 'component', 'props'],
|
|
279
|
+
field: 'component.props',
|
|
280
|
+
words: wordCount(component?.props, false, false, 1),
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
else if (props) {
|
|
284
|
+
for (const [key, child] of Object.entries(props))
|
|
285
|
+
parts.push({
|
|
286
|
+
path: [...base, 'props'],
|
|
287
|
+
field: `props.${key}`,
|
|
288
|
+
words: wordCount({ [key]: child }),
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
const textWords = parts.reduce((sum, part) => sum + part.words, 0);
|
|
292
|
+
const most = parts.reduce((best, part) => (best === undefined || part.words > best.words ? part : best), undefined);
|
|
293
|
+
if (textWords > allowed && most)
|
|
294
|
+
add('too-many-words', 'warning', `The ${name} has ${textWords} words of text; the suggested limit is ${allowed}. ${most.field} holds the most (${most.words}).`, most.path, 'Cut the text, split the slide, or move the detail into the speaker notes.');
|
|
295
|
+
}
|
|
296
|
+
if (visible && layout === 'free') {
|
|
297
|
+
const types = Array.isArray(item.blocks)
|
|
298
|
+
? item.blocks.map((block) => asRecord(block)?.type)
|
|
299
|
+
: [];
|
|
300
|
+
const suggestion = types.find((type) => type === 'chart' ||
|
|
301
|
+
type === 'table' ||
|
|
302
|
+
type === 'quote' ||
|
|
303
|
+
type === 'code' ||
|
|
304
|
+
type === 'image');
|
|
305
|
+
if (typeof suggestion === 'string')
|
|
306
|
+
add('prefer-layout', 'warning', `A ${suggestion} block may fit a catalog layout better.`, [...base, 'layout'], 'Choose a matching catalog layout when its props fit the content.');
|
|
307
|
+
}
|
|
308
|
+
});
|
|
309
|
+
return report();
|
|
310
|
+
}
|
|
311
|
+
function walk(value, path, visit, depth = 0) {
|
|
312
|
+
if (depth > 12 || value === null || typeof value !== 'object')
|
|
313
|
+
return;
|
|
314
|
+
if (Array.isArray(value)) {
|
|
315
|
+
value.forEach((item, index) => {
|
|
316
|
+
walk(item, [...path, index], visit, depth + 1);
|
|
317
|
+
});
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
const record = value;
|
|
321
|
+
visit(record, path);
|
|
322
|
+
for (const [key, child] of Object.entries(record))
|
|
323
|
+
walk(child, [...path, key], visit, depth + 1);
|
|
324
|
+
}
|
|
325
|
+
export const DECK_AUTHORING_LIMITS = Object.freeze({ slides: DECK_MODEL_LIMITS.slides });
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { Deck } from '../model/index.js';
|
|
2
|
+
import { selectDeckLayouts } from './select-layouts.js';
|
|
3
|
+
export declare const DECK_OUTLINE_IDS: readonly ["pitch", "qbr", "launch", "status", "training", "lesson", "report", "talk"];
|
|
4
|
+
export type DeckOutlineId = (typeof DECK_OUTLINE_IDS)[number];
|
|
5
|
+
export type DeckPacing = 'punch' | 'idea' | 'diagram' | 'story';
|
|
6
|
+
export interface OutlineSlide {
|
|
7
|
+
key: string;
|
|
8
|
+
role: string;
|
|
9
|
+
pacing: DeckPacing;
|
|
10
|
+
title: string;
|
|
11
|
+
purpose: string;
|
|
12
|
+
optional?: boolean;
|
|
13
|
+
}
|
|
14
|
+
export interface DeckOutlineTemplate {
|
|
15
|
+
id: DeckOutlineId;
|
|
16
|
+
label: string;
|
|
17
|
+
cues: readonly string[];
|
|
18
|
+
slides: readonly OutlineSlide[];
|
|
19
|
+
}
|
|
20
|
+
/** Eight conservative, deterministic brief outlines. They contain prompts, never invented evidence. */
|
|
21
|
+
export declare const DECK_OUTLINES: readonly DeckOutlineTemplate[];
|
|
22
|
+
export interface GenerateBriefOptions {
|
|
23
|
+
brief: string;
|
|
24
|
+
title?: string;
|
|
25
|
+
outline?: DeckOutlineId;
|
|
26
|
+
locale?: string;
|
|
27
|
+
}
|
|
28
|
+
export interface GeneratedDeck {
|
|
29
|
+
deck: Deck;
|
|
30
|
+
outline: DeckOutlineId;
|
|
31
|
+
selection: ReturnType<typeof selectDeckLayouts>;
|
|
32
|
+
}
|
|
33
|
+
/** Build an honest, reproducible draft from a brief; missing evidence stays explicit. */
|
|
34
|
+
export declare function generateDeckFromBrief(options: GenerateBriefOptions): GeneratedDeck;
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
import { DECK_LAYOUTS, parseDeck } from "../model/index.js";
|
|
2
|
+
import { selectDeckLayouts } from "./select-layouts.js";
|
|
3
|
+
export const DECK_OUTLINE_IDS = [
|
|
4
|
+
'pitch',
|
|
5
|
+
'qbr',
|
|
6
|
+
'launch',
|
|
7
|
+
'status',
|
|
8
|
+
'training',
|
|
9
|
+
'lesson',
|
|
10
|
+
'report',
|
|
11
|
+
'talk',
|
|
12
|
+
];
|
|
13
|
+
const core = (key, role, pacing, title, purpose, optional = false) => ({ key, role, pacing, title, purpose, ...(optional ? { optional: true } : {}) });
|
|
14
|
+
const title = core('title', 'title', 'punch', '', 'Introduce the topic and presenter.');
|
|
15
|
+
const close = core('close', 'close', 'punch', 'Close and next steps', 'Summarize the ask and give a follow-up path.');
|
|
16
|
+
/** Eight conservative, deterministic brief outlines. They contain prompts, never invented evidence. */
|
|
17
|
+
export const DECK_OUTLINES = Object.freeze([
|
|
18
|
+
{
|
|
19
|
+
id: 'pitch',
|
|
20
|
+
label: 'Pitch',
|
|
21
|
+
cues: ['pitch', 'investor', 'fundraise'],
|
|
22
|
+
slides: [
|
|
23
|
+
title,
|
|
24
|
+
core('problem', 'problem', 'story', 'The problem', 'Describe who has the problem and when.'),
|
|
25
|
+
core('solution', 'solution', 'idea', 'The solution', 'Explain the proposed solution.'),
|
|
26
|
+
core('why-now', 'hook', 'idea', 'Why now', 'Name the verified change that makes this timely.', true),
|
|
27
|
+
core('product', 'demo', 'story', 'How it works', 'Walk through the user journey.', true),
|
|
28
|
+
core('traction', 'metrics', 'punch', 'Evidence of demand', 'Add sourced traction measures.'),
|
|
29
|
+
core('business-model', 'solution', 'idea', 'Business model', 'Explain who pays and why.', true),
|
|
30
|
+
core('competition', 'comparison', 'diagram', 'Alternatives', 'Compare approaches fairly.', true),
|
|
31
|
+
core('team', 'team', 'idea', 'The team', 'Introduce contributors and relevant experience.'),
|
|
32
|
+
core('roadmap', 'roadmap', 'diagram', 'Roadmap', 'List milestones and evidence needed.', true),
|
|
33
|
+
core('ask', 'ask', 'punch', 'The ask', 'State the request and what it enables.'),
|
|
34
|
+
close,
|
|
35
|
+
],
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
id: 'qbr',
|
|
39
|
+
label: 'Quarterly business review',
|
|
40
|
+
cues: ['qbr', 'quarterly business review', 'quarterly review'],
|
|
41
|
+
slides: [
|
|
42
|
+
title,
|
|
43
|
+
core('agenda', 'agenda', 'idea', 'Agenda', 'List the review topics.'),
|
|
44
|
+
core('highlights', 'metrics', 'punch', 'Quarter highlights', 'Add sourced performance measures.'),
|
|
45
|
+
core('trend', 'chart', 'diagram', 'Trend', 'Chart a verified measure.', true),
|
|
46
|
+
core('wins', 'findings', 'idea', 'Wins', 'Explain what worked and why.'),
|
|
47
|
+
core('misses', 'risks', 'idea', 'Misses and lessons', 'Describe gaps and lessons.'),
|
|
48
|
+
core('customer', 'quote', 'story', 'Customer voice', 'Add an approved customer quote.', true),
|
|
49
|
+
core('risks', 'risks', 'idea', 'Risks', 'Name risks and mitigation.'),
|
|
50
|
+
core('next', 'roadmap', 'diagram', 'Next quarter', 'List next priorities.'),
|
|
51
|
+
core('asks', 'ask', 'punch', 'Decisions needed', 'State the decisions needed.', true),
|
|
52
|
+
close,
|
|
53
|
+
],
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
id: 'launch',
|
|
57
|
+
label: 'Product launch',
|
|
58
|
+
cues: ['launch', 'release', 'go to market'],
|
|
59
|
+
slides: [
|
|
60
|
+
title,
|
|
61
|
+
core('audience', 'problem', 'idea', 'Who this is for', 'Define the intended audience.'),
|
|
62
|
+
core('need', 'problem', 'story', 'The need', 'Describe the verified user need.'),
|
|
63
|
+
core('promise', 'solution', 'punch', 'The promise', 'State the product promise.'),
|
|
64
|
+
core('experience', 'demo', 'story', 'The experience', 'Show the core user journey.'),
|
|
65
|
+
core('proof', 'metrics', 'punch', 'Proof', 'Add sourced validation.', true),
|
|
66
|
+
core('plan', 'roadmap', 'diagram', 'Launch plan', 'Sequence launch work.'),
|
|
67
|
+
core('risks', 'risks', 'idea', 'Risks', 'Identify launch risks.'),
|
|
68
|
+
core('next', 'ask', 'punch', 'Next steps', 'Name owners and next actions.'),
|
|
69
|
+
close,
|
|
70
|
+
],
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
id: 'status',
|
|
74
|
+
label: 'Project status',
|
|
75
|
+
cues: ['status', 'project update', 'weekly update'],
|
|
76
|
+
slides: [
|
|
77
|
+
title,
|
|
78
|
+
core('summary', 'hook', 'punch', 'Status at a glance', 'State overall status and date.'),
|
|
79
|
+
core('progress', 'roadmap', 'diagram', 'Progress', 'Show completed and active work.'),
|
|
80
|
+
core('evidence', 'metrics', 'punch', 'Measures', 'Add current, sourced measures.', true),
|
|
81
|
+
core('risks', 'risks', 'idea', 'Risks and blockers', 'Explain the effect and response.'),
|
|
82
|
+
core('decisions', 'ask', 'punch', 'Decisions needed', 'List decisions and due dates.'),
|
|
83
|
+
core('next', 'roadmap', 'diagram', 'Next period', 'Name upcoming milestones.'),
|
|
84
|
+
close,
|
|
85
|
+
],
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
id: 'training',
|
|
89
|
+
label: 'Training',
|
|
90
|
+
cues: ['training', 'workshop', 'enablement'],
|
|
91
|
+
slides: [
|
|
92
|
+
title,
|
|
93
|
+
core('objectives', 'agenda', 'idea', 'Learning objectives', 'List observable outcomes.'),
|
|
94
|
+
core('context', 'problem', 'story', 'Why this matters', 'Connect the topic to a task.'),
|
|
95
|
+
core('concept', 'solution', 'idea', 'Core concept', 'Explain one key idea.'),
|
|
96
|
+
core('demo', 'demo', 'story', 'Demonstration', 'Show the method in context.'),
|
|
97
|
+
core('practice', 'steps', 'diagram', 'Practice', 'Give a guided activity.'),
|
|
98
|
+
core('check', 'quiz', 'punch', 'Check understanding', 'Add a question and expected reasoning.'),
|
|
99
|
+
core('resources', 'findings', 'idea', 'Resources', 'Link trusted follow-up material.'),
|
|
100
|
+
close,
|
|
101
|
+
],
|
|
102
|
+
},
|
|
103
|
+
{
|
|
104
|
+
id: 'lesson',
|
|
105
|
+
label: 'Lesson',
|
|
106
|
+
cues: ['lesson', 'teach', 'class'],
|
|
107
|
+
slides: [
|
|
108
|
+
title,
|
|
109
|
+
core('question', 'hook', 'punch', 'Opening question', 'Ask a question that frames the topic.'),
|
|
110
|
+
core('goals', 'agenda', 'idea', 'Goals', 'State what learners can do.'),
|
|
111
|
+
core('idea', 'solution', 'idea', 'Main idea', 'Explain the central concept.'),
|
|
112
|
+
core('example', 'demo', 'story', 'Worked example', 'Walk through a representative case.'),
|
|
113
|
+
core('practice', 'steps', 'diagram', 'Try it', 'Provide a practice sequence.'),
|
|
114
|
+
core('recap', 'findings', 'idea', 'Recap', 'Summarize the core points.'),
|
|
115
|
+
close,
|
|
116
|
+
],
|
|
117
|
+
},
|
|
118
|
+
{
|
|
119
|
+
id: 'report',
|
|
120
|
+
label: 'Report',
|
|
121
|
+
cues: ['report', 'findings', 'research'],
|
|
122
|
+
slides: [
|
|
123
|
+
title,
|
|
124
|
+
core('question', 'hook', 'punch', 'Question', 'State the question this report answers.'),
|
|
125
|
+
core('method', 'steps', 'diagram', 'Method', 'Describe the method and limits.'),
|
|
126
|
+
core('findings', 'findings', 'idea', 'Findings', 'Present evidence with sources.'),
|
|
127
|
+
core('detail', 'chart', 'diagram', 'Evidence', 'Visualize verified data.', true),
|
|
128
|
+
core('implications', 'solution', 'idea', 'Implications', 'Explain what the evidence supports.'),
|
|
129
|
+
core('limitations', 'risks', 'idea', 'Limitations', 'State uncertainty and missing evidence.'),
|
|
130
|
+
core('recommendations', 'recommendations', 'diagram', 'Recommendations', 'Connect actions to findings.'),
|
|
131
|
+
close,
|
|
132
|
+
],
|
|
133
|
+
},
|
|
134
|
+
{
|
|
135
|
+
id: 'talk',
|
|
136
|
+
label: 'Talk',
|
|
137
|
+
cues: ['talk', 'keynote', 'presentation'],
|
|
138
|
+
slides: [
|
|
139
|
+
title,
|
|
140
|
+
core('hook', 'hook', 'punch', 'A question to carry', 'Open with a question or observation.'),
|
|
141
|
+
core('story', 'demo', 'story', 'A concrete story', 'Tell one sourced or personal story.'),
|
|
142
|
+
core('idea', 'solution', 'idea', 'The idea', 'Explain the central insight.'),
|
|
143
|
+
core('evidence', 'metrics', 'punch', 'Evidence', 'Show supporting evidence.', true),
|
|
144
|
+
core('practice', 'steps', 'diagram', 'What to try', 'Offer a practical next step.'),
|
|
145
|
+
core('recap', 'findings', 'idea', 'Takeaways', 'Repeat the memorable points.'),
|
|
146
|
+
close,
|
|
147
|
+
],
|
|
148
|
+
},
|
|
149
|
+
]);
|
|
150
|
+
/** Build an honest, reproducible draft from a brief; missing evidence stays explicit. */
|
|
151
|
+
export function generateDeckFromBrief(options) {
|
|
152
|
+
const brief = options.brief.slice(0, 5_000).trim();
|
|
153
|
+
const outline = options.outline ?? detectOutline(brief);
|
|
154
|
+
const template = DECK_OUTLINES.find((item) => item.id === outline);
|
|
155
|
+
if (!template)
|
|
156
|
+
throw new TypeError(`Unknown deck outline "${outline}".`);
|
|
157
|
+
const slides = template.slides.filter((item) => !item.optional);
|
|
158
|
+
const requests = slides.map((item) => ({
|
|
159
|
+
id: `slide-${item.key}`,
|
|
160
|
+
role: item.role,
|
|
161
|
+
pacing: item.pacing,
|
|
162
|
+
purpose: item.purpose,
|
|
163
|
+
}));
|
|
164
|
+
const selection = selectDeckLayouts(requests);
|
|
165
|
+
const deckSlides = slides.map((item, index) => {
|
|
166
|
+
const chosen = selection[index];
|
|
167
|
+
if (!chosen)
|
|
168
|
+
throw new TypeError(`No layout was selected for slide ${item.key}.`);
|
|
169
|
+
const note = `Draft guidance: ${item.purpose} Add only verified details from the brief or cited sources.`;
|
|
170
|
+
if (chosen.layout === 'free')
|
|
171
|
+
return {
|
|
172
|
+
id: chosen.id,
|
|
173
|
+
title: item.title || options.title || template.label,
|
|
174
|
+
notes: note,
|
|
175
|
+
layout: 'free',
|
|
176
|
+
blocks: [{ type: 'text', markdown: `Draft: ${item.purpose}` }],
|
|
177
|
+
};
|
|
178
|
+
const props = draftProps(structuredClone(DECK_LAYOUTS[chosen.layout].sample));
|
|
179
|
+
if ('title' in props)
|
|
180
|
+
props.title = item.title || `Draft: ${item.purpose}`;
|
|
181
|
+
if ('label' in props)
|
|
182
|
+
props.label = item.title || `Draft: ${item.purpose}`;
|
|
183
|
+
return {
|
|
184
|
+
id: chosen.id,
|
|
185
|
+
title: item.title || options.title || template.label,
|
|
186
|
+
notes: note,
|
|
187
|
+
layout: chosen.layout,
|
|
188
|
+
props,
|
|
189
|
+
};
|
|
190
|
+
});
|
|
191
|
+
const parsed = parseDeck({
|
|
192
|
+
formatVersion: 2,
|
|
193
|
+
meta: {
|
|
194
|
+
title: options.title?.trim() || titleFromBrief(brief) || template.label,
|
|
195
|
+
...(options.locale ? { locale: options.locale } : {}),
|
|
196
|
+
},
|
|
197
|
+
slides: deckSlides,
|
|
198
|
+
});
|
|
199
|
+
if (!parsed.ok || !parsed.deck)
|
|
200
|
+
throw new TypeError(`Generated ${outline} outline failed validation: ${parsed.issues.map((issue) => issue.message).join('; ')}`);
|
|
201
|
+
return { deck: parsed.deck, outline, selection };
|
|
202
|
+
}
|
|
203
|
+
function detectOutline(brief) {
|
|
204
|
+
const normalized = brief
|
|
205
|
+
.toLowerCase()
|
|
206
|
+
.replace(/[^\p{L}\p{N}]+/gu, ' ')
|
|
207
|
+
.trim();
|
|
208
|
+
for (const item of DECK_OUTLINES)
|
|
209
|
+
if (item.cues.some((cue) => ` ${normalized} `.includes(` ${cue} `)))
|
|
210
|
+
return item.id;
|
|
211
|
+
return 'talk';
|
|
212
|
+
}
|
|
213
|
+
function titleFromBrief(brief) {
|
|
214
|
+
return (brief
|
|
215
|
+
.split(/[.!?\n]/u)[0]
|
|
216
|
+
?.trim()
|
|
217
|
+
.slice(0, 80) || undefined);
|
|
218
|
+
}
|
|
219
|
+
const STRUCTURAL_KEYS = new Set([
|
|
220
|
+
'avatar',
|
|
221
|
+
'icon',
|
|
222
|
+
'lang',
|
|
223
|
+
'kind',
|
|
224
|
+
'type',
|
|
225
|
+
'id',
|
|
226
|
+
'height',
|
|
227
|
+
'overlay',
|
|
228
|
+
'gradient',
|
|
229
|
+
'pattern',
|
|
230
|
+
'token',
|
|
231
|
+
'muted',
|
|
232
|
+
'autoplay',
|
|
233
|
+
'loop',
|
|
234
|
+
'showTable',
|
|
235
|
+
'width',
|
|
236
|
+
]);
|
|
237
|
+
function draftProps(value, key = '') {
|
|
238
|
+
if (Array.isArray(value))
|
|
239
|
+
return value.map((item) => draftProps(item, key));
|
|
240
|
+
if (value && typeof value === 'object') {
|
|
241
|
+
return Object.fromEntries(Object.entries(value).map(([childKey, child]) => [childKey, draftProps(child, childKey)]));
|
|
242
|
+
}
|
|
243
|
+
if (typeof value === 'string') {
|
|
244
|
+
if (key === 'src')
|
|
245
|
+
return 'https://example.com/media/draft-placeholder.webp';
|
|
246
|
+
if (key === 'href')
|
|
247
|
+
return 'https://example.com/';
|
|
248
|
+
if (key === 'alt')
|
|
249
|
+
return 'Draft image placeholder.';
|
|
250
|
+
return STRUCTURAL_KEYS.has(key) ? value : 'Draft: add verified details.';
|
|
251
|
+
}
|
|
252
|
+
if (typeof value === 'number')
|
|
253
|
+
return STRUCTURAL_KEYS.has(key) ? value : 0;
|
|
254
|
+
return value;
|
|
255
|
+
}
|