@akairos/pipeline 0.19.2 → 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/article-style.ts +2 -0
- package/package.json +2 -1
- package/src/article-style.test.ts +34 -0
- package/src/article-style.ts +57 -0
- package/src/article-theme.ts +11 -7
package/article-style.ts
ADDED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@akairos/pipeline",
|
|
3
|
-
"version": "0.
|
|
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
|
],
|
|
@@ -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
|
+
}
|
package/src/article-theme.ts
CHANGED
|
@@ -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
|
-
|
|
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)!);
|