shennian 0.2.65 → 0.2.66
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/dist/src/agent-env.js +2 -0
- package/dist/src/agents/command-spec.js +5 -0
- package/dist/src/agents/config-status.d.ts +5 -4
- package/dist/src/agents/config-status.js +30 -9
- package/dist/src/agents/manager.js +11 -1
- package/dist/src/agents/pi-context.d.ts +1 -1
- package/dist/src/agents/pi-context.js +4 -3
- package/dist/src/agents/pi.d.ts +1 -0
- package/dist/src/agents/pi.js +34 -5
- package/dist/src/commands/daemon.d.ts +7 -0
- package/dist/src/commands/daemon.js +28 -20
- package/dist/src/commands/external-attachments.d.ts +9 -0
- package/dist/src/commands/external-attachments.js +52 -0
- package/dist/src/commands/external.js +4 -52
- package/dist/src/commands/manager.js +49 -6
- package/dist/src/commands/tools.d.ts +2 -0
- package/dist/src/commands/tools.js +34 -0
- package/dist/src/commands/upgrade.js +1 -1
- package/dist/src/fs/boundary.js +7 -2
- package/dist/src/index.js +2 -0
- package/dist/src/manager/prompt.d.ts +1 -1
- package/dist/src/manager/prompt.js +10 -4
- package/dist/src/manager/registry.d.ts +4 -0
- package/dist/src/manager/registry.js +2 -0
- package/dist/src/manager/runtime.d.ts +8 -1
- package/dist/src/manager/runtime.js +35 -8
- package/dist/src/session/handlers/agent-config.js +3 -3
- package/dist/src/session/handlers/chat.js +33 -12
- package/dist/src/session/handlers/fs.d.ts +1 -0
- package/dist/src/session/handlers/fs.js +76 -2
- package/dist/src/session/manager.js +4 -1
- package/dist/src/session/queue.js +18 -2
- package/dist/src/session/remote-attachments.d.ts +15 -0
- package/dist/src/session/remote-attachments.js +72 -0
- package/dist/src/tools/markdown-to-pdf.d.ts +20 -0
- package/dist/src/tools/markdown-to-pdf.js +303 -0
- package/dist/src/upgrade/engine.js +5 -5
- package/package.json +1 -1
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import fs from 'node:fs';
|
|
4
4
|
import os from 'node:os';
|
|
5
5
|
import path from 'node:path';
|
|
6
|
+
import { convertMarkdownToPdf, defaultPdfOutputPath } from '../../tools/markdown-to-pdf.js';
|
|
6
7
|
const FILE_SYSTEM_ROOTS_PATH = '__roots__';
|
|
7
8
|
const MAX_FOLDER_UPLOAD_FILES = 2000;
|
|
8
9
|
const MAX_FOLDER_UPLOAD_TOTAL_SIZE = 1024 * 1024 * 1024;
|
|
@@ -23,6 +24,26 @@ function makeFsEntry(entryPath) {
|
|
|
23
24
|
modifiedAt: stat.mtimeMs,
|
|
24
25
|
};
|
|
25
26
|
}
|
|
27
|
+
function listFileSystemRoots() {
|
|
28
|
+
if (os.platform() === 'win32') {
|
|
29
|
+
const entries = [];
|
|
30
|
+
for (let code = 65; code <= 90; code += 1) {
|
|
31
|
+
const drive = `${String.fromCharCode(code)}:\\`;
|
|
32
|
+
if (fs.existsSync(drive))
|
|
33
|
+
entries.push({ name: drive, path: drive, isDir: true });
|
|
34
|
+
}
|
|
35
|
+
return entries;
|
|
36
|
+
}
|
|
37
|
+
return [{ name: '/', path: '/', isDir: true }];
|
|
38
|
+
}
|
|
39
|
+
function fsErrorMessage(err, fallbackPath) {
|
|
40
|
+
const code = typeof err === 'object' && err && 'code' in err ? String(err.code) : '';
|
|
41
|
+
if (code === 'ENOENT')
|
|
42
|
+
return `Directory not found: ${fallbackPath}`;
|
|
43
|
+
if (code === 'EACCES' || code === 'EPERM')
|
|
44
|
+
return `Permission denied: ${fallbackPath}`;
|
|
45
|
+
return err instanceof Error ? err.message : String(err);
|
|
46
|
+
}
|
|
26
47
|
function isSafeRelativeUploadPath(relativePath) {
|
|
27
48
|
if (!relativePath || relativePath === '.' || relativePath.trim() !== relativePath)
|
|
28
49
|
return false;
|
|
@@ -72,7 +93,12 @@ export async function handleFsLs(runtime, req) {
|
|
|
72
93
|
const requestedPath = req.params.path || os.homedir();
|
|
73
94
|
const rootPath = req.params.rootPath || requestedPath;
|
|
74
95
|
if (requestedPath === FILE_SYSTEM_ROOTS_PATH) {
|
|
75
|
-
runtime.client.sendRes({
|
|
96
|
+
runtime.client.sendRes({
|
|
97
|
+
type: 'res',
|
|
98
|
+
id: req.id,
|
|
99
|
+
ok: true,
|
|
100
|
+
payload: { path: FILE_SYSTEM_ROOTS_PATH, entries: listFileSystemRoots() },
|
|
101
|
+
});
|
|
76
102
|
return;
|
|
77
103
|
}
|
|
78
104
|
const resolved = runtime.resolveAuthorizedPath(requestedPath, rootPath);
|
|
@@ -103,7 +129,7 @@ export async function handleFsLs(runtime, req) {
|
|
|
103
129
|
});
|
|
104
130
|
}
|
|
105
131
|
catch (err) {
|
|
106
|
-
runtime.client.sendRes({ type: 'res', id: req.id, ok: false, error:
|
|
132
|
+
runtime.client.sendRes({ type: 'res', id: req.id, ok: false, error: fsErrorMessage(err, dirPath) });
|
|
107
133
|
}
|
|
108
134
|
}
|
|
109
135
|
export async function handleFsRead(runtime, req) {
|
|
@@ -267,6 +293,54 @@ export async function handleFsRename(runtime, req) {
|
|
|
267
293
|
runtime.client.sendRes({ type: 'res', id: req.id, ok: false, error: String(err) });
|
|
268
294
|
}
|
|
269
295
|
}
|
|
296
|
+
export async function handleFsExportMarkdownPdf(runtime, req) {
|
|
297
|
+
const requestedPath = req.params.path;
|
|
298
|
+
const rootPath = req.params.rootPath || path.dirname(requestedPath || '.');
|
|
299
|
+
const title = typeof req.params.title === 'string' ? req.params.title : undefined;
|
|
300
|
+
if (!requestedPath) {
|
|
301
|
+
runtime.client.sendRes({ type: 'res', id: req.id, ok: false, error: 'path is required' });
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
const resolved = runtime.resolveAuthorizedPath(requestedPath, rootPath);
|
|
305
|
+
if (!resolved.ok) {
|
|
306
|
+
runtime.client.sendRes({ type: 'res', id: req.id, ok: false, error: resolved.error });
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
if (!/\.mdx?$/i.test(resolved.path)) {
|
|
310
|
+
runtime.client.sendRes({ type: 'res', id: req.id, ok: false, error: 'Only Markdown files can be exported to PDF' });
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
const outputPath = defaultPdfOutputPath(resolved.path);
|
|
314
|
+
const checkedOutput = runtime.resolveAuthorizedPath(outputPath, rootPath);
|
|
315
|
+
if (!checkedOutput.ok) {
|
|
316
|
+
runtime.client.sendRes({ type: 'res', id: req.id, ok: false, error: checkedOutput.error });
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
try {
|
|
320
|
+
const result = await convertMarkdownToPdf(resolved.path, {
|
|
321
|
+
outputPath: checkedOutput.path,
|
|
322
|
+
title,
|
|
323
|
+
});
|
|
324
|
+
runtime.client.sendRes({
|
|
325
|
+
type: 'res',
|
|
326
|
+
id: req.id,
|
|
327
|
+
ok: true,
|
|
328
|
+
payload: {
|
|
329
|
+
sourcePath: resolved.path,
|
|
330
|
+
outputPath: result.outputPath,
|
|
331
|
+
entry: makeFsEntry(result.outputPath),
|
|
332
|
+
},
|
|
333
|
+
});
|
|
334
|
+
}
|
|
335
|
+
catch (err) {
|
|
336
|
+
runtime.client.sendRes({
|
|
337
|
+
type: 'res',
|
|
338
|
+
id: req.id,
|
|
339
|
+
ok: false,
|
|
340
|
+
error: err instanceof Error ? err.message : String(err),
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
}
|
|
270
344
|
export async function handleFsTransfer(runtime, req) {
|
|
271
345
|
const { name, targetPath, data, direct } = req.params;
|
|
272
346
|
if (!name || !data) {
|
|
@@ -8,7 +8,7 @@ import { handleAgentsRefresh, handleModelsRefresh } from './handlers/agents.js';
|
|
|
8
8
|
import { handleAgentConfigClear, handleAgentConfigGet, handleAgentConfigTest, handleAgentConfigUpsert, } from './handlers/agent-config.js';
|
|
9
9
|
import { handleChatAbort, handleChatSend } from './handlers/chat.js';
|
|
10
10
|
import { handleSessionRefresh } from './handlers/session-refresh.js';
|
|
11
|
-
import { cleanupPendingTransfers, handleFsLs, handleFsRead, handleFsRename, handleFsWrite, handleFsTransfer, handleFsTransferAbort, handleFsTransferChunk, handleFsTransferFinish, handleFsTransferStart, } from './handlers/fs.js';
|
|
11
|
+
import { cleanupPendingTransfers, handleFsLs, handleFsRead, handleFsRename, handleFsWrite, handleFsTransfer, handleFsTransferAbort, handleFsTransferChunk, handleFsTransferFinish, handleFsTransferStart, handleFsExportMarkdownPdf, } from './handlers/fs.js';
|
|
12
12
|
import { handleSkillInstall, handleSkillList, handleSkillUse } from './handlers/skills.js';
|
|
13
13
|
import { handleRegionProbe, handleRegionSwitch, handleUpgradeSetPolicy } from './handlers/control.js';
|
|
14
14
|
import { ManagerRuntimeService, setManagerRuntimeService } from '../manager/runtime.js';
|
|
@@ -117,6 +117,9 @@ export class SessionManager {
|
|
|
117
117
|
case 'fs.write':
|
|
118
118
|
await handleFsWrite(runtime, req);
|
|
119
119
|
break;
|
|
120
|
+
case 'fs.export.markdown-pdf':
|
|
121
|
+
await handleFsExportMarkdownPdf(runtime, req);
|
|
122
|
+
break;
|
|
120
123
|
case 'fs.rename':
|
|
121
124
|
await handleFsRename(runtime, req);
|
|
122
125
|
break;
|
|
@@ -4,6 +4,7 @@ import fs from 'node:fs';
|
|
|
4
4
|
import { randomUUID } from 'node:crypto';
|
|
5
5
|
import { resolveShennianPath } from '../config/index.js';
|
|
6
6
|
import { mergeProjectedSessions } from './projection.js';
|
|
7
|
+
import { materializeRemoteChatAttachments } from './remote-attachments.js';
|
|
7
8
|
const QUEUE_FILE = resolveShennianPath('chat-queue.json');
|
|
8
9
|
function emptyQueue() {
|
|
9
10
|
return { sessions: {} };
|
|
@@ -93,6 +94,10 @@ export class ChatQueueManager {
|
|
|
93
94
|
});
|
|
94
95
|
return;
|
|
95
96
|
}
|
|
97
|
+
const normalizedAttachments = normalizeAttachments(params.attachments);
|
|
98
|
+
const materialized = normalizedAttachments?.length
|
|
99
|
+
? await materializeRemoteChatAttachments({ text: params.text, attachments: normalizedAttachments, workDir: params.workDir })
|
|
100
|
+
: { text: params.text, attachments: normalizedAttachments, localized: false };
|
|
96
101
|
const active = runtime.sessions.get(params.sessionId);
|
|
97
102
|
const isBusy = Boolean(active?.currentRunId);
|
|
98
103
|
if (!isBusy && !(readQueue().sessions[params.sessionId]?.length)) {
|
|
@@ -102,15 +107,21 @@ export class ChatQueueManager {
|
|
|
102
107
|
method: 'chat.send',
|
|
103
108
|
params: {
|
|
104
109
|
...params,
|
|
110
|
+
text: materialized.text,
|
|
105
111
|
responseId: req.id,
|
|
106
112
|
clientMessageId: params.clientMessageId ?? params.queueMessageId,
|
|
107
113
|
waitForDispatch: true,
|
|
114
|
+
attachments: materialized.attachments,
|
|
108
115
|
},
|
|
109
116
|
});
|
|
110
117
|
return;
|
|
111
118
|
}
|
|
112
119
|
const queue = readQueue();
|
|
113
|
-
const message = queueMessageFromParams(
|
|
120
|
+
const message = queueMessageFromParams({
|
|
121
|
+
...params,
|
|
122
|
+
text: materialized.text,
|
|
123
|
+
attachments: materialized.attachments,
|
|
124
|
+
});
|
|
114
125
|
queue.sessions[params.sessionId] = [...(queue.sessions[params.sessionId] ?? []), message];
|
|
115
126
|
writeQueue(queue);
|
|
116
127
|
this.broadcast(params.sessionId);
|
|
@@ -118,7 +129,12 @@ export class ChatQueueManager {
|
|
|
118
129
|
type: 'res',
|
|
119
130
|
id: req.id,
|
|
120
131
|
ok: true,
|
|
121
|
-
payload: {
|
|
132
|
+
payload: {
|
|
133
|
+
queued: true,
|
|
134
|
+
queueMessageId: message.id,
|
|
135
|
+
queue: this.getSnapshot(params.sessionId),
|
|
136
|
+
...(materialized.localized ? { localizedAttachments: true } : {}),
|
|
137
|
+
},
|
|
122
138
|
});
|
|
123
139
|
}
|
|
124
140
|
async handleGet(req) {
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export type ChatAttachmentInput = {
|
|
2
|
+
path: string;
|
|
3
|
+
name: string;
|
|
4
|
+
mimeType: string;
|
|
5
|
+
previewData?: string;
|
|
6
|
+
};
|
|
7
|
+
export declare function materializeRemoteChatAttachments(input: {
|
|
8
|
+
text: string;
|
|
9
|
+
attachments?: ChatAttachmentInput[];
|
|
10
|
+
workDir: string;
|
|
11
|
+
}): Promise<{
|
|
12
|
+
text: string;
|
|
13
|
+
attachments?: ChatAttachmentInput[];
|
|
14
|
+
localized: boolean;
|
|
15
|
+
}>;
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// @arch docs/architecture/app/chat-attachments-preview.md
|
|
2
|
+
// @test src/__tests__/session-manager.test.ts
|
|
3
|
+
import crypto from 'node:crypto';
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
const MAX_REMOTE_ATTACHMENT_BYTES = Number(process.env.SHENNIAN_REMOTE_ATTACHMENT_MAX_BYTES || 50 * 1024 * 1024);
|
|
7
|
+
function safeFileName(name) {
|
|
8
|
+
const cleaned = path.basename(name || 'attachment')
|
|
9
|
+
.normalize('NFKC')
|
|
10
|
+
.replace(/[<>:"/\\|?*\x00-\x1F]/g, '_')
|
|
11
|
+
.replace(/[\r\n\t]+/g, ' ')
|
|
12
|
+
.replace(/\s+/g, ' ')
|
|
13
|
+
.replace(/_+/g, '_')
|
|
14
|
+
.replace(/^[ ._]+|[ ._]+$/g, '');
|
|
15
|
+
return cleaned || 'attachment';
|
|
16
|
+
}
|
|
17
|
+
function uniquePath(dir, name, hash) {
|
|
18
|
+
const safe = safeFileName(name);
|
|
19
|
+
const ext = path.extname(safe);
|
|
20
|
+
const stem = ext ? safe.slice(0, -ext.length) : safe;
|
|
21
|
+
const candidate = path.join(dir, safe);
|
|
22
|
+
if (!fs.existsSync(candidate))
|
|
23
|
+
return candidate;
|
|
24
|
+
return path.join(dir, `${stem}-${hash.slice(0, 8)}${ext}`);
|
|
25
|
+
}
|
|
26
|
+
function isHttpUrl(value) {
|
|
27
|
+
return /^https?:\/\//i.test(value);
|
|
28
|
+
}
|
|
29
|
+
async function downloadRemoteAttachment(attachment, workDir) {
|
|
30
|
+
if (!isHttpUrl(attachment.path))
|
|
31
|
+
return attachment;
|
|
32
|
+
const response = await fetch(attachment.path);
|
|
33
|
+
if (!response.ok)
|
|
34
|
+
return attachment;
|
|
35
|
+
const contentLength = Number(response.headers.get('content-length') || 0);
|
|
36
|
+
if (contentLength > MAX_REMOTE_ATTACHMENT_BYTES)
|
|
37
|
+
return attachment;
|
|
38
|
+
const buffer = Buffer.from(await response.arrayBuffer());
|
|
39
|
+
if (!buffer.byteLength || buffer.byteLength > MAX_REMOTE_ATTACHMENT_BYTES)
|
|
40
|
+
return attachment;
|
|
41
|
+
const hash = crypto.createHash('sha256').update(buffer).digest('hex');
|
|
42
|
+
const uploadDir = path.join(workDir, '.uploads');
|
|
43
|
+
fs.mkdirSync(uploadDir, { recursive: true });
|
|
44
|
+
const filePath = uniquePath(uploadDir, attachment.name, hash);
|
|
45
|
+
if (!fs.existsSync(filePath))
|
|
46
|
+
fs.writeFileSync(filePath, buffer);
|
|
47
|
+
return {
|
|
48
|
+
...attachment,
|
|
49
|
+
path: filePath,
|
|
50
|
+
mimeType: attachment.mimeType || response.headers.get('content-type') || 'application/octet-stream',
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
function replaceAttachmentRefs(text, before, after) {
|
|
54
|
+
if (before.path === after.path || !before.path || !after.path)
|
|
55
|
+
return text;
|
|
56
|
+
return text.split(before.path).join(after.path);
|
|
57
|
+
}
|
|
58
|
+
export async function materializeRemoteChatAttachments(input) {
|
|
59
|
+
if (!input.attachments?.length)
|
|
60
|
+
return { text: input.text, attachments: input.attachments, localized: false };
|
|
61
|
+
const materialized = [];
|
|
62
|
+
let text = input.text;
|
|
63
|
+
let localized = false;
|
|
64
|
+
for (const attachment of input.attachments) {
|
|
65
|
+
const next = await downloadRemoteAttachment(attachment, input.workDir).catch(() => attachment);
|
|
66
|
+
if (next.path !== attachment.path && isHttpUrl(attachment.path))
|
|
67
|
+
localized = true;
|
|
68
|
+
text = replaceAttachmentRefs(text, attachment, next);
|
|
69
|
+
materialized.push(next);
|
|
70
|
+
}
|
|
71
|
+
return { text, attachments: materialized, localized };
|
|
72
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export type MarkdownToPdfOptions = {
|
|
2
|
+
outputPath?: string;
|
|
3
|
+
title?: string;
|
|
4
|
+
keepHtml?: boolean;
|
|
5
|
+
chromePath?: string;
|
|
6
|
+
};
|
|
7
|
+
export type MarkdownToPdfResult = {
|
|
8
|
+
inputPath: string;
|
|
9
|
+
outputPath: string;
|
|
10
|
+
htmlPath: string | null;
|
|
11
|
+
browserPath: string;
|
|
12
|
+
};
|
|
13
|
+
export declare function defaultPdfOutputPath(inputPath: string): string;
|
|
14
|
+
export declare function renderMarkdownBody(markdown: string, sourceDir?: string): string;
|
|
15
|
+
export declare function renderMarkdownHtml(markdown: string, options?: {
|
|
16
|
+
title?: string;
|
|
17
|
+
sourceDir?: string;
|
|
18
|
+
}): string;
|
|
19
|
+
export declare function findChromiumExecutable(explicitPath?: string): Promise<string>;
|
|
20
|
+
export declare function convertMarkdownToPdf(inputPath: string, options?: MarkdownToPdfOptions): Promise<MarkdownToPdfResult>;
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
// @arch docs/features/markdown-pdf-export.md
|
|
2
|
+
// @test src/__tests__/markdown-to-pdf.test.ts
|
|
3
|
+
import { execFile } from 'node:child_process';
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import os from 'node:os';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { pathToFileURL } from 'node:url';
|
|
8
|
+
import { promisify } from 'node:util';
|
|
9
|
+
const execFileAsync = promisify(execFile);
|
|
10
|
+
const FENCED_CODE_RE = /^```(\S*)\s*$/;
|
|
11
|
+
function isWindowsAbsolutePath(value) {
|
|
12
|
+
return /^[A-Za-z]:([\\/]|$)/.test(value) || /^\\\\[^\\]+\\[^\\]+/.test(value);
|
|
13
|
+
}
|
|
14
|
+
function pathApiForPath(value) {
|
|
15
|
+
return isWindowsAbsolutePath(value) ? path.win32 : path;
|
|
16
|
+
}
|
|
17
|
+
export function defaultPdfOutputPath(inputPath) {
|
|
18
|
+
const api = pathApiForPath(inputPath);
|
|
19
|
+
const parsed = api.parse(inputPath);
|
|
20
|
+
return api.join(parsed.dir, `${parsed.name}.pdf`);
|
|
21
|
+
}
|
|
22
|
+
function escapeHtml(value) {
|
|
23
|
+
return value
|
|
24
|
+
.replace(/&/g, '&')
|
|
25
|
+
.replace(/</g, '<')
|
|
26
|
+
.replace(/>/g, '>')
|
|
27
|
+
.replace(/"/g, '"')
|
|
28
|
+
.replace(/'/g, ''');
|
|
29
|
+
}
|
|
30
|
+
function slugify(value) {
|
|
31
|
+
return value
|
|
32
|
+
.toLowerCase()
|
|
33
|
+
.replace(/<[^>]+>/g, '')
|
|
34
|
+
.replace(/[^\p{L}\p{N}]+/gu, '-')
|
|
35
|
+
.replace(/^-+|-+$/g, '');
|
|
36
|
+
}
|
|
37
|
+
function renderInline(markdown, sourceDir) {
|
|
38
|
+
let text = escapeHtml(markdown);
|
|
39
|
+
text = text.replace(/`([^`]+)`/g, '<code>$1</code>');
|
|
40
|
+
text = text.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
|
|
41
|
+
text = text.replace(/__([^_]+)__/g, '<strong>$1</strong>');
|
|
42
|
+
text = text.replace(/\*([^*]+)\*/g, '<em>$1</em>');
|
|
43
|
+
text = text.replace(/_([^_]+)_/g, '<em>$1</em>');
|
|
44
|
+
text = text.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (_match, alt, rawUrl) => {
|
|
45
|
+
const url = rawUrl.trim().replace(/^<|>$/g, '');
|
|
46
|
+
const src = toSafeAssetUrl(url, sourceDir);
|
|
47
|
+
return `<img src="${escapeHtml(src)}" alt="${alt}" />`;
|
|
48
|
+
});
|
|
49
|
+
text = text.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_match, label, rawUrl) => {
|
|
50
|
+
const url = rawUrl.trim().replace(/^<|>$/g, '');
|
|
51
|
+
return `<a href="${escapeHtml(url)}">${label}</a>`;
|
|
52
|
+
});
|
|
53
|
+
return text;
|
|
54
|
+
}
|
|
55
|
+
function toSafeAssetUrl(rawUrl, sourceDir) {
|
|
56
|
+
if (/^(https?:|data:|file:)/i.test(rawUrl))
|
|
57
|
+
return rawUrl;
|
|
58
|
+
const withoutAnchor = rawUrl.split('#')[0].split('?')[0];
|
|
59
|
+
const resolved = path.resolve(sourceDir, decodeURIComponent(withoutAnchor));
|
|
60
|
+
const relative = path.relative(sourceDir, resolved);
|
|
61
|
+
if (relative.startsWith('..') || path.isAbsolute(relative))
|
|
62
|
+
return '';
|
|
63
|
+
return pathToFileURL(resolved).href;
|
|
64
|
+
}
|
|
65
|
+
function renderTable(lines, sourceDir) {
|
|
66
|
+
if (lines.length < 2)
|
|
67
|
+
return null;
|
|
68
|
+
const separator = lines[1].trim();
|
|
69
|
+
if (!/^\|?\s*:?-{3,}:?\s*(\|\s*:?-{3,}:?\s*)+\|?$/.test(separator))
|
|
70
|
+
return null;
|
|
71
|
+
const parseRow = (line) => line.trim().replace(/^\||\|$/g, '').split('|').map((cell) => cell.trim());
|
|
72
|
+
const header = parseRow(lines[0]);
|
|
73
|
+
const rows = lines.slice(2).map(parseRow);
|
|
74
|
+
return [
|
|
75
|
+
'<table>',
|
|
76
|
+
'<thead><tr>',
|
|
77
|
+
...header.map((cell) => `<th>${renderInline(cell, sourceDir)}</th>`),
|
|
78
|
+
'</tr></thead>',
|
|
79
|
+
'<tbody>',
|
|
80
|
+
...rows.map((row) => `<tr>${row.map((cell) => `<td>${renderInline(cell, sourceDir)}</td>`).join('')}</tr>`),
|
|
81
|
+
'</tbody></table>',
|
|
82
|
+
].join('');
|
|
83
|
+
}
|
|
84
|
+
export function renderMarkdownBody(markdown, sourceDir = process.cwd()) {
|
|
85
|
+
const lines = markdown.replace(/\r\n?/g, '\n').split('\n');
|
|
86
|
+
const out = [];
|
|
87
|
+
let paragraph = [];
|
|
88
|
+
let list = null;
|
|
89
|
+
let code = null;
|
|
90
|
+
const flushParagraph = () => {
|
|
91
|
+
if (paragraph.length === 0)
|
|
92
|
+
return;
|
|
93
|
+
out.push(`<p>${renderInline(paragraph.join(' '), sourceDir)}</p>`);
|
|
94
|
+
paragraph = [];
|
|
95
|
+
};
|
|
96
|
+
const flushList = () => {
|
|
97
|
+
if (!list)
|
|
98
|
+
return;
|
|
99
|
+
const tag = list.ordered ? 'ol' : 'ul';
|
|
100
|
+
out.push(`<${tag}>${list.items.map((item) => `<li>${renderInline(item, sourceDir)}</li>`).join('')}</${tag}>`);
|
|
101
|
+
list = null;
|
|
102
|
+
};
|
|
103
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
104
|
+
const line = lines[i];
|
|
105
|
+
const fence = line.match(FENCED_CODE_RE);
|
|
106
|
+
if (code) {
|
|
107
|
+
if (fence) {
|
|
108
|
+
out.push(`<pre><code${code.lang ? ` class="language-${escapeHtml(code.lang)}"` : ''}>${escapeHtml(code.lines.join('\n'))}</code></pre>`);
|
|
109
|
+
code = null;
|
|
110
|
+
}
|
|
111
|
+
else {
|
|
112
|
+
code.lines.push(line);
|
|
113
|
+
}
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
if (fence) {
|
|
117
|
+
flushParagraph();
|
|
118
|
+
flushList();
|
|
119
|
+
code = { lang: fence[1] || '', lines: [] };
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
if (!line.trim()) {
|
|
123
|
+
flushParagraph();
|
|
124
|
+
flushList();
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
const tableCandidate = lines.slice(i, i + 2);
|
|
128
|
+
const maybeTable = renderTable(tableCandidate, sourceDir);
|
|
129
|
+
if (maybeTable) {
|
|
130
|
+
const tableLines = [line, lines[i + 1]];
|
|
131
|
+
i += 2;
|
|
132
|
+
while (i < lines.length && lines[i].includes('|') && lines[i].trim()) {
|
|
133
|
+
tableLines.push(lines[i]);
|
|
134
|
+
i += 1;
|
|
135
|
+
}
|
|
136
|
+
i -= 1;
|
|
137
|
+
flushParagraph();
|
|
138
|
+
flushList();
|
|
139
|
+
out.push(renderTable(tableLines, sourceDir));
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
const heading = line.match(/^(#{1,6})\s+(.+)$/);
|
|
143
|
+
if (heading) {
|
|
144
|
+
flushParagraph();
|
|
145
|
+
flushList();
|
|
146
|
+
const level = heading[1].length;
|
|
147
|
+
const content = renderInline(heading[2].trim(), sourceDir);
|
|
148
|
+
const id = slugify(content);
|
|
149
|
+
out.push(`<h${level}${id ? ` id="${id}"` : ''}>${content}</h${level}>`);
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
const unordered = line.match(/^\s*[-*+]\s+(.+)$/);
|
|
153
|
+
const ordered = line.match(/^\s*\d+[.)]\s+(.+)$/);
|
|
154
|
+
if (unordered || ordered) {
|
|
155
|
+
flushParagraph();
|
|
156
|
+
const isOrdered = Boolean(ordered);
|
|
157
|
+
if (!list || list.ordered !== isOrdered)
|
|
158
|
+
flushList();
|
|
159
|
+
if (!list)
|
|
160
|
+
list = { ordered: isOrdered, items: [] };
|
|
161
|
+
list.items.push((unordered?.[1] ?? ordered?.[1] ?? '').trim());
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
const quote = line.match(/^>\s?(.+)$/);
|
|
165
|
+
if (quote) {
|
|
166
|
+
flushParagraph();
|
|
167
|
+
flushList();
|
|
168
|
+
out.push(`<blockquote>${renderInline(quote[1], sourceDir)}</blockquote>`);
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
if (/^---+$/.test(line.trim())) {
|
|
172
|
+
flushParagraph();
|
|
173
|
+
flushList();
|
|
174
|
+
out.push('<hr />');
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
paragraph.push(line.trim());
|
|
178
|
+
}
|
|
179
|
+
if (code) {
|
|
180
|
+
out.push(`<pre><code${code.lang ? ` class="language-${escapeHtml(code.lang)}"` : ''}>${escapeHtml(code.lines.join('\n'))}</code></pre>`);
|
|
181
|
+
}
|
|
182
|
+
flushParagraph();
|
|
183
|
+
flushList();
|
|
184
|
+
return out.join('\n');
|
|
185
|
+
}
|
|
186
|
+
export function renderMarkdownHtml(markdown, options = {}) {
|
|
187
|
+
const title = options.title || 'Markdown Export';
|
|
188
|
+
const body = renderMarkdownBody(markdown, options.sourceDir);
|
|
189
|
+
return `<!doctype html>
|
|
190
|
+
<html>
|
|
191
|
+
<head>
|
|
192
|
+
<meta charset="utf-8" />
|
|
193
|
+
<title>${escapeHtml(title)}</title>
|
|
194
|
+
<style>
|
|
195
|
+
@page { size: A4; margin: 18mm 16mm; }
|
|
196
|
+
* { box-sizing: border-box; }
|
|
197
|
+
body {
|
|
198
|
+
color: #111827;
|
|
199
|
+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Noto Sans CJK SC", Arial, sans-serif;
|
|
200
|
+
font-size: 14px;
|
|
201
|
+
line-height: 1.68;
|
|
202
|
+
}
|
|
203
|
+
h1, h2, h3, h4, h5, h6 { line-height: 1.25; margin: 1.4em 0 .55em; page-break-after: avoid; }
|
|
204
|
+
h1 { font-size: 28px; border-bottom: 1px solid #e5e7eb; padding-bottom: 10px; }
|
|
205
|
+
h2 { font-size: 22px; border-bottom: 1px solid #eef2f7; padding-bottom: 6px; }
|
|
206
|
+
h3 { font-size: 18px; }
|
|
207
|
+
p { margin: .65em 0; }
|
|
208
|
+
a { color: #2563eb; text-decoration: none; }
|
|
209
|
+
img { display: block; max-width: 100%; max-height: 240mm; margin: 12px auto; object-fit: contain; }
|
|
210
|
+
pre { background: #f6f8fa; border: 1px solid #e5e7eb; border-radius: 10px; padding: 12px; overflow: hidden; white-space: pre-wrap; word-break: break-word; }
|
|
211
|
+
code { font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace; font-size: .92em; }
|
|
212
|
+
p code, li code, td code { background: #f3f4f6; border-radius: 4px; padding: 1px 4px; }
|
|
213
|
+
blockquote { margin: 1em 0; padding: .6em 1em; border-left: 4px solid #d1d5db; color: #4b5563; background: #f9fafb; }
|
|
214
|
+
table { border-collapse: collapse; width: 100%; margin: 1em 0; font-size: 13px; }
|
|
215
|
+
th, td { border: 1px solid #d1d5db; padding: 7px 9px; vertical-align: top; }
|
|
216
|
+
th { background: #f3f4f6; font-weight: 700; }
|
|
217
|
+
tr { page-break-inside: avoid; }
|
|
218
|
+
hr { border: none; border-top: 1px solid #e5e7eb; margin: 1.6em 0; }
|
|
219
|
+
</style>
|
|
220
|
+
</head>
|
|
221
|
+
<body>
|
|
222
|
+
${body}
|
|
223
|
+
</body>
|
|
224
|
+
</html>`;
|
|
225
|
+
}
|
|
226
|
+
function executableCandidates() {
|
|
227
|
+
const envPath = process.env.SHENNIAN_CHROME_PATH;
|
|
228
|
+
const candidates = envPath ? [envPath] : [];
|
|
229
|
+
if (process.platform === 'darwin') {
|
|
230
|
+
candidates.push('/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', '/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge', '/Applications/Chromium.app/Contents/MacOS/Chromium', '/Applications/Brave Browser.app/Contents/MacOS/Brave Browser');
|
|
231
|
+
}
|
|
232
|
+
else if (process.platform === 'win32') {
|
|
233
|
+
const roots = [
|
|
234
|
+
process.env.PROGRAMFILES,
|
|
235
|
+
process.env['PROGRAMFILES(X86)'],
|
|
236
|
+
process.env.LOCALAPPDATA,
|
|
237
|
+
].filter((value) => Boolean(value));
|
|
238
|
+
for (const root of roots) {
|
|
239
|
+
candidates.push(path.join(root, 'Google', 'Chrome', 'Application', 'chrome.exe'), path.join(root, 'Microsoft', 'Edge', 'Application', 'msedge.exe'));
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
else {
|
|
243
|
+
candidates.push('google-chrome-stable', 'google-chrome', 'chromium-browser', 'chromium', 'microsoft-edge', 'brave-browser');
|
|
244
|
+
}
|
|
245
|
+
return candidates;
|
|
246
|
+
}
|
|
247
|
+
export async function findChromiumExecutable(explicitPath) {
|
|
248
|
+
const candidates = explicitPath ? [explicitPath] : executableCandidates();
|
|
249
|
+
for (const candidate of candidates) {
|
|
250
|
+
if (candidate.includes(path.sep) || path.isAbsolute(candidate)) {
|
|
251
|
+
if (fs.existsSync(candidate))
|
|
252
|
+
return candidate;
|
|
253
|
+
continue;
|
|
254
|
+
}
|
|
255
|
+
try {
|
|
256
|
+
await execFileAsync(candidate, ['--version'], { timeout: 5000 });
|
|
257
|
+
return candidate;
|
|
258
|
+
}
|
|
259
|
+
catch {
|
|
260
|
+
// continue
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
throw new Error('No Chrome/Edge/Chromium executable found. Install Chrome/Edge/Chromium or set SHENNIAN_CHROME_PATH.');
|
|
264
|
+
}
|
|
265
|
+
export async function convertMarkdownToPdf(inputPath, options = {}) {
|
|
266
|
+
const resolvedInput = path.resolve(inputPath);
|
|
267
|
+
const stat = fs.statSync(resolvedInput);
|
|
268
|
+
if (!stat.isFile())
|
|
269
|
+
throw new Error(`Not a file: ${inputPath}`);
|
|
270
|
+
if (!/\.mdx?$/i.test(resolvedInput))
|
|
271
|
+
throw new Error('Input must be a Markdown file (.md or .mdx)');
|
|
272
|
+
const outputPath = path.resolve(options.outputPath || defaultPdfOutputPath(resolvedInput));
|
|
273
|
+
fs.mkdirSync(path.dirname(outputPath), { recursive: true });
|
|
274
|
+
const markdown = fs.readFileSync(resolvedInput, 'utf8');
|
|
275
|
+
const html = renderMarkdownHtml(markdown, {
|
|
276
|
+
title: options.title || path.basename(resolvedInput),
|
|
277
|
+
sourceDir: path.dirname(resolvedInput),
|
|
278
|
+
});
|
|
279
|
+
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'shennian-mdpdf-'));
|
|
280
|
+
const htmlPath = options.keepHtml ? outputPath.replace(/\.pdf$/i, '.html') : path.join(tempDir, 'document.html');
|
|
281
|
+
fs.writeFileSync(htmlPath, html, 'utf8');
|
|
282
|
+
const browserPath = await findChromiumExecutable(options.chromePath);
|
|
283
|
+
const args = [
|
|
284
|
+
'--headless=new',
|
|
285
|
+
'--disable-gpu',
|
|
286
|
+
'--no-sandbox',
|
|
287
|
+
'--disable-dev-shm-usage',
|
|
288
|
+
'--allow-file-access-from-files',
|
|
289
|
+
`--print-to-pdf=${outputPath}`,
|
|
290
|
+
pathToFileURL(htmlPath).href,
|
|
291
|
+
];
|
|
292
|
+
try {
|
|
293
|
+
await execFileAsync(browserPath, args, { timeout: 120_000, maxBuffer: 1024 * 1024 });
|
|
294
|
+
}
|
|
295
|
+
finally {
|
|
296
|
+
if (!options.keepHtml)
|
|
297
|
+
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
298
|
+
}
|
|
299
|
+
if (!fs.existsSync(outputPath) || fs.statSync(outputPath).size === 0) {
|
|
300
|
+
throw new Error('PDF export failed: output file was not created');
|
|
301
|
+
}
|
|
302
|
+
return { inputPath: resolvedInput, outputPath, htmlPath: options.keepHtml ? htmlPath : null, browserPath };
|
|
303
|
+
}
|
|
@@ -73,7 +73,7 @@ export function compareVersions(current, latest) {
|
|
|
73
73
|
}
|
|
74
74
|
// ─── npm global path helpers ──────────────────────────────────────────────────
|
|
75
75
|
function getNpmGlobalRoot() {
|
|
76
|
-
return execSync('npm root -g', { encoding: 'utf-8', stdio: 'pipe' }).trim();
|
|
76
|
+
return execSync('npm root -g', { encoding: 'utf-8', stdio: 'pipe', windowsHide: true }).trim();
|
|
77
77
|
}
|
|
78
78
|
function getGlobalPkgDir() {
|
|
79
79
|
return path.join(getNpmGlobalRoot(), 'shennian');
|
|
@@ -222,7 +222,7 @@ export async function performUpgrade(targetVersion, onProgress, opts = {}) {
|
|
|
222
222
|
// Step 1: Verify npm is accessible
|
|
223
223
|
onProgress({ step: 'checking' });
|
|
224
224
|
try {
|
|
225
|
-
execSync('npm --version', { stdio: 'pipe' });
|
|
225
|
+
execSync('npm --version', { stdio: 'pipe', windowsHide: true });
|
|
226
226
|
}
|
|
227
227
|
catch {
|
|
228
228
|
return { ok: false, error: 'npm is not available in PATH' };
|
|
@@ -231,7 +231,7 @@ export async function performUpgrade(targetVersion, onProgress, opts = {}) {
|
|
|
231
231
|
onProgress({ step: 'verifying', version: targetVersion });
|
|
232
232
|
try {
|
|
233
233
|
const binScript = getGlobalBinScript();
|
|
234
|
-
const { stdout } = await exec(`node "${binScript}" --version`, { timeout: 10_000 });
|
|
234
|
+
const { stdout } = await exec(`node "${binScript}" --version`, { timeout: 10_000, windowsHide: true });
|
|
235
235
|
if (!stdout.trim())
|
|
236
236
|
throw new Error('Empty output from --version check');
|
|
237
237
|
}
|
|
@@ -263,7 +263,7 @@ export async function performUpgrade(targetVersion, onProgress, opts = {}) {
|
|
|
263
263
|
// Step 3: npm install new version
|
|
264
264
|
onProgress({ step: 'installing', version: targetVersion });
|
|
265
265
|
try {
|
|
266
|
-
await exec(`npm install -g shennian@${targetVersion}`, { timeout: 120_000 });
|
|
266
|
+
await exec(`npm install -g shennian@${targetVersion}`, { timeout: 120_000, windowsHide: true });
|
|
267
267
|
}
|
|
268
268
|
catch (err) {
|
|
269
269
|
// Restore backup and abort
|
|
@@ -281,7 +281,7 @@ export async function performUpgrade(targetVersion, onProgress, opts = {}) {
|
|
|
281
281
|
onProgress({ step: 'verifying', version: targetVersion });
|
|
282
282
|
try {
|
|
283
283
|
const binScript = getGlobalBinScript();
|
|
284
|
-
const { stdout } = await exec(`node "${binScript}" --version`, { timeout: 10_000 });
|
|
284
|
+
const { stdout } = await exec(`node "${binScript}" --version`, { timeout: 10_000, windowsHide: true });
|
|
285
285
|
if (!stdout.trim())
|
|
286
286
|
throw new Error('Empty output from --version check');
|
|
287
287
|
}
|