@hajimism/dek 0.1.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/LICENSE +21 -0
- package/README.ja.md +75 -0
- package/README.md +75 -0
- package/package.json +65 -0
- package/src/cli/address.ts +53 -0
- package/src/cli/build.ts +85 -0
- package/src/cli/check.ts +97 -0
- package/src/cli/commands.ts +851 -0
- package/src/cli/contract.ts +444 -0
- package/src/cli/cues.ts +18 -0
- package/src/cli/files.ts +309 -0
- package/src/cli/flags.ts +295 -0
- package/src/cli/format.ts +156 -0
- package/src/cli/goto.ts +96 -0
- package/src/cli/init.ts +136 -0
- package/src/cli/keep-alive.ts +72 -0
- package/src/cli/lint.ts +84 -0
- package/src/cli/ls.ts +119 -0
- package/src/cli/main.ts +231 -0
- package/src/cli/marks.ts +16 -0
- package/src/cli/mv.ts +37 -0
- package/src/cli/new.ts +64 -0
- package/src/cli/pdf.ts +16 -0
- package/src/cli/qr.ts +466 -0
- package/src/cli/ref.ts +165 -0
- package/src/cli/rehearse.ts +42 -0
- package/src/cli/result.ts +161 -0
- package/src/cli/scope.ts +308 -0
- package/src/cli/serve.ts +125 -0
- package/src/cli/shot.ts +134 -0
- package/src/cli/show.ts +103 -0
- package/src/cli/sync.ts +37 -0
- package/src/cli/text.ts +479 -0
- package/src/cli/theme.ts +56 -0
- package/src/cli/tty.ts +81 -0
- package/src/cli/usage.ts +198 -0
- package/src/cli/video.ts +17 -0
- package/src/cli/voice.ts +113 -0
- package/src/cli.ts +4 -0
- package/src/core/assets.ts +239 -0
- package/src/core/build.ts +77 -0
- package/src/core/bundled-theme.ts +15 -0
- package/src/core/capture-go.ts +72 -0
- package/src/core/chrome.ts +128 -0
- package/src/core/config-keys.ts +82 -0
- package/src/core/config.ts +95 -0
- package/src/core/css-scan.ts +158 -0
- package/src/core/css-transform.ts +140 -0
- package/src/core/css-value.ts +127 -0
- package/src/core/css.ts +545 -0
- package/src/core/cue.ts +187 -0
- package/src/core/deck-paths.ts +34 -0
- package/src/core/diagnostic.ts +114 -0
- package/src/core/document.ts +218 -0
- package/src/core/error.ts +31 -0
- package/src/core/escape.ts +16 -0
- package/src/core/fill.ts +100 -0
- package/src/core/finish-beat.ts +30 -0
- package/src/core/github.ts +234 -0
- package/src/core/html-scan.ts +253 -0
- package/src/core/html.ts +234 -0
- package/src/core/in-page-go.ts +128 -0
- package/src/core/lines.ts +43 -0
- package/src/core/lint/asset-refs.ts +109 -0
- package/src/core/lint/context.ts +116 -0
- package/src/core/lint/files.ts +117 -0
- package/src/core/lint/keys.ts +48 -0
- package/src/core/lint/morph.ts +91 -0
- package/src/core/lint/script.ts +102 -0
- package/src/core/lint/shared-styles.ts +165 -0
- package/src/core/lint/slide-html.ts +466 -0
- package/src/core/lint/slide-script.ts +179 -0
- package/src/core/lint/theme-css.ts +295 -0
- package/src/core/lint/timing.ts +66 -0
- package/src/core/lint/tokens.ts +520 -0
- package/src/core/lint/voice.ts +76 -0
- package/src/core/lint.ts +137 -0
- package/src/core/live-protocol.ts +130 -0
- package/src/core/marks.ts +198 -0
- package/src/core/mv.ts +293 -0
- package/src/core/ogp.ts +99 -0
- package/src/core/optional.ts +24 -0
- package/src/core/overflow.ts +220 -0
- package/src/core/page-pool.ts +53 -0
- package/src/core/page.ts +66 -0
- package/src/core/parse.ts +307 -0
- package/src/core/path.ts +58 -0
- package/src/core/pdf.ts +71 -0
- package/src/core/playwright-module.d.ts +43 -0
- package/src/core/playwright-motion.ts +42 -0
- package/src/core/playwright-sheet.ts +32 -0
- package/src/core/playwright-visual.ts +391 -0
- package/src/core/playwright-worker.ts +5 -0
- package/src/core/playwright.ts +431 -0
- package/src/core/position.ts +146 -0
- package/src/core/presenter-state.ts +43 -0
- package/src/core/presenter.ts +131 -0
- package/src/core/prose.ts +16 -0
- package/src/core/pseudo-text.ts +67 -0
- package/src/core/rail-width.ts +4 -0
- package/src/core/ref-name.ts +110 -0
- package/src/core/ref.ts +238 -0
- package/src/core/resolve.ts +228 -0
- package/src/core/rumdl.ts +99 -0
- package/src/core/safe-fs.ts +196 -0
- package/src/core/sarif.ts +135 -0
- package/src/core/schema.ts +89 -0
- package/src/core/script-lines.ts +53 -0
- package/src/core/sheet.ts +223 -0
- package/src/core/shot/morph.ts +80 -0
- package/src/core/shot/motion.ts +187 -0
- package/src/core/shot/still.ts +216 -0
- package/src/core/shot-cache.ts +140 -0
- package/src/core/size.ts +11 -0
- package/src/core/skeleton-record.ts +51 -0
- package/src/core/skeleton.ts +89 -0
- package/src/core/slide-draw.ts +165 -0
- package/src/core/slide-eval-worker.ts +58 -0
- package/src/core/slide-measure.ts +272 -0
- package/src/core/slide-script-eval.ts +258 -0
- package/src/core/slide-script.ts +252 -0
- package/src/core/spawn.ts +128 -0
- package/src/core/step.ts +94 -0
- package/src/core/still-page.ts +70 -0
- package/src/core/suggest.ts +33 -0
- package/src/core/sync.ts +316 -0
- package/src/core/text-contrast.ts +466 -0
- package/src/core/theme-excerpt.ts +187 -0
- package/src/core/theme-facts.ts +51 -0
- package/src/core/timeline.ts +216 -0
- package/src/core/timing.ts +88 -0
- package/src/core/toml-keys.ts +349 -0
- package/src/core/url-attributes.ts +103 -0
- package/src/core/video-document.ts +15 -0
- package/src/core/visual-cache.ts +100 -0
- package/src/core/visual.ts +558 -0
- package/src/core/voice.ts +321 -0
- package/src/core/zod.ts +79 -0
- package/src/runtime/browser.ts +203 -0
- package/src/runtime/clone.ts +52 -0
- package/src/runtime/deck-control.ts +43 -0
- package/src/runtime/fit.ts +32 -0
- package/src/runtime/go.ts +56 -0
- package/src/runtime/input.ts +128 -0
- package/src/runtime/laser-view.ts +110 -0
- package/src/runtime/laser.ts +145 -0
- package/src/runtime/live-host.ts +33 -0
- package/src/runtime/live-reload.ts +29 -0
- package/src/runtime/live.ts +88 -0
- package/src/runtime/marks-view.ts +74 -0
- package/src/runtime/motion.ts +113 -0
- package/src/runtime/navigator.ts +189 -0
- package/src/runtime/player.ts +28 -0
- package/src/runtime/presenter-view.ts +289 -0
- package/src/runtime/presenter.ts +52 -0
- package/src/runtime/rail-view.ts +229 -0
- package/src/runtime/rail.ts +52 -0
- package/src/runtime/rehearse.ts +264 -0
- package/src/runtime/routes.ts +142 -0
- package/src/runtime/settle.ts +45 -0
- package/src/runtime/slide.d.ts +28 -0
- package/src/runtime/socket.ts +143 -0
- package/src/runtime/stage.ts +259 -0
- package/src/runtime/step.ts +139 -0
- package/src/runtime/window.ts +38 -0
- package/src/server/auth.ts +210 -0
- package/src/server/deck-snapshot.ts +146 -0
- package/src/server/dev.ts +251 -0
- package/src/server/hub.ts +104 -0
- package/src/server/lan.ts +51 -0
- package/src/server/lock.ts +97 -0
- package/src/server/pages.ts +71 -0
- package/src/server/pairing.ts +40 -0
- package/src/server/project-watch.ts +142 -0
- package/src/server/rooms.ts +133 -0
- package/src/server/routes.ts +286 -0
- package/src/server/serial.ts +25 -0
- package/src/server/watch.ts +308 -0
- package/src/theme/default.css +175 -0
- package/src/video/bake.ts +152 -0
- package/src/video/mux.ts +103 -0
- package/src/video/recorder.ts +115 -0
- package/src/video/sidecar.ts +60 -0
- package/src/video/worker.ts +51 -0
- package/src/voice/engine.ts +177 -0
- package/src/voice/synth.ts +127 -0
- package/src/voice/wav.ts +115 -0
package/src/core/lint.ts
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { cssClassNames, cssLayoutNames } from "./css.ts";
|
|
2
|
+
import type { Diagnostic } from "./diagnostic.ts";
|
|
3
|
+
import { type DeckFiles, type LintContext, readDeckFiles } from "./lint/context.ts";
|
|
4
|
+
import { pairingDiagnostics, suggestRename } from "./lint/files.ts";
|
|
5
|
+
import { frontmatterKeyDiagnostics, tomlKeyDiagnostics } from "./lint/keys.ts";
|
|
6
|
+
import { unpairedMorphDiagnostics } from "./lint/morph.ts";
|
|
7
|
+
import { duplicateIdDiagnostics, strayHeadingDiagnostics } from "./lint/script.ts";
|
|
8
|
+
import { sharedStyleDiagnostics } from "./lint/shared-styles.ts";
|
|
9
|
+
import { lintSlideHtml } from "./lint/slide-html.ts";
|
|
10
|
+
import {
|
|
11
|
+
javascriptDiagnostics,
|
|
12
|
+
type LintedScript,
|
|
13
|
+
lintedSlideScripts,
|
|
14
|
+
slideScriptDiagnostics,
|
|
15
|
+
} from "./lint/slide-script.ts";
|
|
16
|
+
import { lintSlideStyle, themeDiagnostics } from "./lint/theme-css.ts";
|
|
17
|
+
import { timingDiagnostics } from "./lint/timing.ts";
|
|
18
|
+
import { voiceDiagnostics } from "./lint/voice.ts";
|
|
19
|
+
import { asResolvedDeck, type Project, type ProjectDeck } from "./resolve.ts";
|
|
20
|
+
import type { Section } from "./schema.ts";
|
|
21
|
+
import { evaluateSlideScripts, slideScriptsProblems } from "./slide-script-eval.ts";
|
|
22
|
+
|
|
23
|
+
export { unreadableScriptDiagnostics } from "./lint/script.ts";
|
|
24
|
+
export { silentCueDiagnostics } from "./lint/voice.ts";
|
|
25
|
+
|
|
26
|
+
export type LintDeckOptions = {
|
|
27
|
+
/** Only this slide's findings: the ones that name it. Deck findings are `dekc lint`'s. */
|
|
28
|
+
slug?: string;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
type DeckInput = string | { project: Project; deck: ProjectDeck };
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The project's own findings (DEK008 on dek.toml): the same for every deck, so a command that
|
|
35
|
+
* covers several decks reports them once, beside each deck's `lintDeck`.
|
|
36
|
+
*/
|
|
37
|
+
export function lintProject(project: Project): Diagnostic[] {
|
|
38
|
+
return tomlKeyDiagnostics(project);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Every deck and slide rule over one deck, or with `slug` only that slide's. Each module under
|
|
43
|
+
* lint/ owns the rules of one kind of file and exports them as functions of the shared context;
|
|
44
|
+
* this puts their findings in order. Slide scripts are evaluated first, in a child process that
|
|
45
|
+
* this waits for; the dev server uses `lintDeckAsync` instead.
|
|
46
|
+
*/
|
|
47
|
+
export function lintDeck(dir: string, options?: LintDeckOptions): Diagnostic[];
|
|
48
|
+
export function lintDeck(
|
|
49
|
+
source: { project: Project; deck: ProjectDeck },
|
|
50
|
+
options?: LintDeckOptions,
|
|
51
|
+
): Diagnostic[];
|
|
52
|
+
export function lintDeck(input: DeckInput, options?: LintDeckOptions): Diagnostic[] {
|
|
53
|
+
const { files, scripted } = readLintInput(input);
|
|
54
|
+
const problems = slideScriptsProblems(scripted.map((entry) => entry.input));
|
|
55
|
+
return runRules({ ...files, scripts: slideScriptDiagnostics(scripted, problems) }, options);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* `lintDeck` that evaluates slide scripts off the event loop. The scripts are read once, before
|
|
60
|
+
* the evaluation, so one saved meanwhile is checked on the next pass instead of in this one.
|
|
61
|
+
*/
|
|
62
|
+
export async function lintDeckAsync(
|
|
63
|
+
input: DeckInput,
|
|
64
|
+
options?: LintDeckOptions,
|
|
65
|
+
): Promise<Diagnostic[]> {
|
|
66
|
+
const { files, scripted } = readLintInput(input);
|
|
67
|
+
const problems = await evaluateSlideScripts(scripted.map((entry) => entry.input));
|
|
68
|
+
return runRules({ ...files, scripts: slideScriptDiagnostics(scripted, problems) }, options);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function readLintInput(input: DeckInput): { files: DeckFiles; scripted: LintedScript[] } {
|
|
72
|
+
const { project, deck } = asResolvedDeck(input);
|
|
73
|
+
const files = readDeckFiles(project, deck);
|
|
74
|
+
return { files, scripted: lintedSlideScripts(files) };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* A finding with a slug is about that slide; one without is about the deck. So one slide's
|
|
79
|
+
* check is the findings that name it, whichever rule made them: no rule filters on its own.
|
|
80
|
+
*/
|
|
81
|
+
function runRules(ctx: LintContext, options: LintDeckOptions | undefined): Diagnostic[] {
|
|
82
|
+
const diagnostics = [
|
|
83
|
+
...frontmatterKeyDiagnostics(ctx),
|
|
84
|
+
...suggestRename(
|
|
85
|
+
[
|
|
86
|
+
...duplicateIdDiagnostics(ctx),
|
|
87
|
+
...pairingDiagnostics(ctx),
|
|
88
|
+
...javascriptDiagnostics(ctx),
|
|
89
|
+
...themeDiagnostics(ctx),
|
|
90
|
+
...[...ctx.sectionsBySlug.values()].flatMap((section) => slideDiagnostics(ctx, section)),
|
|
91
|
+
...unpairedMorphDiagnostics(ctx),
|
|
92
|
+
...sharedStyleDiagnostics(ctx),
|
|
93
|
+
],
|
|
94
|
+
ctx,
|
|
95
|
+
),
|
|
96
|
+
...voiceDiagnostics(ctx),
|
|
97
|
+
...strayHeadingDiagnostics(ctx),
|
|
98
|
+
...timingDiagnostics(ctx),
|
|
99
|
+
];
|
|
100
|
+
const slug = options?.slug;
|
|
101
|
+
return slug === undefined
|
|
102
|
+
? diagnostics
|
|
103
|
+
: diagnostics.filter((diagnostic) => diagnostic.slug === slug);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Everything about one slide that has HTML: its stylesheet, its script, then its markup. */
|
|
107
|
+
function slideDiagnostics(ctx: LintContext, section: Section): Diagnostic[] {
|
|
108
|
+
const slide = ctx.slidesBySlug.get(section.slug);
|
|
109
|
+
const source = ctx.slideSource(section.slug);
|
|
110
|
+
if (!slide || !source) {
|
|
111
|
+
return [];
|
|
112
|
+
}
|
|
113
|
+
const style = ctx.slideStyle(section.slug);
|
|
114
|
+
const sheet = style?.sheet;
|
|
115
|
+
const script = ctx.scripts.get(section.slug);
|
|
116
|
+
return [
|
|
117
|
+
...(style
|
|
118
|
+
? lintSlideStyle(section.slug, style.path, style.sheet, ctx.theme?.tokens ?? [], ctx.deck.dir)
|
|
119
|
+
: []),
|
|
120
|
+
...(script ?? []),
|
|
121
|
+
...lintSlideHtml(section, slide.path, source.scan, {
|
|
122
|
+
deckDir: ctx.deck.dir,
|
|
123
|
+
skeleton: source.skeleton,
|
|
124
|
+
hasScript: script !== undefined,
|
|
125
|
+
classes: ctx.theme && new Set([...ctx.theme.classes, ...(sheet ? cssClassNames(sheet) : [])]),
|
|
126
|
+
// A theme with no layouts at all has nothing to check a data-layout against.
|
|
127
|
+
...(ctx.theme && ctx.theme.layouts.length > 0
|
|
128
|
+
? {
|
|
129
|
+
layouts: new Set([
|
|
130
|
+
...ctx.theme.layouts.map((layout) => layout.name),
|
|
131
|
+
...(sheet ? cssLayoutNames(sheet) : []),
|
|
132
|
+
]),
|
|
133
|
+
}
|
|
134
|
+
: {}),
|
|
135
|
+
}),
|
|
136
|
+
];
|
|
137
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import type { Diagnostic } from "./diagnostic.ts";
|
|
2
|
+
import type { Position } from "./step.ts";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* What the dev server tells an open deck on `/events`. The server sends these and the page
|
|
6
|
+
* reads them, so both sides take the shape from here.
|
|
7
|
+
*/
|
|
8
|
+
export type LiveEvent =
|
|
9
|
+
/** Slides by slug: the stream reaches the audience, so it names no path on disk. */
|
|
10
|
+
| { type: "sync"; created: string[]; updated?: string[]; removed?: string[] }
|
|
11
|
+
| { type: "reload-slide"; slug: string }
|
|
12
|
+
| { type: "reload-theme" }
|
|
13
|
+
| { type: "reload-script"; slugs: string[] }
|
|
14
|
+
| { type: "diagnostics"; diagnostics: Diagnostic[] }
|
|
15
|
+
| { type: "timeline" };
|
|
16
|
+
|
|
17
|
+
export type LiveEventType = LiveEvent["type"];
|
|
18
|
+
|
|
19
|
+
const isStrings = (value: unknown): value is string[] =>
|
|
20
|
+
Array.isArray(value) && value.every((item) => typeof item === "string");
|
|
21
|
+
|
|
22
|
+
const isOptionalStrings = (value: unknown): boolean => value === undefined || isStrings(value);
|
|
23
|
+
|
|
24
|
+
/** Checks each type's own fields; a new type must be placed here to be let through. */
|
|
25
|
+
const FIELDS_OK: Record<LiveEventType, (event: Record<string, unknown>) => boolean> = {
|
|
26
|
+
sync: (event) =>
|
|
27
|
+
isStrings(event.created) &&
|
|
28
|
+
isOptionalStrings(event.updated) &&
|
|
29
|
+
isOptionalStrings(event.removed),
|
|
30
|
+
"reload-slide": (event) => typeof event.slug === "string",
|
|
31
|
+
"reload-theme": () => true,
|
|
32
|
+
"reload-script": (event) => isStrings(event.slugs),
|
|
33
|
+
diagnostics: (event) => Array.isArray(event.diagnostics),
|
|
34
|
+
timeline: () => true,
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/** Whether a message read off the stream is one this build of the page understands. */
|
|
38
|
+
export function isLiveEvent(value: unknown): value is LiveEvent {
|
|
39
|
+
if (typeof value !== "object" || value === null) {
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
const event = value as Record<string, unknown>;
|
|
43
|
+
const type = event.type;
|
|
44
|
+
return typeof type === "string" && Object.hasOwn(FIELDS_OK, type)
|
|
45
|
+
? FIELDS_OK[type as LiveEventType](event)
|
|
46
|
+
: false;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* A position on the wire: what the socket, the server's rooms, and the windows of one deck send
|
|
51
|
+
* each other. Only the two indexes travel, so nothing else rides along to a room.
|
|
52
|
+
*/
|
|
53
|
+
export function encodePosition(pos: Position): string {
|
|
54
|
+
return JSON.stringify({ slideIndex: pos.slideIndex, beatIndex: pos.beatIndex });
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** A position read off the wire, as text or as the object a BroadcastChannel carries. */
|
|
58
|
+
export function decodePosition(raw: unknown): Position | undefined {
|
|
59
|
+
let value = raw;
|
|
60
|
+
if (typeof raw === "string") {
|
|
61
|
+
try {
|
|
62
|
+
value = JSON.parse(raw);
|
|
63
|
+
} catch {
|
|
64
|
+
return undefined;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
if (typeof value !== "object" || value === null) {
|
|
68
|
+
return undefined;
|
|
69
|
+
}
|
|
70
|
+
const { slideIndex, beatIndex } = value as Record<string, unknown>;
|
|
71
|
+
return isIndex(slideIndex) && isIndex(beatIndex) ? { slideIndex, beatIndex } : undefined;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const isIndex = (value: unknown): value is number =>
|
|
75
|
+
typeof value === "number" && Number.isInteger(value) && value >= 0;
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Where the laser points: a slide, and a point on it as a share of its width and height, so every
|
|
79
|
+
* window draws it at the same place on the slide whatever size the window is.
|
|
80
|
+
*/
|
|
81
|
+
export type Pointer = { slideIndex: number; x: number; y: number };
|
|
82
|
+
|
|
83
|
+
/** What the laser says on the wire: where it points, or null once it went away. */
|
|
84
|
+
export type PointerMessage = { pointer: Pointer | null };
|
|
85
|
+
|
|
86
|
+
/** Four decimals: a ten-thousandth of the slide is finer than any screen shows it. */
|
|
87
|
+
const round = (share: number): number => Math.round(share * 10_000) / 10_000;
|
|
88
|
+
|
|
89
|
+
export function encodePointer(pointer: Pointer | null): string {
|
|
90
|
+
return JSON.stringify({
|
|
91
|
+
pointer: pointer && {
|
|
92
|
+
slideIndex: pointer.slideIndex,
|
|
93
|
+
x: round(pointer.x),
|
|
94
|
+
y: round(pointer.y),
|
|
95
|
+
},
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* A laser message read off the wire, as text or as the object a BroadcastChannel carries; nothing
|
|
101
|
+
* for anything else, a position included. Only the three fields travel, within the slide.
|
|
102
|
+
*/
|
|
103
|
+
export function decodePointer(raw: unknown): PointerMessage | undefined {
|
|
104
|
+
const value = typeof raw === "string" ? parseJson(raw) : raw;
|
|
105
|
+
if (typeof value !== "object" || value === null || !Object.hasOwn(value, "pointer")) {
|
|
106
|
+
return undefined;
|
|
107
|
+
}
|
|
108
|
+
const pointer = (value as { pointer: unknown }).pointer;
|
|
109
|
+
if (pointer === null) {
|
|
110
|
+
return { pointer: null };
|
|
111
|
+
}
|
|
112
|
+
if (typeof pointer !== "object") {
|
|
113
|
+
return undefined;
|
|
114
|
+
}
|
|
115
|
+
const { slideIndex, x, y } = pointer as Record<string, unknown>;
|
|
116
|
+
return isIndex(slideIndex) && isShare(x) && isShare(y)
|
|
117
|
+
? { pointer: { slideIndex, x, y } }
|
|
118
|
+
: undefined;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function parseJson(text: string): unknown {
|
|
122
|
+
try {
|
|
123
|
+
return JSON.parse(text);
|
|
124
|
+
} catch {
|
|
125
|
+
return undefined;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const isShare = (value: unknown): value is number =>
|
|
130
|
+
typeof value === "number" && value >= 0 && value <= 1;
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { DekError } from "./error.ts";
|
|
4
|
+
import type { ProjectDeck } from "./resolve.ts";
|
|
5
|
+
import { readSourceIfExists, writeInside } from "./safe-fs.ts";
|
|
6
|
+
import type { Section } from "./schema.ts";
|
|
7
|
+
import type { Position } from "./step.ts";
|
|
8
|
+
import { formatZodIssues } from "./zod.ts";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* A mark the speaker left on a beat while rehearsing: these words want rewriting. It names its
|
|
12
|
+
* beat by the section's slug and the beat's id, or its title when it has none, never by index,
|
|
13
|
+
* so it stays on its beat while beats are added before it. It keeps the words as they were when
|
|
14
|
+
* marked, so a reader can tell a beat already rewritten from one still waiting.
|
|
15
|
+
*/
|
|
16
|
+
const Mark = z.object({
|
|
17
|
+
slug: z.string(),
|
|
18
|
+
/** The beat's title; null for what the slide says as it arrives, before its first `###`. */
|
|
19
|
+
beat: z.string().nullable(),
|
|
20
|
+
id: z.string().optional(),
|
|
21
|
+
was: z.string(),
|
|
22
|
+
markedAt: z.string(),
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
type Mark = z.infer<typeof Mark>;
|
|
26
|
+
|
|
27
|
+
/** Every deck's marks, by deck name, in one file of the project's. */
|
|
28
|
+
const MarksFile = z.object({ version: z.literal(1), decks: z.record(z.string(), z.array(Mark)) });
|
|
29
|
+
|
|
30
|
+
type MarksFile = z.infer<typeof MarksFile>;
|
|
31
|
+
|
|
32
|
+
/** Where the marks are kept: beside the dev server's lock, out of every deck. */
|
|
33
|
+
export function marksPath(root: string): string {
|
|
34
|
+
return join(root, ".dek", "marks.json");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* A mark as the script has it now: where its beat is and what it says, or `gone` when no beat by
|
|
39
|
+
* its name is left. `edited` once the words differ from what was marked.
|
|
40
|
+
*/
|
|
41
|
+
export type MarkRow = {
|
|
42
|
+
slug: string;
|
|
43
|
+
beat: string | null;
|
|
44
|
+
beatIndex: number | null;
|
|
45
|
+
line: number | null;
|
|
46
|
+
path: string;
|
|
47
|
+
status: "open" | "edited" | "gone";
|
|
48
|
+
was: string;
|
|
49
|
+
text: string | null;
|
|
50
|
+
markedAt: string;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
/** Mark the beat at `position`, or unmark it when it is marked; the deck's marks after. */
|
|
54
|
+
export function toggleMark(
|
|
55
|
+
root: string,
|
|
56
|
+
deck: ProjectDeck,
|
|
57
|
+
position: Position,
|
|
58
|
+
now: Date = new Date(),
|
|
59
|
+
): { marked: boolean; positions: Position[] } {
|
|
60
|
+
const beat = beatAt(deck.deck.sections, position);
|
|
61
|
+
if (!beat) {
|
|
62
|
+
throw new DekError(
|
|
63
|
+
`no beat ${position.beatIndex} on slide ${position.slideIndex + 1} of deck "${deck.name}"`,
|
|
64
|
+
{ hint: "reload the presenter view: the script changed since it opened" },
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
const file = readMarksFile(root);
|
|
68
|
+
const marks = file.decks[deck.name] ?? [];
|
|
69
|
+
const at = marks.findIndex((mark) => sameBeat(mark, beat));
|
|
70
|
+
const next =
|
|
71
|
+
at >= 0
|
|
72
|
+
? marks.filter((_, index) => index !== at)
|
|
73
|
+
: [...marks, { ...beat.anchor, was: beat.text, markedAt: now.toISOString() }];
|
|
74
|
+
writeMarksFile(root, deck.name, next, file);
|
|
75
|
+
return {
|
|
76
|
+
marked: at < 0,
|
|
77
|
+
positions: positionsOf(deck, next),
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** The deck's marks in the order they were made, each where the script has it now. */
|
|
82
|
+
export function listMarks(root: string, deck: ProjectDeck): MarkRow[] {
|
|
83
|
+
return (readMarksFile(root).decks[deck.name] ?? []).map((mark) => {
|
|
84
|
+
const found = findBeat(deck.deck.sections, mark);
|
|
85
|
+
const text = found?.text ?? null;
|
|
86
|
+
return {
|
|
87
|
+
slug: mark.slug,
|
|
88
|
+
beat: found ? found.anchor.beat : mark.beat,
|
|
89
|
+
beatIndex: found?.position.beatIndex ?? null,
|
|
90
|
+
line: found?.line ?? null,
|
|
91
|
+
path: deck.scriptPath,
|
|
92
|
+
status: !found ? "gone" : text === mark.was ? "open" : "edited",
|
|
93
|
+
was: mark.was,
|
|
94
|
+
text,
|
|
95
|
+
markedAt: mark.markedAt,
|
|
96
|
+
};
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Where the deck's marked beats are now; a mark whose beat is gone is on no slide. */
|
|
101
|
+
export function markedPositions(root: string, deck: ProjectDeck): Position[] {
|
|
102
|
+
return positionsOf(deck, readMarksFile(root).decks[deck.name] ?? []);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Drop the deck's marks, the other decks' kept; how many there were. */
|
|
106
|
+
export function clearMarks(root: string, deck: ProjectDeck): number {
|
|
107
|
+
const file = readMarksFile(root);
|
|
108
|
+
const count = file.decks[deck.name]?.length ?? 0;
|
|
109
|
+
if (count > 0) {
|
|
110
|
+
writeMarksFile(root, deck.name, [], file);
|
|
111
|
+
}
|
|
112
|
+
return count;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
type FoundBeat = {
|
|
116
|
+
position: Position;
|
|
117
|
+
line: number;
|
|
118
|
+
text: string;
|
|
119
|
+
anchor: Pick<Mark, "slug" | "beat" | "id">;
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
function beatsOf(sections: Section[]): FoundBeat[] {
|
|
123
|
+
return sections.flatMap((section, slideIndex) => [
|
|
124
|
+
{
|
|
125
|
+
position: { slideIndex, beatIndex: 0 },
|
|
126
|
+
line: section.line,
|
|
127
|
+
text: section.body,
|
|
128
|
+
anchor: { slug: section.slug, beat: null },
|
|
129
|
+
},
|
|
130
|
+
...section.beats.map((beat, index) => ({
|
|
131
|
+
position: { slideIndex, beatIndex: index + 1 },
|
|
132
|
+
line: beat.line,
|
|
133
|
+
text: beat.body,
|
|
134
|
+
anchor: { slug: section.slug, beat: beat.title, ...(beat.id ? { id: beat.id } : {}) },
|
|
135
|
+
})),
|
|
136
|
+
]);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function beatAt(sections: Section[], position: Position): FoundBeat | undefined {
|
|
140
|
+
return beatsOf(sections).find(
|
|
141
|
+
(beat) =>
|
|
142
|
+
beat.position.slideIndex === position.slideIndex &&
|
|
143
|
+
beat.position.beatIndex === position.beatIndex,
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function findBeat(sections: Section[], mark: Mark): FoundBeat | undefined {
|
|
148
|
+
return beatsOf(sections).find((beat) => sameBeat(mark, beat));
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** A beat with an id is known by it, so retitling it keeps its mark; any other, by its title. */
|
|
152
|
+
function sameBeat(mark: Pick<Mark, "slug" | "beat" | "id">, beat: FoundBeat): boolean {
|
|
153
|
+
if (mark.slug !== beat.anchor.slug) {
|
|
154
|
+
return false;
|
|
155
|
+
}
|
|
156
|
+
return mark.id !== undefined ? mark.id === beat.anchor.id : mark.beat === beat.anchor.beat;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function positionsOf(deck: ProjectDeck, marks: Mark[]): Position[] {
|
|
160
|
+
return marks
|
|
161
|
+
.flatMap((mark) => {
|
|
162
|
+
const found = findBeat(deck.deck.sections, mark);
|
|
163
|
+
return found ? [found.position] : [];
|
|
164
|
+
})
|
|
165
|
+
.sort((a, b) => a.slideIndex - b.slideIndex || a.beatIndex - b.beatIndex);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function readMarksFile(root: string): MarksFile {
|
|
169
|
+
const path = marksPath(root);
|
|
170
|
+
const text = readSourceIfExists(path, root);
|
|
171
|
+
if (text === undefined) {
|
|
172
|
+
return { version: 1, decks: {} };
|
|
173
|
+
}
|
|
174
|
+
let value: unknown;
|
|
175
|
+
try {
|
|
176
|
+
value = JSON.parse(text);
|
|
177
|
+
} catch (error) {
|
|
178
|
+
throw unreadable(path, error instanceof Error ? error.message : String(error));
|
|
179
|
+
}
|
|
180
|
+
const parsed = MarksFile.safeParse(value);
|
|
181
|
+
if (!parsed.success) {
|
|
182
|
+
throw unreadable(path, formatZodIssues(parsed.error));
|
|
183
|
+
}
|
|
184
|
+
return parsed.data;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function unreadable(path: string, why: string): DekError {
|
|
188
|
+
return new DekError(`.dek/marks.json does not read: ${why}`, {
|
|
189
|
+
path,
|
|
190
|
+
hint: "delete .dek/marks.json to start over; every deck's marks go with it",
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function writeMarksFile(root: string, deck: string, marks: Mark[], file: MarksFile): void {
|
|
195
|
+
const { [deck]: _old, ...others } = file.decks;
|
|
196
|
+
const decks = marks.length > 0 ? { ...others, [deck]: marks } : others;
|
|
197
|
+
writeInside(marksPath(root), `${JSON.stringify({ version: 1, decks }, null, 2)}\n`, root);
|
|
198
|
+
}
|