rikiki-deck 0.6.0 → 0.7.2
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/.claude/skills/rikiki-debug/SKILL.md +17 -7
- package/.claude/skills/rikiki-deck/SKILL.md +362 -77
- package/.claude/skills/rikiki-theme/SKILL.md +1 -1
- package/README.md +69 -50
- package/bin/lib/assemble.mjs +154 -0
- package/bin/lib/box-geometry.mjs +66 -0
- package/bin/lib/browser.mjs +302 -0
- package/bin/lib/check-api.d.ts +28 -0
- package/bin/lib/check-api.mjs +6 -0
- package/bin/lib/check-plugins.mjs +228 -0
- package/bin/lib/check.mjs +1347 -0
- package/bin/lib/cli-error.mjs +26 -0
- package/bin/lib/component-deps.mjs +69 -0
- package/bin/lib/diff.mjs +275 -0
- package/bin/lib/export-pdf.mjs +65 -0
- package/bin/lib/graph-hit.mjs +86 -0
- package/bin/lib/inline.mjs +137 -39
- package/bin/lib/narrative.mjs +77 -0
- package/bin/lib/prune-icons.mjs +104 -0
- package/bin/lib/render.mjs +195 -0
- package/bin/lib/scan-external.mjs +126 -0
- package/bin/lib/starter.mjs +27 -14
- package/bin/lib/visual.mjs +120 -0
- package/bin/rikiki.mjs +420 -35
- package/dist/annotation-marks.d.ts +60 -0
- package/dist/annotation-marks.js +1 -0
- package/dist/bar-segments.d.ts +28 -0
- package/dist/bar-segments.js +1 -0
- package/dist/browser-location.d.ts +3 -0
- package/dist/browser-location.js +1 -0
- package/dist/cards-syntax.d.ts +31 -0
- package/dist/cards-syntax.js +6 -0
- package/dist/{plugins/click-stages.d.ts → click-stages.d.ts} +1 -1
- package/dist/deck-agenda.d.ts +25 -0
- package/dist/deck-agenda.js +6 -0
- package/dist/deck-annotate.d.ts +108 -0
- package/dist/deck-annotate.js +18 -0
- package/dist/deck-bar.d.ts +32 -0
- package/dist/deck-bar.js +19 -0
- package/dist/deck-bento.d.ts +38 -0
- package/dist/deck-bento.js +4 -0
- package/dist/{molecules/deck-callout.d.ts → deck-callout.d.ts} +2 -0
- package/dist/deck-callout.js +1 -1
- package/dist/deck-cell.d.ts +19 -0
- package/dist/deck-cell.js +1 -0
- package/dist/deck-checklist.d.ts +20 -0
- package/dist/deck-checklist.js +1 -0
- package/dist/{layouts/deck-cover.d.ts → deck-cover.d.ts} +8 -0
- package/dist/deck-cover.js +9 -6
- package/dist/deck-csv.d.ts +38 -0
- package/dist/deck-csv.js +15 -0
- package/dist/deck-feature-cards.js +2 -2
- package/dist/deck-feature.d.ts +18 -0
- package/dist/deck-feature.js +2 -2
- package/dist/deck-figure.d.ts +26 -0
- package/dist/deck-figure.js +8 -0
- package/dist/deck-fit.d.ts +14 -0
- package/dist/deck-fit.js +1 -0
- package/dist/deck-flow.d.ts +41 -0
- package/dist/deck-flow.js +7 -0
- package/dist/deck-graph.d.ts +92 -0
- package/dist/deck-graph.js +25 -0
- package/dist/deck-grid.js +1 -1
- package/dist/deck-icon.d.ts +20 -0
- package/dist/deck-icon.js +1 -0
- package/dist/{atoms/deck-kicker.d.ts → deck-kicker.d.ts} +5 -0
- package/dist/deck-kicker.js +1 -1
- package/dist/deck-kpi-grid.d.ts +26 -0
- package/dist/deck-kpi-grid.js +4 -0
- package/dist/deck-link.d.ts +21 -0
- package/dist/deck-link.js +1 -0
- package/dist/{molecules/deck-md.d.ts → deck-md.d.ts} +3 -0
- package/dist/deck-md.js +8 -3
- package/dist/deck-mermaid.js +15 -3
- package/dist/deck-outline.d.ts +50 -0
- package/dist/deck-outline.js +1 -0
- package/dist/deck-overview.js +53 -39
- package/dist/deck-persona.d.ts +31 -0
- package/dist/deck-persona.js +6 -0
- package/dist/deck-photo.js +1 -1
- package/dist/deck-point.d.ts +22 -0
- package/dist/deck-point.js +1 -0
- package/dist/deck-presenter.js +120 -48
- package/dist/deck-pull.d.ts +13 -0
- package/dist/deck-pull.js +1 -0
- package/dist/{atoms/deck-punch.d.ts → deck-punch.d.ts} +6 -0
- package/dist/deck-punch.js +1 -1
- package/dist/deck-quote.d.ts +28 -0
- package/dist/deck-quote.js +6 -0
- package/dist/{runtime/deck-root.d.ts → deck-root.d.ts} +88 -8
- package/dist/deck-root.js +17 -13
- package/dist/deck-section.js +2 -2
- package/dist/deck-source.d.ts +12 -0
- package/dist/deck-source.js +2 -0
- package/dist/deck-split.d.ts +30 -0
- package/dist/deck-split.js +5 -3
- package/dist/{molecules/deck-stat.d.ts → deck-stat.d.ts} +2 -0
- package/dist/deck-stat.js +2 -2
- package/dist/{molecules/deck-step-list.d.ts → deck-step-list.d.ts} +8 -0
- package/dist/deck-step-list.js +4 -2
- package/dist/deck-table.d.ts +26 -0
- package/dist/deck-table.js +1 -0
- package/dist/deck-takeaway.d.ts +18 -0
- package/dist/deck-takeaway.js +2 -2
- package/dist/deck-timeline.d.ts +37 -0
- package/dist/deck-timeline.js +5 -0
- package/dist/deck-transition.js +3 -3
- package/dist/deck-versus.d.ts +18 -0
- package/dist/deck-versus.js +9 -0
- package/dist/deep-link.d.ts +29 -0
- package/dist/deep-link.js +1 -0
- package/dist/escape-html.d.ts +3 -0
- package/dist/escape-html.js +1 -0
- package/dist/fit-controller.d.ts +27 -0
- package/dist/fit-controller.js +1 -0
- package/dist/graph-layout.d.ts +35 -0
- package/dist/graph-layout.js +1 -0
- package/dist/grid-tracks.d.ts +17 -0
- package/dist/grid-tracks.js +1 -0
- package/dist/icon-set.d.ts +6 -0
- package/dist/icon-set.js +1 -0
- package/dist/index.d.ts +37 -31
- package/dist/index.js +95 -49
- package/dist/keymap.d.ts +40 -0
- package/dist/keymap.js +1 -0
- package/dist/mouse-nav.d.ts +12 -0
- package/dist/mouse-nav.js +1 -0
- package/dist/navigation.d.ts +25 -0
- package/dist/navigation.js +1 -0
- package/dist/parse-csv.d.ts +9 -0
- package/dist/parse-csv.js +3 -0
- package/dist/shared-styles.js +1 -1
- package/dist/shiki.d.ts +8 -0
- package/dist/signature.d.ts +2 -0
- package/dist/signature.js +1 -0
- package/dist/slide-fill.d.ts +8 -0
- package/dist/slide-fill.js +1 -0
- package/dist/standalone.js +301 -169
- package/dist/vendor/THIRD-PARTY-NOTICES.txt +4347 -0
- package/dist/vendor/inventory.json +3029 -0
- package/dist/vendor/lit.js +62 -2
- package/dist/vendor/mermaid.min.js +95 -95
- package/dist/vendor/shiki.js +1 -57
- package/dist/viewport.d.ts +42 -0
- package/dist/viewport.js +1 -0
- package/docs/llms/rikiki-reference.md +955 -64
- package/docs/llms/rikiki-workflow.md +536 -0
- package/llms.txt +39 -12
- package/package.json +33 -12
- package/themes/rikiki.css +173 -47
- package/themes/siliceum.css +171 -51
- package/dist/layouts/deck-feature.d.ts +0 -11
- package/dist/layouts/deck-split.d.ts +0 -18
- package/dist/layouts/deck-takeaway.d.ts +0 -11
- package/dist/plugins/shiki.d.ts +0 -8
- /package/dist/{runtime/color.d.ts → color.d.ts} +0 -0
- /package/dist/{atoms/deck-badge.d.ts → deck-badge.d.ts} +0 -0
- /package/dist/{molecules/deck-card.d.ts → deck-card.d.ts} +0 -0
- /package/dist/{atoms/deck-code-highlighter.d.ts → deck-code-highlighter.d.ts} +0 -0
- /package/dist/{atoms/deck-code.d.ts → deck-code.d.ts} +0 -0
- /package/dist/{layouts/deck-feature-cards.d.ts → deck-feature-cards.d.ts} +0 -0
- /package/dist/{molecules/deck-grid.d.ts → deck-grid.d.ts} +0 -0
- /package/dist/{runtime/deck-help.d.ts → deck-help.d.ts} +0 -0
- /package/dist/{molecules/deck-mermaid.d.ts → deck-mermaid.d.ts} +0 -0
- /package/dist/{molecules/deck-metric.d.ts → deck-metric.d.ts} +0 -0
- /package/dist/{runtime/deck-notes.d.ts → deck-notes.d.ts} +0 -0
- /package/dist/{runtime/deck-overview.d.ts → deck-overview.d.ts} +0 -0
- /package/dist/{layouts/deck-photo.d.ts → deck-photo.d.ts} +0 -0
- /package/dist/{runtime/deck-presenter.d.ts → deck-presenter.d.ts} +0 -0
- /package/dist/{layouts/deck-section.d.ts → deck-section.d.ts} +0 -0
- /package/dist/{molecules/deck-shortcut.d.ts → deck-shortcut.d.ts} +0 -0
- /package/dist/{molecules/deck-stack.d.ts → deck-stack.d.ts} +0 -0
- /package/dist/{molecules/deck-tier-list.d.ts → deck-tier-list.d.ts} +0 -0
- /package/dist/{runtime/deck-transition.d.ts → deck-transition.d.ts} +0 -0
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
// ════════════════════════════════════════════════════════════════
|
|
2
|
+
// The browser rikiki drives, and the server it drives it against.
|
|
3
|
+
//
|
|
4
|
+
// `export`, `render` and `check` all need the same five things: a Playwright
|
|
5
|
+
// that is loaded only when used, a local server on a free port, a deck settled
|
|
6
|
+
// enough to look at, the errors the page reported along the way, and both
|
|
7
|
+
// resources closed whatever happens. This module owns all five so the commands
|
|
8
|
+
// own none of them.
|
|
9
|
+
// ════════════════════════════════════════════════════════════════
|
|
10
|
+
|
|
11
|
+
import { readFile, stat } from 'node:fs/promises';
|
|
12
|
+
import { readFileSync } from 'node:fs';
|
|
13
|
+
import { createServer } from 'node:http';
|
|
14
|
+
import { dirname, extname, join, relative, resolve, sep } from 'node:path';
|
|
15
|
+
import { ExpectedError } from './cli-error.mjs';
|
|
16
|
+
|
|
17
|
+
const TYPES = {
|
|
18
|
+
'.html': 'text/html; charset=utf-8',
|
|
19
|
+
'.js': 'text/javascript; charset=utf-8',
|
|
20
|
+
'.mjs': 'text/javascript; charset=utf-8',
|
|
21
|
+
'.css': 'text/css; charset=utf-8',
|
|
22
|
+
'.json': 'application/json; charset=utf-8',
|
|
23
|
+
'.md': 'text/markdown; charset=utf-8',
|
|
24
|
+
'.svg': 'image/svg+xml',
|
|
25
|
+
'.png': 'image/png',
|
|
26
|
+
'.jpg': 'image/jpeg',
|
|
27
|
+
'.jpeg': 'image/jpeg',
|
|
28
|
+
'.gif': 'image/gif',
|
|
29
|
+
'.webp': 'image/webp',
|
|
30
|
+
'.avif': 'image/avif',
|
|
31
|
+
'.woff2': 'font/woff2',
|
|
32
|
+
'.woff': 'font/woff',
|
|
33
|
+
'.ttf': 'font/ttf',
|
|
34
|
+
'.otf': 'font/otf',
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/** Playwright is an optional peer · loaded on first use, never at import. */
|
|
38
|
+
export async function loadChromium() {
|
|
39
|
+
for (const pkg of ['playwright', 'playwright-core', '@playwright/test']) {
|
|
40
|
+
try {
|
|
41
|
+
const mod = await import(pkg);
|
|
42
|
+
if (mod.chromium) return mod.chromium;
|
|
43
|
+
} catch {
|
|
44
|
+
// Try the next one · only the last failure is worth reporting.
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
throw new ExpectedError(
|
|
48
|
+
'this command needs Playwright, which is an optional peer dependency.\n' +
|
|
49
|
+
' Install it next to rikiki-deck: npm i -D playwright && npx playwright install chromium\n' +
|
|
50
|
+
' (it is optional so that decks which only run in your own browser do not\n' +
|
|
51
|
+
' have to download one)',
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Launch the full Chromium build rather than Playwright's default headless shell.
|
|
57
|
+
*
|
|
58
|
+
* The shell positions glyphs differently: a paragraph that wraps onto six lines
|
|
59
|
+
* in Chrome can fit on five there, so a card that spills in every real browser
|
|
60
|
+
* measured clean and `CONTENT_ESCAPES_BOX` stayed silent. The full build wraps
|
|
61
|
+
* like the browser the deck is actually opened in. When it is not installed the
|
|
62
|
+
* default build still runs, and `note` says the measurements may be off.
|
|
63
|
+
*/
|
|
64
|
+
export async function launchChromium(chromium, note = (line) => console.error(line)) {
|
|
65
|
+
try {
|
|
66
|
+
return await chromium.launch({ channel: 'chromium' });
|
|
67
|
+
} catch (e) {
|
|
68
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
69
|
+
if (!message.includes("Executable doesn't exist")) throw e;
|
|
70
|
+
note(
|
|
71
|
+
'rikiki · note · the full Chromium build is not installed · falling back to the headless shell, ' +
|
|
72
|
+
'whose text wrapping differs from a real browser · run `npx playwright install chromium`',
|
|
73
|
+
);
|
|
74
|
+
return chromium.launch();
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** True when `abs` is inside `root` · `/srv/deck` must not admit `/srv/deck-x`.
|
|
79
|
+
* Exported because a path check nobody can test is a path check nobody trusts. */
|
|
80
|
+
export function isInside(root, abs) {
|
|
81
|
+
return abs === root || abs.startsWith(root.endsWith(sep) ? root : root + sep);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** How far above its own folder a deck reaches.
|
|
85
|
+
*
|
|
86
|
+
* A served deck points at the framework with `../../dist/index.js`, so serving
|
|
87
|
+
* only the deck's directory 404s every module and the page never upgrades.
|
|
88
|
+
* Counting the deepest `../` prefix gives the smallest root that still
|
|
89
|
+
* contains everything the deck asks for · no wider than necessary. */
|
|
90
|
+
export function rootDepthFor(html) {
|
|
91
|
+
let depth = 0;
|
|
92
|
+
for (const m of html.matchAll(/(?:src|href)\s*=\s*["']((?:\.\.\/)+)/g)) {
|
|
93
|
+
depth = Math.max(depth, m[1].split('../').length - 1);
|
|
94
|
+
}
|
|
95
|
+
return depth;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Serve `rootDir` on an ephemeral port · ES modules need http://, not file://.
|
|
99
|
+
* Resolves to `{ origin, close }`. */
|
|
100
|
+
export async function serveDir(rootDir) {
|
|
101
|
+
const root = resolve(rootDir);
|
|
102
|
+
const server = createServer(async (req, res) => {
|
|
103
|
+
let abs;
|
|
104
|
+
try {
|
|
105
|
+
const rel = decodeURIComponent((req.url ?? '/').split('?')[0]);
|
|
106
|
+
abs = resolve(root, '.' + rel);
|
|
107
|
+
} catch {
|
|
108
|
+
// A malformed percent-escape is a bad request, not a missing file.
|
|
109
|
+
res.writeHead(400, { 'content-type': 'text/plain' }).end('bad request');
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
if (!isInside(root, abs)) {
|
|
113
|
+
res.writeHead(403, { 'content-type': 'text/plain' }).end('forbidden');
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
try {
|
|
117
|
+
const info = await stat(abs);
|
|
118
|
+
const file = info.isDirectory() ? join(abs, 'index.html') : abs;
|
|
119
|
+
const body = await readFile(file);
|
|
120
|
+
res.writeHead(200, { 'content-type': TYPES[extname(file)] ?? 'application/octet-stream' });
|
|
121
|
+
res.end(body);
|
|
122
|
+
} catch (cause) {
|
|
123
|
+
res.writeHead(404, { 'content-type': 'text/plain' }).end(`not found: ${req.url}`);
|
|
124
|
+
// Reported to the caller through the page's own failed requests, never
|
|
125
|
+
// swallowed here.
|
|
126
|
+
if (process.env.RIKIKI_DEBUG) console.error('rikiki · 404', req.url, String(cause));
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
await new Promise((ok) => server.listen(0, '127.0.0.1', ok));
|
|
130
|
+
const { port } = server.address();
|
|
131
|
+
return {
|
|
132
|
+
origin: `http://127.0.0.1:${port}`,
|
|
133
|
+
close: () => new Promise((ok) => server.close(ok)),
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** The URL a deck file takes once its smallest containing root is served. */
|
|
138
|
+
export function deckLocation(deckPath) {
|
|
139
|
+
const abs = resolve(deckPath);
|
|
140
|
+
const depth = rootDepthFor(readFileSync(abs, 'utf8'));
|
|
141
|
+
const rootDir = resolve(dirname(abs), ...Array(depth).fill('..'));
|
|
142
|
+
return { rootDir, urlPath: relative(rootDir, abs).split(sep).join('/') };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** The title of a slide, as a line of text.
|
|
146
|
+
*
|
|
147
|
+
* Runs in the page. `textContent` alone joins across a `<br>`, which is how a
|
|
148
|
+
* two-line title became "Clickstages" in a manifest whose whole job is to name
|
|
149
|
+
* the slide you are looking at. */
|
|
150
|
+
export const SLIDE_TITLE_READER = `(el) => {
|
|
151
|
+
const source = el.querySelector('h1, [slot="title"]');
|
|
152
|
+
if (!source) return null;
|
|
153
|
+
const copy = source.cloneNode(true);
|
|
154
|
+
for (const br of copy.querySelectorAll('br')) br.replaceWith(' ');
|
|
155
|
+
return copy.textContent.trim().replace(/\\s+/g, ' ') || null;
|
|
156
|
+
}`;
|
|
157
|
+
|
|
158
|
+
/** Wait until nothing is moving any more.
|
|
159
|
+
*
|
|
160
|
+
* A reveal is a CSS transition, and a screenshot taken while it runs catches
|
|
161
|
+
* the text mid-fade. The Web Animations API knows when each one is done, so
|
|
162
|
+
* the wait is on the animations themselves; the deadline is only there for an
|
|
163
|
+
* animation that never ends (a looping accent, a spinner). */
|
|
164
|
+
export async function waitForStillFrame(page, deadlineMs = 2_000) {
|
|
165
|
+
await page
|
|
166
|
+
.evaluate(async (ms) => {
|
|
167
|
+
const ending = document
|
|
168
|
+
.getAnimations()
|
|
169
|
+
.filter((a) => a.effect?.getComputedTiming?.().iterations !== Number.POSITIVE_INFINITY)
|
|
170
|
+
.map((a) => a.finished.catch(() => {}));
|
|
171
|
+
await Promise.race([
|
|
172
|
+
Promise.all(ending),
|
|
173
|
+
new Promise((done) => setTimeout(done, ms)),
|
|
174
|
+
]);
|
|
175
|
+
// One painted frame after the last change · the screenshot reads what the
|
|
176
|
+
// eye would see, not the state the compositor is still catching up with.
|
|
177
|
+
await new Promise((done) => requestAnimationFrame(() => requestAnimationFrame(done)));
|
|
178
|
+
}, deadlineMs)
|
|
179
|
+
.catch(() => {});
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** How long a deck may take to load and show its first slide. Generous on
|
|
183
|
+
* purpose: a deck loads in well under a second, the margin is for a loaded
|
|
184
|
+
* machine, and a deck that really never loads is reported, not retried. */
|
|
185
|
+
export const PAGE_LOAD_TIMEOUT_MS = 60_000;
|
|
186
|
+
|
|
187
|
+
/** How long a slide change may take before the walk gives up on it. A deck
|
|
188
|
+
* switches slides in milliseconds; the margin is for a loaded machine (a
|
|
189
|
+
* single-core CI runner took over 5 s once), not for the deck. */
|
|
190
|
+
export const NAVIGATION_TIMEOUT_MS = 15_000;
|
|
191
|
+
|
|
192
|
+
/** Go to slide `index` (1-based) and report the state actually reached.
|
|
193
|
+
* Shared by `render` and `check` · both walk a deck the same way. */
|
|
194
|
+
export async function goToSlide(page, index) {
|
|
195
|
+
await page.evaluate((i) => {
|
|
196
|
+
window.location.hash = `#${i}`;
|
|
197
|
+
}, index);
|
|
198
|
+
await page.waitForFunction(
|
|
199
|
+
(i) => document.querySelector('deck-root')?.current === i - 1,
|
|
200
|
+
index,
|
|
201
|
+
{ timeout: NAVIGATION_TIMEOUT_MS },
|
|
202
|
+
);
|
|
203
|
+
await page.evaluate(() => document.fonts.ready);
|
|
204
|
+
await waitForStillFrame(page);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** Advance one step inside the current slide · false when there is none left
|
|
208
|
+
* (either the last state of the deck, or the step moved on to the next
|
|
209
|
+
* slide). */
|
|
210
|
+
export async function advanceStep(page) {
|
|
211
|
+
const before = await page.evaluate(() => {
|
|
212
|
+
const root = document.querySelector('deck-root');
|
|
213
|
+
return { slide: root.current, step: root.step };
|
|
214
|
+
});
|
|
215
|
+
await page.keyboard.press('ArrowRight');
|
|
216
|
+
try {
|
|
217
|
+
await page.waitForFunction(
|
|
218
|
+
(b) => {
|
|
219
|
+
const root = document.querySelector('deck-root');
|
|
220
|
+
return root.current !== b.slide || root.step !== b.step;
|
|
221
|
+
},
|
|
222
|
+
before,
|
|
223
|
+
{ timeout: 2_000 },
|
|
224
|
+
);
|
|
225
|
+
} catch {
|
|
226
|
+
return false; // the deck did not move · this was the last state
|
|
227
|
+
}
|
|
228
|
+
await waitForStillFrame(page);
|
|
229
|
+
const after = await page.evaluate(() => {
|
|
230
|
+
const root = document.querySelector('deck-root');
|
|
231
|
+
return { slide: root.current, step: root.step };
|
|
232
|
+
});
|
|
233
|
+
return after.slide === before.slide;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** Wait for the deck to be worth looking at: upgraded, on a slide, fonts and
|
|
237
|
+
* diagrams settled. Returns false when it never got there. */
|
|
238
|
+
async function settle(page, timeoutMs) {
|
|
239
|
+
try {
|
|
240
|
+
await page.waitForFunction(() => !!document.querySelector('deck-root > [active]'), null, {
|
|
241
|
+
timeout: timeoutMs,
|
|
242
|
+
});
|
|
243
|
+
} catch {
|
|
244
|
+
return false;
|
|
245
|
+
}
|
|
246
|
+
// A diagram still rendering photographs as an empty box, and a font still
|
|
247
|
+
// loading shifts every line · both are worth the wait, neither is worth
|
|
248
|
+
// failing over.
|
|
249
|
+
await page
|
|
250
|
+
.evaluate(async () => {
|
|
251
|
+
await document.fonts.ready;
|
|
252
|
+
const diagrams = Array.from(document.querySelectorAll('deck-mermaid'));
|
|
253
|
+
await Promise.all(diagrams.map((d) => d.whenRendered ?? Promise.resolve()));
|
|
254
|
+
})
|
|
255
|
+
.catch(() => {});
|
|
256
|
+
await waitForStillFrame(page);
|
|
257
|
+
return true;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Open a deck in a real browser and hand it to `fn`.
|
|
262
|
+
*
|
|
263
|
+
* `fn` receives `{ page, browser, origin, url, settled, missing, errors }`, where
|
|
264
|
+
* `missing` lists the requests the page could not load and `errors` the
|
|
265
|
+
* exceptions it threw. The browser and the server are closed on the way out,
|
|
266
|
+
* including when `fn` throws.
|
|
267
|
+
*
|
|
268
|
+
* @returns {Promise<*>} whatever `fn` returns.
|
|
269
|
+
*/
|
|
270
|
+
export async function withDeck(deckPath, fn, { timeoutMs = PAGE_LOAD_TIMEOUT_MS, viewport } = {}) {
|
|
271
|
+
const chromium = await loadChromium();
|
|
272
|
+
const { rootDir, urlPath } = deckLocation(deckPath);
|
|
273
|
+
const server = await serveDir(rootDir);
|
|
274
|
+
const browser = await launchChromium(chromium);
|
|
275
|
+
const missing = [];
|
|
276
|
+
const errors = [];
|
|
277
|
+
|
|
278
|
+
try {
|
|
279
|
+
const page = await browser.newPage(viewport ? { viewport } : {});
|
|
280
|
+
page.on('requestfailed', (r) => missing.push(r.url()));
|
|
281
|
+
page.on('response', (r) => {
|
|
282
|
+
if (r.status() >= 400) missing.push(`${r.url()} (HTTP ${r.status()})`);
|
|
283
|
+
});
|
|
284
|
+
page.on('pageerror', (e) => errors.push(e instanceof Error ? e.message : String(e)));
|
|
285
|
+
|
|
286
|
+
const url = `${server.origin}/${urlPath}`;
|
|
287
|
+
let loaded = true;
|
|
288
|
+
try {
|
|
289
|
+
await page.goto(url, { waitUntil: 'load', timeout: timeoutMs });
|
|
290
|
+
} catch (e) {
|
|
291
|
+
// A deck that will not even load is a result to report, not a crash: the
|
|
292
|
+
// caller turns it into a diagnostic.
|
|
293
|
+
loaded = false;
|
|
294
|
+
errors.push(e instanceof Error ? e.message : String(e));
|
|
295
|
+
}
|
|
296
|
+
const settled = loaded && (await settle(page, timeoutMs));
|
|
297
|
+
return await fn({ page, browser, origin: server.origin, url, settled, missing, errors });
|
|
298
|
+
} finally {
|
|
299
|
+
await browser.close().catch(() => {});
|
|
300
|
+
await server.close().catch(() => {});
|
|
301
|
+
}
|
|
302
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
export interface CheckFinding {
|
|
2
|
+
element?: Element;
|
|
3
|
+
key?: string;
|
|
4
|
+
message: string;
|
|
5
|
+
suggestion?: string;
|
|
6
|
+
measurement?: Record<string, unknown>;
|
|
7
|
+
}
|
|
8
|
+
export interface CheckContext {
|
|
9
|
+
readonly slide: number;
|
|
10
|
+
readonly state: number;
|
|
11
|
+
readonly profile: string;
|
|
12
|
+
query<T extends Element = HTMLElement>(selector: string, options?: { shadow?: boolean }): T[];
|
|
13
|
+
report(finding: CheckFinding): void;
|
|
14
|
+
}
|
|
15
|
+
export interface CheckRule {
|
|
16
|
+
code: string;
|
|
17
|
+
scope: 'document' | 'slide' | 'state';
|
|
18
|
+
severity: 'error' | 'warning';
|
|
19
|
+
profiles?: string[];
|
|
20
|
+
run(ctx: CheckContext): void | Promise<void>;
|
|
21
|
+
}
|
|
22
|
+
export interface CheckPlugin {
|
|
23
|
+
apiVersion: 1;
|
|
24
|
+
id: string;
|
|
25
|
+
version: string;
|
|
26
|
+
rules: CheckRule[];
|
|
27
|
+
}
|
|
28
|
+
export function defineChecks(plugin: CheckPlugin): void;
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/** Register only while `rikiki check` has installed its browser bridge. */
|
|
2
|
+
export function defineChecks(plugin) {
|
|
3
|
+
const bridge = globalThis[Symbol.for('rikiki.checks.v1')];
|
|
4
|
+
if (!bridge) throw new Error('CheckPlugin must be loaded by rikiki check (API 1)');
|
|
5
|
+
bridge.define(plugin);
|
|
6
|
+
}
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
import { existsSync, readFileSync, realpathSync } from 'node:fs';
|
|
2
|
+
import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
|
|
3
|
+
import { createRequire } from 'node:module';
|
|
4
|
+
import { pathToFileURL } from 'node:url';
|
|
5
|
+
|
|
6
|
+
export const CHECK_API_VERSION = 1;
|
|
7
|
+
const failure = (plugin, code, message) => ({ plugin, code, severity: 'error', message });
|
|
8
|
+
|
|
9
|
+
export function findCheckConfig(deckPath, explicit) {
|
|
10
|
+
if (explicit) return resolve(explicit);
|
|
11
|
+
let dir = dirname(resolve(deckPath));
|
|
12
|
+
for (;;) {
|
|
13
|
+
const file = join(dir, 'rikiki.config.json');
|
|
14
|
+
if (existsSync(file)) return file;
|
|
15
|
+
const parent = dirname(dir);
|
|
16
|
+
if (parent === dir) return null;
|
|
17
|
+
dir = parent;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Data only: no dependency's Node entry point is evaluated. */
|
|
22
|
+
export function resolveCheckPlugins(deckPath, { config, plugins = [], noPlugins = false } = {}) {
|
|
23
|
+
const result = { plugins: [], diagnostics: [], notChecked: [], config: {} };
|
|
24
|
+
if (noPlugins) { result.notChecked.push('plugin checks · explicitly disabled'); return result; }
|
|
25
|
+
let file;
|
|
26
|
+
try {
|
|
27
|
+
file = findCheckConfig(deckPath, config);
|
|
28
|
+
result.config = file ? JSON.parse(readFileSync(file, 'utf8')) : {};
|
|
29
|
+
if (!result.config || typeof result.config !== 'object' || Array.isArray(result.config)
|
|
30
|
+
|| (result.config.plugins !== undefined && !Array.isArray(result.config.plugins))) throw new Error('plugins must be an array');
|
|
31
|
+
} catch (error) {
|
|
32
|
+
result.diagnostics.push(failure('rikiki', 'CHECK_CONFIG_INVALID', `Cannot load check configuration: ${error.message}`));
|
|
33
|
+
result.notChecked.push('plugin checks · invalid configuration');
|
|
34
|
+
return result;
|
|
35
|
+
}
|
|
36
|
+
const base = file ? dirname(file) : dirname(resolve(deckPath));
|
|
37
|
+
const require = createRequire(pathToFileURL(join(base, 'package.json')));
|
|
38
|
+
const seen = new Map();
|
|
39
|
+
const namespaces = new Set();
|
|
40
|
+
for (const entry of [...(result.config.plugins ?? []), ...plugins]) {
|
|
41
|
+
const spec = typeof entry === 'string' ? { package: entry } : entry;
|
|
42
|
+
let id = spec?.package ?? spec?.manifest ?? 'unknown';
|
|
43
|
+
try {
|
|
44
|
+
if (!spec || typeof spec !== 'object') throw new Error('expected a package name or a plugin descriptor');
|
|
45
|
+
const manifestPath = spec.manifest
|
|
46
|
+
? resolve(base, spec.manifest)
|
|
47
|
+
: require.resolve(`${spec.package}/rikiki.module.json`);
|
|
48
|
+
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
|
|
49
|
+
id = manifest.id;
|
|
50
|
+
if (manifest.schemaVersion !== 1 || typeof id !== 'string' || !/^[a-zA-Z0-9@/._-]+$/.test(id)
|
|
51
|
+
|| typeof manifest.version !== 'string' || !/^[A-Z][A-Z0-9_]*$/.test(manifest.namespace ?? '')
|
|
52
|
+
|| manifest.checks?.apiVersion !== CHECK_API_VERSION) throw new Error('unsupported module schema or check API');
|
|
53
|
+
const profile = spec.profile ?? 'recommended';
|
|
54
|
+
if (!manifest.checks.profiles?.includes(profile)) throw new Error(`unknown profile: ${profile}`);
|
|
55
|
+
const entryPath = manifest.checks.entry;
|
|
56
|
+
if (typeof entryPath !== 'string' || isAbsolute(entryPath)) throw new Error('checks.entry must be package-relative');
|
|
57
|
+
const root = realpathSync(dirname(manifestPath));
|
|
58
|
+
const script = realpathSync(resolve(root, entryPath));
|
|
59
|
+
const rel = relative(root, script);
|
|
60
|
+
if (rel.startsWith('..') || isAbsolute(rel)) throw new Error('checks.entry escapes the module directory');
|
|
61
|
+
const identity = JSON.stringify([realpathSync(manifestPath), manifest.version, profile]);
|
|
62
|
+
if (seen.has(id)) {
|
|
63
|
+
if (seen.get(id) !== identity) throw new Error(`conflicting versions or profiles of ${id}`);
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
if (namespaces.has(manifest.namespace)) throw new Error(`duplicate namespace ${manifest.namespace}`);
|
|
67
|
+
seen.set(id, identity);
|
|
68
|
+
namespaces.add(manifest.namespace);
|
|
69
|
+
const skills = (manifest.skills ?? []).map(skill => {
|
|
70
|
+
if (!skill || !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(skill.name) || typeof skill.path !== 'string') throw new Error('invalid skill descriptor');
|
|
71
|
+
const src = realpathSync(resolve(root, skill.path));
|
|
72
|
+
const rel = relative(root, src);
|
|
73
|
+
if (rel.startsWith('..') || isAbsolute(rel) || !existsSync(join(src, 'SKILL.md'))) throw new Error('invalid skill path');
|
|
74
|
+
return { name: skill.name, src };
|
|
75
|
+
});
|
|
76
|
+
result.plugins.push({ id, version: manifest.version, namespace: manifest.namespace, apiVersion: CHECK_API_VERSION,
|
|
77
|
+
profile, script, skills, status: 'pending', rules: [], executed: [] });
|
|
78
|
+
} catch (error) {
|
|
79
|
+
result.diagnostics.push(failure(String(id), 'CHECK_PLUGIN_LOAD_FAILED', `Cannot load ${id}: ${error.message}`));
|
|
80
|
+
result.notChecked.push(`plugin ${id} · loading failed`);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
if (!result.plugins.length && !result.diagnostics.length) result.notChecked.push('plugin checks · no plugin configured');
|
|
84
|
+
return result;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Serialized into the browser. This registry has no imports or component side effects. */
|
|
88
|
+
export function installCheckBridge() {
|
|
89
|
+
const registered = new Map();
|
|
90
|
+
const key = Symbol.for('rikiki.checks.v1');
|
|
91
|
+
const query = (root, selector, shadow = false) => {
|
|
92
|
+
const found = [...root.querySelectorAll(selector)];
|
|
93
|
+
if (root.matches?.(selector)) found.unshift(root);
|
|
94
|
+
if (shadow) for (const el of root.querySelectorAll('*')) {
|
|
95
|
+
if (el.shadowRoot) found.push(...query(el.shadowRoot, selector, true));
|
|
96
|
+
}
|
|
97
|
+
return [...new Set(found)];
|
|
98
|
+
};
|
|
99
|
+
const pathOf = (element) => {
|
|
100
|
+
const parts = [];
|
|
101
|
+
for (let el = element; el && el !== document.body;) {
|
|
102
|
+
const tag = el.localName;
|
|
103
|
+
if (!tag) break;
|
|
104
|
+
const siblings = el.parentElement ? [...el.parentElement.children].filter(n => n.localName === tag) : [];
|
|
105
|
+
parts.unshift(el.id ? `${tag}#${CSS.escape(el.id)}` : `${tag}:nth-of-type(${Math.max(1, siblings.indexOf(el) + 1)})`);
|
|
106
|
+
const host = el.getRootNode().host;
|
|
107
|
+
if (!el.parentElement && host) { parts.unshift('::shadow'); el = host; }
|
|
108
|
+
else el = el.parentElement;
|
|
109
|
+
}
|
|
110
|
+
return parts.join(' > ');
|
|
111
|
+
};
|
|
112
|
+
const api = {
|
|
113
|
+
define(plugin) {
|
|
114
|
+
if (plugin.apiVersion !== 1 || !plugin.id || !plugin.version || !Array.isArray(plugin.rules)) throw new Error('Invalid CheckPlugin');
|
|
115
|
+
if (registered.has(plugin.id)) throw new Error(`Duplicate CheckPlugin: ${plugin.id}`);
|
|
116
|
+
const codes = new Set();
|
|
117
|
+
for (const rule of plugin.rules) {
|
|
118
|
+
if (!/^[A-Z][A-Z0-9_]+$/.test(rule.code) || codes.has(rule.code) || typeof rule.run !== 'function'
|
|
119
|
+
|| !['document', 'slide', 'state'].includes(rule.scope) || !['error', 'warning'].includes(rule.severity)) throw new Error('Invalid or duplicate check rule');
|
|
120
|
+
codes.add(rule.code);
|
|
121
|
+
}
|
|
122
|
+
registered.set(plugin.id, plugin);
|
|
123
|
+
},
|
|
124
|
+
describe(id) {
|
|
125
|
+
const p = registered.get(id);
|
|
126
|
+
return p ? { id: p.id, version: p.version, apiVersion: p.apiVersion,
|
|
127
|
+
rules: p.rules.map(({ code, scope, severity, profiles }) => ({ code, scope, severity, profiles })) } : null;
|
|
128
|
+
},
|
|
129
|
+
async run({ id, profile, slide, state, documentPass }) {
|
|
130
|
+
const plugin = registered.get(id);
|
|
131
|
+
const root = document.querySelector('deck-root');
|
|
132
|
+
const slides = root ? [...root.children].filter(e => e.localName.startsWith('deck-') && e.localName !== 'deck-root') : [];
|
|
133
|
+
const active = slides[slide - 1];
|
|
134
|
+
const out = [], executed = [], failures = [];
|
|
135
|
+
for (const rule of plugin.rules) {
|
|
136
|
+
if (rule.profiles && !rule.profiles.includes(profile)) continue;
|
|
137
|
+
if (rule.scope === 'document' ? !documentPass : rule.scope === 'slide' ? state !== 0 || !active : !active) continue;
|
|
138
|
+
const scope = rule.scope === 'document' ? root ?? document : active;
|
|
139
|
+
try {
|
|
140
|
+
for (const el of query(scope, '*', true)) if (el.updateComplete) await el.updateComplete;
|
|
141
|
+
await rule.run({ slide, state, profile,
|
|
142
|
+
query: (selector, options = {}) => query(scope, selector, options.shadow ?? false),
|
|
143
|
+
report: (finding) => {
|
|
144
|
+
if (!finding || typeof finding.message !== 'string' || !finding.message.trim()
|
|
145
|
+
|| (finding.key !== undefined && typeof finding.key !== 'string')
|
|
146
|
+
|| (finding.suggestion !== undefined && typeof finding.suggestion !== 'string')
|
|
147
|
+
|| (finding.element !== undefined && !(finding.element instanceof Element))) throw new Error('Invalid diagnostic');
|
|
148
|
+
const el = finding.element;
|
|
149
|
+
const owner = el ? slides.findIndex(s => s === el || s.contains(el) || query(s, '*', true).includes(el)) : -1;
|
|
150
|
+
const measurement = finding.measurement === undefined ? undefined : JSON.parse(JSON.stringify(finding.measurement));
|
|
151
|
+
out.push({ code: rule.code, severity: rule.severity, message: finding.message, suggestion: finding.suggestion,
|
|
152
|
+
element: el ? pathOf(el) : undefined, key: finding.key,
|
|
153
|
+
slide: owner >= 0 ? owner + 1 : rule.scope !== 'document' ? slide : undefined,
|
|
154
|
+
state: rule.scope === 'document' ? undefined : state, measurement });
|
|
155
|
+
},
|
|
156
|
+
});
|
|
157
|
+
executed.push(rule.code);
|
|
158
|
+
} catch (error) { failures.push({ code: rule.code, message: String(error?.message ?? error) }); }
|
|
159
|
+
}
|
|
160
|
+
return { diagnostics: out, executed, failures };
|
|
161
|
+
},
|
|
162
|
+
};
|
|
163
|
+
Object.defineProperty(globalThis, key, { value: api, configurable: true });
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** A Node timer closes the page even when synchronous plugin code blocks its JS thread. */
|
|
167
|
+
async function bounded(page, operation, timeoutMs) {
|
|
168
|
+
let timer;
|
|
169
|
+
try {
|
|
170
|
+
return await Promise.race([operation(), new Promise((_, reject) => {
|
|
171
|
+
timer = setTimeout(() => {
|
|
172
|
+
reject(new Error('Check plugin execution timed out'));
|
|
173
|
+
void page.close({ runBeforeUnload: false }).catch(() => {});
|
|
174
|
+
}, timeoutMs);
|
|
175
|
+
})]);
|
|
176
|
+
} finally { clearTimeout(timer); }
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export async function startCheckPlugins(page, resolution, timeoutMs = 5000) {
|
|
180
|
+
if (!resolution.plugins.length) return;
|
|
181
|
+
await page.evaluate(installCheckBridge);
|
|
182
|
+
for (const plugin of resolution.plugins) {
|
|
183
|
+
try {
|
|
184
|
+
await bounded(page, () => page.addScriptTag({ content: readFileSync(plugin.script, 'utf8') }), timeoutMs);
|
|
185
|
+
const described = await bounded(page, () => page.evaluate(id => globalThis[Symbol.for('rikiki.checks.v1')].describe(id), plugin.id), timeoutMs);
|
|
186
|
+
if (!described || described.version !== plugin.version || described.apiVersion !== CHECK_API_VERSION
|
|
187
|
+
|| described.rules.some(r => !r.code.startsWith(plugin.namespace + '_'))) throw new Error('Registration does not match the manifest');
|
|
188
|
+
plugin.rules = described.rules.filter(r => !r.profiles || r.profiles.includes(plugin.profile));
|
|
189
|
+
plugin.status = 'loaded';
|
|
190
|
+
} catch (error) {
|
|
191
|
+
plugin.status = 'failed';
|
|
192
|
+
resolution.diagnostics.push(failure(plugin.id, 'CHECK_PLUGIN_LOAD_FAILED', error.message));
|
|
193
|
+
resolution.notChecked.push(`plugin ${plugin.id} · registration failed`);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export async function runCheckPlugins(page, resolution, { slide, state, documentPass }, timeoutMs = 5000) {
|
|
199
|
+
const diagnostics = [];
|
|
200
|
+
for (const plugin of resolution.plugins.filter(p => p.status === 'loaded')) {
|
|
201
|
+
try {
|
|
202
|
+
const result = await bounded(page, () => page.evaluate(args => globalThis[Symbol.for('rikiki.checks.v1')].run(args),
|
|
203
|
+
{ id: plugin.id, profile: plugin.profile, slide, state, documentPass }), timeoutMs);
|
|
204
|
+
if (!result || !Array.isArray(result.diagnostics) || !Array.isArray(result.failures) || !Array.isArray(result.executed)) throw new Error('Invalid plugin result');
|
|
205
|
+
for (const d of result.diagnostics) {
|
|
206
|
+
const rule = plugin.rules.find(r => r.code === d.code);
|
|
207
|
+
if (!rule || d.severity !== rule.severity || typeof d.message !== 'string') throw new Error('Invalid plugin diagnostic');
|
|
208
|
+
diagnostics.push({ ...d, plugin: plugin.id });
|
|
209
|
+
}
|
|
210
|
+
plugin.executed = [...new Set([...plugin.executed, ...result.executed])];
|
|
211
|
+
for (const err of result.failures) diagnostics.push(failure(plugin.id, 'CHECK_RULE_FAILED', `${err.code}: ${err.message}`));
|
|
212
|
+
if (result.failures.length) {
|
|
213
|
+
plugin.status = 'failed';
|
|
214
|
+
resolution.notChecked.push(`plugin ${plugin.id} · rule execution failed`);
|
|
215
|
+
}
|
|
216
|
+
} catch (error) {
|
|
217
|
+
plugin.status = 'failed';
|
|
218
|
+
diagnostics.push(failure(plugin.id, 'CHECK_PLUGIN_FAILED', error.message));
|
|
219
|
+
resolution.notChecked.push(`plugin ${plugin.id} · execution incomplete`);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
return diagnostics;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
export function pluginReport(resolution) {
|
|
226
|
+
return resolution.plugins.map(({ script, skills, ...plugin }) => ({ ...plugin,
|
|
227
|
+
status: plugin.status === 'loaded' ? 'completed' : plugin.status }));
|
|
228
|
+
}
|