@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,216 @@
1
+ import { type Cue, splitSentences } from "./cue.ts";
2
+ import { DekError } from "./error.ts";
3
+ import type { Position } from "./step.ts";
4
+
5
+ export type PauseConfig = {
6
+ sentence: number;
7
+ beat: number;
8
+ };
9
+
10
+ export const DEFAULT_PAUSE: PauseConfig = {
11
+ sentence: 350,
12
+ beat: 700,
13
+ };
14
+
15
+ export type Utterance = {
16
+ text: string;
17
+ kana: string;
18
+ durationMs: number;
19
+ };
20
+
21
+ type TimelineSentence = {
22
+ text: string;
23
+ kana: string;
24
+ start: number;
25
+ end: number;
26
+ };
27
+
28
+ type TimelineBeat = {
29
+ position: Position;
30
+ start: number;
31
+ end: number;
32
+ sentences: TimelineSentence[];
33
+ /** How far the screen change leads the voice. Absent in hand-written timelines. */
34
+ lead?: number;
35
+ };
36
+
37
+ /** Per-beat overrides from voice.toml. `pause` replaces `pause.beat` after the beat. */
38
+ export type BeatTiming = {
39
+ lead?: number;
40
+ pause?: number;
41
+ };
42
+
43
+ export type Timeline = {
44
+ audio: string;
45
+ durationMs: number;
46
+ beats: TimelineBeat[];
47
+ };
48
+
49
+ /** Screen changes lead the voice so a transition settles as the first word lands. */
50
+ export const DEFAULT_LEAD_MS = 300;
51
+
52
+ export type ScheduledGo = {
53
+ at: number;
54
+ position: Position;
55
+ };
56
+
57
+ export type VoiceSchedule = {
58
+ timeline: Timeline;
59
+ pauseAfterMs: number[];
60
+ leadingMs: number;
61
+ };
62
+
63
+ export function scheduleVoice(
64
+ cues: Cue[],
65
+ utterances: Iterable<Utterance>,
66
+ pause: PauseConfig = DEFAULT_PAUSE,
67
+ audio = "",
68
+ timing: (position: Position) => BeatTiming = () => ({}),
69
+ ): VoiceSchedule {
70
+ const queue = [...utterances];
71
+ let next = 0;
72
+ const take = (text: string): Utterance => {
73
+ const utterance = queue[next];
74
+ if (!utterance || utterance.text !== text) {
75
+ throw new DekError(`missing utterance for ${JSON.stringify(text)}`, {
76
+ hint: "run `dekc voice`",
77
+ });
78
+ }
79
+ next += 1;
80
+ return utterance;
81
+ };
82
+
83
+ const beats: TimelineBeat[] = [];
84
+ const pauseAfterMs: number[] = [];
85
+ let leadingMs = 0;
86
+ let t = 0;
87
+ let previousEnd = 0;
88
+ let previousSlide = -1;
89
+ let lastClipIndex = -1;
90
+
91
+ for (const cue of cues) {
92
+ const texts = cue.paragraphs.flatMap(splitSentences);
93
+ const override = timing(cue.position);
94
+ const lead = override.lead ?? DEFAULT_LEAD_MS;
95
+ const beatPause = override.pause ?? pause.beat;
96
+ const sameSlide = cue.position.slideIndex === previousSlide;
97
+ previousSlide = cue.position.slideIndex;
98
+ if (texts.length === 0) {
99
+ // Silence after speech belongs to its slide: a silent beat fills it, a silent arrival waits it out.
100
+ const start = sameSlide ? previousEnd : t;
101
+ const end = start + beatPause;
102
+ if (end > t) {
103
+ const extra = end - t;
104
+ if (lastClipIndex >= 0) {
105
+ pauseAfterMs[lastClipIndex] = (pauseAfterMs[lastClipIndex] ?? 0) + extra;
106
+ } else {
107
+ leadingMs += extra;
108
+ }
109
+ t = end;
110
+ }
111
+ beats.push({ position: cue.position, start, end, sentences: [], lead });
112
+ previousEnd = end;
113
+ continue;
114
+ }
115
+
116
+ const sentences: TimelineSentence[] = [];
117
+ const start = t;
118
+ for (const [index, text] of texts.entries()) {
119
+ const utterance = take(text);
120
+ sentences.push({
121
+ text,
122
+ kana: utterance.kana,
123
+ start: t,
124
+ end: t + utterance.durationMs,
125
+ });
126
+ t += utterance.durationMs;
127
+ if (index < texts.length - 1) {
128
+ pauseAfterMs.push(pause.sentence);
129
+ t += pause.sentence;
130
+ } else {
131
+ pauseAfterMs.push(beatPause);
132
+ t += beatPause;
133
+ }
134
+ lastClipIndex = pauseAfterMs.length - 1;
135
+ }
136
+ const end = sentences[sentences.length - 1]?.end ?? start;
137
+ beats.push({ position: cue.position, start, end, sentences, lead });
138
+ previousEnd = end;
139
+ }
140
+
141
+ return {
142
+ timeline: {
143
+ audio,
144
+ durationMs: t,
145
+ beats,
146
+ },
147
+ pauseAfterMs,
148
+ leadingMs,
149
+ };
150
+ }
151
+
152
+ /**
153
+ * When each beat's screen change fires: its lead ahead of the voice, but never
154
+ * before the previous change, so a long lead cannot send the screen back.
155
+ */
156
+ export function playbackSchedule(timeline: Timeline): ScheduledGo[] {
157
+ let previousAt = 0;
158
+ return timeline.beats.map((beat) => {
159
+ const lead = Math.max(0, beat.lead ?? DEFAULT_LEAD_MS);
160
+ const at = Math.max(previousAt, beat.start - lead);
161
+ previousAt = at;
162
+ return { at, position: beat.position };
163
+ });
164
+ }
165
+
166
+ export function timelineSeconds(timeline: Timeline): number {
167
+ return Math.round(timeline.durationMs / 1000);
168
+ }
169
+
170
+ export function slideTimeRange(
171
+ timeline: Timeline,
172
+ slideIndex: number,
173
+ ): { start: number; end: number } | undefined {
174
+ const beats = timeline.beats.filter((beat) => beat.position.slideIndex === slideIndex);
175
+ if (beats.length === 0) {
176
+ return undefined;
177
+ }
178
+ const start = beats[0]?.start ?? 0;
179
+ const next = timeline.beats.find((beat) => beat.position.slideIndex > slideIndex);
180
+ const end = next?.start ?? timeline.durationMs;
181
+ return { start, end };
182
+ }
183
+
184
+ export function slideVideoSeconds(timeline: Timeline, slideIndex: number): number | undefined {
185
+ const range = slideTimeRange(timeline, slideIndex);
186
+ if (!range) {
187
+ return undefined;
188
+ }
189
+ return Math.round((range.end - range.start) / 1000);
190
+ }
191
+
192
+ export function sliceTimeline(timeline: Timeline, slideIndex: number): Timeline {
193
+ const range = slideTimeRange(timeline, slideIndex);
194
+ if (!range) {
195
+ throw new DekError(`no timeline beats for slide ${slideIndex}`, {
196
+ hint: "run `dekc voice`",
197
+ });
198
+ }
199
+ const offset = range.start;
200
+ return {
201
+ audio: timeline.audio,
202
+ durationMs: Math.max(0, range.end - offset),
203
+ beats: timeline.beats
204
+ .filter((beat) => beat.position.slideIndex === slideIndex)
205
+ .map((beat) => ({
206
+ ...beat,
207
+ start: beat.start - offset,
208
+ end: beat.end - offset,
209
+ sentences: beat.sentences.map((sentence) => ({
210
+ ...sentence,
211
+ start: sentence.start - offset,
212
+ end: sentence.end - offset,
213
+ })),
214
+ })),
215
+ };
216
+ }
@@ -0,0 +1,88 @@
1
+ import type { DekConfig } from "./config.ts";
2
+
3
+ type SectionBodies = {
4
+ body: string;
5
+ beats: Array<{ body: string }>;
6
+ };
7
+
8
+ function stripBlockquotes(markdown: string): string {
9
+ return markdown
10
+ .split(/\r?\n/)
11
+ .filter((line) => !/^\s*>/.test(line))
12
+ .join("\n");
13
+ }
14
+
15
+ export function joinSectionBodies(section: SectionBodies, separator: string): string {
16
+ return [section.body, ...section.beats.map((beat) => beat.body)].filter(Boolean).join(separator);
17
+ }
18
+
19
+ export function speechText(section: SectionBodies): string {
20
+ return joinSectionBodies(section, "\n");
21
+ }
22
+
23
+ export function formatSectionScript(section: SectionBodies): string {
24
+ return joinSectionBodies(section, "\n\n");
25
+ }
26
+
27
+ function speechChars(text: string): number {
28
+ return stripBlockquotes(text).replace(/\s+/g, "").length;
29
+ }
30
+
31
+ function estimateSeconds(text: string, config: DekConfig): number {
32
+ const stripped = stripBlockquotes(text);
33
+ const cjk = [...stripped.matchAll(/[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}]/gu)]
34
+ .length;
35
+ const rest = stripped.replace(/[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}]/gu, " ");
36
+ const words = rest.split(/\s+/).filter(Boolean).length;
37
+ const cjkMinutes = config.cjkPerMinute > 0 ? cjk / config.cjkPerMinute : 0;
38
+ const latinMinutes = config.latinPerMinute > 0 ? words / config.latinPerMinute : 0;
39
+ return Math.round((cjkMinutes + latinMinutes) * 60);
40
+ }
41
+
42
+ export function parseDurationSeconds(duration?: string): number | undefined {
43
+ if (!duration) {
44
+ return undefined;
45
+ }
46
+ const match = duration.match(/^(\d+)m$/);
47
+ if (!match) {
48
+ return undefined;
49
+ }
50
+ return Number(match[1]) * 60;
51
+ }
52
+
53
+ export function formatClock(totalSeconds: number): string {
54
+ const seconds = Math.max(0, Math.floor(totalSeconds));
55
+ const minutes = Math.floor(seconds / 60);
56
+ return `${minutes}:${String(seconds % 60).padStart(2, "0")}`;
57
+ }
58
+
59
+ export type TimedSection = {
60
+ slug: string;
61
+ estimateSeconds: number;
62
+ budgetSeconds?: number;
63
+ };
64
+
65
+ export function sectionTiming(
66
+ sections: Array<{ slug: string; body: string; beats: Array<{ body: string }> }>,
67
+ duration: string | undefined,
68
+ config: DekConfig,
69
+ ): TimedSection[] {
70
+ const rows = sections.map((section) => {
71
+ const text = speechText(section);
72
+ return {
73
+ slug: section.slug,
74
+ chars: speechChars(text),
75
+ estimateSeconds: estimateSeconds(text, config),
76
+ };
77
+ });
78
+ const totalChars = rows.reduce((sum, row) => sum + row.chars, 0);
79
+ const durationSeconds = parseDurationSeconds(duration);
80
+
81
+ return rows.map((row) => ({
82
+ slug: row.slug,
83
+ estimateSeconds: row.estimateSeconds,
84
+ ...(durationSeconds !== undefined && totalChars > 0
85
+ ? { budgetSeconds: Math.round((durationSeconds * row.chars) / totalChars) }
86
+ : {}),
87
+ }));
88
+ }
@@ -0,0 +1,349 @@
1
+ /** One key segment as written on its line: `a`, `"b/c"`, or `'d'`. */
2
+ type KeySegment = { start: number; end: number; key: string; quote: string };
3
+
4
+ /** One key or table header of a TOML file, where it is written. */
5
+ export type TomlKey = {
6
+ /** `[table]`, `[[array]]`, a `key =` line, or a key inside an inline table `{ key = … }`. */
7
+ kind: "table" | "array" | "key" | "inline";
8
+ /** The full dotted path: the table it is under, then the key. */
9
+ path: string[];
10
+ /** The path as written on the line; for a key, without the table it is under. */
11
+ segments: KeySegment[];
12
+ /** 0-based index of the line in `source.split("\n")`; `line` is 1-based. */
13
+ index: number;
14
+ line: number;
15
+ };
16
+
17
+ /**
18
+ * Every key and table header in a TOML file, in order, with where each is written: the one reader
19
+ * dek.toml lint, `dekc mv`, and `dekc ref` share. It reads `[table]`, `[[array]]`, (dotted) keys, and
20
+ * one-line inline tables, and steps over comments and multi-line strings.
21
+ */
22
+ export function scanTomlKeys(source: string): TomlKey[] {
23
+ const lines = source.split("\n");
24
+ const keys: TomlKey[] = [];
25
+ let table: string[] = [];
26
+ for (let index = 0; index < lines.length; index++) {
27
+ const line = lines[index] ?? "";
28
+ const header = readHeader(line);
29
+ if (header) {
30
+ table = names(header.segments);
31
+ keys.push(keyAt(header.kind, table, header.segments, index));
32
+ continue;
33
+ }
34
+ // Blank lines, comments, and anything else that is not `key = value` fail here.
35
+ const assignment = readAssignment(line, 0);
36
+ if (!assignment) {
37
+ continue;
38
+ }
39
+ const path = [...table, ...names(assignment.segments)];
40
+ keys.push(keyAt("key", path, assignment.segments, index));
41
+ if (line[assignment.value] === "{") {
42
+ inlineKeys(line, assignment.value, path, index, keys);
43
+ }
44
+ index = valueLastLine(lines, index, assignment.value);
45
+ }
46
+ return keys;
47
+ }
48
+
49
+ function keyAt(
50
+ kind: TomlKey["kind"],
51
+ path: string[],
52
+ segments: KeySegment[],
53
+ index: number,
54
+ ): TomlKey {
55
+ return { kind, path, segments, index, line: index + 1 };
56
+ }
57
+
58
+ function names(segments: KeySegment[]): string[] {
59
+ return segments.map((segment) => segment.key);
60
+ }
61
+
62
+ /** A `[table]` or `[[array]]` header line. */
63
+ function readHeader(line: string): { kind: "table" | "array"; segments: KeySegment[] } | undefined {
64
+ const open = skipSpace(line, 0);
65
+ if (line[open] !== "[") {
66
+ return undefined;
67
+ }
68
+ const kind = line[open + 1] === "[" ? "array" : "table";
69
+ const path = readKeyPath(line, open + (kind === "array" ? 2 : 1));
70
+ return path && line[path.end] === "]" ? { kind, segments: path.segments } : undefined;
71
+ }
72
+
73
+ /** A `key =` from `from`; `value` is where its value starts. */
74
+ function readAssignment(
75
+ line: string,
76
+ from: number,
77
+ ): { segments: KeySegment[]; value: number } | undefined {
78
+ const path = readKeyPath(line, from);
79
+ if (!path || line[path.end] !== "=") {
80
+ return undefined;
81
+ }
82
+ return { segments: path.segments, value: skipSpace(line, path.end + 1) };
83
+ }
84
+
85
+ /** The keys of the inline table opening at `open`, nested ones too; returns the index past it. */
86
+ function inlineKeys(
87
+ line: string,
88
+ open: number,
89
+ prefix: string[],
90
+ index: number,
91
+ keys: TomlKey[],
92
+ ): number {
93
+ let i = skipSpace(line, open + 1);
94
+ while (i < line.length && line[i] !== "}") {
95
+ const assignment = readAssignment(line, i);
96
+ if (!assignment) {
97
+ return line.length;
98
+ }
99
+ const path = [...prefix, ...names(assignment.segments)];
100
+ keys.push(keyAt("inline", path, assignment.segments, index));
101
+ const { value } = assignment;
102
+ i = skipSpace(
103
+ line,
104
+ line[value] === "{" ? inlineKeys(line, value, path, index, keys) : skipValue(line, value),
105
+ );
106
+ if (line[i] === ",") {
107
+ i = skipSpace(line, i + 1);
108
+ }
109
+ }
110
+ return i + 1;
111
+ }
112
+
113
+ const NESTING: Record<string, number> = { "[": 1, "{": 1, "]": -1, "}": -1 };
114
+ const VALUE_ENDS = new Set([",", "#", "]", "}"]);
115
+
116
+ /** The index past one value on a line: a string, an array or inline table, or a bare word. */
117
+ function skipValue(line: string, from: number): number {
118
+ let depth = 0;
119
+ let i = from;
120
+ while (i < line.length) {
121
+ const ch = line[i] ?? "";
122
+ if (depth === 0 && VALUE_ENDS.has(ch)) {
123
+ return i;
124
+ }
125
+ depth += NESTING[ch] ?? 0;
126
+ i = isQuote(ch) ? skipString(line, i) : i + 1;
127
+ }
128
+ return i;
129
+ }
130
+
131
+ /**
132
+ * The line the value starting at `start` ends on: its own, unless it opens a multi-line string it
133
+ * does not close there. An unclosed one runs to the end of the file.
134
+ */
135
+ function valueLastLine(lines: string[], index: number, start: number): number {
136
+ const line = lines[index] ?? "";
137
+ const delimiter = tripleQuote(line, start);
138
+ if (!delimiter || line.includes(delimiter, start + 3)) {
139
+ return index;
140
+ }
141
+ const close = lines.findIndex((next, i) => i > index && next.includes(delimiter));
142
+ return close === -1 ? lines.length - 1 : close;
143
+ }
144
+
145
+ /** Reads `a . "b" . 'c'` from `from`; `end` is the first non-space after it. */
146
+ function readKeyPath(
147
+ line: string,
148
+ from: number,
149
+ ): { segments: KeySegment[]; end: number } | undefined {
150
+ const segments: KeySegment[] = [];
151
+ let i = skipSpace(line, from);
152
+ for (;;) {
153
+ const segment = readKeySegment(line, i);
154
+ if (!segment) {
155
+ return undefined;
156
+ }
157
+ segments.push(segment);
158
+ i = skipSpace(line, segment.end);
159
+ if (line[i] !== ".") {
160
+ return { segments, end: i };
161
+ }
162
+ i = skipSpace(line, i + 1);
163
+ }
164
+ }
165
+
166
+ /**
167
+ * One key segment. A quoted key with an escape keeps its raw text as `key`,
168
+ * so it matches nothing a caller renames.
169
+ */
170
+ function readKeySegment(line: string, i: number): KeySegment | undefined {
171
+ const quote = line[i] ?? "";
172
+ if (isQuote(quote)) {
173
+ const end = skipString(line, i);
174
+ return end > line.length
175
+ ? undefined
176
+ : { start: i, end, key: line.slice(i + 1, end - 1), quote };
177
+ }
178
+ const bare = /^[A-Za-z0-9_-]+/.exec(line.slice(i));
179
+ return bare ? { start: i, end: i + bare[0].length, key: bare[0], quote: "" } : undefined;
180
+ }
181
+
182
+ /** The index past the string opening at `open`; past the end of the line when it does not close. */
183
+ function skipString(line: string, open: number): number {
184
+ const quote = line[open];
185
+ let j = open + 1;
186
+ while (j < line.length && line[j] !== quote) {
187
+ j += quote === '"' && line[j] === "\\" ? 2 : 1;
188
+ }
189
+ return j + 1;
190
+ }
191
+
192
+ function isQuote(ch: string | undefined): boolean {
193
+ return ch === '"' || ch === "'";
194
+ }
195
+
196
+ /** The `"""` or `'''` opening a multi-line string at `at`, if one does. */
197
+ function tripleQuote(line: string, at: number): string | undefined {
198
+ const delimiter = line.slice(at, at + 3);
199
+ return delimiter === '"""' || delimiter === "'''" ? delimiter : undefined;
200
+ }
201
+
202
+ function skipSpace(line: string, i: number): number {
203
+ let j = i;
204
+ while (line[j] === " " || line[j] === "\t") {
205
+ j++;
206
+ }
207
+ return j;
208
+ }
209
+
210
+ /**
211
+ * Renames the keys directly under one TOML table in place, keeping the rest
212
+ * of the file byte for byte. It leaves values alone; a layout it does not
213
+ * rewrite, such as an inline table, is left unchanged for the caller to
214
+ * detect.
215
+ */
216
+ export function renameTableKeys(
217
+ source: string,
218
+ table: string,
219
+ rename: (key: string) => string | undefined,
220
+ ): string {
221
+ const lines = source.split("\n");
222
+ for (const key of scanTomlKeys(source)) {
223
+ if (key.path[0] !== table || key.path.length < 2) {
224
+ continue;
225
+ }
226
+ // The segment naming the key under `table`: the second of the full path, wherever it is written.
227
+ const at = key.segments.length - (key.path.length - 1);
228
+ if (key.kind !== "inline" && at >= 0) {
229
+ lines[key.index] = renameSegment(lines[key.index] ?? "", key.segments[at], rename);
230
+ }
231
+ }
232
+ return lines.join("\n");
233
+ }
234
+
235
+ function renameSegment(
236
+ line: string,
237
+ segment: KeySegment | undefined,
238
+ rename: (key: string) => string | undefined,
239
+ ): string {
240
+ const next = segment && rename(segment.key);
241
+ if (!segment || next === undefined || next === segment.key) {
242
+ return line;
243
+ }
244
+ return `${line.slice(0, segment.start)}${segment.quote}${next}${segment.quote}${line.slice(segment.end)}`;
245
+ }
246
+
247
+ /** Where a `[table]` is: its header line, the line of the next header, and the keys between. */
248
+ type Section = { header: number; end: number; keys: TomlKey[] };
249
+
250
+ /**
251
+ * Sets one string key directly under a TOML table, or removes it when `value`
252
+ * is undefined, keeping every other line byte for byte. A missing table is
253
+ * added at the end; a table left with no keys and nothing but blank lines is
254
+ * removed.
255
+ */
256
+ export function setTableKey(
257
+ source: string,
258
+ table: string,
259
+ key: string,
260
+ value: string | undefined,
261
+ ): string {
262
+ const lines = source.split("\n");
263
+ const section = findSection(scanTomlKeys(source), table, lines.length);
264
+ const entry = `${JSON.stringify(key)} = ${JSON.stringify(value)}`;
265
+ if (!section) {
266
+ return value === undefined ? source : appendTable(source, table, entry);
267
+ }
268
+ const found = section.keys.find(
269
+ (scanned) => scanned.segments.length === 1 && scanned.segments[0]?.key === key,
270
+ )?.index;
271
+ if (value === undefined) {
272
+ return found === undefined ? source : removeKey(lines, section, found);
273
+ }
274
+ if (found === undefined) {
275
+ const last = section.keys.at(-1);
276
+ const after = last ? valueSpan(lines, last.index).last : section.header;
277
+ lines.splice(after + 1, 0, entry);
278
+ } else {
279
+ replaceValue(lines, found, JSON.stringify(value));
280
+ }
281
+ return lines.join("\n");
282
+ }
283
+
284
+ function findSection(scanned: TomlKey[], table: string, lineCount: number): Section | undefined {
285
+ const header = scanned.find(
286
+ (entry) => entry.kind === "table" && entry.path.length === 1 && entry.path[0] === table,
287
+ )?.index;
288
+ if (header === undefined) {
289
+ return undefined;
290
+ }
291
+ const end =
292
+ scanned.find(
293
+ (entry) => (entry.kind === "table" || entry.kind === "array") && entry.index > header,
294
+ )?.index ?? lineCount;
295
+ const keys = scanned.filter(
296
+ (entry) => entry.kind === "key" && entry.index > header && entry.index < end,
297
+ );
298
+ return { header, end, keys };
299
+ }
300
+
301
+ function appendTable(source: string, table: string, entry: string): string {
302
+ const body = source.replace(/\n+$/, "");
303
+ return `${body}${body ? "\n\n" : ""}[${table}]\n${entry}\n`;
304
+ }
305
+
306
+ /** Removes the key on line `found`, and its table too when nothing but blank lines would remain. */
307
+ function removeKey(lines: string[], section: Section, found: number): string {
308
+ const { header, end } = section;
309
+ const { last } = valueSpan(lines, found);
310
+ const rest = [...lines.slice(header + 1, found), ...lines.slice(last + 1, end)];
311
+ if (rest.some((line) => line.trim() !== "")) {
312
+ lines.splice(found, last - found + 1);
313
+ return lines.join("\n");
314
+ }
315
+ const atEnd = end === lines.length;
316
+ lines.splice(header, end - header);
317
+ const joined = lines.join("\n");
318
+ return atEnd ? `${joined.replace(/\n+$/, "")}\n` : joined;
319
+ }
320
+
321
+ /** Where the value of the key on line `index` starts, and the line it ends on. */
322
+ function valueSpan(lines: string[], index: number): { start: number; last: number } {
323
+ const line = lines[index] ?? "";
324
+ const start = readAssignment(line, 0)?.value ?? line.length;
325
+ return { start, last: valueLastLine(lines, index, start) };
326
+ }
327
+
328
+ /** Replaces the value of the key on line `index`, keeping its indent and a trailing comment. */
329
+ function replaceValue(lines: string[], index: number, value: string): void {
330
+ const { start, last } = valueSpan(lines, index);
331
+ // A multi-line value collapses onto its first line.
332
+ const text = lines.slice(index, last + 1).join("\n");
333
+ const replaced = `${text.slice(0, start)}${value}${text.slice(valueEnd(text, start))}`;
334
+ lines.splice(index, last - index + 1, replaced);
335
+ }
336
+
337
+ /** The index past the value at `start`: a whole string, or a bare value up to its comment. */
338
+ function valueEnd(text: string, start: number): number {
339
+ const delimiter = tripleQuote(text, start);
340
+ if (delimiter) {
341
+ const close = text.indexOf(delimiter, start + 3);
342
+ return close === -1 ? text.length : close + 3;
343
+ }
344
+ if (isQuote(text[start])) {
345
+ return skipString(text, start);
346
+ }
347
+ const comment = text.indexOf("#", start);
348
+ return start + text.slice(start, comment === -1 ? text.length : comment).trimEnd().length;
349
+ }