yuque-cookie-plugin 0.1.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.
@@ -0,0 +1,388 @@
1
+ import { mkdir, writeFile } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { writeReport } from "./reports.js";
4
+ import { buildResourceManifest, buildTocMaps, fixInlineCode, fixLatex, fixMarkdownImage, fixPath, formatDate, getMarkdownImageList, handleMarkdownFooter, localizeAttachments, localizeFileCards, localizeImages, localizeMedia, parseSheet, readProgress, shouldSkipByIncremental, writeProgress, writeSummary } from "./download-utils.js";
5
+ export async function downloadBook(client, url, options) {
6
+ const book = await client.getBookInfo(url);
7
+ if (!book.tocList.length)
8
+ throw new Error('No found toc list');
9
+ const bookPath = path.resolve(options.distDir, book.bookName ? fixPath(book.bookName) : String(book.bookId));
10
+ await mkdir(bookPath, { recursive: true });
11
+ const previous = new Map((await readProgress(bookPath)).map((item) => [item.toc.uuid, item]));
12
+ const tocMap = buildTocMaps(book.tocList);
13
+ const progressItems = [];
14
+ const articleUrlPrefix = url.replace(new RegExp(`(.*?/${book.bookSlug}).*`), '$1');
15
+ const failures = [];
16
+ const warnings = [];
17
+ let downloaded = 0;
18
+ let skipped = 0;
19
+ let handledDocs = 0;
20
+ const docTotal = book.tocList.filter((item) => item.type?.toLowerCase() !== 'title' && item.type?.toLowerCase() !== 'link' && item.url).length;
21
+ logProgress(options, `Downloading book "${book.bookName || book.bookId}" (${docTotal} docs) -> ${bookPath}`);
22
+ for (const item of book.tocList) {
23
+ const progressItem = buildProgressItem(item, tocMap);
24
+ if (!progressItem)
25
+ continue;
26
+ progressItems.push(progressItem);
27
+ const itemType = item.type.toLowerCase();
28
+ if (itemType === 'title' && !item.url) {
29
+ await mkdir(path.resolve(bookPath, progressItem.path), { recursive: true });
30
+ continue;
31
+ }
32
+ if (itemType === 'link') {
33
+ warnings.push({
34
+ type: 'link',
35
+ title: item.title,
36
+ url: item.url,
37
+ error: 'TOC link node is not a Yuque document and was not downloaded.'
38
+ });
39
+ continue;
40
+ }
41
+ if (!item.url)
42
+ continue;
43
+ try {
44
+ const articleUrl = `${articleUrlPrefix}/${item.url}`;
45
+ logProgress(options, `[${handledDocs + 1}/${docTotal}] ${item.title}`);
46
+ const articleResult = await downloadArticleForTest(client, {
47
+ bookId: book.bookId,
48
+ itemUrl: item.url,
49
+ savePath: path.resolve(bookPath, progressItem.savePath || ''),
50
+ saveFilePath: path.resolve(bookPath, progressItem.path),
51
+ uuid: item.uuid,
52
+ articleTitle: item.title,
53
+ articleUrl,
54
+ host: book.host,
55
+ imageServiceDomains: book.imageServiceDomains,
56
+ options,
57
+ progressItem,
58
+ previousProgressItem: previous.get(item.uuid),
59
+ warnings
60
+ });
61
+ if (articleResult.skipped) {
62
+ skipped += 1;
63
+ logProgress(options, ` skipped unchanged: ${progressItem.path}`);
64
+ }
65
+ else {
66
+ downloaded += 1;
67
+ logProgress(options, ` saved: ${progressItem.path}`);
68
+ }
69
+ }
70
+ catch (error) {
71
+ logProgress(options, ` failed: ${item.title}`);
72
+ failures.push({
73
+ title: item.title,
74
+ url: item.url,
75
+ retry_url: `${articleUrlPrefix}/${item.url}`,
76
+ error: error instanceof Error ? error.message : String(error)
77
+ });
78
+ }
79
+ finally {
80
+ handledDocs += 1;
81
+ }
82
+ await writeProgress(bookPath, progressItems);
83
+ }
84
+ await writeSummary({
85
+ bookPath,
86
+ bookName: book.bookName,
87
+ bookDesc: book.bookDesc,
88
+ progressItems
89
+ });
90
+ await writeProgress(bookPath, progressItems);
91
+ const resourceManifest = await buildResourceManifest(bookPath);
92
+ const summary = {
93
+ ok: failures.length === 0,
94
+ book_path: bookPath,
95
+ total: progressItems.length,
96
+ docs: progressItems.filter((item) => {
97
+ const type = item.toc.type?.toLowerCase();
98
+ return type !== 'title' && type !== 'link' && item.path.endsWith('.md');
99
+ }).length,
100
+ downloaded,
101
+ skipped,
102
+ incremental: options.incremental,
103
+ options,
104
+ resources: resourceManifest,
105
+ warnings,
106
+ warning_summary: buildWarningSummary(warnings),
107
+ failures,
108
+ retry: buildRetryPlan('download-doc', failures.map((item) => item.retry_url || item.url).filter(isString), options)
109
+ };
110
+ const report = await writeReport('download-book', summary);
111
+ return {
112
+ ...summary,
113
+ report
114
+ };
115
+ }
116
+ export async function downloadDocs(client, urls, options) {
117
+ const distPath = path.resolve(options.distDir);
118
+ await mkdir(distPath, { recursive: true });
119
+ const failures = [];
120
+ const warnings = [];
121
+ const files = [];
122
+ let downloaded = 0;
123
+ logProgress(options, `Downloading ${urls.length} doc(s) -> ${distPath}`);
124
+ for (const [index, url] of urls.entries()) {
125
+ try {
126
+ const doc = await client.getDocInfoFromUrl(url);
127
+ if (!doc.docSlug || !doc.bookId)
128
+ throw new Error('Failed to get document info from URL');
129
+ const fileName = fixPath(doc.docTitle || doc.docSlug);
130
+ const saveFilePath = path.resolve(distPath, `${fileName}.md`);
131
+ logProgress(options, `[${index + 1}/${urls.length}] ${doc.docTitle || doc.docSlug}`);
132
+ const progressItem = {
133
+ path: `${fileName}.md`,
134
+ savePath: '',
135
+ pathTitleList: [fileName],
136
+ pathIdList: [String(doc.docId || doc.docSlug)],
137
+ toc: {
138
+ type: 'DOC',
139
+ title: doc.docTitle || doc.docSlug,
140
+ uuid: String(doc.docId || doc.docSlug),
141
+ url: doc.docSlug,
142
+ parent_uuid: '',
143
+ child_uuid: ''
144
+ }
145
+ };
146
+ await downloadArticleForTest(client, {
147
+ bookId: doc.bookId,
148
+ itemUrl: doc.docSlug,
149
+ savePath: distPath,
150
+ saveFilePath,
151
+ uuid: String(doc.docId || doc.docSlug),
152
+ articleTitle: doc.docTitle || doc.docSlug,
153
+ articleUrl: url,
154
+ host: doc.host,
155
+ imageServiceDomains: doc.imageServiceDomains,
156
+ options,
157
+ progressItem,
158
+ warnings
159
+ });
160
+ downloaded += 1;
161
+ files.push(saveFilePath);
162
+ logProgress(options, ` saved: ${saveFilePath}`);
163
+ }
164
+ catch (error) {
165
+ logProgress(options, ` failed: ${url}`);
166
+ failures.push({
167
+ url,
168
+ error: error instanceof Error ? error.message : String(error)
169
+ });
170
+ }
171
+ }
172
+ const resourceManifest = await buildResourceManifest(distPath);
173
+ const summary = {
174
+ ok: failures.length === 0,
175
+ dist_path: distPath,
176
+ files,
177
+ downloaded,
178
+ options,
179
+ resources: resourceManifest,
180
+ warnings,
181
+ warning_summary: buildWarningSummary(warnings),
182
+ failures,
183
+ retry: buildRetryPlan('download-doc', failures.map((item) => item.url).filter(isString), options)
184
+ };
185
+ const report = await writeReport('download-doc', summary);
186
+ return {
187
+ ...summary,
188
+ report
189
+ };
190
+ }
191
+ export async function downloadArticleForTest(client, params) {
192
+ const { response, apiUrl, httpStatus } = await client.getDocMarkdownData({
193
+ articleUrl: params.itemUrl,
194
+ bookId: params.bookId,
195
+ host: params.host,
196
+ isMarkdown: true
197
+ });
198
+ const data = response?.data || {};
199
+ params.progressItem.createAt = data.created_at || '';
200
+ params.progressItem.contentUpdatedAt = data.content_updated_at || '';
201
+ params.progressItem.publishedAt = data.published_at || '';
202
+ params.progressItem.firstPublishedAt = data.first_published_at || '';
203
+ if (shouldSkipByIncremental(params.progressItem, params.previousProgressItem, params.options.incremental)) {
204
+ return { skipped: true };
205
+ }
206
+ let markdown = '';
207
+ const type = String(data.type || '').toLowerCase();
208
+ if (type === 'sheet') {
209
+ const raw = await client.getDocMarkdownData({
210
+ articleUrl: params.itemUrl,
211
+ bookId: params.bookId,
212
+ host: params.host,
213
+ isMarkdown: false
214
+ });
215
+ const rawContent = raw.response?.data?.content;
216
+ const content = rawContent ? JSON.parse(rawContent) : {};
217
+ markdown = content?.sheet ? parseSheet(content.sheet) : '';
218
+ }
219
+ else if (type === 'board' || type === 'table') {
220
+ markdown = `[Unsupported Yuque document type: ${type}]\n`;
221
+ }
222
+ else if (typeof data.sourcecode === 'string') {
223
+ markdown = fixLatex(data.sourcecode);
224
+ }
225
+ else {
226
+ throw new Error(`download article Error: ${apiUrl}, http status ${httpStatus}`);
227
+ }
228
+ const raw = await client.getDocMarkdownData({
229
+ articleUrl: params.itemUrl,
230
+ bookId: params.bookId,
231
+ host: params.host,
232
+ isMarkdown: false
233
+ });
234
+ const htmlData = raw.response?.data?.content || '';
235
+ const imgList = getMarkdownImageList(markdown);
236
+ if (imgList.length && !params.options.ignoreImg) {
237
+ markdown = fixMarkdownImage(imgList, markdown, htmlData);
238
+ }
239
+ if (params.options.ignoreAttachments !== true) {
240
+ markdown = await localizeAttachments(markdown, {
241
+ savePath: params.savePath,
242
+ attachmentsDir: `attachments/${fixPath(params.uuid)}`,
243
+ headers: client.htmlHeaders(),
244
+ ignoreAttachments: params.options.ignoreAttachments,
245
+ warnings: params.warnings,
246
+ title: params.articleTitle
247
+ });
248
+ }
249
+ if (params.options.ignoreAttachments !== true) {
250
+ markdown = await localizeFileCards(markdown, htmlData, {
251
+ savePath: params.savePath,
252
+ attachmentsDir: `attachments/${fixPath(params.uuid)}`,
253
+ headers: client.htmlHeaders(),
254
+ ignoreAttachments: params.options.ignoreAttachments,
255
+ warnings: params.warnings,
256
+ title: params.articleTitle
257
+ });
258
+ }
259
+ if (params.options.ignoreAttachments !== true) {
260
+ markdown = await localizeMedia(markdown, htmlData, {
261
+ savePath: params.savePath,
262
+ attachmentsDir: `attachments/${fixPath(params.uuid)}`,
263
+ headers: client.htmlHeaders(),
264
+ ignoreAttachments: params.options.ignoreAttachments,
265
+ getVideoInfo: (videoId) => client.getVideoInfo(videoId),
266
+ warnings: params.warnings,
267
+ title: params.articleTitle
268
+ });
269
+ }
270
+ if (!params.options.ignoreImg) {
271
+ markdown = await localizeImages(markdown, {
272
+ savePath: params.savePath,
273
+ imageDir: `img/${fixPath(params.uuid)}`,
274
+ referer: params.articleUrl,
275
+ headers: client.htmlHeaders(),
276
+ imageServiceDomains: params.imageServiceDomains,
277
+ warnings: params.warnings,
278
+ title: params.articleTitle
279
+ });
280
+ }
281
+ markdown = fixInlineCode(markdown, htmlData);
282
+ markdown = handleMarkdownFooter(markdown, {
283
+ articleTitle: params.articleTitle,
284
+ articleUrl: params.articleUrl,
285
+ toc: params.options.toc,
286
+ articleUpdateTime: formatDate(data.content_updated_at || ''),
287
+ hideFooter: params.options.hideFooter,
288
+ convertMarkdownVideoLinks: params.options.convertMarkdownVideoLinks
289
+ });
290
+ await mkdir(path.dirname(params.saveFilePath), { recursive: true });
291
+ await writeFile(params.saveFilePath, markdown);
292
+ return { skipped: false };
293
+ }
294
+ function buildProgressItem(item, tocMap) {
295
+ if (!item.type)
296
+ return null;
297
+ const pathItems = getPathItems(item, tocMap);
298
+ const pathTitleList = pathItems.map((toc) => fixPath(toc.title));
299
+ const pathIdList = pathItems.map((toc) => toc.uuid);
300
+ const type = item.type.toLowerCase();
301
+ if (type === 'title' && !item.url) {
302
+ return {
303
+ path: pathTitleList.join('/'),
304
+ pathTitleList,
305
+ pathIdList,
306
+ toc: item
307
+ };
308
+ }
309
+ if (!item.url)
310
+ return null;
311
+ if (type === 'link') {
312
+ return {
313
+ path: pathTitleList.join('/'),
314
+ savePath: pathTitleList.slice(0, -1).join('/'),
315
+ pathTitleList,
316
+ pathIdList,
317
+ toc: item
318
+ };
319
+ }
320
+ let mdPath = `${pathTitleList.join('/')}.md`;
321
+ let savePath = pathTitleList.slice(0, -1).join('/');
322
+ if (type === 'doc' && item.child_uuid) {
323
+ mdPath = `${pathTitleList.join('/')}/index.md`;
324
+ savePath = pathTitleList.join('/');
325
+ }
326
+ return {
327
+ path: mdPath,
328
+ savePath,
329
+ pathTitleList,
330
+ pathIdList,
331
+ toc: item
332
+ };
333
+ }
334
+ function getPathItems(item, tocMap) {
335
+ const items = [item];
336
+ let current = item;
337
+ while (current.parent_uuid && tocMap.has(current.parent_uuid)) {
338
+ current = tocMap.get(current.parent_uuid);
339
+ items.unshift(current);
340
+ }
341
+ return items;
342
+ }
343
+ function logProgress(options, message) {
344
+ if (options.quiet)
345
+ return;
346
+ process.stderr.write(`${message}\n`);
347
+ }
348
+ function buildRetryPlan(command, urls, options) {
349
+ return {
350
+ command,
351
+ urls,
352
+ count: urls.length,
353
+ args: [
354
+ command,
355
+ ...urls,
356
+ '--dist-dir',
357
+ options.distDir,
358
+ ...(options.ignoreImg ? ['--ignore-img'] : []),
359
+ ...(options.ignoreAttachments === true ? ['--ignore-attachments'] : []),
360
+ ...(typeof options.ignoreAttachments === 'string' ? ['--ignore-attachments', options.ignoreAttachments] : []),
361
+ ...(options.toc ? ['--toc'] : []),
362
+ ...(options.hideFooter ? ['--hide-footer'] : []),
363
+ ...(options.quiet ? ['--quiet'] : [])
364
+ ]
365
+ };
366
+ }
367
+ function isString(value) {
368
+ return typeof value === 'string' && value.length > 0;
369
+ }
370
+ function buildWarningSummary(warnings) {
371
+ const byType = {};
372
+ for (const warning of warnings) {
373
+ byType[warning.type] = (byType[warning.type] || 0) + 1;
374
+ }
375
+ return {
376
+ total: warnings.length,
377
+ by_type: byType,
378
+ retryable_resources: warnings
379
+ .filter((warning) => warning.type !== 'link' && Boolean(warning.url))
380
+ .map((warning) => ({
381
+ type: warning.type,
382
+ title: warning.title,
383
+ url: warning.url,
384
+ file: warning.file,
385
+ error: warning.error
386
+ }))
387
+ };
388
+ }
@@ -0,0 +1,240 @@
1
+ import { access, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import { fileURLToPath, pathToFileURL } from 'node:url';
5
+ const PACKAGE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
6
+ const VENDOR_DIR = path.join(PACKAGE_ROOT, 'vendor/lake-editor');
7
+ export async function serializeHtmlToLake({ html, out, keepTemp = false }) {
8
+ const { chromium } = await importPlaywright();
9
+ const tempDir = await makeTempDir();
10
+ const pagePath = path.join(tempDir, 'editor-bridge.html');
11
+ const vendorAvailable = await hasOfficialEditorAssets();
12
+ await writeFile(pagePath, bridgeHtml({ vendorAvailable }), 'utf8');
13
+ const browser = await chromium.launch({ headless: true });
14
+ try {
15
+ const page = await browser.newPage();
16
+ await page.goto(pathToFileURL(pagePath).href);
17
+ const result = await page.evaluate(async (input) => {
18
+ return window.__yuqueBridge.serialize(input);
19
+ }, html);
20
+ if (!result?.lake?.trim())
21
+ throw new Error('Editor bridge returned empty Lake content.');
22
+ if (out) {
23
+ await mkdir(path.dirname(path.resolve(out)), { recursive: true });
24
+ await writeFile(out, result.lake, 'utf8');
25
+ }
26
+ return result;
27
+ }
28
+ catch (error) {
29
+ const message = error instanceof Error ? error.message : String(error);
30
+ if (/Executable doesn't exist|browserType.launch/.test(message)) {
31
+ throw new Error(`${message}\nRun "npx playwright install chromium" and try again.`);
32
+ }
33
+ throw error;
34
+ }
35
+ finally {
36
+ await browser.close();
37
+ if (!keepTemp)
38
+ await rm(tempDir, { recursive: true, force: true });
39
+ }
40
+ }
41
+ async function importPlaywright() {
42
+ try {
43
+ return await import('playwright');
44
+ }
45
+ catch {
46
+ throw new Error('Missing Playwright. Run "npm install --save-dev playwright" first.');
47
+ }
48
+ }
49
+ async function makeTempDir() {
50
+ await mkdir(path.join(os.tmpdir(), 'yuque-editor-bridge-'), { recursive: true });
51
+ return mkdtemp(path.join(os.tmpdir(), 'yuque-editor-bridge-'));
52
+ }
53
+ async function hasOfficialEditorAssets() {
54
+ try {
55
+ await access(path.join(VENDOR_DIR, 'doc.umd.js'));
56
+ await access(path.join(VENDOR_DIR, 'doc.css'));
57
+ return true;
58
+ }
59
+ catch {
60
+ return false;
61
+ }
62
+ }
63
+ function bridgeHtml({ vendorAvailable }) {
64
+ const vendorUrl = pathToFileURL(VENDOR_DIR).href;
65
+ return `<!doctype html>
66
+ <html>
67
+ <head>
68
+ <meta charset="utf-8">
69
+ <title>Yuque Editor Bridge</title>
70
+ ${vendorAvailable ? `<link rel="stylesheet" href="${vendorUrl}/antd.4.24.13.css"><link rel="stylesheet" href="${vendorUrl}/doc.css">` : ''}
71
+ </head>
72
+ <body>
73
+ <div id="root"></div>
74
+ ${vendorAvailable ? `<script>window.KITCHEN_URL = ${JSON.stringify(`${vendorUrl}/lake-editor-icon.js`)};</script><script src="${vendorUrl}/react.production.min.js"></script><script src="${vendorUrl}/react-dom.production.min.js"></script><script src="${vendorUrl}/doc.umd.js"></script>` : ''}
75
+ <script>
76
+ const vendorAvailable = ${JSON.stringify(vendorAvailable)};
77
+ const vendorUrl = ${JSON.stringify(vendorUrl)};
78
+
79
+ window.__yuqueBridge = {
80
+ async serialize(html) {
81
+ if (vendorAvailable && window.Doc && typeof window.Doc.createOpenEditor === 'function') {
82
+ return serializeWithOfficialEditor(html);
83
+ }
84
+ return {
85
+ serializer: 'fallback',
86
+ lake: fallbackSerialize(html)
87
+ };
88
+ }
89
+ };
90
+
91
+ async function serializeWithOfficialEditor(html) {
92
+ const root = document.getElementById('root');
93
+ const editor = window.Doc.createOpenEditor(root, {
94
+ placeholder: '',
95
+ defaultFontsize: 14,
96
+ codeblock: {
97
+ codemirrorURL: vendorUrl + '/CodeMirror.js',
98
+ supportCustomStyle: true
99
+ },
100
+ math: {
101
+ KaTexURL: vendorUrl + '/katex.js'
102
+ },
103
+ image: {
104
+ isCaptureImageURL() {
105
+ return false;
106
+ },
107
+ async createUploadPromise(request) {
108
+ if (request.type === 'base64') {
109
+ return {
110
+ url: request.data,
111
+ size: request.data.length * 0.75,
112
+ name: 'image.png'
113
+ };
114
+ }
115
+ return new Promise((resolve, reject) => {
116
+ const reader = new FileReader();
117
+ reader.onload = () => resolve({
118
+ url: reader.result,
119
+ size: request.data.size,
120
+ name: request.data.name || 'image.png'
121
+ });
122
+ reader.onerror = reject;
123
+ reader.readAsDataURL(request.data);
124
+ });
125
+ }
126
+ },
127
+ bookmark: {
128
+ recognizeYuque: true,
129
+ fetchDetailHandler: async (url) => ({ url, title: url })
130
+ },
131
+ link: {
132
+ isValidURL() {
133
+ return true;
134
+ },
135
+ sanitizeURL(url) {
136
+ return url;
137
+ }
138
+ }
139
+ });
140
+ editor.setDocument('text/html', html);
141
+ let attempts = 0;
142
+ while (editor.canGetDocument && !editor.canGetDocument()) {
143
+ if (attempts > 100) throw new Error('Official editor was not ready after 10s.');
144
+ attempts += 1;
145
+ await new Promise(resolve => setTimeout(resolve, 100));
146
+ }
147
+ return {
148
+ serializer: 'official-lake-editor',
149
+ lake: editor.getDocument('text/lake', { includeMeta: true })
150
+ };
151
+ }
152
+
153
+ function fallbackSerialize(html) {
154
+ const doc = new DOMParser().parseFromString(html, 'text/html');
155
+ const blocks = [];
156
+ for (const node of Array.from(doc.body.childNodes)) {
157
+ const rendered = renderBlock(node);
158
+ if (rendered) blocks.push(rendered);
159
+ }
160
+ return '<meta name="serializer" content="yuque-cookie-plugin-fallback">' + blocks.join('');
161
+ }
162
+
163
+ function renderBlock(node) {
164
+ if (node.nodeType === Node.TEXT_NODE) {
165
+ const text = node.textContent.trim();
166
+ return text ? '<p>' + escapeHtml(text) + '</p>' : '';
167
+ }
168
+ if (node.nodeType !== Node.ELEMENT_NODE) return '';
169
+ const tag = node.tagName.toLowerCase();
170
+ if (/^h[1-6]$/.test(tag)) return '<' + tag + '>' + renderInlineChildren(node) + '</' + tag + '>';
171
+ if (tag === 'p') return '<p>' + renderInlineChildren(node) + '</p>';
172
+ if (tag === 'blockquote') return '<blockquote>' + renderInlineChildren(node) + '</blockquote>';
173
+ if (tag === 'hr') return '<card name="hr" value="data:%7B%7D"></card>';
174
+ if (tag === 'pre') return renderCodeBlock(node);
175
+ if (tag === 'ul' || tag === 'ol') return renderList(node, tag);
176
+ if (tag === 'table') return renderTable(node);
177
+ if (tag === 'div' || tag === 'section' || tag === 'article') {
178
+ return Array.from(node.childNodes).map(renderBlock).filter(Boolean).join('');
179
+ }
180
+ return '<p>' + renderInline(node) + '</p>';
181
+ }
182
+
183
+ function renderInlineChildren(node) {
184
+ return Array.from(node.childNodes).map(renderInline).join('');
185
+ }
186
+
187
+ function renderInline(node) {
188
+ if (node.nodeType === Node.TEXT_NODE) return escapeHtml(node.textContent);
189
+ if (node.nodeType !== Node.ELEMENT_NODE) return '';
190
+ const tag = node.tagName.toLowerCase();
191
+ const content = renderInlineChildren(node);
192
+ if (tag === 'strong' || tag === 'b') return '<strong>' + content + '</strong>';
193
+ if (tag === 'em' || tag === 'i') return '<em>' + content + '</em>';
194
+ if (tag === 'del' || tag === 's') return '<del>' + content + '</del>';
195
+ if (tag === 'u') return '<u>' + content + '</u>';
196
+ if (tag === 'code') return '<code>' + content + '</code>';
197
+ if (tag === 'a') return '<a href="' + escapeAttr(node.getAttribute('href') || '') + '">' + content + '</a>';
198
+ if (tag === 'br') return '<br>';
199
+ if (tag === 'img') {
200
+ const data = {
201
+ src: node.getAttribute('src') || '',
202
+ name: node.getAttribute('alt') || 'image-placeholder'
203
+ };
204
+ return '<card name="image" value="data:' + encodeURIComponent(JSON.stringify(data)) + '"></card>';
205
+ }
206
+ return content;
207
+ }
208
+
209
+ function renderCodeBlock(node) {
210
+ const codeNode = node.querySelector('code') || node;
211
+ const className = codeNode.getAttribute('class') || '';
212
+ const language = (className.match(/language-([\\w-]+)/) || [])[1] || 'plain';
213
+ const data = { mode: language, code: codeNode.textContent || '', name: '' };
214
+ return '<card name="codeblock" value="data:' + encodeURIComponent(JSON.stringify(data)) + '"></card>';
215
+ }
216
+
217
+ function renderList(node, tag) {
218
+ return '<' + tag + '>' + Array.from(node.children).filter(child => child.tagName.toLowerCase() === 'li').map((li) => {
219
+ return '<li>' + renderInlineChildren(li) + '</li>';
220
+ }).join('') + '</' + tag + '>';
221
+ }
222
+
223
+ function renderTable(node) {
224
+ return '<table>' + node.innerHTML + '</table>';
225
+ }
226
+
227
+ function escapeHtml(value) {
228
+ return String(value)
229
+ .replace(/&/g, '&amp;')
230
+ .replace(/</g, '&lt;')
231
+ .replace(/>/g, '&gt;');
232
+ }
233
+
234
+ function escapeAttr(value) {
235
+ return escapeHtml(value).replace(/"/g, '&quot;');
236
+ }
237
+ </script>
238
+ </body>
239
+ </html>`;
240
+ }
@@ -0,0 +1,9 @@
1
+ import { mkdir, writeFile } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ export async function writeJson(file, data) {
4
+ await mkdir(path.dirname(path.resolve(file)), { recursive: true });
5
+ await writeFile(file, `${JSON.stringify(data, null, 2)}\n`);
6
+ }
7
+ export function timestampForFile(date = new Date()) {
8
+ return date.toISOString().replace(/[:.]/g, '-');
9
+ }