@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 +22 -1
- package/package.json +3 -2
- package/src/adapter-loader.ts +24 -4
- package/src/adapter-types.ts +14 -0
- package/src/commands/typeset-assets-sync.test.ts +3 -0
- package/src/plugin-runner.ts +10 -4
- package/src/plugin-runtime.test.ts +19 -0
- package/src/plugin-runtime.ts +11 -2
- package/src/runtime-package.ts +9 -3
- package/src/wechat-preview/index.ts +6 -1
- package/src/wechat-preview/server/http.ts +56 -1
- package/src/wechat-preview/server/studio.test.ts +35 -1
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
|
|
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.
|
|
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.
|
|
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
|
},
|
package/src/adapter-loader.ts
CHANGED
|
@@ -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: () =>
|
|
79
|
-
|
|
80
|
-
|
|
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
|
|
package/src/adapter-types.ts
CHANGED
|
@@ -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);
|
package/src/plugin-runner.ts
CHANGED
|
@@ -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"
|
|
10
|
-
|
|
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"],
|
package/src/plugin-runtime.ts
CHANGED
|
@@ -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> } = {},
|
package/src/runtime-package.ts
CHANGED
|
@@ -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
|
-
|
|
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:
|
|
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 =
|
|
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 {
|
|
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
|
});
|