@hajimism/dek 0.3.3 → 0.4.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.
- package/CHANGELOG.md +63 -0
- package/README.ja.md +1 -1
- package/README.md +1 -1
- package/package.json +2 -1
- package/skills/dek/SKILL.md +10 -3
- package/skills/dek-design/SKILL.md +42 -0
- package/src/cli/address.ts +84 -14
- package/src/cli/build.ts +2 -2
- package/src/cli/check.ts +2 -1
- package/src/cli/commands.ts +11 -6
- package/src/cli/contract.ts +9 -1
- package/src/cli/files.ts +1 -5
- package/src/cli/flags.ts +1 -1
- package/src/cli/goto.ts +3 -1
- package/src/cli/init.ts +10 -5
- package/src/cli/lint.ts +4 -3
- package/src/cli/main.ts +7 -5
- package/src/cli/new.ts +14 -7
- package/src/cli/pptx.ts +9 -4
- package/src/cli/ref.ts +91 -9
- package/src/cli/result.ts +5 -2
- package/src/cli/scope.ts +17 -28
- package/src/cli/shot.ts +2 -1
- package/src/cli/text.ts +27 -2
- package/src/cli/theme.ts +16 -2
- package/src/cli/voice.ts +4 -3
- package/src/core/alignment.ts +320 -0
- package/src/core/annotation-rows.ts +16 -4
- package/src/core/annotations.ts +19 -1
- package/src/core/config.ts +61 -12
- package/src/core/diagnostic.ts +5 -1
- package/src/core/escape.ts +43 -0
- package/src/core/line-break.ts +275 -0
- package/src/core/lint/asset-refs.ts +6 -2
- package/src/core/lint/files.ts +30 -5
- package/src/core/lint/keys.ts +7 -3
- package/src/core/lint/slide-html.ts +3 -1
- package/src/core/lint/slide-script.ts +7 -5
- package/src/core/mv.ts +30 -10
- package/src/core/pack.ts +2 -2
- package/src/core/playwright-module.d.ts +1 -0
- package/src/core/playwright-visual.ts +180 -20
- package/src/core/playwright.ts +202 -25
- package/src/core/pptx-package.ts +461 -33
- package/src/core/pptx-paint.ts +1168 -0
- package/src/core/pptx-plan.ts +879 -0
- package/src/core/pptx-text.ts +204 -47
- package/src/core/pptx-theme.ts +222 -0
- package/src/core/pptx.ts +55 -8
- package/src/core/prose.ts +23 -0
- package/src/core/pseudo-text.ts +32 -10
- package/src/core/ref-name.ts +47 -8
- package/src/core/ref.ts +172 -16
- package/src/core/resolve.ts +20 -9
- package/src/core/shot/morph.ts +1 -1
- package/src/core/shot/motion.ts +2 -3
- package/src/core/shot/still.ts +2 -2
- package/src/core/shot-cache.ts +25 -17
- package/src/core/slide-measure.ts +51 -1
- package/src/core/slide-script-eval.ts +10 -8
- package/src/core/slide-script.ts +114 -20
- package/src/core/sync.ts +2 -2
- package/src/core/visual-cache.ts +2 -0
- package/src/core/visual.ts +154 -15
- package/src/core/voice.ts +3 -5
- package/src/core/zod.ts +9 -1
- package/src/server/dev.ts +15 -4
- package/src/server/lock.ts +48 -5
- package/src/server/routes.ts +7 -2
- package/src/theme/default.css +4 -0
- package/src/video/bake.ts +6 -2
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One run of text as the page breaks it into lines: an element's own text with the text of the
|
|
3
|
+
* inline elements in it, as `<strong>` or `<a>`, which share its lines.
|
|
4
|
+
*/
|
|
5
|
+
export type TextFlow = {
|
|
6
|
+
/** Its element's index among the slide's elements, as `measureSlideInPage` lists them. */
|
|
7
|
+
element: number;
|
|
8
|
+
/** Its text as written, a `<br>` or a block inside it read as a newline. */
|
|
9
|
+
text: string;
|
|
10
|
+
/** Where each line starts in `text`, and whether a `<br>` or a block started it. */
|
|
11
|
+
lines: Array<{ start: number; forced: boolean }>;
|
|
12
|
+
/** Where each line would start, broken phrase by phrase; only where that is elsewhere. */
|
|
13
|
+
phrased?: number[];
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
/** What `measureTextFlowsInPage` finds: each text with more than one line, and how the slide breaks. */
|
|
17
|
+
export type MeasuredFlows = {
|
|
18
|
+
/** The slide's own `word-break`, which every text inherits unless a rule sets its own. */
|
|
19
|
+
wordBreak: string;
|
|
20
|
+
flows: Array<TextFlow & { textWrap: string }>;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Lays each text on the first `.slide` out in lines, as `TextFlow` says. Where a line ends inside
|
|
25
|
+
* a phrase is what the page itself says: each text whose `word-break` is `normal` is laid out
|
|
26
|
+
* again beside itself with only `word-break: auto-phrase` changed, and where that puts its lines
|
|
27
|
+
* elsewhere, `phrased` says where. Chromium breaks by phrase only where the page's `lang` is one
|
|
28
|
+
* it knows phrases for, such as `ja`, so other text is never laid out elsewhere. The copies are
|
|
29
|
+
* taken away before it returns, so the page is as it was. It runs inside the page through
|
|
30
|
+
* `page.evaluate`, which serializes only this function, so it must not reference anything outside
|
|
31
|
+
* its own body.
|
|
32
|
+
*/
|
|
33
|
+
export function measureTextFlowsInPage(): MeasuredFlows {
|
|
34
|
+
const slide = document.querySelector(".slide");
|
|
35
|
+
if (!slide) {
|
|
36
|
+
return { wordBreak: "normal", flows: [] };
|
|
37
|
+
}
|
|
38
|
+
// Code keeps its own lines, and a drawing or a formula its own text.
|
|
39
|
+
const code = "pre, code, kbd, samp";
|
|
40
|
+
const drawings = new Set(["svg", "math"]);
|
|
41
|
+
// A text node, whose characters each take their place on a line, or an inline box such as an
|
|
42
|
+
// image or an inline-block, which takes its place whole.
|
|
43
|
+
type Piece = { at: number } & ({ node: Text } | { box: Element });
|
|
44
|
+
type Read = { text: string; pieces: Piece[]; forced: number[] };
|
|
45
|
+
// The text an element lays out in its own lines: its text nodes and those of the inline elements
|
|
46
|
+
// in it, and an inline box as the text it holds, or U+FFFC when it holds none. A <br>, a block
|
|
47
|
+
// between them, or a newline that white-space keeps, as pre-line does, starts a line.
|
|
48
|
+
const read = (root: Element): Read => {
|
|
49
|
+
const found: Read = { text: "", pieces: [], forced: [] };
|
|
50
|
+
const walk = (el: Element) => {
|
|
51
|
+
for (const child of el.childNodes) {
|
|
52
|
+
if (child instanceof Text) {
|
|
53
|
+
const at = found.text.length;
|
|
54
|
+
found.pieces.push({ at, node: child });
|
|
55
|
+
found.text += child.data;
|
|
56
|
+
if (/^(?:pre|pre-wrap|pre-line|break-spaces)$/.test(getComputedStyle(el).whiteSpace)) {
|
|
57
|
+
for (const kept of child.data.matchAll(/\n/g)) {
|
|
58
|
+
found.forced.push(at + kept.index);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
if (!(child instanceof Element)) {
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
const display = getComputedStyle(child).display;
|
|
67
|
+
if (display === "none") {
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
const tag = child.tagName.toLowerCase();
|
|
71
|
+
if (tag === "br" || (!display.startsWith("inline") && display !== "contents")) {
|
|
72
|
+
found.forced.push(found.text.length);
|
|
73
|
+
found.text += "\n";
|
|
74
|
+
} else if ((display === "inline" || display === "contents") && !drawings.has(tag)) {
|
|
75
|
+
walk(child);
|
|
76
|
+
} else {
|
|
77
|
+
found.pieces.push({ at: found.text.length, box: child });
|
|
78
|
+
found.text += child.textContent?.trim() || "";
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
walk(root);
|
|
83
|
+
return found;
|
|
84
|
+
};
|
|
85
|
+
// What a piece draws, each with its place in the text: a text node's characters but its white
|
|
86
|
+
// space, which draws nothing of its own, or an inline box whole.
|
|
87
|
+
const drawn = (piece: Piece): Array<{ offset: number; rect: DOMRect }> => {
|
|
88
|
+
if ("box" in piece) {
|
|
89
|
+
return [{ offset: piece.at, rect: piece.box.getBoundingClientRect() }];
|
|
90
|
+
}
|
|
91
|
+
const { node, at } = piece;
|
|
92
|
+
const marks: Array<{ offset: number; rect: DOMRect }> = [];
|
|
93
|
+
for (let end = 0; end < node.data.length; ) {
|
|
94
|
+
const begin = end;
|
|
95
|
+
end += (node.data.codePointAt(begin) ?? 0) > 0xffff ? 2 : 1;
|
|
96
|
+
if (/\s/.test(node.data.slice(begin, end))) {
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
const range = document.createRange();
|
|
100
|
+
range.setStart(node, begin);
|
|
101
|
+
range.setEnd(node, end);
|
|
102
|
+
marks.push({ offset: at + begin, rect: range.getBoundingClientRect() });
|
|
103
|
+
}
|
|
104
|
+
return marks;
|
|
105
|
+
};
|
|
106
|
+
// Where each line starts: at the first thing drawn below the line before.
|
|
107
|
+
const lineStarts = ({ pieces, forced }: Read): Array<{ start: number; forced: boolean }> => {
|
|
108
|
+
const lines: Array<{ start: number; forced: boolean }> = [];
|
|
109
|
+
let bottom = Number.NEGATIVE_INFINITY;
|
|
110
|
+
let previous = -1;
|
|
111
|
+
for (const { offset, rect } of pieces.flatMap(drawn)) {
|
|
112
|
+
if (rect.width === 0 && rect.height === 0) {
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
if (rect.top >= bottom - 1) {
|
|
116
|
+
lines.push({
|
|
117
|
+
start: offset,
|
|
118
|
+
forced: lines.length > 0 && forced.some((at) => at > previous && at < offset),
|
|
119
|
+
});
|
|
120
|
+
bottom = rect.bottom;
|
|
121
|
+
} else {
|
|
122
|
+
bottom = Math.max(bottom, rect.bottom);
|
|
123
|
+
}
|
|
124
|
+
previous = offset;
|
|
125
|
+
}
|
|
126
|
+
return lines;
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
const all = [...slide.querySelectorAll("*")];
|
|
130
|
+
const measured = all.flatMap((el, element) => {
|
|
131
|
+
const display = getComputedStyle(el).display;
|
|
132
|
+
if (
|
|
133
|
+
!(el instanceof HTMLElement) ||
|
|
134
|
+
display === "inline" ||
|
|
135
|
+
display === "contents" ||
|
|
136
|
+
display === "none" ||
|
|
137
|
+
el.closest(code) !== null ||
|
|
138
|
+
el.closest("svg, math") !== null ||
|
|
139
|
+
el.closest('[aria-hidden="true"]') !== null
|
|
140
|
+
) {
|
|
141
|
+
return [];
|
|
142
|
+
}
|
|
143
|
+
const flow = read(el);
|
|
144
|
+
const lines = lineStarts(flow);
|
|
145
|
+
if (lines.length < 2) {
|
|
146
|
+
return [];
|
|
147
|
+
}
|
|
148
|
+
const style = getComputedStyle(el);
|
|
149
|
+
const soft = lines.some(({ forced }, at) => at > 0 && !forced);
|
|
150
|
+
return [
|
|
151
|
+
{
|
|
152
|
+
el,
|
|
153
|
+
element,
|
|
154
|
+
flow,
|
|
155
|
+
lines,
|
|
156
|
+
textWrap: style.textWrap,
|
|
157
|
+
rewrap: soft && style.wordBreak === "normal",
|
|
158
|
+
},
|
|
159
|
+
];
|
|
160
|
+
});
|
|
161
|
+
// Every line is read before any copy goes in, and every copy is in before any is read, so the
|
|
162
|
+
// page is laid out once for each. A copy sits where its text is, out of the flow, with only its
|
|
163
|
+
// word-break changed.
|
|
164
|
+
const copies = measured.map(({ el, rewrap }) => {
|
|
165
|
+
if (!rewrap) {
|
|
166
|
+
return undefined;
|
|
167
|
+
}
|
|
168
|
+
const copy = el.cloneNode(true) as HTMLElement;
|
|
169
|
+
Object.assign(copy.style, {
|
|
170
|
+
position: "absolute",
|
|
171
|
+
visibility: "hidden",
|
|
172
|
+
left: "0px",
|
|
173
|
+
top: "0px",
|
|
174
|
+
margin: "0px",
|
|
175
|
+
boxSizing: "border-box",
|
|
176
|
+
width: `${el.offsetWidth}px`,
|
|
177
|
+
minWidth: "0px",
|
|
178
|
+
maxWidth: "none",
|
|
179
|
+
wordBreak: "auto-phrase",
|
|
180
|
+
});
|
|
181
|
+
el.after(copy);
|
|
182
|
+
return copy;
|
|
183
|
+
});
|
|
184
|
+
const flows = measured.map(({ element, flow, lines, textWrap }, at) => {
|
|
185
|
+
const copy = copies[at];
|
|
186
|
+
const phrased = copy && lineStarts(read(copy)).map(({ start }) => start);
|
|
187
|
+
copy?.remove();
|
|
188
|
+
const moved =
|
|
189
|
+
phrased !== undefined &&
|
|
190
|
+
(phrased.length !== lines.length || phrased.some((start, at) => start !== lines[at]?.start));
|
|
191
|
+
return {
|
|
192
|
+
element,
|
|
193
|
+
text: flow.text,
|
|
194
|
+
lines,
|
|
195
|
+
...(moved ? { phrased } : {}),
|
|
196
|
+
textWrap,
|
|
197
|
+
};
|
|
198
|
+
});
|
|
199
|
+
return { wordBreak: getComputedStyle(slide).wordBreak, flows };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** What reads wrong about a text's line breaks. */
|
|
203
|
+
export type LineBreakKind =
|
|
204
|
+
| {
|
|
205
|
+
/** A line ends inside a phrase. */
|
|
206
|
+
kind: "split";
|
|
207
|
+
/** The lines broken phrase by phrase. */
|
|
208
|
+
phrased: string[];
|
|
209
|
+
}
|
|
210
|
+
| {
|
|
211
|
+
/** A paragraph's last line holds two letters or fewer. */
|
|
212
|
+
kind: "short";
|
|
213
|
+
tail: string;
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
/** A line break the audience trips over, in the flow of the element at `element`. */
|
|
217
|
+
export type LineBreak = {
|
|
218
|
+
element: number;
|
|
219
|
+
/** Its lines as the audience reads them. */
|
|
220
|
+
lines: string[];
|
|
221
|
+
} & LineBreakKind;
|
|
222
|
+
|
|
223
|
+
/** A last line with no more letters or digits than this reads as a remnant. */
|
|
224
|
+
const MAX_SHORT = 2;
|
|
225
|
+
|
|
226
|
+
const graphemes = new Intl.Segmenter(undefined, { granularity: "grapheme" });
|
|
227
|
+
|
|
228
|
+
/** The letters and digits in `text`, an inline box that holds no text counted as one. */
|
|
229
|
+
function letters(text: string): number {
|
|
230
|
+
return [...graphemes.segment(text)].filter(({ segment }) => /[\p{L}\p{N}]/u.test(segment))
|
|
231
|
+
.length;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** The lines of `text` that start at `starts`, as read: white space collapsed. */
|
|
235
|
+
function readLines(text: string, starts: number[]): string[] {
|
|
236
|
+
return starts.map((start, at) =>
|
|
237
|
+
text
|
|
238
|
+
.slice(start, starts[at + 1] ?? text.length)
|
|
239
|
+
.replace(/\s+/g, " ")
|
|
240
|
+
.trim(),
|
|
241
|
+
);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* The line breaks in each flow that read wrong: a line that ends inside a phrase, which `phrased`
|
|
246
|
+
* shows where the page breaks it phrase by phrase, and a paragraph whose last line holds two
|
|
247
|
+
* letters or fewer. A short line a `<br>` or a block starts is the author's, and passes. Where a
|
|
248
|
+
* phrase is broken, every line after it moves once the text breaks by phrase, so that is all a
|
|
249
|
+
* flow reports.
|
|
250
|
+
*/
|
|
251
|
+
export function findLineBreaks(flows: TextFlow[]): LineBreak[] {
|
|
252
|
+
return flows.flatMap((flow): LineBreak[] => {
|
|
253
|
+
const lines = readLines(
|
|
254
|
+
flow.text,
|
|
255
|
+
flow.lines.map(({ start }) => start),
|
|
256
|
+
);
|
|
257
|
+
if (flow.phrased) {
|
|
258
|
+
return [
|
|
259
|
+
{
|
|
260
|
+
element: flow.element,
|
|
261
|
+
kind: "split",
|
|
262
|
+
lines,
|
|
263
|
+
phrased: readLines(flow.text, flow.phrased),
|
|
264
|
+
},
|
|
265
|
+
];
|
|
266
|
+
}
|
|
267
|
+
return flow.lines.flatMap(({ forced }, at): LineBreak[] => {
|
|
268
|
+
const last = at === flow.lines.length - 1 || flow.lines[at + 1]?.forced === true;
|
|
269
|
+
const tail = lines[at] ?? "";
|
|
270
|
+
return at > 0 && !forced && last && letters(tail) <= MAX_SHORT
|
|
271
|
+
? [{ element: flow.element, kind: "short", lines, tail }]
|
|
272
|
+
: [];
|
|
273
|
+
});
|
|
274
|
+
});
|
|
275
|
+
}
|
|
@@ -2,6 +2,7 @@ import { existsSync } from "node:fs";
|
|
|
2
2
|
import { dirname, join, posix, resolve } from "node:path";
|
|
3
3
|
import { classifyAssetRef, isCanonicalAssetPath } from "../assets.ts";
|
|
4
4
|
import { type Diagnostic, diag } from "../diagnostic.ts";
|
|
5
|
+
import { isPlainWord } from "../escape.ts";
|
|
5
6
|
import type { HtmlRef } from "../html-scan.ts";
|
|
6
7
|
import { isInside } from "../path.ts";
|
|
7
8
|
|
|
@@ -95,7 +96,9 @@ function missingImageHint(value: string, deckDir: string): { hint?: string } {
|
|
|
95
96
|
if (!existsSync(projectFile)) {
|
|
96
97
|
return {};
|
|
97
98
|
}
|
|
98
|
-
|
|
99
|
+
// The name is the slide's text: one that is no plain word is left out of the hint.
|
|
100
|
+
const copy = isPlainWord(name) ? `../../${name}` : "it from the project's assets/";
|
|
101
|
+
return { hint: `the project has it: copy ${copy} into the deck's assets/` };
|
|
99
102
|
}
|
|
100
103
|
|
|
101
104
|
function remoteHint(value: string): string {
|
|
@@ -103,7 +106,8 @@ function remoteHint(value: string): string {
|
|
|
103
106
|
try {
|
|
104
107
|
name = posix.basename(new URL(value.trim(), "https://dek.invalid").pathname);
|
|
105
108
|
} catch {}
|
|
106
|
-
|
|
109
|
+
// The name is the remote URL's: one that is no plain word is left out of the hint.
|
|
110
|
+
return name && isPlainWord(name)
|
|
107
111
|
? `download it into assets/ and use assets/${name}`
|
|
108
112
|
: "download it into assets/ and reference it from there";
|
|
109
113
|
}
|
package/src/core/lint/files.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { type Diagnostic, diag } from "../diagnostic.ts";
|
|
2
|
+
import { shellName } from "../escape.ts";
|
|
2
3
|
import { either } from "../prose.ts";
|
|
3
4
|
import { listSlideFiles, SLIDE_SIDECARS } from "../resolve.ts";
|
|
5
|
+
import { Id } from "../schema.ts";
|
|
4
6
|
import type { LintContext } from "./context.ts";
|
|
5
7
|
|
|
6
8
|
/** DEK001 for a section without slides/<slug>.html; DEK002 for a slide file without its section. */
|
|
@@ -60,9 +62,13 @@ export function pairingDiagnostics(ctx: LintContext): Diagnostic[] {
|
|
|
60
62
|
|
|
61
63
|
/**
|
|
62
64
|
* What to do with a slide file the script does not name, when no rename explains it: give it a
|
|
63
|
-
* section, or take it out of the talk. Which one is the author's call.
|
|
65
|
+
* section, or take it out of the talk. Which one is the author's call. A name that is no section
|
|
66
|
+
* id cannot head one, and is the file's text, so the hint names it nowhere.
|
|
64
67
|
*/
|
|
65
68
|
function orphanHint(slug: string, file: string): string {
|
|
69
|
+
if (!Id.safeParse(slug).success) {
|
|
70
|
+
return "its name is no section id, which uses a-z, 0-9, and -: rename it to one and add that section to script.md, or remove it if the slide is gone from the talk";
|
|
71
|
+
}
|
|
66
72
|
const title = slug.charAt(0).toUpperCase() + slug.slice(1).replaceAll("-", " ");
|
|
67
73
|
return `add a section for it to script.md, like \`## ${title} {#${slug}}\`, or remove ${file} if the slide is gone from the talk`;
|
|
68
74
|
}
|
|
@@ -91,10 +97,16 @@ export function suggestRename(diagnostics: Diagnostic[], ctx: LintContext): Diag
|
|
|
91
97
|
}
|
|
92
98
|
const [orphan] = orphans;
|
|
93
99
|
const [target] = targets;
|
|
94
|
-
if (
|
|
100
|
+
if (
|
|
101
|
+
orphans.size === 1 &&
|
|
102
|
+
targets.length === 1 &&
|
|
103
|
+
orphan?.slug &&
|
|
104
|
+
target &&
|
|
105
|
+
movable(orphan.slug)
|
|
106
|
+
) {
|
|
95
107
|
// An orphan beside a section without its own slide is a rename, made in the script or in the
|
|
96
108
|
// files: which one is the author's to say, and each command finishes it from this state.
|
|
97
|
-
const hint = `run
|
|
109
|
+
const hint = `run ${mvCommand(orphan.slug, target)} to move the files to the script's id, or ${mvCommand(target, orphan.slug)} to give the section the files' id`;
|
|
98
110
|
return diagnostics.map((diagnostic) =>
|
|
99
111
|
diagnostic === orphan
|
|
100
112
|
? { ...diagnostic, hint, data: { ...diagnostic.data, renames: targets } }
|
|
@@ -104,10 +116,11 @@ export function suggestRename(diagnostics: Diagnostic[], ctx: LintContext): Diag
|
|
|
104
116
|
);
|
|
105
117
|
}
|
|
106
118
|
return diagnostics.map((diagnostic) => {
|
|
107
|
-
|
|
119
|
+
const file = diagnostic.slug;
|
|
120
|
+
if (!orphans.has(diagnostic) || !file || !movable(file)) {
|
|
108
121
|
return diagnostic;
|
|
109
122
|
}
|
|
110
|
-
const commands = targets.map((slug) =>
|
|
123
|
+
const commands = targets.map((slug) => mvCommand(file, slug));
|
|
111
124
|
return {
|
|
112
125
|
...diagnostic,
|
|
113
126
|
hint: `run ${either(commands)} to move the files to the section they now belong to`,
|
|
@@ -115,3 +128,15 @@ export function suggestRename(diagnostics: Diagnostic[], ctx: LintContext): Diag
|
|
|
115
128
|
};
|
|
116
129
|
});
|
|
117
130
|
}
|
|
131
|
+
|
|
132
|
+
/** Whether a slide file's name can be written in a command: see `shellName`. */
|
|
133
|
+
function movable(slug: string): boolean {
|
|
134
|
+
return shellName(slug) !== undefined;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** `dekc mv from to`, with `--` first when a name starts with a dash, which would read as a flag. */
|
|
138
|
+
function mvCommand(from: string, to: string): string {
|
|
139
|
+
const words = [from, to].map((slug) => shellName(slug) ?? slug);
|
|
140
|
+
const end = [from, to].some((slug) => slug.startsWith("-")) ? "-- " : "";
|
|
141
|
+
return `\`dekc mv ${end}${words.join(" ")}\``;
|
|
142
|
+
}
|
package/src/core/lint/keys.ts
CHANGED
|
@@ -5,8 +5,10 @@ import {
|
|
|
5
5
|
unknownTomlKeys,
|
|
6
6
|
} from "../config-keys.ts";
|
|
7
7
|
import { type Diagnostic, diag } from "../diagnostic.ts";
|
|
8
|
+
import { isPlainWord } from "../escape.ts";
|
|
8
9
|
import { readFrontmatterYaml } from "../parse.ts";
|
|
9
|
-
import {
|
|
10
|
+
import type { Project } from "../resolve.ts";
|
|
11
|
+
import { readSourceIfExists } from "../safe-fs.ts";
|
|
10
12
|
import type { DeckSettings } from "../schema.ts";
|
|
11
13
|
import type { LintContext } from "./context.ts";
|
|
12
14
|
|
|
@@ -17,7 +19,7 @@ import type { LintContext } from "./context.ts";
|
|
|
17
19
|
|
|
18
20
|
/** DEK008 for dek.toml: a project finding, the same for every deck. */
|
|
19
21
|
export function tomlKeyDiagnostics(project: Project): Diagnostic[] {
|
|
20
|
-
return unknownTomlKeys(
|
|
22
|
+
return unknownTomlKeys(readSourceIfExists(project.configPath, project.root) ?? "").map((key) =>
|
|
21
23
|
unknownKeyDiagnostic(key, { file: "dek.toml", path: project.configPath }),
|
|
22
24
|
);
|
|
23
25
|
}
|
|
@@ -79,8 +81,10 @@ function unknownKeyDiagnostic(
|
|
|
79
81
|
where.file === "dek.toml"
|
|
80
82
|
? `see ${REFERENCE_URL}#dek-toml`
|
|
81
83
|
: `the keys are ${FRONTMATTER_KEYS.join(", ")}; see ${REFERENCE_URL}#frontmatter`;
|
|
84
|
+
// The key is the file's text, which a cloned project brings along: see formatZodIssues.
|
|
85
|
+
const shown = isPlainWord(key) ? key : "<key>";
|
|
82
86
|
return diag("DEK008", {
|
|
83
|
-
message: `unknown key ${
|
|
87
|
+
message: `unknown key ${shown} in ${place}; dek ignores it`,
|
|
84
88
|
path: where.path,
|
|
85
89
|
line,
|
|
86
90
|
hint: suggestion ? `did you mean ${suggestion}?` : known,
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { type Diagnostic, diag } from "../diagnostic.ts";
|
|
2
|
+
import { isPlainWord } from "../escape.ts";
|
|
2
3
|
import type { HtmlAttribute, HtmlScan, Picture, SourceSpot } from "../html-scan.ts";
|
|
3
4
|
import type { Section } from "../schema.ts";
|
|
4
5
|
import { beatAt, formatStepChoices, resolveStep, stepChoices } from "../step.ts";
|
|
@@ -159,7 +160,8 @@ function layoutDiagnostics({ section, path, scan }: SlideHtml, layouts: Set<stri
|
|
|
159
160
|
return [];
|
|
160
161
|
}
|
|
161
162
|
const known = [...layouts].sort();
|
|
162
|
-
|
|
163
|
+
// A layout's name is the theme's text: one that is no plain word is never a guess.
|
|
164
|
+
const guess = suggest(scan.layout, known.filter(isPlainWord));
|
|
163
165
|
return [
|
|
164
166
|
diag("DEK019", {
|
|
165
167
|
message: `data-layout "${scan.layout}" is not a layout of the theme`,
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { readFileSync } from "node:fs";
|
|
2
2
|
import { type Diagnostic, diag } from "../diagnostic.ts";
|
|
3
|
+
import { shellName } from "../escape.ts";
|
|
3
4
|
import { listSlideFiles } from "../resolve.ts";
|
|
4
5
|
import type { Section } from "../schema.ts";
|
|
5
6
|
import type { SlideScriptProblem } from "../slide-script.ts";
|
|
@@ -54,15 +55,16 @@ export function slideScriptDiagnostics(
|
|
|
54
55
|
|
|
55
56
|
/** DEK016 for a slides/<slug>.js, which dek does not load: motion is written in TypeScript. */
|
|
56
57
|
export function javascriptDiagnostics(ctx: LintContext): Diagnostic[] {
|
|
57
|
-
return listSlideFiles(ctx.deck.dir, ".js").map((file) =>
|
|
58
|
-
|
|
58
|
+
return listSlideFiles(ctx.deck.dir, ".js").map((file) => {
|
|
59
|
+
const [from, to] = [`slides/${file.slug}.js`, `slides/${file.slug}.ts`].map(shellName);
|
|
60
|
+
return diag("DEK016", {
|
|
59
61
|
message: `slide scripts are TypeScript; rename ${file.slug}.js to ${file.slug}.ts`,
|
|
60
62
|
path: file.path,
|
|
61
63
|
slug: file.slug,
|
|
62
|
-
hint: `run \`mv
|
|
64
|
+
hint: `${from && to ? `run \`mv ${from} ${to}\`` : "rename it to .ts"}; plain JavaScript is valid TypeScript`,
|
|
63
65
|
data: { file: `slides/${file.slug}.js` },
|
|
64
|
-
})
|
|
65
|
-
);
|
|
66
|
+
});
|
|
67
|
+
});
|
|
66
68
|
}
|
|
67
69
|
|
|
68
70
|
export type SeekProblem = {
|
package/src/core/mv.ts
CHANGED
|
@@ -1,10 +1,16 @@
|
|
|
1
|
-
import { existsSync,
|
|
1
|
+
import { existsSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { basename } from "node:path";
|
|
3
3
|
import { deckPaths } from "./deck-paths.ts";
|
|
4
4
|
import { DekError } from "./error.ts";
|
|
5
|
-
import { escapeRegExp } from "./escape.ts";
|
|
5
|
+
import { escapeRegExp, isPlainWord, shellName } from "./escape.ts";
|
|
6
6
|
import { joinLines, sectionChunks, splitLines } from "./lines.ts";
|
|
7
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
asResolvedDeck,
|
|
9
|
+
type ResolvedDeck,
|
|
10
|
+
readDeckFile,
|
|
11
|
+
requireSection,
|
|
12
|
+
SLIDE_SIDECARS,
|
|
13
|
+
} from "./resolve.ts";
|
|
8
14
|
import { Id } from "./schema.ts";
|
|
9
15
|
import { deckLayouts, skeletonHtml } from "./skeleton.ts";
|
|
10
16
|
import { skeletonRecord } from "./skeleton-record.ts";
|
|
@@ -38,7 +44,7 @@ export function renameSection(input: string | ResolvedDeck, from: string, to: st
|
|
|
38
44
|
});
|
|
39
45
|
}
|
|
40
46
|
|
|
41
|
-
const source =
|
|
47
|
+
const source = readDeckFile(deck.dir, deck.scriptPath) ?? "";
|
|
42
48
|
const lines = splitLines(source);
|
|
43
49
|
const headingIndex = section.line - 1;
|
|
44
50
|
const heading = lines[headingIndex];
|
|
@@ -69,10 +75,11 @@ function slideFileSteps(deck: ResolvedDeck["deck"], from: string, to: string): F
|
|
|
69
75
|
const record = skeletonRecord(deck.dir);
|
|
70
76
|
const read = (slug: string): SlideFile | undefined => {
|
|
71
77
|
const path = paths.slide(slug, ".html");
|
|
72
|
-
|
|
78
|
+
// What is read here may be written under the other id: a link out of the project is refused.
|
|
79
|
+
const html = readDeckFile(deck.dir, path);
|
|
80
|
+
if (html === undefined) {
|
|
73
81
|
return undefined;
|
|
74
82
|
}
|
|
75
|
-
const html = readFileSync(path, "utf8");
|
|
76
83
|
return { path, html, authored: !record.owns(html, skeletonHtml(deck.deck, slug, layouts)) };
|
|
77
84
|
};
|
|
78
85
|
const source = read(from);
|
|
@@ -131,10 +138,23 @@ function bothHold(fromPath: string, toPath: string, from: string, to: string): D
|
|
|
131
138
|
const what = fromPath.endsWith(".html") ? "hold a slide you wrote" : "exist";
|
|
132
139
|
return new DekError(`${fromName} and ${toName} both ${what}`, {
|
|
133
140
|
path: fromPath,
|
|
134
|
-
hint: `merge them into ${toName} and remove ${fromName}, then run
|
|
141
|
+
hint: `merge them into ${toName} and remove ${isPlainWord(from) ? fromName : "the other"}, then run ${again(from, to)}`,
|
|
135
142
|
});
|
|
136
143
|
}
|
|
137
144
|
|
|
145
|
+
/**
|
|
146
|
+
* The rename to run again: as a command when each name has a word every shell reads alike, after
|
|
147
|
+
* `--` when one starts with a dash; else as the one that was run.
|
|
148
|
+
*/
|
|
149
|
+
function again(from: string, to: string): string {
|
|
150
|
+
const [source, target] = [shellName(from), shellName(to)];
|
|
151
|
+
if (source === undefined || target === undefined) {
|
|
152
|
+
return "that `dekc mv` again";
|
|
153
|
+
}
|
|
154
|
+
const end = from.startsWith("-") || to.startsWith("-") ? "-- " : "";
|
|
155
|
+
return `\`dekc mv ${end}${source} ${target}\` again`;
|
|
156
|
+
}
|
|
157
|
+
|
|
138
158
|
/** One file change `dekc mv` makes, with how to take it back. */
|
|
139
159
|
type FileStep = { path: string; apply: () => void; undo: () => void };
|
|
140
160
|
|
|
@@ -192,10 +212,10 @@ function planVoiceBeatKeys(
|
|
|
192
212
|
to: string,
|
|
193
213
|
): { path: string; source: string; next: string } | undefined {
|
|
194
214
|
const path = deckPaths(deckDir).voiceToml;
|
|
195
|
-
|
|
215
|
+
const source = readDeckFile(deckDir, path);
|
|
216
|
+
if (source === undefined) {
|
|
196
217
|
return undefined;
|
|
197
218
|
}
|
|
198
|
-
const source = readFileSync(path, "utf8");
|
|
199
219
|
let parsed: Record<string, unknown>;
|
|
200
220
|
try {
|
|
201
221
|
parsed = Bun.TOML.parse(source) as Record<string, unknown>;
|
|
@@ -259,7 +279,7 @@ export function reorderSection(
|
|
|
259
279
|
requireSection(deck, target);
|
|
260
280
|
}
|
|
261
281
|
|
|
262
|
-
const source =
|
|
282
|
+
const source = readDeckFile(deck.dir, deck.scriptPath) ?? "";
|
|
263
283
|
const lines = splitLines(source);
|
|
264
284
|
const firstLine = sections[0]?.line;
|
|
265
285
|
if (firstLine === undefined) {
|
package/src/core/pack.ts
CHANGED
|
@@ -5,7 +5,7 @@ import { DekError } from "./error.ts";
|
|
|
5
5
|
import { licenseText } from "./licenses.ts";
|
|
6
6
|
import { type DistOptions, distFile } from "./path.ts";
|
|
7
7
|
import { keepsInDeck, LICENSE_NAMES, MAX_SNAPSHOT_BYTES } from "./ref.ts";
|
|
8
|
-
import { isRefDeckName } from "./ref-name.ts";
|
|
8
|
+
import { isRefDeckName, REF_DECK_NAME_RULE } from "./ref-name.ts";
|
|
9
9
|
import { followInside, requireInside, writeInside } from "./safe-fs.ts";
|
|
10
10
|
|
|
11
11
|
/**
|
|
@@ -22,7 +22,7 @@ export async function packDeck(
|
|
|
22
22
|
if (!isRefDeckName(deck.name)) {
|
|
23
23
|
throw new DekError(`deck "${deck.name}" cannot be read as a ref by that name`, {
|
|
24
24
|
path: deck.dir,
|
|
25
|
-
hint:
|
|
25
|
+
hint: `${REF_DECK_NAME_RULE}; rename the deck's folder, e.g. to 2026-10-talk, and pack it again`,
|
|
26
26
|
});
|
|
27
27
|
}
|
|
28
28
|
// Each file by its path in the pack and where it really is, measured before any is read.
|
|
@@ -15,6 +15,7 @@ declare module "playwright" {
|
|
|
15
15
|
clip?: { x: number; y: number; width: number; height: number };
|
|
16
16
|
type?: "png" | "jpeg";
|
|
17
17
|
quality?: number;
|
|
18
|
+
omitBackground?: boolean;
|
|
18
19
|
}): Promise<Buffer>;
|
|
19
20
|
addScriptTag(options: { content: string }): Promise<unknown>;
|
|
20
21
|
pdf(options: {
|