@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.
Files changed (187) hide show
  1. package/LICENSE +21 -0
  2. package/README.ja.md +75 -0
  3. package/README.md +75 -0
  4. package/package.json +65 -0
  5. package/src/cli/address.ts +53 -0
  6. package/src/cli/build.ts +85 -0
  7. package/src/cli/check.ts +97 -0
  8. package/src/cli/commands.ts +851 -0
  9. package/src/cli/contract.ts +444 -0
  10. package/src/cli/cues.ts +18 -0
  11. package/src/cli/files.ts +309 -0
  12. package/src/cli/flags.ts +295 -0
  13. package/src/cli/format.ts +156 -0
  14. package/src/cli/goto.ts +96 -0
  15. package/src/cli/init.ts +136 -0
  16. package/src/cli/keep-alive.ts +72 -0
  17. package/src/cli/lint.ts +84 -0
  18. package/src/cli/ls.ts +119 -0
  19. package/src/cli/main.ts +231 -0
  20. package/src/cli/marks.ts +16 -0
  21. package/src/cli/mv.ts +37 -0
  22. package/src/cli/new.ts +64 -0
  23. package/src/cli/pdf.ts +16 -0
  24. package/src/cli/qr.ts +466 -0
  25. package/src/cli/ref.ts +165 -0
  26. package/src/cli/rehearse.ts +42 -0
  27. package/src/cli/result.ts +161 -0
  28. package/src/cli/scope.ts +308 -0
  29. package/src/cli/serve.ts +125 -0
  30. package/src/cli/shot.ts +134 -0
  31. package/src/cli/show.ts +103 -0
  32. package/src/cli/sync.ts +37 -0
  33. package/src/cli/text.ts +479 -0
  34. package/src/cli/theme.ts +56 -0
  35. package/src/cli/tty.ts +81 -0
  36. package/src/cli/usage.ts +198 -0
  37. package/src/cli/video.ts +17 -0
  38. package/src/cli/voice.ts +113 -0
  39. package/src/cli.ts +4 -0
  40. package/src/core/assets.ts +239 -0
  41. package/src/core/build.ts +77 -0
  42. package/src/core/bundled-theme.ts +15 -0
  43. package/src/core/capture-go.ts +72 -0
  44. package/src/core/chrome.ts +128 -0
  45. package/src/core/config-keys.ts +82 -0
  46. package/src/core/config.ts +95 -0
  47. package/src/core/css-scan.ts +158 -0
  48. package/src/core/css-transform.ts +140 -0
  49. package/src/core/css-value.ts +127 -0
  50. package/src/core/css.ts +545 -0
  51. package/src/core/cue.ts +187 -0
  52. package/src/core/deck-paths.ts +34 -0
  53. package/src/core/diagnostic.ts +114 -0
  54. package/src/core/document.ts +218 -0
  55. package/src/core/error.ts +31 -0
  56. package/src/core/escape.ts +16 -0
  57. package/src/core/fill.ts +100 -0
  58. package/src/core/finish-beat.ts +30 -0
  59. package/src/core/github.ts +234 -0
  60. package/src/core/html-scan.ts +253 -0
  61. package/src/core/html.ts +234 -0
  62. package/src/core/in-page-go.ts +128 -0
  63. package/src/core/lines.ts +43 -0
  64. package/src/core/lint/asset-refs.ts +109 -0
  65. package/src/core/lint/context.ts +116 -0
  66. package/src/core/lint/files.ts +117 -0
  67. package/src/core/lint/keys.ts +48 -0
  68. package/src/core/lint/morph.ts +91 -0
  69. package/src/core/lint/script.ts +102 -0
  70. package/src/core/lint/shared-styles.ts +165 -0
  71. package/src/core/lint/slide-html.ts +466 -0
  72. package/src/core/lint/slide-script.ts +179 -0
  73. package/src/core/lint/theme-css.ts +295 -0
  74. package/src/core/lint/timing.ts +66 -0
  75. package/src/core/lint/tokens.ts +520 -0
  76. package/src/core/lint/voice.ts +76 -0
  77. package/src/core/lint.ts +137 -0
  78. package/src/core/live-protocol.ts +130 -0
  79. package/src/core/marks.ts +198 -0
  80. package/src/core/mv.ts +293 -0
  81. package/src/core/ogp.ts +99 -0
  82. package/src/core/optional.ts +24 -0
  83. package/src/core/overflow.ts +220 -0
  84. package/src/core/page-pool.ts +53 -0
  85. package/src/core/page.ts +66 -0
  86. package/src/core/parse.ts +307 -0
  87. package/src/core/path.ts +58 -0
  88. package/src/core/pdf.ts +71 -0
  89. package/src/core/playwright-module.d.ts +43 -0
  90. package/src/core/playwright-motion.ts +42 -0
  91. package/src/core/playwright-sheet.ts +32 -0
  92. package/src/core/playwright-visual.ts +391 -0
  93. package/src/core/playwright-worker.ts +5 -0
  94. package/src/core/playwright.ts +431 -0
  95. package/src/core/position.ts +146 -0
  96. package/src/core/presenter-state.ts +43 -0
  97. package/src/core/presenter.ts +131 -0
  98. package/src/core/prose.ts +16 -0
  99. package/src/core/pseudo-text.ts +67 -0
  100. package/src/core/rail-width.ts +4 -0
  101. package/src/core/ref-name.ts +110 -0
  102. package/src/core/ref.ts +238 -0
  103. package/src/core/resolve.ts +228 -0
  104. package/src/core/rumdl.ts +99 -0
  105. package/src/core/safe-fs.ts +196 -0
  106. package/src/core/sarif.ts +135 -0
  107. package/src/core/schema.ts +89 -0
  108. package/src/core/script-lines.ts +53 -0
  109. package/src/core/sheet.ts +223 -0
  110. package/src/core/shot/morph.ts +80 -0
  111. package/src/core/shot/motion.ts +187 -0
  112. package/src/core/shot/still.ts +216 -0
  113. package/src/core/shot-cache.ts +140 -0
  114. package/src/core/size.ts +11 -0
  115. package/src/core/skeleton-record.ts +51 -0
  116. package/src/core/skeleton.ts +89 -0
  117. package/src/core/slide-draw.ts +165 -0
  118. package/src/core/slide-eval-worker.ts +58 -0
  119. package/src/core/slide-measure.ts +272 -0
  120. package/src/core/slide-script-eval.ts +258 -0
  121. package/src/core/slide-script.ts +252 -0
  122. package/src/core/spawn.ts +128 -0
  123. package/src/core/step.ts +94 -0
  124. package/src/core/still-page.ts +70 -0
  125. package/src/core/suggest.ts +33 -0
  126. package/src/core/sync.ts +316 -0
  127. package/src/core/text-contrast.ts +466 -0
  128. package/src/core/theme-excerpt.ts +187 -0
  129. package/src/core/theme-facts.ts +51 -0
  130. package/src/core/timeline.ts +216 -0
  131. package/src/core/timing.ts +88 -0
  132. package/src/core/toml-keys.ts +349 -0
  133. package/src/core/url-attributes.ts +103 -0
  134. package/src/core/video-document.ts +15 -0
  135. package/src/core/visual-cache.ts +100 -0
  136. package/src/core/visual.ts +558 -0
  137. package/src/core/voice.ts +321 -0
  138. package/src/core/zod.ts +79 -0
  139. package/src/runtime/browser.ts +203 -0
  140. package/src/runtime/clone.ts +52 -0
  141. package/src/runtime/deck-control.ts +43 -0
  142. package/src/runtime/fit.ts +32 -0
  143. package/src/runtime/go.ts +56 -0
  144. package/src/runtime/input.ts +128 -0
  145. package/src/runtime/laser-view.ts +110 -0
  146. package/src/runtime/laser.ts +145 -0
  147. package/src/runtime/live-host.ts +33 -0
  148. package/src/runtime/live-reload.ts +29 -0
  149. package/src/runtime/live.ts +88 -0
  150. package/src/runtime/marks-view.ts +74 -0
  151. package/src/runtime/motion.ts +113 -0
  152. package/src/runtime/navigator.ts +189 -0
  153. package/src/runtime/player.ts +28 -0
  154. package/src/runtime/presenter-view.ts +289 -0
  155. package/src/runtime/presenter.ts +52 -0
  156. package/src/runtime/rail-view.ts +229 -0
  157. package/src/runtime/rail.ts +52 -0
  158. package/src/runtime/rehearse.ts +264 -0
  159. package/src/runtime/routes.ts +142 -0
  160. package/src/runtime/settle.ts +45 -0
  161. package/src/runtime/slide.d.ts +28 -0
  162. package/src/runtime/socket.ts +143 -0
  163. package/src/runtime/stage.ts +259 -0
  164. package/src/runtime/step.ts +139 -0
  165. package/src/runtime/window.ts +38 -0
  166. package/src/server/auth.ts +210 -0
  167. package/src/server/deck-snapshot.ts +146 -0
  168. package/src/server/dev.ts +251 -0
  169. package/src/server/hub.ts +104 -0
  170. package/src/server/lan.ts +51 -0
  171. package/src/server/lock.ts +97 -0
  172. package/src/server/pages.ts +71 -0
  173. package/src/server/pairing.ts +40 -0
  174. package/src/server/project-watch.ts +142 -0
  175. package/src/server/rooms.ts +133 -0
  176. package/src/server/routes.ts +286 -0
  177. package/src/server/serial.ts +25 -0
  178. package/src/server/watch.ts +308 -0
  179. package/src/theme/default.css +175 -0
  180. package/src/video/bake.ts +152 -0
  181. package/src/video/mux.ts +103 -0
  182. package/src/video/recorder.ts +115 -0
  183. package/src/video/sidecar.ts +60 -0
  184. package/src/video/worker.ts +51 -0
  185. package/src/voice/engine.ts +177 -0
  186. package/src/voice/synth.ts +127 -0
  187. package/src/voice/wav.ts +115 -0
@@ -0,0 +1,117 @@
1
+ import { type Diagnostic, diag } from "../diagnostic.ts";
2
+ import { either } from "../prose.ts";
3
+ import { listSlideFiles, SLIDE_SIDECARS } from "../resolve.ts";
4
+ import type { LintContext } from "./context.ts";
5
+
6
+ /** DEK001 for a section without slides/<slug>.html; DEK002 for a slide file without its section. */
7
+ export function pairingDiagnostics(ctx: LintContext): Diagnostic[] {
8
+ const diagnostics: Diagnostic[] = [];
9
+ for (const section of ctx.deck.deck.sections) {
10
+ if (ctx.slidesBySlug.has(section.slug)) {
11
+ continue;
12
+ }
13
+ diagnostics.push(
14
+ diag("DEK001", {
15
+ message: `missing slide HTML for "${section.slug}"`,
16
+ path: ctx.deck.scriptPath,
17
+ line: section.line,
18
+ slug: section.slug,
19
+ hint: "run `dekc sync` to create the skeleton",
20
+ data: { expected: `slides/${section.slug}.html` },
21
+ }),
22
+ );
23
+ }
24
+
25
+ for (const slide of ctx.slidesBySlug.values()) {
26
+ if (ctx.sectionsBySlug.has(slide.slug)) {
27
+ continue;
28
+ }
29
+ diagnostics.push(
30
+ diag("DEK002", {
31
+ message: `slide HTML has no section "${slide.slug}"`,
32
+ path: slide.path,
33
+ slug: slide.slug,
34
+ hint: orphanHint(slide.slug, `slides/${slide.slug}.html`),
35
+ data: { slug: slide.slug, file: `slides/${slide.slug}.html` },
36
+ }),
37
+ );
38
+ }
39
+
40
+ for (const ext of SLIDE_SIDECARS) {
41
+ const kind = ext === ".css" ? "stylesheet" : "script";
42
+ for (const file of listSlideFiles(ctx.deck.dir, ext)) {
43
+ // A sidecar next to an orphaned HTML file travels with it; DEK002 already names the slug.
44
+ if (ctx.sectionsBySlug.has(file.slug) || ctx.slidesBySlug.has(file.slug)) {
45
+ continue;
46
+ }
47
+ diagnostics.push(
48
+ diag("DEK002", {
49
+ message: `slide ${kind} has no section "${file.slug}"`,
50
+ path: file.path,
51
+ slug: file.slug,
52
+ hint: orphanHint(file.slug, `slides/${file.slug}${ext}`),
53
+ data: { slug: file.slug, file: `slides/${file.slug}${ext}` },
54
+ }),
55
+ );
56
+ }
57
+ }
58
+ return diagnostics;
59
+ }
60
+
61
+ /**
62
+ * What to do with a slide file the script does not name, when no rename explains it: give it a
63
+ * section, or take it out of the talk. Which one is the author's call.
64
+ */
65
+ function orphanHint(slug: string, file: string): string {
66
+ const title = slug.charAt(0).toUpperCase() + slug.slice(1).replaceAll("-", " ");
67
+ return `add a section for it to script.md, like \`## ${title} {#${slug}}\`, or remove ${file} if the slide is gone from the talk`;
68
+ }
69
+
70
+ /**
71
+ * An orphaned HTML file beside sections without their own slide (missing, or still the skeleton
72
+ * the dev server generated on save) looks like a heading renamed in script.md first. `dekc mv`
73
+ * finishes that rename from either state, so each orphan's hint names a `dekc mv` for every such
74
+ * section, in script order, rather than asking to delete a slide someone wrote. With one of each
75
+ * the pairing is certain, and both diagnostics also offer the rename the other way.
76
+ */
77
+ export function suggestRename(diagnostics: Diagnostic[], ctx: LintContext): Diagnostic[] {
78
+ const orphans = new Set(
79
+ diagnostics.filter(
80
+ (diagnostic) => diagnostic.id === "DEK002" && diagnostic.path?.endsWith(".html"),
81
+ ),
82
+ );
83
+ const targets = ctx.deck.deck.sections
84
+ .filter(
85
+ (section) =>
86
+ !ctx.slidesBySlug.has(section.slug) || ctx.slideSource(section.slug)?.skeleton === true,
87
+ )
88
+ .map((section) => section.slug);
89
+ if (orphans.size === 0 || targets.length === 0) {
90
+ return diagnostics;
91
+ }
92
+ const [orphan] = orphans;
93
+ const [target] = targets;
94
+ if (orphans.size === 1 && targets.length === 1 && orphan?.slug && target) {
95
+ // An orphan beside a section without its own slide is a rename, made in the script or in the
96
+ // files: which one is the author's to say, and each command finishes it from this state.
97
+ const hint = `run \`dekc mv ${orphan.slug} ${target}\` to move the files to the script's id, or \`dekc mv ${target} ${orphan.slug}\` to give the section the files' id`;
98
+ return diagnostics.map((diagnostic) =>
99
+ diagnostic === orphan
100
+ ? { ...diagnostic, hint, data: { ...diagnostic.data, renames: targets } }
101
+ : diagnostic.id === "DEK001" && diagnostic.slug === target
102
+ ? { ...diagnostic, hint }
103
+ : diagnostic,
104
+ );
105
+ }
106
+ return diagnostics.map((diagnostic) => {
107
+ if (!orphans.has(diagnostic) || !diagnostic.slug) {
108
+ return diagnostic;
109
+ }
110
+ const commands = targets.map((slug) => `\`dekc mv ${diagnostic.slug} ${slug}\``);
111
+ return {
112
+ ...diagnostic,
113
+ hint: `run ${either(commands)} to move the files to the section they now belong to`,
114
+ data: { ...diagnostic.data, renames: targets },
115
+ };
116
+ });
117
+ }
@@ -0,0 +1,48 @@
1
+ import {
2
+ FRONTMATTER_KEYS,
3
+ type UnknownKey,
4
+ unknownFrontmatterKeys,
5
+ unknownTomlKeys,
6
+ } from "../config-keys.ts";
7
+ import { type Diagnostic, diag } from "../diagnostic.ts";
8
+ import { type Project, readTextIfExists } from "../resolve.ts";
9
+ import type { LintContext } from "./context.ts";
10
+
11
+ /*
12
+ * DEK008: a key in dek.toml or the frontmatter that dek does not read. Parsing drops it
13
+ * without a word, so a typo leaves the default in place and nobody knows why.
14
+ */
15
+
16
+ /** DEK008 for dek.toml: a project finding, the same for every deck. */
17
+ export function tomlKeyDiagnostics(project: Project): Diagnostic[] {
18
+ return unknownTomlKeys(readTextIfExists(project.configPath) ?? "").map((key) =>
19
+ unknownKeyDiagnostic(key, { file: "dek.toml", path: project.configPath }),
20
+ );
21
+ }
22
+
23
+ /** DEK008 for the deck's frontmatter. */
24
+ export function frontmatterKeyDiagnostics({ deck, script }: LintContext): Diagnostic[] {
25
+ return (script ? unknownFrontmatterKeys(script.yaml, 2) : []).map((key) =>
26
+ unknownKeyDiagnostic(key, { file: "frontmatter", path: deck.scriptPath }),
27
+ );
28
+ }
29
+
30
+ function unknownKeyDiagnostic(
31
+ { key, line, suggestion }: UnknownKey,
32
+ where: { file: "dek.toml" | "frontmatter"; path: string },
33
+ ): Diagnostic {
34
+ const place = where.file === "dek.toml" ? "dek.toml" : "the frontmatter";
35
+ const known =
36
+ where.file === "dek.toml"
37
+ ? `see ${REFERENCE_URL}#dek-toml`
38
+ : `the keys are ${FRONTMATTER_KEYS.join(", ")}; see ${REFERENCE_URL}#frontmatter`;
39
+ return diag("DEK008", {
40
+ message: `unknown key ${key} in ${place}; dek ignores it`,
41
+ path: where.path,
42
+ line,
43
+ hint: suggestion ? `did you mean ${suggestion}?` : known,
44
+ data: { file: where.file, key, ...(suggestion ? { suggestion } : {}) },
45
+ });
46
+ }
47
+
48
+ const REFERENCE_URL = "https://hajimism.github.io/dek/reference/config.html";
@@ -0,0 +1,91 @@
1
+ import { type Diagnostic, diag } from "../diagnostic.ts";
2
+ import type { HtmlAttribute } from "../html-scan.ts";
3
+ import { suggest } from "../suggest.ts";
4
+ import type { LintContext } from "./context.ts";
5
+
6
+ /** Names the player takes for the page itself, and the CSS keywords of `view-transition-name`. */
7
+ export const RESERVED_MORPHS = new Set(["slide", "root", "none", "auto", "match-element"]);
8
+
9
+ /**
10
+ * DEK028: a data-morph that no slide beside it names. The browser morphs an element only into
11
+ * the element of the same name on the slide it goes to, so a name with no partner on the slide
12
+ * before or after is a plain fade, however it is spelled. A partner that is one typo away is the
13
+ * likeliest fix; otherwise the hint names the files a partner could go in.
14
+ */
15
+ export function unpairedMorphDiagnostics(ctx: LintContext): Diagnostic[] {
16
+ const order = [...ctx.sectionsBySlug.keys()];
17
+ const morphs = order.map((slug) => morphsOf(ctx, slug));
18
+ return order.flatMap((slug, index) => {
19
+ const own = morphs[index];
20
+ const path = ctx.slidesBySlug.get(slug)?.path;
21
+ if (!own || !path) {
22
+ return [];
23
+ }
24
+ const neighbors = [index - 1, index + 1].flatMap((at) => {
25
+ const names = morphs[at];
26
+ const neighbor = order[at];
27
+ return names && neighbor ? [{ slug: neighbor, names }] : [];
28
+ });
29
+ return [...own].flatMap(([morph, attribute]) => {
30
+ if (neighbors.some((neighbor) => neighbor.names.has(morph))) {
31
+ return [];
32
+ }
33
+ return [
34
+ diag("DEK028", {
35
+ message: `data-morph "${morph}" is on neither slide beside it, so nothing morphs`,
36
+ path,
37
+ line: attribute.line,
38
+ column: attribute.column,
39
+ slug,
40
+ hint: partnerHint(morph, neighbors, own),
41
+ data: { morph, neighbors: neighbors.map((neighbor) => neighbor.slug) },
42
+ }),
43
+ ];
44
+ });
45
+ });
46
+ }
47
+
48
+ /** Each data-morph a slide names, at its first use; reserved names are DEK005's. */
49
+ function morphsOf(ctx: LintContext, slug: string): Map<string, HtmlAttribute> | undefined {
50
+ const scan = ctx.slideSource(slug)?.scan;
51
+ if (!scan) {
52
+ return undefined;
53
+ }
54
+ const morphs = new Map<string, HtmlAttribute>();
55
+ for (const element of scan.elements) {
56
+ for (const attribute of element.attributes) {
57
+ const morph = attribute.value;
58
+ if (
59
+ element.inSlide &&
60
+ attribute.name === "data-morph" &&
61
+ !RESERVED_MORPHS.has(morph) &&
62
+ !morphs.has(morph)
63
+ ) {
64
+ morphs.set(morph, attribute);
65
+ }
66
+ }
67
+ }
68
+ return morphs;
69
+ }
70
+
71
+ function partnerHint(
72
+ morph: string,
73
+ neighbors: { slug: string; names: Map<string, HtmlAttribute> }[],
74
+ own: Map<string, HtmlAttribute>,
75
+ ): string {
76
+ for (const neighbor of neighbors) {
77
+ // A name this slide already uses is some other element's partner, not this one's.
78
+ const guess = suggest(
79
+ morph,
80
+ [...neighbor.names.keys()].filter((name) => !own.has(name)),
81
+ );
82
+ if (guess) {
83
+ return `did you mean "${guess}", as in slides/${neighbor.slug}.html?`;
84
+ }
85
+ }
86
+ if (neighbors.length === 0) {
87
+ return "remove it; a morph carries an element into the slide before or after, and this deck has no other";
88
+ }
89
+ const files = neighbors.map((neighbor) => `slides/${neighbor.slug}.html`).join(" or ");
90
+ return `give its partner data-morph="${morph}" in ${files}, or remove it`;
91
+ }
@@ -0,0 +1,102 @@
1
+ import { type Diagnostic, diag } from "../diagnostic.ts";
2
+ import type { DekError } from "../error.ts";
3
+ import { ScriptError } from "../parse.ts";
4
+ import { scriptLines } from "../script-lines.ts";
5
+ import type { LintContext } from "./context.ts";
6
+
7
+ /** DEK004: a section id or a beat id used twice. */
8
+ export function duplicateIdDiagnostics(ctx: LintContext): Diagnostic[] {
9
+ const diagnostics: Diagnostic[] = [];
10
+ const scriptPath = ctx.deck.scriptPath;
11
+ const seenSlugs = new Set<string>();
12
+ for (const section of ctx.deck.deck.sections) {
13
+ if (seenSlugs.has(section.slug)) {
14
+ diagnostics.push(
15
+ diag("DEK004", {
16
+ message: `duplicate section id "${section.slug}"`,
17
+ path: scriptPath,
18
+ line: section.line,
19
+ slug: section.slug,
20
+ data: { id: section.slug },
21
+ }),
22
+ );
23
+ } else {
24
+ seenSlugs.add(section.slug);
25
+ }
26
+
27
+ const seenBeats = new Set<string>();
28
+ for (const beat of section.beats) {
29
+ if (!beat.id) {
30
+ continue;
31
+ }
32
+ if (seenBeats.has(beat.id)) {
33
+ diagnostics.push(
34
+ diag("DEK004", {
35
+ message: `duplicate beat id "${beat.id}" in "${section.slug}"`,
36
+ path: scriptPath,
37
+ line: beat.line,
38
+ slug: section.slug,
39
+ data: { id: beat.id },
40
+ }),
41
+ );
42
+ } else {
43
+ seenBeats.add(beat.id);
44
+ }
45
+ }
46
+ }
47
+ return diagnostics;
48
+ }
49
+
50
+ /**
51
+ * DEK044: a `#` or `####` heading. Only `##` (slide) and `###` (beat) mean anything, so any
52
+ * other level is read out as part of the script, `#` and all.
53
+ */
54
+ export function strayHeadingDiagnostics(ctx: LintContext): Diagnostic[] {
55
+ const { deck, script } = ctx;
56
+ if (!script) {
57
+ return [];
58
+ }
59
+ const starts = deck.deck.sections.map((section) => ({ line: section.line, slug: section.slug }));
60
+ const diagnostics: Diagnostic[] = [];
61
+ // What the parser reads as text, a code block or a comment, is not a heading here either.
62
+ for (const [offset, { text, literal }] of scriptLines(script.body).entries()) {
63
+ const level = literal ? undefined : text.match(/^(#{1,6})\s/)?.[1]?.length;
64
+ if (level === undefined || level === 2 || level === 3) {
65
+ continue;
66
+ }
67
+ const line = script.bodyStartLine + offset;
68
+ // Under a slide heading it belongs to that slide; above the first, to the deck.
69
+ const slug = starts.filter((start) => start.line < line).at(-1)?.slug;
70
+ diagnostics.push(
71
+ diag("DEK044", {
72
+ message: `${"#".repeat(level)} heading is not a slide or a beat; it is read as spoken text`,
73
+ path: deck.scriptPath,
74
+ line,
75
+ ...(slug !== undefined ? { slug } : {}),
76
+ hint: "use ## for a slide and ### for a beat, or drop the #",
77
+ data: { level },
78
+ }),
79
+ );
80
+ }
81
+ return diagnostics;
82
+ }
83
+
84
+ /**
85
+ * DEK027: a script.md that cannot be read, one finding for each problem that stops it. Nothing
86
+ * else in the deck can be checked against a script that does not read, so these are its findings.
87
+ */
88
+ export function unreadableScriptDiagnostics(deck: {
89
+ scriptPath: string;
90
+ error: DekError;
91
+ }): Diagnostic[] {
92
+ const { error } = deck;
93
+ const problems = error instanceof ScriptError ? error.problems : [error];
94
+ return problems.map(({ message, line, hint }) =>
95
+ diag("DEK027", {
96
+ message,
97
+ path: error.path ?? deck.scriptPath,
98
+ ...(line !== undefined ? { line } : {}),
99
+ ...(hint !== undefined ? { hint } : {}),
100
+ }),
101
+ );
102
+ }
@@ -0,0 +1,165 @@
1
+ import { isKeyframesPrelude, selectorClasses, splitSelectorList, startsAtSlide } from "../css.ts";
2
+ import { deckPaths } from "../deck-paths.ts";
3
+ import { type Diagnostic, diag } from "../diagnostic.ts";
4
+ import { listed } from "../prose.ts";
5
+ import type { LintContext } from "./context.ts";
6
+
7
+ /** How many slides may style something alike before it belongs in the theme: the third copy. */
8
+ const SHARED_BY = 3;
9
+
10
+ /** One declaration as one slide writes it. */
11
+ type Setting = {
12
+ /** The selector part as this slide writes it, spaces squashed. */
13
+ selector: string;
14
+ declaration: string;
15
+ atPath: string[];
16
+ line: number;
17
+ };
18
+
19
+ /** One slide's share under one selector: the slides it shares any of it with, and its settings. */
20
+ type Finding = { slugs: Set<string>; settings: Setting[] };
21
+
22
+ const squash = (text: string): string => text.replace(/\s+/g, " ").trim();
23
+
24
+ /**
25
+ * A selector part as the slide's scope reads it, the way `scopeToSlide` rewrites it: a leading
26
+ * `.slide` compound is the slide itself, anything else sits under it. So `.card` and
27
+ * `.slide .card` match the same elements and are one selector here, and `.slide > .card` is not.
28
+ */
29
+ function scopedSelector(part: string): string {
30
+ const item = squash(part);
31
+ return startsAtSlide(item) ? item.slice(".slide".length) : ` ${item}`;
32
+ }
33
+
34
+ /**
35
+ * The selector part as theme.css must write it: under `.slide`, as the theme scopes every rule
36
+ * and as the slide's own scope already read it.
37
+ */
38
+ function themeSelector(part: string): string {
39
+ return `.slide${scopedSelector(part)}`;
40
+ }
41
+
42
+ /**
43
+ * The other slides a rule moved into the theme would also reach: the ones whose markup uses
44
+ * every class the selector names, and that do not set it themselves. A selector with no class,
45
+ * such as `h2`, is left out: which slides it matches is for the author to look at.
46
+ */
47
+ function alsoReaches(ctx: LintContext, selector: string, setters: string[]): string[] {
48
+ const classes = selectorClasses(selector);
49
+ if (classes.length === 0) {
50
+ return [];
51
+ }
52
+ return [...ctx.sectionsBySlug.keys()].filter((slug) => {
53
+ const source = setters.includes(slug) ? undefined : ctx.slideSource(slug);
54
+ const used = source && new Set(source.scan.classes);
55
+ return used !== undefined && classes.every((name) => used.has(name));
56
+ });
57
+ }
58
+
59
+ /**
60
+ * DEK026: the same declaration under the same selector in the stylesheets of SHARED_BY slides
61
+ * or more. Each slide fixed alike on its own, as agents working on one slide each will, is one
62
+ * fix the theme is missing. Each slide hears of it in its own check, once per selector: what it
63
+ * shares there, and every slide it shares any of that with.
64
+ */
65
+ export function sharedStyleDiagnostics(ctx: LintContext): Diagnostic[] {
66
+ // Where a declaration applies and what it sets, to the slides that write it.
67
+ const bySetting = new Map<string, { where: string; slides: Map<string, Setting> }>();
68
+ for (const slug of ctx.sectionsBySlug.keys()) {
69
+ const style = ctx.slideStyle(slug);
70
+ if (!style) {
71
+ continue;
72
+ }
73
+ for (const decl of style.sheet.decls) {
74
+ // A slide's keyframes are renamed apart from every other slide's.
75
+ if (!decl.selector || decl.atPath.some(isKeyframesPrelude)) {
76
+ continue;
77
+ }
78
+ const declaration = `${decl.property}: ${squash(decl.value)}`;
79
+ for (const part of splitSelectorList(decl.selector)) {
80
+ const where = [...decl.atPath.map(squash), scopedSelector(part)].join("\0");
81
+ const key = [where, decl.property.toLowerCase(), squash(decl.value)].join("\0");
82
+ const entry = bySetting.get(key) ?? { where, slides: new Map<string, Setting>() };
83
+ if (!entry.slides.has(slug)) {
84
+ entry.slides.set(slug, {
85
+ selector: squash(part),
86
+ declaration,
87
+ atPath: decl.atPath,
88
+ line: decl.line,
89
+ });
90
+ }
91
+ bySetting.set(key, entry);
92
+ }
93
+ }
94
+ }
95
+
96
+ // Per slide, what it shares under one selector makes one finding, whoever it shares it with.
97
+ const order = [...ctx.sectionsBySlug.keys()];
98
+ const findings = new Map<string, Map<string, Finding>>();
99
+ for (const { where, slides } of bySetting.values()) {
100
+ if (slides.size < SHARED_BY) {
101
+ continue;
102
+ }
103
+ for (const [slug, setting] of slides) {
104
+ const groups = findings.get(slug) ?? new Map<string, Finding>();
105
+ const found = groups.get(where) ?? { slugs: new Set<string>(), settings: [] };
106
+ for (const other of slides.keys()) {
107
+ found.slugs.add(other);
108
+ }
109
+ found.settings.push(setting);
110
+ groups.set(where, found);
111
+ findings.set(slug, groups);
112
+ }
113
+ }
114
+
115
+ const paths = deckPaths(ctx.deck.dir);
116
+ const cssOf = (slug: string): string => `slides/${slug}.css`;
117
+ return order.flatMap((slug) =>
118
+ [...(findings.get(slug)?.values() ?? [])]
119
+ // In the order this slide's own file writes them.
120
+ .map(({ slugs, settings }) => {
121
+ const own = [...settings].sort((a, b) => a.line - b.line);
122
+ const all = order.filter((other) => slugs.has(other));
123
+ return { slugs: all, settings: own, line: own[0]?.line ?? 0 };
124
+ })
125
+ .sort((a, b) => a.line - b.line)
126
+ .map(({ slugs, settings, line }) => {
127
+ const [first] = settings;
128
+ const selector = first?.selector ?? "";
129
+ const atPath = first?.atPath ?? [];
130
+ const declarations = settings.map((setting) => setting.declaration);
131
+ const block = `{ ${declarations.join("; ")} }`;
132
+ const rule = `${[...atPath, selector].join(" ")} ${block}`;
133
+ const themeRule = `${[...atPath, themeSelector(selector)].join(" ")} ${block}`;
134
+ const others = slugs.filter((other) => other !== slug).map(cssOf);
135
+ // Moving it changes these too: a theme default that slides override is a real finding,
136
+ // and whether the rest should follow is the author's call, made with these in view.
137
+ const reaches = alsoReaches(ctx, selector, slugs);
138
+ const htmlOf = (other: string): string => `slides/${other}.html`;
139
+ const affected =
140
+ reaches.length === 0
141
+ ? ""
142
+ : `; it also reaches ${listed(reaches.map(htmlOf))}, which ${reaches.length === 1 ? "uses" : "use"} ${selectorClasses(
143
+ selector,
144
+ )
145
+ .map((name) => `.${name}`)
146
+ .join(
147
+ "",
148
+ )} without it, so check ${reaches.length === 1 ? "that slide" : "those slides"} after the move`;
149
+ return diag("DEK026", {
150
+ message: `${rule} is also in ${listed(others)}`,
151
+ path: paths.slide(slug, ".css"),
152
+ line,
153
+ slug,
154
+ hint: `write ${themeRule} once in theme.css and delete it from ${listed(slugs.map(cssOf))}${affected}`,
155
+ data: {
156
+ selector,
157
+ declarations,
158
+ slides: slugs,
159
+ ...(atPath.length > 0 ? { atRules: atPath } : {}),
160
+ ...(reaches.length > 0 ? { alsoReaches: reaches } : {}),
161
+ },
162
+ });
163
+ }),
164
+ );
165
+ }