@texuli/deck 0.0.0-stage → 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 (139) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +183 -2
  3. package/dist/authoring/builder.d.ts +351 -0
  4. package/dist/authoring/builder.js +64 -0
  5. package/dist/authoring/index.d.ts +6 -0
  6. package/dist/authoring/index.js +6 -0
  7. package/dist/authoring/lint.d.ts +36 -0
  8. package/dist/authoring/lint.js +325 -0
  9. package/dist/authoring/outlines.d.ts +34 -0
  10. package/dist/authoring/outlines.js +255 -0
  11. package/dist/authoring/patch.d.ts +23 -0
  12. package/dist/authoring/patch.js +166 -0
  13. package/dist/authoring/select-layouts.d.ts +19 -0
  14. package/dist/authoring/select-layouts.js +90 -0
  15. package/dist/engine/auto-slide.d.ts +32 -0
  16. package/dist/engine/auto-slide.js +67 -0
  17. package/dist/engine/canvas.d.ts +56 -0
  18. package/dist/engine/canvas.js +48 -0
  19. package/dist/engine/controller.d.ts +126 -0
  20. package/dist/engine/controller.js +248 -0
  21. package/dist/engine/controls.d.ts +128 -0
  22. package/dist/engine/controls.js +460 -0
  23. package/dist/engine/hash.d.ts +42 -0
  24. package/dist/engine/hash.js +86 -0
  25. package/dist/engine/index.d.ts +88 -0
  26. package/dist/engine/index.js +88 -0
  27. package/dist/engine/messages.d.ts +138 -0
  28. package/dist/engine/messages.js +140 -0
  29. package/dist/engine/motion.d.ts +64 -0
  30. package/dist/engine/motion.js +112 -0
  31. package/dist/engine/navigation.d.ts +87 -0
  32. package/dist/engine/navigation.js +156 -0
  33. package/dist/engine/presenter.d.ts +58 -0
  34. package/dist/engine/presenter.js +157 -0
  35. package/dist/index.d.ts +9 -0
  36. package/dist/index.js +9 -0
  37. package/dist/markdown/attributes.d.ts +24 -0
  38. package/dist/markdown/attributes.js +94 -0
  39. package/dist/markdown/attrs-closed.d.ts +52 -0
  40. package/dist/markdown/attrs-closed.js +520 -0
  41. package/dist/markdown/blocks.d.ts +112 -0
  42. package/dist/markdown/blocks.js +766 -0
  43. package/dist/markdown/frontmatter.d.ts +30 -0
  44. package/dist/markdown/frontmatter.js +165 -0
  45. package/dist/markdown/index.d.ts +29 -0
  46. package/dist/markdown/index.js +29 -0
  47. package/dist/markdown/issues.d.ts +68 -0
  48. package/dist/markdown/issues.js +83 -0
  49. package/dist/markdown/json.d.ts +21 -0
  50. package/dist/markdown/json.js +236 -0
  51. package/dist/markdown/limits.d.ts +32 -0
  52. package/dist/markdown/limits.js +32 -0
  53. package/dist/markdown/parse.d.ts +31 -0
  54. package/dist/markdown/parse.js +283 -0
  55. package/dist/markdown/scan.d.ts +26 -0
  56. package/dist/markdown/scan.js +138 -0
  57. package/dist/markdown/slides.d.ts +32 -0
  58. package/dist/markdown/slides.js +854 -0
  59. package/dist/markdown/text.d.ts +76 -0
  60. package/dist/markdown/text.js +447 -0
  61. package/dist/markdown/twin.d.ts +41 -0
  62. package/dist/markdown/twin.js +374 -0
  63. package/dist/markdown/write.d.ts +17 -0
  64. package/dist/markdown/write.js +688 -0
  65. package/dist/math/index.d.ts +14 -0
  66. package/dist/math/index.js +14 -0
  67. package/dist/math/parse.d.ts +9 -0
  68. package/dist/math/parse.js +648 -0
  69. package/dist/math/symbols.d.ts +44 -0
  70. package/dist/math/symbols.js +333 -0
  71. package/dist/math/types.d.ts +59 -0
  72. package/dist/math/types.js +69 -0
  73. package/dist/model/backgrounds/contrast.d.ts +77 -0
  74. package/dist/model/backgrounds/contrast.js +186 -0
  75. package/dist/model/backgrounds/index.d.ts +7 -0
  76. package/dist/model/backgrounds/index.js +7 -0
  77. package/dist/model/backgrounds/resolve.d.ts +86 -0
  78. package/dist/model/backgrounds/resolve.js +215 -0
  79. package/dist/model/backgrounds/schema.d.ts +178 -0
  80. package/dist/model/backgrounds/schema.js +203 -0
  81. package/dist/model/codes.d.ts +73 -0
  82. package/dist/model/codes.js +67 -0
  83. package/dist/model/deck.d.ts +392 -0
  84. package/dist/model/deck.js +338 -0
  85. package/dist/model/index.d.ts +19 -0
  86. package/dist/model/index.js +19 -0
  87. package/dist/model/layouts/catalog.d.ts +433 -0
  88. package/dist/model/layouts/catalog.js +495 -0
  89. package/dist/model/layouts/index.d.ts +5 -0
  90. package/dist/model/layouts/index.js +5 -0
  91. package/dist/model/layouts/props.d.ts +302 -0
  92. package/dist/model/layouts/props.js +272 -0
  93. package/dist/model/loader.d.ts +37 -0
  94. package/dist/model/loader.js +317 -0
  95. package/dist/model/migrate.d.ts +34 -0
  96. package/dist/model/migrate.js +453 -0
  97. package/dist/model/motion/index.d.ts +6 -0
  98. package/dist/model/motion/index.js +6 -0
  99. package/dist/model/motion/transition.d.ts +96 -0
  100. package/dist/model/motion/transition.js +127 -0
  101. package/dist/model/parse.d.ts +57 -0
  102. package/dist/model/parse.js +307 -0
  103. package/dist/model/plain.d.ts +40 -0
  104. package/dist/model/plain.js +275 -0
  105. package/dist/model/shared.d.ts +63 -0
  106. package/dist/model/shared.js +86 -0
  107. package/dist/model/steps/index.d.ts +7 -0
  108. package/dist/model/steps/index.js +7 -0
  109. package/dist/model/steps/phase.d.ts +34 -0
  110. package/dist/model/steps/phase.js +50 -0
  111. package/dist/model/steps/slide-steps.d.ts +129 -0
  112. package/dist/model/steps/slide-steps.js +256 -0
  113. package/dist/model/style/index.d.ts +8 -0
  114. package/dist/model/style/index.js +8 -0
  115. package/dist/model/style/resolve.d.ts +87 -0
  116. package/dist/model/style/resolve.js +174 -0
  117. package/dist/model/style/schema.d.ts +85 -0
  118. package/dist/model/style/schema.js +53 -0
  119. package/dist/model/style/theme.d.ts +69 -0
  120. package/dist/model/style/theme.js +84 -0
  121. package/dist/schema/builders.d.ts +146 -0
  122. package/dist/schema/builders.js +285 -0
  123. package/dist/schema/diagnostics.d.ts +78 -0
  124. package/dist/schema/diagnostics.js +81 -0
  125. package/dist/schema/index.d.ts +15 -0
  126. package/dist/schema/index.js +14 -0
  127. package/dist/schema/internal.d.ts +40 -0
  128. package/dist/schema/internal.js +209 -0
  129. package/dist/schema/json-schema.d.ts +12 -0
  130. package/dist/schema/json-schema.js +213 -0
  131. package/dist/schema/types.d.ts +166 -0
  132. package/dist/schema/types.js +1 -0
  133. package/dist/schema/validate.d.ts +51 -0
  134. package/dist/schema/validate.js +765 -0
  135. package/dist/templates/index.d.ts +2 -0
  136. package/dist/templates/index.js +2 -0
  137. package/dist/templates/templates.d.ts +11 -0
  138. package/dist/templates/templates.js +47 -0
  139. package/package.json +60 -4
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Text helpers shared by the Markdown reader, the canonical writer and the
3
+ * text twins: hidden characters, escaping (ported from Protoyard's
4
+ * `text/escape.ts`), unescaping, slugs and inert JSON.
5
+ */
6
+ /**
7
+ * Hidden characters the dialect refuses (Trojan Source): C0 and C1 controls
8
+ * except tab and line feed, bidi embeddings, overrides and isolates, the
9
+ * Arabic letter mark, the Mongolian vowel separator, zero-width space, LRM,
10
+ * RLM, word joiner and invisible operators, and the BOM. ZWJ and ZWNJ stay
11
+ * (emoji sequences and Persian text need them).
12
+ */
13
+ export declare function isHiddenCode(code: number): boolean;
14
+ /**
15
+ * Trailing spaces and tabs removed, by index (linear). Regexes such as
16
+ * `/[ \t]+$/` scan every run once per start position: quadratic on a long
17
+ * run that is followed by text.
18
+ */
19
+ export declare function trimBlankEnd(text: string): string;
20
+ /** Trailing `char` repeats removed, by index (linear; see `trimBlankEnd`). */
21
+ export declare function trimCharEnd(text: string, char: string): string;
22
+ /** Remove hidden characters (see `isHiddenCode`). */
23
+ export declare function stripHidden(text: string): string;
24
+ /** Escape block syntax that only matters at the start of a line. */
25
+ export declare function escapeLineStart(line: string): string;
26
+ /**
27
+ * Plain text as one inert Markdown line: whitespace runs become one space,
28
+ * inline syntax is backslash-escaped and block syntax at the start is too,
29
+ * so nothing an author typed becomes structure, HTML or a link.
30
+ */
31
+ export declare function escapeInline(text: string): string;
32
+ /** Plain text as inert Markdown, keeping line breaks as separate escaped lines. */
33
+ export declare function escapeText(text: string): string;
34
+ /** Remove Markdown backslash escapes (`\*` → `*`) and collapse whitespace: a plain line. */
35
+ export declare function unescapeInline(text: string): string;
36
+ /** A link or image destination with spaces, parentheses, angle brackets and backslashes encoded. */
37
+ export declare function linkTarget(url: string): string;
38
+ /** A code fence longer than any backtick run inside `text` (at least three). */
39
+ export declare function fenceFor(text: string): string;
40
+ /** Inline code with a delimiter longer than any backtick run inside. */
41
+ export declare function inlineCode(text: string): string;
42
+ /**
43
+ * Markdown from data (a text block written in JSON) made inert for a text
44
+ * twin: hidden characters removed and every `<` that could open a tag
45
+ * escaped, so the twin never carries raw HTML.
46
+ */
47
+ export declare function inertMarkdown(markdown: string): string;
48
+ /**
49
+ * True when a Markdown link destination is safe to keep in a text twin: an
50
+ * `https:`, `http:` or `mailto:` URL, an empty target, a `#fragment` or a
51
+ * relative path. A relative target is refused when anything before its
52
+ * query or fragment could become a scheme once a renderer decodes it (a
53
+ * `:`, an `&` entity, a `\` escape or whitespace), and protocol-relative
54
+ * `//host` targets are refused.
55
+ */
56
+ export declare function isSafeLinkTarget(url: string): boolean;
57
+ /**
58
+ * Markdown with every unsafe link target neutralized (see
59
+ * `isSafeLinkTarget`), for the one-way text twins: an inline link or image
60
+ * keeps its label and loses `[`, `]` and the destination
61
+ * (`[docs](javascript:x)` → `docs`), a reference definition with an unsafe
62
+ * destination, or none on its own line, is broken (`[ref\]: …`, so no link
63
+ * uses it), and a `](` whose
64
+ * link cannot be delimited has its `(` escaped. Safe links stay as written.
65
+ * Hidden characters are removed first, so none can hide a scheme. One pass
66
+ * with O(1) delimiter lookahead (linear in the text length).
67
+ */
68
+ export declare function neutralizeLinks(markdown: string): string;
69
+ /** True when `markdown` holds a link target `neutralizeLinks` would change. */
70
+ export declare function hasUnsafeLink(markdown: string): boolean;
71
+ /** Pretty JSON with every hidden character escaped as `\uXXXX`, so it round-trips. */
72
+ export declare function inertJson(value: unknown): string;
73
+ /** A slide-id slug from a title: lowercase ASCII letters, digits and single dashes. */
74
+ export declare function slugify(text: string, max?: number): string;
75
+ /** True when two JSON values are equal (key order ignored). */
76
+ export declare function jsonEqual(a: unknown, b: unknown): boolean;
@@ -0,0 +1,447 @@
1
+ /**
2
+ * Text helpers shared by the Markdown reader, the canonical writer and the
3
+ * text twins: hidden characters, escaping (ported from Protoyard's
4
+ * `text/escape.ts`), unescaping, slugs and inert JSON.
5
+ */
6
+ /**
7
+ * Hidden characters the dialect refuses (Trojan Source): C0 and C1 controls
8
+ * except tab and line feed, bidi embeddings, overrides and isolates, the
9
+ * Arabic letter mark, the Mongolian vowel separator, zero-width space, LRM,
10
+ * RLM, word joiner and invisible operators, and the BOM. ZWJ and ZWNJ stay
11
+ * (emoji sequences and Persian text need them).
12
+ */
13
+ export function isHiddenCode(code) {
14
+ return ((code <= 0x1f && code !== 0x09 && code !== 0x0a) ||
15
+ (code >= 0x7f && code <= 0x9f) ||
16
+ code === 0x61c ||
17
+ code === 0x180e ||
18
+ code === 0x200b ||
19
+ code === 0x200e ||
20
+ code === 0x200f ||
21
+ (code >= 0x202a && code <= 0x202e) ||
22
+ (code >= 0x2060 && code <= 0x2064) ||
23
+ (code >= 0x2066 && code <= 0x2069) ||
24
+ code === 0xfeff);
25
+ }
26
+ /**
27
+ * Trailing spaces and tabs removed, by index (linear). Regexes such as
28
+ * `/[ \t]+$/` scan every run once per start position: quadratic on a long
29
+ * run that is followed by text.
30
+ */
31
+ export function trimBlankEnd(text) {
32
+ let end = text.length;
33
+ while (end > 0) {
34
+ const code = text.charCodeAt(end - 1);
35
+ if (code !== 0x20 && code !== 0x09)
36
+ break;
37
+ end--;
38
+ }
39
+ return end === text.length ? text : text.slice(0, end);
40
+ }
41
+ /** Trailing `char` repeats removed, by index (linear; see `trimBlankEnd`). */
42
+ export function trimCharEnd(text, char) {
43
+ let end = text.length;
44
+ while (end > 0 && text[end - 1] === char)
45
+ end--;
46
+ return end === text.length ? text : text.slice(0, end);
47
+ }
48
+ /** Remove hidden characters (see `isHiddenCode`). */
49
+ export function stripHidden(text) {
50
+ let out = '';
51
+ let start = 0;
52
+ for (let i = 0; i < text.length; i++) {
53
+ if (isHiddenCode(text.charCodeAt(i))) {
54
+ out += text.slice(start, i);
55
+ start = i + 1;
56
+ }
57
+ }
58
+ return start === 0 ? text : out + text.slice(start);
59
+ }
60
+ /** Characters that are Markdown or HTML syntax anywhere in a line. */
61
+ const ANYWHERE = /[\\`*_{}[\]<>|~&$]/g;
62
+ /** `https://…` and `www.…` would become links when read back. */
63
+ const AUTOLINK_SCHEME = /\b([a-z][a-z0-9+.-]{0,31}):(?=\/\/)/gi;
64
+ const AUTOLINK_WWW = /\b(www)\./gi;
65
+ /** Escape block syntax that only matters at the start of a line. */
66
+ export function escapeLineStart(line) {
67
+ return line
68
+ .replace(/^(\s*)([#+\-=>:!])/, '$1\\$2')
69
+ .replace(/^(\s*\d{1,9})([.)])(?=\s|$)/, '$1\\$2')
70
+ .replace(/^(\s*)(Notes?|notes?)(:)/, '$1$2\\$3');
71
+ }
72
+ /**
73
+ * Plain text as one inert Markdown line: whitespace runs become one space,
74
+ * inline syntax is backslash-escaped and block syntax at the start is too,
75
+ * so nothing an author typed becomes structure, HTML or a link.
76
+ */
77
+ export function escapeInline(text) {
78
+ const flat = stripHidden(text)
79
+ .replace(/\s+/g, ' ')
80
+ .trim()
81
+ .replace(ANYWHERE, '\\$&')
82
+ .replace(AUTOLINK_SCHEME, '$1\\:')
83
+ .replace(AUTOLINK_WWW, '$1\\.');
84
+ return escapeLineStart(flat);
85
+ }
86
+ /** Plain text as inert Markdown, keeping line breaks as separate escaped lines. */
87
+ export function escapeText(text) {
88
+ return stripHidden(text)
89
+ .split('\n')
90
+ .map((line) => escapeInline(line))
91
+ .join('\n')
92
+ .replace(/\n{3,}/g, '\n\n')
93
+ .trim();
94
+ }
95
+ /** Remove Markdown backslash escapes (`\*` → `*`) and collapse whitespace: a plain line. */
96
+ export function unescapeInline(text) {
97
+ return text
98
+ .replace(/\\([!-/:-@[-`{-~])/g, '$1')
99
+ .replace(/\s+/g, ' ')
100
+ .trim();
101
+ }
102
+ /** A link or image destination with spaces, parentheses, angle brackets and backslashes encoded. */
103
+ export function linkTarget(url) {
104
+ return stripHidden(url).replace(/[\s()<>\\]/g, (c) => `%${c.charCodeAt(0).toString(16).toUpperCase().padStart(2, '0')}`);
105
+ }
106
+ /** A code fence longer than any backtick run inside `text` (at least three). */
107
+ export function fenceFor(text) {
108
+ let longest = 0;
109
+ let run = 0;
110
+ for (let i = 0; i < text.length; i++) {
111
+ if (text[i] === '`') {
112
+ run++;
113
+ if (run > longest)
114
+ longest = run;
115
+ }
116
+ else
117
+ run = 0;
118
+ }
119
+ return '`'.repeat(Math.max(3, longest + 1));
120
+ }
121
+ /** Inline code with a delimiter longer than any backtick run inside. */
122
+ export function inlineCode(text) {
123
+ const body = stripHidden(text).replace(/\s+/g, ' ');
124
+ const tick = fenceFor(body).slice(2);
125
+ const pad = body.startsWith('`') || body.endsWith('`') ? ' ' : '';
126
+ return `${tick}${pad}${body}${pad}${tick}`;
127
+ }
128
+ /**
129
+ * Markdown from data (a text block written in JSON) made inert for a text
130
+ * twin: hidden characters removed and every `<` that could open a tag
131
+ * escaped, so the twin never carries raw HTML.
132
+ */
133
+ export function inertMarkdown(markdown) {
134
+ return stripHidden(markdown).replace(/<(?=[A-Za-z/!?])/g, '\\<');
135
+ }
136
+ /** Link schemes a text twin keeps: `https:`, `http:` and `mailto:`. */
137
+ const SAFE_LINK_SCHEME = /^(?:https?:\/\/|mailto:)/i;
138
+ /**
139
+ * True when a Markdown link destination is safe to keep in a text twin: an
140
+ * `https:`, `http:` or `mailto:` URL, an empty target, a `#fragment` or a
141
+ * relative path. A relative target is refused when anything before its
142
+ * query or fragment could become a scheme once a renderer decodes it (a
143
+ * `:`, an `&` entity, a `\` escape or whitespace), and protocol-relative
144
+ * `//host` targets are refused.
145
+ */
146
+ export function isSafeLinkTarget(url) {
147
+ const target = stripHidden(url);
148
+ if (target === '' || target.startsWith('#'))
149
+ return true;
150
+ if (SAFE_LINK_SCHEME.test(target))
151
+ return true;
152
+ if (target.startsWith('//'))
153
+ return false;
154
+ let end = 0;
155
+ while (end < target.length && target[end] !== '?' && target[end] !== '#')
156
+ end++;
157
+ return !/[:&\\\s]/.test(target.slice(0, end));
158
+ }
159
+ /** CommonMark's limit on nested parentheses inside a bare link destination. */
160
+ const LINK_PAREN_DEPTH = 32;
161
+ const isBlank = (char) => char === ' ' || char === '\t';
162
+ const isSpaceChar = (char) => char !== undefined && /\s/.test(char);
163
+ /** The key of the "next stop character" table (see `Delimiters.stop`). */
164
+ const STOP = 'stop';
165
+ /**
166
+ * For each delimiter, the index of its next unescaped occurrence at or after
167
+ * every position (`text.length` when none), built in one backward pass so
168
+ * every lookahead of the neutralizer is O(1) and the whole pass stays linear.
169
+ */
170
+ class Delimiters {
171
+ #text;
172
+ #escaped;
173
+ #next = new Map();
174
+ constructor(text) {
175
+ this.#text = text;
176
+ this.#escaped = new Uint8Array(text.length);
177
+ for (let i = 0; i < text.length; i++) {
178
+ if (text[i] === '\\' && !this.#escaped[i] && i + 1 < text.length)
179
+ this.#escaped[i + 1] = 1;
180
+ }
181
+ }
182
+ escaped(at) {
183
+ return this.#escaped[at] === 1;
184
+ }
185
+ /** The next `:`, `&`, `\\`, `?`, `#` or whitespace at or after `from`. */
186
+ stop(from) {
187
+ return this.next(STOP, from);
188
+ }
189
+ next(char, from) {
190
+ let table = this.#next.get(char);
191
+ if (!table) {
192
+ const text = this.#text;
193
+ table = new Int32Array(text.length + 1);
194
+ table[text.length] = text.length;
195
+ for (let i = text.length - 1; i >= 0; i--) {
196
+ const hit = char === STOP
197
+ ? /[:&\\?#\s]/.test(text[i])
198
+ : text[i] === char && !this.#escaped[i];
199
+ table[i] = hit ? i : table[i + 1];
200
+ }
201
+ this.#next.set(char, table);
202
+ }
203
+ return from >= this.#text.length ? this.#text.length : table[from];
204
+ }
205
+ }
206
+ /**
207
+ * A link destination starting at `from` (after the opening `(` and any
208
+ * spaces): `<…>` on one line, or a bare run with at most 32 nested
209
+ * parentheses. `null` when it is not a destination.
210
+ */
211
+ function readDestination(text, from, marks) {
212
+ if (text[from] === '<') {
213
+ const close = marks.next('>', from + 1);
214
+ if (close >= text.length || marks.next('\n', from + 1) < close)
215
+ return null;
216
+ return { from: from + 1, to: close, end: close + 1 };
217
+ }
218
+ let j = from;
219
+ let depth = 0;
220
+ while (j < text.length) {
221
+ const c = text[j];
222
+ if (c === '\\') {
223
+ j += 2;
224
+ continue;
225
+ }
226
+ if (isSpaceChar(c))
227
+ break;
228
+ if (c === '(') {
229
+ depth++;
230
+ if (depth > LINK_PAREN_DEPTH)
231
+ return null;
232
+ }
233
+ else if (c === ')') {
234
+ if (depth === 0)
235
+ break;
236
+ depth--;
237
+ }
238
+ j++;
239
+ }
240
+ const end = Math.min(j, text.length);
241
+ return { from, to: end, end };
242
+ }
243
+ /**
244
+ * `isSafeLinkTarget` for `text[from, to)` in O(1), without slicing: the
245
+ * neutralizer runs it once per `](` and `]:`.
246
+ */
247
+ function safeRange(text, from, to, marks) {
248
+ if (from >= to || text[from] === '#')
249
+ return true;
250
+ // A safe scheme prefix holds no whitespace, `)` or `>`, so it never crosses `to`.
251
+ if (SAFE_LINK_SCHEME.test(text.slice(from, from + 8)))
252
+ return true;
253
+ if (text[from] === '/' && text[from + 1] === '/' && from + 1 < to)
254
+ return false;
255
+ const stop = marks.stop(from);
256
+ return stop >= to || text[stop] === '?' || text[stop] === '#';
257
+ }
258
+ /**
259
+ * A reference definition's destination starting at `from`: up to the next
260
+ * whitespace, or inside `<…>`. Found through the stop table so a run of
261
+ * definitions stays linear; `null` when it is not a destination.
262
+ */
263
+ function definitionRange(text, from, marks) {
264
+ if (text[from] === '<') {
265
+ const close = marks.next('>', from + 1);
266
+ if (close >= text.length || marks.next('\n', from + 1) < close)
267
+ return null;
268
+ return { from: from + 1, to: close };
269
+ }
270
+ // Only the part up to the first stop character decides safety (see
271
+ // `safeRange`): whitespace ends the destination, anything else is inside it.
272
+ const stop = marks.stop(from);
273
+ return { from, to: stop < text.length && !isSpaceChar(text[stop]) ? stop + 1 : stop };
274
+ }
275
+ /** The index after the `)` that closes an inline link whose destination ends at `from`. */
276
+ function closeOfLink(text, from, marks) {
277
+ let j = from;
278
+ while (isSpaceChar(text[j]))
279
+ j++;
280
+ const open = text[j];
281
+ if (open === '"' || open === "'" || open === '(') {
282
+ j = marks.next(open === '(' ? ')' : open, j + 1);
283
+ if (j >= text.length)
284
+ return null;
285
+ j++;
286
+ while (isSpaceChar(text[j]))
287
+ j++;
288
+ }
289
+ return text[j] === ')' ? j + 1 : null;
290
+ }
291
+ /** Skip spaces and tabs, then at most one line break and its indent. */
292
+ function skipLinkSpace(text, from) {
293
+ let j = from;
294
+ while (isBlank(text[j]))
295
+ j++;
296
+ if (text[j] === '\n') {
297
+ j++;
298
+ while (isBlank(text[j]))
299
+ j++;
300
+ }
301
+ return j;
302
+ }
303
+ /**
304
+ * Markdown with every unsafe link target neutralized (see
305
+ * `isSafeLinkTarget`), for the one-way text twins: an inline link or image
306
+ * keeps its label and loses `[`, `]` and the destination
307
+ * (`[docs](javascript:x)` → `docs`), a reference definition with an unsafe
308
+ * destination, or none on its own line, is broken (`[ref\]: …`, so no link
309
+ * uses it), and a `](` whose
310
+ * link cannot be delimited has its `(` escaped. Safe links stay as written.
311
+ * Hidden characters are removed first, so none can hide a scheme. One pass
312
+ * with O(1) delimiter lookahead (linear in the text length).
313
+ */
314
+ export function neutralizeLinks(markdown) {
315
+ const text = stripHidden(markdown);
316
+ if (!text.includes(']'))
317
+ return text;
318
+ const marks = new Delimiters(text);
319
+ const openers = [];
320
+ const cuts = [];
321
+ const escapes = [];
322
+ let i = 0;
323
+ while (i < text.length) {
324
+ const c = text[i];
325
+ if (c === '\\') {
326
+ i += 2;
327
+ continue;
328
+ }
329
+ if (c === '[') {
330
+ openers.push(i);
331
+ i++;
332
+ continue;
333
+ }
334
+ if (c !== ']') {
335
+ i++;
336
+ continue;
337
+ }
338
+ const opener = openers.pop();
339
+ const next = text[i + 1];
340
+ if (next === ':') {
341
+ // A definition: its destination must sit on the same line (a container
342
+ // prefix such as `> ` could hide one on the next line) and be safe.
343
+ let at = i + 2;
344
+ while (isBlank(text[at]))
345
+ at++;
346
+ const destination = at < text.length && text[at] !== '\n' ? definitionRange(text, at, marks) : null;
347
+ if (destination === null || !safeRange(text, destination.from, destination.to, marks))
348
+ escapes.push(i);
349
+ i += 2;
350
+ continue;
351
+ }
352
+ if (next !== '(') {
353
+ i++;
354
+ continue;
355
+ }
356
+ const destination = readDestination(text, skipLinkSpace(text, i + 2), marks);
357
+ if (destination !== null && safeRange(text, destination.from, destination.to, marks)) {
358
+ i += 2;
359
+ continue;
360
+ }
361
+ const end = destination === null ? null : closeOfLink(text, destination.end, marks);
362
+ if (end === null) {
363
+ escapes.push(i + 1);
364
+ i += 2;
365
+ continue;
366
+ }
367
+ if (opener !== undefined) {
368
+ const image = opener > 0 && text[opener - 1] === '!' && !marks.escaped(opener - 1);
369
+ cuts.push([image ? opener - 1 : opener, opener + 1]);
370
+ }
371
+ cuts.push([i, end]);
372
+ i = end;
373
+ }
374
+ if (cuts.length === 0 && escapes.length === 0)
375
+ return text;
376
+ const edits = [
377
+ ...cuts.map(([from, to]) => [from, to, '']),
378
+ ...escapes.map((at) => [at, at, '\\']),
379
+ ].sort((a, b) => a[0] - b[0] || a[1] - b[1]);
380
+ let out = '';
381
+ let start = 0;
382
+ for (const [from, to, insert] of edits) {
383
+ if (from < start)
384
+ continue;
385
+ out += text.slice(start, from) + insert;
386
+ start = to;
387
+ }
388
+ return out + text.slice(start);
389
+ }
390
+ /** True when `markdown` holds a link target `neutralizeLinks` would change. */
391
+ export function hasUnsafeLink(markdown) {
392
+ return neutralizeLinks(markdown) !== stripHidden(markdown);
393
+ }
394
+ /** Pretty JSON with every hidden character escaped as `\uXXXX`, so it round-trips. */
395
+ export function inertJson(value) {
396
+ const text = JSON.stringify(value, null, 2) ?? 'null';
397
+ let out = '';
398
+ let start = 0;
399
+ for (let i = 0; i < text.length; i++) {
400
+ const code = text.charCodeAt(i);
401
+ if (code > 0x1f && isHiddenCode(code)) {
402
+ out += `${text.slice(start, i)}\\u${code.toString(16).padStart(4, '0')}`;
403
+ start = i + 1;
404
+ }
405
+ }
406
+ return start === 0 ? text : out + text.slice(start);
407
+ }
408
+ /** A slide-id slug from a title: lowercase ASCII letters, digits and single dashes. */
409
+ export function slugify(text, max = 48) {
410
+ const slug = text
411
+ .normalize('NFKD')
412
+ .replace(/\p{M}/gu, '')
413
+ .toLowerCase()
414
+ .replace(/[^a-z0-9]+/g, '-')
415
+ .replace(/^-+/, '')
416
+ .slice(0, max)
417
+ .replace(/-$/, '');
418
+ return slug;
419
+ }
420
+ /** True when two JSON values are equal (key order ignored). */
421
+ export function jsonEqual(a, b) {
422
+ if (a === b)
423
+ return true;
424
+ if (typeof a !== typeof b || a === null || b === null || typeof a !== 'object')
425
+ return false;
426
+ if (Array.isArray(a)) {
427
+ if (!Array.isArray(b) || a.length !== b.length)
428
+ return false;
429
+ for (let i = 0; i < a.length; i++)
430
+ if (!jsonEqual(a[i], b[i]))
431
+ return false;
432
+ return true;
433
+ }
434
+ if (Array.isArray(b))
435
+ return false;
436
+ const left = a;
437
+ const right = b;
438
+ const keys = Object.keys(left).filter((key) => left[key] !== undefined);
439
+ const other = Object.keys(right).filter((key) => right[key] !== undefined);
440
+ if (keys.length !== other.length)
441
+ return false;
442
+ for (const key of keys) {
443
+ if (!Object.hasOwn(right, key) || !jsonEqual(left[key], right[key]))
444
+ return false;
445
+ }
446
+ return true;
447
+ }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Text twins of a deck, ported from Protoyard's `text/deck.ts` onto model
3
+ * v2: `deckToText` (a readable Markdown document for accessibility, artifact
4
+ * descriptions and LLM context; one-way, unlike `deckToMarkdown`) and
5
+ * `deckOutline` (one compact row per slide). Both are pure and
6
+ * deterministic, and nothing an author typed can become HTML or a link
7
+ * target in them: plain fields are escaped and Markdown fields made inert
8
+ * (no raw HTML, and a link whose target is not `https:`, `http:`,
9
+ * `mailto:`, a fragment or a relative path keeps its label and loses its
10
+ * URL; see `neutralizeLinks`).
11
+ */
12
+ import type { Deck, DeckSlide } from '../model/deck.js';
13
+ export interface DeckTextOptions {
14
+ /** Document title when the deck has none. Default: `meta.title`. */
15
+ title?: string;
16
+ /** Include hidden slides. Default false. */
17
+ includeHidden?: boolean;
18
+ /** Include speaker notes under a "Notes" heading. Default true. */
19
+ notes?: boolean;
20
+ }
21
+ /** The title a slide is known by: its own, else the layout's title prop, else the first heading. */
22
+ export declare function slideTitle(slide: DeckSlide): string | null;
23
+ /** The deck as a readable, inert Markdown document (one-way; see `deckToMarkdown` for the round-trip form). */
24
+ export declare function deckToText(deck: Deck, options?: DeckTextOptions): string;
25
+ export interface DeckOutlineSlide {
26
+ /** 1-based position among the listed slides. */
27
+ number: number;
28
+ id: string;
29
+ layout: string;
30
+ title: string | null;
31
+ section: string | null;
32
+ hidden: boolean;
33
+ }
34
+ export interface DeckOutlineText {
35
+ title: string | null;
36
+ slides: DeckOutlineSlide[];
37
+ }
38
+ /** One compact row per slide: id, layout and title (for authoring hints, MCP and agents). */
39
+ export declare function deckOutline(deck: Deck, options?: Pick<DeckTextOptions, 'includeHidden'>): DeckOutlineText;
40
+ /** The outline as plain lines: `3. pricing · comparison-table · Plans compared`. */
41
+ export declare function formatDeckOutline(outline: DeckOutlineText): string;