@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 +19 -0
- package/package.json +5 -5
- package/src/cli/address.ts +48 -11
- package/src/cli/keep-alive.ts +12 -6
- package/src/cli/main.ts +2 -2
- package/src/cli/theme.ts +1 -1
- package/src/core/lint/context.ts +2 -0
- package/src/core/lint/shared-styles.ts +26 -1
- package/src/core/lint/theme-css.ts +64 -3
- package/src/core/lint.ts +15 -2
- package/src/core/sandbox.ts +1 -1
- package/src/core/slide-measure.ts +1 -0
- package/src/core/spawn.ts +9 -7
- package/src/server/dev.ts +2 -2
- package/src/server/lock.ts +39 -9
- package/tsconfig.json +1 -1
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.
|
|
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.
|
|
65
|
-
"@happy-dom/global-registrator": "21.0.
|
|
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.
|
|
69
|
-
"knip": "6.
|
|
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",
|
package/src/cli/address.ts
CHANGED
|
@@ -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
|
|
13
|
-
*
|
|
14
|
-
*
|
|
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 (
|
|
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/keep-alive.ts
CHANGED
|
@@ -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
|
|
55
|
-
* leaves it no chance to stop its children. A process started
|
|
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
|
|
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 {
|
|
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) =>
|
|
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) ?? "";
|
package/src/core/lint/context.ts
CHANGED
|
@@ -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
|
|
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
|
-
{
|
|
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, {
|
|
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(
|
|
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(
|
|
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[] } {
|
package/src/core/sandbox.ts
CHANGED
|
@@ -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 = /(?:\([^)]*\)|\((?!\)|$)|[^()
|
|
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
|
-
|
|
79
|
-
|
|
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(),
|
package/src/server/lock.ts
CHANGED
|
@@ -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
|
|
92
|
-
const claimedRoots = new Map<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
|
-
/**
|
|
95
|
-
|
|
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
|
|
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:
|
|
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