altalandi 0.4.0 → 0.6.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.
package/segment.mjs ADDED
@@ -0,0 +1,1059 @@
1
+ // Passages for folder and website runs: find the human text in a document,
2
+ // and put refined text back without touching anything else.
3
+ //
4
+ // Every format returns units: a [start, end) slice of the source, the `text`
5
+ // sent to Altalandi for it, and `place(refined)`, which turns refined text back
6
+ // into source for that slice. Everything outside the units is copied as it is,
7
+ // so a document whose passages all come back unchanged is written out byte for
8
+ // byte as it came in (the tests hold every format to that).
9
+ //
10
+ // segmentMarkdown(src) paragraphs, list items, table cells, heading text and
11
+ // a few front-matter values (title, description …)
12
+ // segmentHtml(src) text in elements; inline tags travel as numbered
13
+ // markers (<a1>…</a1>, <code2/>) and come back exactly
14
+ // segmentJson(src) string values of an i18n catalog, never keys
15
+ // segmentText(src) paragraphs or lines of plain text
16
+ //
17
+ // place() refuses ({ ok: false, reason }) when the refined text lost or gained
18
+ // a protected token: code, a URL or link target, a placeholder such as {name}
19
+ // or %s, an HTML tag, or the structure of an ICU plural/select. The caller then
20
+ // keeps the original passage, so a passage is never written broken.
21
+ //
22
+ // Zero dependencies, like the rest of the CLI.
23
+
24
+ /** The hosted API refuses a text longer than this (RITVIT_API_MAX_CHARS). */
25
+ export const API_MAX_CHARS = 20_000;
26
+
27
+ // ── protected tokens ────────────────────────────────────────────────────────
28
+ //
29
+ // Things inside a passage that must come back exactly as they went: the check
30
+ // compares them as a multiset, so a translation may move a placeholder but not
31
+ // drop, duplicate or "translate" it. Exactly one capturing group (the backtick
32
+ // run of inline code), so the backreference stays \1.
33
+
34
+ const BRACE = String.raw`\{[^{}\n]*\}`; // {name} {0} {count, number}
35
+ // Inline code as CommonMark reads it: closed by the next backtick run of the
36
+ // same length, so ``a `b` c`` is one span.
37
+ const CODE_SPAN = String.raw`(?<!\`)(\`+)(?!\`)[\s\S]+?(?<!\`)\1(?!\`)`;
38
+ const TOKEN_PARTS = [
39
+ String.raw`<!--[\s\S]*?-->`, // HTML comment
40
+ CODE_SPAN,
41
+ String.raw`\]\((?:[^()\s]|\([^()\s]*\))*(?:\s+(?:"[^"]*"|'[^']*'))?\)`, // Markdown link target
42
+ String.raw`\]\[[^\]\n]*\]`, // reference-link label
43
+ String.raw`\[\^[^\]\s]+\]`, // footnote reference
44
+ String.raw`<(?:https?|ftp|mailto):[^\s<>]+>`, // autolink
45
+ String.raw`<\/?[A-Za-z][\w.:-]*(?:\s[^<>]*?)?\/?>`, // HTML/XML tag, and our markers
46
+ String.raw`<\/?\d+\s*\/?>`, // react-i18next <0>…</0>
47
+ String.raw`\{\{[\s\S]*?\}\}`, // {{mustache}} and {{< shortcodes >}}
48
+ String.raw`\{%[\s\S]*?%\}`, // {% liquid %}
49
+ String.raw`<%[\s\S]*?%>`, // <% erb %>
50
+ String.raw`\$\{[^{}]*\}`, // ${template}
51
+ String.raw`\$t\([^)]*\)`, // i18next nesting
52
+ String.raw`@(?:\.[a-z]+)?:(?:\{[^}]*\}|[\w.-]*\w)`, // vue-i18n linked message
53
+ BRACE,
54
+ String.raw`%\([\w.-]+\)[-+0# ]*\d*(?:\.\d+)?[diouxXeEfFgGcrsa]`, // %(name)s
55
+ String.raw`%\{[\w.-]+\}`, // %{name}
56
+ String.raw`%%`,
57
+ String.raw`%(?:\d+\$)?[-+0#]*\d*(?:\.\d+)?(?:hh|h|ll|l|L|z|j|t)?[diouxXeEfFgGcsp@]`, // printf
58
+ String.raw`\b(?:https?|ftp):\/\/[^\s<>"'\`{}()\[\]]*[^\s<>"'\`{}()\[\].,;:!?]`, // URL
59
+ String.raw`\bwww\.[^\s<>"'\`{}()\[\]]*[^\s<>"'\`{}()\[\].,;:!?]`,
60
+ String.raw`[\w.+-]+@[\w-]+(?:\.[\w-]+)+`, // email
61
+ String.raw`&(?:[A-Za-z][A-Za-z0-9]{1,31}|#\d{1,7}|#[xX][0-9A-Fa-f]{1,6});`, // entity
62
+ ];
63
+ const TOKEN_SOURCE = TOKEN_PARTS.join("|");
64
+ // ICU messages nest braces ({count, plural, one {# item} …}); their structure is
65
+ // compared by icuSkeleton() instead of as {…} tokens.
66
+ const ICU_TOKEN_SOURCE = TOKEN_PARTS.filter((p) => p !== BRACE).join("|");
67
+ const ICU_COMPLEX = /\{\s*[\w.-]+\s*,\s*(?:plural|selectordinal|select)\s*,/;
68
+
69
+ const tokenRegex = (icu) => new RegExp(icu ? ICU_TOKEN_SOURCE : TOKEN_SOURCE, "g");
70
+
71
+ export const isIcu = (text) => ICU_COMPLEX.test(text);
72
+
73
+ /** The protected tokens in a passage, in order. */
74
+ export function protectedTokens(text) {
75
+ return String(text).match(tokenRegex(isIcu(text))) || [];
76
+ }
77
+
78
+ /** Why `after` is not a safe replacement for `before`, or null when it is. */
79
+ export function tokenProblem(before, after) {
80
+ const count = new Map();
81
+ for (const t of protectedTokens(before)) count.set(t, (count.get(t) || 0) + 1);
82
+ for (const t of protectedTokens(after)) {
83
+ const n = count.get(t) || 0;
84
+ if (!n) return `added or changed ${short(t)}`;
85
+ count.set(t, n - 1);
86
+ }
87
+ for (const [t, n] of count) if (n > 0) return `lost ${short(t)}`;
88
+ if (isIcu(before) && icuSkeleton(before) !== icuSkeleton(after)) {
89
+ return "changed the ICU plural/select structure";
90
+ }
91
+ return null;
92
+ }
93
+
94
+ const short = (t) => (t.length > 60 ? `${t.slice(0, 57)}…` : t);
95
+
96
+ const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
97
+
98
+ /** The engine's proofreading layer spaces out inline code (`x y` → ` x y `),
99
+ * which changes nothing a reader needs: put each code span of the passage as
100
+ * sent back exactly. Only spans that were in the passage are touched, and
101
+ * only whitespace differences are repaired; tokenProblem() still checks the rest. */
102
+ export function repairCodeSpans(before, after) {
103
+ let out = String(after);
104
+ for (const span of new Set(String(before).match(new RegExp(CODE_SPAN, "g")) || [])) {
105
+ const ticks = span.match(/^`+/)[0];
106
+ const inner = span.slice(ticks.length, span.length - ticks.length).trim();
107
+ if (!inner) continue;
108
+ const loose = new RegExp(`${ticks}\\s*${escapeRe(inner).replace(/\s+/g, "\\s+")}\\s*${ticks}`, "g");
109
+ out = out.replace(loose, span);
110
+ }
111
+ return out;
112
+ }
113
+
114
+ // A lone token that reads as code rather than as a word: a file name, a path, a
115
+ // flag, camelCase, snake_case, a version. ALL CAPS words ("OK", "FAQ") are kept:
116
+ // UI strings need them translated.
117
+ function isCodeLike(word) {
118
+ return /^[-–]{1,2}[A-Za-z]/.test(word)
119
+ || /[A-Za-z0-9][._/\\:@][A-Za-z0-9]/.test(word)
120
+ || /^[a-z]+[A-Z]/.test(word)
121
+ || /^[A-Z][a-z]+[A-Z]/.test(word)
122
+ || /_/.test(word)
123
+ || (/\d/.test(word) && /[A-Za-z]/.test(word));
124
+ }
125
+
126
+ /** Does this passage hold anything to refine? Not when it is empty, only code,
127
+ * numbers, URLs or placeholders, or a single code-like token. */
128
+ export function needsWork(text) {
129
+ const t = String(text ?? "");
130
+ if (isIcu(t) && icuSkeleton(t) === null) return false; // can't verify it, so don't send it
131
+ const rest = t.replace(tokenRegex(isIcu(t)), " ").replace(/\\[!-/:-@[-`{-~]/g, " ");
132
+ if (!/\p{L}/u.test(rest)) return false;
133
+ const words = rest.trim().split(/\s+/);
134
+ if (words.length === 1) {
135
+ const word = words[0].replace(/^[("'“„«*_]+/, "").replace(/[.,;:!?…)"'”’»*_]+$/, "");
136
+ if (isCodeLike(word)) return false;
137
+ }
138
+ return true;
139
+ }
140
+
141
+ // ── ICU MessageFormat ───────────────────────────────────────────────────────
142
+
143
+ /** The shape of an ICU message without its words: argument names and types,
144
+ * plural/select selectors and `#`, sorted per level so a translation may
145
+ * reorder them. null when the message does not parse. */
146
+ export function icuSkeleton(text) {
147
+ const s = String(text);
148
+ let i = 0;
149
+ const fail = () => { throw new Error("icu"); };
150
+ const read = (re) => {
151
+ const sticky = new RegExp(re.source, "y");
152
+ sticky.lastIndex = i;
153
+ const m = sticky.exec(s);
154
+ if (!m) return null;
155
+ i += m[0].length;
156
+ return m[1];
157
+ };
158
+ const message = (inPlural, depth) => {
159
+ const items = [];
160
+ while (i < s.length) {
161
+ const ch = s[i];
162
+ if (ch === "'") {
163
+ if (s[i + 1] === "'") { i += 2; continue; }
164
+ if (/[{}#|]/.test(s[i + 1] || "")) {
165
+ const close = s.indexOf("'", i + 1);
166
+ i = close === -1 ? s.length : close + 1;
167
+ continue;
168
+ }
169
+ i++;
170
+ continue;
171
+ }
172
+ if (ch === "{") { i++; items.push(argument(inPlural, depth)); continue; }
173
+ if (ch === "}") { if (depth === 0) fail(); return items; }
174
+ if (ch === "#" && inPlural) items.push("#");
175
+ i++;
176
+ }
177
+ if (depth > 0) fail();
178
+ return items;
179
+ };
180
+ const argument = (inPlural, depth) => {
181
+ const name = read(/\s*([^\s,{}]+)\s*/) ?? fail();
182
+ if (s[i] === "}") { i++; return `{${name}}`; }
183
+ if (s[i] !== ",") fail();
184
+ i++;
185
+ const type = read(/\s*([A-Za-z]+)\s*/) ?? fail();
186
+ if (/^(?:plural|selectordinal|select)$/.test(type)) {
187
+ if (s[i] !== ",") fail();
188
+ i++;
189
+ const offset = read(/\s*(offset:\s*\d+)\s*/) || "";
190
+ const branches = [];
191
+ for (;;) {
192
+ const selector = read(/\s*(=?[\w-]+)\s*/);
193
+ if (selector === null) break;
194
+ if (s[i] !== "{") fail();
195
+ i++;
196
+ const sub = message(inPlural || type !== "select", depth + 1);
197
+ if (s[i] !== "}") fail();
198
+ i++;
199
+ branches.push(`${selector}[${sub.sort().join(",")}]`);
200
+ }
201
+ read(/(\s*)/);
202
+ if (s[i] !== "}") fail();
203
+ i++;
204
+ return `{${name},${type},${offset.replace(/\s+/g, "")}${branches.sort().join(";")}}`;
205
+ }
206
+ let style = "";
207
+ if (s[i] === ",") {
208
+ i++;
209
+ const start = i;
210
+ let d = 0;
211
+ while (i < s.length && !(s[i] === "}" && d === 0)) {
212
+ if (s[i] === "{") d++;
213
+ else if (s[i] === "}") d--;
214
+ i++;
215
+ }
216
+ style = s.slice(start, i).trim();
217
+ }
218
+ if (s[i] !== "}") fail();
219
+ i++;
220
+ return `{${name},${type}${style ? `,${style}` : ""}}`;
221
+ };
222
+ try {
223
+ return message(false, 0).sort().join(",");
224
+ } catch {
225
+ return null;
226
+ }
227
+ }
228
+
229
+ // ── shared helpers ──────────────────────────────────────────────────────────
230
+
231
+ /** Lines with offsets: [start, end) excludes the line ending; `next` is where
232
+ * the next line starts. */
233
+ function toLines(src) {
234
+ const lines = [];
235
+ let start = 0;
236
+ while (start < src.length) {
237
+ const nl = src.indexOf("\n", start);
238
+ const next = nl === -1 ? src.length : nl + 1;
239
+ let end = nl === -1 ? src.length : nl;
240
+ if (end > start && src[end - 1] === "\r") end--;
241
+ lines.push({ start, end, next });
242
+ start = next;
243
+ }
244
+ return lines;
245
+ }
246
+
247
+ const eolOf = (src) => (src.includes("\r\n") ? "\r\n" : "\n");
248
+ const oneLine = (s) => String(s).replace(/\s*[\r\n]+\s*/g, " ").trim();
249
+ const widthOf = (ws) => [...ws].reduce((n, ch) => n + (ch === "\t" ? 4 - (n % 4) : 1), 0);
250
+
251
+ // A refined paragraph must not turn into a different block (a heading, a list
252
+ // item, a quote) just because its first word changed. Escape that first
253
+ // character, unless the original began the same way.
254
+ const BLOCK_START = /^(?:#{1,6}(?:\s|$)|>|[-+*](?:\s|$)|\d{1,9}[.)](?:\s|$)|={3,}|`{3,}|~{3,}|\|)/;
255
+ function escapeBlockStart(text, original) {
256
+ if (!BLOCK_START.test(text) || BLOCK_START.test(original)) return text;
257
+ const num = /^(\d{1,9})([.)])/.exec(text);
258
+ return num ? `${num[1]}\\${text.slice(num[1].length)}` : `\\${text}`;
259
+ }
260
+
261
+ /** place() for a passage that is written back as one line of Markdown/text. */
262
+ function inlinePlacer(sent, { escapeStart = false, escapePipes = false } = {}) {
263
+ return (refined) => {
264
+ let value = oneLine(refined);
265
+ const problem = tokenProblem(sent, value);
266
+ if (problem) return { ok: false, reason: problem };
267
+ if (escapeStart) value = escapeBlockStart(value, sent);
268
+ if (escapePipes) value = value.replace(/(?<!\\)\|/g, "\\|");
269
+ return { ok: true, value };
270
+ };
271
+ }
272
+
273
+ const shiftUnit = (unit, by) => ({ ...unit, start: unit.start + by, end: unit.end + by });
274
+
275
+ // ── Markdown ────────────────────────────────────────────────────────────────
276
+
277
+ // Front-matter keys whose values are text a reader sees. Everything else in
278
+ // front matter (slugs, dates, layouts, tags) is left alone.
279
+ const FRONT_MATTER_KEYS = new Set([
280
+ "title", "subtitle", "description", "summary", "excerpt", "abstract", "lead", "caption", "alt",
281
+ "linktitle", "sidebar_label", "seo_title", "seo_description", "meta_title", "meta_description",
282
+ "og_title", "og_description",
283
+ ]);
284
+
285
+ // HTML block start tags (CommonMark type 6). A line that opens with one of them
286
+ // starts an HTML block whatever follows; inline tags only do when alone.
287
+ const HTML_BLOCK_TAGS = new Set([
288
+ "address", "article", "aside", "base", "blockquote", "body", "caption", "center", "col", "colgroup",
289
+ "dd", "details", "dialog", "dir", "div", "dl", "dt", "fieldset", "figcaption", "figure", "footer",
290
+ "form", "frame", "frameset", "h1", "h2", "h3", "h4", "h5", "h6", "head", "header", "hr", "html",
291
+ "iframe", "legend", "li", "link", "main", "menu", "nav", "ol", "optgroup", "option", "p", "param",
292
+ "picture", "section", "source", "summary", "table", "tbody", "td", "tfoot", "th", "thead", "title",
293
+ "tr", "track", "ul",
294
+ ]);
295
+
296
+ const FENCE = /^\s*(`{3,}|~{3,})/;
297
+ const DELIMITER_ROW = /^\s*\|?\s*:?-+:?\s*(?:\|\s*:?-+:?\s*)*\|?\s*$/;
298
+ const LIST_ITEM = /^([ \t]*)([-+*]|\d{1,9}[.)])([ \t]+|$)(\[[ xX]\][ \t]+)?/;
299
+ const TEMPLATE_LINE = /^\s*(?:\{%.*%\}|\{\{.*\}\}|<%.*%>)\s*$/;
300
+
301
+ function htmlBlockStart(c) {
302
+ if (/^ {0,3}<!--/.test(c)) return "comment";
303
+ const raw = /^ {0,3}<(script|pre|style|textarea)\b/i.exec(c);
304
+ if (raw) return raw[1].toLowerCase();
305
+ const tag = /^ {0,3}<\/?([A-Za-z][\w-]*)(?=[\s/>]|$)/.exec(c);
306
+ if (!tag) return null;
307
+ if (HTML_BLOCK_TAGS.has(tag[1].toLowerCase())) return "block";
308
+ return /^ {0,3}<\/?[A-Za-z][\w-]*(?:\s[^<>]*)?\/?>\s*$/.test(c) ? "block" : null;
309
+ }
310
+
311
+ /** Units of a Markdown document. */
312
+ export function segmentMarkdown(src) {
313
+ const lines = toLines(src);
314
+ const units = [];
315
+ const n = lines.length;
316
+ const text = (k) => src.slice(lines[k].start, lines[k].end);
317
+ const info = (k) => {
318
+ const t = text(k);
319
+ const bq = /^(?: {0,3}>[ \t]?)*/.exec(t)[0];
320
+ return { bq, depth: (bq.match(/>/g) || []).length, c: t.slice(bq.length), cStart: lines[k].start + bq.length };
321
+ };
322
+ const blank = (c) => /^\s*$/.test(c);
323
+ const push = (start, end, sent, place) => {
324
+ if (end > start && needsWork(sent)) units.push({ start, end, text: sent, place });
325
+ };
326
+
327
+ let i = 0;
328
+ // Front matter: only the reader-facing values listed above, one line each.
329
+ const fm = n ? /^\uFEFF?(---|\+\+\+)[ \t]*$/.exec(text(0)) : null;
330
+ if (fm) {
331
+ const marker = fm[1];
332
+ let j = 1;
333
+ while (j < n && !(text(j).trim() === marker || (marker === "---" && text(j).trim() === "..."))) j++;
334
+ if (j < n) {
335
+ for (let k = 1; k < j; k++) frontMatterUnit(src, lines[k], marker === "+++", push);
336
+ i = j + 1;
337
+ }
338
+ }
339
+
340
+ let listIndent = -1; // content column of the open list item, or -1
341
+ // true when no paragraph text is open: after a blank line or a block that is
342
+ // not a paragraph. Only then can an indented line start code, and a line at
343
+ // the margin end the open list.
344
+ let fresh = true;
345
+
346
+ // A paragraph from line k, its text starting at `from`. Soft-wrapped lines
347
+ // become one passage; a hard line break (two trailing spaces or a backslash)
348
+ // ends one, so addresses and verse keep their lines.
349
+ const paragraph = (k, from, depth) => {
350
+ const segs = [];
351
+ let j = k;
352
+ for (;;) {
353
+ const t = text(j);
354
+ const start = j === k ? from : lines[j].start + info(j).bq.length + /^\s*/.exec(info(j).c)[0].length;
355
+ const trailing = /[ \t]*$/.exec(t)[0];
356
+ // An odd run of backslashes at the very end is a hard break; the
357
+ // backslash stays outside the passage, like the trailing spaces.
358
+ const backslash = !trailing && /\\*$/.exec(t)[0].length % 2 === 1;
359
+ const hard = backslash || / {2,}$/.test(t);
360
+ segs.push({ start, end: lines[j].end - trailing.length - (backslash ? 1 : 0), hard });
361
+ j++;
362
+ if (j >= n) break;
363
+ const next = info(j);
364
+ if (next.depth !== depth || blank(next.c) || interrupts(next.c)) break;
365
+ }
366
+ if (j < n && info(j).depth === depth && /^ {0,3}(?:=+|-+)[ \t]*$/.test(info(j).c)) j++; // setext underline, kept
367
+ let group = [];
368
+ const emit = () => {
369
+ if (!group.length) return;
370
+ const sent = group.map((g) => src.slice(g.start, g.end).trim()).join(" ");
371
+ push(group[0].start, group[group.length - 1].end, sent, inlinePlacer(sent, { escapeStart: true }));
372
+ group = [];
373
+ };
374
+ for (const seg of segs) {
375
+ group.push(seg);
376
+ if (seg.hard) emit();
377
+ }
378
+ emit();
379
+ return j;
380
+ };
381
+
382
+ const interrupts = (c) => FENCE.test(c)
383
+ || /^ {0,3}#{1,6}(?:[ \t]|$)/.test(c)
384
+ || /^ {0,3}([-*_])(?:[ \t]*\1){2,}[ \t]*$/.test(c)
385
+ || /^\s*[-+*][ \t]+\S/.test(c)
386
+ || /^\s*1[.)][ \t]+\S/.test(c)
387
+ || htmlBlockStart(c) !== null
388
+ || TEMPLATE_LINE.test(c)
389
+ || /^\s*\$\$/.test(c);
390
+
391
+ while (i < n) {
392
+ const { depth, c, cStart } = info(i);
393
+ if (blank(c)) { fresh = true; i++; continue; }
394
+ const indent = widthOf(/^[ \t]*/.exec(c)[0]);
395
+ if (fresh && listIndent >= 0 && indent < Math.min(listIndent, 2) && !LIST_ITEM.test(c)) listIndent = -1;
396
+ let m;
397
+
398
+ // Fenced code, at any indentation: when in doubt, it is code and stays.
399
+ if ((m = FENCE.exec(c)) && !(m[1][0] === "`" && c.slice(m[0].length).includes("`"))) {
400
+ const ch = m[1][0];
401
+ const len = m[1].length;
402
+ i++;
403
+ while (i < n) {
404
+ const close = /^\s*(`{3,}|~{3,})\s*$/.exec(info(i).c);
405
+ i++;
406
+ if (close && close[1][0] === ch && close[1].length >= len) break;
407
+ }
408
+ fresh = true;
409
+ continue;
410
+ }
411
+ // $$ math $$
412
+ if (/^\s*\$\$/.test(c)) {
413
+ const oneLiner = /^\s*\$\$.*\$\$\s*$/.test(c) && c.trim().length > 4;
414
+ i++;
415
+ if (!oneLiner) {
416
+ while (i < n && !info(i).c.includes("$$")) i++;
417
+ i++;
418
+ }
419
+ fresh = true;
420
+ continue;
421
+ }
422
+ // Indented code: four columns past the open list item's content (or the
423
+ // margin), where no paragraph is open.
424
+ if (fresh && indent >= (listIndent >= 0 ? listIndent + 4 : 4)) {
425
+ const floor = listIndent >= 0 ? listIndent + 4 : 4;
426
+ i++;
427
+ while (i < n) {
428
+ const ci = info(i).c;
429
+ if (blank(ci)) {
430
+ let k = i;
431
+ while (k < n && blank(info(k).c)) k++;
432
+ if (k < n && widthOf(/^[ \t]*/.exec(info(k).c)[0]) >= floor) { i = k; continue; }
433
+ break;
434
+ }
435
+ if (widthOf(/^[ \t]*/.exec(ci)[0]) < floor) break;
436
+ i++;
437
+ }
438
+ fresh = true;
439
+ continue;
440
+ }
441
+ // HTML: comments and raw blocks stay; other blocks are segmented as HTML.
442
+ const html = htmlBlockStart(c);
443
+ if (html) {
444
+ const from = i;
445
+ if (html !== "block") {
446
+ // A comment or <script>/<pre>/<style>/<textarea>: kept whole, blank
447
+ // lines included, through the line that closes it.
448
+ const opener = html === "comment" ? "<!--" : `<${html}`;
449
+ const close = html === "comment" ? "-->" : `</${html}`;
450
+ while (i < n) {
451
+ const lower = text(i).toLowerCase();
452
+ const found = lower.includes(close, i === from ? lower.indexOf(opener) + opener.length : 0);
453
+ i++;
454
+ if (found) break;
455
+ }
456
+ } else {
457
+ while (i < n && !blank(info(i).c)) i++;
458
+ if (depth === 0) {
459
+ const blockStart = lines[from].start;
460
+ const blockEnd = lines[i - 1].end;
461
+ for (const u of segmentHtml(src.slice(blockStart, blockEnd)).units) units.push(shiftUnit(u, blockStart));
462
+ }
463
+ }
464
+ fresh = true;
465
+ continue;
466
+ }
467
+ if (TEMPLATE_LINE.test(c)) { i++; fresh = true; continue; }
468
+ // Thematic break
469
+ if (/^ {0,3}([-*_])(?:[ \t]*\1){2,}[ \t]*$/.test(c)) { i++; fresh = true; listIndent = -1; continue; }
470
+ // ATX heading: the text is refined, the #s and any {#id} stay.
471
+ if ((m = /^( {0,3}#{1,6})(?:[ \t]+|$)/.exec(c))) {
472
+ const body = c.slice(m[0].length);
473
+ const tail = /(?:[ \t]*\{[#.][^}]*\})?(?:[ \t]+#+)?[ \t]*$/.exec(body)[0];
474
+ const core = body.slice(0, body.length - tail.length);
475
+ const start = cStart + m[0].length;
476
+ push(start, start + core.length, core, inlinePlacer(core));
477
+ i++;
478
+ fresh = true;
479
+ listIndent = -1;
480
+ continue;
481
+ }
482
+ // Link reference definition
483
+ if (/^ {0,3}\[[^\]^][^\]]*\]:\s*\S/.test(c)) { i++; continue; }
484
+ // Table: each cell is a passage; pipes and the delimiter row stay.
485
+ if (c.includes("|") && i + 1 < n && info(i + 1).depth === depth && info(i + 1).c.includes("|") && DELIMITER_ROW.test(info(i + 1).c)) {
486
+ tableRow(src, info(i), push);
487
+ i += 2;
488
+ while (i < n && info(i).depth === depth && !blank(info(i).c) && info(i).c.includes("|")) {
489
+ tableRow(src, info(i), push);
490
+ i++;
491
+ }
492
+ fresh = false;
493
+ continue;
494
+ }
495
+ // List item: the marker (and a task box) stay, the item text is a passage.
496
+ if ((m = LIST_ITEM.exec(c))) {
497
+ listIndent = widthOf(m[1]) + m[2].length + (m[3] ? widthOf(m[3]) : 1);
498
+ if (blank(c.slice(m[0].length))) { i++; fresh = false; continue; }
499
+ i = paragraph(i, cStart + m[0].length, depth);
500
+ fresh = false;
501
+ continue;
502
+ }
503
+ // Footnote definition
504
+ if ((m = /^ {0,3}\[\^[^\]]+\]:[ \t]*/.exec(c))) {
505
+ i = paragraph(i, cStart + m[0].length, depth);
506
+ fresh = false;
507
+ continue;
508
+ }
509
+ i = paragraph(i, cStart + /^\s*/.exec(c)[0].length, depth);
510
+ fresh = false;
511
+ }
512
+ return { units };
513
+ }
514
+
515
+ function frontMatterUnit(src, line, toml, push) {
516
+ const t = src.slice(line.start, line.end);
517
+ const m = toml
518
+ ? /^([A-Za-z_][\w-]*)([ \t]*=[ \t]*)("(?:[^"\\]|\\.)*"|'[^']*')[ \t]*$/.exec(t)
519
+ : /^([A-Za-z_][\w-]*)(:[ \t]+)(.+?)[ \t]*$/.exec(t);
520
+ if (!m || !FRONT_MATTER_KEYS.has(m[1].toLowerCase())) return;
521
+ const at = line.start + m[1].length + m[2].length;
522
+ const raw = m[3];
523
+ if (raw.length >= 2 && raw[0] === '"' && raw.endsWith('"')) {
524
+ let value;
525
+ try { value = JSON.parse(raw); } catch { return; }
526
+ if (typeof value !== "string") return;
527
+ push(at + 1, at + raw.length - 1, value, (refined) => {
528
+ const r = oneLine(refined);
529
+ const problem = tokenProblem(value, r);
530
+ return problem ? { ok: false, reason: problem } : { ok: true, value: JSON.stringify(r).slice(1, -1) };
531
+ });
532
+ } else if (raw.length >= 2 && raw[0] === "'" && raw.endsWith("'")) {
533
+ const value = raw.slice(1, -1).replace(/''/g, "'");
534
+ push(at + 1, at + raw.length - 1, value, (refined) => {
535
+ const r = oneLine(refined);
536
+ const problem = tokenProblem(value, r);
537
+ return problem ? { ok: false, reason: problem } : { ok: true, value: r.replace(/'/g, "''") };
538
+ });
539
+ } else if (!toml && !/^[[{&*!|>%@`#"']/.test(raw) && !/\s#/.test(raw)) {
540
+ push(at, at + raw.length, raw, (refined) => {
541
+ const r = oneLine(refined);
542
+ const problem = tokenProblem(raw, r);
543
+ if (problem) return { ok: false, reason: problem };
544
+ // A plain YAML scalar must not grow a ": ", a " #", a leading marker or a
545
+ // boolean look; quote it when it would.
546
+ const plain = /^[\p{L}\p{N}(]/u.test(r) && !/:\s|:$|\s#|["'\\]/.test(r)
547
+ && !/^(?:true|false|yes|no|on|off|null|y|n|~)$/i.test(r);
548
+ return { ok: true, value: plain ? r : JSON.stringify(r) };
549
+ });
550
+ }
551
+ }
552
+
553
+ function tableRow(src, { c, cStart }, push) {
554
+ // GFM splits a row on every pipe that is not escaped, inside code spans too.
555
+ const cuts = [];
556
+ for (let k = 0; k < c.length; k++) {
557
+ if (c[k] === "\\") k++;
558
+ else if (c[k] === "|") cuts.push(k);
559
+ }
560
+ const bounds = [-1, ...cuts, c.length];
561
+ for (let b = 0; b < bounds.length - 1; b++) {
562
+ const from = bounds[b] + 1;
563
+ const to = bounds[b + 1];
564
+ const cell = c.slice(from, to);
565
+ const lead = /^\s*/.exec(cell)[0].length;
566
+ const core = cell.trim();
567
+ if (!core) continue;
568
+ const start = cStart + from + lead;
569
+ push(start, start + core.length, core, inlinePlacer(core, { escapePipes: true }));
570
+ }
571
+ }
572
+
573
+ // ── HTML ────────────────────────────────────────────────────────────────────
574
+
575
+ const RAWTEXT = new Set(["script", "style", "textarea", "title", "xmp", "noembed", "noframes"]);
576
+ export const VOID = new Set([
577
+ "area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "param", "source", "track", "wbr",
578
+ ]);
579
+ // Phrasing elements: their tags travel inside a passage as markers.
580
+ const INLINE = new Set([
581
+ "a", "abbr", "b", "bdi", "bdo", "big", "cite", "data", "del", "dfn", "em", "font", "i", "ins", "mark",
582
+ "q", "s", "small", "span", "strike", "strong", "sub", "sup", "time", "tt", "u", "wbr", "br", "img",
583
+ "nobr", "ruby", "rt", "rp",
584
+ ]);
585
+ // Content that is never sent. Inline ones travel as a single marker; the rest
586
+ // end a passage and are skipped whole.
587
+ const ATOMIC_INLINE = new Set(["code", "kbd", "samp", "var", "svg", "math"]);
588
+ const SKIP_BLOCK = new Set([
589
+ "pre", "script", "style", "textarea", "template", "noscript", "iframe", "object", "canvas", "video", "audio",
590
+ "listing", "plaintext", "xmp",
591
+ ]);
592
+
593
+ const NAMED_ENTITIES = {
594
+ amp: "&", lt: "<", gt: ">", quot: '"', apos: "'", nbsp: "\u00a0", shy: "\u00ad", ensp: "\u2002",
595
+ emsp: "\u2003", thinsp: "\u2009", zwnj: "\u200c", zwj: "\u200d", lrm: "\u200e", rlm: "\u200f",
596
+ ndash: "–", mdash: "—", hellip: "…", lsquo: "‘", rsquo: "’", sbquo: "‚", ldquo: "“", rdquo: "”",
597
+ bdquo: "„", laquo: "«", raquo: "»", lsaquo: "‹", rsaquo: "›", bull: "•", middot: "·", prime: "′",
598
+ copy: "©", reg: "®", trade: "™", deg: "°", euro: "€", pound: "£", yen: "¥", cent: "¢", sect: "§",
599
+ para: "¶", times: "×", divide: "÷", plusmn: "±", minus: "−", frac12: "½", frac14: "¼", frac34: "¾",
600
+ sup1: "¹", sup2: "²", sup3: "³", iexcl: "¡", iquest: "¿", dagger: "†", Dagger: "‡", larr: "←",
601
+ rarr: "→", uarr: "↑", darr: "↓", harr: "↔", check: "✓", hearts: "♥", star: "☆",
602
+ aacute: "á", Aacute: "Á", eacute: "é", Eacute: "É", iacute: "í", Iacute: "Í", oacute: "ó", Oacute: "Ó",
603
+ uacute: "ú", Uacute: "Ú", yacute: "ý", Yacute: "Ý", eth: "ð", ETH: "Ð", thorn: "þ", THORN: "Þ",
604
+ aelig: "æ", AElig: "Æ", ouml: "ö", Ouml: "Ö", auml: "ä", Auml: "Ä", uuml: "ü", Uuml: "Ü", oslash: "ø",
605
+ Oslash: "Ø", aring: "å", Aring: "Å", ccedil: "ç", Ccedil: "Ç", ntilde: "ñ", Ntilde: "Ñ", szlig: "ß",
606
+ agrave: "à", Agrave: "À", egrave: "è", Egrave: "È", ecirc: "ê", Ecirc: "Ê", euml: "ë", ocirc: "ô",
607
+ acirc: "â", icirc: "î", iuml: "ï", ugrave: "ù", ucirc: "û",
608
+ };
609
+
610
+ /** Decode character references. Unknown names stay as written. */
611
+ export function decodeEntities(s) {
612
+ return String(s).replace(/&(#\d{1,7}|#[xX][0-9a-fA-F]{1,6}|[A-Za-z][A-Za-z0-9]{1,31});/g, (m, e) => {
613
+ if (e[0] === "#") {
614
+ const cp = e[1] === "x" || e[1] === "X" ? parseInt(e.slice(2), 16) : parseInt(e.slice(1), 10);
615
+ return cp > 0 && cp <= 0x10ffff && !(cp >= 0xd800 && cp <= 0xdfff) ? String.fromCodePoint(cp) : m;
616
+ }
617
+ return Object.prototype.hasOwnProperty.call(NAMED_ENTITIES, e) ? NAMED_ENTITIES[e] : m;
618
+ });
619
+ }
620
+
621
+ function encodeHtmlText(s, asciiOnly) {
622
+ let out = s
623
+ .replace(/&(?![A-Za-z][A-Za-z0-9]{1,31};|#\d{1,7};|#[xX][0-9a-fA-F]{1,6};)/g, "&amp;")
624
+ .replace(/</g, "&lt;")
625
+ .replace(/>/g, "&gt;")
626
+ .replace(/\u00a0/g, "&nbsp;");
627
+ if (asciiOnly) out = out.replace(/[^\x00-\x7f]/gu, (ch) => `&#${ch.codePointAt(0)};`);
628
+ return out;
629
+ }
630
+
631
+ /** Tokens of an HTML document with offsets: text, tag, comment, rawtext (the
632
+ * content of script/style/textarea/title), doctype. Forgiving: anything that
633
+ * is not a well-formed tag is text. */
634
+ export function tokenizeHtml(src) {
635
+ const tokens = [];
636
+ const lower = src.toLowerCase();
637
+ const tagStart = /<(\/?)([A-Za-z][A-Za-z0-9:-]*)/y;
638
+ let i = 0;
639
+ const text = (start, end) => { if (end > start) tokens.push({ type: "text", start, end }); };
640
+ while (i < src.length) {
641
+ const lt = src.indexOf("<", i);
642
+ if (lt === -1) { text(i, src.length); break; }
643
+ text(i, lt);
644
+ if (src.startsWith("<!--", lt)) {
645
+ const close = src.indexOf("-->", lt + 4);
646
+ const end = close === -1 ? src.length : close + 3;
647
+ tokens.push({ type: "comment", start: lt, end });
648
+ i = end;
649
+ continue;
650
+ }
651
+ if (src[lt + 1] === "!" || src[lt + 1] === "?") {
652
+ const close = src.indexOf(">", lt);
653
+ const end = close === -1 ? src.length : close + 1;
654
+ tokens.push({ type: "doctype", start: lt, end });
655
+ i = end;
656
+ continue;
657
+ }
658
+ tagStart.lastIndex = lt;
659
+ const m = tagStart.exec(src);
660
+ if (!m) { text(lt, lt + 1); i = lt + 1; continue; }
661
+ let j = lt + m[0].length;
662
+ let quote = null;
663
+ while (j < src.length) {
664
+ const ch = src[j];
665
+ if (quote) { if (ch === quote) quote = null; } else if (ch === '"' || ch === "'") quote = ch;
666
+ else if (ch === ">") break;
667
+ j++;
668
+ }
669
+ const end = Math.min(j + 1, src.length);
670
+ const name = m[2].toLowerCase();
671
+ const closing = m[1] === "/";
672
+ const raw = src.slice(lt, end);
673
+ tokens.push({ type: "tag", start: lt, end, name, closing, selfClosing: /\/\s*>$/.test(raw), raw });
674
+ i = end;
675
+ if (!closing && RAWTEXT.has(name)) {
676
+ const close = lower.indexOf(`</${name}`, i);
677
+ const stop = close === -1 ? src.length : close;
678
+ tokens.push({ type: "rawtext", start: i, end: stop, parent: name });
679
+ i = stop;
680
+ }
681
+ }
682
+ return tokens;
683
+ }
684
+
685
+ /** The attributes of a tag token, names lower-cased, values decoded. */
686
+ export function tagAttributes(raw) {
687
+ const attrs = {};
688
+ const body = raw.replace(/^<\/?[A-Za-z][A-Za-z0-9:-]*/, "").replace(/\/?>$/, "");
689
+ const re = /([^\s"'<>/=]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+)))?/g;
690
+ let m;
691
+ while ((m = re.exec(body))) attrs[m[1].toLowerCase()] = decodeEntities(m[2] ?? m[3] ?? m[4] ?? "");
692
+ return attrs;
693
+ }
694
+
695
+ const noTranslate = (tag) => {
696
+ const a = tagAttributes(tag.raw);
697
+ return a.translate === "no" || /(?:^|\s)notranslate(?:\s|$)/.test(a.class || "");
698
+ };
699
+
700
+ /** Index of the tag that closes tokens[i] (same name, nesting counted), or -1. */
701
+ function closingIndex(tokens, i) {
702
+ const name = tokens[i].name;
703
+ let depth = 0;
704
+ for (let k = i + 1; k < tokens.length; k++) {
705
+ const t = tokens[k];
706
+ if (t.type !== "tag" || t.name !== name || VOID.has(name) || t.selfClosing) continue;
707
+ if (!t.closing) depth++;
708
+ else if (depth === 0) return k;
709
+ else depth--;
710
+ }
711
+ return -1;
712
+ }
713
+
714
+ /** Units of an HTML document: text in elements, never inside script, style,
715
+ * pre, code or anything marked translate="no". */
716
+ export function segmentHtml(src) {
717
+ const tokens = tokenizeHtml(src);
718
+ const asciiOnly = /<meta[^>]+charset\s*=\s*["']?\s*(?!utf-?8)[\w-]+/i.test(src);
719
+ const units = [];
720
+ let run = [];
721
+ const flush = () => {
722
+ const unit = htmlUnit(src, run, asciiOnly);
723
+ if (unit) units.push(unit);
724
+ run = [];
725
+ };
726
+ for (let i = 0; i < tokens.length; i++) {
727
+ const t = tokens[i];
728
+ if (t.type === "text") { run.push(t); continue; }
729
+ if (t.type === "comment") { run.push({ ...t, atomic: true }); continue; }
730
+ if (t.type === "rawtext") {
731
+ flush();
732
+ if (t.parent === "title") {
733
+ // The page title is text a reader sees (a browser tab, a search result).
734
+ const unit = htmlUnit(src, [{ type: "text", start: t.start, end: t.end }], asciiOnly);
735
+ if (unit) units.push(unit);
736
+ }
737
+ continue;
738
+ }
739
+ if (t.type !== "tag") { flush(); continue; }
740
+ if (t.closing) {
741
+ if (INLINE.has(t.name) || ATOMIC_INLINE.has(t.name)) run.push(t);
742
+ else flush();
743
+ continue;
744
+ }
745
+ const skip = SKIP_BLOCK.has(t.name) || ATOMIC_INLINE.has(t.name) || noTranslate(t);
746
+ if (skip && !VOID.has(t.name) && !t.selfClosing) {
747
+ const close = closingIndex(tokens, i);
748
+ const last = close === -1 ? tokens.length - 1 : close;
749
+ if (INLINE.has(t.name) || ATOMIC_INLINE.has(t.name)) {
750
+ run.push({ type: "atomic", name: t.name, start: t.start, end: tokens[last].end });
751
+ } else {
752
+ flush();
753
+ }
754
+ i = last;
755
+ continue;
756
+ }
757
+ if (INLINE.has(t.name)) run.push(t);
758
+ else flush();
759
+ }
760
+ flush();
761
+ return { units };
762
+ }
763
+
764
+ function htmlUnit(src, run, asciiOnly) {
765
+ // The passage runs from the first to the last visible thing in the run;
766
+ // whitespace around it stays where it is.
767
+ const significant = (t) => t.type !== "text" || /\S/.test(src.slice(t.start, t.end));
768
+ const first = run.findIndex(significant);
769
+ if (first === -1) return null;
770
+ let last = run.length - 1;
771
+ while (!significant(run[last])) last--;
772
+ const slice = run.slice(first, last + 1);
773
+ const firstT = slice[0];
774
+ const lastT = slice[slice.length - 1];
775
+ const start = firstT.type === "text" ? firstT.start + /^\s*/.exec(src.slice(firstT.start, firstT.end))[0].length : firstT.start;
776
+ const end = lastT.type === "text" ? lastT.end - /\s*$/.exec(src.slice(lastT.start, lastT.end))[0].length : lastT.end;
777
+
778
+ // Pair opening and closing inline tags inside the run; an unpaired one (the
779
+ // run starts or ends inside an element) travels as a single marker.
780
+ const pairOf = new Map();
781
+ const open = [];
782
+ slice.forEach((t, k) => {
783
+ if (t.type !== "tag" || VOID.has(t.name) || t.selfClosing) return;
784
+ if (!t.closing) open.push(k);
785
+ else {
786
+ const at = open.map((o) => slice[o].name).lastIndexOf(t.name);
787
+ if (at !== -1) {
788
+ const [o] = open.splice(at, 1);
789
+ open.length = Math.min(open.length, at);
790
+ pairOf.set(o, k);
791
+ pairOf.set(k, o);
792
+ }
793
+ }
794
+ });
795
+
796
+ const markers = new Map(); // marker → original source
797
+ const ids = new Map(); // token index → number
798
+ let next = 1;
799
+ let sent = "";
800
+ slice.forEach((t, k) => {
801
+ const s = Math.max(t.start, start);
802
+ const e = Math.min(t.end, end);
803
+ if (t.type === "text") {
804
+ sent += decodeEntities(src.slice(s, e)).replace(/\s+/g, " ");
805
+ return;
806
+ }
807
+ const name = (t.name || "x").replace(/[^a-z]/g, "") || "x";
808
+ let marker;
809
+ if (t.type === "tag" && pairOf.has(k)) {
810
+ const opening = !t.closing;
811
+ const id = opening ? next++ : ids.get(pairOf.get(k));
812
+ ids.set(k, id);
813
+ marker = opening ? `<${name}${id}>` : `</${name}${id}>`;
814
+ } else {
815
+ marker = `<${t.type === "comment" ? "x" : name}${next++}/>`;
816
+ }
817
+ markers.set(marker, src.slice(t.start, t.end));
818
+ sent += marker;
819
+ });
820
+ sent = sent.trim();
821
+ if (!needsWork(sent.replace(/<\/?[a-z]+\d+\/?>/g, " "))) return null;
822
+
823
+ const place = (refined) => {
824
+ const value = oneLine(refined);
825
+ const problem = tokenProblem(sent, value);
826
+ if (problem) return { ok: false, reason: problem };
827
+ for (const [marker] of markers) {
828
+ if (marker.startsWith("</")) {
829
+ const opening = `<${marker.slice(2)}`;
830
+ if (value.indexOf(opening) > value.indexOf(marker)) return { ok: false, reason: `moved ${marker} before ${opening}` };
831
+ }
832
+ }
833
+ let out = "";
834
+ let pos = 0;
835
+ const re = /<\/?[a-z]+\d+\/?>/g;
836
+ let m;
837
+ while ((m = re.exec(value))) {
838
+ if (!markers.has(m[0])) continue;
839
+ out += encodeHtmlText(value.slice(pos, m.index), asciiOnly) + markers.get(m[0]);
840
+ pos = m.index + m[0].length;
841
+ }
842
+ out += encodeHtmlText(value.slice(pos), asciiOnly);
843
+ return { ok: true, value: out };
844
+ };
845
+ return { start, end, text: sent, place };
846
+ }
847
+
848
+ // ── JSON (i18n catalogs) ────────────────────────────────────────────────────
849
+
850
+ // Configuration that happens to be JSON. Its strings are commands, names and
851
+ // paths, never prose, so it is copied unchanged.
852
+ const CONFIG_JSON = /^(?:package(?:-lock)?|composer|tsconfig(?:\..+)?|jsconfig(?:\..+)?|manifest|deno|lerna|nx|turbo|vercel|netlify|firebase|renovate|angular|app|babel\.config|eslintrc|prettierrc|bower|project|launch|settings|extensions|devcontainer|.+\.schema)\.json$/i;
853
+
854
+ export const isConfigJson = (fileName) => CONFIG_JSON.test(fileName);
855
+
856
+ const metaKey = (k) => typeof k === "string" && /^[@$]/.test(k);
857
+
858
+ function notCatalog(data) {
859
+ if (data === null || typeof data !== "object") return "not an object of strings";
860
+ let strings = 0;
861
+ let other = 0;
862
+ const walk = (v) => {
863
+ if (typeof v === "string") strings++;
864
+ else if (typeof v === "number" || typeof v === "boolean") other++;
865
+ else if (Array.isArray(v)) v.forEach(walk);
866
+ else if (v && typeof v === "object") for (const [k, x] of Object.entries(v)) if (!metaKey(k)) walk(x);
867
+ };
868
+ walk(data);
869
+ if (other) return "has numbers or true/false values, so it looks like configuration or data";
870
+ if (!strings) return "no strings";
871
+ return null;
872
+ }
873
+
874
+ /** Units of a JSON i18n catalog: string values, never keys, never metadata
875
+ * (keys starting with @ or $). Formatting, key order and escapes elsewhere in
876
+ * the file are untouched. `note` says why a file is not a catalog. */
877
+ export function segmentJson(src) {
878
+ const bom = src.startsWith("\uFEFF") ? 1 : 0;
879
+ let data;
880
+ try { data = JSON.parse(src.slice(bom)); } catch { return { units: [], note: "not valid JSON" }; }
881
+ const why = notCatalog(data);
882
+ if (why) return { units: [], note: why };
883
+ // A file written with \u escapes and no raw non-ASCII keeps that style.
884
+ const asciiOnly = !/[^\x00-\x7f]/.test(src.slice(bom)) && /\\u[0-9a-fA-F]{4}/.test(src);
885
+ const units = [];
886
+ const stack = [];
887
+ let i = bom;
888
+ while (i < src.length) {
889
+ const ch = src[i];
890
+ if (ch === '"') {
891
+ let j = i + 1;
892
+ while (j < src.length && src[j] !== '"') j += src[j] === "\\" ? 2 : 1;
893
+ const end = j + 1;
894
+ const value = JSON.parse(src.slice(i, end));
895
+ const top = stack[stack.length - 1];
896
+ if (top && top.type === "object" && top.expectKey) {
897
+ top.key = value;
898
+ top.expectKey = false;
899
+ } else if (!stack.some((s) => s.type === "object" && metaKey(s.key))) {
900
+ const lead = /^\s*/.exec(value)[0];
901
+ const trail = /\s*$/.exec(value.slice(lead.length))[0];
902
+ const sent = value.trim();
903
+ if (needsWork(sent)) {
904
+ units.push({
905
+ start: i,
906
+ end,
907
+ text: sent,
908
+ place: (refined) => {
909
+ const r = String(refined).trim();
910
+ const problem = tokenProblem(sent, r);
911
+ if (problem) return { ok: false, reason: problem };
912
+ let out = JSON.stringify(lead + r + trail);
913
+ if (asciiOnly) out = out.replace(/[\u0080-\uffff]/g, (c) => `\\u${c.charCodeAt(0).toString(16).padStart(4, "0")}`);
914
+ return { ok: true, value: out };
915
+ },
916
+ });
917
+ }
918
+ }
919
+ i = end;
920
+ continue;
921
+ }
922
+ if (ch === "{") stack.push({ type: "object", key: null, expectKey: true });
923
+ else if (ch === "[") stack.push({ type: "array" });
924
+ else if (ch === "}" || ch === "]") stack.pop();
925
+ else if (ch === ",") {
926
+ const top = stack[stack.length - 1];
927
+ if (top && top.type === "object") top.expectKey = true;
928
+ }
929
+ i++;
930
+ }
931
+ return { units };
932
+ }
933
+
934
+ // ── plain text ──────────────────────────────────────────────────────────────
935
+
936
+ const TEXT_LIST = /^([ \t]*(?:[-*•+]|\d{1,3}[.)])[ \t]+)/;
937
+
938
+ /** Units of a plain-text file. A hard-wrapped paragraph is one passage and is
939
+ * re-wrapped to its old width; short lines and list lines are one each. */
940
+ export function segmentText(src) {
941
+ const lines = toLines(src);
942
+ const eol = eolOf(src);
943
+ const units = [];
944
+ const text = (k) => src.slice(lines[k].start, lines[k].end);
945
+ let i = 0;
946
+ while (i < lines.length) {
947
+ if (!text(i).trim()) { i++; continue; }
948
+ let j = i;
949
+ while (j < lines.length && text(j).trim()) j++;
950
+ const block = [];
951
+ for (let k = i; k < j; k++) block.push(k);
952
+ const listy = block.some((k) => TEXT_LIST.test(text(k)));
953
+ const wrapped = block.length >= 2 && !listy && block.slice(0, -1).every((k) => text(k).trim().length >= 40);
954
+ if (wrapped) {
955
+ const start = lines[i].start + /^\s*/.exec(text(i))[0].length;
956
+ const end = lines[j - 1].end - /\s*$/.exec(text(j - 1))[0].length;
957
+ const sent = block.map((k) => text(k).trim()).join(" ");
958
+ const width = Math.max(40, ...block.map((k) => text(k).replace(/\s+$/, "").length));
959
+ const indent = /^[ \t]*/.exec(text(i + 1))[0];
960
+ const firstIndent = /^[ \t]*/.exec(text(i))[0].length;
961
+ if (needsWork(sent)) {
962
+ units.push({
963
+ start, end, text: sent,
964
+ place: (refined) => {
965
+ const r = oneLine(refined);
966
+ const problem = tokenProblem(sent, r);
967
+ return problem ? { ok: false, reason: problem } : { ok: true, value: wrap(r, width, firstIndent, indent, eol) };
968
+ },
969
+ });
970
+ }
971
+ } else {
972
+ for (const k of block) {
973
+ const t = text(k);
974
+ const prefix = (TEXT_LIST.exec(t) || /^[ \t]*/.exec(t))[0];
975
+ const core = t.slice(prefix.length).replace(/\s+$/, "");
976
+ const start = lines[k].start + prefix.length;
977
+ if (needsWork(core)) units.push({ start, end: start + core.length, text: core, place: inlinePlacer(core) });
978
+ }
979
+ }
980
+ i = j;
981
+ }
982
+ return { units };
983
+ }
984
+
985
+ function wrap(text, width, firstIndent, indent, eol) {
986
+ const words = text.split(" ");
987
+ const out = [];
988
+ let line = "";
989
+ let room = width - firstIndent;
990
+ for (const w of words) {
991
+ if (line && line.length + 1 + w.length > room) {
992
+ out.push(line);
993
+ line = w;
994
+ room = width - indent.length;
995
+ } else {
996
+ line = line ? `${line} ${w}` : w;
997
+ }
998
+ }
999
+ if (line) out.push(line);
1000
+ return out.join(eol + indent);
1001
+ }
1002
+
1003
+ // ── long passages ───────────────────────────────────────────────────────────
1004
+
1005
+ /** Split a passage longer than the API allows at sentence boundaries. Returns
1006
+ * the pieces and the whitespace between them, so that
1007
+ * pieces[0] + joins[0] + pieces[1] + … is the passage again. */
1008
+ export function splitForApi(text, max = API_MAX_CHARS) {
1009
+ if (text.length <= max) return { pieces: [text], joins: [] };
1010
+ const parts = text.split(/(?<=[.!?…]["'”’»)\]]*)(\s+)/);
1011
+ const sentences = [];
1012
+ for (let k = 0; k < parts.length; k += 2) sentences.push({ text: parts[k], sep: parts[k + 1] ?? "" });
1013
+ const pieces = [];
1014
+ const joins = [];
1015
+ let current = "";
1016
+ let pendingSep = "";
1017
+ const emit = (sep) => { pieces.push(current); joins.push(sep); current = ""; };
1018
+ for (const { text: sentence, sep } of sentences) {
1019
+ let s = sentence;
1020
+ while (s.length > max) {
1021
+ // One sentence over the limit: cut at the last space that fits.
1022
+ if (current) emit(pendingSep);
1023
+ let cut = s.lastIndexOf(" ", max);
1024
+ if (cut <= 0) cut = max - (/[\ud800-\udbff]/.test(s[max - 1]) ? 1 : 0);
1025
+ current = s.slice(0, cut);
1026
+ emit(s[cut] === " " ? " " : "");
1027
+ s = s[cut] === " " ? s.slice(cut + 1) : s.slice(cut);
1028
+ }
1029
+ if (current && current.length + pendingSep.length + s.length > max) emit(pendingSep);
1030
+ current = current ? current + pendingSep + s : s;
1031
+ pendingSep = sep;
1032
+ }
1033
+ if (current || !pieces.length) { pieces.push(current); joins.push(pendingSep); }
1034
+ joins.pop();
1035
+ return { pieces, joins };
1036
+ }
1037
+
1038
+ // ── documents ───────────────────────────────────────────────────────────────
1039
+
1040
+ export const FORMATS = { ".md": "md", ".markdown": "md", ".txt": "txt", ".html": "html", ".htm": "html", ".json": "json" };
1041
+
1042
+ /** Segment a document by format: { units, note? }. */
1043
+ export function segment(src, format) {
1044
+ if (format === "md") return segmentMarkdown(src);
1045
+ if (format === "html") return segmentHtml(src);
1046
+ if (format === "json") return segmentJson(src);
1047
+ return segmentText(src);
1048
+ }
1049
+
1050
+ /** The document with each unit replaced by `values.get(unit)` when present. */
1051
+ export function assemble(src, units, values) {
1052
+ let out = "";
1053
+ let pos = 0;
1054
+ for (const u of units) {
1055
+ out += src.slice(pos, u.start) + (values.has(u) ? values.get(u) : src.slice(u.start, u.end));
1056
+ pos = u.end;
1057
+ }
1058
+ return out + src.slice(pos);
1059
+ }