@rarusoft/dendrite-wiki 0.1.0-alpha.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/README.md +79 -0
- package/dist/api-extractor/extract.js +269 -0
- package/dist/api-extractor/language-extractor.js +15 -0
- package/dist/api-extractor/python-extractor.js +358 -0
- package/dist/api-extractor/render.js +195 -0
- package/dist/api-extractor/tree-sitter-extractor.js +1079 -0
- package/dist/api-extractor/types.js +11 -0
- package/dist/api-extractor/typescript-extractor.js +50 -0
- package/dist/api-extractor/walk.js +178 -0
- package/dist/api-reference.js +438 -0
- package/dist/benchmark-events.js +129 -0
- package/dist/benchmark.js +270 -0
- package/dist/binder-export.js +381 -0
- package/dist/canonical-target.js +168 -0
- package/dist/chart-insert.js +377 -0
- package/dist/chart-prompts.js +414 -0
- package/dist/context-cache.js +98 -0
- package/dist/contradicts-shipped-memory.js +232 -0
- package/dist/diff-context.js +142 -0
- package/dist/doctor.js +220 -0
- package/dist/generated-docs.js +219 -0
- package/dist/i18n.js +71 -0
- package/dist/index.js +49 -0
- package/dist/librarian.js +255 -0
- package/dist/maintenance-actions.js +244 -0
- package/dist/maintenance-inbox.js +842 -0
- package/dist/maintenance-runner.js +62 -0
- package/dist/page-drift.js +225 -0
- package/dist/page-inbox.js +168 -0
- package/dist/report-export.js +339 -0
- package/dist/review-bridge.js +1386 -0
- package/dist/search-index.js +199 -0
- package/dist/store.js +1617 -0
- package/dist/telemetry-defaults.js +44 -0
- package/dist/telemetry-report.js +263 -0
- package/dist/telemetry.js +544 -0
- package/dist/wiki-synthesis.js +901 -0
- package/package.json +35 -0
- package/src/api-extractor/extract.ts +333 -0
- package/src/api-extractor/language-extractor.ts +37 -0
- package/src/api-extractor/python-extractor.ts +380 -0
- package/src/api-extractor/render.ts +267 -0
- package/src/api-extractor/tree-sitter-extractor.ts +1210 -0
- package/src/api-extractor/types.ts +41 -0
- package/src/api-extractor/typescript-extractor.ts +56 -0
- package/src/api-extractor/walk.ts +209 -0
- package/src/api-reference.ts +552 -0
- package/src/benchmark-events.ts +216 -0
- package/src/benchmark.ts +376 -0
- package/src/binder-export.ts +437 -0
- package/src/canonical-target.ts +192 -0
- package/src/chart-insert.ts +478 -0
- package/src/chart-prompts.ts +417 -0
- package/src/context-cache.ts +129 -0
- package/src/contradicts-shipped-memory.ts +311 -0
- package/src/diff-context.ts +187 -0
- package/src/doctor.ts +260 -0
- package/src/generated-docs.ts +316 -0
- package/src/i18n.ts +106 -0
- package/src/index.ts +59 -0
- package/src/librarian.ts +331 -0
- package/src/maintenance-actions.ts +314 -0
- package/src/maintenance-inbox.ts +1132 -0
- package/src/maintenance-runner.ts +85 -0
- package/src/page-drift.ts +292 -0
- package/src/page-inbox.ts +254 -0
- package/src/report-export.ts +392 -0
- package/src/review-bridge.ts +1729 -0
- package/src/search-index.ts +266 -0
- package/src/store.ts +2171 -0
- package/src/telemetry-defaults.ts +50 -0
- package/src/telemetry-report.ts +365 -0
- package/src/telemetry.ts +757 -0
- package/src/wiki-synthesis.ts +1307 -0
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Chart insertion + validation module — M2 of the AI-mermaid-charts roadmap.
|
|
3
|
+
*
|
|
4
|
+
* Single source of truth for adding/updating Mermaid diagrams in wiki pages.
|
|
5
|
+
* Both insertion paths converge here:
|
|
6
|
+
* - Operator-side: the editor's Insert Chart wizard (M5) calls
|
|
7
|
+
* `insertChartIntoPage` via the embedded review-bridge endpoint.
|
|
8
|
+
* - Agent-side: the `wiki_insert_chart` MCP tool (M3) calls the same
|
|
9
|
+
* function directly. Same validation, same anchoring, same write path.
|
|
10
|
+
*
|
|
11
|
+
* Design contracts:
|
|
12
|
+
* - VALIDATE first. Both paths parse `mermaidSource` with `mermaid.parse`
|
|
13
|
+
* before any disk write. If the source is malformed, the call fails with
|
|
14
|
+
* a structured error. Never silently corrupt a page.
|
|
15
|
+
* - ANCHOR by heading, not by line number. Line numbers shift with any
|
|
16
|
+
* edit; headings are stable identifiers. `after-heading` / `before-heading`
|
|
17
|
+
* find the matching `## ...` line, then anchor relative to its section
|
|
18
|
+
* boundary (next sibling heading at the same or higher level).
|
|
19
|
+
* - IDEMPOTENT via stable chart-id markers. Each inserted chart is
|
|
20
|
+
* prefixed by `<!-- chart:<kind>-<hash7> -->` where hash7 is sha256 of
|
|
21
|
+
* the mermaid source truncated to 7 hex chars. Calling `insertChartIntoPage`
|
|
22
|
+
* twice with identical (slug, source, anchor) is a no-op on the second
|
|
23
|
+
* call.
|
|
24
|
+
* - WRITE via writeWikiPage so the same lint, cache invalidation,
|
|
25
|
+
* project-log entry, and benchmark event side-effects fire as any other
|
|
26
|
+
* wiki edit. The benchmark trigger is `wiki_insert_chart` (added to the
|
|
27
|
+
* DendriteBenchmarkEventTrigger union when M3 wires the MCP tool).
|
|
28
|
+
*/
|
|
29
|
+
import { createHash } from 'node:crypto';
|
|
30
|
+
import { appendProjectLog, readWikiPage, writeWikiPage } from './store.js';
|
|
31
|
+
const MARKER_PREFIX = 'chart:';
|
|
32
|
+
// Matches the marker comment line we insert before each chart. Captures the
|
|
33
|
+
// chart ID. Single-line; the marker always sits on its own line.
|
|
34
|
+
const MARKER_RE = /^<!--\s*chart:([a-z0-9][a-z0-9-]*)\s*-->\s*$/m;
|
|
35
|
+
// The full chart block: marker line + ```mermaid fence + body + closing fence
|
|
36
|
+
// + optional caption. Captures groups for replacement.
|
|
37
|
+
const CHART_BLOCK_RE = /<!--\s*chart:([a-z0-9][a-z0-9-]*)\s*-->\r?\n```mermaid\r?\n([\s\S]*?)\r?\n```\r?\n?(?:\r?\n\*Figure:\s*([^\n]+)\*\r?\n)?/g;
|
|
38
|
+
// Diagram-type keywords Mermaid recognizes. Match is case-insensitive on the
|
|
39
|
+
// first non-empty line. List kept in sync with current Mermaid docs as of
|
|
40
|
+
// the package version pinned in package.json.
|
|
41
|
+
const DIAGRAM_KEYWORDS = [
|
|
42
|
+
'flowchart', 'graph',
|
|
43
|
+
'sequenceDiagram',
|
|
44
|
+
'stateDiagram-v2', 'stateDiagram',
|
|
45
|
+
'classDiagram',
|
|
46
|
+
'erDiagram',
|
|
47
|
+
'gantt', 'pie', 'journey',
|
|
48
|
+
'gitGraph',
|
|
49
|
+
'mindmap', 'timeline',
|
|
50
|
+
'quadrantChart',
|
|
51
|
+
'xychart-beta', 'sankey-beta',
|
|
52
|
+
'c4Context', 'c4Container', 'c4Component', 'c4Dynamic', 'c4',
|
|
53
|
+
'requirementDiagram',
|
|
54
|
+
'block-beta',
|
|
55
|
+
'packet-beta'
|
|
56
|
+
];
|
|
57
|
+
// Arrow-like operators across the diagram types. The check just needs ANY of
|
|
58
|
+
// them present — a chart with zero connections is almost always a truncated
|
|
59
|
+
// LLM output ("flowchart TD" with no body) rather than a deliberate empty
|
|
60
|
+
// diagram. We accept false-negatives on edge cases (e.g., a single-node
|
|
61
|
+
// flowchart) — the cost of a false-negative is the operator regenerating;
|
|
62
|
+
// the cost of accepting truncation is a useless chart in the wiki.
|
|
63
|
+
const CONNECTION_OPERATORS = /-->|---|-\.->|==>|->>|-->>|<<--|--?>|->|<->|~~~|\.\.>|::|<-/;
|
|
64
|
+
export async function validateMermaidSource(source) {
|
|
65
|
+
const trimmed = source.trim();
|
|
66
|
+
if (!trimmed) {
|
|
67
|
+
return { ok: false, error: { message: 'Mermaid source is empty.', source } };
|
|
68
|
+
}
|
|
69
|
+
// 1. Defense-in-depth on top of mermaid's `securityLevel: 'strict'`. If
|
|
70
|
+
// somehow `<script>` or `<iframe>` survives the LLM and our prompt, refuse
|
|
71
|
+
// to write it to a wiki page. The browser renderer would strip them at
|
|
72
|
+
// display time but the source would still be on disk.
|
|
73
|
+
if (/<\s*(script|iframe|object|embed)\b/i.test(trimmed)) {
|
|
74
|
+
return {
|
|
75
|
+
ok: false,
|
|
76
|
+
error: {
|
|
77
|
+
message: 'Mermaid source contains a forbidden HTML tag (<script>, <iframe>, <object>, <embed>).',
|
|
78
|
+
source
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
// 2. First-line diagram-type keyword. Catches "LLM returned a prose
|
|
83
|
+
// paragraph instead of mermaid" — by far the most common failure mode.
|
|
84
|
+
const firstNonEmpty = trimmed.split(/\r?\n/).find((line) => line.trim().length > 0) ?? '';
|
|
85
|
+
const matchedKeyword = DIAGRAM_KEYWORDS.find((k) => new RegExp(`^${k.replace('-', '\\-')}\\b`, 'i').test(firstNonEmpty));
|
|
86
|
+
if (!matchedKeyword) {
|
|
87
|
+
return {
|
|
88
|
+
ok: false,
|
|
89
|
+
error: {
|
|
90
|
+
message: `Mermaid source must start with a diagram-type keyword (flowchart, sequenceDiagram, stateDiagram, classDiagram, erDiagram, gantt, etc.). Got: ${firstNonEmpty.slice(0, 60)}`,
|
|
91
|
+
source
|
|
92
|
+
}
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
// 3. Bracket balance — catches truncated output. Counts `[`, `]`, `{`, `}`,
|
|
96
|
+
// `(`, `)` and requires each opener to match its closer. Strings and code
|
|
97
|
+
// blocks INSIDE Mermaid source are rare enough that we don't try to skip
|
|
98
|
+
// their contents — if a legitimate diagram fails this check it's because
|
|
99
|
+
// it contains a literal unbalanced bracket, which is itself a Mermaid
|
|
100
|
+
// syntax error.
|
|
101
|
+
const balance = countBracketBalance(trimmed);
|
|
102
|
+
if (balance.square !== 0 || balance.curly !== 0 || balance.paren !== 0) {
|
|
103
|
+
return {
|
|
104
|
+
ok: false,
|
|
105
|
+
error: {
|
|
106
|
+
message: `Mermaid source has unbalanced brackets (square: ${balance.square}, curly: ${balance.curly}, paren: ${balance.paren}). Likely truncated.`,
|
|
107
|
+
source
|
|
108
|
+
}
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
// 4. Connection check — must have at least one arrow operator UNLESS the
|
|
112
|
+
// diagram type is one that legitimately has no connections (gantt, pie,
|
|
113
|
+
// journey, timeline, quadrantChart, xychart, sankey, mindmap, gitGraph).
|
|
114
|
+
// For flowchart / sequence / state / class / er, no connections almost
|
|
115
|
+
// certainly means truncated output.
|
|
116
|
+
const NEEDS_CONNECTIONS = ['flowchart', 'graph', 'sequenceDiagram', 'stateDiagram-v2', 'stateDiagram', 'classDiagram', 'erDiagram'];
|
|
117
|
+
if (NEEDS_CONNECTIONS.includes(matchedKeyword) && !CONNECTION_OPERATORS.test(trimmed)) {
|
|
118
|
+
return {
|
|
119
|
+
ok: false,
|
|
120
|
+
error: {
|
|
121
|
+
message: `Mermaid ${matchedKeyword} has no arrow connections. Likely truncated output.`,
|
|
122
|
+
source
|
|
123
|
+
}
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
return { ok: true, diagramType: keywordToDiagramType(matchedKeyword) };
|
|
127
|
+
}
|
|
128
|
+
function countBracketBalance(source) {
|
|
129
|
+
let square = 0;
|
|
130
|
+
let curly = 0;
|
|
131
|
+
let paren = 0;
|
|
132
|
+
for (const ch of source) {
|
|
133
|
+
if (ch === '[')
|
|
134
|
+
square++;
|
|
135
|
+
else if (ch === ']')
|
|
136
|
+
square--;
|
|
137
|
+
else if (ch === '{')
|
|
138
|
+
curly++;
|
|
139
|
+
else if (ch === '}')
|
|
140
|
+
curly--;
|
|
141
|
+
else if (ch === '(')
|
|
142
|
+
paren++;
|
|
143
|
+
else if (ch === ')')
|
|
144
|
+
paren--;
|
|
145
|
+
}
|
|
146
|
+
return { square, curly, paren };
|
|
147
|
+
}
|
|
148
|
+
function keywordToDiagramType(keyword) {
|
|
149
|
+
// Normalize the keyword to a stable diagram-type label for downstream
|
|
150
|
+
// consumers (matches the names mermaid.parse() returns where possible).
|
|
151
|
+
if (keyword === 'flowchart' || keyword === 'graph')
|
|
152
|
+
return 'flowchart-v2';
|
|
153
|
+
if (keyword === 'stateDiagram' || keyword === 'stateDiagram-v2')
|
|
154
|
+
return 'stateDiagram-v2';
|
|
155
|
+
return keyword;
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* Pure string-transformation core: given the existing page content and the
|
|
159
|
+
* insertion request, returns the new content + chart ID + noop flag. No
|
|
160
|
+
* file IO, no logging, no benchmark events. Used by both the file-system
|
|
161
|
+
* wrapper below AND directly by tests (skips the fixture-cwd dance).
|
|
162
|
+
*/
|
|
163
|
+
export async function computeChartInsertion(existingContent, input) {
|
|
164
|
+
const validation = await validateMermaidSource(input.mermaidSource);
|
|
165
|
+
if (!validation.ok) {
|
|
166
|
+
throw new ChartValidationError(validation.error.message, validation.error.source);
|
|
167
|
+
}
|
|
168
|
+
const chartKind = input.chartKind ?? inferChartKindFromDiagramType(validation.diagramType);
|
|
169
|
+
const chartId = computeChartId(chartKind, input.mermaidSource);
|
|
170
|
+
// Idempotency: if the same chartId is already in the page, no-op.
|
|
171
|
+
const existingIdMatch = new RegExp(`<!--\\s*chart:${chartId}\\s*-->`).test(existingContent);
|
|
172
|
+
if (existingIdMatch) {
|
|
173
|
+
return { chartId, chartKind, content: existingContent, insertedAt: existingContent.indexOf(`<!-- chart:${chartId}`), noop: true };
|
|
174
|
+
}
|
|
175
|
+
const block = renderChartBlock(chartId, input.mermaidSource, input.caption);
|
|
176
|
+
const { content, insertedAt } = applyAnchor(existingContent, block, input.anchor);
|
|
177
|
+
return { chartId, chartKind, content, insertedAt, noop: false };
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* Pure replacement core. Same shape as computeChartInsertion.
|
|
181
|
+
*/
|
|
182
|
+
export async function computeChartReplacement(existingContent, input) {
|
|
183
|
+
const validation = await validateMermaidSource(input.newSource);
|
|
184
|
+
if (!validation.ok) {
|
|
185
|
+
throw new ChartValidationError(validation.error.message, validation.error.source);
|
|
186
|
+
}
|
|
187
|
+
const found = findChartBlockById(existingContent, input.chartId);
|
|
188
|
+
if (!found) {
|
|
189
|
+
throw new ChartNotFoundError(`No chart with id "${input.chartId}" in page.`);
|
|
190
|
+
}
|
|
191
|
+
const originalKind = parseKindFromChartId(input.chartId);
|
|
192
|
+
const newChartId = computeChartId(originalKind, input.newSource);
|
|
193
|
+
if (newChartId === input.chartId) {
|
|
194
|
+
return { chartId: input.chartId, content: existingContent, insertedAt: found.start, noop: true };
|
|
195
|
+
}
|
|
196
|
+
const block = renderChartBlock(newChartId, input.newSource, input.caption);
|
|
197
|
+
const content = existingContent.slice(0, found.start) + block + existingContent.slice(found.end);
|
|
198
|
+
return { chartId: newChartId, content, insertedAt: found.start, noop: false };
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* File-system wrapper around computeChartInsertion. Reads the page, computes
|
|
202
|
+
* the new content, writes it back (unless dryRun), and appends a project-log
|
|
203
|
+
* entry. The MCP tool (M3) and the editor wizard (M5) both call this.
|
|
204
|
+
*/
|
|
205
|
+
export async function insertChartIntoPage(input) {
|
|
206
|
+
const existing = await readWikiPage(input.slug);
|
|
207
|
+
const result = await computeChartInsertion(existing, input);
|
|
208
|
+
if (!result.noop && !input.dryRun) {
|
|
209
|
+
await writeWikiPage(input.slug, result.content);
|
|
210
|
+
const author = input.authorTag === 'operator' ? 'in-browser editor' : 'agent';
|
|
211
|
+
await appendProjectLog(`Inserted ${result.chartKind} chart \`${result.chartId}\` into \`${input.slug}\` via ${author} (chart-insert).`);
|
|
212
|
+
}
|
|
213
|
+
return { ok: true, chartId: result.chartId, noop: result.noop, content: result.content, insertedAt: result.insertedAt };
|
|
214
|
+
}
|
|
215
|
+
export async function replaceChartInPage(input) {
|
|
216
|
+
const existing = await readWikiPage(input.slug);
|
|
217
|
+
const result = await computeChartReplacement(existing, input);
|
|
218
|
+
if (!result.noop && !input.dryRun) {
|
|
219
|
+
await writeWikiPage(input.slug, result.content);
|
|
220
|
+
const author = input.authorTag === 'operator' ? 'in-browser editor' : 'agent';
|
|
221
|
+
await appendProjectLog(`Replaced chart \`${input.chartId}\` → \`${result.chartId}\` in \`${input.slug}\` via ${author} (chart-insert).`);
|
|
222
|
+
}
|
|
223
|
+
return { ok: true, chartId: result.chartId, noop: result.noop, content: result.content, insertedAt: result.insertedAt };
|
|
224
|
+
}
|
|
225
|
+
// -------------------------- helpers --------------------------------------
|
|
226
|
+
export function computeChartId(kind, source) {
|
|
227
|
+
const hash = createHash('sha256').update(source.trim()).digest('hex').slice(0, 7);
|
|
228
|
+
return `auto-${kind}-${hash}`;
|
|
229
|
+
}
|
|
230
|
+
function parseKindFromChartId(chartId) {
|
|
231
|
+
// chartId is `auto-<kind>-<hash>` — pull the kind from the middle segment.
|
|
232
|
+
const match = chartId.match(/^auto-([a-z]+)-[0-9a-f]+$/i);
|
|
233
|
+
const candidate = match?.[1];
|
|
234
|
+
const valid = ['flowchart', 'sequence', 'state', 'class', 'er', 'gantt', 'diagram'];
|
|
235
|
+
return candidate && valid.includes(candidate) ? candidate : 'diagram';
|
|
236
|
+
}
|
|
237
|
+
function inferChartKindFromDiagramType(diagramType) {
|
|
238
|
+
// Mermaid's parse() returns names like 'flowchart-v2', 'sequence', 'stateDiagram', etc.
|
|
239
|
+
if (/^flowchart/i.test(diagramType) || /^graph/i.test(diagramType))
|
|
240
|
+
return 'flowchart';
|
|
241
|
+
if (/^sequence/i.test(diagramType))
|
|
242
|
+
return 'sequence';
|
|
243
|
+
if (/^state/i.test(diagramType))
|
|
244
|
+
return 'state';
|
|
245
|
+
if (/^class/i.test(diagramType))
|
|
246
|
+
return 'class';
|
|
247
|
+
if (/^er/i.test(diagramType))
|
|
248
|
+
return 'er';
|
|
249
|
+
if (/^gantt/i.test(diagramType))
|
|
250
|
+
return 'gantt';
|
|
251
|
+
return 'diagram';
|
|
252
|
+
}
|
|
253
|
+
function renderChartBlock(chartId, source, caption) {
|
|
254
|
+
const trimmed = source.trim();
|
|
255
|
+
const captionLine = caption?.trim() ? `\n*Figure: ${caption.trim()}*\n` : '';
|
|
256
|
+
return `<!-- chart:${chartId} -->\n\`\`\`mermaid\n${trimmed}\n\`\`\`\n${captionLine}`;
|
|
257
|
+
}
|
|
258
|
+
function findChartBlockById(content, chartId) {
|
|
259
|
+
CHART_BLOCK_RE.lastIndex = 0;
|
|
260
|
+
let match;
|
|
261
|
+
while ((match = CHART_BLOCK_RE.exec(content)) !== null) {
|
|
262
|
+
if (match[1] === chartId) {
|
|
263
|
+
return {
|
|
264
|
+
chartId: match[1],
|
|
265
|
+
start: match.index,
|
|
266
|
+
end: match.index + match[0].length,
|
|
267
|
+
source: match[2],
|
|
268
|
+
caption: match[3]
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
return null;
|
|
273
|
+
}
|
|
274
|
+
function applyAnchor(content, block, anchor) {
|
|
275
|
+
const lines = content.split(/\r?\n/);
|
|
276
|
+
const eol = content.includes('\r\n') ? '\r\n' : '\n';
|
|
277
|
+
switch (anchor.kind) {
|
|
278
|
+
case 'after-heading': {
|
|
279
|
+
const idx = findHeadingLine(lines, anchor.heading);
|
|
280
|
+
if (idx === -1) {
|
|
281
|
+
throw new AnchorNotFoundError(`Heading "${anchor.heading}" not found in page.`);
|
|
282
|
+
}
|
|
283
|
+
const sectionEnd = findSectionEnd(lines, idx);
|
|
284
|
+
const insertLineIdx = sectionEnd;
|
|
285
|
+
return spliceAtLine(lines, insertLineIdx, block, eol);
|
|
286
|
+
}
|
|
287
|
+
case 'before-heading': {
|
|
288
|
+
const idx = findHeadingLine(lines, anchor.heading);
|
|
289
|
+
if (idx === -1) {
|
|
290
|
+
throw new AnchorNotFoundError(`Heading "${anchor.heading}" not found in page.`);
|
|
291
|
+
}
|
|
292
|
+
return spliceAtLine(lines, idx, block, eol);
|
|
293
|
+
}
|
|
294
|
+
case 'end-of-page': {
|
|
295
|
+
return spliceAtLine(lines, lines.length, block, eol);
|
|
296
|
+
}
|
|
297
|
+
case 'after-line': {
|
|
298
|
+
if (anchor.line < 1 || anchor.line > lines.length) {
|
|
299
|
+
throw new AnchorNotFoundError(`Line ${anchor.line} is out of range (page has ${lines.length} lines).`);
|
|
300
|
+
}
|
|
301
|
+
return spliceAtLine(lines, anchor.line, block, eol);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
function findHeadingLine(lines, heading) {
|
|
306
|
+
const target = heading.trim().toLowerCase();
|
|
307
|
+
// Match `^#{1,6}\s+<heading>\s*$` case-insensitively. Anchored to the whole
|
|
308
|
+
// line so we don't false-match a heading mention inside a code block — that
|
|
309
|
+
// would be a bug worth catching, but for now we accept it as a known
|
|
310
|
+
// limitation and call it out in the roadmap's open questions.
|
|
311
|
+
for (let i = 0; i < lines.length; i++) {
|
|
312
|
+
const match = lines[i].match(/^(#{1,6})\s+(.+?)\s*$/);
|
|
313
|
+
if (match && match[2].trim().toLowerCase() === target) {
|
|
314
|
+
return i;
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
return -1;
|
|
318
|
+
}
|
|
319
|
+
function headingLevel(line) {
|
|
320
|
+
const match = line.match(/^(#{1,6})\s/);
|
|
321
|
+
return match ? match[1].length : 0;
|
|
322
|
+
}
|
|
323
|
+
function findSectionEnd(lines, headingIdx) {
|
|
324
|
+
// A section ends just before the next heading at the SAME OR HIGHER level
|
|
325
|
+
// (lower number = higher level). H2's section ends before the next H1 or H2.
|
|
326
|
+
// Returns the line index where to insert (== first line AFTER the section).
|
|
327
|
+
const startLevel = headingLevel(lines[headingIdx]);
|
|
328
|
+
if (startLevel === 0)
|
|
329
|
+
return lines.length;
|
|
330
|
+
for (let i = headingIdx + 1; i < lines.length; i++) {
|
|
331
|
+
const lvl = headingLevel(lines[i]);
|
|
332
|
+
if (lvl > 0 && lvl <= startLevel) {
|
|
333
|
+
return i;
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
return lines.length;
|
|
337
|
+
}
|
|
338
|
+
function spliceAtLine(lines, lineIdx, block, eol) {
|
|
339
|
+
const before = lines.slice(0, lineIdx).join(eol);
|
|
340
|
+
const after = lines.slice(lineIdx).join(eol);
|
|
341
|
+
// Always sandwich the block with blank lines so it doesn't fuse into
|
|
342
|
+
// surrounding content (especially next to a heading).
|
|
343
|
+
const parts = [];
|
|
344
|
+
if (before.length > 0)
|
|
345
|
+
parts.push(before);
|
|
346
|
+
parts.push(''); // blank line separator
|
|
347
|
+
parts.push(block.trimEnd());
|
|
348
|
+
parts.push(''); // blank line separator
|
|
349
|
+
if (after.length > 0)
|
|
350
|
+
parts.push(after);
|
|
351
|
+
const content = parts.join(eol);
|
|
352
|
+
// The block starts just after the leading separator(s). Compute by finding
|
|
353
|
+
// the marker comment in the new content.
|
|
354
|
+
const insertedAt = content.indexOf(`<!-- chart:`);
|
|
355
|
+
return { content, insertedAt };
|
|
356
|
+
}
|
|
357
|
+
// -------------------------- typed errors ---------------------------------
|
|
358
|
+
export class ChartValidationError extends Error {
|
|
359
|
+
source;
|
|
360
|
+
constructor(message, source) {
|
|
361
|
+
super(message);
|
|
362
|
+
this.name = 'ChartValidationError';
|
|
363
|
+
this.source = source;
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
export class AnchorNotFoundError extends Error {
|
|
367
|
+
constructor(message) {
|
|
368
|
+
super(message);
|
|
369
|
+
this.name = 'AnchorNotFoundError';
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
export class ChartNotFoundError extends Error {
|
|
373
|
+
constructor(message) {
|
|
374
|
+
super(message);
|
|
375
|
+
this.name = 'ChartNotFoundError';
|
|
376
|
+
}
|
|
377
|
+
}
|