@alfacapital-tech/c4builder 0.3.0-rc.1 → 0.3.0-rc.2
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 +6 -18
- package/dist/cli/commands/help.js +0 -2
- package/dist/cli/commands/jre.js +10 -3
- package/dist/cli/commands/list.js +9 -1
- package/dist/cli/commands/new.js +10 -29
- package/dist/cli/dispatch.js +130 -36
- package/dist/cli/wizard/collect.js +51 -91
- package/dist/config/defaults.js +2 -1
- package/dist/config/options.js +29 -1
- package/dist/config/schema.js +52 -32
- package/dist/core/build.js +74 -48
- package/dist/core/compose/docsify.template.js +2 -2
- package/dist/core/compose/markdown.js +146 -135
- package/dist/core/render/d2renderer.js +67 -11
- package/dist/core/render/diagrams.js +232 -85
- package/dist/core/render/fonts.js +8 -0
- package/dist/core/render/jre.js +155 -51
- package/dist/core/render/pngraster.js +14 -17
- package/dist/core/scan/tree.js +64 -16
- package/dist/util/http.js +55 -0
- package/dist/util/lock.js +58 -0
- package/dist/util/utils.js +10 -25
- package/package.json +5 -1
|
@@ -5,8 +5,9 @@ import { createRequire } from 'node:module';
|
|
|
5
5
|
import defaultDocsifyTemplate from "./docsify.template.js";
|
|
6
6
|
import { encodeURIPath, readFile, writeFile, plantUmlServerUrl } from "../../util/utils.js";
|
|
7
7
|
import { VENDOR_DIR } from "../../util/paths.js";
|
|
8
|
-
// Фаза scan: тип дерева и его элементов + производные (имя папки, формат выхода диаграммы)
|
|
9
|
-
|
|
8
|
+
// Фаза scan: тип дерева и его элементов + производные (имя папки, формат выхода диаграммы)
|
|
9
|
+
// и единый реестр расширений диаграмм (источник истины для regex ссылок ниже).
|
|
10
|
+
import { getFolderName, diagramOutputFormat, DIAGRAM_ENGINES, engineSupportsRemote } from "../scan/tree.js";
|
|
10
11
|
// Фаза render: mime-тип формата и загрузка удалённо отрендеренной картинки (embed-ветка).
|
|
11
12
|
import { getMime, httpGet } from "../render/diagrams.js";
|
|
12
13
|
// docsifyTemplate можно переопределить пользовательским шаблоном (см. generateWebMD),
|
|
@@ -34,27 +35,101 @@ const injectAfterFirstH1 = (md, block) => {
|
|
|
34
35
|
return `${block}\n\n${md}`;
|
|
35
36
|
return m[1] + block + md.slice(m[1].length);
|
|
36
37
|
};
|
|
38
|
+
// Расширения диаграмм без точки, экранированные для regex. Источник истины — реестр
|
|
39
|
+
// DIAGRAM_ENGINES из фазы scan (scan/tree.ts): добавление движка там автоматически
|
|
40
|
+
// расширяет regex ссылок здесь, без синхронной правки. Экранируем на случай спецсимволов.
|
|
41
|
+
const diagramExtAlternation = () => DIAGRAM_ENGINES.map((e) => e.ext.slice(1).replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|');
|
|
42
|
+
// Ссылка на диаграмму в markdown: . Путь ленивый и не пересекает ')',
|
|
43
|
+
// иначе две ссылки на одной строке склеились бы в один матч и не заменились. Свежий объект
|
|
44
|
+
// на вызов — чтобы matchAll не тянул чужой lastIndex.
|
|
45
|
+
const diagramRefRegex = () => new RegExp(`!\\[.*?\\]\\(([^)]*?\\.(?:${diagramExtAlternation()}))\\)`, 'g');
|
|
46
|
+
// Кеш base64 отрендеренных диаграмм на время ОДНОЙ сборки: один файл читают все три
|
|
47
|
+
// генератора (per-page md / docsify / complete). clearDiagramCache() зовётся в начале build() —
|
|
48
|
+
// иначе в watch-режиме (build() вызывается повторно) вернулись бы данные до правки файла.
|
|
49
|
+
const diagramBase64Cache = new Map();
|
|
50
|
+
export const clearDiagramCache = () => diagramBase64Cache.clear();
|
|
51
|
+
const readDiagramBase64 = async (absPath) => {
|
|
52
|
+
const cached = diagramBase64Cache.get(absPath);
|
|
53
|
+
if (cached !== undefined)
|
|
54
|
+
return cached;
|
|
55
|
+
const b64 = (await readFile(absPath)).toString('base64');
|
|
56
|
+
diagramBase64Cache.set(absPath, b64);
|
|
57
|
+
return b64;
|
|
58
|
+
};
|
|
59
|
+
// Тот же кеш для embed-ветки онлайн-рендера (ключ — URL, с абсолютными путями не
|
|
60
|
+
// пересекается): три генератора иначе тянули бы одну и ту же картинку с PlantUML-сервера
|
|
61
|
+
// трижды за сборку.
|
|
62
|
+
const readRemoteDiagramBase64 = async (url) => {
|
|
63
|
+
const cached = diagramBase64Cache.get(url);
|
|
64
|
+
if (cached !== undefined)
|
|
65
|
+
return cached;
|
|
66
|
+
const b64 = await httpGet(url);
|
|
67
|
+
diagramBase64Cache.set(url, b64);
|
|
68
|
+
return b64;
|
|
69
|
+
};
|
|
70
|
+
// Единый рендер markdown одной диаграммы для всех трёх генераторов. В complete-документе
|
|
71
|
+
// ссылки «скачать/перейти» префиксуются папкой элемента, а Download отбивается переводом
|
|
72
|
+
// строки (историческая вёрстка). Признак — variant, а не пустота префикса: у диаграмм в
|
|
73
|
+
// корневой папке префикс пуст, и complete всё равно обязан отбить ссылку.
|
|
74
|
+
const buildDiagramMarkdown = async (item, diagram, options, variant) => {
|
|
75
|
+
const isComplete = variant === 'complete';
|
|
76
|
+
const name = path.parse(diagram.dir).name;
|
|
77
|
+
const format = diagramOutputFormat(diagram, options);
|
|
78
|
+
// rawPath — сырой путь (для чтения файла и как основа ссылок), кодируем ровно один раз.
|
|
79
|
+
const rawPath = path.join(path.dirname(diagram.dir), `${name}.${format}`);
|
|
80
|
+
// Онлайн-рендер (движки с remoteRender в DIAGRAM_ENGINES) отдаёт абсолютный URL
|
|
81
|
+
// сервера: его нельзя ни префиксовать папкой, ни повторно кодировать (encodeURI
|
|
82
|
+
// сломал бы схему https:// и '+' в base64).
|
|
83
|
+
const isRemoteUrl = !options.GENERATE_LOCAL_IMAGES && engineSupportsRemote(diagram.engine);
|
|
84
|
+
const diagramUrl = isRemoteUrl
|
|
85
|
+
? plantUmlServerUrl(options.PLANTUML_SERVER_URL, format, diagram.content // рендер-контент диаграммы (Buffer читается как текст)
|
|
86
|
+
)
|
|
87
|
+
: encodeURIPath(rawPath);
|
|
88
|
+
// В complete-документе И картинка, И ссылка «скачать/перейти» префиксуются папкой
|
|
89
|
+
// элемента: complete.md лежит в корне dist, а картинки — во вложенных папках, поэтому
|
|
90
|
+
// относительный путь считается от корня. Склеиваем СЫРЫЕ сегменты и кодируем один раз
|
|
91
|
+
// (иначе % из первого encodeURI превратился бы в %25). Абсолютный URL онлайн-рендера
|
|
92
|
+
// отдаём как есть — без папки и без повторного encodeURI. В page-варианте md лежит
|
|
93
|
+
// рядом с картинкой, поэтому путь остаётся относительным к странице (diagramUrl).
|
|
94
|
+
const localHref = isComplete && !isRemoteUrl
|
|
95
|
+
? encodeURIPath(path.join(item.dir.replace(options.ROOT_FOLDER, ''), rawPath))
|
|
96
|
+
: diagramUrl;
|
|
97
|
+
if (options.EMBED_DIAGRAM) {
|
|
98
|
+
const imgContent = options.GENERATE_LOCAL_IMAGES
|
|
99
|
+
? await readDiagramBase64(path.join(options.DIST_FOLDER, item.dir.replace(options.ROOT_FOLDER, ''), rawPath))
|
|
100
|
+
: await readRemoteDiagramBase64(diagramUrl);
|
|
101
|
+
const diagramImage = `\n};base64,${imgContent})\n`;
|
|
102
|
+
const diagramLink = `${isComplete ? '\n' : ''}[Download ${name} diagram](${localHref} ':ignore')`;
|
|
103
|
+
return diagramImage + diagramLink;
|
|
104
|
+
}
|
|
105
|
+
const diagramImage = ``;
|
|
106
|
+
if (!options.INCLUDE_LINK_TO_DIAGRAM)
|
|
107
|
+
return diagramImage;
|
|
108
|
+
return `[Go to ${name} diagram](${localHref})`;
|
|
109
|
+
};
|
|
37
110
|
const compileDocument = async (md, item, options, getDiagram) => {
|
|
38
111
|
let MD = md;
|
|
39
112
|
const alreadyIncluded = [];
|
|
40
113
|
const texts = [];
|
|
41
114
|
const diagrams = [];
|
|
42
|
-
const regex =
|
|
115
|
+
const regex = diagramRefRegex();
|
|
43
116
|
for (const mdFile of item.mdFiles) {
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
//
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
117
|
+
const content = mdFile.toString();
|
|
118
|
+
// Один проход matchAll + сборка из срезов: каждая ссылка заменяется ровно один раз.
|
|
119
|
+
// (Прежний regex.exec + переприсваивание content пропускал ссылки при укорачивающей замене.)
|
|
120
|
+
let result = '';
|
|
121
|
+
let lastIndex = 0;
|
|
122
|
+
for (const m of content.matchAll(regex)) {
|
|
123
|
+
const ref = m[1];
|
|
124
|
+
const diagram = item.diagrams.find((x) => x.dir === ref);
|
|
125
|
+
if (!diagram)
|
|
126
|
+
continue; // ссылка без соответствующей диаграммы — оставляем сырой markdown
|
|
127
|
+
alreadyIncluded.push(ref);
|
|
128
|
+
result += content.slice(lastIndex, m.index) + (await getDiagram(item, diagram, options));
|
|
129
|
+
lastIndex = m.index + m[0].length;
|
|
56
130
|
}
|
|
57
|
-
|
|
131
|
+
result += content.slice(lastIndex);
|
|
132
|
+
texts.push(result);
|
|
58
133
|
}
|
|
59
134
|
for (const diagram of item.diagrams) {
|
|
60
135
|
if (alreadyIncluded.find((x) => x === diagram.dir)) {
|
|
@@ -64,7 +139,15 @@ const compileDocument = async (md, item, options, getDiagram) => {
|
|
|
64
139
|
}
|
|
65
140
|
let fullDoc = [];
|
|
66
141
|
if (options.DIAGRAMS_ON_TOP) {
|
|
67
|
-
|
|
142
|
+
// Пользовательский h1 обязан оставаться самым первым: при diagramsOnTop диаграммы
|
|
143
|
+
// иначе встают ВЫШЕ заголовка страницы. Отделяем ведущий h1 первого текста и держим
|
|
144
|
+
// его перед диаграммами (chrome вставляется под него caller'ом), остальное тело —
|
|
145
|
+
// после. Ведущего h1 нет (авто-заголовок) → прежний порядок diagrams → texts.
|
|
146
|
+
const [firstText = '', ...restTexts] = texts;
|
|
147
|
+
const h1 = firstText.match(/^(?\s*#\s+[^\n]*)\n?([\s\S]*)$/);
|
|
148
|
+
fullDoc = h1
|
|
149
|
+
? [h1[1], ...diagrams, h1[2], ...restTexts].filter((s) => s !== '')
|
|
150
|
+
: [...diagrams, ...texts];
|
|
68
151
|
}
|
|
69
152
|
else {
|
|
70
153
|
fullDoc = [...texts, ...diagrams];
|
|
@@ -75,6 +158,24 @@ const compileDocument = async (md, item, options, getDiagram) => {
|
|
|
75
158
|
}
|
|
76
159
|
return MD;
|
|
77
160
|
};
|
|
161
|
+
// Якорь заголовка в complete-документе (докcифай/GitHub-стиль: пробелы → дефисы).
|
|
162
|
+
const mdAnchor = (name) => `#${encodeURIPath(name).replace(/%20/g, '-')}`;
|
|
163
|
+
// Относительная ссылка со страницы уровня fromLevel на README целевой папки
|
|
164
|
+
// (rootRel — путь цели от ROOT_FOLDER). Один паттерн для TOC, навигации вверх и
|
|
165
|
+
// меню подпапок — прежде дублировался трижды.
|
|
166
|
+
const relativeMdLink = (fromLevel, rootRel, mdFileName) => encodeURIPath(path.join('./', fromLevel - 1 > 0 ? '../'.repeat(fromLevel - 1) : '', rootRel, `${mdFileName}.md`));
|
|
167
|
+
// Тело страницы (титул + контент с диаграммами) — общий скелет generateMD и
|
|
168
|
+
// generateWebMD. chrome (breadcrumbs/TOC/навигация) встаёт под заголовок: при
|
|
169
|
+
// авто-титуле — сразу за ним, при пользовательском h1 — пост-вставкой после него.
|
|
170
|
+
const compilePage = async (item, options, chrome = '') => {
|
|
171
|
+
const name = getFolderName(item.dir, options.ROOT_FOLDER, options.HOMEPAGE_NAME);
|
|
172
|
+
const ownH1 = hasOwnH1(item);
|
|
173
|
+
let MD = ownH1 ? '' : `# ${name}${chrome}`;
|
|
174
|
+
MD = await compileDocument(MD, item, options, (i, d, o) => buildDiagramMarkdown(i, d, o, 'page'));
|
|
175
|
+
if (ownH1)
|
|
176
|
+
MD = injectAfterFirstH1(MD, chrome);
|
|
177
|
+
return MD.trimStart();
|
|
178
|
+
};
|
|
78
179
|
export const generateCompleteMD = async (tree, options) => {
|
|
79
180
|
const filePromises = [];
|
|
80
181
|
//title
|
|
@@ -82,7 +183,7 @@ export const generateCompleteMD = async (tree, options) => {
|
|
|
82
183
|
//table of contents
|
|
83
184
|
let tableOfContents = '';
|
|
84
185
|
for (const item of tree)
|
|
85
|
-
tableOfContents += `${' '.repeat(item.level - 1)}* [${item.name}](
|
|
186
|
+
tableOfContents += `${' '.repeat(item.level - 1)}* [${item.name}](${mdAnchor(item.name)})\n`;
|
|
86
187
|
MD += `\n\n${tableOfContents}\n---`;
|
|
87
188
|
for (const item of tree) {
|
|
88
189
|
const name = getFolderName(item.dir, options.ROOT_FOLDER, options.HOMEPAGE_NAME);
|
|
@@ -91,35 +192,12 @@ export const generateCompleteMD = async (tree, options) => {
|
|
|
91
192
|
if (name !== options.HOMEPAGE_NAME) {
|
|
92
193
|
if (options.INCLUDE_BREADCRUMBS)
|
|
93
194
|
MD += `\n\n\`${item.dir.replace(options.ROOT_FOLDER, '')}\``;
|
|
94
|
-
MD += `\n\n[${options.HOMEPAGE_NAME}](
|
|
195
|
+
MD += `\n\n[${options.HOMEPAGE_NAME}](${mdAnchor(options.PROJECT_NAME)})`;
|
|
95
196
|
}
|
|
96
197
|
//concatenate markdown files
|
|
97
|
-
MD = await compileDocument(MD, item, options,
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
diagramUrl = plantUmlServerUrl(options.PLANTUML_SERVER_URL, diagramOutputFormat(diagram, options), diagram.content // рендер-контент диаграммы (Buffer читается как текст)
|
|
101
|
-
);
|
|
102
|
-
if (options.EMBED_DIAGRAM) {
|
|
103
|
-
let imgContent = '';
|
|
104
|
-
if (options.GENERATE_LOCAL_IMAGES)
|
|
105
|
-
imgContent = (await readFile(path.join(options.DIST_FOLDER, item.dir.replace(options.ROOT_FOLDER, ''), diagramUrl))).toString('base64');
|
|
106
|
-
else
|
|
107
|
-
imgContent = await httpGet(diagramUrl);
|
|
108
|
-
const diagramImage = `\n)};base64,${imgContent})\n`;
|
|
109
|
-
const diagramLink = `\n[Download ${path.parse(diagram.dir).name} diagram](${encodeURIPath(path.join(item.dir.replace(options.ROOT_FOLDER, ''), diagramUrl))} ':ignore')`;
|
|
110
|
-
return diagramImage + diagramLink;
|
|
111
|
-
}
|
|
112
|
-
else {
|
|
113
|
-
const diagramImage = ``;
|
|
114
|
-
const diagramLink = `[Go to ${path.parse(diagram.dir).name} diagram](${encodeURIPath(path.join(item.dir.replace(options.ROOT_FOLDER, ''), diagramUrl))})`;
|
|
115
|
-
if (!options.INCLUDE_LINK_TO_DIAGRAM)
|
|
116
|
-
//img
|
|
117
|
-
return diagramImage;
|
|
118
|
-
//link
|
|
119
|
-
else
|
|
120
|
-
return diagramLink;
|
|
121
|
-
}
|
|
122
|
-
});
|
|
198
|
+
MD = await compileDocument(MD, item, options, (item, diagram, options) =>
|
|
199
|
+
// complete: ссылки на диаграммы префиксуются папкой элемента.
|
|
200
|
+
buildDiagramMarkdown(item, diagram, options, 'complete'));
|
|
123
201
|
}
|
|
124
202
|
//write file to disk
|
|
125
203
|
filePromises.push(writeFile(path.join(options.DIST_FOLDER, `${options.PROJECT_NAME}.md`), MD));
|
|
@@ -131,12 +209,8 @@ export const generateMD = async (tree, options, onProgress) => {
|
|
|
131
209
|
const filePromises = [];
|
|
132
210
|
for (const item of tree) {
|
|
133
211
|
const name = getFolderName(item.dir, options.ROOT_FOLDER, options.HOMEPAGE_NAME);
|
|
134
|
-
|
|
135
|
-
//
|
|
136
|
-
let MD = ownH1 ? '' : `# ${name}`;
|
|
137
|
-
// "Page chrome" — breadcrumb / TOC / навигация. Собираем отдельно, чтобы при
|
|
138
|
-
// наличии собственного h1 у пользователя поместить весь блок ПОД его заголовок,
|
|
139
|
-
// а не над ним (как было исторически — между авто-# name и контентом).
|
|
212
|
+
// "Page chrome" — breadcrumb / TOC / навигация. Собирается отдельно и встаёт
|
|
213
|
+
// под заголовок страницы (авто или пользовательский h1) внутри compilePage.
|
|
140
214
|
let chrome = '';
|
|
141
215
|
//bradcrumbs
|
|
142
216
|
if (options.INCLUDE_BREADCRUMBS && name !== options.HOMEPAGE_NAME)
|
|
@@ -146,62 +220,28 @@ export const generateMD = async (tree, options, onProgress) => {
|
|
|
146
220
|
let tableOfContents = '';
|
|
147
221
|
for (const _item of tree) {
|
|
148
222
|
const label = `${item.dir === _item.dir ? '**' : ''}${_item.name}${item.dir === _item.dir ? '**' : ''}`;
|
|
149
|
-
tableOfContents += `${' '.repeat(_item.level - 1)}* [${label}](${
|
|
223
|
+
tableOfContents += `${' '.repeat(_item.level - 1)}* [${label}](${relativeMdLink(item.level, _item.dir.replace(options.ROOT_FOLDER, ''), options.MD_FILE_NAME)})\n`;
|
|
150
224
|
}
|
|
151
225
|
chrome += `\n\n${tableOfContents}\n---`;
|
|
152
226
|
}
|
|
153
227
|
//parent menu
|
|
154
228
|
if (item.parent && options.INCLUDE_NAVIGATION) {
|
|
155
229
|
const parentName = getFolderName(item.parent, options.ROOT_FOLDER, options.HOMEPAGE_NAME);
|
|
156
|
-
chrome += `\n\n[${parentName} (up)](${
|
|
230
|
+
chrome += `\n\n[${parentName} (up)](${relativeMdLink(item.level, item.parent.replace(options.ROOT_FOLDER, ''), options.MD_FILE_NAME)})`;
|
|
157
231
|
}
|
|
158
|
-
//
|
|
232
|
+
//descendants menu (files and folders prefixed with _ excluded at scan)
|
|
159
233
|
let descendantsMenu = '';
|
|
160
234
|
for (const file of item.descendants) {
|
|
161
|
-
descendantsMenu += `\n\n- [${file}](${
|
|
235
|
+
descendantsMenu += `\n\n- [${file}](${relativeMdLink(item.level, path.join(item.dir.replace(options.ROOT_FOLDER, ''), file), options.MD_FILE_NAME)})`;
|
|
162
236
|
}
|
|
163
|
-
//descendants menu
|
|
164
237
|
if (descendantsMenu && options.INCLUDE_NAVIGATION)
|
|
165
238
|
chrome += `${descendantsMenu}`;
|
|
166
239
|
//separator
|
|
167
240
|
if (options.INCLUDE_NAVIGATION)
|
|
168
241
|
chrome += `\n\n---`;
|
|
169
|
-
|
|
170
|
-
// Если есть — chrome будет вставлен после пользовательского h1 пост-обработкой.
|
|
171
|
-
if (!ownH1)
|
|
172
|
-
MD += chrome;
|
|
173
|
-
//concatenate markdown files
|
|
174
|
-
MD = await compileDocument(MD, item, options, async (item, diagram, options) => {
|
|
175
|
-
let diagramUrl = encodeURIPath(path.join(path.dirname(diagram.dir), `${path.parse(diagram.dir).name}.${diagramOutputFormat(diagram, options)}`));
|
|
176
|
-
if (!options.GENERATE_LOCAL_IMAGES && diagram.engine === 'plantuml')
|
|
177
|
-
diagramUrl = plantUmlServerUrl(options.PLANTUML_SERVER_URL, diagramOutputFormat(diagram, options), diagram.content // рендер-контент диаграммы (Buffer читается как текст)
|
|
178
|
-
);
|
|
179
|
-
if (options.EMBED_DIAGRAM) {
|
|
180
|
-
let imgContent = '';
|
|
181
|
-
if (options.GENERATE_LOCAL_IMAGES)
|
|
182
|
-
imgContent = (await readFile(path.join(options.DIST_FOLDER, item.dir.replace(options.ROOT_FOLDER, ''), diagramUrl))).toString('base64');
|
|
183
|
-
else
|
|
184
|
-
imgContent = await httpGet(diagramUrl);
|
|
185
|
-
const diagramImage = `\n)};base64,${imgContent})\n`;
|
|
186
|
-
const diagramLink = `[Download ${path.parse(diagram.dir).name} diagram](${diagramUrl} ':ignore')`;
|
|
187
|
-
return diagramImage + diagramLink;
|
|
188
|
-
}
|
|
189
|
-
else {
|
|
190
|
-
const diagramImage = ``;
|
|
191
|
-
const diagramLink = `[Go to ${path.parse(diagram.dir).name} diagram](${diagramUrl})`;
|
|
192
|
-
if (!options.INCLUDE_LINK_TO_DIAGRAM)
|
|
193
|
-
//img
|
|
194
|
-
return diagramImage;
|
|
195
|
-
//link
|
|
196
|
-
else
|
|
197
|
-
return diagramLink;
|
|
198
|
-
}
|
|
199
|
-
});
|
|
200
|
-
// Если был свой h1 — вставляем chrome (breadcrumbs/TOC/nav) после него.
|
|
201
|
-
if (ownH1)
|
|
202
|
-
MD = injectAfterFirstH1(MD, chrome);
|
|
242
|
+
const MD = await compilePage(item, options, chrome);
|
|
203
243
|
//write to disk
|
|
204
|
-
filePromises.push(writeFile(path.join(options.DIST_FOLDER, item.dir.replace(options.ROOT_FOLDER, ''), `${options.MD_FILE_NAME}.md`), MD
|
|
244
|
+
filePromises.push(writeFile(path.join(options.DIST_FOLDER, item.dir.replace(options.ROOT_FOLDER, ''), `${options.MD_FILE_NAME}.md`), MD).then(() => {
|
|
205
245
|
processedCount++;
|
|
206
246
|
if (onProgress)
|
|
207
247
|
onProgress(processedCount, totalCount);
|
|
@@ -213,57 +253,28 @@ export const generateWebMD = async (tree, options) => {
|
|
|
213
253
|
const filePromises = [];
|
|
214
254
|
let docsifySideBar = '';
|
|
215
255
|
const getWebFileName = (originalFileName) => options.WEB_FILE_NAME || originalFileName;
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
return false;
|
|
219
|
-
return options.EXCLUDE_SIDEBAR_FOLDER_BY_PATH.find((pathToExclude) => {
|
|
220
|
-
const isString = typeof pathToExclude === 'string';
|
|
221
|
-
if (isString)
|
|
222
|
-
return dir.startsWith(pathToExclude);
|
|
223
|
-
return false;
|
|
224
|
-
});
|
|
225
|
-
};
|
|
256
|
+
// Чистоту string[] гарантирует zod-схема (не-строки отброшены препроцессом).
|
|
257
|
+
const isExcluded = (dir) => !!options.EXCLUDE_SIDEBAR_FOLDER_BY_PATH?.some((pathToExclude) => dir.startsWith(pathToExclude));
|
|
226
258
|
for (const item of tree) {
|
|
227
|
-
//sidebar
|
|
259
|
+
//sidebar — путь от ROOT_FOLDER (path.relative, а не отрезание первого сегмента:
|
|
260
|
+
// то ломалось на многосегментном rootFolder из рукописного конфига)
|
|
228
261
|
if (!isExcluded(item.dir)) {
|
|
229
|
-
docsifySideBar += `${' '.repeat(item.level - 1)}* [${item.name}](${encodeURIPath(path.join(
|
|
262
|
+
docsifySideBar += `${' '.repeat(item.level - 1)}* [${item.name}](${encodeURIPath(path.join(path.relative(options.ROOT_FOLDER, item.dir), getWebFileName(item.name)))})\n`;
|
|
230
263
|
}
|
|
231
|
-
const
|
|
232
|
-
//title
|
|
233
|
-
let MD = hasOwnH1(item) ? '' : `# ${name}`;
|
|
234
|
-
//concatenate markdown files
|
|
235
|
-
MD = await compileDocument(MD, item, options, async (item, diagram, options) => {
|
|
236
|
-
let diagramUrl = encodeURIPath(path.join(path.dirname(diagram.dir), `${path.parse(diagram.dir).name}.${diagramOutputFormat(diagram, options)}`));
|
|
237
|
-
if (!options.GENERATE_LOCAL_IMAGES && diagram.engine === 'plantuml')
|
|
238
|
-
diagramUrl = plantUmlServerUrl(options.PLANTUML_SERVER_URL, diagramOutputFormat(diagram, options), diagram.content // рендер-контент диаграммы (Buffer читается как текст)
|
|
239
|
-
);
|
|
240
|
-
if (options.EMBED_DIAGRAM) {
|
|
241
|
-
let imgContent = '';
|
|
242
|
-
if (options.GENERATE_LOCAL_IMAGES)
|
|
243
|
-
imgContent = (await readFile(path.join(options.DIST_FOLDER, item.dir.replace(options.ROOT_FOLDER, ''), diagramUrl))).toString('base64');
|
|
244
|
-
else
|
|
245
|
-
imgContent = await httpGet(diagramUrl);
|
|
246
|
-
const diagramImage = `\n)};base64,${imgContent})\n`;
|
|
247
|
-
const diagramLink = `[Download ${path.parse(diagram.dir).name} diagram](${diagramUrl} ':ignore')`;
|
|
248
|
-
return diagramImage + diagramLink;
|
|
249
|
-
}
|
|
250
|
-
else {
|
|
251
|
-
const diagramImage = ``;
|
|
252
|
-
const diagramLink = `[Go to ${path.parse(diagram.dir).name} diagram](${diagramUrl})`;
|
|
253
|
-
if (!options.INCLUDE_LINK_TO_DIAGRAM)
|
|
254
|
-
//img
|
|
255
|
-
return diagramImage;
|
|
256
|
-
//link
|
|
257
|
-
else
|
|
258
|
-
return diagramLink;
|
|
259
|
-
}
|
|
260
|
-
});
|
|
261
|
-
MD = MD.trimStart();
|
|
264
|
+
const MD = await compilePage(item, options);
|
|
262
265
|
//write to disk
|
|
263
266
|
filePromises.push(writeFile(path.join(options.DIST_FOLDER, item.dir.replace(options.ROOT_FOLDER, ''), `${getWebFileName(item.name)}.md`), MD));
|
|
264
267
|
}
|
|
265
268
|
if (options.DOCSIFY_TEMPLATE && options.DOCSIFY_TEMPLATE !== '') {
|
|
266
|
-
|
|
269
|
+
// require ESM/TS-шаблона возвращает namespace { default: fn }, а не саму функцию —
|
|
270
|
+
// разворачиваем default, иначе дальше «docsifyTemplate is not a function».
|
|
271
|
+
const loaded = require(path.join(process.cwd(), options.DOCSIFY_TEMPLATE));
|
|
272
|
+
const fn = typeof loaded === 'function' ? loaded : loaded?.default;
|
|
273
|
+
if (typeof fn !== 'function') {
|
|
274
|
+
throw new Error(`docsifyTemplate «${options.DOCSIFY_TEMPLATE}» должен экспортировать функцию ` +
|
|
275
|
+
`(module.exports = fn или export default fn), получено: ${typeof fn}`);
|
|
276
|
+
}
|
|
277
|
+
docsifyTemplate = fn;
|
|
267
278
|
}
|
|
268
279
|
// Имя корневого элемента дерева (без parent) для homepage docsify. Считается лениво
|
|
269
280
|
// (только если не задан WEB_FILE_NAME); отсутствие корня — явная ошибка вместо assertion.
|
|
@@ -26,8 +26,13 @@ const getD2 = () => {
|
|
|
26
26
|
})();
|
|
27
27
|
return d2Promise;
|
|
28
28
|
};
|
|
29
|
-
// Явный teardown: webworker иначе держит процесс и CLI не завершается.
|
|
30
|
-
//
|
|
29
|
+
// Явный teardown: webworker иначе держит процесс и CLI не завершается. Зовётся из
|
|
30
|
+
// CLI (dispatch) после ОДИНОЧНОЙ сборки — в watch-режиме воркер переживает ребилды
|
|
31
|
+
// (его пересоздание на каждый ребилд стоило секунды), а при Ctrl+C умирает с процессом.
|
|
32
|
+
// НЕ должен бросать: его throw затёр бы исходную причину падения сборки. Все ошибки
|
|
33
|
+
// освобождения глушим (движок уже мёртв — то, что надо освободить, и так освобождено).
|
|
34
|
+
// Идемпотентен: d2Promise=null в начале. Кеш графа импортов сбрасывает не он, а
|
|
35
|
+
// clearD2FileCache() на границах build().
|
|
31
36
|
const teardownD2 = async () => {
|
|
32
37
|
if (!d2Promise)
|
|
33
38
|
return;
|
|
@@ -43,10 +48,17 @@ const teardownD2 = async () => {
|
|
|
43
48
|
try {
|
|
44
49
|
await inst.ready; // worker создаётся асинхронно в init()
|
|
45
50
|
}
|
|
46
|
-
|
|
51
|
+
catch {
|
|
52
|
+
// init движка мог упасть — это уже отражено ошибкой рендера; глушим, иначе
|
|
53
|
+
// throw из finally build() подменил бы исходную причину падения сборки.
|
|
54
|
+
}
|
|
55
|
+
try {
|
|
47
56
|
if (inst.worker)
|
|
48
57
|
await inst.worker.terminate();
|
|
49
58
|
}
|
|
59
|
+
catch {
|
|
60
|
+
// воркер уже недоступен/мёртв — освобождать нечего.
|
|
61
|
+
}
|
|
50
62
|
};
|
|
51
63
|
// Разрешить ссылку импорта в .d2-файл на диске. Расширение .d2 подразумевается,
|
|
52
64
|
// а `@file.key1.key2` — частичный импорт: имя файла это префикс до ключевого пути,
|
|
@@ -61,14 +73,22 @@ const resolveImport = (ref, fromDir) => {
|
|
|
61
73
|
return null;
|
|
62
74
|
};
|
|
63
75
|
// Собрать граф импортов от входного .d2 рекурсивно: { абсолютный путь -> контент }.
|
|
64
|
-
// Лидирующий `@` не после word-символа отсекает почтовые адреса
|
|
65
|
-
|
|
76
|
+
// Лидирующий `@` не после word-символа отсекает почтовые адреса (`foo@bar`).
|
|
77
|
+
// Путь импорта поддерживает пробелы и юникод (папки `4 D2 Example`, `3 Локализация`),
|
|
78
|
+
// а также закавыченную форму `@"путь с пробелом"` — движок @terrastruct/d2
|
|
79
|
+
// принимает все три (проверено эмпирически). Незакавыченный путь читается до
|
|
80
|
+
// терминатора statement (`#` комментарий, `;`, `}`, перевод строки) минус хвостовой
|
|
81
|
+
// пробел — иначе инлайновый spread `x: { ...@lib }` захватил бы `lib }`. Флаг `u` —
|
|
82
|
+
// корректный юникод. Хвостовые `.key`-сегменты частичного импорта отрезает resolveImport.
|
|
83
|
+
const IMPORT_RE = /(?<!\w)@(?:"([^"\n]+)"|([^\s"#;}\n][^#;}\n]*?)\s*(?=[#;}\n]|$))/gu;
|
|
66
84
|
const collectFiles = (entryAbs, acc = new Map()) => {
|
|
67
85
|
const abs = path.resolve(entryAbs);
|
|
68
86
|
if (acc.has(abs))
|
|
69
87
|
return acc;
|
|
70
88
|
let content;
|
|
71
89
|
try {
|
|
90
|
+
// Строго UTF-8: язык D2 определён поверх UTF-8, опция CHARSET конфига относится
|
|
91
|
+
// к PlantUML (уходит в -charset) и на .d2 сознательно не распространяется.
|
|
72
92
|
content = fs.readFileSync(abs, 'utf-8');
|
|
73
93
|
}
|
|
74
94
|
catch {
|
|
@@ -79,12 +99,35 @@ const collectFiles = (entryAbs, acc = new Map()) => {
|
|
|
79
99
|
// между вложенными вызовами — иначе рекурсия затирала бы позицию глобального
|
|
80
100
|
// regex и вложенные (2-го уровня) импорты молча терялись бы.
|
|
81
101
|
for (const m of content.matchAll(IMPORT_RE)) {
|
|
82
|
-
const
|
|
102
|
+
const ref = m[1] ?? m[2]; // закавыченный | незакавыченный путь
|
|
103
|
+
if (!ref)
|
|
104
|
+
continue;
|
|
105
|
+
const resolved = resolveImport(ref, path.dirname(abs));
|
|
83
106
|
if (resolved)
|
|
84
107
|
collectFiles(resolved, acc);
|
|
85
108
|
}
|
|
86
109
|
return acc;
|
|
87
110
|
};
|
|
111
|
+
// Мемоизация графа на время одной сборки: foldD2Imports (чексумма) и
|
|
112
|
+
// buildCompileRequest (рендер) иначе читают один и тот же граф с диска дважды.
|
|
113
|
+
// Ключ — абсолютный путь входного файла. Сбрасывается clearD2FileCache() на границах
|
|
114
|
+
// build(), иначе watch-режим отдавал бы устаревшее содержимое. Возвращаемую Map
|
|
115
|
+
// потребители НЕ мутируют (foldD2Imports исключает входной файл фильтром, не delete).
|
|
116
|
+
let filesMemo = null;
|
|
117
|
+
const clearD2FileCache = () => {
|
|
118
|
+
filesMemo = null;
|
|
119
|
+
};
|
|
120
|
+
const collectFilesCached = (entryAbs) => {
|
|
121
|
+
const abs = path.resolve(entryAbs);
|
|
122
|
+
if (!filesMemo)
|
|
123
|
+
filesMemo = new Map();
|
|
124
|
+
const hit = filesMemo.get(abs);
|
|
125
|
+
if (hit)
|
|
126
|
+
return hit;
|
|
127
|
+
const built = collectFiles(abs);
|
|
128
|
+
filesMemo.set(abs, built);
|
|
129
|
+
return built;
|
|
130
|
+
};
|
|
88
131
|
// Общий корень набора путей — чтобы ключи виртуальной fs были относительны ему и
|
|
89
132
|
// родительские импорты (`...@../c4lib`) резолвились движком как на реальном диске.
|
|
90
133
|
const commonAncestor = (files) => {
|
|
@@ -102,7 +145,12 @@ const commonAncestor = (files) => {
|
|
|
102
145
|
};
|
|
103
146
|
// Подготовить аргументы compile(): виртуальная fs (все файлы графа) + inputPath.
|
|
104
147
|
const buildCompileRequest = (entryAbs) => {
|
|
105
|
-
const files =
|
|
148
|
+
const files = collectFilesCached(entryAbs);
|
|
149
|
+
// Пустой граф = не прочитался сам входной файл (удалён/недоступен — гонка в watch).
|
|
150
|
+
// Без guard'а commonAncestor([]) давал бы сырой TypeError (Math.min()=Infinity).
|
|
151
|
+
if (files.size === 0) {
|
|
152
|
+
throw new Error(`D2: не удалось прочитать ${entryAbs} (файл удалён или недоступен?)`);
|
|
153
|
+
}
|
|
106
154
|
const root = commonAncestor([...files.keys()]);
|
|
107
155
|
const toKey = (abs) => path.relative(root, abs).split(path.sep).join('/');
|
|
108
156
|
const fsMap = {};
|
|
@@ -135,12 +183,20 @@ const renderD2 = async (entryAbs, { layout = 'dagre' } = {}) => {
|
|
|
135
183
|
// Материал импортов для чексуммы кэша (без самого входного файла — его контент
|
|
136
184
|
// хэшируется отдельно). Правка импортируемого .d2 меняет чексумму зависимой
|
|
137
185
|
// диаграммы — аналог foldIncludes для PlantUML.
|
|
186
|
+
// Путь в материале — относительный к cwd, posix-разделители: чексумма не зависит
|
|
187
|
+
// от машины/чекаута/ОС (абсолютный путь делал кеш непереносимым).
|
|
138
188
|
const foldD2Imports = (entryAbs) => {
|
|
139
|
-
const
|
|
140
|
-
files
|
|
189
|
+
const abs = path.resolve(entryAbs);
|
|
190
|
+
const files = collectFilesCached(entryAbs);
|
|
191
|
+
const rel = (p) => path.relative(process.cwd(), p).split(path.sep).join('/');
|
|
192
|
+
// Мемоизированную Map не мутируем (её же использует buildCompileRequest) —
|
|
193
|
+
// входной файл исключаем фильтром, а не delete. Сортировка по относительному
|
|
194
|
+
// ключу — тому же, что уходит в материал.
|
|
141
195
|
return [...files.entries()]
|
|
196
|
+
.filter(([p]) => p !== abs)
|
|
197
|
+
.map(([p, content]) => [rel(p), content])
|
|
142
198
|
.sort((a, b) => a[0].localeCompare(b[0]))
|
|
143
|
-
.map(([
|
|
199
|
+
.map(([p, content]) => ` ${p} ${content}`)
|
|
144
200
|
.join('');
|
|
145
201
|
};
|
|
146
|
-
export { renderD2, foldD2Imports, teardownD2 };
|
|
202
|
+
export { renderD2, foldD2Imports, teardownD2, clearD2FileCache };
|