@akairos/pipeline 0.18.5 → 0.19.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.
package/README.md CHANGED
@@ -160,7 +160,7 @@ Pipeline 在创建和更新草稿时,会自动读取封面图真实尺寸,
160
160
  | **`@akairos/milkdown-article-plugins`** | Remark / Milkdown 全语法自定义解析与渲染插件 |
161
161
  | **`@akairos/typeset-studio`** | 微信大图头图、多比例排版与卡片视觉合成引擎 |
162
162
 
163
- 配置在 `plugins.imageRenderer` / `plugins.markdownRenderer` 的外部插件在独立 Bun 子进程中加载和执行。加载与 doctor 默认上限为 10 秒,渲染为 120 秒;Markdown 渲染显式传入 `timeoutMs` 时,子进程上限额外预留 10 秒。可通过 `ZZHUB_PIPELINE_PLUGIN_TIMEOUT_MS` 覆盖调用上限。同步死循环、超时和异常退出会终止插件进程,非法返回值会给出 `PLUGIN_*` 错误,命令不会自动重试或静默切换模板。
163
+ 配置在 `plugins.imageRenderer` / `plugins.markdownRenderer` 的外部插件在独立 Bun 子进程中加载,插件的 `render`、`doctor`、`browser` 和 `studio` 方法也在子进程调用。加载、资源入口查询与 doctor 默认上限为 10 秒,`render` 为 120 秒;Markdown 渲染显式传入 `timeoutMs` 时,子进程上限额外预留 10 秒。可通过 `ZZHUB_PIPELINE_PLUGIN_TIMEOUT_MS` 覆盖子进程调用上限。同步死循环、超时和异常退出会终止插件进程,非法返回值会给出 `PLUGIN_*` 错误,命令不会自动重试或静默切换模板。浏览器型插件返回入口后,由 pipeline 现有 Chrome 运行时加载页面,并按渲染 `timeoutMs` 管理浏览器生命周期。
164
164
 
165
165
  图片渲染失败后任务进入失败状态,保留正文与审核结果。修复插件后执行 `zzp reset --state <state_path> --mode render`,再继续任务;也可以将对应的 `plugins.*Renderer` 配置设为 `null` 使用内置渲染器。外部插件通过 `onProgress` 回传进度,不应依赖修改输入对象或跨调用保存内存状态。子进程继承当前环境和文件权限,这一机制负责故障隔离,不是权限沙箱。
166
166
 
@@ -170,6 +170,27 @@ Pipeline 在创建和更新草稿时,会自动读取封面图真实尺寸,
170
170
 
171
171
  旧的字符串入口继续有效,等同于没有配置专属参数;`null` 使用内置渲染器。`zzp config --import` 中的渲染器条目整体替换原条目,只改一个参数时使用 `--key plugins.imageRenderer.options.<参数名>`。
172
172
 
173
+ ### myno 排版插件
174
+
175
+ myno 通过可选的 Markdown 渲染插件接入。从 pipeline 0.19.0 起可选依赖固定为 `@akairos/myno@0.3.0`,仍需在配置中显式启用;使用 `--omit optional` 安装时需自行提供插件。npm 配置示例:
176
+
177
+ ```json
178
+ {
179
+ "plugins": {
180
+ "markdownRenderer": {
181
+ "module": "@akairos/myno/adapter",
182
+ "options": {}
183
+ }
184
+ }
185
+ }
186
+ ```
187
+
188
+ 本地开发时可先在 myno 仓库执行 `bun run build`,将 `module` 指向构建后的 `dist/adapter.js` 绝对路径。运行时部署包只接受 npm 入口和空 `options`,不会复制本机插件路径。
189
+
190
+ 插件可以实现原有的 `render(input, options)`,也可以像 myno 一样只实现 `browser(options)`,返回浏览器排版入口的绝对路径 `{ scriptPath }`。`wechat-export` 和公众号文章发布通过同一个插件加载器调度;浏览器型插件复用 pipeline 现有的 Chrome、超时清理、离线模板校验和文件输出,仅页面排版实现来自插件。myno 不增加浏览器自动化依赖,调样式和导出 config 也不需要启动无头浏览器。独立样式仍与发布账号分离。
191
+
192
+ Markdown 插件可选提供 `studio(options)`,返回绝对资源目录 `root` 和该目录内的 HTML 相对路径 `entry`。`zzp wechat-preview studio` 将插件页面挂到 `/studio`,脚本和样式挂到 `/studio-assets/`;`/api/studio/config`、`/api/studio/save-config` 和历史记录继续由 pipeline 提供。插件不支持 Studio 时返回明确提示;配置为 `null` 保留内置工作台。切换插件后需重启已运行的预览服务。
193
+
173
194
  ### Typeset Studio 插件配置
174
195
 
175
196
  Pipeline 已通过 npm 依赖 `@akairos/typeset-studio`。在本地配置文件启用适配器,并指定与 Studio 编辑器共用的数据目录:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@akairos/pipeline",
3
- "version": "0.18.5",
3
+ "version": "0.19.0",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "bin": {
@@ -29,13 +29,14 @@
29
29
  "dependencies": {
30
30
  "@akairos/milkdown-article-plugins": "^0.1.6",
31
31
  "@akairos/milkdown-article-style": "^0.1.2",
32
- "@akairos/typeset-studio": "0.2.5",
32
+ "@akairos/typeset-studio": "0.2.6",
33
33
  "@chenglou/pretext": "0.0.8",
34
34
  "image-size": "^2.0.2",
35
35
  "pngjs": "^7.0.0",
36
36
  "zod": "^4.4.2"
37
37
  },
38
38
  "optionalDependencies": {
39
+ "@akairos/myno": "0.3.0",
39
40
  "@napi-rs/canvas": "^0.1.97",
40
41
  "cos-nodejs-sdk-v5": "^2.15.4"
41
42
  },
@@ -15,7 +15,10 @@ import type {
15
15
  } from "./adapter-types";
16
16
  import { builtinImageRenderer } from "./adapters/builtin-image-renderer";
17
17
  import { builtinMarkdownRenderer } from "./adapters/builtin-markdown-renderer";
18
- import { PluginDoctorSchema, PluginImageOutputSchema, PluginMarkdownOutputSchema, PluginMetadataSchema, runExternalPlugin } from "./plugin-runtime";
18
+ import { PluginBrowserSchema, PluginDoctorSchema, PluginImageOutputSchema, PluginMarkdownOutputSchema, PluginMetadataSchema, PluginStudioSchema, runExternalPlugin } from "./plugin-runtime";
19
+ import { exportMarkdownToWechatHtml } from "./wechat-preview";
20
+ import { notifyProgress } from "./monitor/recorder";
21
+ import { findChrome } from "./imgx/runtime";
19
22
 
20
23
  // ── Public API ───────────────────────────────────────────────────
21
24
 
@@ -75,9 +78,26 @@ export async function resolveMarkdownRenderer(
75
78
  return {
76
79
  name: metadata.name,
77
80
  version: metadata.version,
78
- ...(metadata.hasDoctor ? { doctor: () => runExternalPlugin(pluginPath, "doctor", PluginDoctorSchema, undefined, { options }) } : {}),
79
- render: ({ onProgress, ...input }) => runExternalPlugin(pluginPath, "render", PluginMarkdownOutputSchema, input, { options }, onProgress,
80
- input.timeoutMs && Number.isFinite(input.timeoutMs) && input.timeoutMs > 0 ? input.timeoutMs + 10_000 : 120_000),
81
+ ...(metadata.hasDoctor || metadata.hasBrowser ? { doctor: async () => {
82
+ const checks = metadata.hasDoctor ? await runExternalPlugin(pluginPath, "doctor", PluginDoctorSchema, undefined, { options }) : [];
83
+ if (metadata.hasBrowser) {
84
+ const chrome = findChrome();
85
+ checks.push({ name: "chrome", ok: Boolean(chrome), message: chrome ?? "Chrome/Chromium not found" });
86
+ }
87
+ return checks;
88
+ } } : {}),
89
+ ...(metadata.hasStudio ? { studio: () => runExternalPlugin(pluginPath, "studio", PluginStudioSchema, undefined, { options }) } : {}),
90
+ render: async ({ onProgress, ...input }) => {
91
+ if (metadata.hasBrowser) {
92
+ const browser = await runExternalPlugin(pluginPath, "browser", PluginBrowserSchema, undefined, { options });
93
+ notifyProgress(onProgress, { stage: "render.html", message: "正在导出微信 HTML" });
94
+ const result = await exportMarkdownToWechatHtml({ ...input, browserScriptPath: browser.scriptPath });
95
+ notifyProgress(onProgress, { stage: "render.html", message: "微信 HTML 导出完成", current: 1, total: 1, unit: "files" });
96
+ return result;
97
+ }
98
+ return runExternalPlugin(pluginPath, "render", PluginMarkdownOutputSchema, input, { options }, onProgress,
99
+ input.timeoutMs && Number.isFinite(input.timeoutMs) && input.timeoutMs > 0 ? input.timeoutMs + 10_000 : 120_000);
100
+ },
81
101
  };
82
102
  }
83
103
 
@@ -207,7 +207,21 @@ export interface MarkdownRenderPlugin {
207
207
  /** Environment checks */
208
208
  doctor?: (options?: Record<string, unknown>) => Promise<PipelinePluginDoctorCheck[]>;
209
209
 
210
+ /** 工作台静态资源由插件分发,配置读写仍由宿主提供。 */
211
+ studio?: (options?: Record<string, unknown>) => Promise<MarkdownStudioAssets>;
212
+
210
213
  /** Render markdown to WeChat-compatible HTML */
211
214
  /** 专属配置作为第二个参数传入,旧插件可忽略。 */
212
215
  render: (input: MarkdownRenderInput, options?: Record<string, unknown>) => Promise<MarkdownRenderOutput>;
213
216
  }
217
+
218
+ /** root 是插件资源目录,entry 是该目录内的 HTML 相对路径。 */
219
+ export interface MarkdownStudioAssets {
220
+ root: string;
221
+ entry: string;
222
+ }
223
+
224
+ /** 仅提供浏览器排版资源的插件,Chrome 生命周期由 pipeline 管理。 */
225
+ export interface BrowserMarkdownRenderPlugin extends Omit<MarkdownRenderPlugin, "render"> {
226
+ browser: (options?: Record<string, unknown>) => Promise<{ scriptPath: string }>;
227
+ }
@@ -66,6 +66,7 @@ test("deduplicates shared images, rewrites only sources, and exports styles with
66
66
  const configPath = join(root, "config.json");
67
67
  const config = await result(configPath);
68
68
  config.wx.accounts.default.articleTheme = "style.json";
69
+ config.plugins.markdownRenderer = { module: "@akairos/myno/adapter", options: {} };
69
70
  await writeFile(configPath, JSON.stringify(config));
70
71
  let uploads = 0;
71
72
  const command = createTypesetAssetsSyncCommand({ upload: uploader(async () => { uploads++; }), verifyUrl });
@@ -74,10 +75,12 @@ test("deduplicates shared images, rewrites only sources, and exports styles with
74
75
  expect(uploads).toBe(1);
75
76
  expect((await result(join(root, "templates/jinx.json"))).images[0]).toEqual({ ...template.images[0], src: "https://cdn.example/jinx.png" });
76
77
  const patch = await result(join(destination, "pipeline.patch.json"));
78
+ expect(patch.plugins.markdownRenderer).toEqual({ module: "@akairos/myno/adapter", options: {} });
77
79
  expect(patch.wx.accounts.default).toMatchObject({ customCss: null, articleTheme: "/data/pipeline-runtime/markdown/article-themes/default/manifest.json" });
78
80
  expect((await readArticleTheme(join(destination, "markdown/article-themes/default/manifest.json"))).css).toBe(style.css);
79
81
  for (const secret of ["secret-cos-token", "private-secret", "private-app-id", "private-pat"]) expect(JSON.stringify(patch)).not.toContain(secret);
80
82
  expect((await result(join(destination, "manifest.json"))).typesetTemplates).toBe(2);
83
+ expect(await readFile(join(destination, "typeset-studio/templates/.canvas-presets-v1"), "utf8")).toBe("1");
81
84
  expect((await readFile(join(root, "uploads/jinx.png"))).equals(image)).toBe(true);
82
85
  await command(["--output", destination]);
83
86
  expect(uploads).toBe(1);
@@ -1,13 +1,15 @@
1
1
  import type { MonitorProgress } from "./monitor/types";
2
2
 
3
3
  /** 子进程只执行一次插件调用,进度与结果通过 IPC 返回。 */
4
- process.on("message", async (request: { specifier: string; action: "describe" | "doctor" | "render"; input?: Record<string, unknown>; options?: Record<string, unknown> }) => {
4
+ process.on("message", async (request: { specifier: string; action: "describe" | "doctor" | "render" | "studio" | "browser"; input?: Record<string, unknown>; options?: Record<string, unknown> }) => {
5
5
  try {
6
6
  const module = await import(request.specifier);
7
7
  const plugin = module.default ?? module.plugin ?? module;
8
8
  if (!plugin || typeof plugin !== "object" || typeof plugin.name !== "string" || !plugin.name.trim() ||
9
- typeof plugin.render !== "function" || (plugin.doctor !== undefined && typeof plugin.doctor !== "function")) {
10
- throw new Error("插件必须导出 name、render,以及可选的 doctor 函数");
9
+ (typeof plugin.render !== "function" && typeof plugin.browser !== "function") ||
10
+ (plugin.browser !== undefined && typeof plugin.browser !== "function") || (plugin.doctor !== undefined && typeof plugin.doctor !== "function") ||
11
+ (plugin.studio !== undefined && typeof plugin.studio !== "function")) {
12
+ throw new Error("插件必须导出 name、render 或 browser,以及可选的 doctor 函数和 studio 函数");
11
13
  }
12
14
  // 故障插件频繁报告进度时,限制 IPC 消息数量。
13
15
  let lastProgressAt = 0;
@@ -18,9 +20,13 @@ process.on("message", async (request: { specifier: string; action: "describe" |
18
20
  process.send?.({ type: "progress", value: progress });
19
21
  };
20
22
  const value = request.action === "describe"
21
- ? { name: plugin.name, version: plugin.version, hasDoctor: typeof plugin.doctor === "function" }
23
+ ? { name: plugin.name, version: plugin.version, hasDoctor: typeof plugin.doctor === "function", hasStudio: typeof plugin.studio === "function", hasBrowser: typeof plugin.browser === "function" }
22
24
  : request.action === "doctor"
23
25
  ? await plugin.doctor(request.options)
26
+ : request.action === "studio"
27
+ ? await plugin.studio(request.options)
28
+ : request.action === "browser"
29
+ ? await plugin.browser(request.options)
24
30
  : await plugin.render({ ...request.input, onProgress }, request.options);
25
31
  process.send?.({ type: "result", value });
26
32
  } catch (error) {
@@ -115,6 +115,25 @@ test("markdown plugins receive their own options in doctor and render", async ()
115
115
  expect((await plugin.render({ markdownPath: join(directory, "post.md"), outPath: join(directory, "post.html"), account: "default" })).html).toBe(options.html);
116
116
  });
117
117
 
118
+ test("browser-only markdown plugins validate their entry and receive their own options", async () => {
119
+ const pluginPath = join(directory, "browser-plugin.mjs");
120
+ await writeFile(pluginPath, `export default {name:'browser-test',browser(options){return {scriptPath:options.scriptPath}}}`);
121
+ const plugin = await resolveMarkdownRenderer(PipelineConfigSchema.parse({ plugins: { markdownRenderer: { module: pluginPath, options: { scriptPath: "relative.js" } } } }));
122
+ await expect(plugin.render({ markdownPath: "unused.md", outPath: "out.html", account: "default" })).rejects.toMatchObject({ code: "PLUGIN_INVALID_RESULT" });
123
+ expect((await plugin.doctor!()).some(check => check.name === "chrome")).toBe(true);
124
+ });
125
+
126
+ test("Studio capability is optional, isolated and validates asset directory paths", async () => {
127
+ const pluginPath = await fixture("return null", `async studio(options){return {root:${JSON.stringify(directory)},entry:options.entry}},`);
128
+ const config = (entry: string) => PipelineConfigSchema.parse({ plugins: { markdownRenderer: { module: pluginPath, options: { entry } } } });
129
+ const plugin = await resolveMarkdownRenderer(config("studio.html"));
130
+ expect(await plugin.studio!()).toEqual({ root: directory, entry: "studio.html" });
131
+ const invalid = await resolveMarkdownRenderer(config("../outside.html"));
132
+ await expect(invalid.studio!()).rejects.toMatchObject({ code: "PLUGIN_INVALID_RESULT" });
133
+ const plain = await resolveMarkdownRenderer(PipelineConfigSchema.parse({ plugins: { markdownRenderer: await fixture("return null") } }));
134
+ expect(plain.studio).toBeUndefined();
135
+ });
136
+
118
137
  test("reports thrown errors, process exits, and invalid image results without escaping the parent", async () => {
119
138
  for (const [body, code] of [
120
139
  ['throw new Error("bad template")', "PLUGIN_EXECUTION_FAILED"],
@@ -10,7 +10,7 @@ import { resolveBunBinary } from "./spawn";
10
10
  /** 插件错误保留阶段和错误码,终端与调用方使用同一提示。 */
11
11
  export class PluginExecutionError extends Error {
12
12
  constructor(readonly code: string, specifier: string, action: string, detail: string) {
13
- const stage = action === "describe" ? "加载" : action === "doctor" ? "检查" : "渲染";
13
+ const stage = action === "describe" || action === "browser" ? "加载" : action === "doctor" ? "检查" : action === "studio" ? "工作台加载" : "渲染";
14
14
  super(`[${code}] 插件「${specifier}」${stage}失败:${detail}。请检查插件配置、运行 zzp doctor 或切换内置渲染器,修复后再重试;本次未自动重试。`);
15
15
  this.name = "PluginExecutionError";
16
16
  }
@@ -20,6 +20,15 @@ export const PluginMetadataSchema = z.object({
20
20
  name: z.string().trim().min(1),
21
21
  version: z.string().optional(),
22
22
  hasDoctor: z.boolean(),
23
+ hasStudio: z.boolean().default(false),
24
+ hasBrowser: z.boolean().default(false),
25
+ });
26
+ export const PluginBrowserSchema = z.object({
27
+ scriptPath: z.string().refine(isAbsolute, "浏览器入口必须是绝对路径"),
28
+ });
29
+ export const PluginStudioSchema = z.object({
30
+ root: z.string().refine(isAbsolute, "Studio root 必须是绝对路径"),
31
+ entry: z.string().min(1).refine(value => !isAbsolute(value) && !value.split(/[\\/]/).includes(".."), "Studio entry 必须位于资源目录内"),
23
32
  });
24
33
  export const PluginDoctorSchema = z.array(z.object({
25
34
  name: z.string().min(1), ok: z.boolean(), message: z.string().optional(), detail: z.unknown().optional(),
@@ -48,7 +57,7 @@ const ProgressSchema = z.object({
48
57
  /** 外部插件独立执行;父进程可终止同步死循环、异常退出和永不结束的 Promise。 */
49
58
  export async function runExternalPlugin<T>(
50
59
  specifier: string,
51
- action: "describe" | "doctor" | "render",
60
+ action: "describe" | "doctor" | "render" | "studio" | "browser",
52
61
  schema: z.ZodType<T>,
53
62
  input?: Record<string, unknown>,
54
63
  configuration: { options?: Record<string, unknown>; environment?: Record<string, string> } = {},
@@ -61,7 +61,7 @@ export function validateStudioDefaults(text: string, templateIds: Set<string>):
61
61
  }
62
62
 
63
63
  /** 准备账号主题文件,只导出样式白名单,不复制凭据或本机插件入口。 */
64
- export async function prepareMarkdownRuntime(config: PipelineConfig, accountMap: Record<string, string> = {}): Promise<{ files: Map<string, Uint8Array>; accounts: Record<string, unknown> }> {
64
+ export async function prepareMarkdownRuntime(config: PipelineConfig, accountMap: Record<string, string> = {}): Promise<{ files: Map<string, Uint8Array>; accounts: Record<string, unknown>; markdownRenderer: PipelineConfig["plugins"]["markdownRenderer"] }> {
65
65
  const files = new Map<string, Uint8Array>();
66
66
  const accounts: Record<string, unknown> = {};
67
67
  for (const [localAccount, value] of Object.entries(config.wx.accounts)) {
@@ -84,7 +84,12 @@ export async function prepareMarkdownRuntime(config: PipelineConfig, accountMap:
84
84
  }
85
85
  accounts[account] = next;
86
86
  }
87
- return { files, accounts };
87
+ const entry = config.plugins.markdownRenderer;
88
+ const module = typeof entry === "string" ? entry : entry?.module;
89
+ // 部署只接受可复现的 npm 入口,禁止把本机绝对路径带到服务器。
90
+ if (module && module !== "@akairos/myno/adapter") throw new Error("运行时同步仅支持 @akairos/myno/adapter 或内置 Markdown 渲染器");
91
+ if (typeof entry === "object" && entry && Object.keys(entry.options).length) throw new Error("myno 运行时同步暂不接受专属参数");
92
+ return { files, accounts, markdownRenderer: module ? { module, options: {} } : null };
88
93
  }
89
94
 
90
95
  /** 写入带文件哈希的独立配置包,调用者在全部成功后切换目录。 */
@@ -101,8 +106,9 @@ export async function writeRuntimePackage(destination: string, templates: Map<st
101
106
  files.set("typeset-studio/default-template.json", Buffer.from(defaults));
102
107
  // 防止渲染时重新播种内置模板,使部署目录与文件清单保持一致。
103
108
  files.set("typeset-studio/templates/.initialized", Buffer.from("1"));
109
+ files.set("typeset-studio/templates/.canvas-presets-v1", Buffer.from("1"));
104
110
  const patch = {
105
- plugins: { imageRenderer: { module: "@akairos/typeset-studio/adapter", options: { dataDir: `${RUNTIME_ROOT}/typeset-studio` } }, markdownRenderer: null },
111
+ plugins: { imageRenderer: { module: "@akairos/typeset-studio/adapter", options: { dataDir: `${RUNTIME_ROOT}/typeset-studio` } }, markdownRenderer: markdown.markdownRenderer },
106
112
  wx: { accounts: markdown.accounts },
107
113
  };
108
114
  files.set("pipeline.patch.json", Buffer.from(JSON.stringify(patch, null, 2) + "\n"));
@@ -100,6 +100,8 @@ export interface ExportMarkdownToWechatHtmlInput {
100
100
  timeoutMs?: number;
101
101
  /** Write intermediate artifacts for debugging. */
102
102
  debugDir?: string;
103
+ /** 外部插件提供的已构建入口;浏览器和文件输出继续由宿主管理。 */
104
+ browserScriptPath?: string;
103
105
  }
104
106
 
105
107
  export interface ExportMarkdownToWechatHtmlResult {
@@ -405,7 +407,9 @@ export async function exportMarkdownToWechatHtml(
405
407
 
406
408
  let bundle: BundleAssets;
407
409
  try {
408
- bundle = ensureBundle();
410
+ bundle = input.browserScriptPath
411
+ ? { scriptPath: input.browserScriptPath, rebuilt: false, stale: false }
412
+ : ensureBundle();
409
413
  bundleRebuilt = bundle.rebuilt;
410
414
  bundleStale = bundle.stale;
411
415
  } catch (error) {
@@ -432,6 +436,7 @@ export async function exportMarkdownToWechatHtml(
432
436
  assetBaseUrl: pathToFileURL(`${dirname(resolve(input.markdownPath))}/`).href,
433
437
  editorVars: theme.editorVars,
434
438
  exportTheme: theme.exportTheme,
439
+ syntaxPresets,
435
440
  customCss,
436
441
  structure,
437
442
  }),
@@ -1,5 +1,6 @@
1
1
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
2
- import { dirname, join } from "path";
2
+ import { realpath, stat } from "fs/promises";
3
+ import { dirname, isAbsolute, join, relative, resolve, sep } from "path";
3
4
  import { z } from "zod";
4
5
  import {
5
6
  previewBaseUrl,
@@ -32,6 +33,8 @@ import { readArticleTheme } from "../../article-theme";
32
33
  import { PipelineConfigSchema, WechatExportThemeOverridesSchema, WechatStructureOverridesSchema } from "../../schema/config";
33
34
  import { loadConfig, saveConfig, getPipelineConfigPath, resolveConfigRelativePath } from "../../config";
34
35
  import { DIST_DIR, WECHAT_PREVIEW_DIR } from "../../runtime-paths";
36
+ import { resolveMarkdownRenderer } from "../../adapter-loader";
37
+ import type { MarkdownStudioAssets } from "../../adapter-types";
35
38
  import type { PreviewRegisterInput } from "./types";
36
39
 
37
40
  /** 保存入口先校验完整请求,避免写入 CLI 无法加载的配置。 */
@@ -105,6 +108,32 @@ export async function startPreviewServer(
105
108
  clearServerLock();
106
109
  }
107
110
 
111
+ // 工作台与导出使用同一个插件;没有工作台能力时给出明确提示。
112
+ const pluginConfig = loadConfig();
113
+ const externalMarkdown = Boolean(pluginConfig.plugins.markdownRenderer);
114
+ let studioAssets: Promise<MarkdownStudioAssets | undefined> | undefined;
115
+ /** 按需加载工作台,故障插件不影响预览登记和历史页面。 */
116
+ function getStudio() {
117
+ return studioAssets ??= resolveMarkdownRenderer(pluginConfig).then(async renderer => {
118
+ const studio = await renderer.studio?.();
119
+ return studio ? { ...studio, root: await realpath(studio.root) } : undefined;
120
+ });
121
+ }
122
+
123
+ /** 插件静态资源独立挂载,真实路径也必须留在插件资源目录内。 */
124
+ async function studioFile(name: string): Promise<string | null> {
125
+ try {
126
+ if (!externalMarkdown) return null;
127
+ const studioRoot = (await getStudio())?.root;
128
+ if (!studioRoot) return null;
129
+ const target = await realpath(resolve(studioRoot, name));
130
+ const rel = relative(studioRoot, target);
131
+ return rel && !isAbsolute(rel) && rel !== ".." && !rel.startsWith(`..${sep}`) && (await stat(target)).isFile() ? target : null;
132
+ } catch {
133
+ return null;
134
+ }
135
+ }
136
+
108
137
  let server: ReturnType<typeof Bun.serve>;
109
138
  server = Bun.serve({
110
139
  hostname: host,
@@ -317,6 +346,21 @@ export async function startPreviewServer(
317
346
  }
318
347
 
319
348
  if (pathname === "/studio" || pathname === "/studio/" || pathname === "/studio/index.html") {
349
+ if (externalMarkdown) {
350
+ let studio: MarkdownStudioAssets | undefined;
351
+ try { studio = await getStudio(); }
352
+ catch (error) { return json({ error: error instanceof Error ? error.message : String(error) }, 500); }
353
+ if (!studio) return html("<h1>当前 Markdown 渲染插件未提供 Studio 工作台。</h1>", 404);
354
+ const entry = await studioFile(studio.entry);
355
+ if (!entry) return html("<h1>插件 Studio 入口不存在或超出资源目录。</h1>", 500);
356
+ // 相对资源按插件入口解析,统一放入命名空间且保留页面导航链接。
357
+ return html(readFileSync(entry, "utf8").replace(/<(?:script|link)\b[^>]*>/g,
358
+ tag => tag.replace(/\b(src|href)=(["'])([^"']+)\2/g, (attribute, name, quote, value: string) => {
359
+ if (/^(?:[a-z][a-z0-9+.-]*:|\/\/|#)/i.test(value)) return attribute;
360
+ const asset = new URL(value, `http://studio/${studio.entry}`);
361
+ return `${name}=${quote}/studio-assets${asset.pathname}${asset.search}${quote}`;
362
+ })));
363
+ }
320
364
  const studioHtmlPath = join(DIST_DIR, "src/wechat-preview/browser/studio/studio.html");
321
365
  const fallbackPath = join(DIST_DIR, "studio.html");
322
366
  const targetHtmlPath = existsSync(studioHtmlPath) ? studioHtmlPath : fallbackPath;
@@ -326,6 +370,17 @@ export async function startPreviewServer(
326
370
  return html("<h1>Studio not built. Run bun run build:wechat-preview first.</h1>", 500);
327
371
  }
328
372
 
373
+ if (pathname.startsWith("/studio-assets/")) {
374
+ let name: string;
375
+ try { name = decodeURIComponent(pathname.slice("/studio-assets/".length)); }
376
+ catch { return json({ error: "invalid asset path" }, 400); }
377
+ const target = await studioFile(name);
378
+ if (!target) return json({ error: "Studio asset not found" }, 404);
379
+ return new Response(new Uint8Array(readFileSync(target)), {
380
+ headers: { "content-type": guessContentType(target), "cache-control": "no-cache" },
381
+ });
382
+ }
383
+
329
384
  if (pathname === "/studio.js" || pathname === "/editor-export.js") {
330
385
  const filePath = join(DIST_DIR, pathname.slice(1));
331
386
  if (existsSync(filePath)) {
@@ -2,7 +2,7 @@ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
2
 
3
3
  import { tmpdir } from "os";
4
4
  import { join } from "path";
5
- import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "fs";
5
+ import { mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "fs";
6
6
  import { startPreviewServer, type StartPreviewServerResult } from "./http";
7
7
  import { loadConfig, saveConfig } from "../../config";
8
8
 
@@ -192,4 +192,38 @@ describe("wechat-preview studio server APIs", () => {
192
192
  expect(html).toContain("studio.js");
193
193
  expect(html).toContain('id="btn-export-config"');
194
194
  });
195
+
196
+ test("external Studio serves plugin resources without exposing files outside its root", async () => {
197
+ const root = join(testDir, "plugin-assets");
198
+ mkdirSync(root);
199
+ writeFileSync(join(root, "studio.html"), '<script src="/studio.js"></script><link href="/style.css"><a href="/">History</a>');
200
+ writeFileSync(join(root, "studio.js"), "/* plugin studio */");
201
+ writeFileSync(join(testDir, "private.txt"), "private");
202
+ symlinkSync(join(testDir, "private.txt"), join(root, "outside.txt"));
203
+ const pluginPath = join(testDir, "studio-plugin.mjs");
204
+ writeFileSync(pluginPath, `export default {name:'studio-test',render(){},studio(){return {root:${JSON.stringify(root)},entry:'studio.html'}}}`);
205
+ const config = loadConfig();
206
+ const original = config.plugins.markdownRenderer;
207
+ config.plugins.markdownRenderer = pluginPath;
208
+ saveConfig(config);
209
+ let external: StartPreviewServerResult | undefined;
210
+ const originalServerDir = process.env.ZZHUB_WECHAT_PREVIEW_DIR;
211
+ process.env.ZZHUB_WECHAT_PREVIEW_DIR = join(testDir, "external-server");
212
+ try {
213
+ external = await startPreviewServer({ host: "127.0.0.1", port: 0, reuseExisting: false });
214
+ const page = await (await fetch(`${external.url}/studio`)).text();
215
+ expect(page).toContain('src="/studio-assets/studio.js"');
216
+ expect(page).toContain('href="/studio-assets/style.css"');
217
+ expect(page).toContain('<a href="/">History</a>');
218
+ expect(await (await fetch(`${external.url}/studio-assets/studio.js`)).text()).toBe("/* plugin studio */");
219
+ expect((await fetch(`${external.url}/studio-assets/outside.txt`)).status).toBe(404);
220
+ expect((await fetch(`${external.url}/studio-assets/..%2fprivate.txt`)).status).toBe(404);
221
+ expect((await fetch(`${external.url}/api/studio/config`)).status).toBe(200);
222
+ } finally {
223
+ external?.stop?.();
224
+ process.env.ZZHUB_WECHAT_PREVIEW_DIR = originalServerDir;
225
+ config.plugins.markdownRenderer = original;
226
+ saveConfig(config);
227
+ }
228
+ });
195
229
  });