@hajimism/dek 0.3.3 → 0.5.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 +136 -0
- package/README.ja.md +2 -2
- package/README.md +2 -2
- package/package.json +5 -3
- package/skills/dek/SKILL.md +10 -3
- package/skills/dek-design/SKILL.md +42 -0
- package/src/cli/address.ts +95 -18
- package/src/cli/build.ts +2 -2
- package/src/cli/check.ts +2 -1
- package/src/cli/commands.ts +57 -19
- package/src/cli/contract.ts +58 -4
- package/src/cli/files.ts +2 -5
- package/src/cli/flags.ts +1 -1
- package/src/cli/format.ts +5 -4
- package/src/cli/goto.ts +44 -11
- package/src/cli/init.ts +10 -5
- package/src/cli/lint.ts +36 -16
- package/src/cli/ls.ts +5 -1
- package/src/cli/main.ts +51 -14
- package/src/cli/new.ts +28 -8
- package/src/cli/pptx.ts +9 -4
- package/src/cli/ref.ts +92 -9
- package/src/cli/result.ts +47 -6
- package/src/cli/scope.ts +57 -29
- package/src/cli/shot.ts +78 -6
- package/src/cli/show.ts +15 -2
- package/src/cli/text.ts +88 -11
- package/src/cli/theme.ts +58 -10
- package/src/cli/tty.ts +16 -2
- package/src/cli/voice.ts +15 -9
- 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/assets.ts +18 -10
- package/src/core/attribute-value.ts +188 -0
- package/src/core/build.ts +3 -3
- package/src/core/config.ts +61 -12
- package/src/core/css-scan.ts +173 -39
- package/src/core/css-transform.ts +57 -11
- package/src/core/css.ts +19 -13
- package/src/core/diagnostic.ts +5 -1
- package/src/core/escape.ts +43 -0
- package/src/core/foreign-text.ts +138 -0
- package/src/core/github.ts +1 -0
- package/src/core/html-entities.ts +318 -0
- package/src/core/html-scan.ts +49 -27
- package/src/core/html.ts +21 -15
- package/src/core/line-break.ts +275 -0
- package/src/core/lint/asset-refs.ts +50 -8
- package/src/core/lint/files.ts +30 -5
- package/src/core/lint/keys.ts +7 -3
- package/src/core/lint/slide-html.ts +24 -19
- package/src/core/lint/slide-script.ts +7 -5
- package/src/core/mv.ts +57 -27
- package/src/core/order.ts +14 -0
- package/src/core/pack.ts +2 -2
- package/src/core/path.ts +8 -2
- package/src/core/playwright-module.d.ts +13 -1
- package/src/core/playwright-motion.ts +1 -0
- package/src/core/playwright-sheet.ts +1 -0
- package/src/core/playwright-visual.ts +207 -30
- package/src/core/playwright.ts +279 -44
- 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 +56 -8
- package/src/core/prose.ts +23 -0
- package/src/core/pseudo-text.ts +32 -10
- package/src/core/ref-name.ts +52 -8
- package/src/core/ref.ts +308 -54
- package/src/core/resolve.ts +23 -11
- package/src/core/rumdl.ts +51 -25
- package/src/core/safe-fs.ts +19 -2
- package/src/core/sandbox.ts +217 -0
- 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 +26 -17
- package/src/core/slide-eval-worker.ts +1 -0
- package/src/core/slide-measure.ts +51 -1
- package/src/core/slide-script-eval.ts +12 -8
- package/src/core/slide-script.ts +114 -20
- package/src/core/spawn.ts +131 -32
- package/src/core/sync.ts +5 -14
- package/src/core/tar.ts +315 -0
- package/src/core/text-contrast.ts +2 -0
- package/src/core/toml-keys.ts +116 -51
- package/src/core/url-attributes.ts +27 -9
- package/src/core/visual-cache.ts +2 -0
- package/src/core/visual.ts +182 -24
- package/src/core/voice.ts +5 -6
- package/src/core/zod.ts +9 -1
- package/src/server/auth.ts +21 -0
- package/src/server/dev.ts +26 -7
- package/src/server/lock.ts +50 -7
- package/src/server/routes.ts +7 -2
- package/src/server/serial.ts +8 -2
- package/src/theme/default.css +4 -0
- package/src/video/bake.ts +6 -2
- package/src/video/mux.ts +27 -3
- package/src/video/recorder.ts +3 -2
- package/src/video/worker.ts +1 -0
- package/src/voice/engine.ts +81 -18
package/src/cli/tty.ts
CHANGED
|
@@ -10,15 +10,29 @@ const codes = {
|
|
|
10
10
|
bold: "\x1b[1m",
|
|
11
11
|
} as const;
|
|
12
12
|
|
|
13
|
+
/** The codes dek's own palette writes, which are all terminalSafe lets through. */
|
|
14
|
+
const PALETTE = new Set<string>([reset, ...Object.values(codes)]);
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Text with no control character but a newline and a tab: each other shows as U+FFFD, the escape
|
|
18
|
+
* that starts a color code included. A result's and an error's text go through it before dek
|
|
19
|
+
* paints them, so what a deck or a ref wrote carries no color, conceal, or cursor move of its own.
|
|
20
|
+
*/
|
|
21
|
+
export function plainText(text: string): string {
|
|
22
|
+
// biome-ignore lint/suspicious/noControlCharactersInRegex: matching them is the point.
|
|
23
|
+
return text.replace(/[\x00-\x08\x0b-\x1f\x7f-\x9f]/g, "\uFFFD");
|
|
24
|
+
}
|
|
25
|
+
|
|
13
26
|
/**
|
|
14
27
|
* Text for a terminal: dek's own colors, newlines, and tabs pass, and every other control
|
|
15
28
|
* character shows as U+FFFD. A deck title, a file name, or a ref's title fetched from GitHub
|
|
16
|
-
* could otherwise set the window title, draw a fake link (OSC 8),
|
|
29
|
+
* could otherwise set the window title, draw a fake link (OSC 8), clear the screen, or hide
|
|
30
|
+
* itself from a person while an agent reading the bytes sees it, as conceal (SGR 8) does.
|
|
17
31
|
*/
|
|
18
32
|
export function terminalSafe(text: string): string {
|
|
19
33
|
// biome-ignore lint/suspicious/noControlCharactersInRegex: matching them is the point.
|
|
20
34
|
return text.replace(/\x1b\[[0-9;]*m|[\x00-\x08\x0b-\x1f\x7f-\x9f]/g, (match) =>
|
|
21
|
-
match
|
|
35
|
+
PALETTE.has(match) ? match : plainText(match),
|
|
22
36
|
);
|
|
23
37
|
}
|
|
24
38
|
|
package/src/cli/voice.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import { spawn } from "node:child_process";
|
|
2
1
|
import { readFileSync } from "node:fs";
|
|
3
2
|
import { join } from "node:path";
|
|
4
3
|
import { DekError } from "../core/error.ts";
|
|
5
4
|
import { deckProjectRoot } from "../core/path.ts";
|
|
6
|
-
import { isCachedFile, writeInside } from "../core/safe-fs.ts";
|
|
5
|
+
import { isCachedFile, requireInside, writeInside } from "../core/safe-fs.ts";
|
|
6
|
+
import { runPiped } from "../core/spawn.ts";
|
|
7
7
|
import { loadVoiceDict, loadVoiceSettings, voiceCacheFile, writeVoiceDict } from "../core/voice.ts";
|
|
8
8
|
import {
|
|
9
9
|
engineBaseUrl,
|
|
@@ -91,11 +91,15 @@ export function pinVoice({ deck }: DeckTarget): VoiceCliResult {
|
|
|
91
91
|
const pinDir = join(deck.dir, "voice", "pin");
|
|
92
92
|
const pinTimeline = join(pinDir, "timeline.json");
|
|
93
93
|
const pinAudio = join(pinDir, "master.wav");
|
|
94
|
-
|
|
95
|
-
writeInside(
|
|
94
|
+
// The folders on the way may be links too; what is pinned comes from inside the project.
|
|
95
|
+
writeInside(pinTimeline, readFileSync(requireInside(timelinePath, root)), root);
|
|
96
|
+
writeInside(pinAudio, readFileSync(requireInside(audioPath, root)), root);
|
|
96
97
|
return { action: "pin", timelinePath: pinTimeline, audioPath: pinAudio };
|
|
97
98
|
}
|
|
98
99
|
|
|
100
|
+
/** A clip of a sentence or two plays well inside this. */
|
|
101
|
+
const PLAY_TIMEOUT_MS = 5 * 60 * 1000;
|
|
102
|
+
|
|
99
103
|
async function playWav(path: string): Promise<void> {
|
|
100
104
|
if (process.env.DEK_VOICE_PLAY === "0") {
|
|
101
105
|
return;
|
|
@@ -105,9 +109,11 @@ async function playWav(path: string): Promise<void> {
|
|
|
105
109
|
if (!bin) {
|
|
106
110
|
return;
|
|
107
111
|
}
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
112
|
+
// Playing is a courtesy: the clip is written either way, so a player that is missing, fails,
|
|
113
|
+
// or hangs is let go.
|
|
114
|
+
await runPiped([bin, path], {
|
|
115
|
+
label: `${bin} failed`,
|
|
116
|
+
hint: `play ${path} yourself`,
|
|
117
|
+
timeoutMs: PLAY_TIMEOUT_MS,
|
|
118
|
+
}).catch(() => {});
|
|
113
119
|
}
|
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
import type { Box, MeasuredElement } from "./slide-measure.ts";
|
|
2
|
+
|
|
3
|
+
/** What lining up needs to know of an element. */
|
|
4
|
+
export type AlignedElement = Pick<
|
|
5
|
+
MeasuredElement,
|
|
6
|
+
| "box"
|
|
7
|
+
| "parent"
|
|
8
|
+
| "rect"
|
|
9
|
+
| "textRects"
|
|
10
|
+
| "baseline"
|
|
11
|
+
| "opacity"
|
|
12
|
+
| "decorative"
|
|
13
|
+
| "inline"
|
|
14
|
+
| "tilted"
|
|
15
|
+
| "paints"
|
|
16
|
+
| "picture"
|
|
17
|
+
| "fontSize"
|
|
18
|
+
>;
|
|
19
|
+
|
|
20
|
+
/** An edge items side by side share (`top`, `middle`, `bottom`), or items stacked (`left`, …). */
|
|
21
|
+
export type AlignedEdge = "top" | "middle" | "bottom" | "left" | "center" | "right";
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Two items that nearly line up and do not: `element` is the one off the line, `other` the one it
|
|
25
|
+
* misses, both indices into the elements measured. `of` says whether their boxes miss each other
|
|
26
|
+
* or the text that starts inside them.
|
|
27
|
+
*/
|
|
28
|
+
export type Misalignment = {
|
|
29
|
+
element: number;
|
|
30
|
+
other: number;
|
|
31
|
+
edge: AlignedEdge;
|
|
32
|
+
of: "box" | "text";
|
|
33
|
+
/** How far apart, in the slide's own pixels. */
|
|
34
|
+
by: number;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/** Edges this close are one line; layout rounds to fractions of a pixel nobody sees. */
|
|
38
|
+
const SAME = 0.5;
|
|
39
|
+
/** Edges closer than this look like one, on a projector as on a laptop. */
|
|
40
|
+
const SEEN = 1.5;
|
|
41
|
+
/** Edges this far apart, or farther, read as a choice; nearer, as a mistake. */
|
|
42
|
+
const NEAR = 8;
|
|
43
|
+
|
|
44
|
+
type Side = "start" | "center" | "end";
|
|
45
|
+
const SIDES: Side[] = ["start", "center", "end"];
|
|
46
|
+
type Axis = Record<Side, AlignedEdge>;
|
|
47
|
+
/** Items side by side, compared down the slide. */
|
|
48
|
+
const ROW: Axis = { start: "top", center: "middle", end: "bottom" };
|
|
49
|
+
/** Items stacked, compared across it. */
|
|
50
|
+
const COLUMN: Axis = { start: "left", center: "center", end: "right" };
|
|
51
|
+
|
|
52
|
+
function along(rect: Box, axis: Axis): Record<Side, number> {
|
|
53
|
+
return axis === ROW
|
|
54
|
+
? { start: rect.top, center: (rect.top + rect.bottom) / 2, end: rect.bottom }
|
|
55
|
+
: { start: rect.left, center: (rect.left + rect.right) / 2, end: rect.right };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Side by side when only their heights overlap, stacked when only their widths do. */
|
|
59
|
+
function relation(a: Box, b: Box): Axis | undefined {
|
|
60
|
+
const across = Math.min(a.right, b.right) - Math.max(a.left, b.left);
|
|
61
|
+
const down = Math.min(a.bottom, b.bottom) - Math.max(a.top, b.top);
|
|
62
|
+
if (down > 0 && across <= 0) {
|
|
63
|
+
return ROW;
|
|
64
|
+
}
|
|
65
|
+
if (across > 0 && down <= 0) {
|
|
66
|
+
return COLUMN;
|
|
67
|
+
}
|
|
68
|
+
return undefined;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function union(rects: Box[]): Box {
|
|
72
|
+
return {
|
|
73
|
+
left: Math.min(...rects.map((rect) => rect.left)),
|
|
74
|
+
top: Math.min(...rects.map((rect) => rect.top)),
|
|
75
|
+
right: Math.max(...rects.map((rect) => rect.right)),
|
|
76
|
+
bottom: Math.max(...rects.map((rect) => rect.bottom)),
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const near = (apart: number): boolean => Math.abs(apart) >= SEEN && Math.abs(apart) <= NEAR;
|
|
81
|
+
|
|
82
|
+
/** What an item is, as its tag and first class say, whatever beat it shows at or id it has. */
|
|
83
|
+
const kindOf = (box: string): string => box.replace(/#[^.[]+|\[data-step="[^"]*"\]/g, "");
|
|
84
|
+
|
|
85
|
+
/** An item compared: one element, or the row or the stack several form beside another item. */
|
|
86
|
+
type Group = {
|
|
87
|
+
members: number[];
|
|
88
|
+
rect: Box;
|
|
89
|
+
line: FirstLine | undefined;
|
|
90
|
+
/** Whether every member paints its box or is a picture, so its edges show. */
|
|
91
|
+
framed: boolean;
|
|
92
|
+
kind: string | undefined;
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* The items on a slide that nearly line up and do not. Items are compared within one parent, and
|
|
97
|
+
* only when they sit side by side, compared by top, middle, and bottom, or stacked, compared by
|
|
98
|
+
* left, center, and right. Several items in a row under one item, or stacked beside it, are
|
|
99
|
+
* compared with it as the row or the stack they form, by the member at that edge. An item lines
|
|
100
|
+
* up when it shares one of those edges, or, side by side, a baseline: a heading over a wider list,
|
|
101
|
+
* an icon centered beside a line of text, and a number on its unit's baseline all pass. Where
|
|
102
|
+
* nothing is shared and the nearest edge is two pixels to eight away, it reads as a mistake; so
|
|
103
|
+
* does a start, top or left, that nearly meets where the end meets, between items that hold text.
|
|
104
|
+
* Items of one kind whose box the audience sees, as a row of cards, are held to more: one that
|
|
105
|
+
* shares its top and nearly its bottom with the others is a little shorter, and ones that start
|
|
106
|
+
* on one line should start their text on one line too.
|
|
107
|
+
*
|
|
108
|
+
* Inline boxes, which sit on a line of text, decoration, what the beat has not shown, what a
|
|
109
|
+
* transform turns, and the parts of a picture, such as an SVG's shapes, are not items. Of two
|
|
110
|
+
* items that miss each other, the one fewer others line up with is off the line, and of two alike
|
|
111
|
+
* the later; each is named once for each of its edges.
|
|
112
|
+
*/
|
|
113
|
+
export function findMisalignments(elements: AlignedElement[]): Misalignment[] {
|
|
114
|
+
const inPicture = (index: number): boolean => {
|
|
115
|
+
for (let at = elements[index]?.parent ?? -1; at >= 0; at = elements[at]?.parent ?? -1) {
|
|
116
|
+
if (elements[at]?.picture) {
|
|
117
|
+
return true;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return false;
|
|
121
|
+
};
|
|
122
|
+
const items = elements.flatMap((element, index) =>
|
|
123
|
+
element.opacity >= 0.01 &&
|
|
124
|
+
!element.decorative &&
|
|
125
|
+
!element.inline &&
|
|
126
|
+
!element.tilted &&
|
|
127
|
+
element.rect.right > element.rect.left &&
|
|
128
|
+
element.rect.bottom > element.rect.top &&
|
|
129
|
+
!inPicture(index)
|
|
130
|
+
? [index]
|
|
131
|
+
: [],
|
|
132
|
+
);
|
|
133
|
+
const lines = firstLines(elements);
|
|
134
|
+
const rectOf = (index: number): Box => elements[index]?.rect ?? union([]);
|
|
135
|
+
|
|
136
|
+
// The row or the stack `item` forms beside `other`: the items `other` relates to as it does to
|
|
137
|
+
// `item`, which sit across that from `item` or from one another.
|
|
138
|
+
const groupOf = (item: number, other: number, axis: Axis, siblings: number[]): Group => {
|
|
139
|
+
const across = axis === ROW ? COLUMN : ROW;
|
|
140
|
+
const members = [item];
|
|
141
|
+
for (let at = 0; at < members.length; at++) {
|
|
142
|
+
const member = members[at] ?? item;
|
|
143
|
+
for (const candidate of siblings) {
|
|
144
|
+
if (
|
|
145
|
+
!members.includes(candidate) &&
|
|
146
|
+
candidate !== other &&
|
|
147
|
+
relation(rectOf(candidate), rectOf(other)) === axis &&
|
|
148
|
+
relation(rectOf(candidate), rectOf(member)) === across
|
|
149
|
+
) {
|
|
150
|
+
members.push(candidate);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
members.sort((a, b) => a - b);
|
|
155
|
+
const kinds = new Set(members.map((member) => kindOf(elements[member]?.box ?? "")));
|
|
156
|
+
const memberLines = members.flatMap((member) => lines[member] ?? []);
|
|
157
|
+
return {
|
|
158
|
+
members,
|
|
159
|
+
rect: union(members.map(rectOf)),
|
|
160
|
+
line: memberLines.reduce<FirstLine | undefined>(
|
|
161
|
+
(first, line) => (!first || earlier(line.rect, first.rect) ? line : first),
|
|
162
|
+
undefined,
|
|
163
|
+
),
|
|
164
|
+
framed: members.every(
|
|
165
|
+
(member) => elements[member]?.paints === true || elements[member]?.picture === true,
|
|
166
|
+
),
|
|
167
|
+
kind: kinds.size === 1 ? [...kinds][0] : undefined,
|
|
168
|
+
};
|
|
169
|
+
};
|
|
170
|
+
// The member whose edge is the group's on that side.
|
|
171
|
+
const at = (group: Group, axis: Axis, side: Side): number =>
|
|
172
|
+
side === "center"
|
|
173
|
+
? (group.members[0] ?? -1)
|
|
174
|
+
: group.members.reduce((best, member) => {
|
|
175
|
+
const value = along(rectOf(member), axis)[side];
|
|
176
|
+
const kept = along(rectOf(best), axis)[side];
|
|
177
|
+
return (side === "start" ? value < kept : value > kept) ? member : best;
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
type Miss = Misalignment & { key: string };
|
|
181
|
+
const misses: Miss[] = [];
|
|
182
|
+
// How many items each one lines up with, edge by edge.
|
|
183
|
+
const support = new Map<string, number>();
|
|
184
|
+
const lineUp = (index: number, key: string) => {
|
|
185
|
+
support.set(`${index}\0${key}`, (support.get(`${index}\0${key}`) ?? 0) + 1);
|
|
186
|
+
};
|
|
187
|
+
const compare = (
|
|
188
|
+
[a, b]: [number, number],
|
|
189
|
+
edge: AlignedEdge,
|
|
190
|
+
of: Miss["of"],
|
|
191
|
+
apart: number,
|
|
192
|
+
): void => {
|
|
193
|
+
const key = `${of}\0${edge}`;
|
|
194
|
+
if (Math.abs(apart) < SAME) {
|
|
195
|
+
lineUp(a, key);
|
|
196
|
+
lineUp(b, key);
|
|
197
|
+
} else if (near(apart)) {
|
|
198
|
+
misses.push({ element: b, other: a, edge, of, by: Math.round(Math.abs(apart)), key });
|
|
199
|
+
}
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
const siblings = Map.groupBy(items, (index) => elements[index]?.parent ?? -1);
|
|
203
|
+
for (const group of siblings.values()) {
|
|
204
|
+
for (const [index, a] of group.entries()) {
|
|
205
|
+
for (const b of group.slice(index + 1)) {
|
|
206
|
+
const axis = relation(rectOf(a), rectOf(b));
|
|
207
|
+
if (!axis) {
|
|
208
|
+
continue;
|
|
209
|
+
}
|
|
210
|
+
const one = groupOf(a, b, axis, group);
|
|
211
|
+
const two = groupOf(b, a, axis, group);
|
|
212
|
+
// A row or a stack is compared once, as its first member.
|
|
213
|
+
if (one.members[0] !== a || two.members[0] !== b) {
|
|
214
|
+
continue;
|
|
215
|
+
}
|
|
216
|
+
const from = along(one.rect, axis);
|
|
217
|
+
const to = along(two.rect, axis);
|
|
218
|
+
const apart = (side: Side) => to[side] - from[side];
|
|
219
|
+
const shared = (side: Side) => Math.abs(apart(side)) < SAME;
|
|
220
|
+
const pair = (side: Side): [number, number] => [at(one, axis, side), at(two, axis, side)];
|
|
221
|
+
for (const side of SIDES.filter(shared)) {
|
|
222
|
+
compare(pair(side), axis[side], "box", 0);
|
|
223
|
+
}
|
|
224
|
+
const texts = one.line !== undefined && two.line !== undefined;
|
|
225
|
+
const cards =
|
|
226
|
+
one.framed && two.framed && texts && one.kind !== undefined && one.kind === two.kind;
|
|
227
|
+
// Text set on one baseline lines up, whatever boxes that do not show do. A baseline is
|
|
228
|
+
// worked out from the font's ascent, which layout may round where a canvas does not, so
|
|
229
|
+
// two that look like one count as one.
|
|
230
|
+
const baseline =
|
|
231
|
+
axis === ROW &&
|
|
232
|
+
!(one.framed && two.framed) &&
|
|
233
|
+
one.line?.baseline !== undefined &&
|
|
234
|
+
two.line?.baseline !== undefined &&
|
|
235
|
+
Math.abs(one.line.baseline - two.line.baseline) < SEEN;
|
|
236
|
+
if (baseline) {
|
|
237
|
+
// Nothing more to compare.
|
|
238
|
+
} else if (shared("center")) {
|
|
239
|
+
// Centered on one line, whatever their sizes.
|
|
240
|
+
} else if (shared("start") || shared("end")) {
|
|
241
|
+
// Side by side, text starts at the top, so a top a few pixels off reads where the
|
|
242
|
+
// bottoms meet; and a row of cards is one height, so one that shares its top and nearly
|
|
243
|
+
// its bottom is a little shorter. Stacked, items take the width their words give them,
|
|
244
|
+
// as tags set flush right do, and only an edge none of them shares can be off.
|
|
245
|
+
if (axis === ROW && shared("end") && texts) {
|
|
246
|
+
compare(pair("start"), axis.start, "box", apart("start"));
|
|
247
|
+
} else if (axis === ROW && shared("start") && cards) {
|
|
248
|
+
compare(pair("end"), axis.end, "box", apart("end"));
|
|
249
|
+
}
|
|
250
|
+
} else {
|
|
251
|
+
const nearest = SIDES.reduce((best, side) =>
|
|
252
|
+
Math.abs(apart(side)) < Math.abs(apart(best)) ? side : best,
|
|
253
|
+
);
|
|
254
|
+
compare(pair(nearest), axis[nearest], "box", apart(nearest));
|
|
255
|
+
}
|
|
256
|
+
// Text in cards off the line moved with them, and is named by their edge. Text centered
|
|
257
|
+
// in its card lines up by its middle, whatever its length.
|
|
258
|
+
if (cards && shared("start") && one.line && two.line) {
|
|
259
|
+
const [from, to] = [along(one.line.rect, axis), along(two.line.rect, axis)];
|
|
260
|
+
const centered = Math.abs(to.center - from.center) < SAME;
|
|
261
|
+
if (axis === COLUMN || one.line.fontSize === two.line.fontSize) {
|
|
262
|
+
compare([a, b], axis.start, "text", centered ? 0 : to.start - from.start);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const named = new Set<string>();
|
|
270
|
+
return misses.flatMap(({ key, ...miss }) => {
|
|
271
|
+
const ofOther = support.get(`${miss.other}\0${key}`) ?? 0;
|
|
272
|
+
const ofElement = support.get(`${miss.element}\0${key}`) ?? 0;
|
|
273
|
+
const found =
|
|
274
|
+
ofElement > ofOther ? { ...miss, element: miss.other, other: miss.element } : miss;
|
|
275
|
+
const id = `${found.element}\0${key}`;
|
|
276
|
+
if (named.has(id)) {
|
|
277
|
+
return [];
|
|
278
|
+
}
|
|
279
|
+
named.add(id);
|
|
280
|
+
return [found];
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** The first line of text in an element: where it is drawn, its baseline, and its size. */
|
|
285
|
+
type FirstLine = { rect: Box; baseline?: number; fontSize: number };
|
|
286
|
+
|
|
287
|
+
/** Whether `a` comes before `b` in reading order: higher, or as high and further left. */
|
|
288
|
+
function earlier(a: Box, b: Box): boolean {
|
|
289
|
+
return a.top < b.top - SAME || (Math.abs(a.top - b.top) < SAME && a.left < b.left);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Each element's first line of text, its own or a descendant's, in reading order. Decoration and
|
|
294
|
+
* what the beat has not shown have none.
|
|
295
|
+
*/
|
|
296
|
+
function firstLines(elements: AlignedElement[]): Array<FirstLine | undefined> {
|
|
297
|
+
const first: Array<FirstLine | undefined> = elements.map(() => undefined);
|
|
298
|
+
// A parent comes before its children, so walking back reaches every child before its parent.
|
|
299
|
+
for (let index = elements.length - 1; index >= 0; index--) {
|
|
300
|
+
const element = elements[index];
|
|
301
|
+
if (!element || element.decorative || element.opacity < 0.01) {
|
|
302
|
+
continue;
|
|
303
|
+
}
|
|
304
|
+
const [own] = element.textRects;
|
|
305
|
+
const kept = first[index];
|
|
306
|
+
if (own && (!kept || earlier(own, kept.rect))) {
|
|
307
|
+
first[index] = {
|
|
308
|
+
rect: own,
|
|
309
|
+
fontSize: element.fontSize,
|
|
310
|
+
...(element.baseline !== undefined ? { baseline: element.baseline } : {}),
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
const mine = first[index];
|
|
314
|
+
const parent = first[element.parent];
|
|
315
|
+
if (mine && element.parent >= 0 && (!parent || earlier(mine.rect, parent.rect))) {
|
|
316
|
+
first[element.parent] = mine;
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
return first;
|
|
320
|
+
}
|
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
// edit, and the Markdown Copy writes. No DOM and no file system here, so all of it is decided by
|
|
4
4
|
// these functions alone.
|
|
5
5
|
|
|
6
|
+
import { isPlainWord } from "./escape.ts";
|
|
7
|
+
|
|
6
8
|
/** A box on the slide, in logical pixels, from its top left. */
|
|
7
9
|
export type Box = { x: number; y: number; width: number; height: number };
|
|
8
10
|
|
|
@@ -115,8 +117,10 @@ export function formatNotes(
|
|
|
115
117
|
groups.set(key, [...(groups.get(key) ?? []), row]);
|
|
116
118
|
}
|
|
117
119
|
|
|
120
|
+
// The deck's folder and the notes file are the project's text, which a clone brings along.
|
|
121
|
+
const folder = shown(deck, "<deck>");
|
|
118
122
|
const lines = [
|
|
119
|
-
`## Notes on decks/${
|
|
123
|
+
`## Notes on decks/${folder}`,
|
|
120
124
|
"",
|
|
121
125
|
"Boxes and points are in the slide's own pixels, from its top left.",
|
|
122
126
|
];
|
|
@@ -129,11 +133,11 @@ export function formatNotes(
|
|
|
129
133
|
const title = slides[index]?.title;
|
|
130
134
|
lines.push(
|
|
131
135
|
"",
|
|
132
|
-
`### Slide ${index + 1} of ${slides.length}, ${first.slug}${title ? ` (${JSON.stringify(title)})` : ""}, ${beatLabel(first.step)}`,
|
|
136
|
+
`### Slide ${index + 1} of ${slides.length}, ${shown(first.slug, "<slug>")}${title ? ` (${JSON.stringify(title)})` : ""}, ${beatLabel(first.step)}`,
|
|
133
137
|
);
|
|
134
138
|
for (const row of group) {
|
|
135
139
|
const where = (target: TargetRow): string =>
|
|
136
|
-
`${describe(target)} at decks/${
|
|
140
|
+
`${describe(target)} at decks/${folder}/slides/${shown(row.slug, "<slug>")}.html:${target.line}:${target.column} (${measure(target)})${target.found ? "" : ", as written before an edit; the line may have moved"}`;
|
|
137
141
|
lines.push("");
|
|
138
142
|
if (row.targets.length === 1 && row.targets[0]) {
|
|
139
143
|
lines.push(`${row.number}. ${where(row.targets[0])}`);
|
|
@@ -159,7 +163,15 @@ function beatLabel(step: string): string {
|
|
|
159
163
|
if (step === "0") {
|
|
160
164
|
return "as it arrives";
|
|
161
165
|
}
|
|
162
|
-
|
|
166
|
+
if (/^\d+$/.test(step)) {
|
|
167
|
+
return `at beat ${step}`;
|
|
168
|
+
}
|
|
169
|
+
return isPlainWord(step) ? `at beat \`${step}\`` : "at beat <step>";
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** `word` as it is when it is plain, else `placeholder`: see `isPlainWord`. */
|
|
173
|
+
function shown(word: string, placeholder: string): string {
|
|
174
|
+
return isPlainWord(word) ? word : placeholder;
|
|
163
175
|
}
|
|
164
176
|
|
|
165
177
|
function describe(target: TargetRow): string {
|
package/src/core/annotations.ts
CHANGED
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
} from "./annotation-rows.ts";
|
|
10
10
|
import { deckPaths } from "./deck-paths.ts";
|
|
11
11
|
import { DekError } from "./error.ts";
|
|
12
|
+
import { shellName } from "./escape.ts";
|
|
12
13
|
import { deckSlides, type WrittenElement, writtenElements } from "./html.ts";
|
|
13
14
|
import { type ProjectDeck, readDeckFile } from "./resolve.ts";
|
|
14
15
|
import { readSourceIfExists, writeInside } from "./safe-fs.ts";
|
|
@@ -306,11 +307,28 @@ function toRow(
|
|
|
306
307
|
text: note.text,
|
|
307
308
|
targets,
|
|
308
309
|
changed,
|
|
309
|
-
shot:
|
|
310
|
+
shot: shotCommand(deck.name, note.slug, note.step),
|
|
310
311
|
createdAt: note.createdAt,
|
|
311
312
|
};
|
|
312
313
|
}
|
|
313
314
|
|
|
315
|
+
/**
|
|
316
|
+
* The shot of a note's slide at its beat, as a command to paste. Each name is the file's, so one
|
|
317
|
+
* that no word holds alike in every shell is a placeholder, and one that starts with a dash goes
|
|
318
|
+
* after `--`, where it reads as no flag.
|
|
319
|
+
*/
|
|
320
|
+
function shotCommand(deck: string, slug: string, step: string): string {
|
|
321
|
+
const [name, slide, beat] = [
|
|
322
|
+
shellName(deck) ?? "<deck>",
|
|
323
|
+
shellName(slug) ?? "<slug>",
|
|
324
|
+
shellName(step) ?? "<step>",
|
|
325
|
+
];
|
|
326
|
+
const flag = step.startsWith("-") ? `--step=${beat}` : `--step ${beat}`;
|
|
327
|
+
return deck.startsWith("-") || slug.startsWith("-")
|
|
328
|
+
? `dekc shot ${flag} -- ${name} ${slide}`
|
|
329
|
+
: `dekc shot ${name} ${slide} ${flag}`;
|
|
330
|
+
}
|
|
331
|
+
|
|
314
332
|
/** A hash of each of the slide's own files there is, by its extension. */
|
|
315
333
|
function slideFileHashes(deck: ProjectDeck, slug: string): Record<string, string> {
|
|
316
334
|
const paths = deckPaths(deck.dir);
|
package/src/core/assets.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { readFileSync, realpathSync, statSync } from "node:fs";
|
|
2
2
|
import { extname, relative, resolve, sep } from "node:path";
|
|
3
|
+
import { decodeAttribute } from "./attribute-value.ts";
|
|
3
4
|
import { parseCss } from "./css.ts";
|
|
4
5
|
import {
|
|
5
6
|
type CssEditor,
|
|
@@ -10,9 +11,10 @@ import {
|
|
|
10
11
|
shareTokensWithTransitions,
|
|
11
12
|
} from "./css-transform.ts";
|
|
12
13
|
import { deckPaths } from "./deck-paths.ts";
|
|
14
|
+
import { escapeAttr } from "./escape.ts";
|
|
13
15
|
import { isInside } from "./path.ts";
|
|
14
16
|
import { listSlideFiles, readDeckFile } from "./resolve.ts";
|
|
15
|
-
import { attributeUrls } from "./url-attributes.ts";
|
|
17
|
+
import { attributeUrls, trimUrl } from "./url-attributes.ts";
|
|
16
18
|
|
|
17
19
|
/** Where one reference from a slide or a stylesheet points. */
|
|
18
20
|
export type AssetRef =
|
|
@@ -35,13 +37,14 @@ const SCHEME_RE = /^[a-z][a-z0-9+.-]*:/i;
|
|
|
35
37
|
* Resolves a reference the one way lint, show, and the build agree on: the
|
|
36
38
|
* deck root first, which is where the dev server and the build look for
|
|
37
39
|
* `assets/`, then next to the file that names it, so `../assets/x` from
|
|
38
|
-
* slides/ still resolves. A query string or fragment is not part of the path
|
|
40
|
+
* slides/ still resolves. A query string or fragment is not part of the path, and only the C0
|
|
41
|
+
* controls and spaces at its ends are trimmed, as the URL parser trims them.
|
|
39
42
|
*/
|
|
40
43
|
export function classifyAssetRef(
|
|
41
44
|
raw: string,
|
|
42
45
|
options: { deckDir: string; from: string },
|
|
43
46
|
): AssetRef {
|
|
44
|
-
const value = raw
|
|
47
|
+
const value = trimUrl(raw).split(/[?#]/)[0] ?? "";
|
|
45
48
|
if (!value) {
|
|
46
49
|
return { kind: "skip" };
|
|
47
50
|
}
|
|
@@ -93,7 +96,7 @@ export function fileInside(
|
|
|
93
96
|
|
|
94
97
|
/** True when a reference is written as `assets/...` from the deck root, or is not a local path at all. */
|
|
95
98
|
export function isCanonicalAssetPath(raw: string): boolean {
|
|
96
|
-
const value = raw
|
|
99
|
+
const value = trimUrl(raw);
|
|
97
100
|
if (!value || value.startsWith("#") || SCHEME_RE.test(value) || REMOTE_RE.test(value)) {
|
|
98
101
|
return true;
|
|
99
102
|
}
|
|
@@ -118,8 +121,10 @@ export function inlineAssets(
|
|
|
118
121
|
|
|
119
122
|
/**
|
|
120
123
|
* The rewriter handler `inlineAssets` runs, for a pass that rewrites more than URLs. The URLs are
|
|
121
|
-
* the ones lint checks, read by the same parser
|
|
122
|
-
* descriptors, links and text stay as written.
|
|
124
|
+
* the ones lint checks, read by the same parser and decoded as the browser decodes them: every
|
|
125
|
+
* candidate of a `srcset` keeps its descriptors, links and text stay as written. The rewriter
|
|
126
|
+
* writes a value as it is given, escaping only `"`, so each data: URI goes in escaped, in place
|
|
127
|
+
* of the URL as written, and an attribute with nothing to inline is never written at all.
|
|
123
128
|
*/
|
|
124
129
|
export function assetInliner(
|
|
125
130
|
deckDir: string,
|
|
@@ -128,7 +133,8 @@ export function assetInliner(
|
|
|
128
133
|
return {
|
|
129
134
|
element(el) {
|
|
130
135
|
const tag = el.tagName.toLowerCase();
|
|
131
|
-
for (const [name,
|
|
136
|
+
for (const [name, written] of [...el.attributes]) {
|
|
137
|
+
const { value, writtenAt } = decodeAttribute(written);
|
|
132
138
|
const named = attributeUrls(tag, name.toLowerCase(), value);
|
|
133
139
|
if (named?.use !== "resource") {
|
|
134
140
|
continue;
|
|
@@ -137,9 +143,11 @@ export function assetInliner(
|
|
|
137
143
|
const uri = inlinedUri(url, deckDir, from);
|
|
138
144
|
return uri === undefined
|
|
139
145
|
? rewritten
|
|
140
|
-
: rewritten.slice(0, offset) +
|
|
141
|
-
|
|
142
|
-
|
|
146
|
+
: rewritten.slice(0, writtenAt(offset)) +
|
|
147
|
+
escapeAttr(uri) +
|
|
148
|
+
rewritten.slice(writtenAt(offset + url.length));
|
|
149
|
+
}, written);
|
|
150
|
+
if (inlined !== written) {
|
|
143
151
|
el.setAttribute(name, inlined);
|
|
144
152
|
}
|
|
145
153
|
}
|