@hajimism/dek 0.3.3 → 0.5.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 (105) hide show
  1. package/CHANGELOG.md +136 -0
  2. package/README.ja.md +2 -2
  3. package/README.md +2 -2
  4. package/package.json +5 -3
  5. package/skills/dek/SKILL.md +10 -3
  6. package/skills/dek-design/SKILL.md +42 -0
  7. package/src/cli/address.ts +95 -18
  8. package/src/cli/build.ts +2 -2
  9. package/src/cli/check.ts +2 -1
  10. package/src/cli/commands.ts +57 -19
  11. package/src/cli/contract.ts +58 -4
  12. package/src/cli/files.ts +2 -5
  13. package/src/cli/flags.ts +1 -1
  14. package/src/cli/format.ts +5 -4
  15. package/src/cli/goto.ts +44 -11
  16. package/src/cli/init.ts +10 -5
  17. package/src/cli/lint.ts +36 -16
  18. package/src/cli/ls.ts +5 -1
  19. package/src/cli/main.ts +51 -14
  20. package/src/cli/new.ts +28 -8
  21. package/src/cli/pptx.ts +9 -4
  22. package/src/cli/ref.ts +92 -9
  23. package/src/cli/result.ts +47 -6
  24. package/src/cli/scope.ts +57 -29
  25. package/src/cli/shot.ts +78 -6
  26. package/src/cli/show.ts +15 -2
  27. package/src/cli/text.ts +88 -11
  28. package/src/cli/theme.ts +58 -10
  29. package/src/cli/tty.ts +16 -2
  30. package/src/cli/voice.ts +15 -9
  31. package/src/core/alignment.ts +320 -0
  32. package/src/core/annotation-rows.ts +16 -4
  33. package/src/core/annotations.ts +19 -1
  34. package/src/core/assets.ts +18 -10
  35. package/src/core/attribute-value.ts +188 -0
  36. package/src/core/build.ts +3 -3
  37. package/src/core/config.ts +61 -12
  38. package/src/core/css-scan.ts +173 -39
  39. package/src/core/css-transform.ts +57 -11
  40. package/src/core/css.ts +19 -13
  41. package/src/core/diagnostic.ts +5 -1
  42. package/src/core/escape.ts +43 -0
  43. package/src/core/foreign-text.ts +138 -0
  44. package/src/core/github.ts +1 -0
  45. package/src/core/html-entities.ts +318 -0
  46. package/src/core/html-scan.ts +49 -27
  47. package/src/core/html.ts +21 -15
  48. package/src/core/line-break.ts +275 -0
  49. package/src/core/lint/asset-refs.ts +50 -8
  50. package/src/core/lint/files.ts +30 -5
  51. package/src/core/lint/keys.ts +7 -3
  52. package/src/core/lint/slide-html.ts +24 -19
  53. package/src/core/lint/slide-script.ts +7 -5
  54. package/src/core/mv.ts +57 -27
  55. package/src/core/order.ts +14 -0
  56. package/src/core/pack.ts +2 -2
  57. package/src/core/path.ts +8 -2
  58. package/src/core/playwright-module.d.ts +13 -1
  59. package/src/core/playwright-motion.ts +1 -0
  60. package/src/core/playwright-sheet.ts +1 -0
  61. package/src/core/playwright-visual.ts +207 -30
  62. package/src/core/playwright.ts +279 -44
  63. package/src/core/pptx-package.ts +461 -33
  64. package/src/core/pptx-paint.ts +1168 -0
  65. package/src/core/pptx-plan.ts +879 -0
  66. package/src/core/pptx-text.ts +204 -47
  67. package/src/core/pptx-theme.ts +222 -0
  68. package/src/core/pptx.ts +56 -8
  69. package/src/core/prose.ts +23 -0
  70. package/src/core/pseudo-text.ts +32 -10
  71. package/src/core/ref-name.ts +52 -8
  72. package/src/core/ref.ts +308 -54
  73. package/src/core/resolve.ts +23 -11
  74. package/src/core/rumdl.ts +51 -25
  75. package/src/core/safe-fs.ts +19 -2
  76. package/src/core/sandbox.ts +217 -0
  77. package/src/core/shot/morph.ts +1 -1
  78. package/src/core/shot/motion.ts +2 -3
  79. package/src/core/shot/still.ts +2 -2
  80. package/src/core/shot-cache.ts +26 -17
  81. package/src/core/slide-eval-worker.ts +1 -0
  82. package/src/core/slide-measure.ts +51 -1
  83. package/src/core/slide-script-eval.ts +12 -8
  84. package/src/core/slide-script.ts +114 -20
  85. package/src/core/spawn.ts +131 -32
  86. package/src/core/sync.ts +5 -14
  87. package/src/core/tar.ts +315 -0
  88. package/src/core/text-contrast.ts +2 -0
  89. package/src/core/toml-keys.ts +116 -51
  90. package/src/core/url-attributes.ts +27 -9
  91. package/src/core/visual-cache.ts +2 -0
  92. package/src/core/visual.ts +182 -24
  93. package/src/core/voice.ts +5 -6
  94. package/src/core/zod.ts +9 -1
  95. package/src/server/auth.ts +21 -0
  96. package/src/server/dev.ts +26 -7
  97. package/src/server/lock.ts +50 -7
  98. package/src/server/routes.ts +7 -2
  99. package/src/server/serial.ts +8 -2
  100. package/src/theme/default.css +4 -0
  101. package/src/video/bake.ts +6 -2
  102. package/src/video/mux.ts +27 -3
  103. package/src/video/recorder.ts +3 -2
  104. package/src/video/worker.ts +1 -0
  105. package/src/voice/engine.ts +81 -18
@@ -1,5 +1,5 @@
1
1
  import { outermostStyleRules, type Stylesheet, splitSelectorList, startsAtSlide } from "./css.ts";
2
- import { consumeComment, consumeString, cssIdents } from "./css-scan.ts";
2
+ import { consumeComment, consumeToken, cssIdents } from "./css-scan.ts";
3
3
 
4
4
  /** Text to put in place of `[start, end)` of the stylesheet as it was read. */
5
5
  type CssEdit = { start: number; end: number; text: string };
@@ -108,32 +108,78 @@ export function replaceUrls(replace: (value: string) => string | undefined): Css
108
108
 
109
109
  /** Whitespace as CSS defines it. U+3000 and NBSP are content, not whitespace. */
110
110
  const CSS_WHITESPACE = new Set([" ", "\t", "\n", "\r", "\f"]);
111
+ const CSS_NEWLINES = new Set(["\n", "\r", "\f"]);
112
+
113
+ /** A token that runs on into the next with nothing between: a name, a number, or `#@.+/`. */
114
+ const RUNS_ON_RE = /^[-\w\u0080-\uffff\0\\#@.+/]/;
115
+ /** What such a token runs on into: a name, a number, `(`, `%`, or the `*` that opens a comment. */
116
+ const RUNS_INTO_RE = /^[-\w\u0080-\uffff\0\\.+(%*]/;
111
117
 
112
118
  /**
113
- * Drops comments and collapses whitespace to one space, leaving strings byte for byte, so
114
- * `content: "a b"` or `" "` renders in the build as it does on the dev server.
119
+ * Whether the token `last` and the one that starts with `next` would read as one with nothing
120
+ * between them: `x` and `url(` as `xurl(`, `/` and `*` as a comment, or `--` and `>`, `<` and
121
+ * `!`, `!` and `--` as the `-->` and `<!--` a stylesheet skips.
122
+ */
123
+ function runTogether(last: string, next: string): boolean {
124
+ return (
125
+ (RUNS_ON_RE.test(last) && RUNS_INTO_RE.test(next)) ||
126
+ (last.endsWith("--") && next === ">") ||
127
+ (last === "<" && next === "!") ||
128
+ (last === "!" && next === "-")
129
+ );
130
+ }
131
+
132
+ /** A hex escape at the end of a token, which takes one whitespace after it as its own. */
133
+ const HEX_ESCAPE_END_RE = /\\[0-9a-fA-F]{1,6}$/;
134
+
135
+ /**
136
+ * Drops comments and collapses whitespace to one space, leaving strings and `url()`s byte for
137
+ * byte, so `content: "a b"` or `" "` renders in the build as it does on the dev server. The
138
+ * result reads as the source does: a newline after a string stays one, since it is what ends a
139
+ * string cut short, and a comment between two tokens that would run into one, as `x` and `url(`
140
+ * into `xurl(`, stays as an empty one, since a space would make `div` and `p` two selectors. A
141
+ * hex escape that a comment ends, such as `\41` before a comment and ` x`, is followed by two
142
+ * spaces, since it takes one as its own.
115
143
  */
116
144
  export function minifyCss(css: string): string {
117
145
  let out = "";
118
- let space = false;
146
+ let last = "";
147
+ // What parts the last token written from the next: "", "comment", a space, or the newline that
148
+ // ends a string.
149
+ let gap = "";
119
150
  let i = 0;
120
151
  while (i < css.length) {
121
152
  const ch = css[i] ?? "";
122
153
  if (ch === "/" && css[i + 1] === "*") {
123
154
  i = consumeComment(css, i);
155
+ gap ||= "comment";
124
156
  continue;
125
157
  }
126
158
  if (CSS_WHITESPACE.has(ch)) {
127
- space = true;
128
- i++;
159
+ const whitespace = ch === "\r" && css[i + 1] === "\n" ? "\r\n" : ch;
160
+ if (gap === "" && CSS_NEWLINES.has(ch) && (last[0] === '"' || last[0] === "'")) {
161
+ // One LF, unless the string ends on a CR it escapes, which an LF would join into a CRLF.
162
+ gap = last.endsWith("\r") ? whitespace : "\n";
163
+ } else if (gap === "" || gap === "comment") {
164
+ gap = " ";
165
+ }
166
+ i += whitespace.length;
129
167
  continue;
130
168
  }
131
- if (space && out !== "") {
132
- out += " ";
169
+ if (out !== "") {
170
+ out +=
171
+ gap === "comment"
172
+ ? runTogether(last, ch)
173
+ ? "/**/"
174
+ : ""
175
+ : gap === " " && HEX_ESCAPE_END_RE.test(last)
176
+ ? " "
177
+ : gap;
133
178
  }
134
- space = false;
135
- const end = ch === '"' || ch === "'" ? consumeString(css, i) : ch === "\\" ? i + 2 : i + 1;
136
- out += css.slice(i, end);
179
+ gap = "";
180
+ const end = consumeToken(css, i);
181
+ last = css.slice(i, end);
182
+ out += last;
137
183
  i = end;
138
184
  }
139
185
  return out;
package/src/core/css.ts CHANGED
@@ -2,6 +2,7 @@ import {
2
2
  blankStringsAndComments,
3
3
  consumeComment,
4
4
  consumeString,
5
+ consumeToken,
5
6
  matchBrace,
6
7
  scanTopLevel,
7
8
  splitTopLevel,
@@ -131,16 +132,11 @@ class SheetReader {
131
132
  const { source } = this;
132
133
  let i = 0;
133
134
  while (i < source.length) {
134
- const ch = source[i];
135
- if (ch === '"' || ch === "'") {
136
- i = consumeString(source, i);
137
- } else if (ch === "/" && source[i + 1] === "*") {
138
- const close = consumeComment(source, i);
139
- this.comments.push([i, close]);
140
- i = close;
141
- } else {
142
- i += ch === "\\" ? 2 : 1;
135
+ const next = consumeToken(source, i);
136
+ if (source[i] === "/" && source[i + 1] === "*") {
137
+ this.comments.push([i, next]);
143
138
  }
139
+ i = next;
144
140
  }
145
141
  const locate = lineLocator(sheet.source);
146
142
  this.lineOf = (offset) => locate(offset).line;
@@ -181,13 +177,23 @@ class SheetReader {
181
177
  return i;
182
178
  }
183
179
 
184
- /** A prelude's span with its trailing spaces trimmed off. */
180
+ /**
181
+ * A prelude's span with the spaces and comments at its end trimmed off; a string it ends on,
182
+ * such as the name of `@keyframes "pop"`, stays.
183
+ */
185
184
  trimEnd(start: number, end: number): number {
186
185
  let j = end;
187
- while (j > start && WHITESPACE_RE.test(this.blank[j - 1] ?? "")) {
188
- j--;
186
+ while (j > start) {
187
+ const comment = this.comments.find(([, close]) => close === j);
188
+ if (comment) {
189
+ j = comment[0];
190
+ } else if (WHITESPACE_RE.test(this.source[j - 1] ?? "")) {
191
+ j--;
192
+ } else {
193
+ break;
194
+ }
189
195
  }
190
- return j;
196
+ return Math.max(j, start);
191
197
  }
192
198
 
193
199
  atRule(start: number, end: number, scope: Scope): number {
@@ -17,7 +17,9 @@ export type Scope = "project" | "deck" | "slide";
17
17
  * (DEK044), an empty heading (DEK024), which a slide script may fill, and a step bound by
18
18
  * position where the beat has an id (DEK025), which is right until a beat is inserted, and a
19
19
  * style several slides repeat (DEK026), which works but belongs in the theme. Text under
20
- * aria-hidden (DEK029) is right on a sample the talk shows as unreadable.
20
+ * aria-hidden (DEK029) is right on a sample the talk shows as unreadable. A line break that
21
+ * reads wrong (DEK035) is fixed by a choice of words or of where to break, which is the author's,
22
+ * and an edge a few pixels off (DEK036) can be meant, as in a picture of something out of line.
21
23
  */
22
24
  export const RULES = {
23
25
  DEK001: { severity: "error", scopes: ["slide"] },
@@ -54,6 +56,8 @@ export const RULES = {
54
56
  DEK032: { severity: "error", scopes: ["slide"] },
55
57
  DEK033: { severity: "error", scopes: ["slide"] },
56
58
  DEK034: { severity: "error", scopes: ["slide"] },
59
+ DEK035: { severity: "warning", scopes: ["slide"] },
60
+ DEK036: { severity: "warning", scopes: ["slide"] },
57
61
  DEK040: { severity: "warning", scopes: ["slide"] },
58
62
  DEK041: { severity: "warning", scopes: ["deck"] },
59
63
  DEK042: { severity: "warning", scopes: ["slide"] },
@@ -14,3 +14,46 @@ export function escapeAttr(text: string): string {
14
14
  export function escapeRegExp(text: string): string {
15
15
  return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
16
16
  }
17
+
18
+ /**
19
+ * A word no shell reads anything in: letters, marks, and digits of any script, and `@%+=:,./_-`,
20
+ * but not first an `=`, which zsh reads as the path of the program named after it.
21
+ */
22
+ const PLAIN_WORD = /^[\p{L}\p{M}\p{N}@%+:,./_-][\p{L}\p{M}\p{N}@%+=:,./_-]*$/u;
23
+
24
+ /** Whether `word` is plain: dek prints it as it is, in a command or in prose. */
25
+ export function isPlainWord(word: string): boolean {
26
+ return PLAIN_WORD.test(word);
27
+ }
28
+
29
+ /** A backtick, as a shell word that holds none: `printf` writes it from its octal code. */
30
+ const BACKTICK_WORD = `"$(printf '\\140')"`;
31
+
32
+ /**
33
+ * `word` as one shell word, as dek writes it in a command to paste: as it is when it is plain,
34
+ * else single-quoted. A command is printed inside a code span, which a backtick would close, so a
35
+ * backtick is written as `printf` of its code, between quoted runs. The quoting is a POSIX shell's,
36
+ * as sh, bash, and zsh read it: fish reads a backslash in single quotes apart, and PowerShell and
37
+ * cmd quote otherwise. A name from a file goes through `shellName` instead.
38
+ */
39
+ export function shellQuote(word: string): string {
40
+ if (isPlainWord(word)) {
41
+ return word;
42
+ }
43
+ const quoted = word
44
+ .split("`")
45
+ .map((run) => (run === "" ? "" : `'${run.replaceAll("'", "'\\''")}'`))
46
+ .join(BACKTICK_WORD);
47
+ return quoted === "" ? "''" : quoted;
48
+ }
49
+
50
+ /**
51
+ * A name from a file, such as a deck's folder or a slide's file, as a word in a command dek
52
+ * suggests, or nothing when it has none: fish reads a quote or a backslash in it apart from a
53
+ * POSIX shell, and a control character would start a line of its own. The command then shows a
54
+ * placeholder, such as `<deck>`, and says where the name is.
55
+ */
56
+ export function shellName(name: string): string | undefined {
57
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: a control in a name is the problem.
58
+ return /['\\\x00-\x1f\x7f-\x9f]/.test(name) ? undefined : shellQuote(name);
59
+ }
@@ -0,0 +1,138 @@
1
+ /**
2
+ * Text someone else wrote, such as a ref's title or the folder names in its repository, as dek
3
+ * puts it inside its own words. An agent reads dek's messages and AGENTS.md's dek block as dek's,
4
+ * so such text must not start a line there, carry a control character, or hold a character the
5
+ * person reading never sees.
6
+ */
7
+
8
+ /**
9
+ * Characters that show as nothing or move the text around them: those Unicode marks
10
+ * default-ignorable, such as zero-width ones, the byte order mark, bidi controls, Hangul fillers,
11
+ * and tag characters, which a model reads as text though a person sees nothing; and the line and
12
+ * paragraph separators and the interlinear annotation marks. The variation selectors, Mongolian's
13
+ * free ones included, are default-ignorable too and left out: emoji, ideographic variants of a
14
+ * kanji, and Mongolian text use them on purpose.
15
+ */
16
+ const HIDDEN_RE =
17
+ /(?![\u{fe00}-\u{fe0f}\u{e0100}-\u{e01ef}\u{180b}-\u{180d}\u{180f}])[\p{Default_Ignorable_Code_Point}\u{2028}\u{2029}\u{fff9}-\u{fffb}]/u;
18
+
19
+ /** Whitespace and control characters, which a line of someone else's text shows as one space. */
20
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: matching them is the point.
21
+ const SPACE_RE = /[\s\x00-\x1f\x7f-\x9f]/u;
22
+
23
+ /** HIDDEN_RE for every match. */
24
+ const HIDDEN_ALL_RE = new RegExp(HIDDEN_RE.source, "gu");
25
+
26
+ /** True when `text` holds a character that shows as nothing or moves the text around it. */
27
+ export function holdsHidden(text: string): boolean {
28
+ return HIDDEN_RE.test(text);
29
+ }
30
+
31
+ /** `U+202E`, as a code point is written. */
32
+ function codePoint(char: string): string {
33
+ return `U+${(char.codePointAt(0) ?? 0).toString(16).toUpperCase().padStart(4, "0")}`;
34
+ }
35
+
36
+ /** A hidden character as dek shows it, so a reader sees it is there and which it is. */
37
+ function marker(char: string): string {
38
+ return `⟨${codePoint(char)}⟩`;
39
+ }
40
+
41
+ /** `text` with each hidden character as dek shows it, `⟨U+XXXX⟩`, and the rest as it is. */
42
+ export function revealHidden(text: string): string {
43
+ return text.replace(HIDDEN_ALL_RE, marker);
44
+ }
45
+
46
+ /** A line of a file someone else wrote that holds characters a person cannot see. */
47
+ export type HiddenText = {
48
+ path: string;
49
+ line: number;
50
+ /** 1-based, in UTF-16 units as editors count: where the first of them is. */
51
+ column: number;
52
+ /** Each of them on the line, in order, as U+XXXX. */
53
+ codePoints: string[];
54
+ };
55
+
56
+ /** Each line of `text`, read from `path` and starting at line `firstLine`, that holds any. */
57
+ export function hiddenText(text: string, path: string, firstLine = 1): HiddenText[] {
58
+ return text.split("\n").flatMap((line, index) => {
59
+ const found = [...line.matchAll(HIDDEN_ALL_RE)];
60
+ const [first] = found;
61
+ if (first === undefined) {
62
+ return [];
63
+ }
64
+ return [
65
+ {
66
+ path,
67
+ line: firstLine + index,
68
+ column: first.index + 1,
69
+ codePoints: found.map((match) => codePoint(match[0])),
70
+ },
71
+ ];
72
+ });
73
+ }
74
+
75
+ /**
76
+ * A marker, which text revealHidden already went over holds, or else one code point: the units a
77
+ * line is cut between.
78
+ */
79
+ const UNIT_RE = /⟨U\+[0-9A-F]{4,6}⟩|[\s\S]/gu;
80
+
81
+ /**
82
+ * One line of someone else's text inside dek's own: each hidden character shows as `⟨U+XXXX⟩`,
83
+ * whitespace and control characters as one space, and the line is cut to `max` characters with
84
+ * an ellipsis, never through a marker, whether this made it or revealHidden did before.
85
+ */
86
+ export function foreignLine(text: string, max: number): string {
87
+ const units: string[] = [];
88
+ for (const [char = ""] of text.matchAll(UNIT_RE)) {
89
+ // A marker is longer than any one code point, which is at most two UTF-16 units.
90
+ const unit =
91
+ char.length > 2
92
+ ? char
93
+ : HIDDEN_RE.test(char)
94
+ ? marker(char)
95
+ : SPACE_RE.test(char)
96
+ ? " "
97
+ : char;
98
+ if (unit !== " " || (units.length > 0 && units.at(-1) !== " ")) {
99
+ units.push(unit);
100
+ }
101
+ }
102
+ if (units.at(-1) === " ") {
103
+ units.pop();
104
+ }
105
+ const width = (unit: string) => [...unit].length;
106
+ if (units.reduce((sum, unit) => sum + width(unit), 0) <= max) {
107
+ return units.join("");
108
+ }
109
+ const kept: string[] = [];
110
+ let size = 0;
111
+ for (const unit of units) {
112
+ if (size + width(unit) > max - 1) {
113
+ break;
114
+ }
115
+ kept.push(unit);
116
+ size += width(unit);
117
+ }
118
+ return `${kept.join("")}…`;
119
+ }
120
+
121
+ /** The longest a ref's title shows in a line of dek's: a title names a deck, it does not describe it. */
122
+ export const TITLE_MAX = 80;
123
+
124
+ /** The longest message dek gives that quotes someone else's text, such as Bun's on their source. */
125
+ export const MESSAGE_MAX = 200;
126
+
127
+ /** The longest name a list shows: a deck's folder names a deck, it does not describe one. */
128
+ const NAME_MAX = 40;
129
+
130
+ /** How many names a list shows before it counts the rest. */
131
+ const LIST_MAX = 10;
132
+
133
+ /** Names someone else chose, each a foreignLine of NAME_MAX, the first LIST_MAX and a count of the rest. */
134
+ export function foreignList(names: string[]): string {
135
+ const shown = names.slice(0, LIST_MAX).map((name) => foreignLine(name, NAME_MAX));
136
+ const rest = names.length - shown.length;
137
+ return rest > 0 ? `${shown.join(", ")}, and ${rest} more` : shown.join(", ");
138
+ }
@@ -93,6 +93,7 @@ async function request(
93
93
  const auth = authorization(base);
94
94
  let response: Response;
95
95
  try {
96
+ // biome-ignore lint/style/noRestrictedGlobals: the deadline's signal bounds the request, and readBody cancels a read that outlasts it.
96
97
  response = await fetch(`${base}${path}`, {
97
98
  headers: {
98
99
  accept,