@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
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
2
|
+
import { join, relative, resolve, sep } from "node:path";
|
|
3
|
+
import { deckPaths } from "./deck-paths.ts";
|
|
4
|
+
import { DekError } from "./error.ts";
|
|
5
|
+
import { walkUp } from "./optional.ts";
|
|
6
|
+
import { parseScript } from "./parse.ts";
|
|
7
|
+
import { deckProjectRoot } from "./path.ts";
|
|
8
|
+
import { readSourceIfExists, requireInside } from "./safe-fs.ts";
|
|
9
|
+
import type { Deck, Section } from "./schema.ts";
|
|
10
|
+
import { suggest } from "./suggest.ts";
|
|
11
|
+
|
|
12
|
+
export type ProjectDeck = {
|
|
13
|
+
name: string;
|
|
14
|
+
dir: string;
|
|
15
|
+
scriptPath: string;
|
|
16
|
+
deck: Deck;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export type Project = {
|
|
20
|
+
root: string;
|
|
21
|
+
configPath: string;
|
|
22
|
+
decks: ProjectDeck[];
|
|
23
|
+
failed: Array<{ name: string; dir: string; scriptPath: string; error: DekError }>;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export type ResolvedDeck = {
|
|
27
|
+
project: Project;
|
|
28
|
+
deck: ProjectDeck;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/** The file's text, or nothing when there is no such file. */
|
|
32
|
+
export function readTextIfExists(path: string): string | undefined {
|
|
33
|
+
return existsSync(path) ? readFileSync(path, "utf8") : undefined;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* A file of the deck's, or nothing when there is none. It may be a symlink to elsewhere in the
|
|
38
|
+
* project, but not out of it: what a deck reads, a build publishes.
|
|
39
|
+
*/
|
|
40
|
+
export function readDeckFile(deckDir: string, path: string): string | undefined {
|
|
41
|
+
return readSourceIfExists(path, deckProjectRoot(deckDir));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function resolveProject(startDir: string): Project {
|
|
45
|
+
const { root, configPath } = findRoot(resolve(startDir));
|
|
46
|
+
const { decks, failed } = loadDecks(root);
|
|
47
|
+
return { root, configPath, decks, failed };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function locateDeck(root: string, dir: string): { name: string; dir: string } | undefined {
|
|
51
|
+
const name = deckNameFromDir(root, dir);
|
|
52
|
+
if (!name) {
|
|
53
|
+
return undefined;
|
|
54
|
+
}
|
|
55
|
+
return { name, dir: join(root, "decks", name) };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function resolveDeck(dir: string): ResolvedDeck {
|
|
59
|
+
const { root, configPath } = findRoot(resolve(dir));
|
|
60
|
+
const located = locateDeck(root, dir);
|
|
61
|
+
if (!located) {
|
|
62
|
+
throw new DekError("not a deck directory", {
|
|
63
|
+
path: dir,
|
|
64
|
+
hint: "pass a deck name or run from a deck directory",
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
const loaded = readDeck(root, located.name);
|
|
68
|
+
if (!loaded.ok) {
|
|
69
|
+
throw loaded.error.error;
|
|
70
|
+
}
|
|
71
|
+
return {
|
|
72
|
+
project: { root, configPath, decks: [loaded.deck], failed: [] },
|
|
73
|
+
deck: loaded.deck,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function asResolvedDeck(input: string | ResolvedDeck): ResolvedDeck {
|
|
78
|
+
return typeof input === "string" ? resolveDeck(input) : input;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function requireSection(deck: ProjectDeck, slug: string): Section {
|
|
82
|
+
const section = deck.deck.sections.find((entry) => entry.slug === slug);
|
|
83
|
+
if (!section) {
|
|
84
|
+
const guess = suggest(
|
|
85
|
+
slug,
|
|
86
|
+
deck.deck.sections.map((entry) => entry.slug),
|
|
87
|
+
);
|
|
88
|
+
throw new DekError(`section "${slug}" not found`, {
|
|
89
|
+
path: deck.scriptPath,
|
|
90
|
+
hint: guess ? `did you mean \`${guess}\`? run \`dekc ls\` for every slide` : "run `dekc ls`",
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
return section;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function listSlides(deckDir: string): { slug: string; path: string }[] {
|
|
97
|
+
return listSlideFiles(deckDir, ".html");
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Files a slide may keep next to its HTML, moved and linted with it. */
|
|
101
|
+
export const SLIDE_SIDECARS = [".css", ".ts"] as const;
|
|
102
|
+
|
|
103
|
+
export type SlideSidecar = (typeof SLIDE_SIDECARS)[number];
|
|
104
|
+
|
|
105
|
+
export function listSlideFiles(
|
|
106
|
+
deckDir: string,
|
|
107
|
+
ext: ".html" | ".js" | SlideSidecar,
|
|
108
|
+
): { slug: string; path: string }[] {
|
|
109
|
+
const dir = deckPaths(deckDir).slides;
|
|
110
|
+
const root = deckProjectRoot(deckDir);
|
|
111
|
+
if (!existsSync(dir) || !statSync(requireInside(dir, root)).isDirectory()) {
|
|
112
|
+
return [];
|
|
113
|
+
}
|
|
114
|
+
return (
|
|
115
|
+
readdirSync(dir)
|
|
116
|
+
// A declaration file carries types for the editor, not a slide.
|
|
117
|
+
.filter((name) => name.endsWith(ext) && !name.endsWith(".d.ts"))
|
|
118
|
+
.sort((a, b) => a.localeCompare(b))
|
|
119
|
+
.flatMap((name) => {
|
|
120
|
+
const path = join(dir, name);
|
|
121
|
+
if (!statSync(requireInside(path, root)).isFile()) {
|
|
122
|
+
return [];
|
|
123
|
+
}
|
|
124
|
+
return [{ slug: name.slice(0, -ext.length), path }];
|
|
125
|
+
})
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function findRoot(startDir: string): { root: string; configPath: string } {
|
|
130
|
+
const hit = walkUp(startDir, (dir) => {
|
|
131
|
+
const configPath = join(dir, "dek.toml");
|
|
132
|
+
if (existsSync(configPath) && statSync(configPath).isFile()) {
|
|
133
|
+
return { root: dir, configPath };
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
if (!hit) {
|
|
137
|
+
throw new DekError("not a dek project", {
|
|
138
|
+
path: startDir,
|
|
139
|
+
hint: "run `dekc init` to create one here, or cd into a project",
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
return hit;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function loadDecks(root: string): {
|
|
146
|
+
decks: ProjectDeck[];
|
|
147
|
+
failed: Project["failed"];
|
|
148
|
+
} {
|
|
149
|
+
const decksDir = join(root, "decks");
|
|
150
|
+
if (!existsSync(decksDir)) {
|
|
151
|
+
return { decks: [], failed: [] };
|
|
152
|
+
}
|
|
153
|
+
if (!statSync(decksDir).isDirectory()) {
|
|
154
|
+
throw new DekError("decks is not a directory", { path: decksDir });
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const decks: ProjectDeck[] = [];
|
|
158
|
+
const failed: Project["failed"] = [];
|
|
159
|
+
|
|
160
|
+
for (const entry of readdirSync(decksDir, { withFileTypes: true })
|
|
161
|
+
.filter((item) => item.isDirectory())
|
|
162
|
+
.sort((a, b) => a.name.localeCompare(b.name))) {
|
|
163
|
+
const loaded = readDeck(root, entry.name);
|
|
164
|
+
if (loaded.ok) {
|
|
165
|
+
decks.push(loaded.deck);
|
|
166
|
+
} else {
|
|
167
|
+
failed.push(loaded.error);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return { decks, failed };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function deckNameFromDir(root: string, dir: string): string | undefined {
|
|
175
|
+
const rel = relative(join(root, "decks"), resolve(dir));
|
|
176
|
+
if (!rel || rel === "." || rel === ".." || rel.startsWith(`..${sep}`)) {
|
|
177
|
+
return undefined;
|
|
178
|
+
}
|
|
179
|
+
const name = rel.split(sep)[0];
|
|
180
|
+
return name || undefined;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function readDeck(
|
|
184
|
+
root: string,
|
|
185
|
+
name: string,
|
|
186
|
+
): { ok: true; deck: ProjectDeck } | { ok: false; error: Project["failed"][number] } {
|
|
187
|
+
const dir = join(root, "decks", name);
|
|
188
|
+
const scriptPath = deckPaths(dir).script;
|
|
189
|
+
let source: string | undefined;
|
|
190
|
+
try {
|
|
191
|
+
source = readDeckFile(dir, scriptPath);
|
|
192
|
+
} catch (error) {
|
|
193
|
+
return { ok: false, error: { name, dir, scriptPath, error: error as DekError } };
|
|
194
|
+
}
|
|
195
|
+
if (source === undefined) {
|
|
196
|
+
return {
|
|
197
|
+
ok: false,
|
|
198
|
+
error: {
|
|
199
|
+
name,
|
|
200
|
+
dir,
|
|
201
|
+
scriptPath,
|
|
202
|
+
error: new DekError(`deck "${name}" has no script.md`, { path: scriptPath }),
|
|
203
|
+
},
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
try {
|
|
207
|
+
return {
|
|
208
|
+
ok: true,
|
|
209
|
+
deck: {
|
|
210
|
+
name,
|
|
211
|
+
dir,
|
|
212
|
+
scriptPath,
|
|
213
|
+
deck: parseScript(source, scriptPath),
|
|
214
|
+
},
|
|
215
|
+
};
|
|
216
|
+
} catch (error) {
|
|
217
|
+
return {
|
|
218
|
+
ok: false,
|
|
219
|
+
error: {
|
|
220
|
+
name,
|
|
221
|
+
dir,
|
|
222
|
+
scriptPath,
|
|
223
|
+
error:
|
|
224
|
+
error instanceof DekError ? error : new DekError(String(error), { path: scriptPath }),
|
|
225
|
+
},
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { dirname } from "node:path";
|
|
2
|
+
import { type Diagnostic, errorDiagnostic } from "./diagnostic.ts";
|
|
3
|
+
import { resolveBinFromAncestors } from "./optional.ts";
|
|
4
|
+
import type { SarifLog } from "./sarif.ts";
|
|
5
|
+
import { awaitPiped, workerCommand } from "./spawn.ts";
|
|
6
|
+
|
|
7
|
+
export type RumdlRunner = (scriptPath: string) => Promise<string | null>;
|
|
8
|
+
|
|
9
|
+
export function resolveRumdlBin(): string | undefined {
|
|
10
|
+
if (process.env.DEK_RUMDL) {
|
|
11
|
+
return process.env.DEK_RUMDL;
|
|
12
|
+
}
|
|
13
|
+
// Beside dek's own install, as `bun add -d rumdl` puts it, never the working directory's: a
|
|
14
|
+
// repository someone else wrote can commit a node_modules/.bin/rumdl for `dekc lint` to run.
|
|
15
|
+
// The PATH as it is now: Bun.which alone searches the one the process started with.
|
|
16
|
+
const onPath = Bun.which("rumdl", { PATH: process.env.PATH ?? "" });
|
|
17
|
+
return onPath ?? resolveBinFromAncestors("rumdl", import.meta.dir);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export async function defaultRumdlRunner(scriptPath: string): Promise<string | null> {
|
|
21
|
+
const bin = resolveRumdlBin();
|
|
22
|
+
if (!bin) {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
try {
|
|
27
|
+
const args = ["check", scriptPath, "--output-format", "sarif"];
|
|
28
|
+
const cmd = workerCommand(bin, args);
|
|
29
|
+
const proc = Bun.spawn(cmd, {
|
|
30
|
+
cwd: dirname(scriptPath),
|
|
31
|
+
// The environment as it is now, like the PATH rumdl was found on; left out, Bun passes
|
|
32
|
+
// the one the process started with.
|
|
33
|
+
env: process.env,
|
|
34
|
+
stdout: "pipe",
|
|
35
|
+
stderr: "pipe",
|
|
36
|
+
});
|
|
37
|
+
const { stdout, exitCode } = await awaitPiped(proc);
|
|
38
|
+
if (exitCode === 2) {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
return stdout;
|
|
42
|
+
} catch {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export async function rumdlDiagnostics(
|
|
48
|
+
scriptPath: string,
|
|
49
|
+
run: RumdlRunner = defaultRumdlRunner,
|
|
50
|
+
): Promise<Diagnostic[]> {
|
|
51
|
+
return (await runRumdl(scriptPath, run)).diagnostics;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export async function runRumdl(
|
|
55
|
+
scriptPath: string,
|
|
56
|
+
run: RumdlRunner = defaultRumdlRunner,
|
|
57
|
+
): Promise<{ diagnostics: Diagnostic[]; sarif?: SarifLog; skipped?: boolean }> {
|
|
58
|
+
const text = await run(scriptPath);
|
|
59
|
+
if (text === null) {
|
|
60
|
+
return { diagnostics: [], skipped: true };
|
|
61
|
+
}
|
|
62
|
+
return parseRumdlSarif(text);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function parseRumdlSarif(text: string | null): {
|
|
66
|
+
diagnostics: Diagnostic[];
|
|
67
|
+
sarif?: SarifLog;
|
|
68
|
+
} {
|
|
69
|
+
if (!text?.trim()) {
|
|
70
|
+
return { diagnostics: [] };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
let parsed: unknown;
|
|
74
|
+
try {
|
|
75
|
+
parsed = JSON.parse(text);
|
|
76
|
+
} catch {
|
|
77
|
+
return { diagnostics: [] };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (!parsed || typeof parsed !== "object" || !("runs" in parsed) || !Array.isArray(parsed.runs)) {
|
|
81
|
+
return { diagnostics: [] };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const sarif = parsed as SarifLog;
|
|
85
|
+
const diagnostics: Diagnostic[] = [];
|
|
86
|
+
for (const run of sarif.runs) {
|
|
87
|
+
for (const result of run.results ?? []) {
|
|
88
|
+
const loc = result.locations?.[0]?.physicalLocation;
|
|
89
|
+
diagnostics.push(
|
|
90
|
+
errorDiagnostic(result.ruleId, {
|
|
91
|
+
message: result.message.text,
|
|
92
|
+
...(loc?.artifactLocation.uri ? { path: loc.artifactLocation.uri } : {}),
|
|
93
|
+
...(loc?.region?.startLine !== undefined ? { line: loc.region.startLine } : {}),
|
|
94
|
+
}),
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return { diagnostics, sarif };
|
|
99
|
+
}
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import {
|
|
3
|
+
existsSync,
|
|
4
|
+
lstatSync,
|
|
5
|
+
mkdirSync,
|
|
6
|
+
readdirSync,
|
|
7
|
+
readFileSync,
|
|
8
|
+
realpathSync,
|
|
9
|
+
renameSync,
|
|
10
|
+
rmSync,
|
|
11
|
+
unlinkSync,
|
|
12
|
+
writeFileSync,
|
|
13
|
+
} from "node:fs";
|
|
14
|
+
import { basename, dirname, extname, join, relative, resolve, sep } from "node:path";
|
|
15
|
+
import { DekError } from "./error.ts";
|
|
16
|
+
import { isInside } from "./path.ts";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Where `path` really lives, when dek may follow it there, else undefined. A path with no link
|
|
20
|
+
* in it is taken as written. A link must stay inside `root`, keep its name's extension, and not
|
|
21
|
+
* lead into a hidden file or folder: a cloned repository can commit `theme.css -> ../../.env`
|
|
22
|
+
* or `.dek/schema.json -> ~/.zshrc`, and dek must neither publish the one nor overwrite the
|
|
23
|
+
* other. The part of `path` that does not exist yet is judged as written.
|
|
24
|
+
*/
|
|
25
|
+
export function followInside(path: string, root: string): string | undefined {
|
|
26
|
+
const wanted = resolve(path);
|
|
27
|
+
if (!isInside(wanted, root)) {
|
|
28
|
+
return undefined;
|
|
29
|
+
}
|
|
30
|
+
const realRoot = realpathSync(root);
|
|
31
|
+
const asWritten = join(realRoot, relative(resolve(root), wanted));
|
|
32
|
+
const real = realOf(wanted);
|
|
33
|
+
if (real === asWritten) {
|
|
34
|
+
return real;
|
|
35
|
+
}
|
|
36
|
+
const rel = relative(realRoot, real);
|
|
37
|
+
if (
|
|
38
|
+
!isInside(real, realRoot) ||
|
|
39
|
+
rel.split(sep).some((segment) => segment.startsWith(".")) ||
|
|
40
|
+
extname(real) !== extname(wanted)
|
|
41
|
+
) {
|
|
42
|
+
return undefined;
|
|
43
|
+
}
|
|
44
|
+
return real;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The deepest part of `path` that exists, with its links resolved, then the rest as written. */
|
|
48
|
+
function realOf(path: string): string {
|
|
49
|
+
const rest: string[] = [];
|
|
50
|
+
let head = path;
|
|
51
|
+
for (;;) {
|
|
52
|
+
try {
|
|
53
|
+
return join(realpathSync(head), ...rest);
|
|
54
|
+
} catch {
|
|
55
|
+
const up = dirname(head);
|
|
56
|
+
if (up === head) {
|
|
57
|
+
return path;
|
|
58
|
+
}
|
|
59
|
+
rest.unshift(basename(head));
|
|
60
|
+
head = up;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function refused(path: string, root: string): DekError {
|
|
66
|
+
return new DekError(`${relative(root, path) || path} leads outside the project`, {
|
|
67
|
+
path,
|
|
68
|
+
hint: "dek follows a symlink only to a file of the same kind inside the project and outside hidden folders; copy the file instead",
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Like `followInside`, but a path dek may not follow is an error that says why. */
|
|
73
|
+
export function requireInside(path: string, root: string): string {
|
|
74
|
+
const real = followInside(path, root);
|
|
75
|
+
if (real === undefined) {
|
|
76
|
+
throw refused(path, root);
|
|
77
|
+
}
|
|
78
|
+
return real;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** A source file's text, or nothing when there is no such file; a link out is an error. */
|
|
82
|
+
export function readSourceIfExists(path: string, root: string): string | undefined {
|
|
83
|
+
const real = requireInside(path, root);
|
|
84
|
+
return existsSync(real) ? readFileSync(real, "utf8") : undefined;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** A folder dek writes into, made if missing; a link out of `root` on the way is an error. */
|
|
88
|
+
export function outputDir(path: string, root: string): string {
|
|
89
|
+
const real = requireInside(path, root);
|
|
90
|
+
mkdirSync(real, { recursive: true });
|
|
91
|
+
// Making the folders may have followed a dangling link to a folder; look again.
|
|
92
|
+
if (followInside(path, root) !== real) {
|
|
93
|
+
throw refused(path, root);
|
|
94
|
+
}
|
|
95
|
+
return real;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Where a writer that opens `path` itself, such as Chromium or ffmpeg, may write it: the folders
|
|
100
|
+
* exist, and nothing at the path is a link it would follow. A link that stays inside comes back
|
|
101
|
+
* resolved; a dangling one, which would create its file wherever it points, is removed.
|
|
102
|
+
*/
|
|
103
|
+
export function outputPath(path: string, root: string): string {
|
|
104
|
+
outputDir(dirname(path), root);
|
|
105
|
+
const real = requireInside(path, root);
|
|
106
|
+
try {
|
|
107
|
+
if (lstatSync(real).isSymbolicLink()) {
|
|
108
|
+
unlinkSync(real);
|
|
109
|
+
}
|
|
110
|
+
} catch {
|
|
111
|
+
/* nothing there yet */
|
|
112
|
+
}
|
|
113
|
+
return real;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* True when a cache entry dek wrote is there: a plain file. A link in its place is not dek's and
|
|
118
|
+
* is removed, so neither a build publishes what it points at nor a new entry is written through it.
|
|
119
|
+
*/
|
|
120
|
+
export function isCachedFile(path: string): boolean {
|
|
121
|
+
try {
|
|
122
|
+
const stat = lstatSync(path);
|
|
123
|
+
if (stat.isSymbolicLink()) {
|
|
124
|
+
unlinkSync(path);
|
|
125
|
+
return false;
|
|
126
|
+
}
|
|
127
|
+
return stat.isFile();
|
|
128
|
+
} catch {
|
|
129
|
+
return false;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Write `data` to `path` inside `root`: to a fresh file beside it, then renamed into place, so a
|
|
135
|
+
* reader never sees half a file and a link in the way is replaced rather than written through.
|
|
136
|
+
*/
|
|
137
|
+
export function writeInside(
|
|
138
|
+
path: string,
|
|
139
|
+
data: string | NodeJS.ArrayBufferView,
|
|
140
|
+
root: string,
|
|
141
|
+
options: { mode?: number } = {},
|
|
142
|
+
): void {
|
|
143
|
+
replaceFile(outputPath(path, root), data, options);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Write `data` as a new file at `path`, in a folder already known to be inside the project: a
|
|
148
|
+
* link at `path`, such as one planted at a cache entry's predictable name, is replaced, never
|
|
149
|
+
* written through.
|
|
150
|
+
*/
|
|
151
|
+
export function replaceFile(
|
|
152
|
+
path: string,
|
|
153
|
+
data: string | NodeJS.ArrayBufferView,
|
|
154
|
+
options: { mode?: number } = {},
|
|
155
|
+
): void {
|
|
156
|
+
const temp = join(dirname(path), `.${basename(path)}.${randomUUID().slice(0, 8)}.tmp`);
|
|
157
|
+
writeFileSync(temp, data, {
|
|
158
|
+
flag: "wx",
|
|
159
|
+
...(options.mode !== undefined ? { mode: options.mode } : {}),
|
|
160
|
+
});
|
|
161
|
+
try {
|
|
162
|
+
renameSync(temp, path);
|
|
163
|
+
} catch (error) {
|
|
164
|
+
rmSync(temp, { force: true });
|
|
165
|
+
throw error;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Remove every link directly in `dir`, a folder of dek's own output that a writer fills by name. */
|
|
170
|
+
export function dropLinks(dir: string): void {
|
|
171
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
172
|
+
if (entry.isSymbolicLink()) {
|
|
173
|
+
unlinkSync(join(dir, entry.name));
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Remove `path` inside `root`. A link is removed itself, never what it points at; a path whose
|
|
180
|
+
* folders lead out of `root` is refused, so no link can aim a delete at a file elsewhere.
|
|
181
|
+
*/
|
|
182
|
+
export function removeInside(
|
|
183
|
+
path: string,
|
|
184
|
+
root: string,
|
|
185
|
+
options: { recursive?: boolean } = {},
|
|
186
|
+
): void {
|
|
187
|
+
let isLink = false;
|
|
188
|
+
try {
|
|
189
|
+
isLink = lstatSync(path).isSymbolicLink();
|
|
190
|
+
} catch {
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
const parent = requireInside(dirname(path), root);
|
|
194
|
+
const target = isLink ? join(parent, basename(path)) : requireInside(path, root);
|
|
195
|
+
rmSync(target, { force: true, recursive: options.recursive === true });
|
|
196
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { resolve } from "node:path";
|
|
2
|
+
import { pathToFileURL } from "node:url";
|
|
3
|
+
import { type Diagnostic, type DiagnosticValue, RULES, type SkippedCheck } from "./diagnostic.ts";
|
|
4
|
+
|
|
5
|
+
type SarifLevel = "error" | "warning" | "note" | "none";
|
|
6
|
+
|
|
7
|
+
export type SarifLog = {
|
|
8
|
+
version: "2.1.0";
|
|
9
|
+
$schema: string;
|
|
10
|
+
runs: Array<{
|
|
11
|
+
tool: {
|
|
12
|
+
driver: {
|
|
13
|
+
name: string;
|
|
14
|
+
informationUri?: string;
|
|
15
|
+
rules?: Array<{ id: string; defaultConfiguration: { level: SarifLevel }; helpUri: string }>;
|
|
16
|
+
};
|
|
17
|
+
};
|
|
18
|
+
invocations?: Array<{
|
|
19
|
+
executionSuccessful: boolean;
|
|
20
|
+
toolExecutionNotifications?: Array<{
|
|
21
|
+
level: SarifLevel;
|
|
22
|
+
message: { text: string };
|
|
23
|
+
descriptor: { id: string };
|
|
24
|
+
properties?: { [key: string]: DiagnosticValue };
|
|
25
|
+
}>;
|
|
26
|
+
}>;
|
|
27
|
+
results: Array<{
|
|
28
|
+
ruleId: string;
|
|
29
|
+
level?: SarifLevel;
|
|
30
|
+
message: { text: string };
|
|
31
|
+
locations?: Array<{
|
|
32
|
+
physicalLocation: {
|
|
33
|
+
artifactLocation: { uri: string };
|
|
34
|
+
region?: { startLine: number; startColumn?: number };
|
|
35
|
+
};
|
|
36
|
+
}>;
|
|
37
|
+
properties?: { [key: string]: DiagnosticValue };
|
|
38
|
+
}>;
|
|
39
|
+
}>;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const RULES_URI = "https://hajimism.github.io/dek/reference/lint.html";
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* dek's own diagnostics as one SARIF run. The message stays as written; the hint, slug, and data
|
|
46
|
+
* go in `properties`, the bag SARIF leaves to the tool. A check that did not run is a tool
|
|
47
|
+
* execution notification, so a log without results never reads as a pass for it.
|
|
48
|
+
*/
|
|
49
|
+
export function toSarif(
|
|
50
|
+
diagnostics: Diagnostic[],
|
|
51
|
+
options: { skipped?: SkippedCheck[] } = {},
|
|
52
|
+
): SarifLog {
|
|
53
|
+
const skipped = options.skipped ?? [];
|
|
54
|
+
return {
|
|
55
|
+
version: "2.1.0",
|
|
56
|
+
$schema: "https://json.schemastore.org/sarif-2.1.0.json",
|
|
57
|
+
runs: [
|
|
58
|
+
{
|
|
59
|
+
tool: {
|
|
60
|
+
driver: {
|
|
61
|
+
name: "dek",
|
|
62
|
+
informationUri: "https://hajimism.github.io/dek/",
|
|
63
|
+
rules: Object.entries(RULES).map(([id, rule]) => ({
|
|
64
|
+
id,
|
|
65
|
+
defaultConfiguration: { level: rule.severity },
|
|
66
|
+
helpUri: RULES_URI,
|
|
67
|
+
})),
|
|
68
|
+
},
|
|
69
|
+
},
|
|
70
|
+
...(skipped.length > 0
|
|
71
|
+
? {
|
|
72
|
+
invocations: [
|
|
73
|
+
{
|
|
74
|
+
executionSuccessful: true,
|
|
75
|
+
toolExecutionNotifications: skipped.map((entry) => ({
|
|
76
|
+
level: "warning" as const,
|
|
77
|
+
message: { text: `${entry.check}: skipped (${entry.reason})` },
|
|
78
|
+
descriptor: { id: `skipped/${entry.check}` },
|
|
79
|
+
properties: {
|
|
80
|
+
check: entry.check,
|
|
81
|
+
...(entry.hint === undefined ? {} : { hint: entry.hint }),
|
|
82
|
+
},
|
|
83
|
+
})),
|
|
84
|
+
},
|
|
85
|
+
],
|
|
86
|
+
}
|
|
87
|
+
: {}),
|
|
88
|
+
results: diagnostics.map(sarifResult),
|
|
89
|
+
},
|
|
90
|
+
],
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function sarifResult(diagnostic: Diagnostic): SarifLog["runs"][number]["results"][number] {
|
|
95
|
+
const properties = {
|
|
96
|
+
...(diagnostic.slug === undefined ? {} : { slug: diagnostic.slug }),
|
|
97
|
+
...(diagnostic.hint === undefined ? {} : { hint: diagnostic.hint }),
|
|
98
|
+
...(diagnostic.data === undefined ? {} : { data: diagnostic.data }),
|
|
99
|
+
};
|
|
100
|
+
return {
|
|
101
|
+
ruleId: diagnostic.id,
|
|
102
|
+
level: diagnostic.severity,
|
|
103
|
+
message: { text: diagnostic.message },
|
|
104
|
+
...(diagnostic.path
|
|
105
|
+
? {
|
|
106
|
+
locations: [
|
|
107
|
+
{
|
|
108
|
+
physicalLocation: {
|
|
109
|
+
artifactLocation: { uri: pathToFileURL(resolve(diagnostic.path)).href },
|
|
110
|
+
...(diagnostic.line !== undefined
|
|
111
|
+
? {
|
|
112
|
+
region: {
|
|
113
|
+
startLine: diagnostic.line,
|
|
114
|
+
...(diagnostic.column === undefined
|
|
115
|
+
? {}
|
|
116
|
+
: { startColumn: diagnostic.column }),
|
|
117
|
+
},
|
|
118
|
+
}
|
|
119
|
+
: {}),
|
|
120
|
+
},
|
|
121
|
+
},
|
|
122
|
+
],
|
|
123
|
+
}
|
|
124
|
+
: {}),
|
|
125
|
+
...(Object.keys(properties).length > 0 ? { properties } : {}),
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function mergeSarif(dek: SarifLog, rumdl?: SarifLog): SarifLog {
|
|
130
|
+
return {
|
|
131
|
+
version: "2.1.0",
|
|
132
|
+
$schema: dek.$schema,
|
|
133
|
+
runs: [...dek.runs, ...(rumdl?.runs ?? [])],
|
|
134
|
+
};
|
|
135
|
+
}
|