@hajimism/dek 0.5.0 → 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 (58) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/package.json +12 -5
  3. package/src/cli/address.ts +48 -11
  4. package/src/cli/build.ts +3 -1
  5. package/src/cli/commands.ts +24 -10
  6. package/src/cli/contract.ts +28 -2
  7. package/src/cli/flags.ts +5 -0
  8. package/src/cli/keep-alive.ts +12 -6
  9. package/src/cli/lint.ts +53 -1
  10. package/src/cli/main.ts +2 -2
  11. package/src/cli/sync.ts +21 -6
  12. package/src/cli/text.ts +20 -2
  13. package/src/cli/theme.ts +1 -1
  14. package/src/core/build.ts +4 -0
  15. package/src/core/config-keys.ts +7 -2
  16. package/src/core/config.ts +63 -0
  17. package/src/core/diagnostic.ts +9 -1
  18. package/src/core/document.ts +28 -3
  19. package/src/core/lint/context.ts +2 -0
  20. package/src/core/lint/shared-styles.ts +26 -1
  21. package/src/core/lint/theme-css.ts +64 -3
  22. package/src/core/lint.ts +15 -2
  23. package/src/core/page.ts +1 -0
  24. package/src/core/plugin-host.ts +669 -0
  25. package/src/core/plugin.ts +436 -0
  26. package/src/core/sandbox.ts +1 -1
  27. package/src/core/sarif.ts +47 -5
  28. package/src/core/slide-measure.ts +1 -0
  29. package/src/core/spawn.ts +9 -7
  30. package/src/core/sync.ts +3 -1
  31. package/src/plugin-api.ts +126 -0
  32. package/src/plugins/study/check.ts +128 -0
  33. package/src/plugins/study/main.ts +47 -0
  34. package/src/plugins/study/manifest.ts +32 -0
  35. package/src/plugins/study/page.ts +295 -0
  36. package/src/plugins/study/place.ts +88 -0
  37. package/src/plugins/study/sarif.ts +30 -0
  38. package/src/plugins/study/study.ts +137 -0
  39. package/src/plugins/study/text.ts +55 -0
  40. package/src/plugins/study/yaml.ts +226 -0
  41. package/src/plugins/words/check.ts +109 -0
  42. package/src/plugins/words/lines.ts +26 -0
  43. package/src/plugins/words/list.ts +288 -0
  44. package/src/plugins/words/main.ts +50 -0
  45. package/src/plugins/words/manifest.ts +36 -0
  46. package/src/plugins/words/match.ts +60 -0
  47. package/src/plugins/words/sarif.ts +37 -0
  48. package/src/plugins/words/text.ts +352 -0
  49. package/src/plugins/words/yaml.ts +225 -0
  50. package/src/runtime/player.ts +18 -2
  51. package/src/runtime/plugins.ts +236 -0
  52. package/src/runtime/start.ts +16 -1
  53. package/src/runtime/window.ts +3 -0
  54. package/src/server/deck-snapshot.ts +32 -1
  55. package/src/server/dev.ts +9 -3
  56. package/src/server/lock.ts +39 -9
  57. package/src/server/pages.ts +9 -1
  58. package/tsconfig.json +1 -1
package/CHANGELOG.md CHANGED
@@ -2,6 +2,43 @@
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
+
23
+ ## [0.5.1](https://github.com/hajimism/dek/compare/v0.5.0...v0.5.1) (2026-10-30)
24
+
25
+
26
+ ### Bug Fixes
27
+
28
+ * **cli:** stop a dev server whose parent died before the server was up ([80b533f](https://github.com/hajimism/dek/commit/80b533fc691cfc104b6c83f7d99be4d73fc75c8d))
29
+ * **dev:** say when the running server serves another deck only ([333ae06](https://github.com/hajimism/dek/commit/333ae06818fa8ad09f5ed492ae33148cce9254fd))
30
+ * judge a command that skips its stdin by how it exits, not as one that cannot start ([103152c](https://github.com/hajimism/dek/commit/103152cd2132ba9c430ede4d7020844f7a8dcf9a))
31
+ * **lint:** free no DEK013 class a skeleton or a slide script may use ([e32638b](https://github.com/hajimism/dek/commit/e32638b12220055d8a5a329b9e93f9eda47371e8))
32
+ * **lint:** name the files a hint sends you to as paths from where the command ran ([5943dbe](https://github.com/hajimism/dek/commit/5943dbe1b11ac4529faae9cc1b98f22b4c63d214))
33
+ * **lint:** name the files a hint sends you to as paths from where the command ran ([c5fcce3](https://github.com/hajimism/dek/commit/c5fcce3101061a6eaad55b27d94019f5b028e762))
34
+ * **lint:** say how to stay within max_classes in DEK013 and DEK026 ([9c3078b](https://github.com/hajimism/dek/commit/9c3078bad4d93e5f381c99f5b6e0dd2c059f32dc))
35
+
36
+
37
+ ### Documentation
38
+
39
+ * say that DEK035 leaves a deck that breaks by phrase to Chromium ([c21b428](https://github.com/hajimism/dek/commit/c21b4284db4a46a231778a4fa8d42aac38c46b0d))
40
+ * say that DEK035 leaves a deck that breaks by phrase to Chromium ([dfef085](https://github.com/hajimism/dek/commit/dfef085bedf2d120c62a0f7418fd3d69f0a77001))
41
+
5
42
  ## [0.5.0](https://github.com/hajimism/dek/compare/v0.4.0...v0.5.0) (2026-10-27)
6
43
 
7
44
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hajimism/dek",
3
- "version": "0.5.0",
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.5.15",
65
- "@happy-dom/global-registrator": "21.0.1",
71
+ "@biomejs/biome": "2.6.4",
72
+ "@happy-dom/global-registrator": "21.0.4",
66
73
  "@types/bun": "^1.4.2",
67
74
  "fast-check": "4.10.2",
68
- "happy-dom": "21.0.1",
69
- "knip": "6.41.0",
75
+ "happy-dom": "21.0.4",
76
+ "knip": "6.43.0",
70
77
  "playwright": "^1.63.0",
71
78
  "publint": "0.3.25",
72
79
  "typescript": "^7.0.2",
@@ -1,30 +1,49 @@
1
1
  import { existsSync, statSync } from "node:fs";
2
- import { basename, dirname, join } from "node:path";
3
- import { shellName } from "../core/escape.ts";
2
+ import { basename, dirname, join, relative } from "node:path";
3
+ import { isPlainWord, shellName } from "../core/escape.ts";
4
4
  import { isInside } from "../core/path.ts";
5
5
  import { snapshotName } from "../core/ref.ts";
6
+ import { isRefName } from "../core/ref-name.ts";
6
7
  import { type AnySpec, COMMANDS, subcommandsOf } from "./commands.ts";
7
8
 
8
9
  /** A deck a hint is about: the name a command takes, and the directory that makes it implicit. */
9
10
  export type HintDeck = { name: string; dir: string };
10
11
 
11
12
  /**
12
- * A hint's commands as they run from `cwd`. Hints are written as if run inside the deck they are
13
- * about, where a command finds its deck by itself. Run anywhere else, a command that takes a deck
14
- * needs its name: as the first word outside every deck, and as `--deck` inside another deck,
15
- * where a first word names something in that deck instead, or where the name would read as a
16
- * subcommand or a flag. The name is one shell word, however it is spelled, or `<deck>` when no
17
- * word holds it alike in every shell, with where to find it unless `note` is false, for a hint
18
- * that joins several and says it once. A command that already names a deck, or takes none, is
19
- * left as written.
13
+ * A hint as it reads from `cwd`. Hints are written as if read inside the deck they are about:
14
+ * its commands are addressed as `addressCommands` says, and the deck files it names become paths
15
+ * from `cwd`, as the path of the diagnostic beside it is, except in a ref.
20
16
  */
21
17
  export function addressHint(
22
18
  hint: string,
23
19
  deck: HintDeck | undefined,
24
20
  cwd: string,
21
+ options: { note?: boolean } = {},
22
+ ): string {
23
+ if (deck === undefined) {
24
+ return hint;
25
+ }
26
+ const addressed = addressCommands(hint, deck, cwd, options);
27
+ // A ref's files are its author's to fix, so a hint about one names them as the ref does.
28
+ return isRefName(deck.name) ? addressed : addressFiles(addressed, deck.dir, cwd);
29
+ }
30
+
31
+ /**
32
+ * A hint's commands as they run from `cwd`. Inside the deck they are about, a command finds its
33
+ * deck by itself. Run anywhere else, a command that takes a deck needs its name: as the first
34
+ * word outside every deck, and as `--deck` inside another deck, where a first word names
35
+ * something in that deck instead, or where the name would read as a subcommand or a flag. The
36
+ * name is one shell word, however it is spelled, or `<deck>` when no word holds it alike in every
37
+ * shell, with where to find it unless `note` is false, for a hint that joins several and says it
38
+ * once. A command that already names a deck, or takes none, is left as written.
39
+ */
40
+ export function addressCommands(
41
+ hint: string,
42
+ deck: HintDeck,
43
+ cwd: string,
25
44
  { note: noted = true }: { note?: boolean } = {},
26
45
  ): string {
27
- if (deck === undefined || isInside(cwd, deck.dir)) {
46
+ if (isInside(cwd, deck.dir)) {
28
47
  return hint;
29
48
  }
30
49
  const word = shellName(deck.name) ?? DECK_PLACEHOLDER;
@@ -80,6 +99,24 @@ export function deckFlag(name: string): string {
80
99
  return name.startsWith("-") ? `--deck=${word}` : `--deck ${word}`;
81
100
  }
82
101
 
102
+ /**
103
+ * A deck file as a hint names it from the deck: a slide's own file, a voice file, theme.css, or
104
+ * script.md, or a project file reached with `../../`. Not part of a longer path or word. A path
105
+ * from `cwd` that is no plain word, through a folder a hint may not name, is left as written.
106
+ */
107
+ const DECK_FILE_RE =
108
+ /(?<![\w./-])(\.\.\/\.\.\/[\w./-]*[\w-]|(?:slides|voice)\/[\w.-]*[\w-]|theme\.css|script\.md)(?![\w/-])/g;
109
+
110
+ function addressFiles(hint: string, dir: string, cwd: string): string {
111
+ if (cwd === dir) {
112
+ return hint;
113
+ }
114
+ return hint.replace(DECK_FILE_RE, (file) => {
115
+ const path = relative(cwd, join(dir, file)) || ".";
116
+ return isPlainWord(path) ? path : file;
117
+ });
118
+ }
119
+
83
120
  /** A backquoted command: `dekc`, a command word, and what follows it up to the closing quote. */
84
121
  const COMMAND_RE = /`dekc ([a-z]+)((?: [^`]*)?)`/g;
85
122
 
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)" },
@@ -7,6 +7,13 @@ const PARENT_POLL_MS = 1_000;
7
7
  /** How long a stop waits for the server to close before the process exits anyway. */
8
8
  const SHUTDOWN_GRACE_MS = 2_000;
9
9
 
10
+ /**
11
+ * The process that started this one, read as the CLI loads. A parent read once the server is up
12
+ * would come too late: whoever waits for the URL can be killed before the next line runs, and the
13
+ * parent read then is already init, which never goes away.
14
+ */
15
+ const startedBy = process.ppid;
16
+
10
17
  /**
11
18
  * Serve until told to stop: Ctrl-C, a kill, a closed terminal, or the process that started the
12
19
  * server going away, so a test run or an editor that dies without cleaning up leaves no server
@@ -51,16 +58,15 @@ export function stopOnce(
51
58
  }
52
59
 
53
60
  /**
54
- * Call `fn` once this process is reparented, which is how Unix says the parent died: a SIGKILL
55
- * leaves it no chance to stop its children. A process started with no parent of its own to lose
56
- * (already under init) is never orphaned.
61
+ * Call `fn` once this process is no longer under `parent`, the one that started it, which is how
62
+ * Unix says the parent died: a SIGKILL leaves it no chance to stop its children. A process started
63
+ * with no parent of its own to lose (already under init) is never orphaned.
57
64
  */
58
65
  export function onOrphaned(
59
66
  fn: () => void,
60
- options: { ppid?: () => number; intervalMs?: number } = {},
67
+ options: { parent?: number; ppid?: () => number; intervalMs?: number } = {},
61
68
  ): () => void {
62
- const ppid = options.ppid ?? (() => process.ppid);
63
- const parent = ppid();
69
+ const { parent = startedBy, ppid = () => process.ppid } = options;
64
70
  const timer = setInterval(() => {
65
71
  if (ppid() !== parent) {
66
72
  clearInterval(timer);
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/main.ts CHANGED
@@ -7,7 +7,7 @@ import { isRefName } from "../core/ref-name.ts";
7
7
  import { type ProjectDeck, readDeckFile, resolveProject } from "../core/resolve.ts";
8
8
  import { takeUnsandboxed } from "../core/sandbox.ts";
9
9
  import { themeFacts } from "../core/theme-facts.ts";
10
- import { addressHint, deckWord, type HintDeck, withPlaceholderNote } from "./address.ts";
10
+ import { addressCommands, deckWord, type HintDeck, withPlaceholderNote } from "./address.ts";
11
11
  import {
12
12
  type AnySpec,
13
13
  type CliResult,
@@ -207,7 +207,7 @@ function retypedWithDeck<T>(line: CommandLine, cwd: string, call: Call, resolve:
207
207
  const decks = error.decks.filter((deck) => runsIn(call, deck));
208
208
  const [first] = decks;
209
209
  // Each command is retyped for one deck; where to find a name left as <deck> is said once.
210
- const retyped = (deck: HintDeck) => addressHint(typed, deck, cwd, { note: false });
210
+ const retyped = (deck: HintDeck) => addressCommands(typed, deck, cwd, { note: false });
211
211
  const hint =
212
212
  error.decks.length === 0
213
213
  ? "run `dekc new <name>` to make a deck"
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/cli/theme.ts CHANGED
@@ -36,7 +36,7 @@ export function themeCommand({ deck, ref }: ReadableDeck, name?: string): ThemeR
36
36
  if (!existsSync(path)) {
37
37
  throw new DekError("theme.css not found", {
38
38
  path,
39
- hint: "copy the project theme.css into the deck, or run `dekc new <name>` for a fresh deck",
39
+ hint: "copy the project's template, ../../theme.css, into the deck, or run `dekc new <name>` for a fresh deck",
40
40
  });
41
41
  }
42
42
  const css = readDeckFile(deck.dir, path) ?? "";
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. */