egregore-artifacts 0.9.8 → 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/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
- export function renderMarkdown(text) {
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
- elements.push(renderTable(tableLines, elements.length));
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 (rare inside a document body; render as section-sized)
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
- elements.push(h('h2', {
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
- }, inlineMarkdown(line.slice(2))));
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
- elements.push(h('h3', {
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
- }, inlineMarkdown(line.slice(3))));
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
- elements.push(h('h3', {
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
- }, inlineMarkdown(line.slice(4))));
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
- elements.push(h('ul', {
208
+ const ulProps = {
129
209
  key: elements.length,
130
210
  style: { listStyle: 'none', padding: 0, margin: '0.5rem 0' },
131
- },
132
- ...listItems.map((item, j) =>
133
- h('li', {
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
- elements.push(h('ol', {
263
+ const olProps = {
170
264
  key: elements.length,
171
265
  style: { listStyle: 'none', padding: 0, margin: '0.5rem 0', counterReset: 'step' },
172
- },
173
- ...listItems.map((item, j) =>
174
- h('li', {
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
- elements.push(h('pre', {
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
- }, h('code', null, codeLines.join('\n'))));
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
- elements.push(h('p', {
361
+ const pProps = {
251
362
  key: elements.length,
252
363
  style: { margin: '0.5rem 0', fontSize: '15px', lineHeight: 1.6, color: 'var(--dark)' },
253
- }, inlineMarkdown(paraLines.join(' '))));
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
- function renderTable(lines, key) {
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
- return h('div', {
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%',