@jacobbubu/md-to-lark 1.0.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.
Files changed (58) hide show
  1. package/LICENSE +15 -0
  2. package/README.md +171 -0
  3. package/dist/btt/build-tree.js +79 -0
  4. package/dist/btt/index.js +1 -0
  5. package/dist/btt/types.js +1 -0
  6. package/dist/cli/publish-md-to-lark.js +15 -0
  7. package/dist/commands/publish-md/args.js +224 -0
  8. package/dist/commands/publish-md/command.js +97 -0
  9. package/dist/commands/publish-md/index.js +1 -0
  10. package/dist/commands/publish-md/input-resolver.js +48 -0
  11. package/dist/commands/publish-md/mermaid-render.js +17 -0
  12. package/dist/commands/publish-md/pipeline-transform.js +4 -0
  13. package/dist/commands/publish-md/preset-loader.js +113 -0
  14. package/dist/commands/publish-md/presets/medium.js +7 -0
  15. package/dist/commands/publish-md/presets/zh-format.js +8 -0
  16. package/dist/commands/publish-md/title-policy.js +93 -0
  17. package/dist/index.js +1 -0
  18. package/dist/interop/btt-to-last.js +79 -0
  19. package/dist/interop/codec-btt-to-last.js +435 -0
  20. package/dist/interop/codec-last-to-btt.js +383 -0
  21. package/dist/interop/codec-shared.js +722 -0
  22. package/dist/interop/index.js +2 -0
  23. package/dist/interop/last-to-btt.js +17 -0
  24. package/dist/lark/block-types.js +42 -0
  25. package/dist/lark/client.js +36 -0
  26. package/dist/lark/docx/ops.js +596 -0
  27. package/dist/lark/docx/render-btt.js +156 -0
  28. package/dist/lark/docx/render-models.js +1 -0
  29. package/dist/lark/docx/render-payload.js +338 -0
  30. package/dist/lark/docx/render-post-process.js +98 -0
  31. package/dist/lark/docx/render-table.js +87 -0
  32. package/dist/lark/docx/render-types.js +7 -0
  33. package/dist/lark/index.js +2 -0
  34. package/dist/lark/types.js +1 -0
  35. package/dist/last/api.js +1687 -0
  36. package/dist/last/index.js +3 -0
  37. package/dist/last/preview-terminal.js +296 -0
  38. package/dist/last/textual-block-types.js +19 -0
  39. package/dist/last/to-markdown.js +303 -0
  40. package/dist/last/types.js +11 -0
  41. package/dist/pipeline/hast-to-last.js +946 -0
  42. package/dist/pipeline/index.js +3 -0
  43. package/dist/pipeline/markdown/md-to-hast.js +34 -0
  44. package/dist/pipeline/markdown/prepare-markdown.js +1049 -0
  45. package/dist/preview/index.js +1 -0
  46. package/dist/preview/markdown-terminal.js +350 -0
  47. package/dist/publish/asset-adapter.js +123 -0
  48. package/dist/publish/btt-patch.js +65 -0
  49. package/dist/publish/common.js +139 -0
  50. package/dist/publish/ids.js +9 -0
  51. package/dist/publish/index.js +7 -0
  52. package/dist/publish/last-normalize.js +327 -0
  53. package/dist/publish/process-file.js +228 -0
  54. package/dist/publish/runtime.js +133 -0
  55. package/dist/publish/stage-cache.js +56 -0
  56. package/dist/shared/rate-limiter.js +18 -0
  57. package/dist/shared/retry.js +141 -0
  58. package/package.json +78 -0
@@ -0,0 +1,327 @@
1
+ import { isTextualBlock, extractTextFromInlines, toPlainTextFromInlineList } from './common.js';
2
+ const TABLE_DEFAULT_PAGE_WIDTH_PX = 1024;
3
+ const TABLE_FONT_SIZE_PX = 14;
4
+ const TABLE_CELL_HORIZONTAL_PADDING_PX = 24;
5
+ const TABLE_TEXT_COLUMN_MIN_WIDTH_PX = 96;
6
+ const TABLE_TEXT_COLUMN_MAX_WIDTH_PX = 360;
7
+ const TABLE_NUMERIC_COLUMN_MIN_WIDTH_PX = 72;
8
+ const TABLE_NUMERIC_COLUMN_MAX_WIDTH_PX = 240;
9
+ const INVALID_NUMERIC_TOKENS = new Set([
10
+ 'inf',
11
+ '+inf',
12
+ '-inf',
13
+ 'infinity',
14
+ '+infinity',
15
+ '-infinity',
16
+ 'na',
17
+ 'n/a',
18
+ 'nan',
19
+ 'null',
20
+ 'nil',
21
+ '--',
22
+ '-',
23
+ ]);
24
+ const CURRENCY_PREFIX_RE = /^(?:[$€£¥₹]|cny|rmb|usd|eur|gbp|jpy|hkd|cad|aud)\s*/i;
25
+ const NUMBER_WITH_OPTIONAL_UNIT_RE = /^([+-]?(?:(?:\d{1,3}(?:,\d{3})+)|\d+)(?:\.\d+)?|[+-]?\.\d+)(?:\s*(%|[a-zA-Z\u4e00-\u9fff]{1,12}))?$/;
26
+ function isMermaidLanguage(language) {
27
+ if (typeof language !== 'string')
28
+ return false;
29
+ const normalized = language.trim().toLowerCase();
30
+ if (normalized === 'mermaid')
31
+ return true;
32
+ if (normalized.startsWith('mermaid '))
33
+ return true;
34
+ return normalized.split(/[,\s{}[\]()]+/).includes('mermaid');
35
+ }
36
+ function clamp(value, min, max) {
37
+ return Math.min(max, Math.max(min, value));
38
+ }
39
+ function charWidthPx(ch) {
40
+ if (/\s/.test(ch))
41
+ return TABLE_FONT_SIZE_PX * 0.33;
42
+ if (/[0-9]/.test(ch))
43
+ return TABLE_FONT_SIZE_PX * 0.56;
44
+ if (/[A-Z]/.test(ch))
45
+ return TABLE_FONT_SIZE_PX * 0.62;
46
+ if (/[a-z]/.test(ch))
47
+ return TABLE_FONT_SIZE_PX * 0.54;
48
+ if (/[\u4e00-\u9fff\u3040-\u30ff\uac00-\ud7af]/.test(ch))
49
+ return TABLE_FONT_SIZE_PX * 1.0;
50
+ if (/[.,:;'"`|]/.test(ch))
51
+ return TABLE_FONT_SIZE_PX * 0.32;
52
+ if (/[(){}\[\]<>]/.test(ch))
53
+ return TABLE_FONT_SIZE_PX * 0.4;
54
+ if (/[!@#$%^&*+=?/\\~-]/.test(ch))
55
+ return TABLE_FONT_SIZE_PX * 0.48;
56
+ return TABLE_FONT_SIZE_PX * 0.56;
57
+ }
58
+ function estimateLineWidthPx(line) {
59
+ let width = 0;
60
+ for (const ch of Array.from(line)) {
61
+ width += charWidthPx(ch);
62
+ }
63
+ return width;
64
+ }
65
+ function estimateCellTextWidthPx(text) {
66
+ if (text.trim().length === 0)
67
+ return 0;
68
+ const lines = text.split(/\r?\n/);
69
+ const maxWidth = lines.reduce((acc, line) => Math.max(acc, estimateLineWidthPx(line)), 0);
70
+ return Math.ceil(maxWidth + TABLE_CELL_HORIZONTAL_PADDING_PX);
71
+ }
72
+ function isEmptyLikeNumericValue(value) {
73
+ const compact = value.trim().toLowerCase().replace(/\s+/g, '');
74
+ return compact.length === 0 || INVALID_NUMERIC_TOKENS.has(compact);
75
+ }
76
+ function isNumericLikeValue(value) {
77
+ let text = value.trim();
78
+ if (text.length === 0)
79
+ return false;
80
+ if (isEmptyLikeNumericValue(text))
81
+ return false;
82
+ text = text.replace(CURRENCY_PREFIX_RE, '');
83
+ if (text.length === 0)
84
+ return false;
85
+ const matched = text.match(NUMBER_WITH_OPTIONAL_UNIT_RE);
86
+ if (!matched)
87
+ return false;
88
+ const numberToken = matched[1];
89
+ if (!numberToken)
90
+ return false;
91
+ const numberPart = numberToken.replace(/,/g, '');
92
+ const parsed = Number(numberPart);
93
+ return Number.isFinite(parsed);
94
+ }
95
+ function classifyCellValue(value) {
96
+ if (isEmptyLikeNumericValue(value))
97
+ return 'empty';
98
+ return isNumericLikeValue(value) ? 'numeric' : 'text';
99
+ }
100
+ function isRightAlignedAlready(align) {
101
+ return align === 'right' || align === 3;
102
+ }
103
+ function normalizeDeclaredColumnAlign(raw, columnSize) {
104
+ if (!Array.isArray(raw) || columnSize <= 0) {
105
+ return Array.from({ length: Math.max(0, columnSize) }, () => undefined);
106
+ }
107
+ const normalized = Array.from({ length: columnSize }, () => undefined);
108
+ for (let col = 0; col < columnSize; col += 1) {
109
+ const value = raw[col];
110
+ if (value === 'left' || value === 'center' || value === 'right') {
111
+ normalized[col] = value;
112
+ }
113
+ else if (value === 1) {
114
+ normalized[col] = 'left';
115
+ }
116
+ else if (value === 2) {
117
+ normalized[col] = 'center';
118
+ }
119
+ else if (value === 3) {
120
+ normalized[col] = 'right';
121
+ }
122
+ }
123
+ return normalized;
124
+ }
125
+ function allocateTableColumnWidths(rows, richCells, hasHeaderRow, pageWidthPx = TABLE_DEFAULT_PAGE_WIDTH_PX) {
126
+ if (rows.length === 0)
127
+ return [];
128
+ const columnCount = rows[0]?.length ?? 0;
129
+ if (columnCount === 0)
130
+ return [];
131
+ const dataRowStart = hasHeaderRow ? 1 : 0;
132
+ const preferred = [];
133
+ const minWidths = [];
134
+ for (let col = 0; col < columnCount; col += 1) {
135
+ let numeric = true;
136
+ for (let row = dataRowStart; row < rows.length; row += 1) {
137
+ if (richCells[row]?.[col]) {
138
+ numeric = false;
139
+ break;
140
+ }
141
+ }
142
+ for (let row = dataRowStart; row < rows.length; row += 1) {
143
+ if (!numeric)
144
+ break;
145
+ const value = rows[row]?.[col] ?? '';
146
+ if (classifyCellValue(value) === 'text') {
147
+ numeric = false;
148
+ break;
149
+ }
150
+ }
151
+ const minWidth = numeric ? TABLE_NUMERIC_COLUMN_MIN_WIDTH_PX : TABLE_TEXT_COLUMN_MIN_WIDTH_PX;
152
+ const maxWidth = numeric ? TABLE_NUMERIC_COLUMN_MAX_WIDTH_PX : TABLE_TEXT_COLUMN_MAX_WIDTH_PX;
153
+ const widths = [];
154
+ for (let row = 0; row < rows.length; row += 1) {
155
+ widths.push(estimateCellTextWidthPx(rows[row]?.[col] ?? ''));
156
+ }
157
+ const sorted = [...widths].sort((a, b) => a - b);
158
+ const p95Idx = Math.max(0, Math.min(sorted.length - 1, Math.ceil(sorted.length * 0.95) - 1));
159
+ const p95 = sorted[p95Idx] ?? 0;
160
+ const preferredWidth = clamp(p95 > 0 ? p95 : minWidth, minWidth, maxWidth);
161
+ preferred.push(preferredWidth);
162
+ minWidths.push(minWidth);
163
+ }
164
+ const preferredSum = preferred.reduce((sum, item) => sum + item, 0);
165
+ if (preferredSum <= pageWidthPx) {
166
+ return preferred.map((item) => Math.round(item));
167
+ }
168
+ const minSum = minWidths.reduce((sum, item) => sum + item, 0);
169
+ if (minSum >= pageWidthPx) {
170
+ const ratio = pageWidthPx / minSum;
171
+ return minWidths.map((item) => Math.max(50, Math.round(item * ratio)));
172
+ }
173
+ const flex = preferred.map((item, idx) => Math.max(item - (minWidths[idx] ?? 0), 0));
174
+ const flexSum = flex.reduce((sum, item) => sum + item, 0);
175
+ const ratio = flexSum > 0 ? (pageWidthPx - minSum) / flexSum : 0;
176
+ return preferred.map((item, idx) => Math.round((minWidths[idx] ?? 0) + (item - (minWidths[idx] ?? 0)) * ratio));
177
+ }
178
+ function applyNumericColumnRightAlignment(rows, textBlocks, richCells, hasHeaderRow) {
179
+ const rowCount = rows.length;
180
+ if (rowCount === 0)
181
+ return;
182
+ const columnCount = rows[0]?.length ?? 0;
183
+ if (columnCount === 0)
184
+ return;
185
+ const dataRowStart = hasHeaderRow ? 1 : 0;
186
+ if (dataRowStart >= rowCount)
187
+ return;
188
+ for (let col = 0; col < columnCount; col += 1) {
189
+ let numericColumn = true;
190
+ for (let row = dataRowStart; row < rowCount; row += 1) {
191
+ if (richCells[row]?.[col]) {
192
+ numericColumn = false;
193
+ break;
194
+ }
195
+ if (classifyCellValue(rows[row]?.[col] ?? '') === 'text') {
196
+ numericColumn = false;
197
+ break;
198
+ }
199
+ }
200
+ if (!numericColumn)
201
+ continue;
202
+ for (let row = dataRowStart; row < rowCount; row += 1) {
203
+ const textBlock = textBlocks[row]?.[col];
204
+ if (!textBlock)
205
+ continue;
206
+ if (isRightAlignedAlready(textBlock.payload.style.align))
207
+ continue;
208
+ textBlock.payload.style.align = 'right';
209
+ }
210
+ }
211
+ }
212
+ function applyDefaultHeaderCenterAlignment(textBlocks, hasHeaderRow) {
213
+ if (!hasHeaderRow)
214
+ return;
215
+ const headerRow = textBlocks[0];
216
+ if (!headerRow || headerRow.length === 0)
217
+ return;
218
+ for (const textBlock of headerRow) {
219
+ if (!textBlock)
220
+ continue;
221
+ textBlock.payload.style.align = 'center';
222
+ }
223
+ }
224
+ function applyDeclaredColumnAlignment(textBlocks, columnAlign) {
225
+ if (columnAlign.length === 0)
226
+ return;
227
+ for (let row = 0; row < textBlocks.length; row += 1) {
228
+ for (let col = 0; col < columnAlign.length; col += 1) {
229
+ const align = columnAlign[col];
230
+ if (align === undefined)
231
+ continue;
232
+ const textBlock = textBlocks[row]?.[col];
233
+ if (!textBlock)
234
+ continue;
235
+ textBlock.payload.style.align = align;
236
+ }
237
+ }
238
+ }
239
+ export function ensureLastBlockBttIds(last) {
240
+ for (const block of Object.values(last.blocks)) {
241
+ block.bttId = block.id;
242
+ }
243
+ }
244
+ export function collectMermaidPatches(last) {
245
+ const patches = new Map();
246
+ for (const block of Object.values(last.blocks)) {
247
+ if (block.type !== 'code')
248
+ continue;
249
+ if (!isMermaidLanguage(block.payload.style.language))
250
+ continue;
251
+ const code = extractTextFromInlines(block.payload.inlines);
252
+ patches.set(block.id, { code });
253
+ }
254
+ return patches;
255
+ }
256
+ export function applyTableColumnWidthHeuristics(last) {
257
+ for (const block of Object.values(last.blocks)) {
258
+ if (block.type !== 'table')
259
+ continue;
260
+ const rowSize = Math.max(1, block.payload.rowSize ?? 0);
261
+ const columnSize = Math.max(1, block.payload.columnSize ?? 0);
262
+ if (rowSize <= 0 || columnSize <= 0)
263
+ continue;
264
+ const cells = block.payload.cells && block.payload.cells.length > 0 ? block.payload.cells : block.children;
265
+ if (!cells || cells.length === 0)
266
+ continue;
267
+ const rows = [];
268
+ const textBlocks = [];
269
+ const richCells = [];
270
+ for (let r = 0; r < rowSize; r += 1) {
271
+ const row = [];
272
+ const rowTextBlocks = [];
273
+ const rowRichCells = [];
274
+ for (let c = 0; c < columnSize; c += 1) {
275
+ const idx = r * columnSize + c;
276
+ const cellId = cells[idx];
277
+ if (!cellId) {
278
+ row.push('');
279
+ rowTextBlocks.push(null);
280
+ rowRichCells.push(false);
281
+ continue;
282
+ }
283
+ const cell = last.blocks[cellId];
284
+ if (!cell || cell.type !== 'table_cell') {
285
+ row.push('');
286
+ rowTextBlocks.push(null);
287
+ rowRichCells.push(false);
288
+ continue;
289
+ }
290
+ const childBlocks = cell.children.map((id) => last.blocks[id]).filter(Boolean);
291
+ const textChild = childBlocks.find((candidate) => {
292
+ return Boolean(candidate && isTextualBlock(candidate) && candidate.type === 'text');
293
+ }) ?? null;
294
+ const hasRichContent = childBlocks.length > 1 ||
295
+ childBlocks.some((candidate) => {
296
+ return !candidate || !isTextualBlock(candidate) || candidate.type !== 'text';
297
+ });
298
+ if (!textChild) {
299
+ row.push('');
300
+ rowTextBlocks.push(null);
301
+ rowRichCells.push(hasRichContent);
302
+ continue;
303
+ }
304
+ row.push(toPlainTextFromInlineList(textChild.payload.inlines).replace(/\s+/g, ' ').trim());
305
+ rowTextBlocks.push(textChild);
306
+ rowRichCells.push(hasRichContent);
307
+ }
308
+ rows.push(row);
309
+ textBlocks.push(rowTextBlocks);
310
+ richCells.push(rowRichCells);
311
+ }
312
+ const hasHeaderRow = block.payload.headerRow ?? rowSize > 1;
313
+ const declaredColumnAlign = normalizeDeclaredColumnAlign(block.payload.columnAlign, columnSize);
314
+ const hasDeclaredColumnAlign = declaredColumnAlign.some((item) => item !== undefined);
315
+ if (hasDeclaredColumnAlign) {
316
+ applyDeclaredColumnAlignment(textBlocks, declaredColumnAlign);
317
+ }
318
+ else {
319
+ applyDefaultHeaderCenterAlignment(textBlocks, hasHeaderRow);
320
+ applyNumericColumnRightAlignment(rows, textBlocks, richCells, hasHeaderRow);
321
+ }
322
+ const widths = allocateTableColumnWidths(rows, richCells, hasHeaderRow);
323
+ if (widths.length > 0) {
324
+ block.payload.columnWidth = widths;
325
+ }
326
+ }
327
+ }
@@ -0,0 +1,228 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { convertLASTToBTT } from '../interop/index.js';
4
+ import { clearDocumentContent, normalizeDocumentId } from '../lark/docx/ops.js';
5
+ import { renderBTTToDocument, } from '../lark/docx/render-btt.js';
6
+ import { hastToLAST, markdownToHast, prepareMarkdownBeforePublish } from '../pipeline/index.js';
7
+ import { applySingleH1TitleRule, buildTitleForMarkdown } from '../commands/publish-md/title-policy.js';
8
+ import { applyStandaloneAttachmentTransforms } from './asset-adapter.js';
9
+ import { patchBTTForMermaidAndAssets } from './btt-patch.js';
10
+ import { buildPipelineDocumentId } from './ids.js';
11
+ import { applyTableColumnWidthHeuristics, collectMermaidPatches, ensureLastBlockBttIds } from './last-normalize.js';
12
+ import { buildPipelineStagePaths, writeBttStage, writeHastStage, writeLastStage, writePrepareStage, writePublishStageArtifact, writeSourceStage, } from './stage-cache.js';
13
+ function stringifyConsoleArg(arg) {
14
+ if (typeof arg === 'string')
15
+ return arg;
16
+ try {
17
+ return JSON.stringify(arg);
18
+ }
19
+ catch {
20
+ return String(arg);
21
+ }
22
+ }
23
+ async function withCapturedRetryLogs(run) {
24
+ const originalWarn = console.warn;
25
+ const retryLogs = [];
26
+ let result;
27
+ let caughtError;
28
+ console.warn = (...args) => {
29
+ const line = args.map((arg) => stringifyConsoleArg(arg)).join(' ');
30
+ if (line.includes('[retry]')) {
31
+ retryLogs.push(line);
32
+ }
33
+ originalWarn(...args);
34
+ };
35
+ try {
36
+ result = await run();
37
+ }
38
+ catch (error) {
39
+ caughtError = error;
40
+ }
41
+ finally {
42
+ console.warn = originalWarn;
43
+ }
44
+ if (caughtError !== undefined) {
45
+ return { retryLogs, error: caughtError };
46
+ }
47
+ if (result !== undefined) {
48
+ return { result, retryLogs };
49
+ }
50
+ return { retryLogs };
51
+ }
52
+ function inferFailedBlocksFromError(error) {
53
+ const message = error instanceof Error ? error.message : String(error);
54
+ const sourceMatch = /source=([^\s]+)\s+type=(\d+)/.exec(message);
55
+ if (!sourceMatch || !sourceMatch[1] || !sourceMatch[2]) {
56
+ return [];
57
+ }
58
+ return [
59
+ {
60
+ sourceBlockId: sourceMatch[1],
61
+ blockType: Number.parseInt(sourceMatch[2], 10),
62
+ parentBlockId: '',
63
+ error: message,
64
+ },
65
+ ];
66
+ }
67
+ export async function processSingleMarkdownFile(params) {
68
+ const { runtime, inputSet, options, markdownPath, index, resolveTargetDocumentId } = params;
69
+ const stagePaths = buildPipelineStagePaths(runtime.pipelineCacheRootDir, markdownPath);
70
+ const startedAt = new Date().toISOString();
71
+ const sourceMarkdown = await readFile(markdownPath, 'utf8');
72
+ let markdown = sourceMarkdown;
73
+ if (runtime.markdownPreset) {
74
+ markdown = await runtime.markdownPreset.transform(markdown, {
75
+ inputPath: markdownPath,
76
+ index,
77
+ total: inputSet.markdownFiles.length,
78
+ env: runtime.env,
79
+ log: (...args) => console.log(`[preset ${index + 1}/${inputSet.markdownFiles.length}]`, ...args.map((arg) => String(arg))),
80
+ });
81
+ }
82
+ await writeSourceStage(stagePaths, sourceMarkdown, markdown, {
83
+ sourcePath: path.resolve(markdownPath),
84
+ preset: runtime.markdownPreset ? runtime.markdownPreset.displayPath : null,
85
+ startedAt,
86
+ });
87
+ const prepareResult = await prepareMarkdownBeforePublish(markdownPath, markdown, {
88
+ ...runtime.prepareConfig,
89
+ prepareDir: stagePaths.prepareDir,
90
+ });
91
+ markdown = prepareResult.preparedContent;
92
+ await writePrepareStage(stagePaths, markdown, prepareResult);
93
+ console.log(`[prepare ${index + 1}/${inputSet.markdownFiles.length}] rewritten=${prepareResult.rewrittenCount} downloaded=${prepareResult.downloadedCount} failed=${prepareResult.failedCount} log=${prepareResult.logFilePath}`);
94
+ const hast = await markdownToHast(markdown);
95
+ await writeHastStage(stagePaths, hast);
96
+ const h1RuleResult = options.title ? {} : applySingleH1TitleRule(hast);
97
+ const title = buildTitleForMarkdown(markdownPath, inputSet, options.title, h1RuleResult.derivedTitle, {
98
+ datePrefix: runtime.titleDatePrefix,
99
+ });
100
+ const documentKey = buildPipelineDocumentId(markdownPath);
101
+ const last = hastToLAST(hast, {
102
+ documentId: documentKey,
103
+ mode: 'fragment',
104
+ });
105
+ await writeLastStage(stagePaths, last);
106
+ ensureLastBlockBttIds(last);
107
+ const baseDir = path.dirname(markdownPath);
108
+ const localAssetByBlockId = applyStandaloneAttachmentTransforms(last, baseDir);
109
+ applyTableColumnWidthHeuristics(last);
110
+ const mermaidByBlockId = collectMermaidPatches(last);
111
+ const btt = convertLASTToBTT(last, {
112
+ documentId: documentKey,
113
+ });
114
+ patchBTTForMermaidAndAssets(btt, mermaidByBlockId, localAssetByBlockId, {
115
+ mermaidRender: runtime.mermaidRenderConfig,
116
+ });
117
+ await writeBttStage(stagePaths, btt, {
118
+ mermaidPatchCount: mermaidByBlockId.size,
119
+ mermaidTarget: runtime.mermaidRenderConfig.target,
120
+ mermaidBoard: runtime.mermaidRenderConfig.board,
121
+ localAssetCount: localAssetByBlockId.size,
122
+ });
123
+ if (options.dryRun) {
124
+ const dryRunArtifact = {
125
+ status: 'dry-run',
126
+ sourcePath: path.resolve(markdownPath),
127
+ title,
128
+ documentId: null,
129
+ rootBlockId: null,
130
+ createdAt: startedAt,
131
+ finishedAt: new Date().toISOString(),
132
+ failedBlocks: [],
133
+ retryLogs: [],
134
+ mediaTokenMappings: [],
135
+ };
136
+ await writePublishStageArtifact(stagePaths, dryRunArtifact);
137
+ console.log(`[dry-run ${index + 1}/${inputSet.markdownFiles.length}] input: ${markdownPath}`);
138
+ console.log(`[dry-run ${index + 1}/${inputSet.markdownFiles.length}] title: ${title}`);
139
+ console.log(`[dry-run ${index + 1}/${inputSet.markdownFiles.length}] blocks: ${Object.keys(last.blocks).length}`);
140
+ console.log(`[dry-run ${index + 1}/${inputSet.markdownFiles.length}] btt blocks: ${Object.keys(btt.flatBlocks).length}`);
141
+ console.log(`[dry-run ${index + 1}/${inputSet.markdownFiles.length}] mermaid patches: ${mermaidByBlockId.size}`);
142
+ console.log(`[dry-run ${index + 1}/${inputSet.markdownFiles.length}] mermaid target: ${runtime.mermaidRenderConfig.target}`);
143
+ console.log(`[dry-run ${index + 1}/${inputSet.markdownFiles.length}] local assets: ${localAssetByBlockId.size}`);
144
+ return {
145
+ stagePaths,
146
+ title,
147
+ documentId: null,
148
+ status: 'dry-run',
149
+ };
150
+ }
151
+ let documentId = options.documentId ? normalizeDocumentId(options.documentId) : '';
152
+ let rootBlockId = null;
153
+ let failedBlocks = [];
154
+ let mediaTokenMappings = [];
155
+ let retryLogs = [];
156
+ try {
157
+ const captured = await withCapturedRetryLogs(async () => {
158
+ if (!documentId) {
159
+ if (!resolveTargetDocumentId) {
160
+ throw new Error('Failed to resolve target document id.');
161
+ }
162
+ documentId = await resolveTargetDocumentId(title);
163
+ }
164
+ if (!documentId) {
165
+ throw new Error('Failed to resolve target document id.');
166
+ }
167
+ rootBlockId = await clearDocumentContent(runtime.sdkClient, documentId, runtime.authOptions, runtime.docxLimiter);
168
+ const renderReport = await renderBTTToDocument(runtime.sdkClient, documentId, rootBlockId, btt.root, runtime.authOptions, runtime.docxLimiter, runtime.mediaLimiter, {
169
+ continueOnError: true,
170
+ mermaidByBlockId,
171
+ mermaidRender: runtime.mermaidRenderConfig,
172
+ });
173
+ failedBlocks = renderReport.failedNodes;
174
+ mediaTokenMappings = renderReport.mediaTokenMappings;
175
+ if (renderReport.failedNodes.length > 0) {
176
+ const first = renderReport.failedNodes[0];
177
+ throw new Error(`renderBTTToDocument has ${renderReport.failedNodes.length} failed block(s), first source=${first.sourceBlockId} type=${first.blockType}`);
178
+ }
179
+ });
180
+ retryLogs = captured.retryLogs;
181
+ if (captured.error !== undefined) {
182
+ throw captured.error;
183
+ }
184
+ }
185
+ catch (error) {
186
+ if (failedBlocks.length === 0) {
187
+ failedBlocks = inferFailedBlocksFromError(error);
188
+ }
189
+ const failedArtifact = {
190
+ status: 'failed',
191
+ sourcePath: path.resolve(markdownPath),
192
+ title,
193
+ documentId: documentId || null,
194
+ rootBlockId,
195
+ createdAt: startedAt,
196
+ finishedAt: new Date().toISOString(),
197
+ failedBlocks,
198
+ retryLogs,
199
+ mediaTokenMappings,
200
+ error: error instanceof Error ? error.message : String(error),
201
+ };
202
+ await writePublishStageArtifact(stagePaths, failedArtifact);
203
+ throw error;
204
+ }
205
+ const successArtifact = {
206
+ status: 'published',
207
+ sourcePath: path.resolve(markdownPath),
208
+ title,
209
+ documentId,
210
+ rootBlockId,
211
+ createdAt: startedAt,
212
+ finishedAt: new Date().toISOString(),
213
+ failedBlocks,
214
+ retryLogs,
215
+ mediaTokenMappings,
216
+ };
217
+ await writePublishStageArtifact(stagePaths, successArtifact);
218
+ console.log(`[${index + 1}/${inputSet.markdownFiles.length}] Published markdown: ${markdownPath}`);
219
+ console.log(`[${index + 1}/${inputSet.markdownFiles.length}] Document ID: ${documentId}`);
220
+ console.log(`[${index + 1}/${inputSet.markdownFiles.length}] Title: ${title}`);
221
+ console.log(`[${index + 1}/${inputSet.markdownFiles.length}] stage-cache: ${stagePaths.rootDir} (00-source..05-publish)`);
222
+ return {
223
+ stagePaths,
224
+ title,
225
+ documentId,
226
+ status: 'published',
227
+ };
228
+ }
@@ -0,0 +1,133 @@
1
+ import path from 'node:path';
2
+ import * as lark from '@larksuiteoapi/node-sdk';
3
+ import { DEFAULT_MERMAID_BOARD_SYNTAX_TYPE, normalizeMermaidRenderTarget, } from '../commands/publish-md/mermaid-render.js';
4
+ import { createLarkClientConfigFromEnv } from '../lark/index.js';
5
+ import { RateLimiter } from '../shared/rate-limiter.js';
6
+ function getSdkDomain(baseUrl) {
7
+ const lower = baseUrl.toLowerCase();
8
+ if (lower.includes('open.larksuite.com'))
9
+ return lark.Domain.Lark;
10
+ if (lower.includes('open.feishu.cn'))
11
+ return lark.Domain.Feishu;
12
+ return baseUrl;
13
+ }
14
+ function toPositiveInt(value) {
15
+ if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0)
16
+ return undefined;
17
+ return Math.round(value);
18
+ }
19
+ function toNonNegativeInt(value) {
20
+ if (typeof value !== 'number' || !Number.isFinite(value) || value < 0)
21
+ return undefined;
22
+ return Math.round(value);
23
+ }
24
+ function parseOptionalEnvNonNegativeInt(raw) {
25
+ if (raw === undefined)
26
+ return undefined;
27
+ const trimmed = raw.trim();
28
+ if (!trimmed)
29
+ return undefined;
30
+ return toNonNegativeInt(Number(trimmed));
31
+ }
32
+ function parseBoolean(value, fallback) {
33
+ if (value === undefined || value.trim().length === 0)
34
+ return fallback;
35
+ const normalized = value.trim().toLowerCase();
36
+ if (['1', 'true', 'yes', 'on'].includes(normalized))
37
+ return true;
38
+ if (['0', 'false', 'no', 'off'].includes(normalized))
39
+ return false;
40
+ return fallback;
41
+ }
42
+ function normalizeOptionalPath(value) {
43
+ if (!value)
44
+ return undefined;
45
+ const trimmed = value.trim();
46
+ if (!trimmed)
47
+ return undefined;
48
+ if (trimmed === '~')
49
+ return process.env.HOME;
50
+ if (trimmed.startsWith('~/')) {
51
+ const home = process.env.HOME;
52
+ return home ? path.join(home, trimmed.slice(2)) : trimmed;
53
+ }
54
+ return trimmed;
55
+ }
56
+ export function buildPublishRuntime(options, env, markdownPreset) {
57
+ const config = createLarkClientConfigFromEnv(env);
58
+ const sdkClient = new lark.Client({
59
+ appId: config.appId,
60
+ appSecret: config.appSecret,
61
+ domain: getSdkDomain(config.baseUrl),
62
+ loggerLevel: lark.LoggerLevel.warn,
63
+ disableTokenCache: false,
64
+ });
65
+ const authOptions = config.tokenType === 'user' ? lark.withUserAccessToken(config.userAccessToken) : undefined;
66
+ const docxLimiterIntervalMs = toPositiveInt(Number((env.LARK_DOCX_MIN_INTERVAL_MS ?? '').trim())) ?? 260;
67
+ const mediaLimiterIntervalMs = toPositiveInt(Number((env.LARK_MEDIA_MIN_INTERVAL_MS ?? '').trim())) ?? 450;
68
+ const publishCooldownMs = toPositiveInt(Number((env.LARK_PUBLISH_COOLDOWN_MS ?? '').trim())) ?? 600;
69
+ const downloadRemoteImages = options.downloadRemoteImages ?? parseBoolean(env.DOWNLOAD_REMOTE_IMAGES, true);
70
+ const titleDatePrefix = options.titleDatePrefix ?? parseBoolean(env.LARK_TITLE_DATE_PREFIX, true);
71
+ const ytDlpPath = normalizeOptionalPath(options.ytDlpPath ?? env.YT_DLP_PATH);
72
+ const ytDlpCookiesPath = normalizeOptionalPath(options.ytDlpCookiesPath ?? env.YT_DLP_COOKIES_PATH);
73
+ const pipelineCacheRootDir = path.resolve(options.pipelineCacheDir ?? env.PIPELINE_CACHE_DIR ?? './out/pipeline-cache');
74
+ const prepareTimeoutMs = toPositiveInt(Number((env.PREPARE_TIMEOUT_MS ?? '').trim())) ?? 15_000;
75
+ const prepareMaxRetries = toNonNegativeInt(Number((env.PREPARE_MAX_RETRIES ?? '').trim())) ?? 3;
76
+ const prepareBackoffBaseMs = toPositiveInt(Number((env.PREPARE_BACKOFF_BASE_MS ?? '').trim())) ?? 500;
77
+ const prepareBackoffMaxMs = toPositiveInt(Number((env.PREPARE_BACKOFF_MAX_MS ?? '').trim())) ?? 5_000;
78
+ const prepareBackoffJitterRatio = Number((env.PREPARE_BACKOFF_JITTER_RATIO ?? '').trim());
79
+ const prepareJitter = Number.isFinite(prepareBackoffJitterRatio) && prepareBackoffJitterRatio >= 0 ? prepareBackoffJitterRatio : 0.2;
80
+ const ytDlpTimeoutMs = toPositiveInt(Number((env.YT_DLP_TIMEOUT_MS ?? '').trim())) ?? 600_000;
81
+ const mermaidTarget = normalizeMermaidRenderTarget(options.mermaidTarget ?? env.LARK_MERMAID_TARGET);
82
+ const mermaidBoardSyntaxType = options.mermaidBoardSyntaxType ??
83
+ parseOptionalEnvNonNegativeInt(env.LARK_MERMAID_BOARD_SYNTAX_TYPE) ??
84
+ DEFAULT_MERMAID_BOARD_SYNTAX_TYPE;
85
+ const mermaidBoardStyleType = options.mermaidBoardStyleType ?? parseOptionalEnvNonNegativeInt(env.LARK_MERMAID_BOARD_STYLE_TYPE);
86
+ const mermaidBoardDiagramType = options.mermaidBoardDiagramType ?? parseOptionalEnvNonNegativeInt(env.LARK_MERMAID_BOARD_DIAGRAM_TYPE);
87
+ const mermaidRenderConfig = {
88
+ target: mermaidTarget,
89
+ board: {
90
+ syntaxType: mermaidBoardSyntaxType,
91
+ ...(mermaidBoardStyleType === undefined ? {} : { styleType: mermaidBoardStyleType }),
92
+ ...(mermaidBoardDiagramType === undefined ? {} : { diagramType: mermaidBoardDiagramType }),
93
+ },
94
+ };
95
+ return {
96
+ env,
97
+ markdownPreset,
98
+ authOptions,
99
+ sdkClient,
100
+ docxLimiter: new RateLimiter(docxLimiterIntervalMs),
101
+ mediaLimiter: new RateLimiter(mediaLimiterIntervalMs),
102
+ docxLimiterIntervalMs,
103
+ mediaLimiterIntervalMs,
104
+ publishCooldownMs,
105
+ pipelineCacheRootDir,
106
+ titleDatePrefix,
107
+ downloadRemoteImages,
108
+ ...(ytDlpPath ? { ytDlpPath } : {}),
109
+ mermaidRenderConfig,
110
+ prepareConfig: {
111
+ enabled: downloadRemoteImages,
112
+ timeoutMs: prepareTimeoutMs,
113
+ maxRetries: prepareMaxRetries,
114
+ backoffBaseMs: prepareBackoffBaseMs,
115
+ backoffMaxMs: prepareBackoffMaxMs,
116
+ backoffJitterRatio: prepareJitter,
117
+ ytDlpTimeoutMs,
118
+ ...(ytDlpPath ? { ytDlpPath } : {}),
119
+ ...(ytDlpCookiesPath ? { ytDlpCookiesPath } : {}),
120
+ },
121
+ };
122
+ }
123
+ export function logPublishRuntimeSummary(runtime, inputCount, inputMode) {
124
+ console.log(`Resolved markdown files: ${inputCount} (${inputMode === 'single' ? 'single' : 'directory'})`);
125
+ console.log(`Rate limits: docx=${runtime.docxLimiterIntervalMs}ms media=${runtime.mediaLimiterIntervalMs}ms cooldown=${runtime.publishCooldownMs}ms`);
126
+ console.log(`Prepare: download_remote_images=${String(runtime.downloadRemoteImages)} yt_dlp=${runtime.ytDlpPath ? 'enabled' : 'disabled'}`);
127
+ console.log(runtime.mermaidRenderConfig.target === 'board'
128
+ ? `Mermaid: target=board syntax_type=${String(runtime.mermaidRenderConfig.board.syntaxType)} style_type=${String(runtime.mermaidRenderConfig.board.styleType ?? '(default)')} diagram_type=${String(runtime.mermaidRenderConfig.board.diagramType ?? '(default)')}`
129
+ : 'Mermaid: target=text-drawing');
130
+ if (runtime.markdownPreset) {
131
+ console.log(`Preset: ${runtime.markdownPreset.displayPath}`);
132
+ }
133
+ }