@hajimism/dek 0.2.1 → 0.3.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 +46 -0
- package/README.ja.md +3 -1
- package/README.md +3 -1
- package/package.json +4 -1
- package/skills/dek/SKILL.md +44 -0
- package/src/cli/annotations.ts +26 -0
- package/src/cli/commands.ts +66 -0
- package/src/cli/contract.ts +75 -4
- package/src/cli/env-file.ts +78 -0
- package/src/cli/files.ts +3 -3
- package/src/cli/flags.ts +1 -1
- package/src/cli/format.ts +3 -0
- package/src/cli/pptx.ts +20 -0
- package/src/cli/show.ts +17 -2
- package/src/cli/text.ts +43 -0
- package/src/cli/theme.ts +7 -1
- package/src/cli.ts +5 -1
- package/src/core/annotation-rows.ts +176 -0
- package/src/core/annotations.ts +362 -0
- package/src/core/diagnostic.ts +1 -0
- package/src/core/github.ts +27 -24
- package/src/core/html-scan.ts +54 -0
- package/src/core/html.ts +53 -8
- package/src/core/lint/slide-html.ts +92 -2
- package/src/core/lint.ts +8 -1
- package/src/core/live-protocol.ts +4 -1
- package/src/core/page.ts +6 -0
- package/src/core/playwright-module.d.ts +7 -3
- package/src/core/playwright-visual.ts +76 -1
- package/src/core/playwright.ts +65 -2
- package/src/core/pptx-package.ts +312 -0
- package/src/core/pptx-text.ts +363 -0
- package/src/core/pptx.ts +75 -0
- package/src/core/slide-measure.ts +1 -0
- package/src/core/slide-script-eval.ts +2 -1
- package/src/core/spawn.ts +8 -1
- package/src/core/sync.ts +33 -6
- package/src/core/theme-excerpt.ts +61 -9
- package/src/core/theme-facts.ts +12 -2
- package/src/core/zip.ts +77 -0
- package/src/runtime/annotate-pick.ts +2 -6
- package/src/runtime/annotate-view.ts +115 -101
- package/src/runtime/annotate.ts +5 -1
- package/src/runtime/browser.ts +5 -1
- package/src/runtime/live.ts +1 -0
- package/src/runtime/routes.ts +3 -0
- package/src/server/auth.ts +3 -1
- package/src/server/routes.ts +68 -1
- package/src/video/worker.ts +2 -0
- package/tsconfig.json +26 -0
- package/src/runtime/annotate-notes.ts +0 -207
package/src/cli/theme.ts
CHANGED
|
@@ -9,7 +9,10 @@ import type { ReadableDeck, RefInfo } from "./scope.ts";
|
|
|
9
9
|
export type ThemeResult = {
|
|
10
10
|
/** The deck's theme.css: the one lint and the slides use. */
|
|
11
11
|
path: string;
|
|
12
|
+
/** The classes slide markup may use; what `max_classes` counts. */
|
|
12
13
|
classes: string[];
|
|
14
|
+
/** The state classes the theme selects that the player sets at runtime; never in markup. */
|
|
15
|
+
stateClasses: string[];
|
|
13
16
|
/** The tokens every slide can `var()`. */
|
|
14
17
|
tokens: CssToken[];
|
|
15
18
|
layouts: ThemeLayout[];
|
|
@@ -28,10 +31,13 @@ export function themeCommand({ deck, ref }: ReadableDeck, name?: string): ThemeR
|
|
|
28
31
|
hint: "copy the project theme.css into the deck, or run `dekc new <name>` for a fresh deck",
|
|
29
32
|
});
|
|
30
33
|
}
|
|
31
|
-
const { classes, layouts, tokens } = themeFacts(
|
|
34
|
+
const { classes, stateClasses, layouts, tokens } = themeFacts(
|
|
35
|
+
parseCss(readDeckFile(deck.dir, path) ?? ""),
|
|
36
|
+
);
|
|
32
37
|
const result: ThemeResult = {
|
|
33
38
|
path,
|
|
34
39
|
classes: [...classes].sort(),
|
|
40
|
+
stateClasses: [...stateClasses].sort(),
|
|
35
41
|
tokens,
|
|
36
42
|
layouts,
|
|
37
43
|
...(ref ? { ref } : {}),
|
package/src/cli.ts
CHANGED
|
@@ -1,4 +1,8 @@
|
|
|
1
|
-
#!/usr/bin/env bun
|
|
1
|
+
#!/usr/bin/env -S bun --no-env-file --config=/dev/null
|
|
2
|
+
import { ignoreEnvFileVariables } from "./cli/env-file.ts";
|
|
2
3
|
import { main } from "./cli/main.ts";
|
|
3
4
|
|
|
5
|
+
// `bun ./node_modules/.bin/dekc` skips the line above and loads `.env` anyway. It reads
|
|
6
|
+
// `bunfig.toml` too, and nothing here can undo a preload that has already run.
|
|
7
|
+
ignoreEnvFileVariables(process.cwd());
|
|
4
8
|
await main(Bun.argv.slice(2), process.cwd());
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
// What annotate mode hands over, shared by the dev server, `dekc annotations`, and the page: a
|
|
2
|
+
// note on elements of a slide as the files have them now, how one is matched again after an
|
|
3
|
+
// edit, and the Markdown Copy writes. No DOM and no file system here, so all of it is decided by
|
|
4
|
+
// these functions alone.
|
|
5
|
+
|
|
6
|
+
/** A box on the slide, in logical pixels, from its top left. */
|
|
7
|
+
export type Box = { x: number; y: number; width: number; height: number };
|
|
8
|
+
|
|
9
|
+
/** A point on the slide, in logical pixels, from its top left. */
|
|
10
|
+
export type Point = { x: number; y: number };
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Whether a note still stands as written: `open` while the slide's own files are as they were
|
|
14
|
+
* when it was written, `edited` once one of them changed, and `gone` once none of its elements is
|
|
15
|
+
* on the slide, or the slide is gone from the script.
|
|
16
|
+
*/
|
|
17
|
+
export type AnnotationStatus = "open" | "edited" | "gone";
|
|
18
|
+
|
|
19
|
+
/** One element a note is about, where the file has it now. */
|
|
20
|
+
export type TargetRow = {
|
|
21
|
+
/** The slide's HTML file. */
|
|
22
|
+
path: string;
|
|
23
|
+
/** Where its start tag is now; where it was last found, when it is not found. */
|
|
24
|
+
line: number;
|
|
25
|
+
column: number;
|
|
26
|
+
found: boolean;
|
|
27
|
+
/** Its tag and its classes as written where it was last found, as `div.chevron`. */
|
|
28
|
+
name: string;
|
|
29
|
+
/** Its text as written when the note was written, cut short; empty for a shape. */
|
|
30
|
+
was: string;
|
|
31
|
+
/** Its text as written now; null when it is not found. */
|
|
32
|
+
text: string | null;
|
|
33
|
+
/** Its box on the slide when the note was written. */
|
|
34
|
+
box: Box;
|
|
35
|
+
/** Where the slide itself was clicked, when the note is about a place rather than an element. */
|
|
36
|
+
point?: Point;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/** One note as it stands now, numbered as the page's markers and Copy number it. */
|
|
40
|
+
export type AnnotationRow = {
|
|
41
|
+
number: number;
|
|
42
|
+
id: string;
|
|
43
|
+
slug: string;
|
|
44
|
+
/** The stop as `dekc shot --step` takes it: `0` as the slide arrives, else the beat. */
|
|
45
|
+
step: string;
|
|
46
|
+
status: AnnotationStatus;
|
|
47
|
+
/** What the human wrote; it may be empty, when the words go in the chat. */
|
|
48
|
+
text: string;
|
|
49
|
+
targets: TargetRow[];
|
|
50
|
+
/** The slide's own files that changed since the note was written. */
|
|
51
|
+
changed: string[];
|
|
52
|
+
/** The command that shows the slide at the note's beat. */
|
|
53
|
+
shot: string;
|
|
54
|
+
createdAt: string;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
/** How long an element's text may be before it is cut short. */
|
|
58
|
+
const TEXT_MAX = 40;
|
|
59
|
+
|
|
60
|
+
/** An element's text, whitespace collapsed, as a note quotes it. */
|
|
61
|
+
export function cutText(text: string): string {
|
|
62
|
+
const squashed = text.replace(/\s+/g, " ").trim();
|
|
63
|
+
return squashed.length > TEXT_MAX ? `${squashed.slice(0, TEXT_MAX)}…` : squashed;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** An element as a note names it, to match a note's target against. */
|
|
67
|
+
type Named = { source: string; name: string; text: string };
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* The element a note's target is now, after an edit: the one at the same place with the same
|
|
71
|
+
* name and text; else the only one with that name and text, wherever it moved; else the one at
|
|
72
|
+
* the same place with the same name, whose text was rewritten, as an agent does when it acts on
|
|
73
|
+
* the note; else the one at the same place with the same tag, whose classes were changed, as an
|
|
74
|
+
* agent does to restyle it. None when it cannot be told.
|
|
75
|
+
*/
|
|
76
|
+
export function reanchor<T extends Named>(target: Named, present: readonly T[]): T | undefined {
|
|
77
|
+
const same = present.find(
|
|
78
|
+
(el) => el.source === target.source && el.name === target.name && el.text === target.text,
|
|
79
|
+
);
|
|
80
|
+
if (same) {
|
|
81
|
+
return same;
|
|
82
|
+
}
|
|
83
|
+
const moved = present.filter((el) => el.name === target.name && el.text === target.text);
|
|
84
|
+
if (moved.length === 1) {
|
|
85
|
+
return moved[0];
|
|
86
|
+
}
|
|
87
|
+
const named = present.find((el) => el.source === target.source && el.name === target.name);
|
|
88
|
+
if (named) {
|
|
89
|
+
return named;
|
|
90
|
+
}
|
|
91
|
+
const tag = (name: string): string => name.split(".")[0] ?? "";
|
|
92
|
+
return present.find((el) => el.source === target.source && tag(el.name) === tag(target.name));
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** A slide as the page lists it. */
|
|
96
|
+
export type SlideInfo = { slug: string; title: string };
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* The notes as one Markdown block for an agent, in the order given, grouped by slide and beat:
|
|
100
|
+
* each element with the file and line it is written at now, and how to see the slide at that
|
|
101
|
+
* beat. Written for an agent that has no dev server to ask, so it says what `dekc annotations`
|
|
102
|
+
* would.
|
|
103
|
+
*/
|
|
104
|
+
export function formatNotes(
|
|
105
|
+
deck: string,
|
|
106
|
+
slides: readonly SlideInfo[],
|
|
107
|
+
rows: readonly AnnotationRow[],
|
|
108
|
+
): string {
|
|
109
|
+
if (rows.length === 0) {
|
|
110
|
+
return "";
|
|
111
|
+
}
|
|
112
|
+
const groups = new Map<string, AnnotationRow[]>();
|
|
113
|
+
for (const row of rows) {
|
|
114
|
+
const key = `${row.slug}\n${row.step}`;
|
|
115
|
+
groups.set(key, [...(groups.get(key) ?? []), row]);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const lines = [
|
|
119
|
+
`## Notes on decks/${deck}`,
|
|
120
|
+
"",
|
|
121
|
+
"Boxes and points are in the slide's own pixels, from its top left.",
|
|
122
|
+
];
|
|
123
|
+
for (const group of groups.values()) {
|
|
124
|
+
const first = group[0];
|
|
125
|
+
if (!first) {
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
const index = slides.findIndex((slide) => slide.slug === first.slug);
|
|
129
|
+
const title = slides[index]?.title;
|
|
130
|
+
lines.push(
|
|
131
|
+
"",
|
|
132
|
+
`### Slide ${index + 1} of ${slides.length}, ${first.slug}${title ? ` (${JSON.stringify(title)})` : ""}, ${beatLabel(first.step)}`,
|
|
133
|
+
);
|
|
134
|
+
for (const row of group) {
|
|
135
|
+
const where = (target: TargetRow): string =>
|
|
136
|
+
`${describe(target)} at decks/${deck}/slides/${row.slug}.html:${target.line}:${target.column} (${measure(target)})${target.found ? "" : ", as written before an edit; the line may have moved"}`;
|
|
137
|
+
lines.push("");
|
|
138
|
+
if (row.targets.length === 1 && row.targets[0]) {
|
|
139
|
+
lines.push(`${row.number}. ${where(row.targets[0])}`);
|
|
140
|
+
} else {
|
|
141
|
+
lines.push(`${row.number}. ${row.targets.length} elements:`);
|
|
142
|
+
lines.push(...row.targets.map((target) => ` - ${where(target)}`));
|
|
143
|
+
}
|
|
144
|
+
if (row.text.trim() !== "") {
|
|
145
|
+
lines.push(
|
|
146
|
+
...row.text
|
|
147
|
+
.trim()
|
|
148
|
+
.split(/\r?\n/)
|
|
149
|
+
.map((line) => ` > ${line}`.trimEnd()),
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
lines.push("", `To see it: \`${first.shot}\``);
|
|
154
|
+
}
|
|
155
|
+
return `${lines.join("\n")}\n`;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function beatLabel(step: string): string {
|
|
159
|
+
if (step === "0") {
|
|
160
|
+
return "as it arrives";
|
|
161
|
+
}
|
|
162
|
+
return /^\d+$/.test(step) ? `at beat ${step}` : `at beat \`${step}\``;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function describe(target: TargetRow): string {
|
|
166
|
+
const text = target.text ?? target.was;
|
|
167
|
+
return text === "" ? target.name : `${target.name} ${JSON.stringify(text)}`;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function measure(target: TargetRow): string {
|
|
171
|
+
if (target.point) {
|
|
172
|
+
return `point ${target.point.x},${target.point.y}`;
|
|
173
|
+
}
|
|
174
|
+
const { x, y, width, height } = target.box;
|
|
175
|
+
return `box ${x},${y} ${width}×${height}`;
|
|
176
|
+
}
|
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import {
|
|
5
|
+
type AnnotationRow,
|
|
6
|
+
type AnnotationStatus,
|
|
7
|
+
reanchor,
|
|
8
|
+
type TargetRow,
|
|
9
|
+
} from "./annotation-rows.ts";
|
|
10
|
+
import { deckPaths } from "./deck-paths.ts";
|
|
11
|
+
import { DekError } from "./error.ts";
|
|
12
|
+
import { deckSlides, type WrittenElement, writtenElements } from "./html.ts";
|
|
13
|
+
import { type ProjectDeck, readDeckFile } from "./resolve.ts";
|
|
14
|
+
import { readSourceIfExists, writeInside } from "./safe-fs.ts";
|
|
15
|
+
import { resolveStop } from "./step.ts";
|
|
16
|
+
import { formatZodIssues } from "./zod.ts";
|
|
17
|
+
|
|
18
|
+
const Box = z.object({
|
|
19
|
+
x: z.number().finite(),
|
|
20
|
+
y: z.number().finite(),
|
|
21
|
+
width: z.number().finite(),
|
|
22
|
+
height: z.number().finite(),
|
|
23
|
+
});
|
|
24
|
+
const Point = z.object({ x: z.number().finite(), y: z.number().finite() });
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* One element a note is about. `was` is its text as the file wrote it when the note was written;
|
|
28
|
+
* `source`, `name`, and `text` are where and what it was last found as, so a note follows its
|
|
29
|
+
* element through one edit after another.
|
|
30
|
+
*/
|
|
31
|
+
const Target = z.object({
|
|
32
|
+
source: z.string(),
|
|
33
|
+
name: z.string(),
|
|
34
|
+
was: z.string(),
|
|
35
|
+
text: z.string(),
|
|
36
|
+
box: Box,
|
|
37
|
+
point: Point.optional(),
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
/** The slide's own files, each by a hash of what it held when the note was written. */
|
|
41
|
+
const SLIDE_FILES = [".html", ".css", ".ts"] as const;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* A note as it is kept: what the human wrote on which elements of a slide at which beat, and the
|
|
45
|
+
* slide's own files as they were then, so a reader can tell an edited slide from one untouched.
|
|
46
|
+
*/
|
|
47
|
+
const Annotation = z.object({
|
|
48
|
+
id: z.string().min(1),
|
|
49
|
+
slug: z.string(),
|
|
50
|
+
step: z.string(),
|
|
51
|
+
text: z.string(),
|
|
52
|
+
createdAt: z.string(),
|
|
53
|
+
files: z.record(z.string(), z.string()),
|
|
54
|
+
targets: z.array(Target).min(1),
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
export type Annotation = z.infer<typeof Annotation>;
|
|
58
|
+
|
|
59
|
+
/** Every deck's notes, by deck name, in one file of the project's. */
|
|
60
|
+
const AnnotationsFile = z.object({
|
|
61
|
+
version: z.literal(1),
|
|
62
|
+
decks: z.record(z.string(), z.array(Annotation)),
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
type AnnotationsFile = z.infer<typeof AnnotationsFile>;
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* What the page asks of the notes: add one on elements of the slide, given by where each start
|
|
69
|
+
* tag is written; change one's words; remove one; clear the deck's; or put back what a clear took.
|
|
70
|
+
*/
|
|
71
|
+
export const AnnotationOp = z.discriminatedUnion("op", [
|
|
72
|
+
z.object({
|
|
73
|
+
op: z.literal("add"),
|
|
74
|
+
slug: z.string(),
|
|
75
|
+
step: z.string(),
|
|
76
|
+
text: z.string(),
|
|
77
|
+
targets: z.array(z.object({ source: z.string(), box: Box, point: Point.optional() })).min(1),
|
|
78
|
+
}),
|
|
79
|
+
z.object({ op: z.literal("edit"), id: z.string(), text: z.string() }),
|
|
80
|
+
z.object({ op: z.literal("remove"), id: z.string() }),
|
|
81
|
+
z.object({ op: z.literal("clear") }),
|
|
82
|
+
z.object({ op: z.literal("restore"), annotations: z.array(Annotation) }),
|
|
83
|
+
]);
|
|
84
|
+
|
|
85
|
+
export type AnnotationOp = z.infer<typeof AnnotationOp>;
|
|
86
|
+
|
|
87
|
+
/** Where the notes are kept: beside the marks, out of every deck. */
|
|
88
|
+
export function annotationsPath(root: string): string {
|
|
89
|
+
return join(root, ".dek", "annotations.json");
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Do what the page asks, and answer with the deck's notes as they stand now; a clear also hands
|
|
94
|
+
* back what it took, to put back. Input that names nothing on the slide as it is now, as a page
|
|
95
|
+
* opened before an edit can send, is refused and changes nothing.
|
|
96
|
+
*/
|
|
97
|
+
export function applyAnnotationOp(
|
|
98
|
+
root: string,
|
|
99
|
+
deck: ProjectDeck,
|
|
100
|
+
op: AnnotationOp,
|
|
101
|
+
now: Date = new Date(),
|
|
102
|
+
): { annotations: AnnotationRow[]; removed?: Annotation[] } {
|
|
103
|
+
const file = readAnnotationsFile(root);
|
|
104
|
+
const notes = file.decks[deck.name] ?? [];
|
|
105
|
+
const known = (id: string): Annotation => {
|
|
106
|
+
const found = notes.find((note) => note.id === id);
|
|
107
|
+
if (!found) {
|
|
108
|
+
throw new DekError(`no note ${id} on deck "${deck.name}"`, {
|
|
109
|
+
hint: "reload the page: the notes changed since it read them",
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
return found;
|
|
113
|
+
};
|
|
114
|
+
let next: Annotation[];
|
|
115
|
+
let removed: Annotation[] | undefined;
|
|
116
|
+
switch (op.op) {
|
|
117
|
+
case "add":
|
|
118
|
+
next = [...notes, newAnnotation(deck, op, now)];
|
|
119
|
+
break;
|
|
120
|
+
case "edit": {
|
|
121
|
+
const target = known(op.id);
|
|
122
|
+
next = notes.map((note) => (note === target ? { ...note, text: op.text } : note));
|
|
123
|
+
break;
|
|
124
|
+
}
|
|
125
|
+
case "remove": {
|
|
126
|
+
const target = known(op.id);
|
|
127
|
+
next = notes.filter((note) => note !== target);
|
|
128
|
+
break;
|
|
129
|
+
}
|
|
130
|
+
case "clear":
|
|
131
|
+
next = [];
|
|
132
|
+
removed = notes;
|
|
133
|
+
break;
|
|
134
|
+
case "restore": {
|
|
135
|
+
const ids = new Set(notes.map((note) => note.id));
|
|
136
|
+
next = [...notes, ...op.annotations.filter((note) => !ids.has(note.id))];
|
|
137
|
+
break;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
writeAnnotationsFile(root, deck.name, next, file);
|
|
141
|
+
return { annotations: listAnnotations(root, deck), ...(removed ? { removed } : {}) };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* The deck's notes in talk order, each element where the file has it now. Where each was found
|
|
146
|
+
* is kept for the next reader, so a note follows its element through edit after edit.
|
|
147
|
+
*/
|
|
148
|
+
export function listAnnotations(root: string, deck: ProjectDeck): AnnotationRow[] {
|
|
149
|
+
const file = readAnnotationsFile(root);
|
|
150
|
+
const notes = file.decks[deck.name] ?? [];
|
|
151
|
+
const slides = deckSlides(deck);
|
|
152
|
+
const present = new Map<string, WrittenElement[] | undefined>();
|
|
153
|
+
const elementsOf = (slug: string): WrittenElement[] | undefined => {
|
|
154
|
+
if (!present.has(slug)) {
|
|
155
|
+
const inScript = deck.deck.sections.some((section) => section.slug === slug);
|
|
156
|
+
present.set(slug, inScript ? writtenElements(slides, slug) : undefined);
|
|
157
|
+
}
|
|
158
|
+
return present.get(slug);
|
|
159
|
+
};
|
|
160
|
+
const followed = notes.map((note) => follow(note, elementsOf(note.slug)));
|
|
161
|
+
const moved = followed.some(({ note }, index) => note !== notes[index]);
|
|
162
|
+
if (moved) {
|
|
163
|
+
writeAnnotationsFile(
|
|
164
|
+
root,
|
|
165
|
+
deck.name,
|
|
166
|
+
followed.map(({ note }) => note),
|
|
167
|
+
file,
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
return order(deck, followed).map(({ note, found }, index) => toRow(deck, note, found, index + 1));
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Drop the deck's notes, the other decks' kept; how many there were. */
|
|
174
|
+
export function clearAnnotations(root: string, deck: ProjectDeck): number {
|
|
175
|
+
const file = readAnnotationsFile(root);
|
|
176
|
+
const count = file.decks[deck.name]?.length ?? 0;
|
|
177
|
+
if (count > 0) {
|
|
178
|
+
writeAnnotationsFile(root, deck.name, [], file);
|
|
179
|
+
}
|
|
180
|
+
return count;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
type AddOp = Extract<AnnotationOp, { op: "add" }>;
|
|
184
|
+
|
|
185
|
+
/** A note as the slide's files have it now, refused when they do not have what it names. */
|
|
186
|
+
function newAnnotation(deck: ProjectDeck, op: AddOp, now: Date): Annotation {
|
|
187
|
+
const section = deck.deck.sections.find((entry) => entry.slug === op.slug);
|
|
188
|
+
if (!section) {
|
|
189
|
+
throw new DekError(`no slide "${op.slug}" in deck "${deck.name}"`, {
|
|
190
|
+
hint: "reload the page: the script changed since it opened",
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
if (resolveStop(section.beats, op.step) === undefined) {
|
|
194
|
+
throw new DekError(`no beat "${op.step}" on slide "${op.slug}"`, {
|
|
195
|
+
hint: "reload the page: the script changed since it opened",
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
const elements = writtenElements(deckSlides(deck), op.slug) ?? [];
|
|
199
|
+
const targets = op.targets.map((target) => {
|
|
200
|
+
const element = elements.find((el) => el.source === target.source);
|
|
201
|
+
if (!element) {
|
|
202
|
+
throw new DekError(`slides/${op.slug}.html has no element at ${target.source}`, {
|
|
203
|
+
hint: "pick it again: the slide changed since the page drew it",
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
return {
|
|
207
|
+
source: element.source,
|
|
208
|
+
name: element.name,
|
|
209
|
+
was: element.text,
|
|
210
|
+
text: element.text,
|
|
211
|
+
box: target.box,
|
|
212
|
+
...(target.point ? { point: target.point } : {}),
|
|
213
|
+
};
|
|
214
|
+
});
|
|
215
|
+
return {
|
|
216
|
+
id: crypto.randomUUID(),
|
|
217
|
+
slug: op.slug,
|
|
218
|
+
step: op.step,
|
|
219
|
+
text: op.text,
|
|
220
|
+
createdAt: now.toISOString(),
|
|
221
|
+
files: slideFileHashes(deck, op.slug),
|
|
222
|
+
targets,
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** A note matched again to the slide as it is now, and which of its elements were found. */
|
|
227
|
+
type Followed = { note: Annotation; found: boolean[] };
|
|
228
|
+
|
|
229
|
+
function follow(note: Annotation, present: WrittenElement[] | undefined): Followed {
|
|
230
|
+
const matches = note.targets.map((target) => (present ? reanchor(target, present) : undefined));
|
|
231
|
+
const changed = note.targets.some((target, index) => {
|
|
232
|
+
const match = matches[index];
|
|
233
|
+
return (
|
|
234
|
+
match !== undefined &&
|
|
235
|
+
(match.source !== target.source || match.name !== target.name || match.text !== target.text)
|
|
236
|
+
);
|
|
237
|
+
});
|
|
238
|
+
return {
|
|
239
|
+
note: changed
|
|
240
|
+
? {
|
|
241
|
+
...note,
|
|
242
|
+
targets: note.targets.map((target, index) => {
|
|
243
|
+
const match = matches[index];
|
|
244
|
+
return match
|
|
245
|
+
? { ...target, source: match.source, name: match.name, text: match.text }
|
|
246
|
+
: target;
|
|
247
|
+
}),
|
|
248
|
+
}
|
|
249
|
+
: note,
|
|
250
|
+
found: matches.map((match) => match !== undefined),
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** By slide and beat in talk order, then as they were written; past the deck, at the end. */
|
|
255
|
+
function order(deck: ProjectDeck, followed: Followed[]): Followed[] {
|
|
256
|
+
const { sections } = deck.deck;
|
|
257
|
+
const rank = ({ note }: Followed): [number, number] => {
|
|
258
|
+
const slide = sections.findIndex((section) => section.slug === note.slug);
|
|
259
|
+
const stop = slide < 0 ? undefined : resolveStop(sections[slide]?.beats ?? [], note.step);
|
|
260
|
+
return [slide < 0 ? sections.length : slide, stop ?? Number.MAX_SAFE_INTEGER];
|
|
261
|
+
};
|
|
262
|
+
return followed
|
|
263
|
+
.map((entry, index) => ({ entry, index, rank: rank(entry) }))
|
|
264
|
+
.sort((a, b) => a.rank[0] - b.rank[0] || a.rank[1] - b.rank[1] || a.index - b.index)
|
|
265
|
+
.map(({ entry }) => entry);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function toRow(
|
|
269
|
+
deck: ProjectDeck,
|
|
270
|
+
note: Annotation,
|
|
271
|
+
found: boolean[],
|
|
272
|
+
number: number,
|
|
273
|
+
): AnnotationRow {
|
|
274
|
+
const paths = deckPaths(deck.dir);
|
|
275
|
+
const html = paths.slide(note.slug, ".html");
|
|
276
|
+
const targets: TargetRow[] = note.targets.map((target, index) => {
|
|
277
|
+
const [line = 0, column = 0] = target.source.split(":").map(Number);
|
|
278
|
+
const isFound = found[index] === true;
|
|
279
|
+
return {
|
|
280
|
+
path: html,
|
|
281
|
+
line,
|
|
282
|
+
column,
|
|
283
|
+
found: isFound,
|
|
284
|
+
name: target.name,
|
|
285
|
+
was: target.was,
|
|
286
|
+
text: isFound ? target.text : null,
|
|
287
|
+
box: target.box,
|
|
288
|
+
...(target.point ? { point: target.point } : {}),
|
|
289
|
+
};
|
|
290
|
+
});
|
|
291
|
+
const now = slideFileHashes(deck, note.slug);
|
|
292
|
+
const changed = SLIDE_FILES.filter((ext) => now[ext] !== note.files[ext]).map((ext) =>
|
|
293
|
+
paths.slide(note.slug, ext),
|
|
294
|
+
);
|
|
295
|
+
const status: AnnotationStatus = !found.some(Boolean)
|
|
296
|
+
? "gone"
|
|
297
|
+
: changed.length > 0
|
|
298
|
+
? "edited"
|
|
299
|
+
: "open";
|
|
300
|
+
return {
|
|
301
|
+
number,
|
|
302
|
+
id: note.id,
|
|
303
|
+
slug: note.slug,
|
|
304
|
+
step: note.step,
|
|
305
|
+
status,
|
|
306
|
+
text: note.text,
|
|
307
|
+
targets,
|
|
308
|
+
changed,
|
|
309
|
+
shot: `dekc shot ${deck.name} ${note.slug} --step ${note.step}`,
|
|
310
|
+
createdAt: note.createdAt,
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/** A hash of each of the slide's own files there is, by its extension. */
|
|
315
|
+
function slideFileHashes(deck: ProjectDeck, slug: string): Record<string, string> {
|
|
316
|
+
const paths = deckPaths(deck.dir);
|
|
317
|
+
return Object.fromEntries(
|
|
318
|
+
SLIDE_FILES.flatMap((ext) => {
|
|
319
|
+
const text = readDeckFile(deck.dir, paths.slide(slug, ext));
|
|
320
|
+
return text === undefined
|
|
321
|
+
? []
|
|
322
|
+
: [[ext, createHash("sha256").update(text).digest("hex").slice(0, 16)]];
|
|
323
|
+
}),
|
|
324
|
+
);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function readAnnotationsFile(root: string): AnnotationsFile {
|
|
328
|
+
const path = annotationsPath(root);
|
|
329
|
+
const text = readSourceIfExists(path, root);
|
|
330
|
+
if (text === undefined) {
|
|
331
|
+
return { version: 1, decks: {} };
|
|
332
|
+
}
|
|
333
|
+
let value: unknown;
|
|
334
|
+
try {
|
|
335
|
+
value = JSON.parse(text);
|
|
336
|
+
} catch (error) {
|
|
337
|
+
throw unreadable(path, error instanceof Error ? error.message : String(error));
|
|
338
|
+
}
|
|
339
|
+
const parsed = AnnotationsFile.safeParse(value);
|
|
340
|
+
if (!parsed.success) {
|
|
341
|
+
throw unreadable(path, formatZodIssues(parsed.error));
|
|
342
|
+
}
|
|
343
|
+
return parsed.data;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function unreadable(path: string, why: string): DekError {
|
|
347
|
+
return new DekError(`.dek/annotations.json does not read: ${why}`, {
|
|
348
|
+
path,
|
|
349
|
+
hint: "delete .dek/annotations.json to start over; every deck's notes go with it",
|
|
350
|
+
});
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function writeAnnotationsFile(
|
|
354
|
+
root: string,
|
|
355
|
+
deck: string,
|
|
356
|
+
notes: Annotation[],
|
|
357
|
+
file: AnnotationsFile,
|
|
358
|
+
): void {
|
|
359
|
+
const { [deck]: _old, ...others } = file.decks;
|
|
360
|
+
const decks = notes.length > 0 ? { ...others, [deck]: notes } : others;
|
|
361
|
+
writeInside(annotationsPath(root), `${JSON.stringify({ version: 1, decks }, null, 2)}\n`, root);
|
|
362
|
+
}
|
package/src/core/diagnostic.ts
CHANGED
|
@@ -53,6 +53,7 @@ export const RULES = {
|
|
|
53
53
|
DEK031: { severity: "error", scopes: ["slide"] },
|
|
54
54
|
DEK032: { severity: "error", scopes: ["slide"] },
|
|
55
55
|
DEK033: { severity: "error", scopes: ["slide"] },
|
|
56
|
+
DEK034: { severity: "error", scopes: ["slide"] },
|
|
56
57
|
DEK040: { severity: "warning", scopes: ["slide"] },
|
|
57
58
|
DEK041: { severity: "warning", scopes: ["deck"] },
|
|
58
59
|
DEK042: { severity: "warning", scopes: ["slide"] },
|
package/src/core/github.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { DekError } from "./error.ts";
|
|
|
3
3
|
/**
|
|
4
4
|
* The two GitHub calls dekc ref needs: pin a rev to a commit, and download
|
|
5
5
|
* that commit. DEK_GITHUB_API points them elsewhere, the way DEK_VOICE_URL
|
|
6
|
-
* does for the voice engine.
|
|
6
|
+
* does for the voice engine, but a token only ever goes here.
|
|
7
7
|
*/
|
|
8
8
|
const DEFAULT_API = "https://api.github.com";
|
|
9
9
|
|
|
@@ -16,9 +16,6 @@ const COMMIT_TIMEOUT_MS = 30 * 1000;
|
|
|
16
16
|
/** A tarball near the size cap on a slow line still fits well inside this. */
|
|
17
17
|
const TARBALL_TIMEOUT_MS = 10 * 60 * 1000;
|
|
18
18
|
|
|
19
|
-
const SIGN_IN_HINT =
|
|
20
|
-
"if the repository is private, set GITHUB_TOKEN or run `gh auth login`, then run the command again";
|
|
21
|
-
|
|
22
19
|
export type GithubOptions = {
|
|
23
20
|
/** Covers the whole call, from connecting to the last byte of the body. */
|
|
24
21
|
timeoutMs?: number;
|
|
@@ -32,22 +29,27 @@ function apiBase(): string {
|
|
|
32
29
|
return (process.env.DEK_GITHUB_API ?? DEFAULT_API).replace(/\/+$/, "");
|
|
33
30
|
}
|
|
34
31
|
|
|
35
|
-
/**
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
const printed = result.exitCode === 0 ? result.stdout.toString().trim() : "";
|
|
47
|
-
return printed || undefined;
|
|
48
|
-
} catch {
|
|
32
|
+
/**
|
|
33
|
+
* The Authorization header for a call to `base`: the token the user set in GITHUB_TOKEN, else
|
|
34
|
+
* GH_TOKEN, and only for api.github.com. Public repositories need none. dek never asks `gh` for
|
|
35
|
+
* one: that token can write to every repository its owner can, and a tool that takes it unasked
|
|
36
|
+
* reads as credential theft to the machine's security software.
|
|
37
|
+
*/
|
|
38
|
+
export function authorization(
|
|
39
|
+
base: string,
|
|
40
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
41
|
+
): string | undefined {
|
|
42
|
+
if (base !== DEFAULT_API) {
|
|
49
43
|
return undefined;
|
|
50
44
|
}
|
|
45
|
+
const token = env.GITHUB_TOKEN?.trim() || env.GH_TOKEN?.trim();
|
|
46
|
+
return token ? `Bearer ${token}` : undefined;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function signInHint(base: string): string {
|
|
50
|
+
return base === DEFAULT_API
|
|
51
|
+
? "if the repository is private, set GITHUB_TOKEN to a token that can read it (`gh auth token` prints yours), then run the command again"
|
|
52
|
+
: "DEK_GITHUB_API points dek away from api.github.com, and a token goes only there; unset it to read a private repository";
|
|
51
53
|
}
|
|
52
54
|
|
|
53
55
|
/**
|
|
@@ -78,7 +80,7 @@ function deadline(what: string, timeoutMs: number): Deadline {
|
|
|
78
80
|
|
|
79
81
|
function rateLimitHint(retryAfter: string | null): string {
|
|
80
82
|
const wait = retryAfter ? `wait ${retryAfter}s, and ` : "";
|
|
81
|
-
return `${wait}set GITHUB_TOKEN
|
|
83
|
+
return `${wait}set GITHUB_TOKEN to raise the limit, then run the command again`;
|
|
82
84
|
}
|
|
83
85
|
|
|
84
86
|
async function request(
|
|
@@ -87,14 +89,15 @@ async function request(
|
|
|
87
89
|
accept: string,
|
|
88
90
|
{ signal, timeout, expired }: Deadline,
|
|
89
91
|
): Promise<Response> {
|
|
90
|
-
const
|
|
92
|
+
const base = apiBase();
|
|
93
|
+
const auth = authorization(base);
|
|
91
94
|
let response: Response;
|
|
92
95
|
try {
|
|
93
|
-
response = await fetch(`${
|
|
96
|
+
response = await fetch(`${base}${path}`, {
|
|
94
97
|
headers: {
|
|
95
98
|
accept,
|
|
96
99
|
"user-agent": "dek",
|
|
97
|
-
...(auth ? { authorization:
|
|
100
|
+
...(auth ? { authorization: auth } : {}),
|
|
98
101
|
},
|
|
99
102
|
redirect: "follow",
|
|
100
103
|
signal,
|
|
@@ -112,7 +115,7 @@ async function request(
|
|
|
112
115
|
return response;
|
|
113
116
|
}
|
|
114
117
|
if (response.status === 404) {
|
|
115
|
-
throw new DekError(`${what} not found on GitHub`, { hint:
|
|
118
|
+
throw new DekError(`${what} not found on GitHub`, { hint: signInHint(base) });
|
|
116
119
|
}
|
|
117
120
|
const retryAfter = response.headers.get("retry-after");
|
|
118
121
|
if (
|
|
@@ -125,7 +128,7 @@ async function request(
|
|
|
125
128
|
}
|
|
126
129
|
if (response.status === 401 || response.status === 403) {
|
|
127
130
|
throw new DekError(`GitHub refused access to ${what} (${response.status})`, {
|
|
128
|
-
hint:
|
|
131
|
+
hint: signInHint(base),
|
|
129
132
|
});
|
|
130
133
|
}
|
|
131
134
|
throw new DekError(`GitHub answered ${response.status} for ${what}`, {
|