@alfacapital-tech/c4builder 0.4.0-rc.4 → 0.5.0-rc.1
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 +69 -615
- package/dist/cli/commands/check.js +43 -26
- package/dist/cli/commands/help.js +5 -2
- package/dist/cli/commands/jre.js +6 -6
- package/dist/cli/commands/list.js +4 -1
- package/dist/cli/commands/new.js +6 -6
- package/dist/cli/commands/site.js +3 -2
- package/dist/cli/dispatch.js +38 -18
- package/dist/cli/wizard/collect.js +16 -10
- package/dist/config/defaults.js +1 -0
- package/dist/config/options.js +1 -1
- package/dist/config/schema.js +5 -2
- package/dist/core/build.js +15 -6
- package/dist/core/compose/docsify.template.js +0 -1
- package/dist/core/compose/markdown.js +85 -19
- package/dist/core/plugins/hooks.js +1 -1
- package/dist/core/plugins/load.js +6 -6
- package/dist/core/plugins/source.js +6 -6
- package/dist/core/plugins/tree.js +5 -5
- package/dist/core/render/bpmnrenderer.js +522 -0
- package/dist/core/render/d2renderer.js +19 -19
- package/dist/core/render/diagrams.js +64 -45
- package/dist/core/render/jre.js +14 -14
- package/dist/core/render/pngraster.js +4 -4
- package/dist/{plugins/openspec → core/scan}/fences.js +11 -10
- package/dist/core/scan/tree.js +37 -14
- package/dist/plugins/openapi/index.js +17 -7
- package/dist/plugins/openspec/index.js +1 -1
- package/dist/plugins/openspec/render.js +5 -2
- package/dist/util/archive.js +1 -1
- package/dist/util/http.js +3 -3
- package/dist/util/lock.js +3 -3
- package/dist/util/paths.js +1 -1
- package/package.json +9 -4
- package/template/AGENTS.md +52 -0
- package/template/readme.md +3 -3
- package/template/src/5 BPMN Example/account-opening.bpmn +69 -0
- package/template/src/5 BPMN Example/account-opening.md +7 -0
- package/dist/util/glob.js +0 -69
|
@@ -7,9 +7,11 @@ import { encodeURIPath, readFile, writeFile, plantUmlServerUrl } from "../../uti
|
|
|
7
7
|
import { VENDOR_DIR } from "../../util/paths.js";
|
|
8
8
|
// Фаза scan: тип дерева и его элементов + производные (имя папки, формат выхода диаграммы)
|
|
9
9
|
// и единый реестр расширений диаграмм (источник истины для regex ссылок ниже).
|
|
10
|
-
import { getFolderName, diagramOutputFormat, DIAGRAM_ENGINES, engineSupportsRemote } from "../scan/tree.js";
|
|
10
|
+
import { getFolderName, diagramOutputFormat, DIAGRAM_ENGINES, engineSupportsRemote, collectIncludes } from "../scan/tree.js";
|
|
11
11
|
// Фаза render: mime-тип формата и загрузка удалённо отрендеренной картинки (embed-ветка).
|
|
12
12
|
import { getMime, httpGet } from "../render/diagrams.js";
|
|
13
|
+
// Граф D2-импортов — тот же, что у рендера (приложение llms-full.txt).
|
|
14
|
+
import { d2LocalImports } from "../render/d2renderer.js";
|
|
13
15
|
// docsifyTemplate можно переопределить пользовательским шаблоном (см. generateWebMD),
|
|
14
16
|
// поэтому это mutable-локаль, а не const-импорт; user-шаблон грузим синхронно через createRequire.
|
|
15
17
|
const require = createRequire(import.meta.url);
|
|
@@ -164,6 +166,12 @@ const mdAnchor = (name) => `#${encodeURIPath(name).replace(/%20/g, '-')}`;
|
|
|
164
166
|
// (rootRel — путь цели от ROOT_FOLDER). Один паттерн для TOC, навигации вверх и
|
|
165
167
|
// меню подпапок — прежде дублировался трижды.
|
|
166
168
|
const relativeMdLink = (fromLevel, rootRel, mdFileName) => encodeURIPath(path.join('./', fromLevel - 1 > 0 ? '../'.repeat(fromLevel - 1) : '', rootRel, `${mdFileName}.md`));
|
|
169
|
+
// Состав и ссылки sidebar сайта — общие с llms.txt, чтобы оглавление агента не
|
|
170
|
+
// разъехалось с навигацией. Чистоту string[] гарантирует zod-схема.
|
|
171
|
+
const inSidebar = (item, options) => !options.EXCLUDE_SIDEBAR_FOLDER_BY_PATH?.some((pathToExclude) => item.dir.startsWith(pathToExclude));
|
|
172
|
+
// Путь веб-страницы от корня сайта, без .md. path.relative, а не отрезание первого
|
|
173
|
+
// сегмента: то ломалось на многосегментном rootFolder из рукописного конфига.
|
|
174
|
+
const sitePagePath = (item, options) => path.join(path.relative(options.ROOT_FOLDER, item.dir), options.WEB_FILE_NAME || item.name);
|
|
167
175
|
// Тело страницы (титул + контент с диаграммами) — общий скелет generateMD и
|
|
168
176
|
// generateWebMD. chrome (breadcrumbs/TOC/навигация) встаёт под заголовок: при
|
|
169
177
|
// авто-титуле — сразу за ним, при пользовательском h1 — пост-вставкой после него.
|
|
@@ -176,8 +184,9 @@ const compilePage = async (item, options, chrome = '') => {
|
|
|
176
184
|
MD = injectAfterFirstH1(MD, chrome);
|
|
177
185
|
return MD.trimStart();
|
|
178
186
|
};
|
|
179
|
-
|
|
180
|
-
|
|
187
|
+
// Единый документ со всеми страницами в порядке дерева. Стратегия диаграмм — у вызывающего:
|
|
188
|
+
// complete.md вставляет картинки, llms-full.txt — исходники; состав и порядок общие.
|
|
189
|
+
const composeComplete = async (tree, options, getDiagram) => {
|
|
181
190
|
//title
|
|
182
191
|
let MD = `# ${options.PROJECT_NAME}`;
|
|
183
192
|
//table of contents
|
|
@@ -195,13 +204,67 @@ export const generateCompleteMD = async (tree, options) => {
|
|
|
195
204
|
MD += `\n\n[${options.HOMEPAGE_NAME}](${mdAnchor(options.PROJECT_NAME)})`;
|
|
196
205
|
}
|
|
197
206
|
//concatenate markdown files
|
|
198
|
-
MD = await compileDocument(MD, item, options,
|
|
199
|
-
// complete: ссылки на диаграммы префиксуются папкой элемента.
|
|
200
|
-
buildDiagramMarkdown(item, diagram, options, 'complete'));
|
|
207
|
+
MD = await compileDocument(MD, item, options, getDiagram);
|
|
201
208
|
}
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
209
|
+
return MD;
|
|
210
|
+
};
|
|
211
|
+
export const generateCompleteMD = async (tree, options) => writeFile(path.join(options.DIST_FOLDER, `${options.PROJECT_NAME}.md`),
|
|
212
|
+
// complete: ссылки на диаграммы префиксуются папкой элемента.
|
|
213
|
+
await composeComplete(tree, options, (i, d, o) => buildDiagramMarkdown(i, d, o, 'complete')));
|
|
214
|
+
// llms.txt (llmstxt.org): плоский список — парсеры читают строки `- [..](..)` от начала
|
|
215
|
+
// строки и теряют вложенные; иерархию несёт заголовок «A / B / C».
|
|
216
|
+
export const generateLlmsTxt = (tree, options) => {
|
|
217
|
+
const pages = tree
|
|
218
|
+
.filter((item) => inSidebar(item, options))
|
|
219
|
+
.map((item) => {
|
|
220
|
+
const rel = path.relative(options.ROOT_FOLDER, item.dir);
|
|
221
|
+
const title = rel ? rel.split(path.sep).join(' / ') : options.HOMEPAGE_NAME;
|
|
222
|
+
return `- [${title}](${encodeURIPath(`${sitePagePath(item, options)}.md`)})\n`;
|
|
223
|
+
});
|
|
224
|
+
return (`# ${options.PROJECT_NAME}\n\n` +
|
|
225
|
+
'> Architecture documentation built with c4builder. Pages are markdown; full text with ' +
|
|
226
|
+
'diagram sources (PlantUML/D2/BPMN): [llms-full.txt](llms-full.txt).\n\n' +
|
|
227
|
+
`## Pages\n\n${pages.join('')}`);
|
|
228
|
+
};
|
|
229
|
+
// Fence длиннее самой длинной серии обратных кавычек текста — иначе блок закрылся бы изнутри.
|
|
230
|
+
const fenced = (text, lang = '') => {
|
|
231
|
+
const fence = '`'.repeat(Math.max(3, ...(text.match(/`+/g) ?? []).map((run) => run.length + 1)));
|
|
232
|
+
return `${fence}${lang}\n${text.endsWith('\n') ? text : `${text}\n`}${fence}`;
|
|
233
|
+
};
|
|
234
|
+
const FENCE_LANG = {
|
|
235
|
+
'.puml': 'plantuml',
|
|
236
|
+
'.iuml': 'plantuml',
|
|
237
|
+
'.d2': 'd2',
|
|
238
|
+
'.bpmn': 'xml'
|
|
239
|
+
};
|
|
240
|
+
// llms-full.txt: состав complete-документа, но диаграмма — исходником (C4-PlantUML/D2-текст
|
|
241
|
+
// агенту полезнее SVG). Локальные include/импорты — один раз в приложении, а не инлайном:
|
|
242
|
+
// общий styles.iuml иначе повторился бы в каждой диаграмме. Резолв — тот же, что у рендера.
|
|
243
|
+
export const generateLlmsFull = async (tree, options) => {
|
|
244
|
+
const MD = await composeComplete(tree, options, async (_item, diagram) => fenced(`${diagram.content}`, FENCE_LANG[diagram.ext]));
|
|
245
|
+
const deps = new Map();
|
|
246
|
+
for (const item of tree) {
|
|
247
|
+
for (const diagram of item.diagrams) {
|
|
248
|
+
const body = `${diagram.content}`;
|
|
249
|
+
if (diagram.engine === 'd2') {
|
|
250
|
+
for (const [abs, content] of d2LocalImports(path.join(item.dir, diagram.dir), body))
|
|
251
|
+
deps.set(abs, content);
|
|
252
|
+
}
|
|
253
|
+
else if (diagram.engine === 'plantuml') {
|
|
254
|
+
for (const inc of collectIncludes(body, item.dir, item.dir, new Set()))
|
|
255
|
+
deps.set(inc.abs, inc.content);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
if (!deps.size)
|
|
260
|
+
return `${MD}\n`;
|
|
261
|
+
const files = [...deps]
|
|
262
|
+
.map(([abs, content]) => [path.relative(process.cwd(), abs).split(path.sep).join('/'), content])
|
|
263
|
+
.sort(([a], [b]) => (a < b ? -1 : 1));
|
|
264
|
+
const appendix = files
|
|
265
|
+
.map(([p, content]) => `\n\n### ${p}\n\n${fenced(content, FENCE_LANG[path.extname(p).toLowerCase()])}`)
|
|
266
|
+
.join('');
|
|
267
|
+
return `${MD}\n\n## Included files${appendix}\n`;
|
|
205
268
|
};
|
|
206
269
|
export const generateMD = async (tree, options, onProgress) => {
|
|
207
270
|
let processedCount = 0;
|
|
@@ -253,13 +316,9 @@ export const generateWebMD = async (tree, options) => {
|
|
|
253
316
|
const filePromises = [];
|
|
254
317
|
let docsifySideBar = '';
|
|
255
318
|
const getWebFileName = (originalFileName) => options.WEB_FILE_NAME || originalFileName;
|
|
256
|
-
// Чистоту string[] гарантирует zod-схема (не-строки отброшены препроцессом).
|
|
257
|
-
const isExcluded = (dir) => !!options.EXCLUDE_SIDEBAR_FOLDER_BY_PATH?.some((pathToExclude) => dir.startsWith(pathToExclude));
|
|
258
319
|
for (const item of tree) {
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
if (!isExcluded(item.dir)) {
|
|
262
|
-
docsifySideBar += `${' '.repeat(item.level - 1)}* [${item.name}](${encodeURIPath(path.join(path.relative(options.ROOT_FOLDER, item.dir), getWebFileName(item.name)))})\n`;
|
|
320
|
+
if (inSidebar(item, options)) {
|
|
321
|
+
docsifySideBar += `${' '.repeat(item.level - 1)}* [${item.name}](${encodeURIPath(sitePagePath(item, options))})\n`;
|
|
263
322
|
}
|
|
264
323
|
const MD = await compilePage(item, options);
|
|
265
324
|
//write to disk
|
|
@@ -271,8 +330,8 @@ export const generateWebMD = async (tree, options) => {
|
|
|
271
330
|
const loaded = require(path.join(process.cwd(), options.DOCSIFY_TEMPLATE));
|
|
272
331
|
const fn = typeof loaded === 'function' ? loaded : loaded?.default;
|
|
273
332
|
if (typeof fn !== 'function') {
|
|
274
|
-
throw new Error(`docsifyTemplate
|
|
275
|
-
`(module.exports = fn
|
|
333
|
+
throw new Error(`docsifyTemplate "${options.DOCSIFY_TEMPLATE}" must export a function ` +
|
|
334
|
+
`(module.exports = fn or export default fn), got: ${typeof fn}`);
|
|
276
335
|
}
|
|
277
336
|
docsifyTemplate = fn;
|
|
278
337
|
}
|
|
@@ -281,7 +340,7 @@ export const generateWebMD = async (tree, options) => {
|
|
|
281
340
|
const rootName = () => {
|
|
282
341
|
const root = tree.find((item) => !item.parent);
|
|
283
342
|
if (!root)
|
|
284
|
-
throw new Error('docsify:
|
|
343
|
+
throw new Error('docsify: tree root not found');
|
|
285
344
|
return root.name;
|
|
286
345
|
};
|
|
287
346
|
//docsify homepage
|
|
@@ -296,13 +355,20 @@ export const generateWebMD = async (tree, options) => {
|
|
|
296
355
|
},
|
|
297
356
|
stylesheet: options.WEB_THEME,
|
|
298
357
|
alias: { '/.*/_sidebar.md': '/_sidebar.md' },
|
|
358
|
+
nativeEmoji: true,
|
|
299
359
|
supportSearch: options.SUPPORT_SEARCH,
|
|
300
360
|
executeScript: options.EXECUTE_SCRIPT
|
|
301
361
|
})));
|
|
302
362
|
//copy local docsify vendor files to dist
|
|
303
363
|
const docsifyVendorSrc = path.join(VENDOR_DIR, 'docsify');
|
|
304
364
|
if (fs.existsSync(docsifyVendorSrc)) {
|
|
305
|
-
|
|
365
|
+
const vendorDist = path.join(options.DIST_FOLDER, 'vendor');
|
|
366
|
+
// Тема — без ведущего @import Google Fonts (у её шрифтов есть системный fallback).
|
|
367
|
+
// Правим копию, а не vendor/: повторное вендорение вернуло бы @import.
|
|
368
|
+
const theme = path.join(vendorDist, 'vue.css');
|
|
369
|
+
filePromises.push(fsextra
|
|
370
|
+
.copy(docsifyVendorSrc, vendorDist)
|
|
371
|
+
.then(async () => writeFile(theme, (await readFile(theme)).toString().replace(/^@import url\([^)]*\);/, ''))));
|
|
306
372
|
}
|
|
307
373
|
//github pages preparation
|
|
308
374
|
filePromises.push(writeFile(path.join(options.DIST_FOLDER, `.nojekyll`), ''));
|
|
@@ -5,7 +5,7 @@ const wrap = async (name, hook, fn) => {
|
|
|
5
5
|
await fn();
|
|
6
6
|
}
|
|
7
7
|
catch (e) {
|
|
8
|
-
throw new Error(
|
|
8
|
+
throw new Error(`Plugin ${name} (${hook}): ${e.message ?? e}`, { cause: e });
|
|
9
9
|
}
|
|
10
10
|
};
|
|
11
11
|
export const runAfterScan = async (tree, options, plugins) => {
|
|
@@ -70,14 +70,14 @@ const importPlugin = async (id, cwd) => {
|
|
|
70
70
|
if (e.code === 'ERR_PACKAGE_PATH_NOT_EXPORTED')
|
|
71
71
|
resolved = resolveEsmMain(id, require.resolve.paths(id) ?? []);
|
|
72
72
|
if (!resolved)
|
|
73
|
-
throw new Error(
|
|
74
|
-
|
|
73
|
+
throw new Error(`not found among built-ins (${Object.keys(BUILTIN_PLUGINS).join(', ')}) ` +
|
|
74
|
+
`nor as an npm package from ${cwd}: ${e.message}`);
|
|
75
75
|
}
|
|
76
76
|
mod = await import(__rewriteRelativeImportExtension(pathToFileURL(resolved).href));
|
|
77
77
|
}
|
|
78
78
|
const plugin = mod.default ?? mod;
|
|
79
79
|
if (!plugin || typeof plugin !== 'object' || typeof plugin.name !== 'string')
|
|
80
|
-
throw new Error('
|
|
80
|
+
throw new Error('module must default-export a plugin object with a name field');
|
|
81
81
|
return plugin;
|
|
82
82
|
};
|
|
83
83
|
/**
|
|
@@ -101,14 +101,14 @@ export const loadPlugins = async (entries, cwd, options) => {
|
|
|
101
101
|
if (plugin.options) {
|
|
102
102
|
const parsed = plugin.options.safeParse(opts);
|
|
103
103
|
if (!parsed.success) {
|
|
104
|
-
const issues = parsed.error.issues.map((iss) => `${iss.path.length ? iss.path.join('.') : '(
|
|
104
|
+
const issues = parsed.error.issues.map((iss) => `${iss.path.length ? iss.path.join('.') : '(options)'} — ${iss.message}`);
|
|
105
105
|
throw new Error(`plugins[${i}] ${plugin.name}: ${issues.join('; ')}`);
|
|
106
106
|
}
|
|
107
107
|
opts = parsed.data;
|
|
108
108
|
}
|
|
109
109
|
if (plugin.requires?.executeScript && !options.EXECUTE_SCRIPT) {
|
|
110
110
|
options.EXECUTE_SCRIPT = true;
|
|
111
|
-
console.log(chalk.gray(
|
|
111
|
+
console.log(chalk.gray(`plugin ${plugin.name} requires executeScript — enabled for this build`));
|
|
112
112
|
}
|
|
113
113
|
loaded.push({ plugin, opts });
|
|
114
114
|
}
|
|
@@ -120,6 +120,6 @@ export const pluginWatchPaths = (plugins, cwd, exists) => plugins.flatMap(({ plu
|
|
|
120
120
|
.filter((p) => {
|
|
121
121
|
if (exists(p))
|
|
122
122
|
return true;
|
|
123
|
-
console.log(chalk.yellow(
|
|
123
|
+
console.log(chalk.yellow(`plugin ${plugin.name}: watch path does not exist — ${p}`));
|
|
124
124
|
return false;
|
|
125
125
|
}));
|
|
@@ -25,7 +25,7 @@ export const redactUrl = (url) => {
|
|
|
25
25
|
// За корпоративным TLS-перехватом Node не доверяет подменённой цепочке: свой набор
|
|
26
26
|
// корней он берёт не из системы и не из SSL_CERT_FILE — только NODE_EXTRA_CA_CERTS.
|
|
27
27
|
export const tlsHint = (reason) => /certificate|self.signed|unable to (?:verify|get local issuer)/i.test(reason)
|
|
28
|
-
? ' — TLS
|
|
28
|
+
? ' — TLS interception? set the CA bundle path in NODE_EXTRA_CA_CERTS'
|
|
29
29
|
: '';
|
|
30
30
|
const downloadArchive = async (url, headers) => {
|
|
31
31
|
const dir = path.join(os.tmpdir(), `c4builder-src-${crypto.createHash('sha1').update(url).digest('hex')}`);
|
|
@@ -39,7 +39,7 @@ const downloadArchive = async (url, headers) => {
|
|
|
39
39
|
}
|
|
40
40
|
catch (e) {
|
|
41
41
|
const reason = e.message.replaceAll(url, redactUrl(url));
|
|
42
|
-
throw new Error(
|
|
42
|
+
throw new Error(`archive unavailable: ${reason}${tlsHint(reason)}`);
|
|
43
43
|
}
|
|
44
44
|
fs.rmSync(stage, { recursive: true, force: true });
|
|
45
45
|
fs.mkdirSync(stage, { recursive: true });
|
|
@@ -50,7 +50,7 @@ const downloadArchive = async (url, headers) => {
|
|
|
50
50
|
else if (isGzipBuffer(body))
|
|
51
51
|
await extractTarGz(archive, stage);
|
|
52
52
|
else
|
|
53
|
-
throw new Error(
|
|
53
|
+
throw new Error(`unknown archive format ${redactUrl(url)} (expected zip or tar.gz)`);
|
|
54
54
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
55
55
|
fs.renameSync(stage, dir);
|
|
56
56
|
}
|
|
@@ -71,11 +71,11 @@ export const resolveSource = async (spec, cwd = process.cwd()) => {
|
|
|
71
71
|
if (spec.dir !== undefined) {
|
|
72
72
|
const abs = path.resolve(cwd, spec.dir, spec.subdir ?? '');
|
|
73
73
|
if (!fs.existsSync(abs) || !fs.statSync(abs).isDirectory())
|
|
74
|
-
throw new Error(
|
|
74
|
+
throw new Error(`dir source not found: ${abs}`);
|
|
75
75
|
return abs;
|
|
76
76
|
}
|
|
77
77
|
if (spec.archive === undefined)
|
|
78
|
-
throw new Error('
|
|
78
|
+
throw new Error('source must set dir or archive');
|
|
79
79
|
const url = spec.archive;
|
|
80
80
|
const headers = Object.fromEntries(Object.entries(spec.headers ?? {}).filter(([, v]) => v !== ''));
|
|
81
81
|
let rootPromise = archiveCache.get(url);
|
|
@@ -87,6 +87,6 @@ export const resolveSource = async (spec, cwd = process.cwd()) => {
|
|
|
87
87
|
const root = await rootPromise;
|
|
88
88
|
const result = spec.subdir ? path.join(root, spec.subdir) : root;
|
|
89
89
|
if (!fs.existsSync(result) || !fs.statSync(result).isDirectory())
|
|
90
|
-
throw new Error(
|
|
90
|
+
throw new Error(`archive ${redactUrl(url)} has no folder ${spec.subdir ?? '.'} (extracted to ${root})`);
|
|
91
91
|
return result;
|
|
92
92
|
};
|
|
@@ -25,10 +25,10 @@ const subtreeEnd = (tree, parent) => {
|
|
|
25
25
|
export const addPage = (tree, options, page) => {
|
|
26
26
|
const segments = page.path;
|
|
27
27
|
if (!segments.length || segments.some((s) => !s || /[\\/]/.test(s) || s === '.' || s === '..'))
|
|
28
|
-
throw new Error(`addPage:
|
|
28
|
+
throw new Error(`addPage: invalid page path ${JSON.stringify(segments)}`);
|
|
29
29
|
const root = tree.find((x) => !x.parent);
|
|
30
30
|
if (!root)
|
|
31
|
-
throw new Error('addPage:
|
|
31
|
+
throw new Error('addPage: tree has no root');
|
|
32
32
|
let parent = root;
|
|
33
33
|
let item = root;
|
|
34
34
|
for (const [i, seg] of segments.entries()) {
|
|
@@ -38,9 +38,9 @@ export const addPage = (tree, options, page) => {
|
|
|
38
38
|
if (existing) {
|
|
39
39
|
if (last) {
|
|
40
40
|
if (!VIRTUAL.has(existing))
|
|
41
|
-
throw new Error(`addPage:
|
|
41
|
+
throw new Error(`addPage: path ${segments.join('/')} is taken by a real folder ${dir} — rename the plugin section (mount)`);
|
|
42
42
|
if (existing.mdFiles.length || existing.diagrams.length)
|
|
43
|
-
throw new Error(`addPage:
|
|
43
|
+
throw new Error(`addPage: page ${segments.join('/')} already added`);
|
|
44
44
|
}
|
|
45
45
|
item = existing;
|
|
46
46
|
}
|
|
@@ -70,7 +70,7 @@ export const addPage = (tree, options, page) => {
|
|
|
70
70
|
const ext = path.extname(d.file).toLowerCase();
|
|
71
71
|
const engine = engineForExt(ext);
|
|
72
72
|
if (!engine)
|
|
73
|
-
throw new Error(`addPage:
|
|
73
|
+
throw new Error(`addPage: diagram "${d.file}" of page ${segments.join('/')} has an unknown extension`);
|
|
74
74
|
item.diagrams.push({
|
|
75
75
|
dir: d.file,
|
|
76
76
|
ext,
|