egregore-artifacts 0.6.0 → 0.9.6
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/bin/cli.js +12 -2
- package/lib/artifact-id.js +28 -0
- package/lib/comments.js +248 -0
- package/lib/components.js +2 -2
- package/lib/index.js +28 -5
- package/lib/markdown.js +53 -5
- package/lib/parsers/handoff-v1.js +65 -0
- package/lib/parsers/handoff.js +5 -3
- package/lib/render.js +12 -2
- package/lib/shell.js +439 -0
- package/lib/templates/handoff-v1.js +762 -0
- package/package.json +1 -1
package/bin/cli.js
CHANGED
|
@@ -4,7 +4,7 @@ import { execSync } from 'node:child_process';
|
|
|
4
4
|
import fs from 'node:fs';
|
|
5
5
|
import path from 'node:path';
|
|
6
6
|
|
|
7
|
-
const KNOWN_TYPES = ['quest', 'handoff', 'activity', 'document', 'board', 'network'];
|
|
7
|
+
const KNOWN_TYPES = ['quest', 'handoff', 'handoff-v1', 'activity', 'document', 'board', 'network'];
|
|
8
8
|
const args = process.argv.slice(2);
|
|
9
9
|
|
|
10
10
|
let type, filePath;
|
|
@@ -26,17 +26,27 @@ if (args.length === 0) {
|
|
|
26
26
|
// Parse flags
|
|
27
27
|
let outputFile = null;
|
|
28
28
|
let noOpen = false;
|
|
29
|
+
let orgSlug = null;
|
|
30
|
+
let viewBase = null;
|
|
29
31
|
const positional = [];
|
|
30
32
|
for (let i = 0; i < args.length; i++) {
|
|
31
33
|
if (args[i] === '--output' || args[i] === '-o') {
|
|
32
34
|
outputFile = args[++i];
|
|
33
35
|
} else if (args[i] === '--no-open') {
|
|
34
36
|
noOpen = true;
|
|
37
|
+
} else if (args[i] === '--org-slug') {
|
|
38
|
+
orgSlug = args[++i];
|
|
39
|
+
} else if (args[i] === '--view-base') {
|
|
40
|
+
viewBase = args[++i];
|
|
35
41
|
} else {
|
|
36
42
|
positional.push(args[i]);
|
|
37
43
|
}
|
|
38
44
|
}
|
|
39
45
|
|
|
46
|
+
// Default view base; slug is intentionally left null unless supplied so OSS
|
|
47
|
+
// mode (which cannot predict URLs) falls through to plain <code> rendering.
|
|
48
|
+
if (!viewBase) viewBase = 'https://egregore.xyz/view';
|
|
49
|
+
|
|
40
50
|
// If first positional is a known type, use it. Otherwise treat it as a file path.
|
|
41
51
|
if (KNOWN_TYPES.includes(positional[0])) {
|
|
42
52
|
type = positional[0];
|
|
@@ -83,7 +93,7 @@ function resolveFile(fp) {
|
|
|
83
93
|
|
|
84
94
|
try {
|
|
85
95
|
const input = (type === 'activity' || type === 'board' || type === 'network') ? (filePath || 'live') : resolveFile(filePath);
|
|
86
|
-
const html = await generateArtifact(type, input);
|
|
96
|
+
const html = await generateArtifact(type, input, { orgSlug, viewBase });
|
|
87
97
|
const slug = filePath ? filePath.split('/').pop().replace('.md', '') : new Date().toISOString().split('T')[0];
|
|
88
98
|
|
|
89
99
|
if (outputFile) {
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// Deterministic artifact IDs for memory files.
|
|
2
|
+
//
|
|
3
|
+
// Emits `{prefix}-{12 hex}` where prefix is derived from the file extension
|
|
4
|
+
// (m = .md, h = .html) and the hex is the first 12 chars of sha256(canonical_path).
|
|
5
|
+
//
|
|
6
|
+
// Kept in parity with bin/lib/artifact-id.sh — both MUST produce identical
|
|
7
|
+
// output for the same input. Parity is enforced by test/artifact-id.test.js.
|
|
8
|
+
import { createHash } from 'node:crypto';
|
|
9
|
+
|
|
10
|
+
const EXT_PREFIX = { '.md': 'm', '.html': 'h' };
|
|
11
|
+
|
|
12
|
+
function canonicalize(path) {
|
|
13
|
+
return path.replace(/^\.\//, '').replace(/\/{2,}/g, '/');
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function extension(path) {
|
|
17
|
+
const m = path.match(/\.[a-z0-9]+$/i);
|
|
18
|
+
return m ? m[0].toLowerCase() : '';
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function artifactIdFromPath(rawPath) {
|
|
22
|
+
if (!rawPath || typeof rawPath !== 'string') return null;
|
|
23
|
+
const canonical = canonicalize(rawPath);
|
|
24
|
+
const prefix = EXT_PREFIX[extension(canonical)];
|
|
25
|
+
if (!prefix) return null;
|
|
26
|
+
const hash = createHash('sha256').update(canonical).digest('hex').slice(0, 12);
|
|
27
|
+
return `${prefix}-${hash}`;
|
|
28
|
+
}
|
package/lib/comments.js
ADDED
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
// Comment thread + composer — renders inline at the bottom of any
|
|
2
|
+
// `/view`-rendered artifact. Reads markdown files from
|
|
3
|
+
// `memory/comments/{kind}/{id}/*.md`. No graph access — filesystem only,
|
|
4
|
+
// so it works in both connected and local mode.
|
|
5
|
+
import React from 'react';
|
|
6
|
+
import fs from 'node:fs';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import { execSync } from 'node:child_process';
|
|
9
|
+
import { renderMarkdown } from './markdown.js';
|
|
10
|
+
|
|
11
|
+
const h = React.createElement;
|
|
12
|
+
|
|
13
|
+
// ── Path resolution ─────────────────────────────────────────────
|
|
14
|
+
|
|
15
|
+
function findGitRoot(startPath) {
|
|
16
|
+
try {
|
|
17
|
+
return execSync('git rev-parse --show-toplevel 2>/dev/null', {
|
|
18
|
+
encoding: 'utf-8',
|
|
19
|
+
cwd: startPath || process.cwd(),
|
|
20
|
+
}).trim();
|
|
21
|
+
} catch {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Derive (parentKind, parentId) for a rendered artifact.
|
|
27
|
+
// Mirrors the indexer's parent_kind enum: quest|handoff|card|decision|document.
|
|
28
|
+
export function derivedParent(type, filePath) {
|
|
29
|
+
if (!filePath || typeof filePath !== 'string') return null;
|
|
30
|
+
|
|
31
|
+
const base = path.basename(filePath, '.md');
|
|
32
|
+
|
|
33
|
+
if (type === 'quest') {
|
|
34
|
+
return { kind: 'quest', id: base };
|
|
35
|
+
}
|
|
36
|
+
if (type === 'handoff') {
|
|
37
|
+
// sessionId can be either YYYY-MM-DD-author-topic (flat) or DD-author-topic
|
|
38
|
+
// inside a YYYY-MM/ subdir. Mirror bin/index-handoff.sh's id derivation.
|
|
39
|
+
const dir = path.basename(path.dirname(filePath));
|
|
40
|
+
if (/^\d{4}-\d{2}$/.test(dir)) {
|
|
41
|
+
return { kind: 'handoff', id: `${dir}-${base}` };
|
|
42
|
+
}
|
|
43
|
+
return { kind: 'handoff', id: base };
|
|
44
|
+
}
|
|
45
|
+
if (type === 'document') {
|
|
46
|
+
if (filePath.includes('/knowledge/decisions/')) {
|
|
47
|
+
return { kind: 'decision', id: base };
|
|
48
|
+
}
|
|
49
|
+
return { kind: 'document', id: base };
|
|
50
|
+
}
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// ── Loader ──────────────────────────────────────────────────────
|
|
55
|
+
|
|
56
|
+
export function loadComments(parent, gitRootHint) {
|
|
57
|
+
if (!parent || !parent.kind || !parent.id) return [];
|
|
58
|
+
|
|
59
|
+
const gitRoot = gitRootHint || findGitRoot();
|
|
60
|
+
if (!gitRoot) return [];
|
|
61
|
+
|
|
62
|
+
const dir = path.join(gitRoot, 'memory', 'comments', parent.kind, parent.id);
|
|
63
|
+
if (!fs.existsSync(dir)) return [];
|
|
64
|
+
|
|
65
|
+
const files = fs.readdirSync(dir)
|
|
66
|
+
.filter(f => f.endsWith('.md'))
|
|
67
|
+
.sort();
|
|
68
|
+
|
|
69
|
+
return files.map(f => parseCommentFile(path.join(dir, f))).filter(Boolean);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function parseCommentFile(filePath) {
|
|
73
|
+
const raw = fs.readFileSync(filePath, 'utf-8');
|
|
74
|
+
const id = path.basename(filePath, '.md');
|
|
75
|
+
|
|
76
|
+
const fmMatch = raw.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/);
|
|
77
|
+
if (!fmMatch) return null;
|
|
78
|
+
|
|
79
|
+
const fm = parseSimpleYaml(fmMatch[1]);
|
|
80
|
+
const body = fmMatch[2].trim();
|
|
81
|
+
|
|
82
|
+
return {
|
|
83
|
+
id,
|
|
84
|
+
parentKind: fm.parent_kind,
|
|
85
|
+
parentId: fm.parent_id,
|
|
86
|
+
author: fm.author,
|
|
87
|
+
to: fm.to || [],
|
|
88
|
+
replyTo: fm.reply_to || null,
|
|
89
|
+
createdAt: fm.created || null,
|
|
90
|
+
body,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function parseSimpleYaml(text) {
|
|
95
|
+
const result = {};
|
|
96
|
+
for (const line of text.split('\n')) {
|
|
97
|
+
const m = line.match(/^([\w_-]+):\s*(.*)$/);
|
|
98
|
+
if (!m) continue;
|
|
99
|
+
const [, key, rawVal] = m;
|
|
100
|
+
let val = rawVal.trim();
|
|
101
|
+
if (val.startsWith('[') && val.endsWith(']')) {
|
|
102
|
+
val = val.slice(1, -1).split(',').map(s => s.trim().replace(/^["']|["']$/g, '')).filter(Boolean);
|
|
103
|
+
} else if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) {
|
|
104
|
+
val = val.slice(1, -1);
|
|
105
|
+
} else if (val === 'null' || val === '') {
|
|
106
|
+
val = null;
|
|
107
|
+
}
|
|
108
|
+
result[key] = val;
|
|
109
|
+
}
|
|
110
|
+
return result;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// ── Threading ───────────────────────────────────────────────────
|
|
114
|
+
|
|
115
|
+
// Build a tree from a flat list using replyTo. Top-level items keep their order;
|
|
116
|
+
// each child renders inline under its parent. Orphaned replies (parent missing)
|
|
117
|
+
// are promoted to top-level so nothing disappears.
|
|
118
|
+
function buildThreads(comments) {
|
|
119
|
+
const byId = new Map(comments.map(c => [c.id, { ...c, children: [] }]));
|
|
120
|
+
const roots = [];
|
|
121
|
+
for (const c of byId.values()) {
|
|
122
|
+
if (c.replyTo && byId.has(c.replyTo)) {
|
|
123
|
+
byId.get(c.replyTo).children.push(c);
|
|
124
|
+
} else {
|
|
125
|
+
roots.push(c);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return roots;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function formatDate(iso) {
|
|
132
|
+
if (!iso) return '';
|
|
133
|
+
try {
|
|
134
|
+
return new Date(iso).toLocaleDateString('en-US', {
|
|
135
|
+
month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit',
|
|
136
|
+
});
|
|
137
|
+
} catch {
|
|
138
|
+
return iso;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// ── Components ──────────────────────────────────────────────────
|
|
143
|
+
|
|
144
|
+
function Comment({ comment, depth }) {
|
|
145
|
+
const indent = depth > 0 ? { marginLeft: `${Math.min(depth, 3) * 1.5}rem` } : {};
|
|
146
|
+
const recipients = (comment.to || []).filter(Boolean);
|
|
147
|
+
|
|
148
|
+
return h('div', { className: 'eg-comment', style: indent, 'data-comment-id': comment.id },
|
|
149
|
+
h('div', { className: 'eg-comment-meta' },
|
|
150
|
+
h('span', { className: 'eg-comment-avatar' },
|
|
151
|
+
(comment.author || '?')[0].toUpperCase()
|
|
152
|
+
),
|
|
153
|
+
h('span', { className: 'eg-comment-author' }, comment.author || 'unknown'),
|
|
154
|
+
comment.createdAt && h('span', { className: 'eg-comment-date' }, formatDate(comment.createdAt)),
|
|
155
|
+
recipients.length > 0 && h('span', { className: 'eg-comment-to' },
|
|
156
|
+
'→ ', recipients.join(', ')
|
|
157
|
+
),
|
|
158
|
+
),
|
|
159
|
+
h('div', { className: 'eg-comment-body' }, renderMarkdown(comment.body)),
|
|
160
|
+
h('div', { className: 'eg-comment-actions' },
|
|
161
|
+
h('button', {
|
|
162
|
+
type: 'button',
|
|
163
|
+
className: 'eg-comment-reply',
|
|
164
|
+
'data-reply-to': comment.id,
|
|
165
|
+
'data-reply-author': comment.author || '',
|
|
166
|
+
}, '↩ reply'),
|
|
167
|
+
),
|
|
168
|
+
...(comment.children || []).map(child =>
|
|
169
|
+
h(Comment, { key: child.id, comment: child, depth: depth + 1 })
|
|
170
|
+
),
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function CommentThread({ comments }) {
|
|
175
|
+
if (!comments || comments.length === 0) {
|
|
176
|
+
return h('div', { className: 'eg-comments-empty' },
|
|
177
|
+
'No comments yet. Be the first.'
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
const roots = buildThreads(comments);
|
|
181
|
+
return h('div', { className: 'eg-comments-thread' },
|
|
182
|
+
...roots.map(c => h(Comment, { key: c.id, comment: c, depth: 0 }))
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function CommentComposer({ parent }) {
|
|
187
|
+
return h('div', {
|
|
188
|
+
className: 'eg-comments-composer',
|
|
189
|
+
'data-parent-kind': parent.kind,
|
|
190
|
+
'data-parent-id': parent.id,
|
|
191
|
+
},
|
|
192
|
+
h('div', { className: 'eg-card-title' },
|
|
193
|
+
h('span', { className: 'eg-live-only' }, 'Add a comment to '),
|
|
194
|
+
h('span', { className: 'eg-paste-only' }, 'Compose a comment on '),
|
|
195
|
+
h('code', { className: 'eg-comment-target' }, `${parent.kind}/${parent.id}`),
|
|
196
|
+
),
|
|
197
|
+
h('textarea', {
|
|
198
|
+
className: 'eg-comment-textarea',
|
|
199
|
+
placeholder: 'Type your comment. @handle to ping someone. Markdown is supported.',
|
|
200
|
+
rows: 4,
|
|
201
|
+
}),
|
|
202
|
+
|
|
203
|
+
h('div', { className: 'eg-comment-preview-label' },
|
|
204
|
+
h('span', { className: 'eg-live-only' }, 'Preview:'),
|
|
205
|
+
h('span', { className: 'eg-paste-only' }, 'Paste this in your Claude terminal:'),
|
|
206
|
+
),
|
|
207
|
+
h('div', { className: 'eg-comment-preview-frame' },
|
|
208
|
+
h('pre', { className: 'eg-comment-preview' },
|
|
209
|
+
h('code', { className: 'eg-comment-preview-code' }, ''),
|
|
210
|
+
),
|
|
211
|
+
h('button', {
|
|
212
|
+
type: 'button',
|
|
213
|
+
className: 'eg-comment-copy-btn',
|
|
214
|
+
'aria-label': 'Copy snippet',
|
|
215
|
+
}, 'Copy'),
|
|
216
|
+
),
|
|
217
|
+
h('div', { className: 'eg-comment-status', 'aria-live': 'polite' }),
|
|
218
|
+
|
|
219
|
+
// Primary action — only visible when the loopback server is reachable.
|
|
220
|
+
h('button', {
|
|
221
|
+
type: 'button',
|
|
222
|
+
className: 'eg-comment-post-btn eg-live-only',
|
|
223
|
+
}, 'Post comment'),
|
|
224
|
+
|
|
225
|
+
h('div', { className: 'eg-comment-help' },
|
|
226
|
+
h('span', { className: 'eg-live-only' },
|
|
227
|
+
'Posts directly via the local /view server. Writes the file, indexes the graph, fires Telegram for any @mentions, and commits to memory.',
|
|
228
|
+
),
|
|
229
|
+
h('span', { className: 'eg-paste-only' },
|
|
230
|
+
'The browser can’t write to your filesystem or run git, so the snippet is the handoff. Claude recognizes the marker and runs the publish flow.',
|
|
231
|
+
),
|
|
232
|
+
),
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
export function CommentSection({ parent, comments }) {
|
|
237
|
+
if (!parent) return null;
|
|
238
|
+
return h('section', { className: 'eg-comments-section' },
|
|
239
|
+
h('div', { className: 'eg-comments-heading' },
|
|
240
|
+
h('span', null, 'Comments'),
|
|
241
|
+
comments && comments.length > 0 && h('span', { className: 'eg-comments-count' },
|
|
242
|
+
` (${comments.length})`
|
|
243
|
+
),
|
|
244
|
+
),
|
|
245
|
+
h(CommentThread, { comments }),
|
|
246
|
+
h(CommentComposer, { parent }),
|
|
247
|
+
);
|
|
248
|
+
}
|
package/lib/components.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Branded Egregore React components — no JSX, uses React.createElement
|
|
2
2
|
import React from 'react';
|
|
3
3
|
import { colors, fonts, typography } from './tokens.js';
|
|
4
|
-
import { renderMarkdown } from './markdown.js';
|
|
4
|
+
import { renderMarkdown, inlineMarkdown } from './markdown.js';
|
|
5
5
|
|
|
6
6
|
const h = React.createElement;
|
|
7
7
|
|
|
@@ -105,7 +105,7 @@ export function BulletList({ items }) {
|
|
|
105
105
|
|
|
106
106
|
return h('ul', { className: 'eg-bullet-list' },
|
|
107
107
|
...items.map((item, i) =>
|
|
108
|
-
h('li', { key: i }, item)
|
|
108
|
+
h('li', { key: i }, inlineMarkdown(item))
|
|
109
109
|
),
|
|
110
110
|
);
|
|
111
111
|
}
|
package/lib/index.js
CHANGED
|
@@ -5,22 +5,27 @@ import os from 'node:os';
|
|
|
5
5
|
import { renderToHtml, renderSpecToHtml } from './render.js';
|
|
6
6
|
import { parseQuest } from './parsers/quest.js';
|
|
7
7
|
import { parseHandoff } from './parsers/handoff.js';
|
|
8
|
+
import { parseHandoffV1 } from './parsers/handoff-v1.js';
|
|
8
9
|
import { parseActivity } from './parsers/activity.js';
|
|
9
10
|
import { parseDocument } from './parsers/document.js';
|
|
10
11
|
import { parseBoard } from './parsers/board.js';
|
|
11
12
|
import { parseNetwork } from './parsers/network.js';
|
|
12
13
|
import { questTemplate } from './templates/quest.js';
|
|
13
14
|
import { handoffTemplate } from './templates/handoff.js';
|
|
15
|
+
import { handoffV1Template } from './templates/handoff-v1.js';
|
|
14
16
|
import { activityTemplate } from './templates/activity.js';
|
|
15
17
|
import { documentTemplate } from './templates/document.js';
|
|
16
18
|
import { boardTemplate } from './templates/board.js';
|
|
17
19
|
import { networkTemplate } from './templates/network.js';
|
|
18
20
|
import { openInBrowser } from './open.js';
|
|
21
|
+
import { setLinkContext, clearLinkContext } from './markdown.js';
|
|
22
|
+
import { derivedParent, loadComments } from './comments.js';
|
|
19
23
|
|
|
20
|
-
const PARSERS = { quest: parseQuest, handoff: parseHandoff, activity: parseActivity, document: parseDocument, board: parseBoard, network: parseNetwork };
|
|
21
|
-
const TEMPLATES = { quest: questTemplate, handoff: handoffTemplate, activity: activityTemplate, document: documentTemplate, board: boardTemplate, network: networkTemplate };
|
|
24
|
+
const PARSERS = { quest: parseQuest, handoff: parseHandoff, 'handoff-v1': parseHandoffV1, activity: parseActivity, document: parseDocument, board: parseBoard, network: parseNetwork };
|
|
25
|
+
const TEMPLATES = { quest: questTemplate, handoff: handoffTemplate, 'handoff-v1': handoffV1Template, activity: activityTemplate, document: documentTemplate, board: boardTemplate, network: networkTemplate };
|
|
22
26
|
|
|
23
|
-
|
|
27
|
+
const COMMENTABLE_TYPES = new Set(['quest', 'handoff', 'document']);
|
|
28
|
+
export async function generateArtifact(type, input, options = {}) {
|
|
24
29
|
const template = TEMPLATES[type];
|
|
25
30
|
if (!template) throw new Error(`Unknown artifact type: ${type}. Available: ${Object.keys(TEMPLATES).join(', ')}`);
|
|
26
31
|
|
|
@@ -33,8 +38,26 @@ export async function generateArtifact(type, input) {
|
|
|
33
38
|
data = input;
|
|
34
39
|
}
|
|
35
40
|
|
|
36
|
-
|
|
37
|
-
|
|
41
|
+
let parent = null;
|
|
42
|
+
let comments = [];
|
|
43
|
+
if (typeof input === 'string' && COMMENTABLE_TYPES.has(type)) {
|
|
44
|
+
parent = derivedParent(type, input);
|
|
45
|
+
if (parent) comments = loadComments(parent);
|
|
46
|
+
}
|
|
47
|
+
// Link context for inlineMarkdown — only enabled when both values are supplied.
|
|
48
|
+
// Missing either falls through to plain <code> rendering (OSS mode).
|
|
49
|
+
setLinkContext({ viewBase: options.viewBase, orgSlug: options.orgSlug });
|
|
50
|
+
try {
|
|
51
|
+
const element = template(data);
|
|
52
|
+
return renderToHtml(element, {
|
|
53
|
+
title: data.title || 'Untitled',
|
|
54
|
+
type,
|
|
55
|
+
parent,
|
|
56
|
+
comments,
|
|
57
|
+
});
|
|
58
|
+
} finally {
|
|
59
|
+
clearLinkContext();
|
|
60
|
+
}
|
|
38
61
|
}
|
|
39
62
|
|
|
40
63
|
export async function openArtifact(html, title = 'Egregore Artifact') {
|
package/lib/markdown.js
CHANGED
|
@@ -2,9 +2,34 @@
|
|
|
2
2
|
// Handles: **bold**, *italic*, `code`, ### headings, [links](url), tables, lists
|
|
3
3
|
import React from 'react';
|
|
4
4
|
import { colors, fonts } from './tokens.js';
|
|
5
|
+
import { artifactIdFromPath } from './artifact-id.js';
|
|
5
6
|
|
|
6
7
|
const h = React.createElement;
|
|
7
8
|
|
|
9
|
+
// Module-level link context. Set by generateArtifact() before rendering, read
|
|
10
|
+
// by inlineMarkdown() when deciding whether to rewrite `memory/…` code spans
|
|
11
|
+
// and bare egregore.xyz URLs into <a> tags. Renders are synchronous so this
|
|
12
|
+
// is safe; missing context falls through to plain-<code> behaviour (OSS mode).
|
|
13
|
+
let linkContext = { viewBase: null, orgSlug: null };
|
|
14
|
+
|
|
15
|
+
export function setLinkContext(ctx) {
|
|
16
|
+
linkContext = {
|
|
17
|
+
viewBase: (ctx && ctx.viewBase) || null,
|
|
18
|
+
orgSlug: (ctx && ctx.orgSlug) || null,
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function clearLinkContext() {
|
|
23
|
+
linkContext = { viewBase: null, orgSlug: null };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function memoryPathHref(memoryPath) {
|
|
27
|
+
if (!linkContext.viewBase || !linkContext.orgSlug) return null;
|
|
28
|
+
const id = artifactIdFromPath(memoryPath);
|
|
29
|
+
if (!id) return null;
|
|
30
|
+
return `${linkContext.viewBase}/${linkContext.orgSlug}/${id}`;
|
|
31
|
+
}
|
|
32
|
+
|
|
8
33
|
export function renderMarkdown(text) {
|
|
9
34
|
if (!text) return null;
|
|
10
35
|
|
|
@@ -237,7 +262,7 @@ export function renderMarkdown(text) {
|
|
|
237
262
|
|
|
238
263
|
// Inline markdown: **bold**, *italic*, `code`, [link](url)
|
|
239
264
|
// Finds whichever pattern appears earliest in the string
|
|
240
|
-
function inlineMarkdown(text) {
|
|
265
|
+
export function inlineMarkdown(text) {
|
|
241
266
|
if (!text) return text;
|
|
242
267
|
|
|
243
268
|
const parts = [];
|
|
@@ -245,6 +270,27 @@ function inlineMarkdown(text) {
|
|
|
245
270
|
let key = 0;
|
|
246
271
|
|
|
247
272
|
const patterns = [
|
|
273
|
+
// Backtick-wrapped memory/*.{md,html} → link to its own published view
|
|
274
|
+
// when linkContext is available (connected mode). Falls through to plain
|
|
275
|
+
// <code> otherwise via the generic `code` pattern below.
|
|
276
|
+
{ re: /`(memory\/[^`\s]+\.(?:md|html))`/, render: (m) => {
|
|
277
|
+
const href = memoryPathHref(m[1]);
|
|
278
|
+
if (!href) return h('code', { key: key++, className: 'eg-code' }, m[1]);
|
|
279
|
+
return h('a', {
|
|
280
|
+
key: key++,
|
|
281
|
+
href,
|
|
282
|
+
style: { color: 'var(--terracotta)', textDecoration: 'none' },
|
|
283
|
+
}, h('code', { className: 'eg-code' }, m[1]));
|
|
284
|
+
} },
|
|
285
|
+
// Bare http(s) URLs → auto-linked <a>. URLs inside `code`, **bold**, or
|
|
286
|
+
// [text](url) spans are unaffected because those patterns match at an
|
|
287
|
+
// earlier index and win the "earliest wins" race. Regex excludes common
|
|
288
|
+
// trailing punctuation so "See https://foo.com." doesn't eat the period,
|
|
289
|
+
// and excludes quote characters so "href=\"https://x.com\"" or inline
|
|
290
|
+
// strings like 'see https://x.com' terminate cleanly at the quote.
|
|
291
|
+
{ re: /https?:\/\/[^\s)`<>"']*[^\s)`<>"'.,;:!?]/, render: (m) => h('a', {
|
|
292
|
+
key: key++, href: m[0], style: { color: 'var(--terracotta)', textDecoration: 'underline' },
|
|
293
|
+
}, m[0]) },
|
|
248
294
|
{ re: /`([^`]+)`/, render: (m) => h('code', {
|
|
249
295
|
key: key++,
|
|
250
296
|
className: 'eg-code',
|
|
@@ -359,8 +405,10 @@ function parseTableRow(line) {
|
|
|
359
405
|
return line.replace(/^\|/, '').replace(/\|$/, '').split('|');
|
|
360
406
|
}
|
|
361
407
|
|
|
362
|
-
// Lightweight markdown renderer — tables
|
|
363
|
-
//
|
|
408
|
+
// Lightweight markdown renderer — tables, bullets, and paragraphs. Pipes
|
|
409
|
+
// items and paragraph text through inlineMarkdown so attachments pick up
|
|
410
|
+
// auto-linking of memory paths and bare URLs. Skips the full block grammar
|
|
411
|
+
// (headings beyond H2, ordered lists, code fences) for speed on large inputs.
|
|
364
412
|
export function renderMarkdownLite(text) {
|
|
365
413
|
if (!text) return null;
|
|
366
414
|
|
|
@@ -397,7 +445,7 @@ export function renderMarkdownLite(text) {
|
|
|
397
445
|
i++;
|
|
398
446
|
}
|
|
399
447
|
elements.push(h('ul', { key: elements.length, style: { listStyle: 'disc', paddingLeft: '1.5rem', margin: '0.5rem 0' } },
|
|
400
|
-
...items.map((item, j) => h('li', { key: j, style: { fontSize: '14px', lineHeight: 1.5, marginBottom: '0.25rem' } }, item)),
|
|
448
|
+
...items.map((item, j) => h('li', { key: j, style: { fontSize: '14px', lineHeight: 1.5, marginBottom: '0.25rem' } }, inlineMarkdown(item))),
|
|
401
449
|
));
|
|
402
450
|
continue;
|
|
403
451
|
}
|
|
@@ -406,7 +454,7 @@ export function renderMarkdownLite(text) {
|
|
|
406
454
|
if (line.trim() === '') { i++; continue; }
|
|
407
455
|
|
|
408
456
|
// Paragraph
|
|
409
|
-
elements.push(h('p', { key: elements.length, style: { margin: '0.5rem 0', fontSize: '14px', lineHeight: 1.6, color: 'var(--dark)' } }, line));
|
|
457
|
+
elements.push(h('p', { key: elements.length, style: { margin: '0.5rem 0', fontSize: '14px', lineHeight: 1.6, color: 'var(--dark)' } }, inlineMarkdown(line)));
|
|
410
458
|
i++;
|
|
411
459
|
}
|
|
412
460
|
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// Parse v1 handoff JSON (canonical format per docs/specs/handoff-format-v1.md).
|
|
2
|
+
// Input: file path to a JSON file, OR a JSON string, OR a parsed object.
|
|
3
|
+
// Output: the canonical v1 object normalized for the template.
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
|
|
6
|
+
export function parseHandoffV1(input) {
|
|
7
|
+
let obj;
|
|
8
|
+
|
|
9
|
+
if (typeof input === 'string') {
|
|
10
|
+
if (input.trim().startsWith('{')) {
|
|
11
|
+
obj = JSON.parse(input);
|
|
12
|
+
} else {
|
|
13
|
+
if (!fs.existsSync(input)) throw new Error(`File not found: ${input}`);
|
|
14
|
+
const raw = fs.readFileSync(input, 'utf-8');
|
|
15
|
+
obj = JSON.parse(raw);
|
|
16
|
+
}
|
|
17
|
+
} else if (typeof input === 'object' && input !== null) {
|
|
18
|
+
obj = input;
|
|
19
|
+
} else {
|
|
20
|
+
throw new Error('parseHandoffV1 expects a file path, JSON string, or object');
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Light validation — surface clear errors.
|
|
24
|
+
if (obj['@context'] !== 'https://egregore.xyz/spec/handoff/v1') {
|
|
25
|
+
throw new Error(`Unexpected @context: ${obj['@context']} (expected handoff/v1)`);
|
|
26
|
+
}
|
|
27
|
+
if (!obj.topic || !obj.claim || !obj.author || !obj.body) {
|
|
28
|
+
throw new Error('Missing required v1 fields (topic, claim, author, body)');
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Normalize: ensure arrays exist; preserve everything else as-is.
|
|
32
|
+
return {
|
|
33
|
+
'@context': obj['@context'],
|
|
34
|
+
id: obj.id || null,
|
|
35
|
+
version: obj.version || '1.0',
|
|
36
|
+
kind: obj.kind || 'handoff',
|
|
37
|
+
topic: obj.topic,
|
|
38
|
+
claim: obj.claim,
|
|
39
|
+
ask: obj.ask || null,
|
|
40
|
+
created_at: obj.created_at || null,
|
|
41
|
+
updated_at: obj.updated_at || null,
|
|
42
|
+
author: obj.author,
|
|
43
|
+
audience: obj.audience || { addressed_to: [], visible_to: 'public', extendable_by: 'anyone' },
|
|
44
|
+
parents: Array.isArray(obj.parents) ? obj.parents : [],
|
|
45
|
+
body: obj.body,
|
|
46
|
+
references: Array.isArray(obj.references) ? obj.references : [],
|
|
47
|
+
repo_state: Array.isArray(obj.repo_state) ? obj.repo_state : null,
|
|
48
|
+
depth: obj.depth || 'shallow',
|
|
49
|
+
deep_context: obj.deep_context || null,
|
|
50
|
+
receiver_instructions: obj.receiver_instructions || null,
|
|
51
|
+
extensions: Array.isArray(obj.extensions) ? obj.extensions : [],
|
|
52
|
+
// Convenience for the template:
|
|
53
|
+
title: obj.topic,
|
|
54
|
+
canonical_url: obj.id || null,
|
|
55
|
+
extension_endpoint: obj.id ? deriveExtensionEndpoint(obj.id) : null,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function deriveExtensionEndpoint(canonicalUrl) {
|
|
60
|
+
// Canonical URL pattern: https://egregore.xyz/view/_/{id}
|
|
61
|
+
// Extension endpoint: https://egregore.xyz/api/artifacts/handoff/{id}/extend
|
|
62
|
+
const m = canonicalUrl.match(/\/(?:view\/_|h)\/([a-zA-Z0-9_-]+)/);
|
|
63
|
+
if (!m) return null;
|
|
64
|
+
return `https://egregore.xyz/api/artifacts/handoff/${m[1]}/extend`;
|
|
65
|
+
}
|
package/lib/parsers/handoff.js
CHANGED
|
@@ -210,8 +210,10 @@ function parseGraphResponse(raw) {
|
|
|
210
210
|
|
|
211
211
|
function extractReferencedFiles(content, inputDir) {
|
|
212
212
|
const attachments = [];
|
|
213
|
-
// Find backtick-wrapped paths that look like memory files
|
|
214
|
-
|
|
213
|
+
// Find backtick-wrapped paths that look like memory files (.md or .html).
|
|
214
|
+
// Must stay in sync with the corresponding pattern in lib/markdown.js and
|
|
215
|
+
// with bin/publish-references.sh.
|
|
216
|
+
const refs = content.matchAll(/`(memory\/[^`\s]+\.(?:md|html))`/g);
|
|
215
217
|
|
|
216
218
|
// Resolve git root for file lookup
|
|
217
219
|
let gitRoot = null;
|
|
@@ -235,7 +237,7 @@ function extractReferencedFiles(content, inputDir) {
|
|
|
235
237
|
for (const candidate of candidates) {
|
|
236
238
|
if (fs.existsSync(candidate)) {
|
|
237
239
|
const fileContent = fs.readFileSync(candidate, 'utf-8');
|
|
238
|
-
const fileTitle = extractTitle(fileContent) || path.basename(refPath,
|
|
240
|
+
const fileTitle = extractTitle(fileContent) || path.basename(refPath, path.extname(refPath));
|
|
239
241
|
attachments.push({ title: fileTitle, path: refPath, content: fileContent });
|
|
240
242
|
break;
|
|
241
243
|
}
|
package/lib/render.js
CHANGED
|
@@ -4,12 +4,22 @@ import { renderToStaticMarkup } from 'react-dom/server';
|
|
|
4
4
|
import { Renderer, JSONUIProvider, createStateStore } from '@json-render/react';
|
|
5
5
|
import { registry } from './registry.js';
|
|
6
6
|
import { htmlShell } from './shell.js';
|
|
7
|
+
import { CommentSection } from './comments.js';
|
|
7
8
|
|
|
8
9
|
const h = React.createElement;
|
|
9
10
|
|
|
10
|
-
// Render a React element tree (from direct templates)
|
|
11
|
+
// Render a React element tree (from direct templates).
|
|
12
|
+
// If `options.parent` is set, append a CommentSection so every commentable
|
|
13
|
+
// artifact gets the same thread + composer treatment without per-template work.
|
|
11
14
|
export function renderToHtml(element, options = {}) {
|
|
12
|
-
const
|
|
15
|
+
const finalElement = options.parent
|
|
16
|
+
? h('div', null, element, h(CommentSection, {
|
|
17
|
+
parent: options.parent,
|
|
18
|
+
comments: options.comments || [],
|
|
19
|
+
}))
|
|
20
|
+
: element;
|
|
21
|
+
|
|
22
|
+
const bodyHtml = renderToStaticMarkup(finalElement);
|
|
13
23
|
return htmlShell(bodyHtml, {
|
|
14
24
|
title: options.title || 'Egregore Artifact',
|
|
15
25
|
type: options.type || 'artifact',
|