@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.
@@ -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
- import { getFolderName, diagramOutputFormat } from "../scan/tree.js";
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: ![alt](path.puml|d2). Путь ленивый и не пересекает ')',
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![${name}](data:${getMime(format)};base64,${imgContent})\n`;
102
+ const diagramLink = `${isComplete ? '\n' : ''}[Download ${name} diagram](${localHref} ':ignore')`;
103
+ return diagramImage + diagramLink;
104
+ }
105
+ const diagramImage = `![diagram](${localHref})`;
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 = /(?:!\[.*?\]\()(.*\.(?:puml|d2))(\))/g;
115
+ const regex = diagramRefRegex();
43
116
  for (const mdFile of item.mdFiles) {
44
- let content = mdFile.toString();
45
- let diagramRef;
46
- // biome-ignore lint/suspicious/noAssignInExpressions: идиома regex.exec() в условии while
47
- while ((diagramRef = regex.exec(content)) !== null) {
48
- if (diagramRef?.[1]) {
49
- const ref = diagramRef[1]; // const → сужение держится в замыкании .find
50
- const diagram = item.diagrams.find((x) => x.dir === ref);
51
- if (diagram) {
52
- alreadyIncluded.push(ref);
53
- content = content.replace(diagramRef[0], await getDiagram(item, diagram, options));
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
- texts.push(content);
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
- fullDoc = [...diagrams, ...texts];
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}](#${encodeURIPath(item.name).replace(/%20/g, '-')})\n`;
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}](#${encodeURIPath(options.PROJECT_NAME).replace(/%20/g, '-')})`;
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, async (item, diagram, options) => {
98
- let diagramUrl = encodeURIPath(path.join(path.dirname(diagram.dir), `${path.parse(diagram.dir).name}.${diagramOutputFormat(diagram, options)}`));
99
- if (!options.GENERATE_LOCAL_IMAGES && diagram.engine === 'plantuml')
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![${path.parse(diagram.dir).name}](data:${getMime(diagramOutputFormat(diagram, options))};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 = `![diagram](${diagramUrl})`;
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
- const ownH1 = hasOwnH1(item);
135
- //title
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}](${encodeURIPath(path.join('./', item.level - 1 > 0 ? '../'.repeat(item.level - 1) : '', _item.dir.replace(options.ROOT_FOLDER, ''), `${options.MD_FILE_NAME}.md`))})\n`; //slice 1 if root and down
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)](${encodeURIPath(path.join('./', item.level - 1 > 0 ? '../'.repeat(item.level - 1) : '', item.parent.replace(options.ROOT_FOLDER, ''), `${options.MD_FILE_NAME}.md`))})`;
230
+ chrome += `\n\n[${parentName} (up)](${relativeMdLink(item.level, item.parent.replace(options.ROOT_FOLDER, ''), options.MD_FILE_NAME)})`;
157
231
  }
158
- //exclude files and folders prefixed with _
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}](${encodeURIPath(path.join('./', item.level - 1 > 0 ? '../'.repeat(item.level - 1) : '', item.dir.replace(options.ROOT_FOLDER, ''), file, `${options.MD_FILE_NAME}.md`))})`;
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
- // Если своего h1 нет — chrome идёт сразу после авто-заголовка, как раньше.
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![${path.parse(diagram.dir).name}](data:${getMime(diagramOutputFormat(diagram, options))};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 = `![diagram](${diagramUrl})`;
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.trimStart()).then(() => {
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
- const isExcluded = (dir) => {
217
- if (!Array.isArray(options.EXCLUDE_SIDEBAR_FOLDER_BY_PATH))
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(...path.join(item.dir).split(path.sep).splice(1), getWebFileName(item.name)))})\n`;
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 name = getFolderName(item.dir, options.ROOT_FOLDER, options.HOMEPAGE_NAME);
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![${path.parse(diagram.dir).name}](data:${getMime(diagramOutputFormat(diagram, options))};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 = `![diagram](${diagramUrl})`;
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
- docsifyTemplate = require(path.join(process.cwd(), options.DOCSIFY_TEMPLATE));
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
- // завершаем в finally, чтобы освободить его даже если init (inst.ready) упал.
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
- finally {
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
- const IMPORT_RE = /(?<![\w])@([\w./-]+)/g;
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 resolved = resolveImport(m[1], path.dirname(abs));
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 = collectFiles(entryAbs);
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 files = collectFiles(entryAbs);
140
- files.delete(path.resolve(entryAbs));
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(([abs, content]) => ` ${abs} ${content}`)
199
+ .map(([p, content]) => ` ${p} ${content}`)
144
200
  .join('');
145
201
  };
146
- export { renderD2, foldD2Imports, teardownD2 };
202
+ export { renderD2, foldD2Imports, teardownD2, clearD2FileCache };