@hajimism/dek 0.5.0 → 0.5.1

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/CHANGELOG.md CHANGED
@@ -2,6 +2,25 @@
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.5.1](https://github.com/hajimism/dek/compare/v0.5.0...v0.5.1) (2026-10-30)
6
+
7
+
8
+ ### Bug Fixes
9
+
10
+ * **cli:** stop a dev server whose parent died before the server was up ([80b533f](https://github.com/hajimism/dek/commit/80b533fc691cfc104b6c83f7d99be4d73fc75c8d))
11
+ * **dev:** say when the running server serves another deck only ([333ae06](https://github.com/hajimism/dek/commit/333ae06818fa8ad09f5ed492ae33148cce9254fd))
12
+ * 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))
13
+ * **lint:** free no DEK013 class a skeleton or a slide script may use ([e32638b](https://github.com/hajimism/dek/commit/e32638b12220055d8a5a329b9e93f9eda47371e8))
14
+ * **lint:** name the files a hint sends you to as paths from where the command ran ([5943dbe](https://github.com/hajimism/dek/commit/5943dbe1b11ac4529faae9cc1b98f22b4c63d214))
15
+ * **lint:** name the files a hint sends you to as paths from where the command ran ([c5fcce3](https://github.com/hajimism/dek/commit/c5fcce3101061a6eaad55b27d94019f5b028e762))
16
+ * **lint:** say how to stay within max_classes in DEK013 and DEK026 ([9c3078b](https://github.com/hajimism/dek/commit/9c3078bad4d93e5f381c99f5b6e0dd2c059f32dc))
17
+
18
+
19
+ ### Documentation
20
+
21
+ * say that DEK035 leaves a deck that breaks by phrase to Chromium ([c21b428](https://github.com/hajimism/dek/commit/c21b4284db4a46a231778a4fa8d42aac38c46b0d))
22
+ * say that DEK035 leaves a deck that breaks by phrase to Chromium ([dfef085](https://github.com/hajimism/dek/commit/dfef085bedf2d120c62a0f7418fd3d69f0a77001))
23
+
5
24
  ## [0.5.0](https://github.com/hajimism/dek/compare/v0.4.0...v0.5.0) (2026-10-27)
6
25
 
7
26
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hajimism/dek",
3
- "version": "0.5.0",
3
+ "version": "0.5.1",
4
4
  "description": "A build system for talks.",
5
5
  "license": "MIT",
6
6
  "author": "Hajime Nakagawa (https://github.com/hajimism)",
@@ -61,12 +61,12 @@
61
61
  "zod": "^4.6.5"
62
62
  },
63
63
  "devDependencies": {
64
- "@biomejs/biome": "2.5.15",
65
- "@happy-dom/global-registrator": "21.0.1",
64
+ "@biomejs/biome": "2.6.2",
65
+ "@happy-dom/global-registrator": "21.0.4",
66
66
  "@types/bun": "^1.4.2",
67
67
  "fast-check": "4.10.2",
68
- "happy-dom": "21.0.1",
69
- "knip": "6.41.0",
68
+ "happy-dom": "21.0.4",
69
+ "knip": "6.42.1",
70
70
  "playwright": "^1.63.0",
71
71
  "publint": "0.3.25",
72
72
  "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
 
@@ -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/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/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) ?? "";
@@ -26,6 +26,8 @@ type SlideSource = { html: string; scan: HtmlScan; skeleton: boolean };
26
26
  export type LintContext = DeckFiles & {
27
27
  /** DEK016 and DEK017 of each slide script, by slug: evaluated before the rules run. */
28
28
  scripts: Map<string, Diagnostic[]>;
29
+ /** The source of each slide script lint evaluated, by slug. */
30
+ scriptCode: Map<string, string>;
29
31
  };
30
32
 
31
33
  /** The deck as read from disk, each file once. */
@@ -2,6 +2,7 @@ import { isKeyframesPrelude, selectorClasses, splitSelectorList, startsAtSlide }
2
2
  import { deckPaths } from "../deck-paths.ts";
3
3
  import { type Diagnostic, diag } from "../diagnostic.ts";
4
4
  import { listed } from "../prose.ts";
5
+ import { RUNTIME_CLASSES } from "../theme-facts.ts";
5
6
  import type { LintContext } from "./context.ts";
6
7
 
7
8
  /** How many slides may style something alike before it belongs in the theme: the third copy. */
@@ -56,6 +57,21 @@ function alsoReaches(ctx: LintContext, selector: string, setters: string[]): str
56
57
  });
57
58
  }
58
59
 
60
+ /**
61
+ * How many classes theme.css would define with a rule under `selector` moved into it, when that
62
+ * is more than the deck's `max_classes` (DEK013); nothing while the move stays within it.
63
+ */
64
+ function overBudget(ctx: LintContext, selector: string): number | undefined {
65
+ const defined = ctx.theme?.classes ?? new Set<string>();
66
+ const added = new Set(
67
+ selectorClasses(themeSelector(selector)).filter(
68
+ (name) => !defined.has(name) && !RUNTIME_CLASSES.has(name),
69
+ ),
70
+ );
71
+ const after = defined.size + added.size;
72
+ return added.size > 0 && after > ctx.deck.deck.max_classes ? after : undefined;
73
+ }
74
+
59
75
  /**
60
76
  * DEK026: the same declaration under the same selector in the stylesheets of SHARED_BY slides
61
77
  * or more. Each slide fixed alike on its own, as agents working on one slide each will, is one
@@ -146,18 +162,27 @@ export function sharedStyleDiagnostics(ctx: LintContext): Diagnostic[] {
146
162
  .join(
147
163
  "",
148
164
  )} without it, so check ${reaches.length === 1 ? "that slide" : "those slides"} after the move`;
165
+ // Following the hint must not trade this warning for DEK013's error.
166
+ const themeClasses = overBudget(ctx, selector);
167
+ const budget =
168
+ themeClasses === undefined
169
+ ? ""
170
+ : `; theme.css would then define ${themeClasses} classes, past max_classes ${ctx.deck.deck.max_classes}, so set \`max_classes: ${themeClasses}\` in the frontmatter too, or leave the rule in the slides`;
149
171
  return diag("DEK026", {
150
172
  message: `${rule} is also in ${listed(others)}`,
151
173
  path: paths.slide(slug, ".css"),
152
174
  line,
153
175
  slug,
154
- hint: `write ${themeRule} once in theme.css and delete it from ${listed(slugs.map(cssOf))}${affected}`,
176
+ hint: `write ${themeRule} once in theme.css and delete it from ${listed(slugs.map(cssOf))}${affected}${budget}`,
155
177
  data: {
156
178
  selector,
157
179
  declarations,
158
180
  slides: slugs,
159
181
  ...(atPath.length > 0 ? { atRules: atPath } : {}),
160
182
  ...(reaches.length > 0 ? { alsoReaches: reaches } : {}),
183
+ ...(themeClasses !== undefined
184
+ ? { themeClasses, limit: ctx.deck.deck.max_classes }
185
+ : {}),
161
186
  },
162
187
  });
163
188
  }),
@@ -12,6 +12,8 @@ import {
12
12
  } from "../css.ts";
13
13
  import { deckPaths } from "../deck-paths.ts";
14
14
  import { type Diagnostic, diag } from "../diagnostic.ts";
15
+ import { escapeRegExp } from "../escape.ts";
16
+ import { listed } from "../prose.ts";
15
17
  import { assetRefDiagnostics } from "./asset-refs.ts";
16
18
  import type { LintContext } from "./context.ts";
17
19
  import { isRawThemeValue, isRawTokenValue, REQUIRED_TOKENS, tokenSuggestion } from "./tokens.ts";
@@ -24,14 +26,18 @@ function missingThemeDiagnostic(ctx: LintContext): Diagnostic {
24
26
  return diag("DEK018", {
25
27
  message: "theme.css not found",
26
28
  path: deckPaths(ctx.deck.dir).theme,
27
- hint: "copy theme.css from the project root or another deck into the deck directory",
29
+ hint: "copy the project's template, ../../theme.css, or another deck's theme into the deck directory",
28
30
  });
29
31
  }
30
32
 
31
33
  /** DEK012, DEK013, DEK015, DEK014, and what its `url()`s load (DEK020 to DEK023) for theme.css. */
32
34
  function lintTheme(
33
35
  { path, sheet, classes, tokens }: NonNullable<LintContext["theme"]>,
34
- { maxClasses, deckDir }: { maxClasses: number; deckDir: string },
36
+ {
37
+ maxClasses,
38
+ deckDir,
39
+ usedBy,
40
+ }: { maxClasses: number; deckDir: string; usedBy: Map<string, string[]> | undefined },
35
41
  ): Diagnostic[] {
36
42
  const diagnostics: Diagnostic[] = [];
37
43
  // A rule inside `@media` reaches the page as surely as one outside it.
@@ -56,6 +62,7 @@ function lintTheme(
56
62
  diag("DEK013", {
57
63
  message: `theme.css has ${classCount} classes; limit is ${maxClasses}`,
58
64
  path,
65
+ hint: classBudgetHint(classCount, usedBy),
59
66
  data: { classes: classCount, limit: maxClasses },
60
67
  }),
61
68
  );
@@ -287,9 +294,63 @@ function themeScopeHint(selector: string): string {
287
294
  return `write it as ${scoped.join(", ")}`;
288
295
  }
289
296
 
297
+ /**
298
+ * How to bring the theme back within its class budget: first what frees a class without changing
299
+ * a slide, a class no slide uses and one only one slide uses, then raising the budget itself.
300
+ */
301
+ function classBudgetHint(classCount: number, usedBy: Map<string, string[]> | undefined): string {
302
+ if (usedBy === undefined) {
303
+ return `set \`max_classes: ${classCount}\` in the frontmatter`;
304
+ }
305
+ const unused = [...usedBy].filter(([, slugs]) => slugs.length === 0).map(([name]) => `.${name}`);
306
+ const single = [...usedBy].flatMap(([name, slugs]) =>
307
+ slugs.length === 1 ? [`.${name} into slides/${slugs[0]}.css`] : [],
308
+ );
309
+ return [
310
+ ...(unused.length > 0 ? [`delete ${listed(unused)}, which no slide uses`] : []),
311
+ ...(single.length > 0
312
+ ? [`move each class only one slide uses into that slide's stylesheet: ${listed(single)}`]
313
+ : []),
314
+ `set \`max_classes: ${classCount}\` in the frontmatter`,
315
+ ].join("; or ");
316
+ }
317
+
318
+ /**
319
+ * Each class the theme defines but `.slide`, which every slide is, to the slides that use it, in
320
+ * script order: in their markup, or anywhere in their script, which may set it with `classList`.
321
+ * None while a section has no HTML yet or only its skeleton: what it will use is unknown.
322
+ */
323
+ function slidesUsing(ctx: LintContext, classes: Set<string>): Map<string, string[]> | undefined {
324
+ const usedBy = new Map(
325
+ [...classes].filter((name) => name !== "slide").map((name) => [name, [] as string[]]),
326
+ );
327
+ for (const slug of ctx.sectionsBySlug.keys()) {
328
+ const source = ctx.slideSource(slug);
329
+ if (source === undefined || source.skeleton) {
330
+ return undefined;
331
+ }
332
+ const code = ctx.scriptCode.get(slug);
333
+ for (const [name, slugs] of usedBy) {
334
+ if (source.scan.classes.includes(name) || (code !== undefined && namesClass(code, name))) {
335
+ slugs.push(slug);
336
+ }
337
+ }
338
+ }
339
+ return usedBy;
340
+ }
341
+
342
+ /** Whether `code` holds `name` as a class token: a word of its own, wherever it stands. */
343
+ function namesClass(code: string, name: string): boolean {
344
+ return new RegExp(`(^|[^\\w-])${escapeRegExp(name)}($|[^\\w-])`).test(code);
345
+ }
346
+
290
347
  /** The theme's own findings, or DEK018 when there is no theme. */
291
348
  export function themeDiagnostics(ctx: LintContext): Diagnostic[] {
292
349
  return ctx.theme
293
- ? lintTheme(ctx.theme, { maxClasses: ctx.deck.deck.max_classes, deckDir: ctx.deck.dir })
350
+ ? lintTheme(ctx.theme, {
351
+ maxClasses: ctx.deck.deck.max_classes,
352
+ deckDir: ctx.deck.dir,
353
+ usedBy: slidesUsing(ctx, ctx.theme.classes),
354
+ })
294
355
  : [missingThemeDiagnostic(ctx)];
295
356
  }
package/src/core/lint.ts CHANGED
@@ -18,6 +18,7 @@ import { timingDiagnostics } from "./lint/timing.ts";
18
18
  import { voiceDiagnostics } from "./lint/voice.ts";
19
19
  import { asResolvedDeck, type Project, type ProjectDeck } from "./resolve.ts";
20
20
  import type { Section } from "./schema.ts";
21
+ import type { SlideScriptProblem } from "./slide-script.ts";
21
22
  import { evaluateSlideScripts, slideScriptsProblems } from "./slide-script-eval.ts";
22
23
  import { RUNTIME_CLASSES } from "./theme-facts.ts";
23
24
 
@@ -53,7 +54,7 @@ export function lintDeck(
53
54
  export function lintDeck(input: DeckInput, options?: LintDeckOptions): Diagnostic[] {
54
55
  const { files, scripted } = readLintInput(input);
55
56
  const problems = slideScriptsProblems(scripted.map((entry) => entry.input));
56
- return runRules({ ...files, scripts: slideScriptDiagnostics(scripted, problems) }, options);
57
+ return runRules(lintContext(files, scripted, problems), options);
57
58
  }
58
59
 
59
60
  /**
@@ -66,7 +67,19 @@ export async function lintDeckAsync(
66
67
  ): Promise<Diagnostic[]> {
67
68
  const { files, scripted } = readLintInput(input);
68
69
  const problems = await evaluateSlideScripts(scripted.map((entry) => entry.input));
69
- return runRules({ ...files, scripts: slideScriptDiagnostics(scripted, problems) }, options);
70
+ return runRules(lintContext(files, scripted, problems), options);
71
+ }
72
+
73
+ function lintContext(
74
+ files: DeckFiles,
75
+ scripted: LintedScript[],
76
+ problems: SlideScriptProblem[][],
77
+ ): LintContext {
78
+ return {
79
+ ...files,
80
+ scripts: slideScriptDiagnostics(scripted, problems),
81
+ scriptCode: new Map(scripted.map(({ section, input }) => [section.slug, input.code])),
82
+ };
70
83
  }
71
84
 
72
85
  function readLintInput(input: DeckInput): { files: DeckFiles; scripted: LintedScript[] } {
@@ -46,7 +46,7 @@ const CHROMIUM_SAYS = /\[err\]|FATAL/;
46
46
  const REASON_CHARS = 280;
47
47
 
48
48
  /** A sentence: up to a `.`, `!`, or `?` that ends a word, outside parentheses, as `(0 vs. 1)` is. */
49
- const SENTENCE = /(?:\([^)]*\)|\((?!\)|$)|[^()!?\.]|[.!?](?!\s|$))+(?:[.!?]+|$)/g;
49
+ const SENTENCE = /(?:\([^)]*\)|\((?!\)|$)|[^()!?.]|[.!?](?!\s|$))+(?:[.!?]+|$)/g;
50
50
 
51
51
  /**
52
52
  * What Chromium said when it failed: its words for a sandbox that cannot start, else its first
@@ -251,6 +251,7 @@ export function measureSlideInPage(): SlideMeasure {
251
251
  const own = squash(textNodes.map((node) => node.textContent ?? "").join(" "));
252
252
  // innerText separates block children; textContent would run "item 0item 1" together.
253
253
  // SVG and MathML have no innerText, so they fall back to textContent.
254
+ // biome-ignore lint/performance/useDomNodeTextContent: the rendered line breaks are the point here.
254
255
  const whole = el instanceof HTMLElement ? el.innerText : el.textContent;
255
256
  const text = own || squash(whole ?? "");
256
257
  const parentEl = el.parentElement;
package/src/core/spawn.ts CHANGED
@@ -75,10 +75,16 @@ export async function runPiped(cmd: string[], options: PipedOptions): Promise<Pi
75
75
  hint: `it did not finish within ${Math.round(options.timeoutMs / 1000)}s and was stopped`,
76
76
  });
77
77
  try {
78
- if (options.stdin !== undefined) {
79
- proc.stdin.write(options.stdin);
78
+ try {
79
+ if (options.stdin !== undefined) {
80
+ // A write the pipe cannot take at once returns a promise, which can reject with the pipe.
81
+ await proc.stdin.write(options.stdin);
82
+ }
83
+ await proc.stdin.end();
84
+ } catch {
85
+ // The pipe broke: the command exited without reading all of its stdin, as `head` does, or
86
+ // was killed at its timeout while a write waited on it. Its exit says which, and how it went.
80
87
  }
81
- await proc.stdin.end();
82
88
  const exitCode = await proc.exited;
83
89
  // It finished in time, however long what it left behind keeps the pipes open.
84
90
  stop.clear();
@@ -91,10 +97,6 @@ export async function runPiped(cmd: string[], options: PipedOptions): Promise<Pi
91
97
  if (error instanceof DekError) {
92
98
  throw error;
93
99
  }
94
- // Killing the command breaks the pipe a write to its stdin was waiting on.
95
- if (stop.fired()) {
96
- throw overrun();
97
- }
98
100
  throw new DekError(options.label, { hint: options.hint, cause: error });
99
101
  } finally {
100
102
  stop.clear();
package/src/server/dev.ts CHANGED
@@ -76,9 +76,9 @@ export async function startDevServer(options: DevServerOptions): Promise<DevServ
76
76
  if (options.deck !== undefined && isRefName(options.deck)) {
77
77
  throw refReadOnlyError(options.deck);
78
78
  }
79
- // Before the port and the watchers; writing the lock checks again, for a server that raced us.
80
- assertNoDevServer(project.root);
81
79
  const { scopedDeckName, deckDir } = serverScope(project, options);
80
+ // Before the port and the watchers; writing the lock checks again, for a server that raced us.
81
+ assertNoDevServer(project.root, scopedDeckName);
82
82
  const access = accessFor(options);
83
83
  const embed = {
84
84
  playerScript: await livePlayerScript(),
@@ -1,6 +1,7 @@
1
1
  import { existsSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { DekError } from "../core/error.ts";
4
+ import { isDeckName } from "../core/path.ts";
4
5
  import { readSourceIfExists, removeInside, writeInside } from "../core/safe-fs.ts";
5
6
 
6
7
  export type DevServerLock = {
@@ -88,30 +89,59 @@ export function readDevServerLock(root: string): DevServerLock | undefined {
88
89
  return undefined;
89
90
  }
90
91
 
91
- /** The URL of each root a server in this process holds, which two racing servers both see. */
92
- const claimedRoots = new Map<string, string>();
92
+ /** The server each root a server in this process holds, which two racing servers both see. */
93
+ const claimedRoots = new Map<string, Omit<DevServerLock, "password">>();
93
94
 
94
- /** Throw when a live server, in this process or another, already holds `root`. */
95
- export function assertNoDevServer(root: string): void {
95
+ /**
96
+ * Throw when a live server, in this process or another, already holds `root`. `deck` is the one
97
+ * deck the server about to start would serve, none for the whole project.
98
+ */
99
+ export function assertNoDevServer(root: string, deck?: string): void {
96
100
  const existing = readDevServerLock(root);
97
101
  const running =
98
- claimedRoots.get(root) ?? (existing && isPidAlive(existing.pid) ? existing.url : undefined);
102
+ claimedRoots.get(root) ?? (existing && isPidAlive(existing.pid) ? existing : undefined);
99
103
  if (running !== undefined) {
100
- throw new DekError(`dev server already running at ${running}`, {
104
+ throw new DekError(`dev server already running at ${running.url}`, {
101
105
  path: serverLockPath(root),
102
- hint: `open ${running}`,
106
+ hint: runningServerHint(root, running, deck),
103
107
  });
104
108
  }
105
109
  }
106
110
 
111
+ /**
112
+ * Where the running server shows what was asked for, or, when it serves one other deck only, how
113
+ * to get one that serves both: `dekc` started inside a deck's folder serves that deck alone.
114
+ */
115
+ function runningServerHint(
116
+ root: string,
117
+ running: Omit<DevServerLock, "password">,
118
+ deck: string | undefined,
119
+ ): string {
120
+ if (running.deck === deck) {
121
+ return `open ${running.url}`;
122
+ }
123
+ if (running.deck === undefined && deck !== undefined) {
124
+ return `open ${running.url}decks/${encodeURIComponent(deck)}/`;
125
+ }
126
+ // The lock is a file a repository could bring along: name only a deck this project has.
127
+ const named =
128
+ running.deck !== undefined &&
129
+ isDeckName(running.deck) &&
130
+ existsSync(join(root, "decks", running.deck, "script.md"))
131
+ ? `decks/${running.deck}`
132
+ : "one deck";
133
+ const wanted = deck === undefined ? "the whole project" : `decks/${deck}`;
134
+ return `it serves only ${named}; to serve ${wanted}, stop it (pid ${running.pid}) and run \`dekc\` at the project root, which serves every deck`;
135
+ }
136
+
107
137
  export function writeDevServerLock(
108
138
  root: string,
109
139
  url: string,
110
140
  password?: string,
111
141
  deck?: string,
112
142
  ): void {
113
- assertNoDevServer(root);
114
- claimedRoots.set(root, url);
143
+ assertNoDevServer(root, deck);
144
+ claimedRoots.set(root, { url, pid: process.pid, ...(deck ? { deck } : {}) });
115
145
  try {
116
146
  // A fresh file renamed into place, so it may hold the --remote password: its owner's only.
117
147
  writeInside(
package/tsconfig.json CHANGED
@@ -22,5 +22,5 @@
22
22
  "noUnusedParameters": false,
23
23
  "noPropertyAccessFromIndexSignature": false
24
24
  },
25
- "include": ["src", "tests"]
25
+ "include": ["src", "tests", "evals"]
26
26
  }