@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
package/src/cli/qr.ts ADDED
@@ -0,0 +1,466 @@
1
+ /**
2
+ * A QR code for a short URL, drawn in the terminal: byte mode, error correction level M,
3
+ * versions 1 to 10 (up to 213 bytes). dek needs one only for `--remote`'s pairing link, which
4
+ * is far shorter, so it carries its own encoder rather than a dependency. It follows ISO/IEC
5
+ * 18004 as laid out by Project Nayuki's reference implementation.
6
+ */
7
+
8
+ /** Rows of modules, `true` for dark, without the quiet zone around them. */
9
+ export type QrMatrix = boolean[][];
10
+
11
+ type Position = readonly [x: number, y: number];
12
+
13
+ interface Version {
14
+ readonly number: number;
15
+ /** Data and error correction codewords together. */
16
+ readonly codewords: number;
17
+ readonly eccPerBlock: number;
18
+ readonly blocks: number;
19
+ /** Alignment pattern centers, the same on both axes. */
20
+ readonly alignment: readonly number[];
21
+ }
22
+
23
+ /** Level M only. */
24
+ const VERSIONS: readonly Version[] = [
25
+ { number: 1, codewords: 26, eccPerBlock: 10, blocks: 1, alignment: [] },
26
+ { number: 2, codewords: 44, eccPerBlock: 16, blocks: 1, alignment: [6, 18] },
27
+ { number: 3, codewords: 70, eccPerBlock: 26, blocks: 1, alignment: [6, 22] },
28
+ { number: 4, codewords: 100, eccPerBlock: 18, blocks: 2, alignment: [6, 26] },
29
+ { number: 5, codewords: 134, eccPerBlock: 24, blocks: 2, alignment: [6, 30] },
30
+ { number: 6, codewords: 172, eccPerBlock: 16, blocks: 4, alignment: [6, 34] },
31
+ { number: 7, codewords: 196, eccPerBlock: 18, blocks: 4, alignment: [6, 22, 38] },
32
+ { number: 8, codewords: 242, eccPerBlock: 22, blocks: 4, alignment: [6, 24, 42] },
33
+ { number: 9, codewords: 292, eccPerBlock: 22, blocks: 5, alignment: [6, 26, 46] },
34
+ { number: 10, codewords: 346, eccPerBlock: 26, blocks: 5, alignment: [6, 28, 50] },
35
+ ];
36
+
37
+ /** `mask` fixes the mask pattern, for tests; otherwise the one with the lowest penalty wins. */
38
+ export function qrMatrix(text: string, options: { mask?: number } = {}): QrMatrix {
39
+ const bytes = [...new TextEncoder().encode(text)];
40
+ const version = smallestVersion(bytes.length);
41
+ const grid = new Grid(version);
42
+ grid.drawCodewords(withErrorCorrection(dataCodewords(bytes, version), version));
43
+ const mask = options.mask ?? lowestPenaltyMask(grid);
44
+ grid.applyMask(mask);
45
+ grid.drawFormatBits(mask);
46
+ return grid.modules;
47
+ }
48
+
49
+ function smallestVersion(byteCount: number): Version {
50
+ const version = VERSIONS.find(
51
+ (candidate) => 4 + countBits(candidate) + byteCount * 8 <= dataCapacity(candidate) * 8,
52
+ );
53
+ if (!version) {
54
+ throw new Error(`text too long for a QR code: ${byteCount} bytes`);
55
+ }
56
+ return version;
57
+ }
58
+
59
+ function dataCapacity(version: Version): number {
60
+ return version.codewords - version.eccPerBlock * version.blocks;
61
+ }
62
+
63
+ /** Width of byte mode's character count field. */
64
+ function countBits(version: Version): number {
65
+ return version.number < 10 ? 8 : 16;
66
+ }
67
+
68
+ function dataCodewords(bytes: readonly number[], version: Version): number[] {
69
+ const capacity = dataCapacity(version);
70
+ const segment = [
71
+ ...bitsOf(0b0100, 4),
72
+ ...bitsOf(bytes.length, countBits(version)),
73
+ ...bytes.flatMap((byte) => bitsOf(byte, 8)),
74
+ ];
75
+ const terminated = [...segment, ...bitsOf(0, Math.min(4, capacity * 8 - segment.length))];
76
+ const bits = [...terminated, ...bitsOf(0, (8 - (terminated.length % 8)) % 8)];
77
+ const words = range(0, bits.length / 8).map((i) =>
78
+ bits.slice(i * 8, i * 8 + 8).reduce((word, bit) => (word << 1) | bit, 0),
79
+ );
80
+ const padding = range(words.length, capacity).map((i) =>
81
+ (i - words.length) % 2 === 0 ? 0xec : 0x11,
82
+ );
83
+ return [...words, ...padding];
84
+ }
85
+
86
+ /** The low `length` bits of `value`, most significant first. */
87
+ function bitsOf(value: number, length: number): number[] {
88
+ return range(0, length).map((i) => (value >>> (length - 1 - i)) & 1);
89
+ }
90
+
91
+ /** Splits the data into blocks, appends each block's Reed-Solomon codewords, and interleaves. */
92
+ function withErrorCorrection(data: readonly number[], version: Version): number[] {
93
+ const blocks = splitBlocks(data, version);
94
+ const divisor = rsDivisor(version.eccPerBlock);
95
+ return [...interleave(blocks), ...interleave(blocks.map((block) => rsRemainder(block, divisor)))];
96
+ }
97
+
98
+ /** The short blocks come first; the rest hold one more data codeword each. */
99
+ function splitBlocks(data: readonly number[], version: Version): number[][] {
100
+ const shortBlocks = version.blocks - (version.codewords % version.blocks);
101
+ const shortLength = Math.floor(version.codewords / version.blocks) - version.eccPerBlock;
102
+ return range(0, version.blocks).map((i) => {
103
+ const start = i * shortLength + Math.max(0, i - shortBlocks);
104
+ return data.slice(start, start + shortLength + (i < shortBlocks ? 0 : 1));
105
+ });
106
+ }
107
+
108
+ /** The first codeword of every block, then every second, and so on; short blocks run out first. */
109
+ function interleave(blocks: ReadonlyArray<readonly number[]>): number[] {
110
+ const longest = Math.max(0, ...blocks.map((block) => block.length));
111
+ return range(0, longest).flatMap((i) => blocks.flatMap((block) => block[i] ?? []));
112
+ }
113
+
114
+ /** Multiplication in GF(2^8) modulo the QR field's polynomial, 0x11d. */
115
+ function gfMultiply(x: number, y: number): number {
116
+ let z = 0;
117
+ for (let i = 7; i >= 0; i--) {
118
+ z = (z << 1) ^ ((z >>> 7) * 0x11d);
119
+ z ^= ((y >>> i) & 1) * x;
120
+ }
121
+ return z;
122
+ }
123
+
124
+ /** The generator polynomial of the given degree, highest power first, its leading 1 left out. */
125
+ function rsDivisor(degree: number): number[] {
126
+ let coefficients: number[] = range(0, degree).map((i) => (i === degree - 1 ? 1 : 0));
127
+ let root = 1;
128
+ for (let i = 0; i < degree; i++) {
129
+ const previous = coefficients;
130
+ coefficients = previous.map((c, j) => gfMultiply(c, root) ^ (previous[j + 1] ?? 0));
131
+ root = gfMultiply(root, 0x02);
132
+ }
133
+ return coefficients;
134
+ }
135
+
136
+ function rsRemainder(data: readonly number[], divisor: readonly number[]): number[] {
137
+ let remainder = divisor.map(() => 0);
138
+ for (const byte of data) {
139
+ const [lead = 0, ...rest] = remainder;
140
+ const factor = byte ^ lead;
141
+ remainder = divisor.map((coefficient, i) => (rest[i] ?? 0) ^ gfMultiply(coefficient, factor));
142
+ }
143
+ return remainder;
144
+ }
145
+
146
+ /**
147
+ * Appends the BCH check bits that format and version information carry, computed by
148
+ * long division by `generator`, a polynomial of the given degree.
149
+ */
150
+ function withBch(data: number, degree: number, generator: number): number {
151
+ let remainder = data;
152
+ for (let i = 0; i < degree; i++) {
153
+ remainder = (remainder << 1) ^ ((remainder >>> (degree - 1)) * generator);
154
+ }
155
+ return (data << degree) | remainder;
156
+ }
157
+
158
+ type MaskPattern = (x: number, y: number) => boolean;
159
+
160
+ const MASKS: readonly MaskPattern[] = [
161
+ (x, y) => (x + y) % 2 === 0,
162
+ (_x, y) => y % 2 === 0,
163
+ (x) => x % 3 === 0,
164
+ (x, y) => (x + y) % 3 === 0,
165
+ (x, y) => (Math.floor(x / 3) + Math.floor(y / 2)) % 2 === 0,
166
+ (x, y) => ((x * y) % 2) + ((x * y) % 3) === 0,
167
+ (x, y) => (((x * y) % 2) + ((x * y) % 3)) % 2 === 0,
168
+ (x, y) => (((x + y) % 2) + ((x * y) % 3)) % 2 === 0,
169
+ ];
170
+
171
+ function maskPattern(mask: number): MaskPattern {
172
+ const pattern = MASKS[mask];
173
+ if (!pattern) {
174
+ throw new RangeError(`no QR mask ${mask}; masks run from 0 to 7`);
175
+ }
176
+ return pattern;
177
+ }
178
+
179
+ class Grid {
180
+ readonly size: number;
181
+ readonly modules: QrMatrix;
182
+ /** Function modules, which neither data nor masks touch. */
183
+ private readonly reserved: boolean[][];
184
+
185
+ constructor(private readonly version: Version) {
186
+ this.size = version.number * 4 + 17;
187
+ this.modules = square(this.size);
188
+ this.reserved = square(this.size);
189
+ // Later patterns overwrite earlier ones where they cross, as the standard draws them.
190
+ this.drawTimingPatterns();
191
+ this.drawFinderPatterns();
192
+ this.drawAlignmentPatterns();
193
+ // Reserve the format areas now; drawFormatBits fills them for each mask.
194
+ this.drawFormatBits(0);
195
+ this.drawVersionInfo();
196
+ }
197
+
198
+ private setFunction(x: number, y: number, dark: boolean): void {
199
+ setCell(this.modules, x, y, dark);
200
+ setCell(this.reserved, x, y, true);
201
+ }
202
+
203
+ private isReserved(x: number, y: number): boolean {
204
+ return this.reserved[y]?.[x] === true;
205
+ }
206
+
207
+ private drawTimingPatterns(): void {
208
+ for (let i = 0; i < this.size; i++) {
209
+ this.setFunction(6, i, i % 2 === 0);
210
+ this.setFunction(i, 6, i % 2 === 0);
211
+ }
212
+ }
213
+
214
+ /** Each finder comes with its light separator, which runs off the edge and is clipped there. */
215
+ private drawFinderPatterns(): void {
216
+ const far = this.size - 4;
217
+ for (const [x, y] of [
218
+ [3, 3],
219
+ [far, 3],
220
+ [3, far],
221
+ ] as const) {
222
+ this.drawRings(x, y, 4, (ring) => ring !== 2 && ring !== 4);
223
+ }
224
+ }
225
+
226
+ private drawAlignmentPatterns(): void {
227
+ for (const [x, y] of alignmentCenters(this.version.alignment)) {
228
+ this.drawRings(x, y, 2, (ring) => ring !== 1);
229
+ }
230
+ }
231
+
232
+ /** Paints the square of `radius` around a center, choosing each ring's color by its distance. */
233
+ private drawRings(cx: number, cy: number, radius: number, dark: (ring: number) => boolean): void {
234
+ for (let dy = -radius; dy <= radius; dy++) {
235
+ for (let dx = -radius; dx <= radius; dx++) {
236
+ this.setFunction(cx + dx, cy + dy, dark(Math.max(Math.abs(dx), Math.abs(dy))));
237
+ }
238
+ }
239
+ }
240
+
241
+ /** Level M is 00, so the format data is just the mask. */
242
+ drawFormatBits(mask: number): void {
243
+ const bits = withBch(mask, 10, 0x537) ^ 0x5412;
244
+ for (const copy of formatPositions(this.size)) {
245
+ for (const [i, [x, y]] of copy.entries()) {
246
+ this.setFunction(x, y, ((bits >>> i) & 1) === 1);
247
+ }
248
+ }
249
+ // The one module that is always dark.
250
+ this.setFunction(8, this.size - 8, true);
251
+ }
252
+
253
+ /** Versions 7 and up spell out their number in two 6x3 blocks by the far finders. */
254
+ private drawVersionInfo(): void {
255
+ if (this.version.number < 7) {
256
+ return;
257
+ }
258
+ const bits = withBch(this.version.number, 12, 0x1f25);
259
+ for (let i = 0; i < 18; i++) {
260
+ const dark = ((bits >>> i) & 1) === 1;
261
+ const a = this.size - 11 + (i % 3);
262
+ const b = Math.floor(i / 3);
263
+ this.setFunction(a, b, dark);
264
+ this.setFunction(b, a, dark);
265
+ }
266
+ }
267
+
268
+ /** Modules past the last codeword are the remainder bits, left light. */
269
+ drawCodewords(codewords: readonly number[]): void {
270
+ const bits = codewords.flatMap((word) => bitsOf(word, 8));
271
+ const free = placementOrder(this.size).filter(([x, y]) => !this.isReserved(x, y));
272
+ for (const [i, [x, y]] of free.entries()) {
273
+ setCell(this.modules, x, y, bits[i] === 1);
274
+ }
275
+ }
276
+
277
+ /** XORs the mask in, so applying it twice takes it out again. */
278
+ applyMask(mask: number): void {
279
+ const pattern = maskPattern(mask);
280
+ this.modules.forEach((row, y) => {
281
+ row.forEach((dark, x) => {
282
+ if (!this.isReserved(x, y) && pattern(x, y)) {
283
+ row[x] = !dark;
284
+ }
285
+ });
286
+ });
287
+ }
288
+ }
289
+
290
+ function square(size: number): boolean[][] {
291
+ return range(0, size).map(() => new Array<boolean>(size).fill(false));
292
+ }
293
+
294
+ /** Writes outside the grid are dropped, which is what clips a finder's separator. */
295
+ function setCell(rows: boolean[][], x: number, y: number, value: boolean): void {
296
+ const row = rows[y];
297
+ if (row && x >= 0 && x < row.length) {
298
+ row[x] = value;
299
+ }
300
+ }
301
+
302
+ /** Every pairing of the centers, except the three corners the finders already occupy. */
303
+ function alignmentCenters(centers: readonly number[]): Position[] {
304
+ const first = centers[0];
305
+ const last = centers.at(-1);
306
+ const finders = [
307
+ [first, first],
308
+ [first, last],
309
+ [last, first],
310
+ ];
311
+ return centers
312
+ .flatMap((x) => centers.map((y): Position => [x, y]))
313
+ .filter(([x, y]) => !finders.some(([fx, fy]) => fx === x && fy === y));
314
+ }
315
+
316
+ /** Where each of the 15 format bits goes, least significant first, in each of its two copies. */
317
+ function formatPositions(size: number): ReadonlyArray<readonly Position[]> {
318
+ const byFirstFinder: Position[] = [
319
+ ...range(0, 6).map((i): Position => [8, i]),
320
+ [8, 7],
321
+ [8, 8],
322
+ [7, 8],
323
+ ...range(9, 15).map((i): Position => [14 - i, 8]),
324
+ ];
325
+ const byOtherFinders: Position[] = [
326
+ ...range(0, 8).map((i): Position => [size - 1 - i, 8]),
327
+ ...range(8, 15).map((i): Position => [8, size - 15 + i]),
328
+ ];
329
+ return [byFirstFinder, byOtherFinders];
330
+ }
331
+
332
+ /**
333
+ * The zigzag the codeword bits follow: two-module columns from the right edge, alternately
334
+ * upward and downward, skipping the column of the vertical timing pattern.
335
+ */
336
+ function placementOrder(size: number): Position[] {
337
+ const rights = range(0, (size - 1) / 2)
338
+ .map((i) => size - 1 - i * 2)
339
+ .map((right) => (right <= 6 ? right - 1 : right));
340
+ return rights.flatMap((right, i) =>
341
+ range(0, size).flatMap((vert): Position[] => {
342
+ const y = i % 2 === 0 ? size - 1 - vert : vert;
343
+ return [
344
+ [right, y],
345
+ [right - 1, y],
346
+ ];
347
+ }),
348
+ );
349
+ }
350
+
351
+ /** Tries every mask; the first of any tied for the lowest penalty wins. */
352
+ function lowestPenaltyMask(grid: Grid): number {
353
+ const penalties = MASKS.map((_pattern, mask) => {
354
+ grid.applyMask(mask);
355
+ grid.drawFormatBits(mask);
356
+ const score = penalty(grid.modules);
357
+ grid.applyMask(mask);
358
+ return score;
359
+ });
360
+ return penalties.indexOf(Math.min(...penalties));
361
+ }
362
+
363
+ type ReadonlyMatrix = ReadonlyArray<readonly boolean[]>;
364
+
365
+ /** The standard's penalty: long runs, 2x2 blocks, finder look-alikes, and dark balance. */
366
+ function penalty(matrix: ReadonlyMatrix): number {
367
+ const columns = matrix.map((_row, x) => matrix.map((row) => row[x] === true));
368
+ const lines = [...matrix, ...columns];
369
+ return (
370
+ sum(lines.map((line) => runPenalty(line) + finderLikePenalty(line))) +
371
+ blockPenalty(matrix) +
372
+ balancePenalty(matrix)
373
+ );
374
+ }
375
+
376
+ /** A run of five or more modules of one color costs its length less two. */
377
+ function runPenalty(line: readonly boolean[]): number {
378
+ return sum(
379
+ runLengths(line)
380
+ .filter((run) => run >= 5)
381
+ .map((run) => run - 2),
382
+ );
383
+ }
384
+
385
+ function runLengths(line: readonly boolean[]): number[] {
386
+ const runs: number[] = [];
387
+ let previous: boolean | undefined;
388
+ for (const dark of line) {
389
+ runs.push(dark === previous ? (runs.pop() ?? 0) + 1 : 1);
390
+ previous = dark;
391
+ }
392
+ return runs;
393
+ }
394
+
395
+ /** 1:1:3:1:1 with four light modules on one side, which a reader could take for a finder. */
396
+ function finderLikePenalty(line: readonly boolean[]): number {
397
+ const text = line.map((dark) => (dark ? "1" : "0")).join("");
398
+ return 40 * sum(["10111010000", "00001011101"].map((pattern) => occurrences(text, pattern)));
399
+ }
400
+
401
+ /** Overlapping ones count too. */
402
+ function occurrences(text: string, pattern: string): number {
403
+ let count = 0;
404
+ for (let from = text.indexOf(pattern); from >= 0; from = text.indexOf(pattern, from + 1)) {
405
+ count++;
406
+ }
407
+ return count;
408
+ }
409
+
410
+ /** Every 2x2 square of one color, overlapping ones included, costs three. */
411
+ function blockPenalty(matrix: ReadonlyMatrix): number {
412
+ let blocks = 0;
413
+ for (const [y, row] of matrix.entries()) {
414
+ const below = matrix[y + 1] ?? [];
415
+ for (const [x, dark] of row.entries()) {
416
+ if (row[x + 1] === dark && below[x] === dark && below[x + 1] === dark) {
417
+ blocks++;
418
+ }
419
+ }
420
+ }
421
+ return blocks * 3;
422
+ }
423
+
424
+ /** Ten for each whole five percent the share of dark modules strays from half. */
425
+ function balancePenalty(matrix: ReadonlyMatrix): number {
426
+ const modules = matrix.flat();
427
+ const percent = (modules.filter(Boolean).length * 100) / modules.length;
428
+ return Math.floor(Math.abs(percent - 50) / 5) * 10;
429
+ }
430
+
431
+ function sum(values: readonly number[]): number {
432
+ return values.reduce((total, value) => total + value, 0);
433
+ }
434
+
435
+ /** The integers from `from` up to, but not including, `to`. */
436
+ function range(from: number, to: number): number[] {
437
+ return Array.from({ length: Math.max(0, to - from) }, (_, i) => from + i);
438
+ }
439
+
440
+ /**
441
+ * The code in half blocks, two rows of modules per line, inside the four-module quiet zone a
442
+ * reader needs. With color it is black on white whatever the terminal's theme, since a phone
443
+ * reads a light-on-dark code less reliably.
444
+ */
445
+ export function renderQr(matrix: QrMatrix, options: { color: boolean }): string {
446
+ const quiet = 4;
447
+ const size = matrix.length + quiet * 2;
448
+ // Outside the matrix is the quiet zone, including the row past the bottom when size is odd.
449
+ const dark = (x: number, y: number): boolean => matrix[y - quiet]?.[x - quiet] === true;
450
+ return range(0, Math.ceil(size / 2))
451
+ .map((row) => {
452
+ const y = row * 2;
453
+ const line = range(0, size)
454
+ .map((x) => halfBlock(dark(x, y), dark(x, y + 1)))
455
+ .join("");
456
+ return options.color ? `\x1b[38;5;16;48;5;231m${line}\x1b[0m` : line;
457
+ })
458
+ .join("\n");
459
+ }
460
+
461
+ function halfBlock(top: boolean, bottom: boolean): string {
462
+ if (top) {
463
+ return bottom ? "█" : "▀";
464
+ }
465
+ return bottom ? "▄" : " ";
466
+ }
package/src/cli/ref.ts ADDED
@@ -0,0 +1,165 @@
1
+ import { existsSync, lstatSync, readdirSync, readFileSync, rmdirSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { loadConfig } from "../core/config.ts";
4
+ import { DekError } from "../core/error.ts";
5
+ import { downloadTarball, resolveRev } from "../core/github.ts";
6
+ import {
7
+ installSnapshot,
8
+ isPinnedRev,
9
+ parseRefSource,
10
+ type RefSource,
11
+ readTarball,
12
+ refLicense,
13
+ refState,
14
+ refTitle,
15
+ } from "../core/ref.ts";
16
+ import { readTextIfExists } from "../core/resolve.ts";
17
+ import { readSourceIfExists, removeInside, writeInside } from "../core/safe-fs.ts";
18
+ import { writeAgentsMd } from "../core/sync.ts";
19
+ import { setTableKey } from "../core/toml-keys.ts";
20
+ import { requireProject } from "./scope.ts";
21
+
22
+ export type RefAddResult = {
23
+ action: "add";
24
+ name: string;
25
+ rev: string;
26
+ /** The rev it was pinned to before, when this moved the pin. */
27
+ from?: string;
28
+ /** False when the pin and the snapshot were already at `rev`. */
29
+ changed: boolean;
30
+ dir: string;
31
+ license: string | null;
32
+ warnings: string[];
33
+ };
34
+
35
+ export type RefListResult = {
36
+ action: "list";
37
+ refs: Array<{ name: string; rev: string; fetched: boolean; title?: string; slides?: number }>;
38
+ };
39
+
40
+ export type RefRmResult = { action: "rm"; name: string };
41
+
42
+ export type RefCliResult = RefAddResult | RefListResult | RefRmResult;
43
+
44
+ /**
45
+ * Fetches a pinned ref whose snapshot is missing or at another commit, so
46
+ * clearing refs/ or cloning the project never loses one. A ref that is not
47
+ * pinned is left for the read to explain.
48
+ */
49
+ export async function restoreRef(cwd: string, arg: string): Promise<void> {
50
+ const source = parseRefSource(arg);
51
+ const project = requireProject(cwd);
52
+ const { pinned, fetched } = refState(project, source.name);
53
+ if (pinned === undefined || fetched) {
54
+ return;
55
+ }
56
+ await fetchSnapshot(project.root, source, pinned);
57
+ }
58
+
59
+ async function fetchSnapshot(root: string, source: RefSource, sha: string) {
60
+ const files = await readTarball(await downloadTarball(source.owner, source.repo, sha));
61
+ return installSnapshot(root, source, sha, files);
62
+ }
63
+
64
+ /** `dekc ref <source>`: pin and fetch, or move the pin to the latest or to the rev after `@`. */
65
+ export async function addRef(cwd: string, arg: string): Promise<RefAddResult> {
66
+ const source = parseRefSource(arg);
67
+ const project = requireProject(cwd);
68
+ const { pinned: from, dir, fetched } = refState(project, source.name);
69
+ const rev =
70
+ source.rev !== undefined && isPinnedRev(source.rev)
71
+ ? source.rev
72
+ : await resolveRev(source.owner, source.repo, source.rev);
73
+
74
+ let changed = false;
75
+ // The snapshot matches only when it is at `rev`; "fetched" says it is at the old pin.
76
+ if (!(fetched && from === rev)) {
77
+ await fetchSnapshot(project.root, source, rev);
78
+ changed = true;
79
+ }
80
+ if (from !== rev) {
81
+ const toml = readTextIfExists(project.configPath) ?? "";
82
+ writeInside(project.configPath, setTableKey(toml, "refs", source.name, rev), project.root);
83
+ changed = true;
84
+ }
85
+ ignoreRefs(project.root);
86
+ writeAgentsMd(project.root);
87
+
88
+ const license = refLicense(dir);
89
+ return {
90
+ action: "add",
91
+ name: source.name,
92
+ rev,
93
+ ...(from !== undefined && from !== rev ? { from } : {}),
94
+ changed,
95
+ dir,
96
+ license,
97
+ warnings:
98
+ license === null
99
+ ? [
100
+ `${source.owner}/${source.repo} has no license; read it as a model, but ask its author before reusing its text or assets`,
101
+ ]
102
+ : [],
103
+ };
104
+ }
105
+
106
+ export function listRefs(cwd: string): RefListResult {
107
+ const project = requireProject(cwd);
108
+ const refs = Object.keys(loadConfig(project.configPath).refs ?? {}).map((name) => {
109
+ const { pinned: rev = "", dir, fetched } = refState(project, name);
110
+ const summary = fetched ? refTitle(dir, parseRefSource(name).deck) : undefined;
111
+ return { name, rev, fetched, ...summary };
112
+ });
113
+ return { action: "list", refs };
114
+ }
115
+
116
+ export function removeRef(cwd: string, arg: string): RefRmResult {
117
+ const source = parseRefSource(arg);
118
+ const project = requireProject(cwd);
119
+ const { pinned, dir } = refState(project, source.name);
120
+ if (pinned === undefined && !existsSync(dir)) {
121
+ throw new DekError(`ref "${source.name}" is not added`, {
122
+ path: project.configPath,
123
+ hint: "run `dekc ref` to list refs",
124
+ });
125
+ }
126
+ if (pinned !== undefined) {
127
+ const toml = readFileSync(project.configPath, "utf8");
128
+ writeInside(
129
+ project.configPath,
130
+ setTableKey(toml, "refs", source.name, undefined),
131
+ project.root,
132
+ );
133
+ }
134
+ removeInside(dir, project.root, { recursive: true });
135
+ removeEmptyParents(dirname(dir), project.root);
136
+ writeAgentsMd(project.root);
137
+ return { action: "rm", name: source.name };
138
+ }
139
+
140
+ /** refs/owner/repo, refs/owner, and refs itself, while each is left empty. */
141
+ function removeEmptyParents(dir: string, root: string): void {
142
+ let current = dir;
143
+ while (
144
+ current.startsWith(join(root, "refs")) &&
145
+ existsSync(current) &&
146
+ !lstatSync(current).isSymbolicLink()
147
+ ) {
148
+ if (readdirSync(current).length > 0) {
149
+ return;
150
+ }
151
+ rmdirSync(current);
152
+ current = dirname(current);
153
+ }
154
+ }
155
+
156
+ /** Snapshots are fetched again from dek.toml, so git never needs them. */
157
+ function ignoreRefs(root: string): void {
158
+ const path = join(root, ".gitignore");
159
+ const current = readSourceIfExists(path, root) ?? "";
160
+ if (/^\/?refs\/?$/m.test(current)) {
161
+ return;
162
+ }
163
+ const separator = current === "" || current.endsWith("\n") ? "" : "\n";
164
+ writeInside(path, `${current}${separator}refs/\n`, root);
165
+ }
@@ -0,0 +1,42 @@
1
+ import type { DevServer } from "../server/dev.ts";
2
+ import { remoteBanner } from "../server/lan.ts";
3
+ import { type DeckTarget, requireSection } from "./scope.ts";
4
+ import { runDevSession } from "./serve.ts";
5
+
6
+ export async function rehearseCommand(
7
+ { deck }: DeckTarget,
8
+ options: { slug?: string; remote?: boolean },
9
+ ): Promise<void> {
10
+ const { slug } = options;
11
+ // A typo would start at the first slide as if nothing was asked.
12
+ if (slug) {
13
+ requireSection(deck, slug);
14
+ }
15
+ await runDevSession({ cwd: deck.dir, remote: options.remote === true }, (server, password) =>
16
+ rehearseSessionView(server, password, slug),
17
+ );
18
+ }
19
+
20
+ /** What `dekc rehearse` prints once up: the deck in rehearsal, at `slug` when one is named. */
21
+ export function rehearseSessionView(
22
+ server: Pick<DevServer, "url" | "remoteUrls">,
23
+ password: string | undefined,
24
+ slug?: string,
25
+ ): { banner: string; pairPath: string } {
26
+ // Every address opens the rehearsal, so the loopback one is not listed again as the plain deck.
27
+ const rehearsal = (base: string): string => {
28
+ const url = new URL(base);
29
+ url.searchParams.set("rehearse", "");
30
+ if (slug) {
31
+ url.hash = slug;
32
+ }
33
+ return url.toString();
34
+ };
35
+ return {
36
+ banner: remoteBanner(rehearsal(server.url), server.remoteUrls.map(rehearsal), {
37
+ password,
38
+ presenterPaths: ["presenter"],
39
+ }),
40
+ pairPath: "presenter",
41
+ };
42
+ }