pentimento 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +93 -0
- package/assets/chrome.js +16 -0
- package/assets/theme.css +286 -0
- package/assets/viewer.js +105 -0
- package/dist/cli.js +289 -0
- package/dist/core.js +223 -0
- package/dist/render.js +433 -0
- package/dist/semdiff.js +125 -0
- package/dist/verify.js +120 -0
- package/dist/viewer.js +239 -0
- package/package.json +52 -0
- package/skill/SKILL.md +68 -0
- package/skill/references/archetypes.md +81 -0
- package/skill/references/directives.md +107 -0
package/dist/render.js
ADDED
|
@@ -0,0 +1,433 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import MarkdownIt from 'markdown-it';
|
|
5
|
+
import { parse as parseYaml } from 'yaml';
|
|
6
|
+
import { loadDoc, readMeta, readRevision, slugify, splitRaw } from './core.js';
|
|
7
|
+
import { renderDiffHtml } from './semdiff.js';
|
|
8
|
+
const ASSETS = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../assets');
|
|
9
|
+
const md = new MarkdownIt({ html: true, linkify: false, typographer: false });
|
|
10
|
+
// reader comments are untrusted (they arrive over HTTP via the viewer) — html:false makes
|
|
11
|
+
// markdown-it escape raw HTML and reject javascript: links, unlike the regex sanitize()
|
|
12
|
+
const mdUntrusted = new MarkdownIt({ html: false, linkify: false, typographer: false });
|
|
13
|
+
// every table scrolls inside its own container; the page never scrolls sideways
|
|
14
|
+
md.renderer.rules.table_open = () => '<div class="tablewrap"><table>\n';
|
|
15
|
+
md.renderer.rules.table_close = () => '</table></div>\n';
|
|
16
|
+
const defaultFence = md.renderer.rules.fence;
|
|
17
|
+
md.renderer.rules.fence = (tokens, idx, options, env, self) => {
|
|
18
|
+
const rendered = defaultFence(tokens, idx, options, env, self);
|
|
19
|
+
return rendered.replace(/^<pre>/, '<pre class="block">');
|
|
20
|
+
};
|
|
21
|
+
const escapeHtml = (s) => s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
22
|
+
/** Strip active content from agent-supplied HTML/SVG (defense in depth; not a full sanitizer). */
|
|
23
|
+
const sanitize = (html) => html
|
|
24
|
+
.replace(/<\s*(script|iframe|object|embed|foreignObject)\b[\s\S]*?<\s*\/\s*\1\s*>/gi, '')
|
|
25
|
+
.replace(/<\s*(script|iframe|object|embed|foreignObject)\b[^>]*\/?>/gi, '')
|
|
26
|
+
.replace(/\son[a-z]+\s*=\s*("[^"]*"|'[^']*'|[^\s>]+)/gi, '')
|
|
27
|
+
.replace(/(href|src|xlink:href)\s*=\s*(["'])\s*javascript:[^"']*\2/gi, '$1=$2#$2');
|
|
28
|
+
const plainText = (s) => s.replace(/[`*_]/g, '');
|
|
29
|
+
const parseAttrs = (s) => {
|
|
30
|
+
const attrs = {};
|
|
31
|
+
const re = /([\w-]+)=(?:"([^"]*)"|(\S+))/g;
|
|
32
|
+
let m;
|
|
33
|
+
while ((m = re.exec(s)))
|
|
34
|
+
attrs[m[1]] = m[2] ?? m[3];
|
|
35
|
+
return attrs;
|
|
36
|
+
};
|
|
37
|
+
const CALLOUT_LABELS = {
|
|
38
|
+
decision: 'Decision', info: 'Info', warn: 'Warning', risk: 'Risk',
|
|
39
|
+
};
|
|
40
|
+
const renderCallout = (content, attrs, kind) => {
|
|
41
|
+
const label = CALLOUT_LABELS[kind] ?? 'Note';
|
|
42
|
+
const id = attrs.id ? ` id="${escapeHtml(attrs.id)}"` : '';
|
|
43
|
+
return `<div class="callout ${kind}"${id}><span class="label">${label}</span>${md.render(content)}</div>`;
|
|
44
|
+
};
|
|
45
|
+
const renderVerdict = (content) => {
|
|
46
|
+
const cells = content
|
|
47
|
+
.split('\n')
|
|
48
|
+
.map((l) => /^-\s+(.+?)\s+::\s+(.+)$/.exec(l.trim()))
|
|
49
|
+
.filter((m) => m !== null)
|
|
50
|
+
.map(([, q, a]) => `<div><span class="q">${md.renderInline(q)}</span><span class="a">${md.renderInline(a)}</span></div>`);
|
|
51
|
+
return `<div class="verdict">${cells.join('')}</div>`;
|
|
52
|
+
};
|
|
53
|
+
const SEV_CLASS = { CRIT: 'c', HIGH: 'h', MED: 'm', LOW: 'm' };
|
|
54
|
+
const renderFindings = (content) => {
|
|
55
|
+
const open = [];
|
|
56
|
+
const collapsed = [];
|
|
57
|
+
let collapseLabel = null;
|
|
58
|
+
for (const line of content.split('\n')) {
|
|
59
|
+
const t = line.trim();
|
|
60
|
+
if (!t)
|
|
61
|
+
continue;
|
|
62
|
+
const c = /^@collapse\s+(.+)$/.exec(t);
|
|
63
|
+
if (c) {
|
|
64
|
+
collapseLabel = c[1];
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
const m = /^-\s+(CRIT|HIGH|MED|LOW)\s+::\s+(.+)$/.exec(t);
|
|
68
|
+
if (!m)
|
|
69
|
+
continue;
|
|
70
|
+
const html = `<div class="finding"><span class="sev ${SEV_CLASS[m[1]]}">${m[1]}</span><div>${md.renderInline(m[2])}</div></div>`;
|
|
71
|
+
(collapseLabel ? collapsed : open).push(html);
|
|
72
|
+
}
|
|
73
|
+
const details = collapseLabel
|
|
74
|
+
? `<details><summary>${escapeHtml(collapseLabel)}</summary>${collapsed.join('\n')}</details>`
|
|
75
|
+
: '';
|
|
76
|
+
return `<div>${open.join('\n')}</div>${details}`;
|
|
77
|
+
};
|
|
78
|
+
const renderTimeline = (content) => {
|
|
79
|
+
const items = [];
|
|
80
|
+
let current = [];
|
|
81
|
+
for (const line of content.split('\n')) {
|
|
82
|
+
if (/^\d+\.\s+/.test(line.trim()) && current.length) {
|
|
83
|
+
items.push(current.join(' '));
|
|
84
|
+
current = [];
|
|
85
|
+
}
|
|
86
|
+
if (line.trim())
|
|
87
|
+
current.push(line.trim().replace(/^\d+\.\s+/, ''));
|
|
88
|
+
}
|
|
89
|
+
if (current.length)
|
|
90
|
+
items.push(current.join(' '));
|
|
91
|
+
const lis = items.map((item, i) => {
|
|
92
|
+
const m = /^\*\*(.+?)\*\*(?:\s+\[(next|later|done)\])?(?:\s+—\s+([\s\S]+))?$/.exec(item);
|
|
93
|
+
const title = m ? md.renderInline(m[1]) : md.renderInline(item);
|
|
94
|
+
const pill = m?.[2] ? ` <span class="pill${m[2] === 'next' ? '' : ` ${m[2]}`}">${m[2]}</span>` : '';
|
|
95
|
+
const desc = m?.[3] ? `<p>${md.renderInline(m[3])}</p>` : '';
|
|
96
|
+
return `<li><span class="ph">${i + 1}</span><div><h3>${title}${pill}</h3>${desc}</div></li>`;
|
|
97
|
+
});
|
|
98
|
+
return `<ol class="timeline">${lis.join('\n')}</ol>`;
|
|
99
|
+
};
|
|
100
|
+
/** Content is a unified diff (optionally fenced); rendered as responsive side-by-side panes. */
|
|
101
|
+
const renderDiff = (content, attrs) => {
|
|
102
|
+
let lines = content.split('\n');
|
|
103
|
+
if (lines[0]?.startsWith('```'))
|
|
104
|
+
lines = lines.slice(1, lines.lastIndexOf('```'));
|
|
105
|
+
const before = [];
|
|
106
|
+
const after = [];
|
|
107
|
+
for (const line of lines) {
|
|
108
|
+
if (line.startsWith('-'))
|
|
109
|
+
before.push(`<span class="del">${escapeHtml(line.slice(1))}</span>`);
|
|
110
|
+
else if (line.startsWith('+'))
|
|
111
|
+
after.push(`<span class="add">${escapeHtml(line.slice(1))}</span>`);
|
|
112
|
+
else {
|
|
113
|
+
const ctx = `<span>${escapeHtml(line.replace(/^ /, ''))}</span>`;
|
|
114
|
+
before.push(ctx);
|
|
115
|
+
after.push(ctx);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
const head = attrs.head ? `<div class="diff-head">${escapeHtml(attrs.head)}</div>` : '';
|
|
119
|
+
return `<div class="diff">${head}<div class="diff-cols">` +
|
|
120
|
+
`<div class="diff-pane"><div class="pane-label">Before</div><pre>${before.join('')}</pre></div>` +
|
|
121
|
+
`<div class="diff-pane"><div class="pane-label">After</div><pre>${after.join('')}</pre></div>` +
|
|
122
|
+
`</div></div>`;
|
|
123
|
+
};
|
|
124
|
+
const renderFigure = (content, attrs) => {
|
|
125
|
+
const aria = attrs.aria ? ` role="img" aria-label="${escapeHtml(attrs.aria)}"` : '';
|
|
126
|
+
return `<div class="diagram"${aria}>${sanitize(content)}</div>`;
|
|
127
|
+
};
|
|
128
|
+
const HANDLERS = {
|
|
129
|
+
callout: renderCallout,
|
|
130
|
+
verdict: renderVerdict,
|
|
131
|
+
findings: renderFindings,
|
|
132
|
+
timeline: renderTimeline,
|
|
133
|
+
diff: renderDiff,
|
|
134
|
+
figure: renderFigure,
|
|
135
|
+
compare: (content) => md.render(content),
|
|
136
|
+
files: (content) => md.render(content),
|
|
137
|
+
};
|
|
138
|
+
const HEADING_META_RE = /\s*<!--\s*([^>]*?)\s*-->\s*$/;
|
|
139
|
+
const parseHeadingMeta = (comment) => {
|
|
140
|
+
const out = {};
|
|
141
|
+
for (const part of comment.split(';')) {
|
|
142
|
+
const m = /^\s*(id|eyebrow)\s*:\s*(.+?)\s*$/.exec(part);
|
|
143
|
+
if (m)
|
|
144
|
+
out[m[1]] = m[2];
|
|
145
|
+
}
|
|
146
|
+
return out;
|
|
147
|
+
};
|
|
148
|
+
const prepare = (body) => {
|
|
149
|
+
const swapped = body.replace(/\{dot:([\w-]+)\}/g, '<span class="dot dot-$1"></span>');
|
|
150
|
+
const lines = swapped.split('\n');
|
|
151
|
+
const blocks = [];
|
|
152
|
+
const headings = [];
|
|
153
|
+
let title = '';
|
|
154
|
+
const standfirstLines = [];
|
|
155
|
+
const out = [];
|
|
156
|
+
let inFence = false;
|
|
157
|
+
let i = 0;
|
|
158
|
+
while (i < lines.length) {
|
|
159
|
+
const line = lines[i];
|
|
160
|
+
if (/^```/.test(line.trim()))
|
|
161
|
+
inFence = !inFence;
|
|
162
|
+
if (!inFence) {
|
|
163
|
+
if (!title && /^#\s+/.test(line)) {
|
|
164
|
+
title = line.replace(/^#\s+/, '').trim();
|
|
165
|
+
i++;
|
|
166
|
+
while (i < lines.length && (lines[i].trim() === '' || lines[i].startsWith('>'))) {
|
|
167
|
+
if (lines[i].startsWith('>'))
|
|
168
|
+
standfirstLines.push(lines[i].replace(/^>\s?/, ''));
|
|
169
|
+
i++;
|
|
170
|
+
}
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
const open = /^:::\s*([\w-]+)\s*(.*)$/.exec(line);
|
|
174
|
+
if (open && HANDLERS[open[1]]) {
|
|
175
|
+
const contentLines = [];
|
|
176
|
+
i++;
|
|
177
|
+
let innerFence = false;
|
|
178
|
+
while (i < lines.length) {
|
|
179
|
+
if (/^```/.test(lines[i].trim()))
|
|
180
|
+
innerFence = !innerFence;
|
|
181
|
+
if (!innerFence && /^:::\s*$/.test(lines[i]))
|
|
182
|
+
break;
|
|
183
|
+
contentLines.push(lines[i]);
|
|
184
|
+
i++;
|
|
185
|
+
}
|
|
186
|
+
i++; // consume closing :::
|
|
187
|
+
// a leading bare word is the variant (e.g. `::: callout decision`), the rest is k=v attrs
|
|
188
|
+
let rest = open[2];
|
|
189
|
+
let variant = open[1];
|
|
190
|
+
const vm = /^([\w-]+)\b\s*/.exec(rest);
|
|
191
|
+
if (vm && !rest.startsWith(`${vm[1]}=`)) {
|
|
192
|
+
variant = vm[1];
|
|
193
|
+
rest = rest.slice(vm[0].length);
|
|
194
|
+
}
|
|
195
|
+
blocks.push(HANDLERS[open[1]](contentLines.join('\n').trim(), parseAttrs(rest), variant));
|
|
196
|
+
out.push('', `<!--PENTIMENTO-BLOCK-${blocks.length - 1}-->`, '');
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
const h = /^(#{2,3})\s+(.+)$/.exec(line);
|
|
200
|
+
if (h) {
|
|
201
|
+
let text = h[2];
|
|
202
|
+
let meta = {};
|
|
203
|
+
const c = HEADING_META_RE.exec(text);
|
|
204
|
+
if (c) {
|
|
205
|
+
meta = parseHeadingMeta(c[1]);
|
|
206
|
+
text = text.replace(HEADING_META_RE, '');
|
|
207
|
+
}
|
|
208
|
+
const info = {
|
|
209
|
+
level: h[1].length,
|
|
210
|
+
title: text.trim(),
|
|
211
|
+
id: meta.id ?? slugify(text),
|
|
212
|
+
eyebrow: meta.eyebrow,
|
|
213
|
+
};
|
|
214
|
+
headings.push(info);
|
|
215
|
+
out.push(`<!--PENTIMENTO-H-${headings.length - 1}-->`);
|
|
216
|
+
i++;
|
|
217
|
+
continue;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
out.push(line);
|
|
221
|
+
i++;
|
|
222
|
+
}
|
|
223
|
+
// split into sections at h2 markers
|
|
224
|
+
const sections = [];
|
|
225
|
+
const preamble = [];
|
|
226
|
+
let bucket = preamble;
|
|
227
|
+
for (const line of out) {
|
|
228
|
+
const hm = /^<!--PENTIMENTO-H-(\d+)-->$/.exec(line);
|
|
229
|
+
if (hm && headings[Number(hm[1])].level === 2) {
|
|
230
|
+
sections.push({ heading: headings[Number(hm[1])], bodyLines: [] });
|
|
231
|
+
bucket = sections[sections.length - 1].bodyLines;
|
|
232
|
+
continue;
|
|
233
|
+
}
|
|
234
|
+
bucket.push(line);
|
|
235
|
+
}
|
|
236
|
+
return { title, standfirst: standfirstLines.join(' ').trim(), sections, preamble, blocks, headings };
|
|
237
|
+
};
|
|
238
|
+
const anchor = (id) => `<a class="anch" href="#${id}" aria-label="Link to this section">#</a>`;
|
|
239
|
+
const renderSectionBody = (prepared, bodyLines) => {
|
|
240
|
+
let html = md.render(bodyLines.join('\n'));
|
|
241
|
+
html = html.replace(/<!--PENTIMENTO-H-(\d+)-->/g, (_, n) => {
|
|
242
|
+
const h = prepared.headings[Number(n)];
|
|
243
|
+
return `<h3 id="${h.id}">${md.renderInline(h.title)}${anchor(h.id)}</h3>`;
|
|
244
|
+
});
|
|
245
|
+
html = html.replace(/<!--PENTIMENTO-BLOCK-(\d+)-->/g, (_, n) => prepared.blocks[Number(n)]);
|
|
246
|
+
return sanitize(html);
|
|
247
|
+
};
|
|
248
|
+
const ARCHETYPES = {
|
|
249
|
+
implementation: 'Implementation plan',
|
|
250
|
+
brainstorm: 'Brainstorm / exploration',
|
|
251
|
+
audit: 'Audit / review',
|
|
252
|
+
'design-doc': 'Design doc · PRD',
|
|
253
|
+
};
|
|
254
|
+
const PALETTES = [
|
|
255
|
+
['verdigris', 'Verdigris'],
|
|
256
|
+
['mist', 'Mist'],
|
|
257
|
+
['iris', 'Iris'],
|
|
258
|
+
];
|
|
259
|
+
const DEFAULT_PALETTE = 'iris';
|
|
260
|
+
const chrome = (doc, meta, prepared, opts) => {
|
|
261
|
+
const css = fs.readFileSync(path.join(ASSETS, 'theme.css'), 'utf8');
|
|
262
|
+
const js = fs.readFileSync(path.join(ASSETS, 'chrome.js'), 'utf8');
|
|
263
|
+
const archetype = String(doc.frontmatter['Archetype'] ?? 'design-doc');
|
|
264
|
+
const badge = ARCHETYPES[archetype] ?? archetype;
|
|
265
|
+
const rev = String(doc.frontmatter['Current Revision'] ?? '—');
|
|
266
|
+
const latest = meta.revisions[meta.revisions.length - 1];
|
|
267
|
+
const date = latest ? latest.created_at.slice(0, 10) : '';
|
|
268
|
+
const pathLabel = doc.canonicalPath.split(path.sep).slice(-3).join('/');
|
|
269
|
+
const historyRel = String(doc.frontmatter['History Folder'] ?? `.history/${doc.name}`);
|
|
270
|
+
const fmPalette = String(doc.frontmatter['Palette'] ?? '');
|
|
271
|
+
const defaultPalette = PALETTES.some(([k]) => k === fmPalette) ? fmPalette : DEFAULT_PALETTE;
|
|
272
|
+
const paletteBtns = PALETTES.map(([key, label]) => `<button class="pbtn" type="button" data-p="${key}" aria-pressed="${key === defaultPalette}">` +
|
|
273
|
+
`<span class="dot dot-${key}"></span>${label}</button>`).join('\n ');
|
|
274
|
+
const inline = (s) => sanitize(md.renderInline(s));
|
|
275
|
+
const evolution = [...meta.revisions].reverse().slice(0, 4).map((r) => `<div class="evolution"><span class="rev">${escapeHtml(r.id)}</span>` +
|
|
276
|
+
`<span class="what"><strong>${inline(r.summary)}</strong>` +
|
|
277
|
+
`${r.why ? ` — ${inline(r.why)}` : ''}</span></div>`).join('\n ');
|
|
278
|
+
const openComments = meta.comments.filter((c) => c.status === 'open');
|
|
279
|
+
const commentsPanel = openComments.length
|
|
280
|
+
? `\n <details class="comments" open><summary>${openComments.length} open comment${openComments.length > 1 ? 's' : ''}</summary>${openComments.map((c) => `<div class="vcomment" data-cid="${escapeHtml(c.id)}">` +
|
|
281
|
+
`${c.anchor ? `<a class="vc-anchor" href="${escapeHtml(c.anchor)}">${escapeHtml(c.anchor)}</a> ` : ''}` +
|
|
282
|
+
`${c.quote ? `<blockquote>${escapeHtml(c.quote)}</blockquote>` : ''}` +
|
|
283
|
+
`<p>${mdUntrusted.renderInline(c.text)}</p>` +
|
|
284
|
+
`<span class="vc-meta">${escapeHtml(c.author)} · ${escapeHtml(String(c.created_at).slice(0, 10))}</span></div>`).join('')}</details>`
|
|
285
|
+
: '';
|
|
286
|
+
// what changed since the previous revision — so a reader never has to ask the agent
|
|
287
|
+
let changes = '';
|
|
288
|
+
if (meta.revisions.length >= 2) {
|
|
289
|
+
const prev = meta.revisions[meta.revisions.length - 2];
|
|
290
|
+
const latest = meta.revisions[meta.revisions.length - 1];
|
|
291
|
+
const prevFile = path.join(doc.historyDir, `${prev.id}.md`);
|
|
292
|
+
if (fs.existsSync(prevFile)) {
|
|
293
|
+
const prevBody = splitRaw(fs.readFileSync(prevFile, 'utf8')).body;
|
|
294
|
+
changes = `\n <details class="changes"><summary>What changed in ${escapeHtml(latest.id)} (vs ${escapeHtml(prev.id)})</summary><div class="rdiff">${renderDiffHtml(prevBody, doc.body)}</div></details>`;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
const toc = prepared.sections.map((s, i) => `<li><a href="#${s.heading.id}"><span class="n">${String(i + 1).padStart(2, '0')}</span>` +
|
|
298
|
+
`${escapeHtml(plainText(s.heading.title))}</a></li>`).join('\n ');
|
|
299
|
+
const sections = prepared.sections.map((s) => {
|
|
300
|
+
const eyebrow = s.heading.eyebrow ? `<span class="eyebrow">${escapeHtml(s.heading.eyebrow)}</span>\n ` : '';
|
|
301
|
+
return `<section>\n ${eyebrow}<h2 id="${s.heading.id}">${md.renderInline(s.heading.title)}${anchor(s.heading.id)}</h2>\n` +
|
|
302
|
+
renderSectionBody(prepared, s.bodyLines) + '\n</section>';
|
|
303
|
+
}).join('\n\n');
|
|
304
|
+
const preambleHtml = prepared.preamble.join('\n').trim()
|
|
305
|
+
? renderSectionBody(prepared, prepared.preamble)
|
|
306
|
+
: '';
|
|
307
|
+
const title = escapeHtml(plainText(prepared.title));
|
|
308
|
+
// set the palette before first paint; localStorage (user's explicit pick) wins over the doc default
|
|
309
|
+
const paletteInit = `<script>(()=>{let p=null;try{p=localStorage.getItem('pentimento-palette')}catch(e){}p=p||'${defaultPalette}';if(p!=='verdigris')document.documentElement.dataset.palette=p})()</script>`;
|
|
310
|
+
const body = `${paletteInit}
|
|
311
|
+
|
|
312
|
+
<div class="wrap">
|
|
313
|
+
<header class="doc">
|
|
314
|
+
<div class="meta-row">
|
|
315
|
+
<span class="badge badge-${escapeHtml(archetype)}">${escapeHtml(badge)}</span>
|
|
316
|
+
<span class="chip">${escapeHtml(rev)}</span>
|
|
317
|
+
${date ? `<span class="chip">${escapeHtml(date)}</span>\n ` : ''}<span class="chip">${escapeHtml(pathLabel)}</span>
|
|
318
|
+
<div class="palettes" role="group" aria-label="Color theme">
|
|
319
|
+
${paletteBtns}
|
|
320
|
+
</div>
|
|
321
|
+
</div>
|
|
322
|
+
<h1>${inline(prepared.title)}</h1>
|
|
323
|
+
${prepared.standfirst ? `<p class="standfirst">${inline(prepared.standfirst)}</p>` : ''}
|
|
324
|
+
${evolution}${changes}${commentsPanel}
|
|
325
|
+
</header>
|
|
326
|
+
|
|
327
|
+
<details class="toc" open>
|
|
328
|
+
<summary>Contents</summary>
|
|
329
|
+
<nav aria-label="Contents">
|
|
330
|
+
<ol>
|
|
331
|
+
${toc}
|
|
332
|
+
</ol>
|
|
333
|
+
</nav>
|
|
334
|
+
</details>
|
|
335
|
+
|
|
336
|
+
<main>
|
|
337
|
+
${preambleHtml}
|
|
338
|
+
${sections}
|
|
339
|
+
</main>
|
|
340
|
+
|
|
341
|
+
<footer class="doc">
|
|
342
|
+
Source: <code>${escapeHtml(pathLabel)}</code> · history: <code>${escapeHtml(historyRel)}/</code> (${escapeHtml(rev)}) · Rendered by <code>pentimento render</code>.
|
|
343
|
+
</footer>
|
|
344
|
+
</div>
|
|
345
|
+
<script>
|
|
346
|
+
${js}</script>
|
|
347
|
+
`;
|
|
348
|
+
if (opts.artifact)
|
|
349
|
+
return `<title>${title}</title>\n<style>\n${css}</style>\n\n${body}`;
|
|
350
|
+
return `<!doctype html>
|
|
351
|
+
<html lang="en">
|
|
352
|
+
<head>
|
|
353
|
+
<meta charset="utf-8">
|
|
354
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
355
|
+
<title>${title}</title>
|
|
356
|
+
<style>
|
|
357
|
+
${css}</style>
|
|
358
|
+
</head>
|
|
359
|
+
<body>
|
|
360
|
+
${body}</body>
|
|
361
|
+
</html>
|
|
362
|
+
`;
|
|
363
|
+
};
|
|
364
|
+
export const render = (docPath, opts = {}) => {
|
|
365
|
+
const doc = loadDoc(docPath);
|
|
366
|
+
const meta = readMeta(doc.historyDir);
|
|
367
|
+
return chrome(doc, meta, prepare(doc.body), opts);
|
|
368
|
+
};
|
|
369
|
+
export const renderToFile = (docPath, outPath, opts = {}) => {
|
|
370
|
+
const doc = loadDoc(docPath);
|
|
371
|
+
const out = outPath
|
|
372
|
+
? path.resolve(outPath)
|
|
373
|
+
: path.join(path.dirname(doc.canonicalPath), `${doc.name.toLowerCase()}.html`);
|
|
374
|
+
fs.writeFileSync(out, render(docPath, opts), 'utf8');
|
|
375
|
+
return out;
|
|
376
|
+
};
|
|
377
|
+
/** Render the document as it stood at a given revision (evolution strip trimmed to that point). */
|
|
378
|
+
export const renderRevisionHtml = (docPath, rev, opts = {}) => {
|
|
379
|
+
const doc = loadDoc(docPath);
|
|
380
|
+
const raw = readRevision(docPath, rev);
|
|
381
|
+
const { frontmatterRaw, body } = splitRaw(raw);
|
|
382
|
+
let frontmatter = { ...doc.frontmatter, 'Current Revision': rev };
|
|
383
|
+
if (frontmatterRaw) {
|
|
384
|
+
try {
|
|
385
|
+
frontmatter = { ...(parseYaml(frontmatterRaw) ?? {}), 'Current Revision': rev };
|
|
386
|
+
}
|
|
387
|
+
catch { /* fall back to the canonical's frontmatter */ }
|
|
388
|
+
}
|
|
389
|
+
const meta = readMeta(doc.historyDir);
|
|
390
|
+
const idx = meta.revisions.findIndex((r) => r.id === rev);
|
|
391
|
+
const trimmed = {
|
|
392
|
+
revisions: idx >= 0 ? meta.revisions.slice(0, idx + 1) : meta.revisions,
|
|
393
|
+
comments: meta.comments,
|
|
394
|
+
};
|
|
395
|
+
const revDoc = { ...doc, raw, body, frontmatter };
|
|
396
|
+
return chrome(revDoc, trimmed, prepare(body), opts);
|
|
397
|
+
};
|
|
398
|
+
/** Standalone page showing the changes between two revisions ('canonical' = current file). */
|
|
399
|
+
export const renderDiffPage = (docPath, a, b) => {
|
|
400
|
+
const doc = loadDoc(docPath);
|
|
401
|
+
const css = fs.readFileSync(path.join(ASSETS, 'theme.css'), 'utf8');
|
|
402
|
+
const bodyOf = (rev) => rev === 'canonical' ? doc.body : splitRaw(readRevision(docPath, rev)).body;
|
|
403
|
+
const rdiff = renderDiffHtml(bodyOf(a), bodyOf(b));
|
|
404
|
+
const title = `${doc.name}: ${a} → ${b}`;
|
|
405
|
+
return `<!doctype html>
|
|
406
|
+
<html lang="en">
|
|
407
|
+
<head>
|
|
408
|
+
<meta charset="utf-8">
|
|
409
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
410
|
+
<title>${escapeHtml(title)}</title>
|
|
411
|
+
<style>
|
|
412
|
+
${css}</style>
|
|
413
|
+
</head>
|
|
414
|
+
<body>
|
|
415
|
+
<div class="wrap">
|
|
416
|
+
<header class="doc">
|
|
417
|
+
<div class="meta-row">
|
|
418
|
+
<span class="badge">Changes</span>
|
|
419
|
+
<span class="chip">${escapeHtml(a)} → ${escapeHtml(b)}</span>
|
|
420
|
+
<span class="chip">${escapeHtml(doc.name)}</span>
|
|
421
|
+
</div>
|
|
422
|
+
<h1>${escapeHtml(doc.name)}</h1>
|
|
423
|
+
<p class="standfirst">What changed between ${escapeHtml(a)} and ${escapeHtml(b)}.</p>
|
|
424
|
+
</header>
|
|
425
|
+
<main>
|
|
426
|
+
<div class="rdiff" style="margin-top:2rem">${rdiff}</div>
|
|
427
|
+
</main>
|
|
428
|
+
<footer class="doc">Rendered by <code>pentimento diff --html</code>.</footer>
|
|
429
|
+
</div>
|
|
430
|
+
</body>
|
|
431
|
+
</html>
|
|
432
|
+
`;
|
|
433
|
+
};
|
package/dist/semdiff.js
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { diffArrays, diffWords } from 'diff';
|
|
2
|
+
const escapeHtml = (s) => s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
3
|
+
/** Split a markdown body into diffable blocks; fences and ::: containers stay atomic. */
|
|
4
|
+
export const splitBlocks = (body) => {
|
|
5
|
+
const lines = body.split('\n');
|
|
6
|
+
const blocks = [];
|
|
7
|
+
let cur = [];
|
|
8
|
+
let inFence = false;
|
|
9
|
+
let inContainer = false;
|
|
10
|
+
const flush = () => {
|
|
11
|
+
const t = cur.join('\n').trim();
|
|
12
|
+
if (t)
|
|
13
|
+
blocks.push(t);
|
|
14
|
+
cur = [];
|
|
15
|
+
};
|
|
16
|
+
for (const line of lines) {
|
|
17
|
+
const t = line.trim();
|
|
18
|
+
if (/^```/.test(t)) {
|
|
19
|
+
inFence = !inFence;
|
|
20
|
+
cur.push(line);
|
|
21
|
+
if (!inFence && !inContainer)
|
|
22
|
+
flush();
|
|
23
|
+
continue;
|
|
24
|
+
}
|
|
25
|
+
if (!inFence) {
|
|
26
|
+
if (!inContainer && /^:::\s*\w/.test(t)) {
|
|
27
|
+
flush();
|
|
28
|
+
inContainer = true;
|
|
29
|
+
cur.push(line);
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
if (inContainer && /^:::$/.test(t)) {
|
|
33
|
+
cur.push(line);
|
|
34
|
+
inContainer = false;
|
|
35
|
+
flush();
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
if (!inContainer && t === '') {
|
|
39
|
+
flush();
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
cur.push(line);
|
|
44
|
+
}
|
|
45
|
+
flush();
|
|
46
|
+
return blocks;
|
|
47
|
+
};
|
|
48
|
+
export const diffBlocks = (oldBody, newBody) => {
|
|
49
|
+
const parts = diffArrays(splitBlocks(oldBody), splitBlocks(newBody));
|
|
50
|
+
const out = [];
|
|
51
|
+
let i = 0;
|
|
52
|
+
while (i < parts.length) {
|
|
53
|
+
const p = parts[i];
|
|
54
|
+
const next = parts[i + 1];
|
|
55
|
+
if (p.removed && next?.added) {
|
|
56
|
+
// a replace hunk: pair blocks positionally for word-level diffing
|
|
57
|
+
const paired = Math.min(p.value.length, next.value.length);
|
|
58
|
+
for (let k = 0; k < paired; k++)
|
|
59
|
+
out.push({ type: 'change', old: p.value[k], new: next.value[k] });
|
|
60
|
+
for (let k = paired; k < p.value.length; k++)
|
|
61
|
+
out.push({ type: 'del', old: p.value[k] });
|
|
62
|
+
for (let k = paired; k < next.value.length; k++)
|
|
63
|
+
out.push({ type: 'add', new: next.value[k] });
|
|
64
|
+
i += 2;
|
|
65
|
+
}
|
|
66
|
+
else if (p.removed) {
|
|
67
|
+
p.value.forEach((v) => out.push({ type: 'del', old: v }));
|
|
68
|
+
i++;
|
|
69
|
+
}
|
|
70
|
+
else if (p.added) {
|
|
71
|
+
p.value.forEach((v) => out.push({ type: 'add', new: v }));
|
|
72
|
+
i++;
|
|
73
|
+
}
|
|
74
|
+
else {
|
|
75
|
+
p.value.forEach((v) => out.push({ type: 'same', old: v, new: v }));
|
|
76
|
+
i++;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return out;
|
|
80
|
+
};
|
|
81
|
+
const isCodey = (s) => /^(```|:::|\||<)/.test(s.trimStart());
|
|
82
|
+
const wordDiffHtml = (oldS, newS) => diffWords(oldS, newS)
|
|
83
|
+
.map((p) => (p.added ? `<ins>${escapeHtml(p.value)}</ins>` : p.removed ? `<del>${escapeHtml(p.value)}</del>` : escapeHtml(p.value)))
|
|
84
|
+
.join('');
|
|
85
|
+
/**
|
|
86
|
+
* Reader-facing revision diff: changed blocks with word-level ins/del marks,
|
|
87
|
+
* unchanged runs collapsed, the nearest heading shown as context before each change.
|
|
88
|
+
*/
|
|
89
|
+
export const renderDiffHtml = (oldBody, newBody) => {
|
|
90
|
+
const parts = diffBlocks(oldBody, newBody);
|
|
91
|
+
const out = [];
|
|
92
|
+
let skip = 0;
|
|
93
|
+
let pendingCtx = null;
|
|
94
|
+
const flushSkip = () => {
|
|
95
|
+
if (skip) {
|
|
96
|
+
out.push(`<div class="rdiff-skip">⋯ ${skip} unchanged block${skip > 1 ? 's' : ''}</div>`);
|
|
97
|
+
skip = 0;
|
|
98
|
+
}
|
|
99
|
+
};
|
|
100
|
+
for (const p of parts) {
|
|
101
|
+
if (p.type === 'same') {
|
|
102
|
+
skip++;
|
|
103
|
+
if (/^#{1,3}\s/.test(p.new))
|
|
104
|
+
pendingCtx = p.new.split('\n')[0].replace(/<!--.*?-->/g, '').trim();
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
flushSkip();
|
|
108
|
+
if (pendingCtx) {
|
|
109
|
+
out.push(`<div class="rdiff-ctx">${escapeHtml(pendingCtx)}</div>`);
|
|
110
|
+
pendingCtx = null;
|
|
111
|
+
}
|
|
112
|
+
const cls = `rdiff-block${isCodey(p.new ?? p.old ?? '') ? ' codey' : ''}`;
|
|
113
|
+
if (p.type === 'change')
|
|
114
|
+
out.push(`<div class="${cls}">${wordDiffHtml(p.old, p.new)}</div>`);
|
|
115
|
+
else if (p.type === 'add')
|
|
116
|
+
out.push(`<div class="${cls}"><ins>${escapeHtml(p.new)}</ins></div>`);
|
|
117
|
+
else
|
|
118
|
+
out.push(`<div class="${cls}"><del>${escapeHtml(p.old)}</del></div>`);
|
|
119
|
+
}
|
|
120
|
+
flushSkip();
|
|
121
|
+
if (!out.some((h) => h.includes('rdiff-block'))) {
|
|
122
|
+
return '<div class="rdiff-skip">No content changes in the body (frontmatter or metadata only).</div>';
|
|
123
|
+
}
|
|
124
|
+
return out.join('\n');
|
|
125
|
+
};
|
package/dist/verify.js
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { loadDoc, parseRevId, readMeta, revId, splitRaw } from './core.js';
|
|
4
|
+
const SKIP_DIRS = new Set(['.git', 'node_modules', '.history', 'dist', '.obsidian']);
|
|
5
|
+
export const findPentimentoDocs = (root) => {
|
|
6
|
+
const out = [];
|
|
7
|
+
const walk = (dir) => {
|
|
8
|
+
let entries;
|
|
9
|
+
try {
|
|
10
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
11
|
+
}
|
|
12
|
+
catch {
|
|
13
|
+
return; // unreadable directory — skip
|
|
14
|
+
}
|
|
15
|
+
for (const entry of entries) {
|
|
16
|
+
if (entry.isDirectory()) {
|
|
17
|
+
if (!SKIP_DIRS.has(entry.name) && !entry.name.startsWith('.'))
|
|
18
|
+
walk(path.join(dir, entry.name));
|
|
19
|
+
continue;
|
|
20
|
+
}
|
|
21
|
+
if (!entry.name.endsWith('.md'))
|
|
22
|
+
continue;
|
|
23
|
+
const p = path.join(dir, entry.name);
|
|
24
|
+
try {
|
|
25
|
+
const head = fs.readFileSync(p, 'utf8').slice(0, 2000);
|
|
26
|
+
if (/^(?:Pentimento|Vellum):\s*true$/m.test(head))
|
|
27
|
+
out.push(p);
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
// broken symlink or unreadable file — not a Pentimento doc
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
};
|
|
34
|
+
walk(root);
|
|
35
|
+
return out.sort();
|
|
36
|
+
};
|
|
37
|
+
/** Consistency check: canonical frontmatter, history files, and meta.yml must agree. */
|
|
38
|
+
export const verifyDoc = (docPath) => {
|
|
39
|
+
const issues = [];
|
|
40
|
+
const err = (message) => issues.push({ level: 'error', message });
|
|
41
|
+
const warn = (message) => issues.push({ level: 'warn', message });
|
|
42
|
+
const info = (message) => issues.push({ level: 'info', message });
|
|
43
|
+
let doc;
|
|
44
|
+
try {
|
|
45
|
+
doc = loadDoc(docPath);
|
|
46
|
+
}
|
|
47
|
+
catch (e) {
|
|
48
|
+
err(e instanceof Error ? e.message : String(e));
|
|
49
|
+
return issues;
|
|
50
|
+
}
|
|
51
|
+
if (doc.frontmatter['Pentimento'] !== true && doc.frontmatter['Vellum'] !== true) {
|
|
52
|
+
info('not a Pentimento document (no `Pentimento: true` frontmatter) — nothing to verify');
|
|
53
|
+
return issues;
|
|
54
|
+
}
|
|
55
|
+
if (doc.historyDir.includes('{{')) {
|
|
56
|
+
info('template file (placeholder in History Folder) — skipped');
|
|
57
|
+
return issues;
|
|
58
|
+
}
|
|
59
|
+
let current = 0;
|
|
60
|
+
try {
|
|
61
|
+
current = parseRevId(doc.frontmatter['Current Revision']);
|
|
62
|
+
}
|
|
63
|
+
catch (e) {
|
|
64
|
+
err(e instanceof Error ? e.message : String(e));
|
|
65
|
+
}
|
|
66
|
+
if (!fs.existsSync(doc.historyDir)) {
|
|
67
|
+
err(`history folder missing: ${doc.historyDir}`);
|
|
68
|
+
return issues;
|
|
69
|
+
}
|
|
70
|
+
let meta;
|
|
71
|
+
try {
|
|
72
|
+
meta = readMeta(doc.historyDir);
|
|
73
|
+
}
|
|
74
|
+
catch (e) {
|
|
75
|
+
err(e instanceof Error ? e.message : String(e));
|
|
76
|
+
return issues;
|
|
77
|
+
}
|
|
78
|
+
const files = fs
|
|
79
|
+
.readdirSync(doc.historyDir)
|
|
80
|
+
.filter((f) => /^r\d{3,}\.md$/.test(f))
|
|
81
|
+
.map((f) => f.replace(/\.md$/, ''))
|
|
82
|
+
.sort();
|
|
83
|
+
const metaIds = meta.revisions.map((r) => r.id);
|
|
84
|
+
if (new Set(metaIds).size !== metaIds.length)
|
|
85
|
+
err('duplicate revision ids in meta.yml');
|
|
86
|
+
for (const id of metaIds) {
|
|
87
|
+
if (!files.includes(id))
|
|
88
|
+
err(`meta.yml lists ${id} but ${id}.md does not exist`);
|
|
89
|
+
}
|
|
90
|
+
for (const f of files) {
|
|
91
|
+
if (!metaIds.includes(f))
|
|
92
|
+
warn(`${f}.md exists but meta.yml has no entry for it`);
|
|
93
|
+
}
|
|
94
|
+
for (let i = 0; i < metaIds.length; i++) {
|
|
95
|
+
if (metaIds[i] !== revId(i + 1)) {
|
|
96
|
+
warn(`revision ids are not sequential from r001 (found ${metaIds[i]} at position ${i + 1})`);
|
|
97
|
+
break;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
const last = metaIds[metaIds.length - 1];
|
|
101
|
+
if (current > 0 && last && revId(current) !== last) {
|
|
102
|
+
err(`canonical says Current Revision ${revId(current)} but the latest meta revision is ${last}`);
|
|
103
|
+
}
|
|
104
|
+
if (current > 0 && !files.includes(revId(current))) {
|
|
105
|
+
err(`Current Revision ${revId(current)} has no snapshot file`);
|
|
106
|
+
}
|
|
107
|
+
for (const f of files) {
|
|
108
|
+
const content = fs.readFileSync(path.join(doc.historyDir, `${f}.md`), 'utf8');
|
|
109
|
+
if (splitRaw(content).body.trim().length < 40) {
|
|
110
|
+
warn(`${f}.md looks like a stub (${content.length} bytes), not a real snapshot`);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
if (last && files.includes(last)) {
|
|
114
|
+
const lastBody = splitRaw(fs.readFileSync(path.join(doc.historyDir, `${last}.md`), 'utf8')).body;
|
|
115
|
+
if (lastBody.trim() !== doc.body.trim()) {
|
|
116
|
+
info(`canonical has changes not yet snapshotted (differs from ${last})`);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return issues;
|
|
120
|
+
};
|