pi-declaw 0.2.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 (70) hide show
  1. package/ARCHITECTURE.md +90 -0
  2. package/GLOSSARY.md +48 -0
  3. package/LICENSE +201 -0
  4. package/NOTICE +30 -0
  5. package/PLUGIN_GUIDE.md +142 -0
  6. package/README.md +161 -0
  7. package/SOURCES.md +139 -0
  8. package/index.ts +2 -0
  9. package/licenses/asd-ste100-skill-LICENSE +21 -0
  10. package/licenses/i-have-adhd-LICENSE +21 -0
  11. package/licenses/speak-like-you-eat-LICENSE +9 -0
  12. package/licenses/squirrel-mode-LICENSE +21 -0
  13. package/package.json +63 -0
  14. package/playground/README.md +64 -0
  15. package/playground/app.js +123 -0
  16. package/playground/assets/declaw-e.png +0 -0
  17. package/playground/assets/declaw-favicon.png +0 -0
  18. package/playground/assets/declaw-wordmark-charcoal.png +0 -0
  19. package/playground/assets/declaw-wordmark-white.png +0 -0
  20. package/playground/assets/geist.woff2 +0 -0
  21. package/playground/assets/licenses/Claudish-MIT.txt +21 -0
  22. package/playground/assets/licenses/Geist-NOTICE.txt +4 -0
  23. package/playground/assets/licenses/Geist-OFL.txt +93 -0
  24. package/playground/assets/licenses/asd-ste100-skill-LICENSE +21 -0
  25. package/playground/assets/licenses/i-have-adhd-LICENSE +21 -0
  26. package/playground/assets/licenses/paseo-plain-LICENSE +211 -0
  27. package/playground/assets/licenses/squirrel-mode-LICENSE +21 -0
  28. package/playground/build.ts +180 -0
  29. package/playground/credits.html +14 -0
  30. package/playground/diff.ts +65 -0
  31. package/playground/evidence.css +1 -0
  32. package/playground/index.html +96 -0
  33. package/playground/inline-diff.ts +131 -0
  34. package/playground/markdown.ts +239 -0
  35. package/playground/samples.json +244 -0
  36. package/playground/serve.ts +21 -0
  37. package/playground/styles.css +30 -0
  38. package/playground/unified.ts +136 -0
  39. package/src/adapters/command.ts +203 -0
  40. package/src/adapters/model.ts +53 -0
  41. package/src/adapters/pi.ts +56 -0
  42. package/src/adapters/settings.ts +117 -0
  43. package/src/application/rewrite.ts +39 -0
  44. package/src/domain/preservation.ts +62 -0
  45. package/src/domain/rewrite.ts +55 -0
  46. package/src/domain/styles.ts +169 -0
  47. package/src/extension.ts +50 -0
  48. package/src/plugin-api.ts +88 -0
  49. package/src/plugins/built-in/asd-ste100/plugin.ts +19 -0
  50. package/src/plugins/built-in/asd-ste100/prompt.ts +24 -0
  51. package/src/plugins/built-in/catalog.ts +17 -0
  52. package/src/plugins/built-in/i-have-adhd/plugin.ts +19 -0
  53. package/src/plugins/built-in/i-have-adhd/prompt.ts +20 -0
  54. package/src/plugins/built-in/index.ts +17 -0
  55. package/src/plugins/built-in/paseo-plain/plugin.ts +26 -0
  56. package/src/plugins/built-in/paseo-plain/upstream/Claudish-MIT.txt +21 -0
  57. package/src/plugins/built-in/paseo-plain/upstream/LICENSE +211 -0
  58. package/src/plugins/built-in/paseo-plain/upstream/NOTICE +16 -0
  59. package/src/plugins/built-in/paseo-plain/upstream/prompt.ts +351 -0
  60. package/src/plugins/built-in/paseo-plain/upstream/rewriter.ts +46 -0
  61. package/src/plugins/built-in/shared/adapted-rules.ts +28 -0
  62. package/src/plugins/built-in/shared/json-payload.ts +5 -0
  63. package/src/plugins/built-in/speak-like-you-eat/payload.ts +5 -0
  64. package/src/plugins/built-in/speak-like-you-eat/plugin.ts +26 -0
  65. package/src/plugins/built-in/speak-like-you-eat/prompt.ts +4 -0
  66. package/src/plugins/built-in/speak-like-you-eat/upstream/model-rewrite.ts +19 -0
  67. package/src/plugins/built-in/squirrel-mode/plugin.ts +19 -0
  68. package/src/plugins/built-in/squirrel-mode/prompt.ts +24 -0
  69. package/src/plugins/built-in/terse/plugin.ts +18 -0
  70. package/src/plugins/built-in/terse/prompt.ts +9 -0
@@ -0,0 +1,131 @@
1
+ import { diffWords } from './diff.ts';
2
+
3
+ type Node = { tag: string; attrs: string; text: string; children: Node[] };
4
+ type Segment = { start: number; end: number; tags: string[] };
5
+ const inlineTags = new Set(['strong', 'em', 'code']);
6
+ const allowed = /^(p|h[1-6]|strong|em|code|pre|ol|ul|li|table|thead|tbody|tr|th|td)$/;
7
+ const unmark = (html: string) => html.replace(/<(?:del|ins) class="change">|<\/(?:del|ins)>/g, '');
8
+ const escape = (s: string) => s.replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c]!);
9
+ const decode = (s: string) => s.replace(/&(amp|lt|gt|quot|#39);/g, (_, entity: string) => ({ amp: '&', lt: '<', gt: '>', quot: '"', '#39': "'" })[entity]!);
10
+
11
+ /** Parse only the bounded, inert HTML produced by markdown.ts. Never raw model HTML. */
12
+ function parse(html: string): Node {
13
+ const root: Node = { tag: '', attrs: '', text: '', children: [] }, stack = [root];
14
+ for (const token of unmark(html).matchAll(/<([^>]+)>|([^<]+)/g)) {
15
+ if (token[2] !== undefined) {
16
+ stack.at(-1)!.children.push({ tag: '', attrs: '', text: decode(token[2]), children: [] });
17
+ } else if (token[1].startsWith('/')) {
18
+ if (stack.length === 1 || stack.pop()!.tag !== token[1].slice(1)) throw new Error('Unbalanced generated Markdown');
19
+ } else {
20
+ const match = /^([a-z][a-z0-9]*)(.*)$/.exec(token[1]);
21
+ if (!match || !allowed.test(match[1]) || !(match[2] === '' || match[1] === 'ol' && /^ start="\d+"$/.test(match[2]))) throw new Error('Unsupported generated Markdown tag');
22
+ if (stack.length > 96) throw new Error('Generated Markdown nesting limit');
23
+ const node: Node = { tag: match[1], attrs: match[2], text: '', children: [] };
24
+ stack.at(-1)!.children.push(node); stack.push(node);
25
+ }
26
+ }
27
+ if (stack.length !== 1) throw new Error('Unclosed generated Markdown');
28
+ return root;
29
+ }
30
+ const serialize = (n: Node): string => n.tag ? `<${n.tag}${n.attrs}>${n.children.map(serialize).join('')}</${n.tag}>` : escape(n.text);
31
+ const phrasing = (n: Node): boolean => !n.tag || inlineTags.has(n.tag) && n.children.every(phrasing);
32
+ const children = (n: Node) => n.children.some(c => c.tag && !inlineTags.has(c.tag))
33
+ ? n.children.filter(c => c.tag || c.text.trim()) : n.children;
34
+ const sameShape = (a: Node, b: Node) => a.tag === b.tag && a.attrs === b.attrs;
35
+ function marked(n: Node, kind: 'remove' | 'add'): string {
36
+ if (!n.tag) return n.text ? `<${kind === 'remove' ? 'del' : 'ins'} class="change">${escape(n.text)}</${kind === 'remove' ? 'del' : 'ins'}>` : '';
37
+ return `<${n.tag}${n.attrs}>${n.children.map(c => marked(c, kind)).join('')}</${n.tag}>`;
38
+ }
39
+ function flatten(nodes: Node[]) {
40
+ let text = ''; const segments: Segment[] = [];
41
+ function visit(node: Node, tags: string[]) {
42
+ if (!node.tag) {
43
+ const start = text.length; text += node.text;
44
+ if (text.length > start) segments.push({ start, end: text.length, tags });
45
+ } else node.children.forEach(c => visit(c, [...tags, node.tag]));
46
+ }
47
+ nodes.forEach(n => visit(n, []));
48
+ return { text, segments };
49
+ }
50
+ function inline(before: Node[], after: Node[], allowGap: boolean): string {
51
+ const left = flatten(before), right = flatten(after);
52
+ if (left.text === right.text && before.map(serialize).join('') !== after.map(serialize).join('')) {
53
+ return before.map(n => marked(n, 'remove')).join('') + (allowGap ? '<span class="diff-gap"> </span>' : '') + after.map(n => marked(n, 'add')).join('');
54
+ }
55
+ const parts = diffWords(left.text, right.text).parts;
56
+ let a = 0, b = 0, output = '';
57
+ for (let i = 0; i < parts.length; i++) {
58
+ const part = parts[i], side = part.kind === 'remove' ? left : right;
59
+ const start = part.kind === 'remove' ? a : b, end = start + part.text.length;
60
+ // This is presentation spacing between two alternatives, not source content.
61
+ if (allowGap && part.kind === 'add' && parts[i - 1]?.kind === 'remove' && /\S$/.test(parts[i - 1].text) && /^\S/.test(part.text)) output += '<span class="diff-gap"> </span>';
62
+ let low = 0, high = side.segments.length;
63
+ while (low < high) { const mid = (low + high) >>> 1; if (side.segments[mid].end <= start) low = mid + 1; else high = mid; }
64
+ for (let at = low; at < side.segments.length && side.segments[at].start < end; at++) {
65
+ const segment = side.segments[at];
66
+ let leaf = escape(side.text.slice(Math.max(start, segment.start), Math.min(end, segment.end)));
67
+ if (part.kind !== 'equal') {
68
+ const tag = part.kind === 'remove' ? 'del' : 'ins';
69
+ leaf = `<${tag} class="change">${leaf}</${tag}>`;
70
+ }
71
+ for (const tag of [...segment.tags].reverse()) leaf = `<${tag}>${leaf}</${tag}>`;
72
+ output += leaf;
73
+ }
74
+ if (part.kind !== 'add') a += part.text.length;
75
+ if (part.kind !== 'remove') b += part.text.length;
76
+ }
77
+ return output;
78
+ }
79
+
80
+ export function markBlock(html: string, kind: 'remove' | 'add'): string {
81
+ return children(parse(html)).map(n => marked(n, kind)).join('');
82
+ }
83
+
84
+ /** Merge compatible paragraphs, list items and table cells in place. Common text
85
+ * occurs once; replacements are adjacent del/ins within the same sentence.
86
+ * Structural insertions/deletions remain at their actual tree positions.
87
+ */
88
+ export function mergeInlineBlocks(beforeHtml: string, afterHtml: string): string | null {
89
+ const a = children(parse(beforeHtml)), b = children(parse(afterHtml));
90
+ if (a.length !== 1 || b.length !== 1 || !sameShape(a[0], b[0])) return null;
91
+ let budget = 250_000;
92
+ function merge(left: Node, right: Node): string {
93
+ if (serialize(left) === serialize(right)) return serialize(right);
94
+ if (!left.tag) return inline([left], [right], true);
95
+ const body = left.children.every(phrasing) && right.children.every(phrasing)
96
+ ? inline(left.children, right.children, left.tag !== 'pre' && left.tag !== 'code')
97
+ : sequence(children(left), children(right));
98
+ return `<${right.tag}${right.attrs}>${body}</${right.tag}>`;
99
+ }
100
+ function sequence(left: Node[], right: Node[]): string {
101
+ const leftKeys = left.map(serialize), rightKeys = right.map(serialize);
102
+ let output = '';
103
+ function pairs(from: number, to: number, otherFrom: number, otherTo: number) {
104
+ while (from < to || otherFrom < otherTo) {
105
+ if (from < to && otherFrom < otherTo && sameShape(left[from], right[otherFrom])) {
106
+ output += merge(left[from++], right[otherFrom++]);
107
+ } else {
108
+ if (from < to) output += marked(left[from++], 'remove');
109
+ if (otherFrom < otherTo) output += marked(right[otherFrom++], 'add');
110
+ }
111
+ }
112
+ }
113
+ const columns = right.length + 1, cells = (left.length + 1) * columns;
114
+ if (cells > budget) { pairs(0, left.length, 0, right.length); return output; }
115
+ budget -= cells;
116
+ const lengths = new Uint32Array(cells);
117
+ for (let i = left.length - 1; i >= 0; i--) for (let j = right.length - 1; j >= 0; j--) {
118
+ lengths[i * columns + j] = leftKeys[i] === rightKeys[j] ? 1 + lengths[(i + 1) * columns + j + 1] : Math.max(lengths[(i + 1) * columns + j], lengths[i * columns + j + 1]);
119
+ }
120
+ let i = 0, j = 0, start = 0, otherStart = 0;
121
+ while (i < left.length && j < right.length) {
122
+ if (leftKeys[i] === rightKeys[j]) {
123
+ pairs(start, i, otherStart, j); output += rightKeys[j]; start = ++i; otherStart = ++j;
124
+ } else if (lengths[(i + 1) * columns + j] >= lengths[i * columns + j + 1]) i++;
125
+ else j++;
126
+ }
127
+ pairs(start, left.length, otherStart, right.length);
128
+ return output;
129
+ }
130
+ return merge(a[0], b[0]);
131
+ }
@@ -0,0 +1,239 @@
1
+ import { diffWords } from './diff.ts';
2
+
3
+ /** Half-open UTF-16 offsets into the unmodified Markdown source. */
4
+ export interface Mark { start: number; end: number; kind: 'remove' | 'add' }
5
+ interface Range { start: number; end: number }
6
+ interface Line extends Range { next: number }
7
+ const escape = (text: string) => text.replace(/[&<>"']/g, char =>
8
+ ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[char]!);
9
+
10
+ /** A small, inert Markdown subset, not CommonMark. Links/images remain literal text.
11
+ * Line views retain source offsets when list indentation is removed. Marks are
12
+ * emitted only at literal leaves, so they cannot alter parsing or cross tags.
13
+ * Depth and delimiter-search budgets fall back to literal text, never truncation.
14
+ */
15
+ export function renderMarkdown(source: string, marks: Mark[] = []): string {
16
+ const ranges: Mark[] = [];
17
+ for (const mark of marks.filter(m => Number.isInteger(m.start) && Number.isInteger(m.end) &&
18
+ (m.kind === 'remove' || m.kind === 'add')).slice().sort((a, b) => a.start - b.start)) {
19
+ const start = Math.max(0, mark.start, ranges.at(-1)?.end ?? 0);
20
+ const end = Math.min(source.length, mark.end);
21
+ if (end > start) ranges.push({ start, end, kind: mark.kind });
22
+ }
23
+ const literal = (start: number, end: number): string => {
24
+ if (start >= end) return '';
25
+ let html = '', cursor = start, low = 0, high = ranges.length;
26
+ while (low < high) {
27
+ const mid = (low + high) >>> 1;
28
+ if (ranges[mid].end <= start) low = mid + 1;
29
+ else high = mid;
30
+ }
31
+ for (let i = low; i < ranges.length && ranges[i].start < end; i++) {
32
+ const mark = ranges[i], a = Math.max(cursor, mark.start), b = Math.min(end, mark.end);
33
+ html += escape(source.slice(cursor, a));
34
+ const tag = mark.kind === 'remove' ? 'del' : 'ins';
35
+ html += `<${tag} class="change">${escape(source.slice(a, b))}</${tag}>`;
36
+ cursor = b;
37
+ }
38
+ return html + escape(source.slice(cursor, end));
39
+ };
40
+ let searches = 100_000;
41
+ const inline = (start: number, end: number, depth = 0): string => {
42
+ if (depth >= 16 || searches <= 0) return literal(start, end);
43
+ const text = source.slice(start, end);
44
+ const tokens = [...text.matchAll(/\\[!"#$%&'()*+,\-./:;<=>?@[\]\\^_`{|}~]|`+|\*+|_+/g)];
45
+ let html = '', cursor = 0;
46
+ const codeEnd = (index: number) => {
47
+ const delimiter = tokens[index][0];
48
+ let at = tokens[index].index! + delimiter.length;
49
+ while (searches-- > 0 && (at = text.indexOf(delimiter, at)) >= 0) {
50
+ // Backslashes are literal inside code; only an exact backtick run closes it.
51
+ if (text[at - 1] !== '`' && text[at + delimiter.length] !== '`') return at;
52
+ at += delimiter.length;
53
+ }
54
+ return -1;
55
+ };
56
+ for (let i = 0; i < tokens.length && searches > 0; i++) {
57
+ const token = tokens[i], at = token.index!, delimiter = token[0];
58
+ if (at < cursor) continue;
59
+ html += literal(start + cursor, start + at);
60
+ cursor = at;
61
+ if (delimiter[0] === '\\') {
62
+ html += literal(start + at + 1, start + at + 2);
63
+ cursor += 2;
64
+ continue;
65
+ }
66
+ if (delimiter[0] === '`') {
67
+ const close = codeEnd(i);
68
+ if (close >= 0) {
69
+ html += `<code>${literal(start + at + delimiter.length, start + close)}</code>`;
70
+ cursor = close + delimiter.length;
71
+ } else {
72
+ html += literal(start + at, start + at + delimiter.length);
73
+ cursor += delimiter.length;
74
+ }
75
+ continue;
76
+ }
77
+ let close = -1;
78
+ if (delimiter.length <= 3 && /\S/.test(text[at + delimiter.length] ?? '') &&
79
+ !(delimiter[0] === '_' && /[\p{L}\p{N}_]/u.test(text[at - 1] ?? ''))) {
80
+ for (let j = i + 1; j < tokens.length && searches-- > 0; j++) {
81
+ if (tokens[j][0][0] === '`') {
82
+ const codeClose = codeEnd(j);
83
+ if (codeClose >= 0) {
84
+ const next = codeClose + tokens[j][0].length;
85
+ while (j + 1 < tokens.length && tokens[j + 1].index! < next) j++;
86
+ continue;
87
+ }
88
+ }
89
+ if (tokens[j][0] === delimiter && /\S/.test(text[tokens[j].index! - 1] ?? '') &&
90
+ !(delimiter[0] === '_' && /[\p{L}\p{N}_]/u.test(text[tokens[j].index! + delimiter.length] ?? ''))) {
91
+ close = j; break;
92
+ }
93
+ }
94
+ }
95
+ if (close >= 0) {
96
+ const bodyStart = start + at + delimiter.length, bodyEnd = start + tokens[close].index!;
97
+ const tags = delimiter.length === 3 ? ['strong', 'em'] : [delimiter.length === 2 ? 'strong' : 'em'];
98
+ html += tags.map(t => `<${t}>`).join('') + inline(bodyStart, bodyEnd, depth + 1) +
99
+ [...tags].reverse().map(t => `</${t}>`).join('');
100
+ cursor = tokens[close].index! + delimiter.length;
101
+ i = close;
102
+ } else {
103
+ html += literal(start + at, start + at + delimiter.length);
104
+ cursor += delimiter.length;
105
+ }
106
+ }
107
+ return html + literal(start + cursor, end);
108
+ };
109
+ const lines: Line[] = [];
110
+ for (let start = 0; start < source.length;) {
111
+ const lf = source.indexOf('\n', start), next = lf < 0 ? source.length : lf + 1;
112
+ const end = lf < 0 ? next : lf > start && source[lf - 1] === '\r' ? lf - 1 : lf;
113
+ lines.push({ start, end, next });
114
+ start = next;
115
+ }
116
+ const text = (line: Range) => source.slice(line.start, line.end);
117
+ const blank = (line: Line) => !text(line).trim();
118
+ const trim = (range: Range): Range => {
119
+ const value = text(range), left = value.length - value.trimStart().length;
120
+ return { start: range.start + left, end: Math.max(range.start + left, range.end - (value.length - value.trimEnd().length)) };
121
+ };
122
+ const heading = (line: Line) => /^ {0,3}(#{1,6})(?:[ \t]+|$)/.exec(text(line));
123
+ const fence = (line: Line) => /^ {0,3}(`{3,}|~{3,})([^\r\n]*)$/.exec(text(line));
124
+ const item = (line: Line) => /^( {0,3})([-+*]|\d{1,9}[.)])([ \t]+|$)/.exec(text(line));
125
+ const indent = (line: Line) => /^ */.exec(text(line))![0].length;
126
+ const cells = (line: Line): Range[] | null => {
127
+ const value = text(line), cuts: number[] = [];
128
+ let ticks = 0;
129
+ for (let i = 0; i < value.length; i++) {
130
+ if (value[i] === '\\') { i++; continue; }
131
+ if (value[i] === '`') {
132
+ let count = 1;
133
+ while (value[i + count] === '`') count++;
134
+ ticks = ticks === count ? 0 : ticks || count;
135
+ i += count - 1;
136
+ } else if (value[i] === '|' && !ticks) cuts.push(line.start + i);
137
+ }
138
+ if (!cuts.length) return null;
139
+ const bounds = [line.start - 1, ...cuts, line.end];
140
+ const result = bounds.slice(0, -1).map((bound, i) => trim({ start: bound + 1, end: bounds[i + 1] }));
141
+ if (result[0].start === result[0].end) result.shift();
142
+ if (result.at(-1)?.start === result.at(-1)?.end) result.pop();
143
+ return result;
144
+ };
145
+ const tableHeader = (rows: Line[], index: number) => {
146
+ if (!rows[index + 1]) return null;
147
+ const header = cells(rows[index]), rule = cells(rows[index + 1]);
148
+ return header?.length && rule?.length === header.length && rule.every(cell => /^:?-{3,}:?$/.test(text(cell)))
149
+ ? header : null;
150
+ };
151
+ const blocks = (rows: Line[], depth = 0): string => {
152
+ if (depth >= 32) return `<pre>${rows.map(line => literal(line.start, line.next)).join('')}</pre>`;
153
+ const out: string[] = [];
154
+ for (let i = 0; i < rows.length;) {
155
+ const line = rows[i];
156
+ if (blank(line)) { i++; continue; }
157
+ const opening = fence(line);
158
+ if (opening) {
159
+ const closing = new RegExp(`^ {0,3}${opening[1][0]}{${opening[1].length},}[ \\t]*$`);
160
+ const body: string[] = [];
161
+ for (i++; i < rows.length && !closing.test(text(rows[i])); i++) body.push(literal(rows[i].start, rows[i].next));
162
+ if (i < rows.length) i++;
163
+ out.push(`<pre><code>${body.join('')}</code></pre>`);
164
+ continue;
165
+ }
166
+ const title = heading(line);
167
+ if (title) {
168
+ const level = title[1].length;
169
+ out.push(`<h${level}>${inline(line.start + title[0].length, line.end)}</h${level}>`);
170
+ i++; continue;
171
+ }
172
+ const first = item(line);
173
+ if (first) {
174
+ const ordered = /^\d/.test(first[2]), tag = ordered ? 'ol' : 'ul', base = first[1].length;
175
+ const start = ordered ? ` start="${Number.parseInt(first[2], 10)}"` : '';
176
+ const entries: string[] = [];
177
+ while (i < rows.length) {
178
+ const marker = item(rows[i]);
179
+ if (!marker || marker[1].length !== base || /^\d/.test(marker[2]) !== ordered) break;
180
+ const width = marker[0].length;
181
+ const children: Line[] = [{ ...rows[i], start: rows[i].start + width }];
182
+ i++;
183
+ while (i < rows.length) {
184
+ if (blank(rows[i])) {
185
+ children.push({ ...rows[i], start: rows[i].start + Math.min(width, indent(rows[i])) });
186
+ i++; continue;
187
+ }
188
+ if (indent(rows[i]) < width) break;
189
+ children.push({ ...rows[i], start: rows[i].start + width });
190
+ i++;
191
+ }
192
+ entries.push(`<li>${blocks(children, depth + 1)}</li>`);
193
+ }
194
+ out.push(`<${tag}${start}>${entries.join('')}</${tag}>`);
195
+ continue;
196
+ }
197
+ const header = tableHeader(rows, i);
198
+ if (header) {
199
+ const row = (values: Range[], cellTag: string) => `<tr>${values.map(cell =>
200
+ `<${cellTag}>${inline(cell.start, cell.end)}</${cellTag}>`).join('')}</tr>`;
201
+ const body: string[] = [];
202
+ i += 2;
203
+ while (i < rows.length) {
204
+ const values = cells(rows[i]);
205
+ // Do not silently drop extra cells or swallow malformed rows.
206
+ if (!values || values.length !== header.length) break;
207
+ body.push(row(values, 'td')); i++;
208
+ }
209
+ out.push(`<table><thead>${row(header, 'th')}</thead><tbody>${body.join('')}</tbody></table>`);
210
+ continue;
211
+ }
212
+ const paragraph: string[] = [];
213
+ do {
214
+ const current = rows[i++];
215
+ paragraph.push(inline(current.start, current.end));
216
+ if (i >= rows.length || blank(rows[i]) || heading(rows[i]) || fence(rows[i]) || item(rows[i]) || tableHeader(rows, i)) break;
217
+ paragraph.push(literal(current.end, current.next));
218
+ } while (i < rows.length);
219
+ out.push(`<p>${paragraph.join('')}</p>`);
220
+ }
221
+ return out.join('\n');
222
+ };
223
+ return blocks(lines);
224
+ }
225
+
226
+ export function renderComparison(before: string, after: string): {
227
+ beforeHtml: string; afterHtml: string; granularity: 'word' | 'block'; changed: boolean;
228
+ } {
229
+ const diff = diffWords(before, after), left: Mark[] = [], right: Mark[] = [];
230
+ let beforeOffset = 0, afterOffset = 0;
231
+ for (const part of diff.parts) {
232
+ if (part.kind === 'remove') left.push({ start: beforeOffset, end: beforeOffset + part.text.length, kind: 'remove' });
233
+ if (part.kind === 'add') right.push({ start: afterOffset, end: afterOffset + part.text.length, kind: 'add' });
234
+ if (part.kind !== 'add') beforeOffset += part.text.length;
235
+ if (part.kind !== 'remove') afterOffset += part.text.length;
236
+ }
237
+ return { beforeHtml: renderMarkdown(before, left), afterHtml: renderMarkdown(after, right),
238
+ granularity: diff.granularity, changed: before !== after };
239
+ }