@hajimism/dek 0.5.1 → 0.6.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.
Files changed (45) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/package.json +10 -3
  3. package/src/cli/build.ts +3 -1
  4. package/src/cli/commands.ts +24 -10
  5. package/src/cli/contract.ts +28 -2
  6. package/src/cli/flags.ts +5 -0
  7. package/src/cli/lint.ts +53 -1
  8. package/src/cli/sync.ts +21 -6
  9. package/src/cli/text.ts +20 -2
  10. package/src/core/build.ts +4 -0
  11. package/src/core/config-keys.ts +7 -2
  12. package/src/core/config.ts +63 -0
  13. package/src/core/diagnostic.ts +9 -1
  14. package/src/core/document.ts +28 -3
  15. package/src/core/page.ts +1 -0
  16. package/src/core/plugin-host.ts +669 -0
  17. package/src/core/plugin.ts +436 -0
  18. package/src/core/sarif.ts +47 -5
  19. package/src/core/sync.ts +3 -1
  20. package/src/plugin-api.ts +126 -0
  21. package/src/plugins/study/check.ts +128 -0
  22. package/src/plugins/study/main.ts +47 -0
  23. package/src/plugins/study/manifest.ts +32 -0
  24. package/src/plugins/study/page.ts +295 -0
  25. package/src/plugins/study/place.ts +88 -0
  26. package/src/plugins/study/sarif.ts +30 -0
  27. package/src/plugins/study/study.ts +137 -0
  28. package/src/plugins/study/text.ts +55 -0
  29. package/src/plugins/study/yaml.ts +226 -0
  30. package/src/plugins/words/check.ts +109 -0
  31. package/src/plugins/words/lines.ts +26 -0
  32. package/src/plugins/words/list.ts +288 -0
  33. package/src/plugins/words/main.ts +50 -0
  34. package/src/plugins/words/manifest.ts +36 -0
  35. package/src/plugins/words/match.ts +60 -0
  36. package/src/plugins/words/sarif.ts +37 -0
  37. package/src/plugins/words/text.ts +352 -0
  38. package/src/plugins/words/yaml.ts +225 -0
  39. package/src/runtime/player.ts +18 -2
  40. package/src/runtime/plugins.ts +236 -0
  41. package/src/runtime/start.ts +16 -1
  42. package/src/runtime/window.ts +3 -0
  43. package/src/server/deck-snapshot.ts +32 -1
  44. package/src/server/dev.ts +7 -1
  45. package/src/server/pages.ts +9 -1
package/CHANGELOG.md CHANGED
@@ -2,6 +2,24 @@
2
2
 
3
3
  Release notes for `@hajimism/dek`. From 0.1.0 on, release-please writes this file from the Conventional Commits on `main`.
4
4
 
5
+ ## [0.6.0](https://github.com/hajimism/dek/compare/v0.5.1...v0.6.0) (2026-11-07)
6
+
7
+
8
+ ### ⚠ BREAKING CHANGES
9
+
10
+ * a skipped check can now be "plugin" in --json (ADR 31).
11
+
12
+ ### Features
13
+
14
+ * let a plugin the user installed check a deck in dekc lint ([847822c](https://github.com/hajimism/dek/commit/847822cdfb34f4931c284b103007cb10d0dfc450))
15
+ * **plugins:** lay dek-study's sheet over the slides ([8886efc](https://github.com/hajimism/dek/commit/8886efc9b8a0b9bd4bc47290574654e79be32368))
16
+ * **plugins:** let a plugin keep data for the whole project in plugins/<name>/ ([84add8a](https://github.com/hajimism/dek/commit/84add8ab77918fdbfd60bfced6c18a103b1de0b1))
17
+ * **plugins:** ship dek-study inside dek as its first official plugin ([96595d5](https://github.com/hajimism/dek/commit/96595d5f3996c597a1c47564a736ef0ca5ae9413))
18
+ * **plugins:** ship dek-study inside dek, with a sheet of stickers over the slides ([855908c](https://github.com/hajimism/dek/commit/855908c03305aa8e8284c890bea0e19e25986994))
19
+ * **plugins:** ship dek-words, a check for a project's word list ([9eff6d1](https://github.com/hajimism/dek/commit/9eff6d13cb2727b0f0cf1aa8bdc9595ee4d5c1c1))
20
+ * **plugins:** ship dek-words, a check for a project's word list ([07d8f54](https://github.com/hajimism/dek/commit/07d8f54908ef8cd1e52cbf9cec5e576f929e55fb))
21
+ * **plugins:** show a plugin's page in the player and builds behind page API 1 ([b815ef6](https://github.com/hajimism/dek/commit/b815ef625444c572a13af1a6e0ab0dddfbffcb22))
22
+
5
23
  ## [0.5.1](https://github.com/hajimism/dek/compare/v0.5.0...v0.5.1) (2026-10-30)
6
24
 
7
25
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hajimism/dek",
3
- "version": "0.5.1",
3
+ "version": "0.6.0",
4
4
  "description": "A build system for talks.",
5
5
  "license": "MIT",
6
6
  "author": "Hajime Nakagawa (https://github.com/hajimism)",
@@ -8,6 +8,13 @@
8
8
  "bin": {
9
9
  "dekc": "src/cli.ts"
10
10
  },
11
+ "exports": {
12
+ "./plugin": {
13
+ "types": "./src/plugin-api.ts",
14
+ "default": "./src/plugin-api.ts"
15
+ },
16
+ "./package.json": "./package.json"
17
+ },
11
18
  "files": [
12
19
  "src",
13
20
  "skills",
@@ -61,12 +68,12 @@
61
68
  "zod": "^4.6.5"
62
69
  },
63
70
  "devDependencies": {
64
- "@biomejs/biome": "2.6.2",
71
+ "@biomejs/biome": "2.6.4",
65
72
  "@happy-dom/global-registrator": "21.0.4",
66
73
  "@types/bun": "^1.4.2",
67
74
  "fast-check": "4.10.2",
68
75
  "happy-dom": "21.0.4",
69
- "knip": "6.42.1",
76
+ "knip": "6.43.0",
70
77
  "playwright": "^1.63.0",
71
78
  "publint": "0.3.25",
72
79
  "typescript": "^7.0.2",
package/src/cli/build.ts CHANGED
@@ -4,7 +4,7 @@ import { DekError } from "../core/error.ts";
4
4
  import { lintDeck, lintProject } from "../core/lint.ts";
5
5
  import { parsePublicUrl } from "../core/ogp.ts";
6
6
  import { PLAYWRIGHT_INSTALL, type PlaywrightRunner } from "../core/playwright.ts";
7
- import { playerScript } from "../runtime/player.ts";
7
+ import { playerScript, pluginPageScripts } from "../runtime/player.ts";
8
8
  import { RENDERED_RULES, type SkippedCheck, skippedChecks, visualSkipped } from "./result.ts";
9
9
  import type { DecksTarget } from "./scope.ts";
10
10
 
@@ -47,12 +47,14 @@ export async function buildCommand(
47
47
  });
48
48
  }
49
49
  const script = await playerScript();
50
+ const pluginScripts = await pluginPageScripts();
50
51
  const results = await Promise.all(
51
52
  decks.map((entry) =>
52
53
  buildDeck(
53
54
  { project, deck: entry },
54
55
  {
55
56
  playerScript: script,
57
+ pluginScripts,
56
58
  rootDist: options.rootDist,
57
59
  ...(url ? { url } : {}),
58
60
  ...(options.public ? { public: true } : {}),
@@ -1,4 +1,4 @@
1
- import { hasErrors } from "../core/diagnostic.ts";
1
+ import { hasErrors, RULES } from "../core/diagnostic.ts";
2
2
  import { shellQuote } from "../core/escape.ts";
3
3
  import type { HiddenText } from "../core/foreign-text.ts";
4
4
  import { isRefName } from "../core/ref-name.ts";
@@ -27,6 +27,7 @@ import {
27
27
  formatNew,
28
28
  formatPack,
29
29
  formatPdf,
30
+ formatPluginNotes,
30
31
  formatPptx,
31
32
  formatRef,
32
33
  formatReport,
@@ -658,23 +659,31 @@ export const COMMANDS = {
658
659
  sync: result({
659
660
  scope: "decks",
660
661
  args: [],
661
- flags: [],
662
- usage: ["dekc sync [deck]"],
662
+ flags: ["accept"],
663
+ usage: ["dekc sync [deck] [--accept <plugin>]"],
663
664
  summary:
664
665
  "Create a skeleton for each section without slide HTML, refresh the skeletons\nnobody has edited since, and remove the ones whose section is gone.",
665
666
  group: "Slide",
666
- overview: [["dekc sync", "create missing skeletons; refresh or drop the ones nobody edited"]],
667
+ overview: [
668
+ ["dekc sync", "create missing skeletons; refresh or drop the ones nobody edited"],
669
+ ["dekc sync --accept <plugin>", "pin a plugin whose manifest changed since its lock"],
670
+ ],
667
671
  agent: [
668
672
  "dekc sync create missing skeletons; refresh or drop untouched ones; never edits your slides",
673
+ "dekc sync --accept <plugin> pin a plugin's changed manifest, once the user has read the change",
669
674
  ],
670
- run: async ({ target }) => {
675
+ run: async ({ target, flags }) => {
671
676
  const { syncCommand } = await import("./sync.ts");
672
- return syncCommand(target);
677
+ return syncCommand(target, { accept: flags.accept });
673
678
  },
674
679
  output: {
675
680
  text: (data) => formatCreated(data.created, data.updated, data.removed),
676
- notes: (data, color) => formatKept(data.kept, color) || undefined,
681
+ notes: (data, color) =>
682
+ [formatKept(data.kept, color), formatPluginNotes(data.plugins, color)]
683
+ .filter(Boolean)
684
+ .join("\n") || undefined,
677
685
  paths: (data, display) => ({
686
+ ...data,
678
687
  created: data.created.map(display),
679
688
  updated: data.updated.map(display),
680
689
  removed: data.removed.map(display),
@@ -703,14 +712,19 @@ export const COMMANDS = {
703
712
  output: {
704
713
  text: (data, color) => formatReport(data.diagnostics, { color }),
705
714
  notes: (data, color) => formatSkipped(data.skipped, color) || undefined,
706
- json: ({ rumdlSarif: _rumdlSarif, ...data }) => data,
715
+ json: ({ rumdlSarif: _rumdlSarif, pluginSarif: _pluginSarif, ...data }) => data,
707
716
  paths: withDisplayDiagnostics,
708
717
  // The rerun measures what this run did: without --visual, the errors it found may not show.
709
718
  failure: (data, flags) =>
710
719
  diagnosticFailure("lint", data, flags.visual ? "dekc lint --visual" : "dekc lint"),
720
+ // dek's run holds its own rules; rumdl and each plugin have runs of their own.
711
721
  sarif: (data) => {
712
- const dek = data.diagnostics.filter((diagnostic) => diagnostic.id.startsWith("DEK"));
713
- return mergeSarif(toSarif(dek, { skipped: data.skipped ?? [] }), data.rumdlSarif);
722
+ const dek = data.diagnostics.filter((diagnostic) => Object.hasOwn(RULES, diagnostic.id));
723
+ return mergeSarif(
724
+ toSarif(dek, { skipped: data.skipped ?? [] }),
725
+ data.rumdlSarif,
726
+ data.pluginSarif,
727
+ );
714
728
  },
715
729
  },
716
730
  }),
@@ -41,7 +41,8 @@ const Diagnostic = z
41
41
 
42
42
  const SkippedCheck = z
43
43
  .strictObject({
44
- check: z.enum(["lint", "preview", "rumdl", "visual", "voice"]),
44
+ check: z.enum(["lint", "plugin", "preview", "rumdl", "visual", "voice"]),
45
+ plugin: z.string().optional().describe("For a plugin's check, its name in dek.toml [plugins]."),
45
46
  reason: z.string(),
46
47
  hint: z.string().optional().describe("What to do so that the check runs."),
47
48
  })
@@ -421,7 +422,32 @@ export const RESULT_FIELDS = {
421
422
  }),
422
423
  z.strictObject({ action: z.literal("clear"), cleared: z.number().int() }),
423
424
  ]),
424
- sync: z.strictObject({ created: paths, updated: paths, removed: paths, kept: paths }),
425
+ sync: z.strictObject({
426
+ created: paths,
427
+ updated: paths,
428
+ removed: paths,
429
+ kept: paths,
430
+ plugins: z
431
+ .array(
432
+ z.strictObject({
433
+ name: z.string().describe("The plugin's name in dek.toml [plugins]."),
434
+ status: z
435
+ .enum(["accepted", "current", "changed", "missing", "invalid", "unsupported"])
436
+ .describe(
437
+ "accepted: pinned now; current: as pinned; changed: differs from its lock, which was left as it was; missing: not installed; invalid: printed no manifest dek can use; unsupported: needs a newer dek.",
438
+ ),
439
+ version: z.string().optional().describe("The installed plugin's version."),
440
+ changes: z
441
+ .array(z.string())
442
+ .optional()
443
+ .describe("For changed: what differs from the lock, one change each."),
444
+ reason: z.string().optional(),
445
+ hint: z.string().optional(),
446
+ }),
447
+ )
448
+ .optional()
449
+ .describe("Each plugin dek.toml names, in name order; absent when it names none."),
450
+ }),
425
451
  lint: z.strictObject({ diagnostics, skipped, unsandboxed }),
426
452
  cues: z.strictObject({
427
453
  name: z.string(),
package/src/cli/flags.ts CHANGED
@@ -52,6 +52,11 @@ export const FLAGS = {
52
52
  note: "for JSON, pass --json",
53
53
  text: "print diagnostics as SARIF",
54
54
  },
55
+ accept: {
56
+ type: "string",
57
+ value: "PLUGIN",
58
+ text: "pin the installed plugin's manifest though it changed from plugins/<name>/plugin.json",
59
+ },
55
60
  before: { type: "string", value: "SLUG", text: "move the section before this one" },
56
61
  after: { type: "string", value: "SLUG", text: "move the section after this one" },
57
62
  step: { type: "string", value: "ID|N", text: "the beat to capture (default: the last)" },
package/src/cli/lint.ts CHANGED
@@ -4,8 +4,9 @@ import type { Diagnostic } from "../core/diagnostic.ts";
4
4
  import { shellQuote } from "../core/escape.ts";
5
5
  import { lintDeck, lintProject, unreadableScriptDiagnostics } from "../core/lint.ts";
6
6
  import { playwrightMissingError } from "../core/playwright.ts";
7
+ import { checkDeck, onProjectData, pluginChecks } from "../core/plugin-host.ts";
7
8
  import { type RumdlRunner, type RumdlSkip, resolveRumdlBin, runRumdl } from "../core/rumdl.ts";
8
- import { mergeSarif, type SarifLog } from "../core/sarif.ts";
9
+ import { mergeSarif, pluginSarif, type SarifLog } from "../core/sarif.ts";
9
10
  import { syncDeck } from "../core/sync.ts";
10
11
  import { lintVisualDeck } from "../core/visual.ts";
11
12
  import { RENDERED_RULES, type SkippedCheck, skippedChecks, visualSkipped } from "./result.ts";
@@ -16,6 +17,8 @@ export type LintCliResult = {
16
17
  /** Checks that did not run, each with why and how to run it, so an empty list is not a pass. */
17
18
  skipped?: SkippedCheck[];
18
19
  rumdlSarif?: SarifLog;
20
+ /** A run per plugin that ran, for `--format sarif`. */
21
+ pluginSarif?: SarifLog;
19
22
  };
20
23
 
21
24
  /** `cwd` is where a skipped rumdl's hint says to run it from; `rumdl` runs it, dek's by default. */
@@ -32,6 +35,16 @@ export async function lintCommand(
32
35
  let rumdlSarif: SarifLog | undefined;
33
36
  // A rumdl that hung on one deck would hang on each, half a minute apiece.
34
37
  let rumdlHung = false;
38
+ // The plugins as the project accepted them; one that hung is run on no deck after, as rumdl.
39
+ const plugins = await pluginChecks(project);
40
+ diagnostics.push(...plugins.diagnostics);
41
+ const pluginRuns = plugins.ready.map((plugin) => ({
42
+ ...plugin,
43
+ diagnostics: [] as Diagnostic[],
44
+ hungOn: undefined as string | undefined,
45
+ skippedDecks: 0,
46
+ seen: new Set<string>(),
47
+ }));
35
48
 
36
49
  for (const deck of decks) {
37
50
  if (options.fix) {
@@ -50,6 +63,31 @@ export async function lintCommand(
50
63
  rumdlSarif = rumdlSarif ? mergeSarif(rumdlSarif, markdown.sarif) : markdown.sarif;
51
64
  }
52
65
  }
66
+ for (const plugin of pluginRuns) {
67
+ if (plugin.hungOn !== undefined) {
68
+ plugin.skippedDecks += 1;
69
+ continue;
70
+ }
71
+ const outcome = await checkDeck(plugin, project, deck);
72
+ // A finding on the project's data comes back from each deck; it is reported once (ADR 35).
73
+ const fresh = outcome.diagnostics.filter((finding) => {
74
+ if (!onProjectData(finding, project.root, plugin.name)) {
75
+ return true;
76
+ }
77
+ const { id, path, line, column, message } = finding;
78
+ const key = JSON.stringify([id, path, line, column, message]);
79
+ if (plugin.seen.has(key)) {
80
+ return false;
81
+ }
82
+ plugin.seen.add(key);
83
+ return true;
84
+ });
85
+ diagnostics.push(...fresh);
86
+ plugin.diagnostics.push(...fresh.filter(({ id }) => id !== "DEK046"));
87
+ if (outcome.hung) {
88
+ plugin.hungOn = deck.name;
89
+ }
90
+ }
53
91
  if (options.visual) {
54
92
  const visual = await lintVisualDeck({ project, deck });
55
93
  if (visual === null) {
@@ -63,6 +101,19 @@ export async function lintCommand(
63
101
  diagnostics,
64
102
  ...skippedChecks([
65
103
  ...(rumdlSkip ? [rumdlSkip] : []),
104
+ ...plugins.skipped,
105
+ ...pluginRuns.flatMap((plugin): SkippedCheck[] =>
106
+ plugin.skippedDecks > 0
107
+ ? [
108
+ {
109
+ check: "plugin",
110
+ plugin: plugin.name,
111
+ reason: `dek-${plugin.name} hung on ${plugin.hungOn}, so it ran on no deck after`,
112
+ hint: `tell the user: dek-${plugin.name} hangs, and its author can fix it`,
113
+ },
114
+ ]
115
+ : [],
116
+ ),
66
117
  ...(options.visual
67
118
  ? []
68
119
  : [
@@ -72,6 +123,7 @@ export async function lintCommand(
72
123
  ]),
73
124
  ]),
74
125
  ...(rumdlSarif ? { rumdlSarif } : {}),
126
+ ...(pluginRuns.length > 0 ? { pluginSarif: pluginSarif(pluginRuns) } : {}),
75
127
  };
76
128
  }
77
129
 
package/src/cli/sync.ts CHANGED
@@ -1,22 +1,37 @@
1
1
  import { join } from "node:path";
2
+ import { type PluginSync, syncPlugins } from "../core/plugin-host.ts";
2
3
  import { syncDeck } from "../core/sync.ts";
3
4
  import { ignoreLocalState } from "./files.ts";
4
5
  import type { DecksTarget } from "./scope.ts";
5
6
 
6
7
  /**
7
- * Every file the sync wrote or removed: the decks' slides, then dek's own files, then .gitignore
8
- * when it lacked what dek keeps for itself; and the slides it kept though their section is gone,
9
- * which lint reports as DEK002.
8
+ * Every file the sync wrote or removed: the plugins' locks, the decks' slides, then dek's own
9
+ * files, then .gitignore when it lacked what dek keeps for itself; the slides it kept though their
10
+ * section is gone, which lint reports as DEK002; and what it did with each plugin dek.toml names.
10
11
  */
11
12
  export type SyncCliResult = {
12
13
  created: string[];
13
14
  updated: string[];
14
15
  removed: string[];
15
16
  kept: string[];
17
+ plugins?: PluginSync[];
16
18
  };
17
19
 
18
- export function syncCommand({ project, decks }: DecksTarget): SyncCliResult {
19
- const result: SyncCliResult = { created: [], updated: [], removed: [], kept: [] };
20
+ /**
21
+ * `accept` names a plugin whose changed manifest to pin anyway. The plugins go first, so the
22
+ * AGENTS.md the decks' sync writes lists them as they now stand.
23
+ */
24
+ export async function syncCommand(
25
+ { project, decks }: DecksTarget,
26
+ options: { accept?: string } = {},
27
+ ): Promise<SyncCliResult> {
28
+ const plugins = await syncPlugins(project, options);
29
+ const result: SyncCliResult = {
30
+ created: [...plugins.created],
31
+ updated: [...plugins.updated],
32
+ removed: [],
33
+ kept: [],
34
+ };
20
35
  const dek = { created: [] as string[], updated: [] as string[] };
21
36
  for (const deck of decks) {
22
37
  const synced = syncDeck({ project, deck });
@@ -33,5 +48,5 @@ export function syncCommand({ project, decks }: DecksTarget): SyncCliResult {
33
48
  if (ignoreLocalState(project.root)) {
34
49
  result.updated.push(join(project.root, ".gitignore"));
35
50
  }
36
- return result;
51
+ return plugins.plugins.length > 0 ? { ...result, plugins: plugins.plugins } : result;
37
52
  }
package/src/cli/text.ts CHANGED
@@ -1,9 +1,10 @@
1
1
  // How each command's result reads as text; commands.ts says which command prints which.
2
2
  // The pieces the dev server's log shares, such as diagnostics and tables, are in format.ts.
3
- import type { Diagnostic, SkippedCheck } from "../core/diagnostic.ts";
3
+ import { type Diagnostic, type SkippedCheck, skippedLabel } from "../core/diagnostic.ts";
4
4
  import { shellQuote } from "../core/escape.ts";
5
5
  import { foreignLine, type HiddenText, TITLE_MAX } from "../core/foreign-text.ts";
6
6
  import { compareCodeUnits } from "../core/order.ts";
7
+ import type { PluginSync } from "../core/plugin-host.ts";
7
8
  import { unprinted } from "../core/prose.ts";
8
9
  import { packFile, parseRefSource } from "../core/ref-name.ts";
9
10
  import { formatClock } from "../core/timing.ts";
@@ -65,7 +66,7 @@ export function formatSkipped(skipped: SkippedCheck[] | undefined, color = false
65
66
  const c = ansi(color);
66
67
  return (skipped ?? [])
67
68
  .flatMap((entry) => [
68
- `${entry.check}: skipped (${entry.reason})`,
69
+ `${skippedLabel(entry)}: skipped (${entry.reason})`,
69
70
  ...(entry.hint ? [` ${c.yellow("help:")} ${entry.hint}`] : []),
70
71
  ])
71
72
  .join("\n");
@@ -118,6 +119,23 @@ export function formatKept(kept: string[], color = false): string {
118
119
  .join("\n");
119
120
  }
120
121
 
122
+ /**
123
+ * The plugins sync could not pin as they are, for stderr: one whose manifest changed, with what
124
+ * changed, and one dek could not find or read, with why; a plugin pinned or current says nothing.
125
+ */
126
+ export function formatPluginNotes(plugins: PluginSync[] | undefined, color = false): string {
127
+ const c = ansi(color);
128
+ return (plugins ?? [])
129
+ .filter(({ status }) => status !== "accepted" && status !== "current")
130
+ .flatMap((plugin) => [
131
+ plugin.status === "changed"
132
+ ? `plugin ${plugin.name} changed: ${(plugin.changes ?? [plugin.reason ?? ""]).join("; ")}`
133
+ : `plugin ${plugin.name}: ${plugin.reason ?? plugin.status}`,
134
+ ...(plugin.hint ? [` ${c.yellow("help:")} ${plugin.hint}`] : []),
135
+ ])
136
+ .join("\n");
137
+ }
138
+
121
139
  /**
122
140
  * A theme.css init kept that is no dekc theme, for stderr: every deck copies it, so each would fail
123
141
  * lint on its first run with a DEK015 per token.
package/src/core/build.ts CHANGED
@@ -4,6 +4,7 @@ import { loadConfig } from "./config.ts";
4
4
  import { renderDeckDocument } from "./document.ts";
5
5
  import { type DistOptions, distFile } from "./path.ts";
6
6
  import type { PlaywrightRunner } from "./playwright.ts";
7
+ import { pluginPages } from "./plugin-host.ts";
7
8
  import { asResolvedDeck, type ResolvedDeck } from "./resolve.ts";
8
9
  import { removeInside, writeInside } from "./safe-fs.ts";
9
10
  import { coverShot } from "./shot/still.ts";
@@ -22,6 +23,8 @@ export type BuildResult = { outPath: string } & (
22
23
 
23
24
  export type BuildOptions = DistOptions & {
24
25
  playerScript: string;
26
+ /** The pages of the plugins dek ships, compiled, by name; a pinned one's goes into the page. */
27
+ pluginScripts?: Record<string, string>;
25
28
  /** Where dist/ is served from, ending in a slash; overrides `url` in dek.toml. */
26
29
  url?: string;
27
30
  /** For anyone with the link: the presenter view carries only what is said aloud. */
@@ -50,6 +53,7 @@ export async function buildDeck(
50
53
  }
51
54
  const html = renderDeckDocument(deck, {
52
55
  playerScript: options.playerScript,
56
+ plugins: pluginPages(project.root, deck.dir, options.pluginScripts ?? {}),
53
57
  target: {
54
58
  kind: "build",
55
59
  ...(options.public ? { public: true } : {}),
@@ -1,4 +1,5 @@
1
- import { DekToml, Pack, Voice } from "./config.ts";
1
+ import { z } from "zod";
2
+ import { DekToml, Pack, PluginTable, Voice } from "./config.ts";
2
3
  import { splitLines } from "./lines.ts";
3
4
  import { readFrontmatterYaml } from "./parse.ts";
4
5
  import { Frontmatter } from "./schema.ts";
@@ -9,10 +10,14 @@ import { unrecognizedKeys } from "./zod.ts";
9
10
  /** A key dek does not read, where it is, and the known key it most likely meant. */
10
11
  export type UnknownKey = { key: string; line: number; suggestion?: string };
11
12
 
12
- /** dek.toml's schema, but a key it does not read is an issue; `refs` still takes any key. */
13
+ /**
14
+ * dek.toml's schema, but a key it does not read is an issue; `refs` and `plugins` still take any
15
+ * name, and a plugin's table, none of its own.
16
+ */
13
17
  const StrictDekToml = DekToml.extend({
14
18
  voice: Voice.strict().optional(),
15
19
  pack: Pack.strict().optional(),
20
+ plugins: z.record(z.string(), PluginTable.strict()).optional(),
16
21
  }).strict();
17
22
 
18
23
  /** Every key dek.toml may hold, as dotted paths. */
@@ -4,6 +4,7 @@ import { DekError } from "./error.ts";
4
4
  import { shellQuote } from "./escape.ts";
5
5
  import { isPackLicense, PACK_LICENSES, type PackLicense } from "./licenses.ts";
6
6
  import { parsePublicUrl } from "./ogp.ts";
7
+ import { pluginNameProblem } from "./plugin.ts";
7
8
  import {
8
9
  isPinFor,
9
10
  isPlainRefName,
@@ -31,6 +32,12 @@ export const Pack = z.object({
31
32
  .optional(),
32
33
  });
33
34
 
35
+ /**
36
+ * A plugin's table in `[plugins]`. It takes no key yet: a project chooses which of the plugins the
37
+ * user installed it uses, and never names a path, an argument, or a program for dek to run.
38
+ */
39
+ export const PluginTable = z.object({});
40
+
34
41
  export const DekToml = z.object({
35
42
  url: z
36
43
  .string()
@@ -71,6 +78,18 @@ export const DekToml = z.object({
71
78
  })
72
79
  .transform((refs) => refs as Record<string, string>)
73
80
  .optional(),
81
+ // A name that is no plugin's is refused without being repeated, as a ref's is.
82
+ plugins: z
83
+ .record(z.string(), PluginTable)
84
+ .superRefine((plugins, ctx) => {
85
+ for (const name of Object.keys(plugins)) {
86
+ const problem = pluginNameProblem(name);
87
+ if (problem) {
88
+ ctx.addIssue({ code: "custom", path: [], message: problem });
89
+ }
90
+ }
91
+ })
92
+ .optional(),
74
93
  });
75
94
 
76
95
  const REFUSED_REF_KEY = "a key in [refs] names no ref";
@@ -107,6 +126,37 @@ function refusedRefKey(source: string): { line?: number } | undefined {
107
126
  return entry === undefined ? {} : { line: entry.line };
108
127
  }
109
128
 
129
+ /**
130
+ * The first name in [plugins] that is no plugin's, with what is wrong and its line. Like a ref's,
131
+ * the name may be someone else's text, so the error points at it and never repeats it.
132
+ */
133
+ function refusedPluginKey(source: string): { problem: string; line?: number } | undefined {
134
+ let parsed: unknown;
135
+ try {
136
+ parsed = Bun.TOML.parse(source);
137
+ } catch {
138
+ return undefined;
139
+ }
140
+ const plugins = (parsed as { plugins?: unknown } | null)?.plugins;
141
+ if (plugins === null || typeof plugins !== "object") {
142
+ return undefined;
143
+ }
144
+ for (const name of Object.keys(plugins)) {
145
+ const problem = pluginNameProblem(name);
146
+ if (problem === undefined) {
147
+ continue;
148
+ }
149
+ const written = scanTomlKeys(source).filter(
150
+ (entry) => entry.path[0] === "plugins" && entry.path.length === 2,
151
+ );
152
+ const entry =
153
+ written.find((key) => key.path[1] === name) ??
154
+ written.find((key) => pluginNameProblem(key.path[1] ?? "") !== undefined);
155
+ return entry === undefined ? { problem } : { problem, line: entry.line };
156
+ }
157
+ return undefined;
158
+ }
159
+
110
160
  type VoiceDefaults = {
111
161
  engine: string;
112
162
  speaker: string;
@@ -126,6 +176,8 @@ export type DekConfig = {
126
176
  pack?: { license?: PackLicense };
127
177
  /** `[refs]`: each ref name and what it is pinned to: a commit, or a pack's digest. */
128
178
  refs?: Record<string, string>;
179
+ /** `[plugins]`: the name of each plugin the project uses, sorted, as dek runs them. */
180
+ plugins?: string[];
129
181
  };
130
182
 
131
183
  export function parseDekToml(source: string, path?: string): DekConfig {
@@ -133,6 +185,14 @@ export function parseDekToml(source: string, path?: string): DekConfig {
133
185
  if (refused) {
134
186
  throw new DekError(REFUSED_REF_KEY, { path, ...refused, hint: REFUSED_REF_HINT });
135
187
  }
188
+ const plugin = refusedPluginKey(source);
189
+ if (plugin) {
190
+ throw new DekError(`a name in [plugins] is no plugin's: ${plugin.problem}`, {
191
+ path,
192
+ ...(plugin.line === undefined ? {} : { line: plugin.line }),
193
+ hint: "rename the table to the <name> of the dek-<name> you installed, or delete it",
194
+ });
195
+ }
136
196
  const data = parseTomlWith(DekToml, source, {
137
197
  label: "dek.toml",
138
198
  anchor: "dek-toml",
@@ -155,6 +215,9 @@ export function parseDekToml(source: string, path?: string): DekConfig {
155
215
  ? { pack: data.pack.license ? { license: data.pack.license as PackLicense } : {} }
156
216
  : {}),
157
217
  ...(data.refs && Object.keys(data.refs).length > 0 ? { refs: data.refs } : {}),
218
+ ...(data.plugins && Object.keys(data.plugins).length > 0
219
+ ? { plugins: Object.keys(data.plugins).sort() }
220
+ : {}),
158
221
  };
159
222
  }
160
223
 
@@ -64,6 +64,7 @@ export const RULES = {
64
64
  DEK043: { severity: "warning", scopes: ["deck"] },
65
65
  DEK044: { severity: "warning", scopes: ["deck", "slide"] },
66
66
  DEK045: { severity: "warning", scopes: ["deck"] },
67
+ DEK046: { severity: "error", scopes: ["deck"] },
67
68
  } as const satisfies Record<string, { severity: Severity; scopes: readonly Scope[] }>;
68
69
 
69
70
  export type RuleId = keyof typeof RULES;
@@ -113,7 +114,14 @@ export function hasErrors(diagnostics: Diagnostic[]): boolean {
113
114
  */
114
115
  export type SkippedCheck = {
115
116
  /** `preview` is a build's link preview image, which needs a URL and Playwright. */
116
- check: "lint" | "preview" | "rumdl" | "visual" | "voice";
117
+ check: "lint" | "plugin" | "preview" | "rumdl" | "visual" | "voice";
118
+ /** The plugin, for a `plugin` check: its name in dek.toml `[plugins]`. */
119
+ plugin?: string;
117
120
  reason: string;
118
121
  hint?: string;
119
122
  };
123
+
124
+ /** A skipped check as text names it: the check, or the plugin for a plugin's. */
125
+ export function skippedLabel(entry: SkippedCheck): string {
126
+ return entry.plugin === undefined ? entry.check : `plugin ${entry.plugin}`;
127
+ }
@@ -4,6 +4,7 @@ import { escapeAttr, escapeHtml } from "./escape.ts";
4
4
  import { collectSlidesHtml, htmlShell } from "./html.ts";
5
5
  import { type OgpImage, ogpHead } from "./ogp.ts";
6
6
  import { PAGE_ID, type PageMode, pageConfigAttrs } from "./page.ts";
7
+ import type { PluginPage } from "./plugin.ts";
7
8
  import {
8
9
  nextPresenterTitle,
9
10
  presenterSlides,
@@ -49,14 +50,19 @@ export type PageTarget =
49
50
  /** The deck at `dir`, rendered for `target`. */
50
51
  export function renderDeckHtml(
51
52
  dir: string,
52
- options: { playerScript: string; target: PageTarget },
53
+ options: { playerScript: string; target: PageTarget; plugins?: PluginPage[] },
53
54
  ): string {
54
55
  return renderDeckDocument(resolveDeck(dir).deck, options);
55
56
  }
56
57
 
57
58
  export function renderDeckDocument(
58
59
  deck: ProjectDeck,
59
- options: { playerScript: string; target: PageTarget },
60
+ options: {
61
+ playerScript: string;
62
+ target: PageTarget;
63
+ /** Each plugin's page; only the player and the build carry them (ADR 33). */
64
+ plugins?: PluginPage[];
65
+ },
60
66
  ): string {
61
67
  const { target } = options;
62
68
  const mode: PageMode =
@@ -160,7 +166,8 @@ export function renderDeckDocument(
160
166
  ${slideScriptTags(slideScripts)}
161
167
  <script>${options.playerScript}</script>
162
168
  ${target.kind === "dev" ? `<script>${target.liveReloadScript}</script>` : ""}
163
- ${target.kind === "dev" && includeNotes ? `<script>${target.annotateScript}</script>` : ""}`,
169
+ ${target.kind === "dev" && includeNotes ? `<script>${target.annotateScript}</script>` : ""}
170
+ ${pluginPagesHtml(target, options.plugins ?? [])}`,
164
171
  });
165
172
  }
166
173
 
@@ -225,6 +232,24 @@ function renderKeyHintHtml(lang: string): string {
225
232
  return `<div id="${PAGE_ID.hint}" role="status"><span><kbd>s</kbd>${labels.rail}</span><span><kbd>p</kbd>${labels.presenter}</span></div>`;
226
233
  }
227
234
 
235
+ /**
236
+ * The plugins' pages, after the player, which gives them `dek.plugin`: their keys and data as one
237
+ * JSON block, then each script in name order. Only the player and the build carry them; the
238
+ * presenter view and a video, like every still page, never do.
239
+ */
240
+ function pluginPagesHtml(target: PageTarget, plugins: PluginPage[]): string {
241
+ const carried = target.kind === "build" || (target.kind === "dev" && target.mode === "player");
242
+ if (!carried || plugins.length === 0) {
243
+ return "";
244
+ }
245
+ const ordered = plugins.toSorted((a, b) => a.name.localeCompare(b.name));
246
+ const config = Object.fromEntries(ordered.map(({ name, keys, data }) => [name, { keys, data }]));
247
+ return [
248
+ `<script type="application/json" id="${PAGE_ID.plugins}">${jsonForScript(config)}</script>`,
249
+ ...ordered.map(({ script }) => `<script>${script.replace(/<\/(script)/gi, "<\\/$1")}</script>`),
250
+ ].join("\n ");
251
+ }
252
+
228
253
  function jsonForScript(value: unknown): string {
229
254
  return JSON.stringify(value).replace(/</g, "\\u003c");
230
255
  }
package/src/core/page.ts CHANGED
@@ -7,6 +7,7 @@ import { escapeAttr } from "./escape.ts";
7
7
  export const PAGE_ID = {
8
8
  deck: "deck",
9
9
  data: "dek-data",
10
+ plugins: "dek-plugins",
10
11
  shell: "dek-shell",
11
12
  rail: "dek-rail",
12
13
  railResize: "dek-rail-resize",