@hajimism/dek 0.1.0 → 0.2.1

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 ADDED
@@ -0,0 +1,43 @@
1
+ # Changelog
2
+
3
+ Release notes for `@hajimism/dek`. From 0.1.0 on, release-please writes this file from the Conventional Commits on `main`.
4
+
5
+ ## [0.2.1](https://github.com/hajimism/dek/compare/v0.2.0...v0.2.1) (2026-10-06)
6
+
7
+
8
+ ### Features
9
+
10
+ * **dev:** annotate slide elements and copy the notes for an agent ([8565bd5](https://github.com/hajimism/dek/commit/8565bd5a8d385a84c10d4fa78cab1b7460a86389))
11
+
12
+
13
+ ### Documentation
14
+
15
+ * **sample:** show annotate mode on a roadmap in with-agents ([c1d9c4c](https://github.com/hajimism/dek/commit/c1d9c4c6ddd6306748bdd5544b868fbf3d170a6c))
16
+
17
+ ## [0.2.0](https://github.com/hajimism/dek/compare/v0.1.0...v0.2.0) (2026-10-01)
18
+
19
+
20
+ ### ⚠ BREAKING CHANGES
21
+
22
+ * max_classes, cjk_per_minute and latin_per_minute in dek.toml no longer apply to existing decks. Add them to each deck's script.md frontmatter to keep them; lint's DEK008 names each one.
23
+
24
+ ### Features
25
+
26
+ * let a deck own its class budget and speaking rate ([8dc9a07](https://github.com/hajimism/dek/commit/8dc9a07efb32b803e685a353ee628cae21a8bc56))
27
+
28
+
29
+ ### Bug Fixes
30
+
31
+ * **lint:** count vbscript: and script-running data: URLs in DEK011 ([1b825b1](https://github.com/hajimism/dek/commit/1b825b1bffc33809ef0bdf3a8e5a1d45794a41a7))
32
+
33
+ ## 0.1.0 (2026-10-01)
34
+
35
+ The first release on npm.
36
+
37
+ ### Features
38
+
39
+ * Published as `@hajimism/dek`. Create a project with `bunx @hajimism/dek init`, then install it with `bun add -d @hajimism/dek`.
40
+
41
+ ### ⚠ BREAKING CHANGES
42
+
43
+ * The command is `dekc`, not `dek`. `dek` on npm is an unrelated package, and `bunx dek` ran it anywhere dek was not installed. Run `bunx dekc` inside a project; the package, `dek.toml`, `.dek/`, and the `DEK` lint codes keep their names.
package/README.ja.md CHANGED
@@ -1,5 +1,7 @@
1
1
  # dek
2
2
 
3
+ [![npm](https://img.shields.io/npm/v/@hajimism/dek)](https://www.npmjs.com/package/@hajimism/dek) [![CI](https://github.com/hajimism/dek/actions/workflows/ci.yml/badge.svg)](https://github.com/hajimism/dek/actions/workflows/ci.yml) [![OpenSSF Scorecard](https://api.scorecard.dev/projects/github.com/hajimism/dek/badge)](https://scorecard.dev/viewer/?uri=github.com/hajimism/dek)
4
+
3
5
  **発表のビルドシステム。** 喋ることを書けば、あとは dek が組み立て、測り、届ける。
4
6
 
5
7
  [ドキュメント](https://hajimism.github.io/dek/ja/) · [English README](./README.md)
@@ -20,7 +22,7 @@ dek は、ビルドシステムがコードを扱うように発表を扱う CLI
20
22
 
21
23
  ## クイックスタート
22
24
 
23
- [Bun](https://bun.sh) 1.4 以上。
25
+ [Bun](https://bun.sh) 1.4 以上が必要です。dek は Bun でだけ動き、Node では動きません。`npx` ではなく `bunx` を使ってください。
24
26
 
25
27
  ```bash
26
28
  bunx @hajimism/dek init my-talks --deck 2026-04-vite
@@ -66,10 +68,12 @@ bun run check # Biome の format + lint
66
68
  bun run docs:dev # VitePress
67
69
  ```
68
70
 
69
- CI では `biome ci`、`typecheck`、全テスト(ブラウザを使うものは Chromium で)、`docs:build` を走らせます。
71
+ CI では `biome ci`、`typecheck`、`knip`、全テスト(ブラウザを使うものは Chromium で)、`docs:build`、`scripts/check-package.sh`(npm に出す tarball を入れて動かす)を走らせます。
70
72
 
71
73
  ドキュメントサイトは GitHub Actions 経由で GitHub Pages に出します。初回だけ、リポジトリの設定で **Pages → Source** を **GitHub Actions** にしてください。
72
74
 
75
+ プルリクエストで通すチェックは [CONTRIBUTING.md](./CONTRIBUTING.md)、リリースごとの変更は [CHANGELOG.md](./CHANGELOG.md) にあります。脆弱性は [SECURITY.md](./SECURITY.md) のとおり非公開で報告してください。
76
+
73
77
  ## ライセンス
74
78
 
75
79
  MIT
package/README.md CHANGED
@@ -1,5 +1,7 @@
1
1
  # dek
2
2
 
3
+ [![npm](https://img.shields.io/npm/v/@hajimism/dek)](https://www.npmjs.com/package/@hajimism/dek) [![CI](https://github.com/hajimism/dek/actions/workflows/ci.yml/badge.svg)](https://github.com/hajimism/dek/actions/workflows/ci.yml) [![OpenSSF Scorecard](https://api.scorecard.dev/projects/github.com/hajimism/dek/badge)](https://scorecard.dev/viewer/?uri=github.com/hajimism/dek)
4
+
3
5
  **A build system for talks.** Write what you will say; dek builds, measures, and ships the rest.
4
6
 
5
7
  [Documentation](https://hajimism.github.io/dek/) · [日本語 README](./README.ja.md)
@@ -20,7 +22,7 @@ The three principles and the comparison with Slidev are in [Why dek](https://haj
20
22
 
21
23
  ## Quick start
22
24
 
23
- [Bun](https://bun.sh) 1.4 or later.
25
+ Requires [Bun](https://bun.sh) 1.4 or later. dek runs on Bun only: it does not run on Node, so use `bunx`, not `npx`.
24
26
 
25
27
  ```bash
26
28
  bunx @hajimism/dek init my-talks --deck 2026-04-vite
@@ -66,10 +68,12 @@ bun run check # Biome format + lint
66
68
  bun run docs:dev # VitePress
67
69
  ```
68
70
 
69
- CI runs `biome ci`, `typecheck`, the full test suite (the browser tests in Chromium), and `docs:build`.
71
+ CI runs `biome ci`, `typecheck`, `knip`, the full test suite (the browser tests in Chromium), `docs:build`, and `scripts/check-package.sh`, which installs the packed tarball and runs it.
70
72
 
71
73
  The documentation site deploys to GitHub Pages through Actions. Once, in the repository settings, set **Pages → Source** to **GitHub Actions**.
72
74
 
75
+ See [CONTRIBUTING.md](./CONTRIBUTING.md) for the checks a pull request runs, and [CHANGELOG.md](./CHANGELOG.md) for what each release changed. Report security issues privately, as [SECURITY.md](./SECURITY.md) describes.
76
+
73
77
  ## License
74
78
 
75
79
  MIT
package/package.json CHANGED
@@ -1,8 +1,9 @@
1
1
  {
2
2
  "name": "@hajimism/dek",
3
- "version": "0.1.0",
3
+ "version": "0.2.1",
4
4
  "description": "A build system for talks.",
5
5
  "license": "MIT",
6
+ "author": "Hajime Nakagawa (https://github.com/hajimism)",
6
7
  "type": "module",
7
8
  "bin": {
8
9
  "dekc": "src/cli.ts"
@@ -11,7 +12,8 @@
11
12
  "src",
12
13
  "LICENSE",
13
14
  "README.md",
14
- "README.ja.md"
15
+ "README.ja.md",
16
+ "CHANGELOG.md"
15
17
  ],
16
18
  "repository": {
17
19
  "type": "git",
@@ -31,9 +33,12 @@
31
33
  "slides",
32
34
  "presentation",
33
35
  "talk",
36
+ "markdown",
34
37
  "build",
35
38
  "cli",
36
- "html"
39
+ "html",
40
+ "bun",
41
+ "ai-agents"
37
42
  ],
38
43
  "scripts": {
39
44
  "test": "bun test --parallel=4",
@@ -61,5 +66,8 @@
61
66
  "playwright": "^1.63.0",
62
67
  "typescript": "^7.0.2",
63
68
  "vitepress": "^1.6.4"
69
+ },
70
+ "overrides": {
71
+ "vite": "^6.4.3"
64
72
  }
65
73
  }
package/src/cli/files.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
2
2
  import { dirname, join, relative } from "node:path";
3
3
  import { bundledTheme } from "../core/bundled-theme.ts";
4
+ import type { DekConfig } from "../core/config.ts";
4
5
  import { deckPaths } from "../core/deck-paths.ts";
5
6
  import { DekError } from "../core/error.ts";
6
7
  import { walkUp } from "../core/optional.ts";
@@ -72,12 +73,17 @@ const INSTALL_DEK = "bun add -d @hajimism/dek";
72
73
  * shows sections, beats, and an estimate against the budget. A deck with a
73
74
  * voice starts in Japanese, the language dek's voice reads; any other in English.
74
75
  */
75
- export function defaultScript(title: string, lang: "en" | "ja" = "en"): string {
76
+ export function defaultScript(
77
+ title: string,
78
+ lang: "en" | "ja" = "en",
79
+ settings: DekConfig["seed"] = {},
80
+ ): string {
81
+ const lines = Object.entries(settings).map(([key, value]) => `${key}: ${value}\n`);
76
82
  return `---
77
83
  # yaml-language-server: $schema=../../.dek/schema.json
78
84
  title: ${JSON.stringify(title)}
79
85
  duration: 1m
80
- ---
86
+ ${lines.join("")}---
81
87
  ${STARTER_BODY[lang]}`;
82
88
  }
83
89
 
@@ -230,18 +236,19 @@ function placeable(path: string, kind: "file" | "directory"): boolean {
230
236
  }
231
237
 
232
238
  /**
233
- * A deck's files: its directories, the starter script, the theme it starts
234
- * from, and voice settings when the project has a voice. The skeleton slides
235
- * are sync's to write, once the theme they are drawn in is in place.
239
+ * A new deck's files: its directories, the starter script, the theme it starts from, and what the
240
+ * project's dek.toml seeds, its deck settings and `[voice]`. From then on the deck owns them: a
241
+ * later change to dek.toml leaves it alone. The skeleton slides are sync's to write, once the
242
+ * theme they are drawn in is in place.
236
243
  */
237
244
  export function deckPlan(
238
245
  root: string,
239
246
  name: string,
240
247
  theme: string,
241
- voice?: { engine: string; speaker: string; speed?: number },
248
+ { seed, voice }: Pick<DekConfig, "seed" | "voice"> = {},
242
249
  ): PlannedPath[] {
243
250
  const paths = deckPaths(join(root, "decks", name));
244
- const script = defaultScript(name, voice ? "ja" : "en");
251
+ const script = defaultScript(name, voice ? "ja" : "en", seed);
245
252
  const plan: PlannedPath[] = [
246
253
  { path: paths.slides },
247
254
  { path: paths.assets },
package/src/cli/ls.ts CHANGED
@@ -1,4 +1,3 @@
1
- import { type DekConfig, loadConfig } from "../core/config.ts";
2
1
  import type { Diagnostic } from "../core/diagnostic.ts";
3
2
  import { lintDeck, lintProject } from "../core/lint.ts";
4
3
  import { listSlides, type Project, type ProjectDeck } from "../core/resolve.ts";
@@ -54,7 +53,7 @@ export function lsCommand(target: DecksTarget): LsListResult | LsDeckResult {
54
53
  const { project, deck, ref } = target;
55
54
  if (deck && ref) {
56
55
  return {
57
- ...formatDeck(deck, loadConfig(project.configPath), project, { lint: false }),
56
+ ...formatDeck(deck, project, { lint: false }),
58
57
  ...skippedChecks([
59
58
  { check: "lint", reason: "a ref is read-only; its problems are not yours to fix" },
60
59
  ]),
@@ -62,7 +61,7 @@ export function lsCommand(target: DecksTarget): LsListResult | LsDeckResult {
62
61
  };
63
62
  }
64
63
  if (deck) {
65
- return formatDeck(deck, loadConfig(project.configPath), project);
64
+ return formatDeck(deck, project);
66
65
  }
67
66
  return {
68
67
  kind: "list",
@@ -85,11 +84,10 @@ function summarizeDeck(deck: ProjectDeck, project: Project): LsListResult["decks
85
84
 
86
85
  function formatDeck(
87
86
  deck: ProjectDeck,
88
- config: DekConfig,
89
87
  project: Project,
90
88
  options: { lint: boolean } = { lint: true },
91
89
  ): LsDeckResult {
92
- const timing = sectionTiming(deck.deck.sections, deck.deck.duration, config);
90
+ const timing = sectionTiming(deck.deck);
93
91
  const estimateTotal = timing.reduce((sum, row) => sum + row.estimateSeconds, 0);
94
92
  const timeline = tryLoadCachedTimeline(deck.dir);
95
93
 
package/src/cli/new.ts CHANGED
@@ -48,8 +48,9 @@ export function newCommand(options: { cwd: string; name: string; themeFrom?: str
48
48
  }
49
49
 
50
50
  const theme = readFileSync(themeSource, "utf8");
51
- const voice = loadConfig(project.configPath).voice;
52
- const { created } = applyPlan(deckPlan(project.root, name, theme, voice));
51
+ const { created } = applyPlan(
52
+ deckPlan(project.root, name, theme, loadConfig(project.configPath)),
53
+ );
53
54
  // As sync would. AGENTS.md and .dek/ follow the project theme and dek, so they change here
54
55
  // only when either moved on since the last command that wrote them: say which.
55
56
  const synced = syncDeck(dir);
package/src/cli/serve.ts CHANGED
@@ -121,5 +121,5 @@ export function devBanner(
121
121
  remoteUrls: string[],
122
122
  options: Parameters<typeof remoteBanner>[2],
123
123
  ): string {
124
- return `${remoteBanner(url, remoteUrls, options)}\n\np presenter view · s slide rail · Ctrl-C stops the server`;
124
+ return `${remoteBanner(url, remoteUrls, options)}\n\np presenter view · s slide rail · a annotate · Ctrl-C stops the server`;
125
125
  }
package/src/core/build.ts CHANGED
@@ -49,7 +49,6 @@ export async function buildDeck(
49
49
  removeInside(imagePath, project.root);
50
50
  }
51
51
  const html = renderDeckDocument(deck, {
52
- config,
53
52
  playerScript: options.playerScript,
54
53
  target: {
55
54
  kind: "build",
@@ -60,10 +60,12 @@ ${indent(printPageCss({ width, height }))}
60
60
  @media print { #dek-presenter, #dek-progress { display: none !important; } }
61
61
  body[data-mode="video"] #dek-presenter, body[data-mode="video"] #dek-progress { display: none !important; }
62
62
  #dek-presenter[hidden], #dek-progress[hidden], #dek-next-end[hidden] { display: none; }
63
- #dek-mark-toggle { flex: none; width: 2.25rem; height: 2.25rem; padding: 0; border: 1px solid rgba(255,255,255,0.2); border-radius: 50%; background: transparent; color: rgba(255,255,255,0.45); cursor: pointer; font: 600 1rem/1 system-ui, sans-serif; }
63
+ #dek-mark-toggle, #dek-annotate-toggle { flex: none; width: 2.25rem; height: 2.25rem; padding: 0; border: 1px solid rgba(255,255,255,0.2); border-radius: 50%; background: transparent; color: rgba(255,255,255,0.45); cursor: pointer; font: 600 1rem/1 system-ui, sans-serif; }
64
64
  #dek-mark-toggle::before { content: "✎"; }
65
65
  #dek-mark-toggle[aria-pressed="true"] { border-color: #eab308; color: #eab308; }
66
66
  #dek-mark-toggle[data-error] { border-color: #ef4444; }
67
+ #dek-annotate-toggle::before { content: "⌖"; }
68
+ #dek-annotate-toggle[aria-pressed="true"] { border-color: #3b82f6; color: #3b82f6; }
67
69
  #dek-beats li.is-marked::after { content: " ✎"; color: #eab308; }
68
70
  #dek-laser-toggle { flex: none; width: 2.25rem; height: 2.25rem; padding: 0; border: 1px solid rgba(255,255,255,0.2); border-radius: 50%; background: transparent; cursor: pointer; }
69
71
  #dek-laser-toggle::before { content: ""; display: block; width: 10px; height: 10px; margin: auto; border-radius: 50%; background: rgba(255,255,255,0.45); }
@@ -2,6 +2,7 @@ import { existsSync, readFileSync } from "node:fs";
2
2
  import { z } from "zod";
3
3
  import { parsePublicUrl } from "./ogp.ts";
4
4
  import { isPinnedRev, isPlainRefName } from "./ref-name.ts";
5
+ import { DeckSettings } from "./schema.ts";
5
6
  import { parseTomlWith } from "./zod.ts";
6
7
 
7
8
  export const Voice = z.object({
@@ -18,9 +19,7 @@ export const DekToml = z.object({
18
19
  "write the absolute http(s) URL dist/ is served from, like https://example.com/talks/",
19
20
  })
20
21
  .optional(),
21
- max_classes: z.number().optional(),
22
- cjk_per_minute: z.number().optional(),
23
- latin_per_minute: z.number().optional(),
22
+ ...DeckSettings.partial().shape,
24
23
  voice: Voice.optional(),
25
24
  refs: z
26
25
  .record(z.string(), z.string())
@@ -49,20 +48,16 @@ type VoiceDefaults = {
49
48
  export type DekConfig = {
50
49
  /** Where dist/ is served from, ending in a slash; link previews need it for og:image. */
51
50
  url?: string;
52
- maxClasses: number;
53
- cjkPerMinute: number;
54
- latinPerMinute: number;
51
+ /**
52
+ * The deck settings `dekc new` writes into a new deck's frontmatter. Nothing else reads them: a
53
+ * deck is judged by its own, so changing dek.toml leaves the decks already made as they were.
54
+ */
55
+ seed?: Partial<DeckSettings>;
55
56
  voice?: VoiceDefaults;
56
57
  /** `[refs]`: each ref name and the commit it is pinned to. */
57
58
  refs?: Record<string, string>;
58
59
  };
59
60
 
60
- export const DEFAULT_CONFIG: DekConfig = {
61
- maxClasses: 40,
62
- cjkPerMinute: 300,
63
- latinPerMinute: 130,
64
- };
65
-
66
61
  export function parseDekToml(source: string, path?: string): DekConfig {
67
62
  const data = parseTomlWith(DekToml, source, {
68
63
  label: "dek.toml",
@@ -77,11 +72,10 @@ export function parseDekToml(source: string, path?: string): DekConfig {
77
72
  }
78
73
  : undefined;
79
74
  const url = data.url === undefined ? undefined : parsePublicUrl(data.url);
75
+ const seed = DeckSettings.partial().parse(data);
80
76
  return {
81
77
  ...(url ? { url } : {}),
82
- maxClasses: data.max_classes ?? DEFAULT_CONFIG.maxClasses,
83
- cjkPerMinute: data.cjk_per_minute ?? DEFAULT_CONFIG.cjkPerMinute,
84
- latinPerMinute: data.latin_per_minute ?? DEFAULT_CONFIG.latinPerMinute,
78
+ ...(Object.keys(seed).length > 0 ? { seed } : {}),
85
79
  ...(voice ? { voice } : {}),
86
80
  ...(data.refs && Object.keys(data.refs).length > 0 ? { refs: data.refs } : {}),
87
81
  };
@@ -89,7 +83,7 @@ export function parseDekToml(source: string, path?: string): DekConfig {
89
83
 
90
84
  export function loadConfig(configPath: string): DekConfig {
91
85
  if (!existsSync(configPath)) {
92
- return { ...DEFAULT_CONFIG };
86
+ return {};
93
87
  }
94
88
  return parseDekToml(readFileSync(configPath, "utf8"), configPath);
95
89
  }
@@ -1,6 +1,5 @@
1
1
  import { readTheme } from "./assets.ts";
2
2
  import { playerChromeCss } from "./chrome.ts";
3
- import { type DekConfig, loadConfig } from "./config.ts";
4
3
  import { escapeAttr, escapeHtml } from "./escape.ts";
5
4
  import { collectSlidesHtml, htmlShell } from "./html.ts";
6
5
  import { type OgpImage, ogpHead } from "./ogp.ts";
@@ -25,6 +24,8 @@ export type PageTarget =
25
24
  mode: "player" | "presenter";
26
25
  includeNotes: boolean;
27
26
  liveReloadScript: string;
27
+ /** Annotate mode, run after the player on a page that is the speaker's: one with the notes. */
28
+ annotateScript: string;
28
29
  /**
29
30
  * What a presenter page sends as `?token=` on its live channels at `/decks/<name>/events`
30
31
  * and `/decks/<name>/ws`: neither an EventSource nor a WebSocket can set a header of its
@@ -50,13 +51,12 @@ export function renderDeckHtml(
50
51
  dir: string,
51
52
  options: { playerScript: string; target: PageTarget },
52
53
  ): string {
53
- const { project, deck } = resolveDeck(dir);
54
- return renderDeckDocument(deck, { ...options, config: loadConfig(project.configPath) });
54
+ return renderDeckDocument(resolveDeck(dir).deck, options);
55
55
  }
56
56
 
57
57
  export function renderDeckDocument(
58
58
  deck: ProjectDeck,
59
- options: { config: DekConfig; playerScript: string; target: PageTarget },
59
+ options: { playerScript: string; target: PageTarget },
60
60
  ): string {
61
61
  const { target } = options;
62
62
  const mode: PageMode =
@@ -69,10 +69,10 @@ export function renderDeckDocument(
69
69
  const standalone = !live;
70
70
  const strictScripts = target.kind === "video";
71
71
  const includeNotes = target.kind === "dev" ? target.includeNotes : target.kind === "build";
72
- const data = presenterSlides(deck, options.config, scriptReader(target)).map((slide) =>
72
+ const data = presenterSlides(deck, scriptReader(target)).map((slide) =>
73
73
  includeNotes ? slide : { ...slide, script: "" },
74
74
  );
75
- const slidesHtml = collectSlidesHtml(deck, { inline: standalone });
75
+ const slidesHtml = collectSlidesHtml(deck, { inline: standalone, sources: live });
76
76
  const themeCss = readTheme(deck.dir, standalone);
77
77
  const slideScripts = readSlideScripts(deck.dir, { strict: strictScripts });
78
78
  const state = data[0] ? presenterState(data, { slideIndex: 0, beatIndex: 0 }) : undefined;
@@ -107,6 +107,7 @@ export function renderDeckDocument(
107
107
  <p id="${PAGE_ID.elapsed}">0:00</p>
108
108
  <p id="${PAGE_ID.budget}">${budget}</p>
109
109
  ${target.kind === "dev" ? markButton(deck.deck.lang) : ""}
110
+ ${target.kind === "dev" ? annotateButton(deck.deck.lang) : ""}
110
111
  <button id="${PAGE_ID.laserToggle}" type="button" aria-pressed="false" aria-label="${escapeAttr(laserLabel(deck.deck.lang))}" title="${escapeAttr(laserLabel(deck.deck.lang))}"></button>
111
112
  </footer>
112
113
  </aside>`
@@ -159,7 +160,8 @@ export function renderDeckDocument(
159
160
  <script type="application/json" id="${PAGE_ID.data}">${jsonForScript(data)}</script>
160
161
  ${slideScriptTags(slideScripts)}
161
162
  <script>${options.playerScript}</script>
162
- ${target.kind === "dev" ? `<script>${target.liveReloadScript}</script>` : ""}`,
163
+ ${target.kind === "dev" ? `<script>${target.liveReloadScript}</script>` : ""}
164
+ ${target.kind === "dev" && includeNotes ? `<script>${target.annotateScript}</script>` : ""}`,
163
165
  });
164
166
  }
165
167
 
@@ -199,6 +201,17 @@ function markButton(lang: string): string {
199
201
  return `<button id="${PAGE_ID.markToggle}" type="button" aria-pressed="false" aria-label="${escapeAttr(label)}" title="${escapeAttr(label)}"></button>`;
200
202
  }
201
203
 
204
+ /**
205
+ * The button that turns annotate mode on and off. Only the dev server's page has it: the notes
206
+ * name files and lines of a project on this machine, and a built file is someone else's to read.
207
+ */
208
+ function annotateButton(lang: string): string {
209
+ const label = lang.toLowerCase().startsWith("ja")
210
+ ? "スライドの要素に注釈を付ける (a)"
211
+ : "Annotate slide elements (a)";
212
+ return `<button id="${PAGE_ID.annotateToggle}" type="button" aria-pressed="false" aria-label="${escapeAttr(label)}" title="${escapeAttr(label)}"></button>`;
213
+ }
214
+
202
215
  /** The laser button's name, with the key that does the same. */
203
216
  function laserLabel(lang: string): string {
204
217
  return lang.toLowerCase().startsWith("ja") ? "レーザーポインター (l)" : "Laser pointer (l)";
package/src/core/html.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { assetInliner } from "./assets.ts";
2
2
  import { deckPaths } from "./deck-paths.ts";
3
3
  import { escapeAttr, escapeHtml } from "./escape.ts";
4
+ import { type LineColumn, lineLocator } from "./lines.ts";
4
5
  import { isInside } from "./path.ts";
5
6
  import { type ProjectDeck, readDeckFile } from "./resolve.ts";
6
7
  import { FALLBACK_LANG, type Section } from "./schema.ts";
@@ -41,6 +42,11 @@ export function hasSlideClass(className: string | null | undefined): boolean {
41
42
  * written. A slide that never closes runs to `</body>`, or to the end.
42
43
  */
43
44
  export function extractSlideSection(html: string): string | undefined {
45
+ return locateSlideSection(html)?.section;
46
+ }
47
+
48
+ /** The first slide section, as `extractSlideSection` reads it, and the offset it starts at. */
49
+ function locateSlideSection(html: string): { section: string; start: number } | undefined {
44
50
  const mark = uniqueMark(html);
45
51
  let found = false;
46
52
  let closed = false;
@@ -68,10 +74,13 @@ export function extractSlideSection(html: string): string | undefined {
68
74
  : undefined;
69
75
  // The parser closes an unclosed slide at the end of its parent, past `</body>`.
70
76
  if (slide !== undefined && /<\/section\s*>$/i.test(slide)) {
71
- return slide;
77
+ return { section: slide, start };
72
78
  }
73
79
  const bodyClose = html.slice(start).search(/<\/body>/i);
74
- return bodyClose >= 0 ? html.slice(start, start + bodyClose) : html.slice(start);
80
+ return {
81
+ section: bodyClose >= 0 ? html.slice(start, start + bodyClose) : html.slice(start),
82
+ start,
83
+ };
75
84
  }
76
85
 
77
86
  /** A deck's slide markup, each file read at most once. */
@@ -86,32 +95,43 @@ export type DeckSlides = {
86
95
  * stops on them, because at the venue a deck that shows beats one that does not.
87
96
  */
88
97
  section(slug: string): string;
98
+ /** Where the written section is in `slides/<slug>.html`; none for a skeleton. */
99
+ source(slug: string): SlideSource | undefined;
89
100
  };
90
101
 
102
+ /** A slide file and the offset its section starts at: the section is `file` from `start`. */
103
+ type SlideSource = { file: string; start: number };
104
+
91
105
  export function deckSlides(deck: ProjectDeck): DeckSlides {
92
106
  const paths = deckPaths(deck.dir);
93
- const read = new Map<string, string | undefined>();
107
+ const read = new Map<string, (SlideSource & { section: string }) | undefined>();
94
108
  let layouts: SkeletonLayouts | undefined;
95
109
  const skeleton = (slug: string): string | undefined => {
96
110
  layouts ??= deckLayouts(deck.dir);
97
111
  return skeletonHtml(deck.deck, slug, layouts);
98
112
  };
99
- const written = (slug: string): string | undefined => {
113
+ const located = (slug: string): (SlideSource & { section: string }) | undefined => {
100
114
  if (!read.has(slug)) {
101
115
  const path = paths.slide(slug, ".html");
102
116
  // A slug from a URL may try to climb out of slides/.
103
- const source =
117
+ const file =
104
118
  isSafeSlideSlug(slug) && isInside(path, paths.slides)
105
119
  ? readDeckFile(deck.dir, path)
106
120
  : undefined;
107
- read.set(slug, source === undefined ? undefined : extractSlideSection(source));
121
+ const found = file === undefined ? undefined : locateSlideSection(file);
122
+ read.set(slug, file !== undefined && found ? { file, ...found } : undefined);
108
123
  }
109
124
  return read.get(slug);
110
125
  };
126
+ const written = (slug: string): string | undefined => located(slug)?.section;
111
127
  return {
112
128
  has: (slug) => written(slug) !== undefined,
113
129
  written,
114
130
  section: (slug) => written(slug) ?? extractSlideSection(skeleton(slug) ?? "") ?? "",
131
+ source(slug) {
132
+ const found = located(slug);
133
+ return found && { file: found.file, start: found.start };
134
+ },
115
135
  };
116
136
  }
117
137
 
@@ -140,15 +160,24 @@ export type SlideStamp = {
140
160
  beat?: { index: number; step: string };
141
161
  /** Inline the deck's files as data: URIs, for a page that stands alone. */
142
162
  inline?: { deckDir: string };
163
+ /**
164
+ * Where the section is written, for the dev server's pages: every start tag then says where
165
+ * in the file it is, as `data-dek-source="<line>:<column>"`, and the classes it is written
166
+ * with, as `data-dek-class`, so annotate mode can name it as the file does whatever a script
167
+ * adds to it.
168
+ */
169
+ source?: SlideSource;
143
170
  };
144
171
 
145
172
  /**
146
173
  * One slide section with what the page needs on it, in one parser pass: its `data-slug` unless
147
- * it names its own, its place as `--dek-slide-number` and `--dek-slide-count`, and for a still
148
- * page the classes and beat the player would have set.
174
+ * it names its own, its place as `--dek-slide-number` and `--dek-slide-count`, for a still page
175
+ * the classes and beat the player would have set, and for the dev server where each tag is
176
+ * written.
149
177
  */
150
178
  export function stampSlide(html: string, stamp: SlideStamp): string {
151
179
  let done = false;
180
+ const spots = stamp.source && startTagSpots(html, stamp.source);
152
181
  return rewriteHtml(html, (rewriter) => {
153
182
  rewriter.on("section", {
154
183
  element(el) {
@@ -186,21 +215,77 @@ export function stampSlide(html: string, stamp: SlideStamp): string {
186
215
  if (stamp.inline) {
187
216
  rewriter.on("*", assetInliner(stamp.inline.deckDir));
188
217
  }
218
+ if (spots) {
219
+ // The same parser over the same markup meets the start tags in the same order.
220
+ let index = 0;
221
+ rewriter.on("*", {
222
+ element(el) {
223
+ const spot = spots[index++];
224
+ if (spot) {
225
+ el.setAttribute("data-dek-source", `${spot.line}:${spot.column}`);
226
+ if (spot.classes !== undefined) {
227
+ el.setAttribute("data-dek-class", spot.classes);
228
+ }
229
+ }
230
+ },
231
+ });
232
+ }
233
+ });
234
+ }
235
+
236
+ /** Where a start tag is written, and its classes as written; none when it has no `class`. */
237
+ type TagSpot = LineColumn & { classes?: string };
238
+
239
+ /**
240
+ * Where each start tag of `html` is written in the file it was taken from, in document order,
241
+ * read before anything else touches the markup. A marker goes in front of each tag; where the
242
+ * markers land, less their own length, is where the tags were.
243
+ */
244
+ function startTagSpots(html: string, source: SlideSource): TagSpot[] {
245
+ const mark = uniqueMark(html);
246
+ const classes: Array<string | null> = [];
247
+ const marked = rewriteHtml(html, (rewriter) => {
248
+ rewriter.on("*", {
249
+ element(el) {
250
+ el.before(mark, { html: true });
251
+ classes.push(el.getAttribute("class"));
252
+ },
253
+ });
189
254
  });
255
+ const spot = lineLocator(source.file);
256
+ let offset = source.start;
257
+ return marked
258
+ .split(mark)
259
+ .slice(0, -1)
260
+ .map((before, index) => {
261
+ offset += before.length;
262
+ const written = classes[index];
263
+ return written === null || written === undefined
264
+ ? spot(offset)
265
+ : { ...spot(offset), classes: written.split(/\s+/).filter(Boolean).join(" ") };
266
+ });
190
267
  }
191
268
 
192
- /** Every slide of the deck in script order, as the player pages hold them. */
193
- export function collectSlidesHtml(deck: ProjectDeck, options: { inline: boolean }): string {
269
+ /**
270
+ * Every slide of the deck in script order, as the player pages hold them. `sources` says where
271
+ * each tag is written, for the dev server's pages.
272
+ */
273
+ export function collectSlidesHtml(
274
+ deck: ProjectDeck,
275
+ options: { inline: boolean; sources?: boolean },
276
+ ): string {
194
277
  const slides = deckSlides(deck);
195
278
  const { sections } = deck.deck;
196
279
  return sections
197
- .map((section, index) =>
198
- stampSlide(slides.section(section.slug), {
280
+ .map((section, index) => {
281
+ const source = options.sources ? slides.source(section.slug) : undefined;
282
+ return stampSlide(slides.section(section.slug), {
199
283
  slug: section.slug,
200
284
  place: slidePlace(sections, index),
201
285
  ...(options.inline ? { inline: { deckDir: deck.dir } } : {}),
202
- }),
203
- )
286
+ ...(source ? { source } : {}),
287
+ });
288
+ })
204
289
  .join("");
205
290
  }
206
291