@akairos/pipeline 0.19.1 → 0.20.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
@@ -234,7 +234,7 @@ Studio 模板与 pipeline 配置分开保存:`data/templates/*.json` 保存模
234
234
 
235
235
  固定素材的 `templateAssetKey` 是模板内的替换标识,图片来源仍是 `images[].src`。支持可访问的 HTTP/HTTPS URL、本地文件及 `/uploads/...` 引用。使用 `/uploads/...` 时应连同 `uploads/` 目录原文件名一起同步;绝对路径应改为服务器可用路径,或把素材上传到 COS/CDN 后使用稳定 HTTPS URL。编辑器预览远程图片还需要图片服务允许跨域读取。
236
236
 
237
- **版本基线(2026-10-15):** Pipeline `0.19.1` 固定 Typeset Studio `0.2.6` 和 myno `0.3.1`;插件仍需显式配置。模板与素材不打进 npm 包,服务器通过运行时配置包提供。源码适配器路径仅用于本地联调,Worker 使用 npm 入口。
237
+ **版本基线(2026-10-15):** Pipeline `0.19.2` 固定 Typeset Studio `0.2.7` 和 myno `0.3.1`;插件仍需显式配置。模板与素材不打进 npm 包,服务器通过运行时配置包提供。源码适配器路径仅用于本地联调,Worker 使用 npm 入口。
238
238
 
239
239
  ### 生成 Worker 运行时配置包
240
240
 
@@ -0,0 +1,2 @@
1
+ export { createArticleStyleSnapshot, validateArticleStyleSnapshot } from "./src/article-style";
2
+ export type { ArticleStyleManifest, ArticleStyleSnapshot } from "./src/article-style";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@akairos/pipeline",
3
- "version": "0.19.1",
3
+ "version": "0.20.0",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "bin": {
@@ -12,6 +12,7 @@
12
12
  },
13
13
  "files": [
14
14
  "src/",
15
+ "article-style.ts",
15
16
  "ARTICLE-THEMES.md",
16
17
  "THIRD-PARTY-NOTICES.md"
17
18
  ],
@@ -28,7 +29,7 @@
28
29
  },
29
30
  "dependencies": {
30
31
  "@akairos/milkdown-article-plugins": "^0.1.6",
31
- "@akairos/typeset-studio": "0.2.6",
32
+ "@akairos/typeset-studio": "0.2.7",
32
33
  "@chenglou/pretext": "0.0.8",
33
34
  "image-size": "^2.0.2",
34
35
  "pngjs": "^7.0.0",
@@ -0,0 +1,34 @@
1
+ import { expect, test } from "bun:test";
2
+ import { createArticleStyleSnapshot, validateArticleStyleSnapshot } from "../article-style";
3
+
4
+ const manifest = {
5
+ schemaVersion: 1, engineVersion: 1, id: "style-1", name: "正文样式", version: "1.0.0",
6
+ editorVars: { "--brand": "#AABBCC" },
7
+ exportTheme: { bodyFontSize: "18.00px", bodyLineHeight: "1.80", primaryColor: "#AABBCC", h2Color: "#112233" },
8
+ syntaxPresets: { h2: "pillar" },
9
+ };
10
+
11
+ test("正文样式规范化、内容 hash 与修订身份独立且可严格回读", () => {
12
+ const snapshot = createArticleStyleSnapshot(manifest, manifest.id, 1);
13
+ expect(snapshot.manifest.exportTheme.bodyFontSize).toBe("18px");
14
+ expect(snapshot.manifest.exportTheme.bodyLineHeight).toBe("1.8");
15
+ expect(snapshot.manifest.editorVars["--brand"]).toBe("#aabbcc");
16
+ expect(snapshot.manifest.css).toBe("");
17
+ expect(validateArticleStyleSnapshot(JSON.parse(JSON.stringify(snapshot)))).toEqual(snapshot);
18
+ expect(createArticleStyleSnapshot(snapshot.manifest, manifest.id, 2).hash).toBe(snapshot.hash);
19
+ const reordered = Object.fromEntries(Object.entries(manifest).reverse());
20
+ expect(createArticleStyleSnapshot(reordered, manifest.id, 1).hash).toBe(snapshot.hash);
21
+ });
22
+
23
+ test("正文快照拒绝未知版本、身份分裂、篡改及未支持字段", () => {
24
+ const snapshot = createArticleStyleSnapshot(manifest, manifest.id, 1);
25
+ for (const patch of [{ schemaVersion: 2 }, { revision: 0 }, { id: "other" }, { hash: "0".repeat(64) }, { path: "/private/style.json" }]) {
26
+ expect(() => validateArticleStyleSnapshot({ ...snapshot, ...patch })).toThrow();
27
+ }
28
+ for (const patch of [
29
+ { engineVersion: 2 }, { css: "p { color: red }" }, { structure: {} }, { assets: [] }, { fonts: [] },
30
+ { editorVars: { "--font": "serif" } }, { editorVars: { "--brand": "url(https://example.test/a)" } },
31
+ { exportTheme: { bodyFontSize: "25px" } }, { exportTheme: { bodyLineHeight: "1.1" } },
32
+ { exportTheme: { fontFamily: "serif" } }, { syntaxPresets: { h2: "unknown" } },
33
+ ]) expect(() => createArticleStyleSnapshot({ ...manifest, ...patch }, manifest.id, 1)).toThrow();
34
+ });
@@ -0,0 +1,57 @@
1
+ import { createHash } from "node:crypto";
2
+ import { z } from "zod";
3
+ import { ArticleThemeSchema, validateArticleThemeCss } from "./article-theme";
4
+
5
+ const color = z.string().regex(/^#[a-f\d]{6}$/i).transform(value => value.toLowerCase());
6
+ const fontSize = z.string().regex(/^\d+(?:\.\d+)?px$/)
7
+ .refine(value => Number.parseFloat(value) >= 12 && Number.parseFloat(value) <= 24)
8
+ .transform(value => `${Number.parseFloat(value)}px`);
9
+ const lineHeight = z.string().regex(/^\d+(?:\.\d+)?$/)
10
+ .refine(value => Number(value) >= 1.2 && Number(value) <= 2.4)
11
+ .transform(value => String(Number(value)));
12
+
13
+ // 复用主题身份,仅开放本轮已验证的纯 JSON 正文样式。
14
+ const ArticleStyleManifestSchema = ArticleThemeSchema.pick({
15
+ schemaVersion: true, engineVersion: true, id: true, name: true, version: true, description: true,
16
+ }).extend({
17
+ editorVars: z.object({ "--brand": color.optional() }).strict().default({}),
18
+ exportTheme: z.object({
19
+ bodyFontSize: fontSize.optional(), bodyLineHeight: lineHeight.optional(),
20
+ primaryColor: color.optional(), h2Color: color.optional(),
21
+ }).strict().default({}),
22
+ syntaxPresets: z.object({ h2: z.enum(["pillar", "bottom-line", "plain"]).optional() }).strict().default({}),
23
+ css: z.literal("").default(""),
24
+ }).strict();
25
+
26
+ export type ArticleStyleManifest = z.infer<typeof ArticleStyleManifestSchema>;
27
+ const SnapshotHeaderSchema = z.object({
28
+ schemaVersion: z.literal(1),
29
+ id: ArticleThemeSchema.shape.id,
30
+ revision: z.number().int().positive(),
31
+ }).strict();
32
+
33
+ export interface ArticleStyleSnapshot {
34
+ schemaVersion: 1;
35
+ id: string;
36
+ revision: number;
37
+ manifest: ArticleStyleManifest;
38
+ hash: string;
39
+ }
40
+
41
+ /** 内容 hash 只基于规范化 manifest;资产修订身份由外层快照保存。 */
42
+ export function createArticleStyleSnapshot(value: unknown, id: string, revision: number): ArticleStyleSnapshot {
43
+ const header = SnapshotHeaderSchema.parse({ schemaVersion: 1, id, revision });
44
+ const manifest = ArticleStyleManifestSchema.parse(value);
45
+ if (manifest.id !== header.id) throw new Error("Article style id must match manifest.id");
46
+ validateArticleThemeCss(ArticleThemeSchema.parse(manifest), manifest.css);
47
+ const hash = createHash("sha256").update(JSON.stringify(manifest)).digest("hex");
48
+ return { ...header, manifest, hash };
49
+ }
50
+
51
+ /** 严格验证快照格式及内容 hash,未知版本和被篡改内容均拒绝。 */
52
+ export function validateArticleStyleSnapshot(value: unknown): ArticleStyleSnapshot {
53
+ const input = SnapshotHeaderSchema.extend({ manifest: z.unknown(), hash: z.string().regex(/^[a-f\d]{64}$/) }).strict().parse(value);
54
+ const snapshot = createArticleStyleSnapshot(input.manifest, input.id, input.revision);
55
+ if (input.hash !== snapshot.hash) throw new Error("Article style hash mismatch");
56
+ return snapshot;
57
+ }
@@ -41,6 +41,16 @@ export interface ArticleThemePackage {
41
41
  files: Map<string, Uint8Array>;
42
42
  }
43
43
 
44
+ /** 本地主题与云端快照共用 CSS 边界,禁止隐藏外部资源。 */
45
+ export function validateArticleThemeCss(manifest: ArticleThemeManifest, css: string): void {
46
+ const unsafeCss = (value: string) => /\\|@import\b|url\s*\(|expression\s*\(/i.test(value.replace(/\/\*[\s\S]*?\*\//g, ""));
47
+ if (Object.keys(manifest.editorVars).some(key => !/^--[a-zA-Z0-9_-]+$/.test(key))) throw new Error("模板变量名无效");
48
+ for (const value of [...Object.values(manifest.editorVars), ...Object.values(manifest.exportTheme)]) {
49
+ if (value.length > 8192 || unsafeCss(value)) throw new Error("模板变量不能包含外部资源或 CSS 转义");
50
+ }
51
+ if (css.length > 256_000 || unsafeCss(css)) throw new Error("模板 CSS 不支持外部资源、CSS 转义或可执行表达式");
52
+ }
53
+
44
54
  /** 限制所有资源在包内,拒绝符号链接、目录越界及过大文件。 */
45
55
  export async function readArticleTheme(inputPath: string): Promise<ArticleThemePackage> {
46
56
  const entry = resolve(inputPath);
@@ -76,14 +86,8 @@ export async function readArticleTheme(inputPath: string): Promise<ArticleThemeP
76
86
  if (manifest.structure.headerImage) paths.push(manifest.structure.headerImage);
77
87
  for (const name of new Set(paths)) await read(name);
78
88
  const css = manifest.css ?? new TextDecoder().decode(files.get(manifest.stylesheet));
79
- // 模板无需 CSS 转义;拒绝用转义或注释隐藏的网络/文件资源。
80
- const unsafeCss = (value: string) => /\\|@import\b|url\s*\(|expression\s*\(/i.test(value.replace(/\/\*[\s\S]*?\*\//g, ""));
81
- if (Object.keys(manifest.editorVars).some(key => !/^--[a-zA-Z0-9_-]+$/.test(key))) throw new Error("模板变量名无效");
82
- for (const value of [...Object.values(manifest.editorVars), ...Object.values(manifest.exportTheme)]) {
83
- if (value.length > 8192 || unsafeCss(value)) throw new Error("模板变量不能包含外部资源或 CSS 转义");
84
- }
89
+ validateArticleThemeCss(manifest, css);
85
90
  if (manifest.structure.headerImage && !/\.(png|jpe?g|gif|webp)$/i.test(manifest.structure.headerImage)) throw new Error("模板页首素材仅支持 PNG、JPEG、GIF、WebP");
86
- if (css.length > 256_000 || unsafeCss(css)) throw new Error("模板 CSS 不支持外部资源、CSS 转义或可执行表达式");
87
91
  const digest = createHash("sha256").update(JSON.stringify(manifest)).update(css);
88
92
  for (const name of [...new Set([...manifest.assets, ...(manifest.structure.headerImage ? [manifest.structure.headerImage] : [])])].sort()) {
89
93
  digest.update(name).update(files.get(name)!);