rikiki-deck 0.7.1 → 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/README.md +1 -1
- package/bin/lib/browser.mjs +24 -1
- 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 +369 -14
- package/bin/lib/component-deps.mjs +69 -0
- package/bin/lib/export-pdf.mjs +28 -5
- package/bin/lib/inline.mjs +70 -35
- package/bin/lib/narrative.mjs +77 -0
- package/bin/lib/prune-icons.mjs +24 -1
- package/bin/rikiki.mjs +45 -7
- package/dist/{plugins/click-stages.d.ts → click-stages.d.ts} +1 -1
- package/dist/{layouts/deck-cover.d.ts → deck-cover.d.ts} +2 -0
- package/dist/deck-cover.js +9 -6
- package/dist/{atoms/deck-kicker.d.ts → deck-kicker.d.ts} +5 -0
- package/dist/deck-kicker.js +1 -1
- package/dist/deck-mermaid.js +4 -4
- package/dist/deck-overview.js +41 -40
- package/dist/deck-presenter.js +89 -46
- package/dist/{runtime/deck-root.d.ts → deck-root.d.ts} +14 -3
- package/dist/deck-root.js +5 -5
- package/dist/{extras/deck-timeline.d.ts → deck-timeline.d.ts} +6 -0
- package/dist/deck-timeline.js +4 -4
- package/dist/deck-versus.js +2 -2
- package/dist/index.d.ts +37 -37
- package/dist/index.js +55 -52
- package/dist/standalone.js +229 -182
- package/dist/vendor/THIRD-PARTY-NOTICES.txt +3 -3
- package/dist/vendor/inventory.json +7 -7
- package/dist/vendor/mermaid.min.js +95 -95
- package/docs/llms/rikiki-reference.md +31 -4
- package/llms.txt +1 -1
- package/package.json +13 -6
- package/dist/contrast.js +0 -1
- package/dist/shared/contrast.d.ts +0 -52
- /package/dist/{shared/annotation-marks.d.ts → annotation-marks.d.ts} +0 -0
- /package/dist/{shared/bar-segments.d.ts → bar-segments.d.ts} +0 -0
- /package/dist/{infrastructure/browser-location.d.ts → browser-location.d.ts} +0 -0
- /package/dist/{shared/cards-syntax.d.ts → cards-syntax.d.ts} +0 -0
- /package/dist/{runtime/color.d.ts → color.d.ts} +0 -0
- /package/dist/{extras/deck-agenda.d.ts → deck-agenda.d.ts} +0 -0
- /package/dist/{extras/deck-annotate.d.ts → deck-annotate.d.ts} +0 -0
- /package/dist/{atoms/deck-badge.d.ts → deck-badge.d.ts} +0 -0
- /package/dist/{extras/deck-bar.d.ts → deck-bar.d.ts} +0 -0
- /package/dist/{layouts/deck-bento.d.ts → deck-bento.d.ts} +0 -0
- /package/dist/{molecules/deck-callout.d.ts → deck-callout.d.ts} +0 -0
- /package/dist/{molecules/deck-card.d.ts → deck-card.d.ts} +0 -0
- /package/dist/{molecules/deck-cell.d.ts → deck-cell.d.ts} +0 -0
- /package/dist/{extras/deck-checklist.d.ts → deck-checklist.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/{molecules/deck-csv.d.ts → deck-csv.d.ts} +0 -0
- /package/dist/{layouts/deck-feature-cards.d.ts → deck-feature-cards.d.ts} +0 -0
- /package/dist/{layouts/deck-feature.d.ts → deck-feature.d.ts} +0 -0
- /package/dist/{extras/deck-figure.d.ts → deck-figure.d.ts} +0 -0
- /package/dist/{molecules/deck-fit.d.ts → deck-fit.d.ts} +0 -0
- /package/dist/{extras/deck-flow.d.ts → deck-flow.d.ts} +0 -0
- /package/dist/{extras/deck-graph.d.ts → deck-graph.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/{extras/deck-icon.d.ts → deck-icon.d.ts} +0 -0
- /package/dist/{extras/deck-kpi-grid.d.ts → deck-kpi-grid.d.ts} +0 -0
- /package/dist/{domain/deck-link.d.ts → deck-link.d.ts} +0 -0
- /package/dist/{molecules/deck-md.d.ts → deck-md.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/{domain/deck-outline.d.ts → deck-outline.d.ts} +0 -0
- /package/dist/{runtime/deck-overview.d.ts → deck-overview.d.ts} +0 -0
- /package/dist/{extras/deck-persona.d.ts → deck-persona.d.ts} +0 -0
- /package/dist/{layouts/deck-photo.d.ts → deck-photo.d.ts} +0 -0
- /package/dist/{molecules/deck-point.d.ts → deck-point.d.ts} +0 -0
- /package/dist/{runtime/deck-presenter.d.ts → deck-presenter.d.ts} +0 -0
- /package/dist/{extras/deck-pull.d.ts → deck-pull.d.ts} +0 -0
- /package/dist/{atoms/deck-punch.d.ts → deck-punch.d.ts} +0 -0
- /package/dist/{extras/deck-quote.d.ts → deck-quote.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/{atoms/deck-source.d.ts → deck-source.d.ts} +0 -0
- /package/dist/{layouts/deck-split.d.ts → deck-split.d.ts} +0 -0
- /package/dist/{molecules/deck-stack.d.ts → deck-stack.d.ts} +0 -0
- /package/dist/{molecules/deck-stat.d.ts → deck-stat.d.ts} +0 -0
- /package/dist/{molecules/deck-step-list.d.ts → deck-step-list.d.ts} +0 -0
- /package/dist/{extras/deck-table.d.ts → deck-table.d.ts} +0 -0
- /package/dist/{layouts/deck-takeaway.d.ts → deck-takeaway.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
- /package/dist/{extras/deck-versus.d.ts → deck-versus.d.ts} +0 -0
- /package/dist/{application/deep-link.d.ts → deep-link.d.ts} +0 -0
- /package/dist/{shared/escape-html.d.ts → escape-html.d.ts} +0 -0
- /package/dist/{shared/fit-controller.d.ts → fit-controller.d.ts} +0 -0
- /package/dist/{shared/graph-layout.d.ts → graph-layout.d.ts} +0 -0
- /package/dist/{shared/grid-tracks.d.ts → grid-tracks.d.ts} +0 -0
- /package/dist/{shared/icon-set.d.ts → icon-set.d.ts} +0 -0
- /package/dist/{application/keymap.d.ts → keymap.d.ts} +0 -0
- /package/dist/{application/mouse-nav.d.ts → mouse-nav.d.ts} +0 -0
- /package/dist/{domain/navigation.d.ts → navigation.d.ts} +0 -0
- /package/dist/{shared/parse-csv.d.ts → parse-csv.d.ts} +0 -0
- /package/dist/{plugins/shiki.d.ts → shiki.d.ts} +0 -0
- /package/dist/{extras/signature.d.ts → signature.d.ts} +0 -0
- /package/dist/{shared/slide-fill.d.ts → slide-fill.d.ts} +0 -0
- /package/dist/{domain/viewport.d.ts → viewport.d.ts} +0 -0
package/bin/lib/check.mjs
CHANGED
|
@@ -18,6 +18,8 @@ import { BOX_GEOMETRY_READER } from './box-geometry.mjs';
|
|
|
18
18
|
import { GRAPH_GEOMETRY_READER } from './graph-hit.mjs';
|
|
19
19
|
import { measureSlides } from './visual.mjs';
|
|
20
20
|
import { scanExternal } from './scan-external.mjs';
|
|
21
|
+
import { resolveCheckPlugins, startCheckPlugins, runCheckPlugins, pluginReport } from './check-plugins.mjs';
|
|
22
|
+
import { collectNarrative, narrativeRequest, applyNarrativeReview } from './narrative.mjs';
|
|
21
23
|
|
|
22
24
|
export const REPORT_SCHEMA = 1;
|
|
23
25
|
|
|
@@ -54,6 +56,33 @@ const LIMITS = {
|
|
|
54
56
|
// Two boxes sharing a few pixels at a corner is normal layout slop; sharing
|
|
55
57
|
// this many on both axes is one painted over the other.
|
|
56
58
|
siblingOverlapPx: 8,
|
|
59
|
+
// Under a pixel, two coordinates are the same coordinate · a percentage
|
|
60
|
+
// resolved into device pixels lands a hair apart and means nothing by it.
|
|
61
|
+
graphAlignFloorPx: 1,
|
|
62
|
+
// How far apart two graph centres may sit and still read as an attempt at
|
|
63
|
+
// the same row or the same column. A few tens of pixels on a 1920 canvas is
|
|
64
|
+
// the band where the eye says "almost" · beyond it the author moved the node
|
|
65
|
+
// somewhere else on purpose.
|
|
66
|
+
graphAlignSlackPx: 24,
|
|
67
|
+
// An edge whose smaller delta is under this share of its larger one was
|
|
68
|
+
// aiming at horizontal or vertical. Above it the line is a diagonal, and a
|
|
69
|
+
// diagonal is a choice nobody needs told about.
|
|
70
|
+
graphSkewRatio: 0.3,
|
|
71
|
+
// Two nodes of one row differing by less than this share read as a failed
|
|
72
|
+
// attempt at the same size; differing by more reads as a deliberate
|
|
73
|
+
// hierarchy. Paired with a floor, because text metrics move a box by a
|
|
74
|
+
// pixel or two on nothing but the glyphs in it.
|
|
75
|
+
graphSizeSlack: 0.3,
|
|
76
|
+
graphSizeFloorPx: 3,
|
|
77
|
+
// Below three nodes, one row is not an arrangement · two nodes side by side
|
|
78
|
+
// are just two nodes, and `layout` would say less than the coordinates do.
|
|
79
|
+
graphSemanticMinNodes: 3,
|
|
80
|
+
// A last line narrower than this share of the widest one is a stub hanging
|
|
81
|
+
// under the block · the classic orphan of a slide.
|
|
82
|
+
orphanLineRatio: 0.25,
|
|
83
|
+
// Under this many words there is no paragraph to break badly: a two-word
|
|
84
|
+
// label wrapping is the layout, not a typographic accident.
|
|
85
|
+
orphanMinWords: 8,
|
|
57
86
|
};
|
|
58
87
|
|
|
59
88
|
const diagnostic = (code, severity, message, extra = {}) => ({
|
|
@@ -265,6 +294,101 @@ const inspectPage = ({ limits, titleReader, graphGeometry, boxGeometry, only = n
|
|
|
265
294
|
return worst;
|
|
266
295
|
};
|
|
267
296
|
|
|
297
|
+
/** Where a component re-renders the author's own prose into its shadow tree ·
|
|
298
|
+
* the lines the browser broke are in there, not in the light DOM. */
|
|
299
|
+
const PROSE_IN_SHADOW = new Set(['deck-md']);
|
|
300
|
+
|
|
301
|
+
/** Text whose line breaks are not the browser's to judge: a code listing
|
|
302
|
+
* breaks where it was typed, a diagram and a table lay their own text out,
|
|
303
|
+
* and notes are never shown. */
|
|
304
|
+
const NOT_PROSE = new Set(['deck-notes', 'deck-code', 'deck-mermaid', 'deck-table', 'deck-graph', 'deck-annotate']);
|
|
305
|
+
const HEADINGS = new Set(['h1', 'h2', 'h3', 'h4', 'h5', 'h6']);
|
|
306
|
+
const INLINE_DISPLAY = new Set(['inline', 'inline-block', 'inline-flex', 'contents']);
|
|
307
|
+
|
|
308
|
+
/** The words the browser put on the line starting at `lineTop` · measured
|
|
309
|
+
* one word at a time, because nothing short of a rect says where a line
|
|
310
|
+
* actually broke. Only ever asked of the block already found guilty. */
|
|
311
|
+
const wordsOnLine = (el, lineTop) => {
|
|
312
|
+
const out = [];
|
|
313
|
+
const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT);
|
|
314
|
+
const range = document.createRange();
|
|
315
|
+
for (let node = walker.nextNode(); node; node = walker.nextNode()) {
|
|
316
|
+
for (const m of (node.textContent ?? '').matchAll(/\S+/g)) {
|
|
317
|
+
range.setStart(node, m.index);
|
|
318
|
+
range.setEnd(node, m.index + m[0].length);
|
|
319
|
+
const r = range.getBoundingClientRect();
|
|
320
|
+
if (r.height && Math.abs(r.top - lineTop) < r.height / 2) out.push(m[0]);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
return out.join(' ');
|
|
324
|
+
};
|
|
325
|
+
|
|
326
|
+
/** The worst `TEXT_LAST_LINE_ORPHAN` offender on a slide: a block of prose
|
|
327
|
+
* whose last line is a stub of the ones above it.
|
|
328
|
+
*
|
|
329
|
+
* The lines are read with a Range over the block's own contents · the
|
|
330
|
+
* stylesheet says where text *may* break, only the painted rects say where
|
|
331
|
+
* it did. Headings and short blocks are left alone: a title wrapping onto a
|
|
332
|
+
* second line is the composition, not an accident. */
|
|
333
|
+
const worstOrphan = (slide) => {
|
|
334
|
+
let worst = null;
|
|
335
|
+
const consider = (el) => {
|
|
336
|
+
const tag = el.tagName.toLowerCase();
|
|
337
|
+
if (HEADINGS.has(tag) || NOT_PROSE.has(tag) || isInside(el, NOT_PROSE)) return;
|
|
338
|
+
if (el.ownerSVGElement) return;
|
|
339
|
+
const style = getComputedStyle(el);
|
|
340
|
+
if (style.display === 'none' || style.visibility === 'hidden') return;
|
|
341
|
+
if (INLINE_DISPLAY.has(style.display)) return;
|
|
342
|
+
if (style.whiteSpace.startsWith('pre')) return;
|
|
343
|
+
// A container is judged through its children, never as one block: a
|
|
344
|
+
// Range over it would read every line of every paragraph it holds and
|
|
345
|
+
// call the last one an orphan of the first.
|
|
346
|
+
for (const child of el.children) {
|
|
347
|
+
if (!INLINE_DISPLAY.has(getComputedStyle(child).display)) return;
|
|
348
|
+
}
|
|
349
|
+
const words = ((el.textContent ?? '').match(/[\p{L}\p{N}'’-]+/gu) ?? []).length;
|
|
350
|
+
if (words < limits.orphanMinWords) return;
|
|
351
|
+
|
|
352
|
+
const range = document.createRange();
|
|
353
|
+
range.selectNodeContents(el);
|
|
354
|
+
const rects = [...range.getClientRects()].filter((r) => r.width > 0.5 && r.height > 0.5);
|
|
355
|
+
if (rects.length < 2) return;
|
|
356
|
+
// Several rects share one line · an <em> mid-sentence is a rect of its
|
|
357
|
+
// own, not a line of its own.
|
|
358
|
+
const lines = [];
|
|
359
|
+
for (const r of rects) {
|
|
360
|
+
const line = lines.find((l) => Math.abs(l.top - r.top) < r.height / 2);
|
|
361
|
+
if (line) {
|
|
362
|
+
line.left = Math.min(line.left, r.left);
|
|
363
|
+
line.right = Math.max(line.right, r.right);
|
|
364
|
+
} else {
|
|
365
|
+
lines.push({ top: r.top, left: r.left, right: r.right });
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
if (lines.length < 2) return;
|
|
369
|
+
const last = lines[lines.length - 1];
|
|
370
|
+
const widest = Math.max(...lines.map((l) => l.right - l.left));
|
|
371
|
+
if (!widest) return;
|
|
372
|
+
const ratio = (last.right - last.left) / widest;
|
|
373
|
+
if (ratio >= limits.orphanLineRatio) return;
|
|
374
|
+
if (ratio < (worst?.ratio ?? Number.POSITIVE_INFINITY)) {
|
|
375
|
+
worst = { ratio, lines: lines.length, path: pathOf(el), tail: wordsOnLine(el, last.top).slice(0, 40) };
|
|
376
|
+
}
|
|
377
|
+
};
|
|
378
|
+
|
|
379
|
+
const seen = new Set();
|
|
380
|
+
const scan = (root) => {
|
|
381
|
+
for (const el of root.querySelectorAll('*')) {
|
|
382
|
+
if (seen.has(el)) continue;
|
|
383
|
+
seen.add(el);
|
|
384
|
+
if (el.shadowRoot && PROSE_IN_SHADOW.has(el.tagName.toLowerCase())) scan(el.shadowRoot);
|
|
385
|
+
consider(el);
|
|
386
|
+
}
|
|
387
|
+
};
|
|
388
|
+
scan(slide);
|
|
389
|
+
return worst;
|
|
390
|
+
};
|
|
391
|
+
|
|
268
392
|
const measured = slides.map((slide, index) => {
|
|
269
393
|
const outline = {
|
|
270
394
|
index: index + 1,
|
|
@@ -345,6 +469,7 @@ const inspectPage = ({ limits, titleReader, graphGeometry, boxGeometry, only = n
|
|
|
345
469
|
tiny: tiny.slice(0, 3),
|
|
346
470
|
escapesBox: worstEscape(boxes),
|
|
347
471
|
overlapsSibling: worstOverlap(boxes),
|
|
472
|
+
lastLineOrphan: worstOrphan(slide),
|
|
348
473
|
};
|
|
349
474
|
});
|
|
350
475
|
|
|
@@ -380,7 +505,7 @@ const inspectPage = ({ limits, titleReader, graphGeometry, boxGeometry, only = n
|
|
|
380
505
|
const styledCache = new Map();
|
|
381
506
|
for (const el of scanDocument ? document.querySelectorAll('*') : []) {
|
|
382
507
|
const tag = el.tagName.toLowerCase();
|
|
383
|
-
if (!tag.
|
|
508
|
+
if (!tag.includes('-')) continue;
|
|
384
509
|
const ctor = customElements.get(tag);
|
|
385
510
|
if (!ctor) continue;
|
|
386
511
|
if (!styledCache.has(tag)) styledCache.set(tag, styledAttrsOf(el));
|
|
@@ -400,7 +525,7 @@ const inspectPage = ({ limits, titleReader, graphGeometry, boxGeometry, only = n
|
|
|
400
525
|
for (const el of scanDocument ? document.querySelectorAll('*') : []) {
|
|
401
526
|
const parent = el.parentElement;
|
|
402
527
|
if (!parent?.shadowRoot) continue;
|
|
403
|
-
if (!parent.tagName.toLowerCase().
|
|
528
|
+
if (!parent.tagName.toLowerCase().includes('-')) continue;
|
|
404
529
|
if (el.assignedSlot) continue;
|
|
405
530
|
if (el.tagName.toLowerCase() === 'deck-notes') continue; // read by the presenter, never shown
|
|
406
531
|
const offered = [...parent.shadowRoot.querySelectorAll('slot')].map((n) => n.name || '(default)');
|
|
@@ -415,10 +540,16 @@ const inspectPage = ({ limits, titleReader, graphGeometry, boxGeometry, only = n
|
|
|
415
540
|
// A tag that was never defined renders as an empty inline box: the author
|
|
416
541
|
// typed `deck-callot`, and the slide simply lost a block with no error.
|
|
417
542
|
const unknown = [];
|
|
418
|
-
|
|
543
|
+
const elements = scanDocument ? [...document.querySelectorAll('*')] : [];
|
|
544
|
+
const knownPrefixes = new Set(['deck']);
|
|
545
|
+
for (const el of elements) {
|
|
419
546
|
const tag = el.tagName.toLowerCase();
|
|
420
|
-
if (
|
|
421
|
-
|
|
547
|
+
if (customElements.get(tag)) knownPrefixes.add(tag.split('-')[0]);
|
|
548
|
+
}
|
|
549
|
+
for (const el of elements) {
|
|
550
|
+
const tag = el.tagName.toLowerCase();
|
|
551
|
+
if (el.namespaceURI === 'http://www.w3.org/1999/xhtml' && tag.includes('-') && !customElements.get(tag)) {
|
|
552
|
+
unknown.push({ tag, path: pathOf(el), knownPrefix: knownPrefixes.has(tag.split('-')[0]) });
|
|
422
553
|
}
|
|
423
554
|
}
|
|
424
555
|
|
|
@@ -473,6 +604,117 @@ const inspectPage = ({ limits, titleReader, graphGeometry, boxGeometry, only = n
|
|
|
473
604
|
}
|
|
474
605
|
}
|
|
475
606
|
|
|
607
|
+
// A caption painted under a node is a caption nobody reads. The generic
|
|
608
|
+
// sibling rule skips everything inside a graph — the component stacks its
|
|
609
|
+
// own layers by design — so the region and edge captions have no net but
|
|
610
|
+
// this one. They live in shadow trees: the graph paints the edge labels,
|
|
611
|
+
// each deck-group / deck-lane paints its own.
|
|
612
|
+
const labels = [];
|
|
613
|
+
for (const tag of graph.shadowRoot?.querySelectorAll('.edge-label') ?? []) {
|
|
614
|
+
labels.push({ el: tag, kind: 'edge', text: tag.textContent?.trim() ?? '', box: tag.getBoundingClientRect() });
|
|
615
|
+
}
|
|
616
|
+
for (const region of graph.querySelectorAll('deck-group, deck-lane')) {
|
|
617
|
+
const tag = region.shadowRoot?.querySelector('.tag');
|
|
618
|
+
if (!tag) continue;
|
|
619
|
+
const kind = region.tagName.toLowerCase() === 'deck-lane' ? 'lane' : 'group';
|
|
620
|
+
labels.push({ el: region, kind, text: region.getAttribute('label') ?? '', box: tag.getBoundingClientRect() });
|
|
621
|
+
}
|
|
622
|
+
for (const label of labels) {
|
|
623
|
+
if (!label.box.width || !label.box.height) continue;
|
|
624
|
+
let covered = null;
|
|
625
|
+
for (const candidate of nodes) {
|
|
626
|
+
const x = Math.min(candidate.box.right, label.box.right) - Math.max(candidate.box.left, label.box.left);
|
|
627
|
+
const y = Math.min(candidate.box.bottom, label.box.bottom) - Math.max(candidate.box.top, label.box.top);
|
|
628
|
+
if (x <= limits.siblingOverlapPx || y <= limits.siblingOverlapPx) continue;
|
|
629
|
+
if (x * y > (covered?.area ?? 0)) {
|
|
630
|
+
covered = { area: x * y, entry: candidate, overlap: { x: Math.round(x), y: Math.round(y) } };
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
if (covered) {
|
|
634
|
+
graphIssues.push({ kind: 'label-covered', slide: slideIndex, graph: pathOf(graph), label: pathOf(label.el), labelKind: label.kind, text: label.text, node: pathOf(covered.entry.node), nodeId: covered.entry.id || null, overlap: covered.overlap });
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
// What the author meant to line up, and what the browser painted. These
|
|
639
|
+
// read the centres rather than the authored `at`: a percentage is resolved
|
|
640
|
+
// against the canvas, and two nodes written 2% apart are as misaligned as
|
|
641
|
+
// the canvas is wide.
|
|
642
|
+
const centres = nodes.map((entry) => ({
|
|
643
|
+
...entry,
|
|
644
|
+
cx: (entry.box.left + entry.box.right) / 2,
|
|
645
|
+
cy: (entry.box.top + entry.box.bottom) / 2,
|
|
646
|
+
}));
|
|
647
|
+
|
|
648
|
+
// One sloppy row must not produce one diagnostic per pair · the worst
|
|
649
|
+
// offender names the row, and fixing it is what the author does anyway.
|
|
650
|
+
let offAxis = null;
|
|
651
|
+
for (let i = 0; i < centres.length; i++) {
|
|
652
|
+
for (let j = i + 1; j < centres.length; j++) {
|
|
653
|
+
for (const axis of ['x', 'y']) {
|
|
654
|
+
const delta = Math.abs(axis === 'x' ? centres[i].cx - centres[j].cx : centres[i].cy - centres[j].cy);
|
|
655
|
+
if (delta <= limits.graphAlignFloorPx || delta > limits.graphAlignSlackPx) continue;
|
|
656
|
+
if (delta > (offAxis?.delta ?? 0)) offAxis = { delta, axis, a: centres[i], b: centres[j] };
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
if (offAxis) {
|
|
661
|
+
graphIssues.push({ kind: 'nodes-off-axis', slide: slideIndex, graph: pathOf(graph), axis: offAxis.axis, pixels: Math.round(offAxis.delta), node: pathOf(offAxis.a.node), other: pathOf(offAxis.b.node), a: offAxis.a.id || null, b: offAxis.b.id || null });
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
/** The nodes grouped into rows (`cy`) or columns (`cx`) · a run of centres
|
|
665
|
+
* no further apart than the alignment slack is one row. */
|
|
666
|
+
const runsOn = (key) => {
|
|
667
|
+
const sorted = [...centres].sort((a, b) => a[key] - b[key]);
|
|
668
|
+
const runs = [];
|
|
669
|
+
let run = [];
|
|
670
|
+
for (const entry of sorted) {
|
|
671
|
+
if (run.length && entry[key] - run[run.length - 1][key] > limits.graphAlignSlackPx) {
|
|
672
|
+
runs.push(run);
|
|
673
|
+
run = [];
|
|
674
|
+
}
|
|
675
|
+
run.push(entry);
|
|
676
|
+
}
|
|
677
|
+
if (run.length) runs.push(run);
|
|
678
|
+
return runs.filter((r) => r.length > 1);
|
|
679
|
+
};
|
|
680
|
+
// A ragged edge down a column, or an uneven baseline across a row · the
|
|
681
|
+
// dimension compared is the one whose mismatch is visible as a ragged
|
|
682
|
+
// line, so a row is judged on height and a column on width.
|
|
683
|
+
let mixedSizes = null;
|
|
684
|
+
const compareSizes = (run, dimension, along) => {
|
|
685
|
+
for (let i = 0; i < run.length; i++) {
|
|
686
|
+
for (let j = i + 1; j < run.length; j++) {
|
|
687
|
+
const a = run[i].box[dimension];
|
|
688
|
+
const b = run[j].box[dimension];
|
|
689
|
+
const diff = Math.abs(a - b);
|
|
690
|
+
const largest = Math.max(a, b);
|
|
691
|
+
if (!largest || diff <= limits.graphSizeFloorPx) continue;
|
|
692
|
+
if (diff / largest >= limits.graphSizeSlack) continue;
|
|
693
|
+
if (diff > (mixedSizes?.diff ?? 0)) {
|
|
694
|
+
mixedSizes = { diff, ratio: diff / largest, dimension, along, a: run[i], b: run[j] };
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
};
|
|
699
|
+
for (const row of runsOn('cy')) compareSizes(row, 'height', 'row');
|
|
700
|
+
for (const column of runsOn('cx')) compareSizes(column, 'width', 'column');
|
|
701
|
+
if (mixedSizes) {
|
|
702
|
+
graphIssues.push({ kind: 'sizes-mixed', slide: slideIndex, graph: pathOf(graph), dimension: mixedSizes.dimension, along: mixedSizes.along, pixels: Math.round(mixedSizes.diff), share: Math.round(mixedSizes.ratio * 100), node: pathOf(mixedSizes.a.node), other: pathOf(mixedSizes.b.node), a: mixedSizes.a.id || null, b: mixedSizes.b.id || null });
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
// Hand-placed coordinates that spell out an arrangement the component
|
|
706
|
+
// already has a word for. `layout` reflects, and its default reflects as
|
|
707
|
+
// `free`, so both readings mean "the author placed every node".
|
|
708
|
+
const declaredLayout = (graph.getAttribute('layout') ?? 'free').toLowerCase();
|
|
709
|
+
if (declaredLayout === 'free' && centres.length >= limits.graphSemanticMinNodes) {
|
|
710
|
+
const spread = (key) => Math.max(...centres.map((c) => c[key])) - Math.min(...centres.map((c) => c[key]));
|
|
711
|
+
const arrangement =
|
|
712
|
+
spread('cy') <= limits.graphAlignSlackPx ? 'row' : spread('cx') <= limits.graphAlignSlackPx ? 'column' : null;
|
|
713
|
+
if (arrangement) {
|
|
714
|
+
graphIssues.push({ kind: 'layout-not-semantic', slide: slideIndex, graph: pathOf(graph), layout: arrangement, nodes: centres.length });
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
|
|
476
718
|
// An edge is a band of ink, not a mathematical line. Half the stroke width
|
|
477
719
|
// on each side of the centre line paints, so a line that misses a node by
|
|
478
720
|
// one pixel still crosses it on screen · that half width is the tolerance,
|
|
@@ -502,6 +744,19 @@ const inspectPage = ({ limits, titleReader, graphGeometry, boxGeometry, only = n
|
|
|
502
744
|
graphIssues.push({ kind: 'edge-crosses-node', slide: slideIndex, graph: pathOf(graph), edge: pathOf(edge), node: pathOf(candidate.node), from: from.id, to: to.id });
|
|
503
745
|
}
|
|
504
746
|
}
|
|
747
|
+
// An arrow that almost lands on horizontal or vertical reads as a slip;
|
|
748
|
+
// a frank diagonal reads as a decision. Only the first offending segment
|
|
749
|
+
// is reported · an ortho route bends at right angles and never fires.
|
|
750
|
+
for (let p = 1; p < points.length; p++) {
|
|
751
|
+
const dx = Math.abs(points[p].x - points[p - 1].x);
|
|
752
|
+
const dy = Math.abs(points[p].y - points[p - 1].y);
|
|
753
|
+
const minor = Math.min(dx, dy);
|
|
754
|
+
const major = Math.max(dx, dy);
|
|
755
|
+
if (!major || minor <= limits.graphAlignFloorPx || minor > limits.graphAlignSlackPx) continue;
|
|
756
|
+
if (minor / major >= limits.graphSkewRatio) continue;
|
|
757
|
+
graphIssues.push({ kind: 'edge-skewed', slide: slideIndex, graph: pathOf(graph), edge: pathOf(edge), from: from.id, to: to.id, axis: dx >= dy ? 'horizontal' : 'vertical', pixels: Math.round(minor), slope: Math.round((minor / major) * 100) });
|
|
758
|
+
break;
|
|
759
|
+
}
|
|
505
760
|
}
|
|
506
761
|
}
|
|
507
762
|
|
|
@@ -597,9 +852,9 @@ function diagnose(page, source, limits) {
|
|
|
597
852
|
element: u.path,
|
|
598
853
|
suggestion: 'escape the angle brackets (< >) where the deck talks about markup',
|
|
599
854
|
})
|
|
600
|
-
: diagnostic('UNKNOWN_ELEMENT', SEVERITY.error, `<${u.tag}> is not
|
|
855
|
+
: diagnostic('UNKNOWN_ELEMENT', u.knownPrefix || u.tag.startsWith('deck-') ? SEVERITY.error : SEVERITY.warning, `<${u.tag}> is not defined · its component behavior is unavailable`, {
|
|
601
856
|
element: u.path,
|
|
602
|
-
suggestion: 'check the spelling
|
|
857
|
+
suggestion: 'check the spelling and load the module that defines this custom element',
|
|
603
858
|
}),
|
|
604
859
|
);
|
|
605
860
|
}
|
|
@@ -660,6 +915,54 @@ function diagnose(page, source, limits) {
|
|
|
660
915
|
suggestion: 'move the obstructing node or split the route into a clear path; an orthogonal route is preferable when available',
|
|
661
916
|
}),
|
|
662
917
|
);
|
|
918
|
+
} else if (issue.kind === 'label-covered') {
|
|
919
|
+
found.push(
|
|
920
|
+
diagnostic('GRAPH_NODE_COVERS_LABEL', SEVERITY.error, `a node is painted over the "${issue.text}" ${issue.labelKind} label · ${issue.overlap.x}x${issue.overlap.y}px of it`, {
|
|
921
|
+
...where,
|
|
922
|
+
element: issue.label,
|
|
923
|
+
measurement: { overlapPx: issue.overlap, node: issue.node },
|
|
924
|
+
suggestion: issue.labelKind === 'edge'
|
|
925
|
+
? 'move the node with `at`, or the caption with `label-offset` · a label under a node is a label nobody reads'
|
|
926
|
+
: 'move the node with `at`, or the region with its own `at` · a label under a node is a label nobody reads',
|
|
927
|
+
}),
|
|
928
|
+
);
|
|
929
|
+
} else if (issue.kind === 'edge-skewed') {
|
|
930
|
+
found.push(
|
|
931
|
+
diagnostic('GRAPH_EDGE_SKEWED', SEVERITY.warning, `the ${issue.from} → ${issue.to} edge misses ${issue.axis} by ${issue.pixels}px`, {
|
|
932
|
+
...where,
|
|
933
|
+
element: issue.edge,
|
|
934
|
+
measurement: { deviationPx: issue.pixels, slopePercent: issue.slope, axis: issue.axis },
|
|
935
|
+
suggestion: 'give both nodes the same `at` coordinate on that axis, or set `route="ortho"` · a frank diagonal is a choice, a three-degree slope is a slip',
|
|
936
|
+
}),
|
|
937
|
+
);
|
|
938
|
+
} else if (issue.kind === 'nodes-off-axis') {
|
|
939
|
+
const name = (id, path) => (id ? `"${id}"` : path);
|
|
940
|
+
const line = issue.axis === 'x' ? 'column' : 'row';
|
|
941
|
+
found.push(
|
|
942
|
+
diagnostic('GRAPH_NODES_OFF_AXIS', SEVERITY.warning, `the ${name(issue.a, issue.node)} and ${name(issue.b, issue.other)} nodes miss the same ${line} by ${issue.pixels}px`, {
|
|
943
|
+
...where,
|
|
944
|
+
measurement: { offsetPx: issue.pixels, axis: issue.axis, nodes: [issue.node, issue.other] },
|
|
945
|
+
suggestion: `give them the same \`at\` coordinate on that axis · this close, they were meant to share a ${line}`,
|
|
946
|
+
}),
|
|
947
|
+
);
|
|
948
|
+
} else if (issue.kind === 'sizes-mixed') {
|
|
949
|
+
const name = (id, path) => (id ? `"${id}"` : path);
|
|
950
|
+
found.push(
|
|
951
|
+
diagnostic('GRAPH_NODE_SIZES_MIXED', SEVERITY.warning, `the ${name(issue.a, issue.node)} and ${name(issue.b, issue.other)} nodes share a ${issue.along} but differ by ${issue.pixels}px in ${issue.dimension} · ${issue.share}%`, {
|
|
952
|
+
...where,
|
|
953
|
+
measurement: { differencePx: issue.pixels, sharePercent: issue.share, dimension: issue.dimension, nodes: [issue.node, issue.other] },
|
|
954
|
+
suggestion: 'set `width` or `--deck-node-size` on both, or even out their notes · near-equal boxes read as a failed attempt at the same size, clearly different ones read as a hierarchy',
|
|
955
|
+
}),
|
|
956
|
+
);
|
|
957
|
+
} else if (issue.kind === 'layout-not-semantic') {
|
|
958
|
+
found.push(
|
|
959
|
+
diagnostic('GRAPH_LAYOUT_NOT_SEMANTIC', SEVERITY.warning, `every node of this graph sits on one ${issue.layout} · the \`at\` coordinates spell out what layout="${issue.layout}" already says`, {
|
|
960
|
+
...where,
|
|
961
|
+
element: issue.graph,
|
|
962
|
+
measurement: { nodes: issue.nodes, arrangement: issue.layout },
|
|
963
|
+
suggestion: `set layout="${issue.layout}" and drop the \`at\` · the arrangement then survives a node added or removed`,
|
|
964
|
+
}),
|
|
965
|
+
);
|
|
663
966
|
}
|
|
664
967
|
}
|
|
665
968
|
|
|
@@ -723,6 +1026,18 @@ function diagnose(page, source, limits) {
|
|
|
723
1026
|
}),
|
|
724
1027
|
);
|
|
725
1028
|
}
|
|
1029
|
+
if (slide.lastLineOrphan) {
|
|
1030
|
+
const o = slide.lastLineOrphan;
|
|
1031
|
+
found.push(
|
|
1032
|
+
diagnostic('TEXT_LAST_LINE_ORPHAN', SEVERITY.warning, `"${o.tail}" hangs alone on the last line · ${Math.round(o.ratio * 100)}% of the width above it`, {
|
|
1033
|
+
...where,
|
|
1034
|
+
element: o.path,
|
|
1035
|
+
measurement: { lastLineRatio: Math.round(o.ratio * 100) / 100, lines: o.lines, floorRatio: limits.orphanLineRatio },
|
|
1036
|
+
excerpt: o.tail,
|
|
1037
|
+
suggestion: 'set `text-wrap: pretty`, shorten the wording, or bind the last words with a non-breaking space · on a wall this reads as a typographic accident',
|
|
1038
|
+
}),
|
|
1039
|
+
);
|
|
1040
|
+
}
|
|
726
1041
|
for (const t of slide.tiny) {
|
|
727
1042
|
found.push(
|
|
728
1043
|
diagnostic('TEXT_TOO_SMALL', SEVERITY.warning, `text renders at ${t.px}px · the back row will not read it`, {
|
|
@@ -777,8 +1092,13 @@ const MEASURED_IN_MESSAGE = new Set([
|
|
|
777
1092
|
'SLIDE_DENSE',
|
|
778
1093
|
'SLIDE_TOP_HEAVY',
|
|
779
1094
|
'TEXT_TOO_SMALL',
|
|
1095
|
+
'TEXT_LAST_LINE_ORPHAN',
|
|
780
1096
|
'GRAPH_NODE_OUT_OF_BOUNDS',
|
|
781
1097
|
'GRAPH_NODE_OVERLAPS_NODE',
|
|
1098
|
+
'GRAPH_NODE_COVERS_LABEL',
|
|
1099
|
+
'GRAPH_EDGE_SKEWED',
|
|
1100
|
+
'GRAPH_NODES_OFF_AXIS',
|
|
1101
|
+
'GRAPH_NODE_SIZES_MIXED',
|
|
782
1102
|
'TALK_SHORTER_THAN_ANNOUNCED',
|
|
783
1103
|
]);
|
|
784
1104
|
|
|
@@ -793,7 +1113,7 @@ function dedupeStateDiagnostics(diagnostics) {
|
|
|
793
1113
|
for (const d of diagnostics) {
|
|
794
1114
|
const message = String(d.message ?? '');
|
|
795
1115
|
const wording = MEASURED_IN_MESSAGE.has(d.code) ? message.replace(/[\d.]+/g, '#') : message;
|
|
796
|
-
const key = JSON.stringify([d.code, d.slide ?? null, d.element ?? null, wording]);
|
|
1116
|
+
const key = JSON.stringify([d.plugin ?? 'rikiki', d.code, d.slide ?? null, d.element ?? null, d.key ?? wording]);
|
|
797
1117
|
const prior = kept.get(key);
|
|
798
1118
|
if (!prior || d.state < prior.state) kept.set(key, d);
|
|
799
1119
|
}
|
|
@@ -811,10 +1131,11 @@ function dedupeStateDiagnostics(diagnostics) {
|
|
|
811
1131
|
*
|
|
812
1132
|
* Whatever does not depend on which slide is showing is asked once, on the
|
|
813
1133
|
* first pass, rather than once per state. */
|
|
814
|
-
async function diagnoseAllStates(page, slideCount, inspectOpts, source, limits, { steps }) {
|
|
1134
|
+
async function diagnoseAllStates(page, slideCount, inspectOpts, source, limits, { steps, plugins, pluginTimeoutMs }) {
|
|
815
1135
|
const found = [];
|
|
816
1136
|
let statesInspected = 0;
|
|
817
1137
|
for (let index = 1; index <= slideCount; index++) {
|
|
1138
|
+
if (page.isClosed()) break;
|
|
818
1139
|
try {
|
|
819
1140
|
await goToSlide(page, index);
|
|
820
1141
|
} catch {
|
|
@@ -837,6 +1158,8 @@ async function diagnoseAllStates(page, slideCount, inspectOpts, source, limits,
|
|
|
837
1158
|
const snapshot = await page.evaluate(inspectPage, { ...inspectOpts, only: index, scanDocument });
|
|
838
1159
|
statesInspected += 1;
|
|
839
1160
|
for (const d of diagnose(snapshot, source, limits)) found.push(steps ? { ...d, state } : d);
|
|
1161
|
+
found.push(...await runCheckPlugins(page, plugins, { slide: index, state, documentPass: scanDocument }, pluginTimeoutMs));
|
|
1162
|
+
if (page.isClosed()) break;
|
|
840
1163
|
if (!steps || !(await advanceStep(page))) break;
|
|
841
1164
|
state += 1;
|
|
842
1165
|
}
|
|
@@ -850,10 +1173,13 @@ async function diagnoseAllStates(page, slideCount, inspectOpts, source, limits,
|
|
|
850
1173
|
*/
|
|
851
1174
|
export async function checkDeck(
|
|
852
1175
|
deckPath,
|
|
853
|
-
{ timeoutMs = PAGE_LOAD_TIMEOUT_MS, width = 1920, height = 1080, visual = true, steps = false
|
|
1176
|
+
{ timeoutMs = PAGE_LOAD_TIMEOUT_MS, width = 1920, height = 1080, visual = true, steps = false,
|
|
1177
|
+
config, plugins = [], noPlugins = false, pluginTimeoutMs = 5000, narrativeOut, narrativeReview } = {},
|
|
854
1178
|
) {
|
|
855
1179
|
const source = readFileSync(deckPath, 'utf8');
|
|
856
1180
|
const limits = LIMITS;
|
|
1181
|
+
const resolution = resolveCheckPlugins(deckPath, { config, plugins, noPlugins });
|
|
1182
|
+
if (!Number.isFinite(pluginTimeoutMs) || pluginTimeoutMs < 1) throw new Error('pluginTimeoutMs must be positive');
|
|
857
1183
|
|
|
858
1184
|
return withDeck(
|
|
859
1185
|
deckPath,
|
|
@@ -875,8 +1201,13 @@ export async function checkDeck(
|
|
|
875
1201
|
|
|
876
1202
|
let diagnostics;
|
|
877
1203
|
let statesInspected;
|
|
878
|
-
if (settled
|
|
879
|
-
|
|
1204
|
+
if (settled) await startCheckPlugins(page, resolution, pluginTimeoutMs);
|
|
1205
|
+
else for (const plugin of resolution.plugins) {
|
|
1206
|
+
plugin.status = 'skipped';
|
|
1207
|
+
resolution.notChecked.push(`plugin ${plugin.id} · deck did not settle`);
|
|
1208
|
+
}
|
|
1209
|
+
if (settled && observed.slides.length && !page.isClosed()) {
|
|
1210
|
+
const walked = await diagnoseAllStates(page, observed.slides.length, inspectOpts, source, limits, { steps, plugins: resolution, pluginTimeoutMs });
|
|
880
1211
|
diagnostics = walked.diagnostics;
|
|
881
1212
|
statesInspected = walked.statesInspected;
|
|
882
1213
|
} else {
|
|
@@ -884,17 +1215,37 @@ export async function checkDeck(
|
|
|
884
1215
|
// all. One whole-document pass is all there is to report · on a deck
|
|
885
1216
|
// that never settled the geometry is unreliable anyway, but saying
|
|
886
1217
|
// nothing about it would be worse.
|
|
887
|
-
const whole = observed.slides.length
|
|
1218
|
+
const whole = observed.slides.length && !page.isClosed()
|
|
888
1219
|
? await page.evaluate(inspectPage, { ...inspectOpts, only: null }).catch(() => observed)
|
|
889
1220
|
: observed;
|
|
890
1221
|
diagnostics = diagnose(whole, source, limits);
|
|
891
1222
|
statesInspected = observed.slides.length;
|
|
892
1223
|
}
|
|
1224
|
+
diagnostics.push(...resolution.diagnostics);
|
|
1225
|
+
|
|
1226
|
+
let narrative = { status: 'not-run' };
|
|
1227
|
+
if ((narrativeOut || narrativeReview) && !page.isClosed() && settled) {
|
|
1228
|
+
const slides = await page.evaluate(collectNarrative);
|
|
1229
|
+
const request = narrativeRequest(deckPath, source, slides, { width, height }, resolution.config.narrative);
|
|
1230
|
+
if (narrativeOut) {
|
|
1231
|
+
const { writeFileSync } = await import('node:fs');
|
|
1232
|
+
writeFileSync(narrativeOut, JSON.stringify(request, null, 2) + '\n');
|
|
1233
|
+
narrative = { status: 'pending', digest: request.digest, request: narrativeOut };
|
|
1234
|
+
}
|
|
1235
|
+
if (narrativeReview) {
|
|
1236
|
+
const reviewed = applyNarrativeReview(request, narrativeReview);
|
|
1237
|
+
narrative = reviewed.narrative;
|
|
1238
|
+
diagnostics.push(...reviewed.diagnostics);
|
|
1239
|
+
}
|
|
1240
|
+
} else if (narrativeOut || narrativeReview) {
|
|
1241
|
+
narrative = { status: 'failed' };
|
|
1242
|
+
diagnostics.push(diagnostic('NARRATIVE_UNAVAILABLE', 'error', 'Narrative review requires a settled deck and an open browser'));
|
|
1243
|
+
}
|
|
893
1244
|
|
|
894
1245
|
// The pixel pass needs a settled deck and one screenshot per slide · it
|
|
895
1246
|
// is the slowest thing here, so it is skippable.
|
|
896
1247
|
let visualMeasured = false;
|
|
897
|
-
if (visual && settled && observed.slides.length) {
|
|
1248
|
+
if (visual && settled && observed.slides.length && !page.isClosed()) {
|
|
898
1249
|
const goTo = async (index) => {
|
|
899
1250
|
await page.evaluate((i) => {
|
|
900
1251
|
window.location.hash = `#${i}`;
|
|
@@ -949,8 +1300,12 @@ export async function checkDeck(
|
|
|
949
1300
|
visualMeasured,
|
|
950
1301
|
summary,
|
|
951
1302
|
diagnostics,
|
|
1303
|
+
plugins: pluginReport(resolution),
|
|
1304
|
+
narrative,
|
|
952
1305
|
// Named so a reader does not mistake silence for a clean bill.
|
|
953
1306
|
notChecked: [
|
|
1307
|
+
...resolution.notChecked,
|
|
1308
|
+
...(narrative.status === 'completed' ? [] : [`narrative composition · ${narrative.status} (review by the current agent)`]),
|
|
954
1309
|
...(observed.runtimeLoaded
|
|
955
1310
|
? []
|
|
956
1311
|
: ['layout · the runtime never ran, so nothing about size or fit was measured']),
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// What a component needs loaded besides itself.
|
|
2
|
+
//
|
|
3
|
+
// A component may render another component's tag · deck-figure renders
|
|
4
|
+
// <deck-source> for its credit line, deck-annotate does the same, deck-graph
|
|
5
|
+
// renders <deck-icon>. The curated bundle is built from the tags written in
|
|
6
|
+
// the DECK (see scanComponents in inline.mjs), so those tags are invisible to
|
|
7
|
+
// it: the deck shipped without the module and the element stayed unregistered,
|
|
8
|
+
// rendering as bare text. decks/tests/figure.html is the live case.
|
|
9
|
+
//
|
|
10
|
+
// Read from dist/, not src/, for one reason: `rikiki bundle` runs from an
|
|
11
|
+
// installed package, where src/ does not exist. dist/ is also the only thing
|
|
12
|
+
// that can be wrong · a graph derived from what ships cannot disagree with
|
|
13
|
+
// what ships.
|
|
14
|
+
//
|
|
15
|
+
// The scan OVER-APPROXIMATES on purpose. `<deck-code` also appears in a
|
|
16
|
+
// warning string inside deck-code-highlighter and shiki, so both are reported
|
|
17
|
+
// as needing deck-code. That is the safe direction: a false positive adds a
|
|
18
|
+
// module the deck almost certainly wanted anyway (shiki highlights deck-code),
|
|
19
|
+
// while a false negative ships a deck with a missing element. Precision here
|
|
20
|
+
// would mean parsing minified JS to tell a Lit template from a string
|
|
21
|
+
// literal, which is a lot of machinery to save a few hundred bytes.
|
|
22
|
+
|
|
23
|
+
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
|
24
|
+
import { join } from 'node:path';
|
|
25
|
+
|
|
26
|
+
/** Tags a module mentions in markup · `<deck-thing`. */
|
|
27
|
+
const RENDERED = /<(deck-[a-z0-9-]+)/g;
|
|
28
|
+
|
|
29
|
+
/** Every loadable module, mapped to the modules it needs beside it.
|
|
30
|
+
*
|
|
31
|
+
* dist/ is flat and a file IS its tag (build.mjs entryNames '[name]'), so the
|
|
32
|
+
* filename is the lookup key. A secondary element defined inside a sibling's
|
|
33
|
+
* file · deck-kbd in deck-shortcut.js, deck-tier in deck-tier-list.js · has
|
|
34
|
+
* no module of its own and travels with its parent, which is why anything
|
|
35
|
+
* without a dist/<tag>.js is dropped rather than requested. */
|
|
36
|
+
export function componentGraph(distDir) {
|
|
37
|
+
const modules = readdirSync(distDir)
|
|
38
|
+
.filter((f) => f.endsWith('.js') && f.startsWith('deck-'))
|
|
39
|
+
.map((f) => f.slice(0, -3));
|
|
40
|
+
const known = new Set(modules);
|
|
41
|
+
|
|
42
|
+
const graph = new Map();
|
|
43
|
+
for (const tag of modules) {
|
|
44
|
+
const source = readFileSync(join(distDir, `${tag}.js`), 'utf8');
|
|
45
|
+
const deps = new Set();
|
|
46
|
+
for (const [, found] of source.matchAll(RENDERED)) {
|
|
47
|
+
if (found !== tag && known.has(found)) deps.add(found);
|
|
48
|
+
}
|
|
49
|
+
graph.set(tag, deps);
|
|
50
|
+
}
|
|
51
|
+
return graph;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** The tags to load so every one of `tags` renders completely.
|
|
55
|
+
*
|
|
56
|
+
* A closure over a visited set · a cycle between two components would be a
|
|
57
|
+
* defect to report, not a reason for this to recurse forever. */
|
|
58
|
+
export function expandDeps(tags, distDir, graph = componentGraph(distDir)) {
|
|
59
|
+
const out = new Set();
|
|
60
|
+
const queue = [...tags];
|
|
61
|
+
while (queue.length > 0) {
|
|
62
|
+
const tag = queue.pop();
|
|
63
|
+
if (out.has(tag)) continue;
|
|
64
|
+
out.add(tag);
|
|
65
|
+
for (const dep of graph.get(tag) ?? []) if (!out.has(dep)) queue.push(dep);
|
|
66
|
+
}
|
|
67
|
+
// Keep only what can actually be loaded · a caller may pass deck-kbd.
|
|
68
|
+
return [...out].filter((t) => existsSync(join(distDir, `${t}.js`)));
|
|
69
|
+
}
|
package/bin/lib/export-pdf.mjs
CHANGED
|
@@ -8,19 +8,42 @@
|
|
|
8
8
|
// and check share with it.
|
|
9
9
|
// ════════════════════════════════════════════════════════════════
|
|
10
10
|
|
|
11
|
-
import { PAGE_LOAD_TIMEOUT_MS, withDeck } from './browser.mjs';
|
|
11
|
+
import { PAGE_LOAD_TIMEOUT_MS, waitForStillFrame, withDeck } from './browser.mjs';
|
|
12
12
|
|
|
13
13
|
export { rootDepthFor } from './browser.mjs';
|
|
14
14
|
|
|
15
|
+
/** Page objects in a PDF written by Chrome, which keeps them out of object
|
|
16
|
+
* streams · `/Type /Pages` (the tree) and `/Count` on the outline are not pages. */
|
|
17
|
+
export function pdfPageCount(buffer) {
|
|
18
|
+
return buffer.toString('latin1').match(/\/Type\s*\/Page(?![s\w])/g)?.length ?? 0;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Lay the deck out as paper before the PDF is taken.
|
|
23
|
+
*
|
|
24
|
+
* Components that measure themselves (graph edges, annotation marks) only draw
|
|
25
|
+
* once the layout they measure exists, and a slide never shown on screen has
|
|
26
|
+
* none. page.pdf() switches to print media and snapshots in the same breath, so
|
|
27
|
+
* their ResizeObservers never ran: every diagram printed without its arrows.
|
|
28
|
+
* Switching first and letting frames pass gives them that layout.
|
|
29
|
+
*/
|
|
30
|
+
export async function preparePrint(page) {
|
|
31
|
+
await page.emulateMedia({ media: 'print' });
|
|
32
|
+
await waitForStillFrame(page);
|
|
33
|
+
}
|
|
34
|
+
|
|
15
35
|
/**
|
|
16
36
|
* Render `deckPath` to `outputPath`.
|
|
17
|
-
* @returns {Promise<{pages: number, missing: string[]}>}
|
|
37
|
+
* @returns {Promise<{pages: number, slides: number, missing: string[]}>}
|
|
38
|
+
* `pages` is read from the PDF itself, so a slide lost on paper shows up as
|
|
39
|
+
* `pages < slides` instead of being reported as printed.
|
|
18
40
|
*/
|
|
19
41
|
export async function exportPdf(deckPath, outputPath, { timeoutMs = PAGE_LOAD_TIMEOUT_MS } = {}) {
|
|
20
42
|
return withDeck(
|
|
21
43
|
deckPath,
|
|
22
44
|
async ({ page, missing }) => {
|
|
23
|
-
await page
|
|
45
|
+
await preparePrint(page);
|
|
46
|
+
const pdf = await page.pdf({
|
|
24
47
|
path: outputPath,
|
|
25
48
|
printBackground: true,
|
|
26
49
|
preferCSSPageSize: true,
|
|
@@ -32,10 +55,10 @@ export async function exportPdf(deckPath, outputPath, { timeoutMs = PAGE_LOAD_TI
|
|
|
32
55
|
// makes the outline above meaningful, and what a screen reader needs.
|
|
33
56
|
tagged: true,
|
|
34
57
|
});
|
|
35
|
-
const
|
|
58
|
+
const slides = await page.evaluate(
|
|
36
59
|
() => document.querySelectorAll('deck-root > *:not(script):not(style):not(template)').length,
|
|
37
60
|
);
|
|
38
|
-
return { pages, missing };
|
|
61
|
+
return { pages: pdfPageCount(pdf), slides, missing };
|
|
39
62
|
},
|
|
40
63
|
{ timeoutMs },
|
|
41
64
|
);
|