@bytesbybrandon/living-docs 0.2.0 → 0.3.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/README.md +7 -3
- package/package.json +15 -3
- package/samples/agent-skills-audit.json +38 -1
- package/samples/end-of-harness-engineering.json +30 -7
- package/samples/harness-engineering.json +32 -2
- package/src/axi/cli.js +53 -7
- package/src/export/video.js +150 -0
- package/src/ingest/cues.js +81 -0
- package/src/ingest/index.js +22 -8
- package/src/ingest/style.js +57 -0
- package/src/mcp/server.js +32 -8
- package/src/model/markdown.js +9 -3
- package/src/model/schema.js +42 -1
- package/src/model/validate.js +82 -4
- package/src/render/blocks.js +9 -5
- package/src/render/index.js +16 -5
- package/src/render/style.js +8 -3
- package/src/render/svg.js +13 -16
- package/src/render/themes/hud.js +19 -8
- package/src/render/themes/variants.js +43 -0
- package/src/render/timeline.js +304 -0
package/src/ingest/index.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
|
+
import { draftCues } from './cues.js';
|
|
2
|
+
import { draftDesign } from './style.js';
|
|
1
3
|
import fs from 'node:fs/promises';
|
|
2
4
|
import path from 'node:path';
|
|
3
|
-
import { parseMarkdown, slug, wordCount
|
|
5
|
+
import { parseMarkdown, slug, wordCount } from '../model/markdown.js';
|
|
4
6
|
import { htmlToMarkdown, htmlTitle } from './html.js';
|
|
5
7
|
import { textToMarkdown } from './text.js';
|
|
6
8
|
|
|
@@ -79,8 +81,8 @@ function normalizeBlocks(blocks) {
|
|
|
79
81
|
if (block.type === 'table') {
|
|
80
82
|
return {
|
|
81
83
|
type: 'table',
|
|
82
|
-
columns: block.columns.map(c =>
|
|
83
|
-
rows: block.rows.map(row => row.map(cell =>
|
|
84
|
+
columns: block.columns.map(c => c.trim()),
|
|
85
|
+
rows: block.rows.map(row => row.map(cell => cell.trim()))
|
|
84
86
|
};
|
|
85
87
|
}
|
|
86
88
|
if (block.type === 'list') {
|
|
@@ -197,28 +199,36 @@ export function blockToModelBlock(block) {
|
|
|
197
199
|
* Build a valid draft model straight from a source. This is the zero-judgment path:
|
|
198
200
|
* it renders, then an agent upgrades sections, figures, and callouts.
|
|
199
201
|
*/
|
|
200
|
-
export function draftModel(ingested, { theme
|
|
202
|
+
export function draftModel(ingested, { theme, seed, includePreamble = true, maxBlocks = 400, cues = true } = {}) {
|
|
201
203
|
const { meta, sections, preamble } = ingested;
|
|
202
204
|
const blocks = [];
|
|
205
|
+
let abstractPending = Boolean(meta.abstract);
|
|
206
|
+
const takeBlock = block => {
|
|
207
|
+
if (abstractPending && block.type === 'paragraph' && block.text === meta.abstract) {
|
|
208
|
+
abstractPending = false;
|
|
209
|
+
return null;
|
|
210
|
+
}
|
|
211
|
+
return blockToModelBlock(block);
|
|
212
|
+
};
|
|
203
213
|
if (includePreamble && preamble.length) {
|
|
204
214
|
for (const block of preamble) {
|
|
205
|
-
const mapped =
|
|
215
|
+
const mapped = takeBlock(block);
|
|
206
216
|
if (mapped) blocks.push(mapped);
|
|
207
217
|
}
|
|
208
218
|
}
|
|
209
219
|
for (const section of sections) {
|
|
210
220
|
blocks.push({ type: 'heading', title: section.title, level: 2 });
|
|
211
221
|
for (const block of section.blocks) {
|
|
212
|
-
const mapped =
|
|
222
|
+
const mapped = takeBlock(block);
|
|
213
223
|
if (mapped) blocks.push(mapped);
|
|
214
224
|
if (blocks.length >= maxBlocks) break;
|
|
215
225
|
}
|
|
216
226
|
if (blocks.length >= maxBlocks) break;
|
|
217
227
|
}
|
|
218
228
|
|
|
219
|
-
|
|
229
|
+
const model = {
|
|
220
230
|
schemaVersion: 1,
|
|
221
|
-
theme,
|
|
231
|
+
theme: 'paper-dark',
|
|
222
232
|
meta: {
|
|
223
233
|
title: meta.title,
|
|
224
234
|
...(meta.subtitle ? { subtitle: meta.subtitle } : {}),
|
|
@@ -228,6 +238,10 @@ export function draftModel(ingested, { theme = 'paper-dark', includePreamble = t
|
|
|
228
238
|
},
|
|
229
239
|
blocks: blocks.length ? blocks : [{ type: 'prose', markdown: meta.title }]
|
|
230
240
|
};
|
|
241
|
+
const design = draftDesign(model, { theme, seed });
|
|
242
|
+
model.theme = design.theme;
|
|
243
|
+
if (Object.keys(design.style).length) model.meta.style = design.style;
|
|
244
|
+
return cues ? draftCues(model) : model;
|
|
231
245
|
}
|
|
232
246
|
|
|
233
247
|
/**
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { THEMES } from '../model/schema.js';
|
|
2
|
+
import { toHex, hslToRgb, parseHex } from '../render/style.js';
|
|
3
|
+
|
|
4
|
+
// Design a doc from its own source: pick a look from the shape of the content and
|
|
5
|
+
// derive an accent from the title. Deterministic, validated tokens only, always overridable.
|
|
6
|
+
|
|
7
|
+
const BASES = new Set(['paper-light', 'paper-dark']);
|
|
8
|
+
|
|
9
|
+
function hash(text) {
|
|
10
|
+
let h = 2166136261;
|
|
11
|
+
for (const ch of text) h = Math.imul(h ^ ch.codePointAt(0), 16777619);
|
|
12
|
+
return h >>> 0;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function words(blocks) {
|
|
16
|
+
return blocks.reduce((sum, b) => sum + (typeof b.markdown === 'string' ? b.markdown.split(/\s+/).length : 0), 0);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Pick a theme from the content shape. */
|
|
20
|
+
export function chooseTheme(model) {
|
|
21
|
+
const body = model.blocks.filter(b => b.type !== 'heading');
|
|
22
|
+
const count = type => body.filter(b => b.type === type).length;
|
|
23
|
+
const code = count('code');
|
|
24
|
+
const tables = count('table');
|
|
25
|
+
const dense = count('table') + count('list');
|
|
26
|
+
if (code >= 3 || (body.length && code / body.length >= 0.15)) return 'blueprint';
|
|
27
|
+
if (model.meta.abstract && (tables >= 1 || model.blocks.some(b => b.type === 'heading' && /^\d/.test(b.title)))) return 'paper-light';
|
|
28
|
+
if (words(body) >= 1500 && tables === 0) return 'editorial';
|
|
29
|
+
if (body.length >= 10 && dense / body.length >= 0.4) return 'paper-dark';
|
|
30
|
+
if (body.length <= 14) return 'field-notes';
|
|
31
|
+
return 'paper-dark';
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Accent seed from the title: a stable hue at a tuned saturation and lightness. */
|
|
35
|
+
export function seedFromTitle(title, theme) {
|
|
36
|
+
const hue = (hash(title) % 360) / 360;
|
|
37
|
+
return toHex(hslToRgb({ h: hue, s: 0.5, l: theme === 'paper-dark' ? 0.62 : 0.38 }));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Return { theme, style } for a drafted model.
|
|
42
|
+
* `theme` and `seed` are explicit overrides; curated looks keep their own accent.
|
|
43
|
+
*/
|
|
44
|
+
export function draftDesign(model, { theme, seed } = {}) {
|
|
45
|
+
if (theme && !THEMES[theme]) throw new Error(`unknown theme "${theme}"`);
|
|
46
|
+
const chosen = theme || chooseTheme(model);
|
|
47
|
+
const base = THEMES[chosen].base || chosen;
|
|
48
|
+
const style = {};
|
|
49
|
+
const accent = seed && parseHex(seed) ? seed : BASES.has(chosen) ? seedFromTitle(model.meta.title, chosen) : null;
|
|
50
|
+
if (accent) style.seed = accent;
|
|
51
|
+
const body = model.blocks.length;
|
|
52
|
+
if (body <= 12) style.density = 'roomy';
|
|
53
|
+
else if (body >= 80) style.density = 'compact';
|
|
54
|
+
if (chosen === 'blueprint') style.type = 'technical';
|
|
55
|
+
if (chosen === 'editorial') style.figure = 'minimal';
|
|
56
|
+
return { theme: chosen, base, style };
|
|
57
|
+
}
|
package/src/mcp/server.js
CHANGED
|
@@ -8,6 +8,7 @@ import { renderDoc, themeList } from '../render/index.js';
|
|
|
8
8
|
import { validateDoc, formatErrors } from '../model/validate.js';
|
|
9
9
|
import { BLOCKS, THEMES, STYLE, blockFieldSummary } from '../model/schema.js';
|
|
10
10
|
import { createPreviewServer } from '../server/preview.js';
|
|
11
|
+
import { exportVideo, FORMATS } from '../export/video.js';
|
|
11
12
|
|
|
12
13
|
const quoteCell = value => {
|
|
13
14
|
const text = String(value ?? '');
|
|
@@ -33,7 +34,7 @@ function textResult(text) {
|
|
|
33
34
|
const TOOLS = [
|
|
34
35
|
{
|
|
35
36
|
name: 'living_docs_themes',
|
|
36
|
-
description: 'List the
|
|
37
|
+
description: 'List the living-doc themes and what each is best for.',
|
|
37
38
|
inputSchema: { type: 'object', properties: {}, additionalProperties: false }
|
|
38
39
|
},
|
|
39
40
|
{
|
|
@@ -56,7 +57,9 @@ const TOOLS = [
|
|
|
56
57
|
text: { type: 'string', description: 'Inline source text' },
|
|
57
58
|
section: { type: 'string', description: 'Return one section: 1-based index or id' },
|
|
58
59
|
draft: { type: 'boolean', description: 'Include a draft document model built from the source' },
|
|
59
|
-
|
|
60
|
+
static: { type: 'boolean', description: 'Draft without the auto-generated timeline cues' },
|
|
61
|
+
seed: { type: 'string', description: 'Hex accent seed for the draft, overriding the one derived from the title' },
|
|
62
|
+
theme: { type: 'string', enum: Object.keys(THEMES), description: 'Theme for the draft; omit to let the source pick a look' },
|
|
60
63
|
out: { type: 'string', description: 'Write the draft model JSON to this path' },
|
|
61
64
|
max_chars: { type: 'number', description: 'Section text limit, default 12000' }
|
|
62
65
|
},
|
|
@@ -65,7 +68,7 @@ const TOOLS = [
|
|
|
65
68
|
},
|
|
66
69
|
{
|
|
67
70
|
name: 'living_docs_render',
|
|
68
|
-
description: 'Validate a document model and write a self-contained HTML doc. Accepts a model object, a model path, or a source to draft from.',
|
|
71
|
+
description: 'Validate a document model and write a self-contained HTML doc, or an mp4, webm, or gif of its timeline. Accepts a model object, a model path, or a source to draft from.',
|
|
69
72
|
inputSchema: {
|
|
70
73
|
type: 'object',
|
|
71
74
|
properties: {
|
|
@@ -73,7 +76,10 @@ const TOOLS = [
|
|
|
73
76
|
model_path: { type: 'string', description: 'Path to a model JSON file' },
|
|
74
77
|
source: { type: 'string', description: 'Source path or URL to ingest and draft from' },
|
|
75
78
|
theme: { type: 'string', enum: Object.keys(THEMES) },
|
|
76
|
-
out: { type: 'string', description: 'Output
|
|
79
|
+
out: { type: 'string', description: 'Output path, default docs/<title>-<theme>.<format>' },
|
|
80
|
+
format: { type: 'string', enum: ['html', ...FORMATS], description: 'html (default) or a video of the timeline; video needs playwright and ffmpeg' },
|
|
81
|
+
size: { type: 'string', description: 'Video stage size, default 1080x1350' },
|
|
82
|
+
fps: { type: 'number', description: 'Video frames per second, default 30' }
|
|
77
83
|
},
|
|
78
84
|
additionalProperties: false
|
|
79
85
|
}
|
|
@@ -161,8 +167,7 @@ async function handle(name, args = {}) {
|
|
|
161
167
|
toon('sections', ['index', 'id', 'title', 'words'], outline.sections.map(section => [section.index, section.id, section.title, section.words]))
|
|
162
168
|
];
|
|
163
169
|
if (args.draft) {
|
|
164
|
-
const
|
|
165
|
-
const model = draftModel(ingested, { theme });
|
|
170
|
+
const model = draftModel(ingested, { theme: args.theme, seed: args.seed, cues: !args.static });
|
|
166
171
|
const validation = validateDoc(model);
|
|
167
172
|
if (!validation.ok) return toolError(`draft model invalid:\n${formatErrors(validation.errors)}`);
|
|
168
173
|
if (args.out) {
|
|
@@ -185,10 +190,29 @@ async function handle(name, args = {}) {
|
|
|
185
190
|
if (!model && args.source) {
|
|
186
191
|
const isUrl = /^https?:\/\//i.test(args.source);
|
|
187
192
|
const ingested = await ingestSource(isUrl ? { url: args.source } : { path: args.source });
|
|
188
|
-
model = draftModel(ingested, { theme: args.theme
|
|
193
|
+
model = draftModel(ingested, { theme: args.theme });
|
|
189
194
|
}
|
|
190
195
|
if (!model) return toolError('pass model, model_path, or source');
|
|
191
196
|
const theme = args.theme || model.theme || 'paper-light';
|
|
197
|
+
if (args.format && args.format !== 'html') {
|
|
198
|
+
const out = args.out || path.join('docs', `${slugify(model.meta?.title || 'document')}-${theme}.${args.format}`);
|
|
199
|
+
fs.mkdirSync(path.dirname(path.resolve(out)), { recursive: true });
|
|
200
|
+
try {
|
|
201
|
+
const video = await exportVideo(model, { theme, baseDir, format: args.format, out, size: args.size, fps: args.fps });
|
|
202
|
+
return textResult([
|
|
203
|
+
'ok: true',
|
|
204
|
+
`file: ${path.resolve(out)}`,
|
|
205
|
+
`format: ${video.format}`,
|
|
206
|
+
`size: ${video.width}x${video.height}`,
|
|
207
|
+
`frames: ${video.frames}`,
|
|
208
|
+
`seconds: ${video.duration}`,
|
|
209
|
+
`bytes: ${fs.statSync(out).size}`
|
|
210
|
+
].join('\n'));
|
|
211
|
+
} catch (error) {
|
|
212
|
+
if (error.name === 'RenderError') return toolError(`model invalid:\n${formatErrors(error.errors)}`);
|
|
213
|
+
return toolError(`${error.message}${error.hint ? `\nhint: ${error.hint}` : ''}`);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
192
216
|
const result = renderDoc(model, { theme, baseDir });
|
|
193
217
|
if (!result.ok) return toolError(`model invalid:\n${formatErrors(result.errors)}`);
|
|
194
218
|
const out = args.out || path.join('docs', `${slugify(model.meta.title)}-${theme}.html`);
|
|
@@ -235,7 +259,7 @@ export function createServer() {
|
|
|
235
259
|
{ name: 'living-docs', version: '0.1.0' },
|
|
236
260
|
{
|
|
237
261
|
capabilities: { tools: {} },
|
|
238
|
-
instructions: 'Turn source documents into designed living docs. Ingest to get an outline, author a document model against the schema, then render with
|
|
262
|
+
instructions: 'Turn source documents into designed living docs. Ingest to get an outline, author a document model against the schema, then render with a theme from living_docs_themes. A draft without a theme picks a look from the source.'
|
|
239
263
|
}
|
|
240
264
|
);
|
|
241
265
|
|
package/src/model/markdown.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// Small markdown subset: enough to ingest prose without pulling a parser stack.
|
|
2
|
-
// Inline: `code`, **bold**, *italic*, ~~strike~~, [text](url), [[n]] footnote refs.
|
|
2
|
+
// Inline: `code`, **bold**, *italic*, ~~strike~~, [text](url), [[n]] footnote refs, [[mark:id|text]] cue marks.
|
|
3
3
|
// Blocks: ATX headings, paragraphs, fenced code, lists, blockquotes, pipe tables, rules.
|
|
4
4
|
|
|
5
5
|
const ESCAPES = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' };
|
|
@@ -13,6 +13,9 @@ export function safeUrl(url) {
|
|
|
13
13
|
return null;
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
+
// [[mark:id|text]] wraps a source sentence so a timeline cue can point at it.
|
|
17
|
+
export const MARK_RE = /\[\[mark:([a-z][a-z0-9-]*)\|([^\]\n]+)\]\]/g;
|
|
18
|
+
|
|
16
19
|
export function renderInline(markdown, { allowLinks = true } = {}) {
|
|
17
20
|
const source = String(markdown ?? '');
|
|
18
21
|
const tokens = [];
|
|
@@ -21,7 +24,8 @@ export function renderInline(markdown, { allowLinks = true } = {}) {
|
|
|
21
24
|
return `\u0000${tokens.length - 1}\u0000`;
|
|
22
25
|
};
|
|
23
26
|
|
|
24
|
-
let text = source.replace(
|
|
27
|
+
let text = source.replace(MARK_RE, (_, id, body) => stash(`<span class="ld-mark" data-mark="${id}">${renderInline(body, { allowLinks })}</span>`));
|
|
28
|
+
text = text.replace(/`([^`\n]+)`/g, (_, code) => stash(`<code>${escapeHtml(code)}</code>`));
|
|
25
29
|
|
|
26
30
|
if (allowLinks) {
|
|
27
31
|
text = text.replace(/\[([^\]\n]+)\]\(([^)\s]+)\)/g, (_, label, url) => {
|
|
@@ -56,7 +60,8 @@ const olRe = /^\s*\d+[.)]\s+(.*)$/;
|
|
|
56
60
|
const hrRe = /^\s*([-*_])(?:\s*\1){2,}\s*$/;
|
|
57
61
|
|
|
58
62
|
function splitRow(line) {
|
|
59
|
-
|
|
63
|
+
const body = line.trim().replace(/^\|/, '').replace(/(?<!\\)\|$/, '');
|
|
64
|
+
return body.split(/(?<!\\)\|/).map(cell => cell.trim().replace(/\\\|/g, '|'));
|
|
60
65
|
}
|
|
61
66
|
|
|
62
67
|
/**
|
|
@@ -154,6 +159,7 @@ export function parseMarkdown(source) {
|
|
|
154
159
|
|
|
155
160
|
export function plainText(markdown) {
|
|
156
161
|
return String(markdown ?? '')
|
|
162
|
+
.replace(MARK_RE, '$2')
|
|
157
163
|
.replace(/`([^`]+)`/g, '$1')
|
|
158
164
|
.replace(/\[\[(\d+)\]\]/g, '')
|
|
159
165
|
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
|
package/src/model/schema.js
CHANGED
|
@@ -300,7 +300,8 @@ export const STYLE = {
|
|
|
300
300
|
seed: str('hex color that derives the accent family, e.g. #2f6f8f', { optional: true }),
|
|
301
301
|
type: oneOf(['classic', 'editorial', 'technical', 'mono', 'hybrid', 'terminal'], 'curated type pairing', { optional: true }),
|
|
302
302
|
density: oneOf(['compact', 'normal', 'roomy'], 'vertical rhythm', { optional: true }),
|
|
303
|
-
figure: oneOf(['outline', 'solid', 'minimal'], 'diagram and chart treatment', { optional: true })
|
|
303
|
+
figure: oneOf(['outline', 'solid', 'minimal'], 'diagram and chart treatment', { optional: true }),
|
|
304
|
+
layout: oneOf(['columns', 'single'], 'paper themes only: two columns (default) or one reading column for code and wide tables', { optional: true })
|
|
304
305
|
};
|
|
305
306
|
|
|
306
307
|
export const META = {
|
|
@@ -326,6 +327,31 @@ export const META = {
|
|
|
326
327
|
})
|
|
327
328
|
};
|
|
328
329
|
|
|
330
|
+
// Any block may carry an id so timeline cues can target it.
|
|
331
|
+
export const BLOCK_ID = str('cue anchor: lowercase letters, digits, hyphens; unique in the document', { optional: true });
|
|
332
|
+
|
|
333
|
+
export const CUE_ACTIONS = {
|
|
334
|
+
highlight: 'sweep a highlighter across a [[mark:id|text]] sentence or a block, then leave a faint underline',
|
|
335
|
+
focus: 'light up a block or part (diagram node, panel, scene label) and dim its siblings',
|
|
336
|
+
reveal: 'fade and rise a block or part into view at the cue time; hidden before it',
|
|
337
|
+
count: 'count the numbers inside the target up from zero',
|
|
338
|
+
type: 'type the text of the target out character by character',
|
|
339
|
+
camera: 'stage video only: scroll the frame to a block or part, or back to "page" (flat at 100% unless zoom is set)',
|
|
340
|
+
activate: 'hud scene only: glide the crosshair to a scene label (target "<sceneId>/<label>")'
|
|
341
|
+
};
|
|
342
|
+
|
|
343
|
+
export const TIMELINE = {
|
|
344
|
+
duration: num('total seconds; default last cue end plus 1.5', { optional: true }),
|
|
345
|
+
loop: bool('restart when the timeline ends (live page only)', { optional: true }),
|
|
346
|
+
cues: arr(obj({
|
|
347
|
+
at: num('start time in seconds'),
|
|
348
|
+
dur: num('seconds the cue runs, default 1.2', { optional: true }),
|
|
349
|
+
do: oneOf(Object.keys(CUE_ACTIONS), 'action'),
|
|
350
|
+
target: str('"mark:<id>", "<blockId>", "<blockId>/<part>" (diagram node id, panel index, scene label), or "page" for camera'),
|
|
351
|
+
zoom: num('camera only: opt-in scale, default 1 (flat)', { optional: true })
|
|
352
|
+
}, 'cue'), 'cues in any order')
|
|
353
|
+
};
|
|
354
|
+
|
|
329
355
|
export const THEMES = {
|
|
330
356
|
'paper-light': {
|
|
331
357
|
summary: 'Academic paper: serif, two justified columns, drop caps, margin stamp, print rules.',
|
|
@@ -335,6 +361,21 @@ export const THEMES = {
|
|
|
335
361
|
summary: 'Reading notes: cream on carbon, orange accent, rules, algorithm boxes, takeaway strip.',
|
|
336
362
|
best: 'paper summaries, essays, annotated readings'
|
|
337
363
|
},
|
|
364
|
+
'field-notes': {
|
|
365
|
+
base: 'paper-light',
|
|
366
|
+
summary: 'Warm cream paper, olive rules, italic titles.',
|
|
367
|
+
best: 'field reports, lab notes, reading journals'
|
|
368
|
+
},
|
|
369
|
+
editorial: {
|
|
370
|
+
base: 'paper-light',
|
|
371
|
+
summary: 'Magazine masthead: oversized left-set title, heavy rules, signal red.',
|
|
372
|
+
best: 'essays, features, opinion pieces'
|
|
373
|
+
},
|
|
374
|
+
blueprint: {
|
|
375
|
+
base: 'paper-dark',
|
|
376
|
+
summary: 'Drafting-table blue with a faint grid and cyan linework.',
|
|
377
|
+
best: 'architecture notes, system designs, specs'
|
|
378
|
+
},
|
|
338
379
|
hud: {
|
|
339
380
|
summary: 'Telemetry console: monospaced 12-panel grid, cyan accents, graph hero, live clock.',
|
|
340
381
|
best: 'repo audits, skill catalogs, system snapshots, run reports'
|
package/src/model/validate.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { BLOCKS, META, THEMES } from './schema.js';
|
|
1
|
+
import { BLOCKS, META, THEMES, TIMELINE, BLOCK_ID, CUE_ACTIONS } from './schema.js';
|
|
2
|
+
import { MARK_RE } from './markdown.js';
|
|
2
3
|
|
|
3
4
|
const isObj = v => v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
4
5
|
|
|
@@ -55,8 +56,8 @@ export function validateDoc(doc, { defaultTheme = null } = {}) {
|
|
|
55
56
|
}
|
|
56
57
|
|
|
57
58
|
for (const name of Object.keys(doc)) {
|
|
58
|
-
if (!['schemaVersion', 'theme', 'meta', 'blocks'].includes(name)) {
|
|
59
|
-
err(name, 'unknown top-level field (allowed: schemaVersion, theme, meta, blocks)');
|
|
59
|
+
if (!['schemaVersion', 'theme', 'meta', 'blocks', 'timeline'].includes(name)) {
|
|
60
|
+
err(name, 'unknown top-level field (allowed: schemaVersion, theme, meta, blocks, timeline)');
|
|
60
61
|
}
|
|
61
62
|
}
|
|
62
63
|
if (doc.schemaVersion !== undefined && doc.schemaVersion !== 1) err('schemaVersion', 'must be 1');
|
|
@@ -69,6 +70,7 @@ export function validateDoc(doc, { defaultTheme = null } = {}) {
|
|
|
69
70
|
return { ok: false, errors, theme: doc.theme || defaultTheme };
|
|
70
71
|
}
|
|
71
72
|
|
|
73
|
+
const ids = new Set();
|
|
72
74
|
doc.blocks.forEach((block, i) => {
|
|
73
75
|
const path = `blocks[${i}]`;
|
|
74
76
|
if (!isObj(block)) { err(path, 'must be an object with a `type`'); return; }
|
|
@@ -77,7 +79,7 @@ export function validateDoc(doc, { defaultTheme = null } = {}) {
|
|
|
77
79
|
const def = BLOCKS[type];
|
|
78
80
|
if (!def) { err(`${path}.type`, `unknown block type "${type}" (one of: ${Object.keys(BLOCKS).join(', ')})`); return; }
|
|
79
81
|
for (const name of Object.keys(block)) {
|
|
80
|
-
if (name !== 'type' && !(name in def.fields)) {
|
|
82
|
+
if (name !== 'type' && name !== 'id' && !(name in def.fields)) {
|
|
81
83
|
err(`${path}.${name}`, `unknown field for ${type} (allowed: ${Object.keys(def.fields).join(', ')})`);
|
|
82
84
|
}
|
|
83
85
|
}
|
|
@@ -85,6 +87,12 @@ export function validateDoc(doc, { defaultTheme = null } = {}) {
|
|
|
85
87
|
checkSpec(field, block[name], `${path}.${name}`, err);
|
|
86
88
|
}
|
|
87
89
|
if (def.check) def.check(block, path, err);
|
|
90
|
+
checkSpec(BLOCK_ID, block.id, `${path}.id`, err);
|
|
91
|
+
if (typeof block.id === 'string') {
|
|
92
|
+
if (!ID_RE.test(block.id)) err(`${path}.id`, 'must match ^[a-z][a-z0-9-]*$');
|
|
93
|
+
else if (ids.has(block.id)) err(`${path}.id`, `duplicate block id "${block.id}"`);
|
|
94
|
+
ids.add(block.id);
|
|
95
|
+
}
|
|
88
96
|
if (type === 'panels' && Array.isArray(block.items)) {
|
|
89
97
|
block.items.forEach((item, j) => {
|
|
90
98
|
const wanted = String(j + 1).padStart(2, '0');
|
|
@@ -95,9 +103,79 @@ export function validateDoc(doc, { defaultTheme = null } = {}) {
|
|
|
95
103
|
}
|
|
96
104
|
});
|
|
97
105
|
|
|
106
|
+
if (doc.timeline !== undefined) checkTimeline(doc, err);
|
|
107
|
+
|
|
98
108
|
return { ok: errors.length === 0, errors, theme: doc.theme || defaultTheme };
|
|
99
109
|
}
|
|
100
110
|
|
|
111
|
+
const ID_RE = /^[a-z][a-z0-9-]*$/;
|
|
112
|
+
|
|
113
|
+
// Parts a cue can address inside a block: diagram nodes, panels, scene labels.
|
|
114
|
+
function blockParts(block) {
|
|
115
|
+
if (block.type === 'figure' && block.spec === 'diagram') return (block.diagram?.nodes || []).map(node => node.id);
|
|
116
|
+
if (block.type === 'panels') return (block.items || []).map((item, i) => item.index || String(i + 1).padStart(2, '0'));
|
|
117
|
+
if (block.type === 'graphScene') return (block.graph?.labels || []).slice(0, 9); // the scene draws the first 9
|
|
118
|
+
return [];
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function collectMarks(value, path, marks, err) {
|
|
122
|
+
if (typeof value === 'string') {
|
|
123
|
+
for (const match of value.matchAll(MARK_RE)) {
|
|
124
|
+
if (marks.has(match[1])) err(path, `duplicate mark id "${match[1]}"`);
|
|
125
|
+
marks.add(match[1]);
|
|
126
|
+
}
|
|
127
|
+
} else if (Array.isArray(value)) {
|
|
128
|
+
value.forEach((item, i) => collectMarks(item, `${path}[${i}]`, marks, err));
|
|
129
|
+
} else if (isObj(value)) {
|
|
130
|
+
for (const [key, item] of Object.entries(value)) collectMarks(item, `${path}.${key}`, marks, err);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function checkTimeline(doc, err) {
|
|
135
|
+
checkSpec({ kind: 'object', fields: TIMELINE }, doc.timeline, 'timeline', err);
|
|
136
|
+
if (!isObj(doc.timeline) || !Array.isArray(doc.timeline.cues)) return;
|
|
137
|
+
|
|
138
|
+
const blocks = new Map();
|
|
139
|
+
for (const block of doc.blocks) {
|
|
140
|
+
if (isObj(block) && typeof block.id === 'string' && !blocks.has(block.id)) blocks.set(block.id, block);
|
|
141
|
+
}
|
|
142
|
+
const marks = new Set();
|
|
143
|
+
collectMarks(doc.meta, 'meta', marks, err);
|
|
144
|
+
collectMarks(doc.blocks, 'blocks', marks, err);
|
|
145
|
+
|
|
146
|
+
doc.timeline.cues.forEach((cue, i) => {
|
|
147
|
+
const path = `timeline.cues[${i}]`;
|
|
148
|
+
if (!isObj(cue) || typeof cue.target !== 'string' || !CUE_ACTIONS[cue.do]) return;
|
|
149
|
+
if (typeof cue.at === 'number' && cue.at < 0) err(`${path}.at`, 'must be 0 or more');
|
|
150
|
+
if (typeof cue.dur === 'number' && cue.dur <= 0) err(`${path}.dur`, 'must be more than 0');
|
|
151
|
+
if (cue.zoom !== undefined && cue.do !== 'camera') err(`${path}.zoom`, 'only camera cues take zoom');
|
|
152
|
+
const target = cue.target;
|
|
153
|
+
if (target === 'page') {
|
|
154
|
+
if (cue.do !== 'camera') err(`${path}.target`, '"page" is only a camera target');
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
if (target.startsWith('mark:')) {
|
|
158
|
+
const id = target.slice(5);
|
|
159
|
+
if (!marks.has(id)) err(`${path}.target`, `unknown mark "${id}" (add [[mark:${id}|text]] to a prose string)`);
|
|
160
|
+
else if (cue.do === 'camera' || cue.do === 'activate') err(`${path}.do`, `${cue.do} cannot target a mark`);
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
const [blockId, part, extra] = target.split('/');
|
|
164
|
+
const block = blocks.get(blockId);
|
|
165
|
+
if (!block || extra !== undefined) {
|
|
166
|
+
err(`${path}.target`, `unknown block id "${blockId}" (known: ${[...blocks.keys()].join(', ') || 'none'})`);
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
if (part !== undefined) {
|
|
170
|
+
const parts = blockParts(block);
|
|
171
|
+
if (!parts.includes(part)) err(`${path}.target`, `unknown part "${part}" in ${blockId} (known: ${parts.join(', ') || 'none'})`);
|
|
172
|
+
}
|
|
173
|
+
if (cue.do === 'activate' && (block.type !== 'graphScene' || part === undefined)) {
|
|
174
|
+
err(`${path}.target`, 'activate needs "<graphScene id>/<label>"');
|
|
175
|
+
}
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
|
|
101
179
|
export function formatErrors(errors, limit = 25) {
|
|
102
180
|
const shown = errors.slice(0, limit);
|
|
103
181
|
const lines = shown.map(e => `${e.path}: ${e.message}`);
|
package/src/render/blocks.js
CHANGED
|
@@ -186,7 +186,7 @@ function renderGraphScene(block, ctx) {
|
|
|
186
186
|
const left = (active.x / payload.width) * 100;
|
|
187
187
|
const top = (active.y / payload.height) * 100;
|
|
188
188
|
const cardRows = (card?.rows || []).map(row => `<div class="sc-row"><span>${escapeHtml(row.k)}</span><b>${escapeHtml(row.v)}</b></div>`).join('');
|
|
189
|
-
const labels = payload.labels.map(label => `<span class="scene-label" style="left:${(label.x / payload.width) * 100}%;top:${(label.y / payload.height) * 100}%">${escapeHtml(label.text)}</span>`).join('');
|
|
189
|
+
const labels = payload.labels.map(label => `<span class="scene-label" data-part="${escapeHtml(label.text)}" style="left:${(label.x / payload.width) * 100}%;top:${(label.y / payload.height) * 100}%">${escapeHtml(label.text)}</span>`).join('');
|
|
190
190
|
const status = block.status
|
|
191
191
|
? `<div class="scene-status"><span class="sc-seq">${escapeHtml(block.status.seq)}</span> <span class="sc-kind">${escapeHtml(block.status.kind)}</span> <span class="sc-sep">::</span> <span class="sc-text">${escapeHtml(block.status.text)}</span> <span class="sc-tail">${escapeHtml(block.status.tail)}</span></div>`
|
|
192
192
|
: '';
|
|
@@ -209,8 +209,8 @@ function renderGraphScene(block, ctx) {
|
|
|
209
209
|
<canvas class="scene-canvas" aria-hidden="true"></canvas>
|
|
210
210
|
<div class="scene-crosshair" style="left:${left.toFixed(2)}%;top:${top.toFixed(2)}%"></div>
|
|
211
211
|
${labels}
|
|
212
|
-
<span class="scene-active-label" style="left:${left.toFixed(2)}%;top:${top.toFixed(2)}%">${escapeHtml(graph.active?.id || '')}</span>
|
|
213
|
-
${graph.active?.badge ? `<span class="scene-badge" style="left:${left.toFixed(2)}%;top:${top.toFixed(2)}%">${escapeHtml(graph.active.badge)}</span>` : ''}
|
|
212
|
+
<span class="scene-active-label${left > 55 ? ' to-left' : ''}" style="left:${left.toFixed(2)}%;top:${top.toFixed(2)}%">${escapeHtml(graph.active?.id || '')}</span>
|
|
213
|
+
${graph.active?.badge ? `<span class="scene-badge${left > 55 ? ' to-left' : ''}" style="left:${left.toFixed(2)}%;top:${top.toFixed(2)}%">${escapeHtml(graph.active.badge)}</span>` : ''}
|
|
214
214
|
${card ? `<div class="scene-card" style="left:${left.toFixed(2)}%;top:${top.toFixed(2)}%">
|
|
215
215
|
<div class="sc-card-title">${escapeHtml(card.title)}</div>
|
|
216
216
|
${cardRows}
|
|
@@ -305,7 +305,7 @@ function renderPanels(block, ctx) {
|
|
|
305
305
|
const columns = Math.max(1, Math.min(6, block.columns || 4));
|
|
306
306
|
const items = block.items.map((panel, index) => {
|
|
307
307
|
const tag = panel.tag ? `<span class="panel-tag">${escapeHtml(panel.tag)}</span>` : '';
|
|
308
|
-
return `<article class="panel panel-${panel.kind}"${panel.span ? ` style="grid-column: span ${Math.min(columns, panel.span)}"` : ''}>
|
|
308
|
+
return `<article class="panel panel-${panel.kind}" data-part="${escapeHtml(panel.index || String(index + 1).padStart(2, '0'))}"${panel.span ? ` style="grid-column: span ${Math.min(columns, panel.span)}"` : ''}>
|
|
309
309
|
<header class="panel-head"><span class="panel-index">${escapeHtml(panel.index || String(index + 1).padStart(2, '0'))}</span><span class="panel-title">${escapeHtml(panel.title || '')}</span>${tag}</header>
|
|
310
310
|
<div class="panel-body">${renderPanelBody(panel.kind, panel.data || {}, ctx)}</div>
|
|
311
311
|
</article>`;
|
|
@@ -354,8 +354,12 @@ export function renderBlock(block, ctx) {
|
|
|
354
354
|
}
|
|
355
355
|
}
|
|
356
356
|
|
|
357
|
+
// A block id becomes a data-cue anchor on the block's first element.
|
|
357
358
|
export function renderBlocks(blocks, ctx) {
|
|
358
|
-
return blocks.map(block =>
|
|
359
|
+
return blocks.map(block => {
|
|
360
|
+
const html = renderBlock(block, ctx);
|
|
361
|
+
return block.id ? html.replace(/^(\s*<[a-z0-9]+)/i, `$1 data-cue="${escapeHtml(block.id)}"`) : html;
|
|
362
|
+
}).join('\n');
|
|
359
363
|
}
|
|
360
364
|
|
|
361
365
|
export function documentStats(model, html) {
|
package/src/render/index.js
CHANGED
|
@@ -2,14 +2,17 @@ import { validateDoc } from '../model/validate.js';
|
|
|
2
2
|
import { createContext, renderBlocks, documentStats } from './blocks.js';
|
|
3
3
|
import { THEMES as THEME_INFO } from '../model/schema.js';
|
|
4
4
|
import { resolveStyle } from './style.js';
|
|
5
|
+
import { timelineAssets } from './timeline.js';
|
|
5
6
|
import * as paperLight from './themes/paper-light.js';
|
|
6
7
|
import * as paperDark from './themes/paper-dark.js';
|
|
7
8
|
import * as hud from './themes/hud.js';
|
|
9
|
+
import { VARIANTS } from './themes/variants.js';
|
|
8
10
|
|
|
9
11
|
export const THEMES = {
|
|
10
12
|
'paper-light': paperLight,
|
|
11
13
|
'paper-dark': paperDark,
|
|
12
|
-
hud
|
|
14
|
+
hud,
|
|
15
|
+
...VARIANTS
|
|
13
16
|
};
|
|
14
17
|
|
|
15
18
|
export class RenderError extends Error {
|
|
@@ -33,24 +36,32 @@ export function themeList() {
|
|
|
33
36
|
* Render a document model to a complete, self-contained HTML string.
|
|
34
37
|
* @returns {{ok: boolean, html?: string, stats?: object, warnings?: string[], errors?: object[], theme: string}}
|
|
35
38
|
*/
|
|
36
|
-
export function renderDoc(model, { theme, baseDir, accent } = {}) {
|
|
39
|
+
export function renderDoc(model, { theme, baseDir, accent, stage = false } = {}) {
|
|
37
40
|
const chosen = theme || model?.theme || 'paper-light';
|
|
38
41
|
const themeModule = THEMES[chosen];
|
|
39
42
|
if (!themeModule) {
|
|
40
43
|
throw new RenderError(`Unknown theme "${chosen}". Available: ${Object.keys(THEMES).join(', ')}`);
|
|
41
44
|
}
|
|
42
45
|
|
|
46
|
+
const base = THEME_INFO[chosen]?.base || chosen;
|
|
43
47
|
const validation = validateDoc(model, { defaultTheme: chosen });
|
|
44
48
|
if (!validation.ok) {
|
|
45
49
|
return { ok: false, theme: chosen, errors: validation.errors, warnings: [] };
|
|
46
50
|
}
|
|
47
51
|
|
|
48
|
-
const styled = resolveStyle(
|
|
52
|
+
const styled = resolveStyle(base, themeModule.palette, model.meta.style || {}, accent || model.meta.accent);
|
|
49
53
|
const palette = styled.palette;
|
|
50
|
-
const ctx = createContext({ theme:
|
|
54
|
+
const ctx = createContext({ theme: base, palette, baseDir, model });
|
|
51
55
|
ctx.style = styled;
|
|
52
56
|
const blocksHtml = renderBlocks(model.blocks, ctx);
|
|
53
|
-
|
|
57
|
+
let html = themeModule.document({ model, ctx, palette, blocksHtml, style: styled });
|
|
58
|
+
if (base !== chosen) html = html.replace(`class="theme-${base} `, () => `class="theme-${base} theme-${chosen} `);
|
|
59
|
+
// Timeline runtime only ships when cues exist or a video stage is rendered, so static docs keep their bytes.
|
|
60
|
+
if (model.timeline || stage) {
|
|
61
|
+
html = html
|
|
62
|
+
.replace('<html lang="en">', `<html lang="en" class="ld-timed${stage ? ' ld-stage' : ''}">`)
|
|
63
|
+
.replace('</body>', () => `${timelineAssets(model)}</body>`);
|
|
64
|
+
}
|
|
54
65
|
const stats = documentStats(model, html);
|
|
55
66
|
|
|
56
67
|
return {
|
package/src/render/style.js
CHANGED
|
@@ -86,7 +86,7 @@ function rgbToHsl({ r, g, b }) {
|
|
|
86
86
|
return { h: hue, s: saturation, l: lightness };
|
|
87
87
|
}
|
|
88
88
|
|
|
89
|
-
function hslToRgb({ h, s, l }) {
|
|
89
|
+
export function hslToRgb({ h, s, l }) {
|
|
90
90
|
if (s === 0) {
|
|
91
91
|
const value = l * 255;
|
|
92
92
|
return { r: value, g: value, b: value };
|
|
@@ -173,13 +173,15 @@ export function resolveStyle(theme, basePalette, style = {}, accentOverride) {
|
|
|
173
173
|
|
|
174
174
|
const density = DENSITIES.has(style.density) ? style.density : 'normal';
|
|
175
175
|
const figure = FIGURES.has(style.figure) ? style.figure : 'outline';
|
|
176
|
+
const layout = !isConsole && style.layout === 'single' ? 'single' : 'columns';
|
|
176
177
|
|
|
177
178
|
return {
|
|
178
179
|
palette,
|
|
179
180
|
type,
|
|
180
181
|
density,
|
|
181
182
|
figure,
|
|
182
|
-
|
|
183
|
+
layout,
|
|
184
|
+
className: `density-${density} type-${type} fig-${figure} layout-${layout}`
|
|
183
185
|
};
|
|
184
186
|
}
|
|
185
187
|
|
|
@@ -287,5 +289,8 @@ export function styleCss(theme, palette, resolved) {
|
|
|
287
289
|
const overrides = theme === 'hud' ? CONSOLE_OVERRIDES : PAPER_OVERRIDES;
|
|
288
290
|
const density = DENSITY[theme]?.[resolved.density] || '';
|
|
289
291
|
const type = TYPE[theme]?.[resolved.type] || '';
|
|
290
|
-
|
|
292
|
+
const layout = resolved.layout === 'single'
|
|
293
|
+
? '.columns{column-count:1;max-width:880px;margin-left:auto;margin-right:auto}.span-page{column-span:none}.footnotes{column-count:1}'
|
|
294
|
+
: '';
|
|
295
|
+
return `${palettes(palette)}${overrides}${density}${type}${layout}`;
|
|
291
296
|
}
|