egregore-artifacts 0.9.6 → 0.9.9
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/lib/edit-client.js +866 -0
- package/lib/edit-shell.js +64 -0
- package/lib/edit-styles.js +537 -0
- package/lib/markdown.js +318 -32
- package/lib/templates/handoff-v1.js +71 -237
- package/package.json +1 -1
package/lib/markdown.js
CHANGED
|
@@ -1,11 +1,53 @@
|
|
|
1
1
|
// Lightweight markdown → React elements converter
|
|
2
2
|
// Handles: **bold**, *italic*, `code`, ### headings, [links](url), tables, lists
|
|
3
3
|
import React from 'react';
|
|
4
|
+
import { renderToStaticMarkup } from 'react-dom/server';
|
|
5
|
+
import crypto from 'node:crypto';
|
|
4
6
|
import { colors, fonts } from './tokens.js';
|
|
5
7
|
import { artifactIdFromPath } from './artifact-id.js';
|
|
6
8
|
|
|
7
9
|
const h = React.createElement;
|
|
8
10
|
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
// Source-map helpers (Contract §4)
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
|
|
15
|
+
// sha256 hex of `text`, truncated to first 16 chars, prefixed `sha256:`.
|
|
16
|
+
// Used as the paragraphId (per Contract §2.3 and §4.2).
|
|
17
|
+
function paragraphIdOf(text) {
|
|
18
|
+
const hex = crypto.createHash('sha256').update(text, 'utf8').digest('hex');
|
|
19
|
+
return `sha256:${hex.slice(0, 16)}`;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// kebab-case the heading text and truncate to 40 chars (Contract §4.3 / §2.3).
|
|
23
|
+
// Strips non-alphanumerics (collapsing to single hyphens) and trims hyphens.
|
|
24
|
+
function slugify(headingText) {
|
|
25
|
+
const sliced = (headingText || '').slice(0, 40);
|
|
26
|
+
return sliced
|
|
27
|
+
.toLowerCase()
|
|
28
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
29
|
+
.replace(/^-+|-+$/g, '');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Compute char offsets for the start of every line. Index `i` is the offset
|
|
33
|
+
// of `lines[i]` in the original source (accounting for newline separators).
|
|
34
|
+
function buildLineOffsets(lines) {
|
|
35
|
+
const offsets = new Array(lines.length);
|
|
36
|
+
let off = 0;
|
|
37
|
+
for (let i = 0; i < lines.length; i++) {
|
|
38
|
+
offsets[i] = off;
|
|
39
|
+
off += lines[i].length + 1; // +1 for the '\n' that join uses
|
|
40
|
+
}
|
|
41
|
+
return offsets;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Strip block-level markdown sigils from a line so anchor text (used for
|
|
45
|
+
// per-`<li>` paragraphIds and paragraphIndex.text) is what the user actually
|
|
46
|
+
// reads, not the raw source. Used only for hashing/text fields, not rendering.
|
|
47
|
+
function stripListSigils(line) {
|
|
48
|
+
return line.replace(/^[-*]\s+/, '').replace(/^\d+\.\s+/, '');
|
|
49
|
+
}
|
|
50
|
+
|
|
9
51
|
// Module-level link context. Set by generateArtifact() before rendering, read
|
|
10
52
|
// by inlineMarkdown() when deciding whether to rewrite `memory/…` code spans
|
|
11
53
|
// and bare egregore.xyz URLs into <a> tags. Renders are synchronous so this
|
|
@@ -30,15 +72,33 @@ function memoryPathHref(memoryPath) {
|
|
|
30
72
|
return `${linkContext.viewBase}/${linkContext.orgSlug}/${id}`;
|
|
31
73
|
}
|
|
32
74
|
|
|
33
|
-
|
|
75
|
+
/**
|
|
76
|
+
* Render markdown to React elements with optional source-map data attributes.
|
|
77
|
+
* @param {string} text — raw markdown source
|
|
78
|
+
* @param {object} [opts]
|
|
79
|
+
* @param {boolean} [opts.sourceMap=false] — when true, emit data-* attributes
|
|
80
|
+
* @returns {React.Element} root <div> of rendered content
|
|
81
|
+
*/
|
|
82
|
+
export function renderMarkdown(text, opts = {}) {
|
|
34
83
|
if (!text) return null;
|
|
35
84
|
|
|
85
|
+
const sourceMap = opts.sourceMap === true; // default false — /edit passes true
|
|
36
86
|
const lines = text.split('\n');
|
|
87
|
+
const lineOffsets = sourceMap ? buildLineOffsets(lines) : null;
|
|
88
|
+
|
|
89
|
+
// Section counters per Contract §4.3. Top-level (`## `) increments N and
|
|
90
|
+
// resets M; sub-section (`### `) increments M. Deeper headings emit
|
|
91
|
+
// normally without `data-section` (Q1 v0.1 decision).
|
|
92
|
+
let sectionCounter = 0;
|
|
93
|
+
let subSectionCounter = 0;
|
|
94
|
+
|
|
37
95
|
const elements = [];
|
|
38
96
|
let i = 0;
|
|
39
97
|
|
|
40
98
|
while (i < lines.length) {
|
|
41
99
|
const line = lines[i];
|
|
100
|
+
const lineStart = i;
|
|
101
|
+
const charOffset = sourceMap ? lineOffsets[lineStart] : 0;
|
|
42
102
|
|
|
43
103
|
// Table detection — starts with |
|
|
44
104
|
if (line.trim().startsWith('|') && line.includes('|', 1)) {
|
|
@@ -47,13 +107,18 @@ export function renderMarkdown(text) {
|
|
|
47
107
|
tableLines.push(lines[i]);
|
|
48
108
|
i++;
|
|
49
109
|
}
|
|
50
|
-
|
|
110
|
+
const tableProps = sourceMap
|
|
111
|
+
? { sourceOffset: charOffset, sourceLine: lineStart + 1 }
|
|
112
|
+
: null;
|
|
113
|
+
elements.push(renderTable(tableLines, elements.length, tableProps));
|
|
51
114
|
continue;
|
|
52
115
|
}
|
|
53
116
|
|
|
54
|
-
// H1 heading
|
|
117
|
+
// H1 heading — treated as document title; does NOT participate in section
|
|
118
|
+
// numbering (per Q1 v0.1 decision: §N starts at the first `## `).
|
|
55
119
|
if (line.startsWith('# ')) {
|
|
56
|
-
|
|
120
|
+
const headingText = line.slice(2);
|
|
121
|
+
const props = {
|
|
57
122
|
key: elements.length,
|
|
58
123
|
style: {
|
|
59
124
|
fontFamily: fonts.serif,
|
|
@@ -63,14 +128,20 @@ export function renderMarkdown(text) {
|
|
|
63
128
|
margin: '1.75rem 0 0.75rem',
|
|
64
129
|
color: 'var(--black)',
|
|
65
130
|
},
|
|
66
|
-
}
|
|
131
|
+
};
|
|
132
|
+
// No data-section attribute — H1 is unnumbered.
|
|
133
|
+
elements.push(h('h2', props, inlineMarkdown(headingText)));
|
|
67
134
|
i++;
|
|
68
135
|
continue;
|
|
69
136
|
}
|
|
70
137
|
|
|
71
138
|
// H2 heading
|
|
72
139
|
if (line.startsWith('## ')) {
|
|
73
|
-
|
|
140
|
+
const headingText = line.slice(3);
|
|
141
|
+
sectionCounter += 1;
|
|
142
|
+
subSectionCounter = 0;
|
|
143
|
+
const sectionLabel = `§${sectionCounter}. ${slugify(headingText)}`;
|
|
144
|
+
const props = {
|
|
74
145
|
key: elements.length,
|
|
75
146
|
style: {
|
|
76
147
|
fontFamily: fonts.serif,
|
|
@@ -80,14 +151,19 @@ export function renderMarkdown(text) {
|
|
|
80
151
|
margin: '1.5rem 0 0.5rem',
|
|
81
152
|
color: 'var(--black)',
|
|
82
153
|
},
|
|
83
|
-
}
|
|
154
|
+
};
|
|
155
|
+
if (sourceMap) props['data-section'] = sectionLabel;
|
|
156
|
+
elements.push(h('h3', props, inlineMarkdown(headingText)));
|
|
84
157
|
i++;
|
|
85
158
|
continue;
|
|
86
159
|
}
|
|
87
160
|
|
|
88
161
|
// H3 heading
|
|
89
162
|
if (line.startsWith('### ')) {
|
|
90
|
-
|
|
163
|
+
const headingText = line.slice(4);
|
|
164
|
+
subSectionCounter += 1;
|
|
165
|
+
const sectionLabel = `§${sectionCounter}.${subSectionCounter}. ${slugify(headingText)}`;
|
|
166
|
+
const props = {
|
|
91
167
|
key: elements.length,
|
|
92
168
|
style: {
|
|
93
169
|
fontFamily: fonts.serif,
|
|
@@ -97,12 +173,14 @@ export function renderMarkdown(text) {
|
|
|
97
173
|
margin: '1.5rem 0 0.5rem',
|
|
98
174
|
color: 'var(--black)',
|
|
99
175
|
},
|
|
100
|
-
}
|
|
176
|
+
};
|
|
177
|
+
if (sourceMap) props['data-section'] = sectionLabel;
|
|
178
|
+
elements.push(h('h3', props, inlineMarkdown(headingText)));
|
|
101
179
|
i++;
|
|
102
180
|
continue;
|
|
103
181
|
}
|
|
104
182
|
|
|
105
|
-
// H4 heading
|
|
183
|
+
// H4 heading — no data-section (per Q1 v0.1 decision: emit normally)
|
|
106
184
|
if (line.startsWith('#### ')) {
|
|
107
185
|
elements.push(h('h4', {
|
|
108
186
|
key: elements.length,
|
|
@@ -121,16 +199,23 @@ export function renderMarkdown(text) {
|
|
|
121
199
|
// Unordered list item
|
|
122
200
|
if (line.match(/^[-*] /)) {
|
|
123
201
|
const listItems = [];
|
|
202
|
+
const itemLineStarts = [];
|
|
124
203
|
while (i < lines.length && lines[i].match(/^[-*] /)) {
|
|
204
|
+
itemLineStarts.push(i);
|
|
125
205
|
listItems.push(lines[i].replace(/^[-*] /, ''));
|
|
126
206
|
i++;
|
|
127
207
|
}
|
|
128
|
-
|
|
208
|
+
const ulProps = {
|
|
129
209
|
key: elements.length,
|
|
130
210
|
style: { listStyle: 'none', padding: 0, margin: '0.5rem 0' },
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
|
|
211
|
+
};
|
|
212
|
+
if (sourceMap) {
|
|
213
|
+
ulProps['data-source-offset'] = String(charOffset);
|
|
214
|
+
ulProps['data-source-line'] = String(lineStart + 1);
|
|
215
|
+
}
|
|
216
|
+
elements.push(h('ul', ulProps,
|
|
217
|
+
...listItems.map((item, j) => {
|
|
218
|
+
const liProps = {
|
|
134
219
|
key: j,
|
|
135
220
|
style: {
|
|
136
221
|
position: 'relative',
|
|
@@ -140,7 +225,14 @@ export function renderMarkdown(text) {
|
|
|
140
225
|
lineHeight: 1.55,
|
|
141
226
|
color: 'var(--dark)',
|
|
142
227
|
},
|
|
143
|
-
}
|
|
228
|
+
};
|
|
229
|
+
if (sourceMap) {
|
|
230
|
+
const liLineIdx = itemLineStarts[j];
|
|
231
|
+
liProps['data-para-id'] = paragraphIdOf(item);
|
|
232
|
+
liProps['data-source-offset'] = String(lineOffsets[liLineIdx]);
|
|
233
|
+
liProps['data-source-line'] = String(liLineIdx + 1);
|
|
234
|
+
}
|
|
235
|
+
return h('li', liProps,
|
|
144
236
|
h('span', {
|
|
145
237
|
style: {
|
|
146
238
|
position: 'absolute',
|
|
@@ -153,8 +245,8 @@ export function renderMarkdown(text) {
|
|
|
153
245
|
},
|
|
154
246
|
}),
|
|
155
247
|
inlineMarkdown(item),
|
|
156
|
-
)
|
|
157
|
-
),
|
|
248
|
+
);
|
|
249
|
+
}),
|
|
158
250
|
));
|
|
159
251
|
continue;
|
|
160
252
|
}
|
|
@@ -162,16 +254,23 @@ export function renderMarkdown(text) {
|
|
|
162
254
|
// Ordered list item
|
|
163
255
|
if (line.match(/^\d+\.\s/)) {
|
|
164
256
|
const listItems = [];
|
|
257
|
+
const itemLineStarts = [];
|
|
165
258
|
while (i < lines.length && lines[i].match(/^\d+\.\s/)) {
|
|
259
|
+
itemLineStarts.push(i);
|
|
166
260
|
listItems.push(lines[i].replace(/^\d+\.\s*/, ''));
|
|
167
261
|
i++;
|
|
168
262
|
}
|
|
169
|
-
|
|
263
|
+
const olProps = {
|
|
170
264
|
key: elements.length,
|
|
171
265
|
style: { listStyle: 'none', padding: 0, margin: '0.5rem 0', counterReset: 'step' },
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
|
|
266
|
+
};
|
|
267
|
+
if (sourceMap) {
|
|
268
|
+
olProps['data-source-offset'] = String(charOffset);
|
|
269
|
+
olProps['data-source-line'] = String(lineStart + 1);
|
|
270
|
+
}
|
|
271
|
+
elements.push(h('ol', olProps,
|
|
272
|
+
...listItems.map((item, j) => {
|
|
273
|
+
const liProps = {
|
|
175
274
|
key: j,
|
|
176
275
|
style: {
|
|
177
276
|
display: 'flex',
|
|
@@ -180,7 +279,14 @@ export function renderMarkdown(text) {
|
|
|
180
279
|
fontSize: '15px',
|
|
181
280
|
lineHeight: 1.55,
|
|
182
281
|
},
|
|
183
|
-
}
|
|
282
|
+
};
|
|
283
|
+
if (sourceMap) {
|
|
284
|
+
const liLineIdx = itemLineStarts[j];
|
|
285
|
+
liProps['data-para-id'] = paragraphIdOf(item);
|
|
286
|
+
liProps['data-source-offset'] = String(lineOffsets[liLineIdx]);
|
|
287
|
+
liProps['data-source-line'] = String(liLineIdx + 1);
|
|
288
|
+
}
|
|
289
|
+
return h('li', liProps,
|
|
184
290
|
h('span', {
|
|
185
291
|
style: {
|
|
186
292
|
flexShrink: 0,
|
|
@@ -199,8 +305,8 @@ export function renderMarkdown(text) {
|
|
|
199
305
|
},
|
|
200
306
|
}, String(j + 1)),
|
|
201
307
|
h('span', null, inlineMarkdown(item)),
|
|
202
|
-
)
|
|
203
|
-
),
|
|
308
|
+
);
|
|
309
|
+
}),
|
|
204
310
|
));
|
|
205
311
|
continue;
|
|
206
312
|
}
|
|
@@ -214,7 +320,7 @@ export function renderMarkdown(text) {
|
|
|
214
320
|
i++;
|
|
215
321
|
}
|
|
216
322
|
i++; // skip closing ```
|
|
217
|
-
|
|
323
|
+
const preProps = {
|
|
218
324
|
key: elements.length,
|
|
219
325
|
style: {
|
|
220
326
|
background: 'var(--terminal-bg)',
|
|
@@ -227,7 +333,12 @@ export function renderMarkdown(text) {
|
|
|
227
333
|
overflowX: 'auto',
|
|
228
334
|
margin: '0.75rem 0',
|
|
229
335
|
},
|
|
230
|
-
}
|
|
336
|
+
};
|
|
337
|
+
if (sourceMap) {
|
|
338
|
+
preProps['data-source-offset'] = String(charOffset);
|
|
339
|
+
preProps['data-source-line'] = String(lineStart + 1);
|
|
340
|
+
}
|
|
341
|
+
elements.push(h('pre', preProps, h('code', null, codeLines.join('\n'))));
|
|
231
342
|
continue;
|
|
232
343
|
}
|
|
233
344
|
|
|
@@ -247,10 +358,16 @@ export function renderMarkdown(text) {
|
|
|
247
358
|
i++;
|
|
248
359
|
}
|
|
249
360
|
if (paraLines.length > 0) {
|
|
250
|
-
|
|
361
|
+
const pProps = {
|
|
251
362
|
key: elements.length,
|
|
252
363
|
style: { margin: '0.5rem 0', fontSize: '15px', lineHeight: 1.6, color: 'var(--dark)' },
|
|
253
|
-
}
|
|
364
|
+
};
|
|
365
|
+
if (sourceMap) {
|
|
366
|
+
pProps['data-para-id'] = paragraphIdOf(paraLines.join('\n'));
|
|
367
|
+
pProps['data-source-offset'] = String(charOffset);
|
|
368
|
+
pProps['data-source-line'] = String(lineStart + 1);
|
|
369
|
+
}
|
|
370
|
+
elements.push(h('p', pProps, inlineMarkdown(paraLines.join(' '))));
|
|
254
371
|
} else {
|
|
255
372
|
// Safety: nothing consumed this line. Skip it to avoid an infinite loop.
|
|
256
373
|
i++;
|
|
@@ -260,6 +377,167 @@ export function renderMarkdown(text) {
|
|
|
260
377
|
return h('div', { style: { color: 'var(--dark)' } }, ...elements);
|
|
261
378
|
}
|
|
262
379
|
|
|
380
|
+
/**
|
|
381
|
+
* Render markdown to a static HTML string (source-mapped by default).
|
|
382
|
+
*
|
|
383
|
+
* Wraps `renderMarkdown` + `renderToStaticMarkup` so consumers outside the
|
|
384
|
+
* artifacts package don't need to resolve `react-dom/server` themselves —
|
|
385
|
+
* the import lives here and resolves from the package's local node_modules.
|
|
386
|
+
*
|
|
387
|
+
* Used by `/api/edit/render` (bin/lib/edit-loopback.mjs) to ship source-mapped
|
|
388
|
+
* HTML to the browser annotation surface. This is "source render" — the body
|
|
389
|
+
* markup only, NOT the full /view document shell.
|
|
390
|
+
*
|
|
391
|
+
* @param {string} text — raw markdown source
|
|
392
|
+
* @param {object} opts — same options as renderMarkdown
|
|
393
|
+
* @returns {string} static HTML string (body only, no shell)
|
|
394
|
+
*/
|
|
395
|
+
export function renderMarkdownToHtml(text, opts = {}) {
|
|
396
|
+
const tree = renderMarkdown(text, opts);
|
|
397
|
+
if (!tree) return '';
|
|
398
|
+
return renderToStaticMarkup(tree);
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* Build a paragraph index for a markdown source. Same line walker as
|
|
403
|
+
* renderMarkdown, but emits a structured array instead of React elements.
|
|
404
|
+
* Used by /api/edit/render to ship paragraphIndex alongside HTML so the
|
|
405
|
+
* browser can resolve DOM selection to anchors without reparsing.
|
|
406
|
+
*
|
|
407
|
+
* Returns one entry per anchorable block: paragraphs and per-`<li>` items
|
|
408
|
+
* (per Contract §4.2 / Q2 v0.1 decision). Headings, tables, and code fences
|
|
409
|
+
* are NOT in the index — they're container-only anchors.
|
|
410
|
+
*
|
|
411
|
+
* @param {string} text — raw markdown source
|
|
412
|
+
* @returns {Array<{paragraphId: string, section: string, charOffset: number, lineNumber: number, text: string}>}
|
|
413
|
+
*/
|
|
414
|
+
export function buildParagraphIndex(text) {
|
|
415
|
+
if (!text) return [];
|
|
416
|
+
|
|
417
|
+
const lines = text.split('\n');
|
|
418
|
+
const lineOffsets = buildLineOffsets(lines);
|
|
419
|
+
const index = [];
|
|
420
|
+
|
|
421
|
+
let sectionCounter = 0;
|
|
422
|
+
let subSectionCounter = 0;
|
|
423
|
+
let currentSection = '§0. unsectioned';
|
|
424
|
+
let i = 0;
|
|
425
|
+
|
|
426
|
+
while (i < lines.length) {
|
|
427
|
+
const line = lines[i];
|
|
428
|
+
const lineStart = i;
|
|
429
|
+
const charOffset = lineOffsets[lineStart];
|
|
430
|
+
|
|
431
|
+
// Table — skip; not an anchorable paragraph.
|
|
432
|
+
if (line.trim().startsWith('|') && line.includes('|', 1)) {
|
|
433
|
+
while (i < lines.length && lines[i].trim().startsWith('|')) i++;
|
|
434
|
+
continue;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// H1 — document title; does NOT participate in section numbering
|
|
438
|
+
// (matches renderMarkdown behavior). Skip without changing currentSection.
|
|
439
|
+
if (line.startsWith('# ')) {
|
|
440
|
+
i++;
|
|
441
|
+
continue;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
// H2 — top-level section.
|
|
445
|
+
if (line.startsWith('## ')) {
|
|
446
|
+
const headingText = line.slice(3);
|
|
447
|
+
sectionCounter += 1;
|
|
448
|
+
subSectionCounter = 0;
|
|
449
|
+
currentSection = `§${sectionCounter}. ${slugify(headingText)}`;
|
|
450
|
+
i++;
|
|
451
|
+
continue;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
// H3 — sub-section.
|
|
455
|
+
if (line.startsWith('### ')) {
|
|
456
|
+
const headingText = line.slice(4);
|
|
457
|
+
subSectionCounter += 1;
|
|
458
|
+
currentSection = `§${sectionCounter}.${subSectionCounter}. ${slugify(headingText)}`;
|
|
459
|
+
i++;
|
|
460
|
+
continue;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
// H4+ — emit normally; section unchanged.
|
|
464
|
+
if (line.startsWith('#### ') || line.startsWith('##### ') || line.startsWith('###### ')) {
|
|
465
|
+
i++;
|
|
466
|
+
continue;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
// Unordered list — each <li> is an anchor.
|
|
470
|
+
if (line.match(/^[-*] /)) {
|
|
471
|
+
while (i < lines.length && lines[i].match(/^[-*] /)) {
|
|
472
|
+
const itemText = lines[i].replace(/^[-*] /, '');
|
|
473
|
+
index.push({
|
|
474
|
+
paragraphId: paragraphIdOf(itemText),
|
|
475
|
+
section: currentSection,
|
|
476
|
+
charOffset: lineOffsets[i],
|
|
477
|
+
lineNumber: i + 1,
|
|
478
|
+
text: itemText,
|
|
479
|
+
});
|
|
480
|
+
i++;
|
|
481
|
+
}
|
|
482
|
+
continue;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
// Ordered list — each <li> is an anchor.
|
|
486
|
+
if (line.match(/^\d+\.\s/)) {
|
|
487
|
+
while (i < lines.length && lines[i].match(/^\d+\.\s/)) {
|
|
488
|
+
const itemText = lines[i].replace(/^\d+\.\s*/, '');
|
|
489
|
+
index.push({
|
|
490
|
+
paragraphId: paragraphIdOf(itemText),
|
|
491
|
+
section: currentSection,
|
|
492
|
+
charOffset: lineOffsets[i],
|
|
493
|
+
lineNumber: i + 1,
|
|
494
|
+
text: itemText,
|
|
495
|
+
});
|
|
496
|
+
i++;
|
|
497
|
+
}
|
|
498
|
+
continue;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
// Code fence — skip; container-only anchor.
|
|
502
|
+
if (line.startsWith('```')) {
|
|
503
|
+
i++;
|
|
504
|
+
while (i < lines.length && !lines[i].startsWith('```')) i++;
|
|
505
|
+
if (i < lines.length) i++; // skip closing ```
|
|
506
|
+
continue;
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
// Empty line.
|
|
510
|
+
if (line.trim() === '') {
|
|
511
|
+
i++;
|
|
512
|
+
continue;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
// Paragraph.
|
|
516
|
+
const paraLines = [];
|
|
517
|
+
while (i < lines.length && lines[i].trim() !== '' &&
|
|
518
|
+
!lines[i].startsWith('#') && !lines[i].startsWith('|') &&
|
|
519
|
+
!lines[i].startsWith('```') && !lines[i].match(/^[-*] /) &&
|
|
520
|
+
!lines[i].match(/^\d+\.\s/)) {
|
|
521
|
+
paraLines.push(lines[i]);
|
|
522
|
+
i++;
|
|
523
|
+
}
|
|
524
|
+
if (paraLines.length > 0) {
|
|
525
|
+
const joined = paraLines.join('\n');
|
|
526
|
+
index.push({
|
|
527
|
+
paragraphId: paragraphIdOf(joined),
|
|
528
|
+
section: currentSection,
|
|
529
|
+
charOffset,
|
|
530
|
+
lineNumber: lineStart + 1,
|
|
531
|
+
text: paraLines.join(' '),
|
|
532
|
+
});
|
|
533
|
+
} else {
|
|
534
|
+
i++;
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
return index;
|
|
539
|
+
}
|
|
540
|
+
|
|
263
541
|
// Inline markdown: **bold**, *italic*, `code`, [link](url)
|
|
264
542
|
// Finds whichever pattern appears earliest in the string
|
|
265
543
|
export function inlineMarkdown(text) {
|
|
@@ -340,18 +618,26 @@ function inlineMarkdownSimple(text, key) {
|
|
|
340
618
|
return h(React.Fragment, { key }, text);
|
|
341
619
|
}
|
|
342
620
|
|
|
343
|
-
// Render markdown table
|
|
344
|
-
|
|
621
|
+
// Render markdown table.
|
|
622
|
+
// `sourceMapProps` is { sourceOffset, sourceLine } when the caller wants
|
|
623
|
+
// `data-source-offset` / `data-source-line` on the outer wrapper (Contract §4.2).
|
|
624
|
+
function renderTable(lines, key, sourceMapProps) {
|
|
345
625
|
// Parse header
|
|
346
626
|
const headerCells = parseTableRow(lines[0]);
|
|
347
627
|
// Skip separator row (|---|---|)
|
|
348
628
|
const startRow = lines[1]?.match(/^\|[\s-:|]+\|$/) ? 2 : 1;
|
|
349
629
|
const bodyRows = lines.slice(startRow).map(parseTableRow);
|
|
350
630
|
|
|
351
|
-
|
|
631
|
+
const wrapperProps = {
|
|
352
632
|
key,
|
|
353
633
|
style: { overflowX: 'auto', margin: '0.75rem 0' },
|
|
354
|
-
}
|
|
634
|
+
};
|
|
635
|
+
if (sourceMapProps) {
|
|
636
|
+
wrapperProps['data-source-offset'] = String(sourceMapProps.sourceOffset);
|
|
637
|
+
wrapperProps['data-source-line'] = String(sourceMapProps.sourceLine);
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
return h('div', wrapperProps,
|
|
355
641
|
h('table', {
|
|
356
642
|
style: {
|
|
357
643
|
width: '100%',
|