@zzclub/pipeline 0.2.0 → 0.4.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 +366 -170
- package/package.json +4 -7
- package/src/adapter-loader.ts +138 -0
- package/src/adapter-types.ts +172 -0
- package/src/adapters/builtin-image-renderer.ts +353 -0
- package/src/adapters/builtin-markdown-renderer.ts +63 -0
- package/src/adapters/index.ts +2 -0
- package/src/args.test.ts +118 -0
- package/src/args.ts +90 -0
- package/src/cli.ts +51 -0
- package/src/commands/abandon.test.ts +104 -0
- package/src/commands/abandon.ts +202 -0
- package/src/commands/attach-body-images.ts +88 -0
- package/src/commands/attach-body.ts +86 -0
- package/src/commands/attach-newspic-spec.ts +41 -0
- package/src/commands/checkpoint.test.ts +91 -0
- package/src/commands/checkpoint.ts +66 -0
- package/src/commands/config.ts +106 -0
- package/src/commands/cos-upload.ts +62 -0
- package/src/commands/doctor.ts +66 -0
- package/src/commands/find-run.ts +55 -0
- package/src/commands/hermes-metrics.ts +226 -0
- package/src/commands/imgx.ts +60 -0
- package/src/commands/ingest-handoff.ts +509 -0
- package/src/commands/init.ts +126 -0
- package/src/commands/prepare-finalize.ts +348 -0
- package/src/commands/prepare.ts +310 -0
- package/src/commands/publish.ts +147 -0
- package/src/commands/reconcile.ts +36 -0
- package/src/commands/render.ts +244 -0
- package/src/commands/reset.test.ts +151 -0
- package/src/commands/reset.ts +159 -0
- package/src/commands/review.ts +77 -0
- package/src/commands/status.ts +68 -0
- package/src/commands/sync-blog.ts +39 -0
- package/src/commands/tasks.ts +50 -0
- package/src/commands/wechat-export.ts +68 -0
- package/src/config.ts +0 -0
- package/src/imgx/ascii-portrait.ts +77 -0
- package/src/imgx/assets/fonts/AlimamaShuHeiTi-Bold.ttf +0 -0
- package/src/imgx/assets/fonts/LXGWNeoZhiSongPlus.ttf +0 -0
- package/src/imgx/assets/fonts/LXGWWenKai-Regular.ttf +0 -0
- package/src/imgx/assets/icons/logo.png +0 -0
- package/src/imgx/assets/icons/logo.svg +8 -0
- package/{dist/assets → src}/imgx/assets/templates/longform-3-4.html +1 -1
- package/{dist/assets → src}/imgx/assets/templates/poster-3-4.html +69 -30
- package/{dist/assets → src}/imgx/assets/templates/wechat-cover-split.html +58 -27
- package/src/imgx/cli.ts +57 -0
- package/src/imgx/geometry.ts +68 -0
- package/src/imgx/index.ts +5 -0
- package/src/imgx/longform-theme.ts +243 -0
- package/src/imgx/poster-recipe.ts +81 -0
- package/src/imgx/pretext-adapter.ts +14 -0
- package/src/imgx/pretext-runtime.ts +61 -0
- package/src/imgx/references/ascii-portrait-3-4.md +59 -0
- package/src/imgx/references/longform-3-4.md +173 -0
- package/src/imgx/references/poster-3-4.md +87 -0
- package/src/imgx/references/tips-3-4.md +69 -0
- package/src/imgx/references/wechat-cover-split.md +89 -0
- package/src/imgx/references/x-like-posts.md +139 -0
- package/src/imgx/render-article.test.ts +53 -0
- package/src/imgx/render-article.ts +1364 -0
- package/src/imgx/render-ascii-portrait.ts +53 -0
- package/src/imgx/render-card.ts +232 -0
- package/src/imgx/render-x-like-posts.ts +235 -0
- package/src/imgx/runtime.ts +403 -0
- package/src/imgx/typographic-scale.test.ts +56 -0
- package/src/imgx/typographic-scale.ts +44 -0
- package/src/output.ts +632 -0
- package/src/plugins.ts +101 -0
- package/src/profiles.ts +149 -0
- package/src/providers/blog.test.ts +58 -0
- package/src/providers/blog.ts +154 -0
- package/src/providers/cos.ts +183 -0
- package/src/providers/index.test.ts +26 -0
- package/src/providers/index.ts +185 -0
- package/src/providers/wechat.test.ts +215 -0
- package/src/providers/wechat.ts +548 -0
- package/src/routes.ts +287 -0
- package/src/runtime-paths.ts +179 -0
- package/src/schema/config.ts +133 -0
- package/src/schema/state.ts +376 -0
- package/src/spawn.ts +103 -0
- package/src/state.ts +265 -0
- package/src/task-manager.ts +657 -0
- package/src/task-views.ts +222 -0
- package/src/text.test.ts +689 -0
- package/src/text.ts +683 -0
- package/src/wechat-preview/assets/fonts/SweiCurveLegCJKsc-Regular.woff +0 -0
- package/src/wechat-preview/browser/editor-export.css +241 -0
- package/src/wechat-preview/browser/editor-export.ts +103 -0
- package/src/wechat-preview/frontmatter-handler.ts +73 -0
- package/src/wechat-preview/index.test.ts +154 -0
- package/src/wechat-preview/index.ts +294 -0
- package/src/wechat-preview/themes.ts +141 -0
- package/src/wechat-preview/wechat-formatter.test.ts +288 -0
- package/src/wechat-preview/wechat-formatter.ts +1506 -0
- package/src/wechat-preview.test.ts +73 -0
- package/src/workflow-materials.test.ts +225 -0
- package/src/workflow-materials.ts +217 -0
- package/src/workflow.test.ts +2087 -0
- package/dist/assets/imgx/assets/icons/avatar_jinx_cartoon.jpg +0 -0
- package/dist/assets/imgx/assets/icons/fishbone-logo-square.png +0 -0
- package/dist/assets/imgx/assets/icons/fishbone-logo.jpg +0 -0
- package/dist/assets/imgx/assets/icons/fishbone-logo.png +0 -0
- package/dist/assets/imgx/assets/icons/openclaw-logo.svg +0 -22
- package/dist/assets/imgx/assets/icons/zzclub-logo-black.jpg +0 -0
- package/dist/assets/imgx/assets/icons/zzclub-logo-gray.svg +0 -25
- package/dist/cli.js +0 -432
- package/dist/node_modules/@chenglou/pretext/dist/analysis.d.ts +0 -33
- package/dist/node_modules/@chenglou/pretext/dist/analysis.js +0 -1063
- package/dist/node_modules/@chenglou/pretext/dist/bidi.d.ts +0 -1
- package/dist/node_modules/@chenglou/pretext/dist/bidi.js +0 -175
- package/dist/node_modules/@chenglou/pretext/dist/generated/bidi-data.d.ts +0 -4
- package/dist/node_modules/@chenglou/pretext/dist/generated/bidi-data.js +0 -979
- package/dist/node_modules/@chenglou/pretext/dist/layout.d.ts +0 -70
- package/dist/node_modules/@chenglou/pretext/dist/layout.js +0 -496
- package/dist/node_modules/@chenglou/pretext/dist/line-break.d.ts +0 -36
- package/dist/node_modules/@chenglou/pretext/dist/line-break.js +0 -820
- package/dist/node_modules/@chenglou/pretext/dist/measurement.d.ts +0 -28
- package/dist/node_modules/@chenglou/pretext/dist/measurement.js +0 -219
- package/dist/node_modules/@chenglou/pretext/dist/rich-inline.d.ts +0 -51
- package/dist/node_modules/@chenglou/pretext/dist/rich-inline.js +0 -401
- /package/{dist/assets → src}/imgx/assets/browser/obstacle-flow.d.ts +0 -0
- /package/{dist/assets → src}/imgx/assets/browser/obstacle-flow.js +0 -0
- /package/{dist/assets → src}/imgx/assets/icons/clover.svg +0 -0
- /package/{dist/assets → src}/imgx/assets/templates/ascii-portrait-3-4.html +0 -0
- /package/{dist/assets → src}/imgx/assets/templates/ascii-portrait-tile.html +0 -0
- /package/{dist/assets → src}/imgx/assets/templates/tips-3-4.html +0 -0
- /package/{dist/assets → src}/imgx/assets/templates/x-like-posts.html +0 -0
- /package/{dist/assets → src}/wechat-preview/assets/browser-dist/.vite/manifest.json +0 -0
- /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
- /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
- /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
- /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
- /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
- /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
- /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
- /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
- /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
- /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
- /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
- /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
- /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
- /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
- /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
- /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
- /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
- /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
- /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
- /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
- /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
- /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
- /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
- /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
- /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
- /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
- /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
- /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
- /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
- /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
- /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
- /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
- /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
- /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
- /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
- /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
- /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
- /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
- /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
- /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
- /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
- /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
- /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
- /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
- /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
- /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
- /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
- /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
- /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
- /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
- /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
- /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
- /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
- /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
- /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
- /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
- /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
- /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
- /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
- /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/SweiCurveLegCJKsc-Regular-B3Ut5bwH.woff +0 -0
- /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/style-BYNXnUvc.css +0 -0
- /package/{dist/assets → src}/wechat-preview/assets/browser-dist/editor-export.js +0 -0
- /package/{dist/assets → src}/wechat-preview/assets/templates/export-shell.html +0 -0
|
@@ -0,0 +1,403 @@
|
|
|
1
|
+
import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { dirname, isAbsolute, join, resolve } from "node:path";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { pathToFileURL } from "node:url";
|
|
5
|
+
import { imageSize } from "image-size";
|
|
6
|
+
import { PNG } from "pngjs";
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
PACKAGE_ROOT as _PACKAGE_ROOT,
|
|
10
|
+
IMGX_DIR as _IMGX_DIR,
|
|
11
|
+
ASSETS_DIR as _ASSETS_DIR,
|
|
12
|
+
TEMPLATES_DIR as _TEMPLATES_DIR,
|
|
13
|
+
ICONS_DIR as _ICONS_DIR,
|
|
14
|
+
FONTS_DIR as _FONTS_DIR,
|
|
15
|
+
STYLES_DIR as _STYLES_DIR,
|
|
16
|
+
} from "../runtime-paths";
|
|
17
|
+
|
|
18
|
+
export const PACKAGE_ROOT = _PACKAGE_ROOT;
|
|
19
|
+
export const IMGX_DIR = _IMGX_DIR;
|
|
20
|
+
export const SKILL_DIR = _IMGX_DIR;
|
|
21
|
+
export const ASSETS_DIR = _ASSETS_DIR;
|
|
22
|
+
export const TEMPLATES_DIR = _TEMPLATES_DIR;
|
|
23
|
+
export const ICONS_DIR = _ICONS_DIR;
|
|
24
|
+
export const FONTS_DIR = _FONTS_DIR;
|
|
25
|
+
export const STYLES_DIR = _STYLES_DIR;
|
|
26
|
+
|
|
27
|
+
const CHROME_PATHS = [
|
|
28
|
+
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
|
29
|
+
"/Applications/Chromium.app/Contents/MacOS/Chromium",
|
|
30
|
+
"google-chrome",
|
|
31
|
+
"chromium",
|
|
32
|
+
];
|
|
33
|
+
|
|
34
|
+
const STATIC_RENDER_STYLE = `<style data-zzhub-static-render>*,:before,:after{animation:none!important;transition:none!important}html{scroll-behavior:auto!important}</style>`;
|
|
35
|
+
const CHROME_VIEWPORT_PROBE_SIZE = { width: 900, height: 1200 } as const;
|
|
36
|
+
const chromeViewportInsetCache = new Map<string, { width: number; height: number }>();
|
|
37
|
+
|
|
38
|
+
export function escapeHtml(value: string): string {
|
|
39
|
+
return value
|
|
40
|
+
.replaceAll("&", "&")
|
|
41
|
+
.replaceAll("<", "<")
|
|
42
|
+
.replaceAll(">", ">")
|
|
43
|
+
.replaceAll('"', """)
|
|
44
|
+
.replaceAll("'", "'");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Render inline markdown bold (**text**) and italic (*text*) as HTML,
|
|
49
|
+
* while properly escaping HTML entities in non-markdown segments.
|
|
50
|
+
* Handles nested bold+italic (***text***) and multiple segments per line.
|
|
51
|
+
*/
|
|
52
|
+
export function renderInlineMarkdown(text: string): string {
|
|
53
|
+
if (!text) return "";
|
|
54
|
+
|
|
55
|
+
const parts: string[] = [];
|
|
56
|
+
let remaining = text;
|
|
57
|
+
|
|
58
|
+
while (remaining.length > 0) {
|
|
59
|
+
// Try to match ***bold+italic*** first, then **bold**, then *italic*
|
|
60
|
+
// We need to find the opening marker and then the matching closing marker
|
|
61
|
+
const tripleMatch = remaining.match(/\*\*\*/);
|
|
62
|
+
const doubleMatch = remaining.match(/\*\*/);
|
|
63
|
+
const singleMatch = remaining.match(/(?<!\*)\*(?!\*)/);
|
|
64
|
+
|
|
65
|
+
// Find the earliest marker
|
|
66
|
+
let earliestIdx = remaining.length;
|
|
67
|
+
let markerType: "triple" | "double" | "single" | null = null;
|
|
68
|
+
|
|
69
|
+
if (tripleMatch && tripleMatch.index !== undefined && tripleMatch.index < earliestIdx) {
|
|
70
|
+
earliestIdx = tripleMatch.index;
|
|
71
|
+
markerType = "triple";
|
|
72
|
+
}
|
|
73
|
+
if (doubleMatch && doubleMatch.index !== undefined && doubleMatch.index < earliestIdx) {
|
|
74
|
+
earliestIdx = doubleMatch.index;
|
|
75
|
+
markerType = "double";
|
|
76
|
+
}
|
|
77
|
+
if (singleMatch && singleMatch.index !== undefined && singleMatch.index < earliestIdx) {
|
|
78
|
+
earliestIdx = singleMatch.index;
|
|
79
|
+
markerType = "single";
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (markerType === null) {
|
|
83
|
+
// No more markers — escape the rest
|
|
84
|
+
parts.push(escapeHtml(remaining));
|
|
85
|
+
break;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Escape any text before the marker
|
|
89
|
+
if (earliestIdx > 0) {
|
|
90
|
+
parts.push(escapeHtml(remaining.slice(0, earliestIdx)));
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (markerType === "triple") {
|
|
94
|
+
const afterOpen = remaining.slice(earliestIdx + 3);
|
|
95
|
+
const closeIdx = afterOpen.indexOf("***");
|
|
96
|
+
if (closeIdx === -1) {
|
|
97
|
+
// No closing marker — treat as literal
|
|
98
|
+
parts.push(escapeHtml(remaining.slice(earliestIdx)));
|
|
99
|
+
break;
|
|
100
|
+
}
|
|
101
|
+
const inner = afterOpen.slice(0, closeIdx);
|
|
102
|
+
parts.push(`<strong><em>${escapeHtml(inner)}</em></strong>`);
|
|
103
|
+
remaining = afterOpen.slice(closeIdx + 3);
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (markerType === "double") {
|
|
108
|
+
const afterOpen = remaining.slice(earliestIdx + 2);
|
|
109
|
+
const closeIdx = afterOpen.indexOf("**");
|
|
110
|
+
if (closeIdx === -1) {
|
|
111
|
+
parts.push(escapeHtml(remaining.slice(earliestIdx)));
|
|
112
|
+
break;
|
|
113
|
+
}
|
|
114
|
+
const inner = afterOpen.slice(0, closeIdx);
|
|
115
|
+
parts.push(`<strong>${escapeHtml(inner)}</strong>`);
|
|
116
|
+
remaining = afterOpen.slice(closeIdx + 2);
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (markerType === "single") {
|
|
121
|
+
const afterOpen = remaining.slice(earliestIdx + 1);
|
|
122
|
+
const closeIdx = afterOpen.indexOf("*");
|
|
123
|
+
if (closeIdx === -1) {
|
|
124
|
+
parts.push(escapeHtml(remaining.slice(earliestIdx)));
|
|
125
|
+
break;
|
|
126
|
+
}
|
|
127
|
+
const inner = afterOpen.slice(0, closeIdx);
|
|
128
|
+
parts.push(`<em>${escapeHtml(inner)}</em>`);
|
|
129
|
+
remaining = afterOpen.slice(closeIdx + 1);
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return parts.join("");
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function escapeRegExp(value: string): string {
|
|
138
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export function ensureParentDir(path: string): void {
|
|
142
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function readUtf8(path: string): string {
|
|
146
|
+
return readFileSync(path, "utf8");
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function renderTemplate(template: string, replacements: Record<string, string>): string {
|
|
150
|
+
let html = template;
|
|
151
|
+
for (const [key, value] of Object.entries(replacements)) {
|
|
152
|
+
html = html.replaceAll(key, value);
|
|
153
|
+
}
|
|
154
|
+
return html;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export function findChrome(): string | null {
|
|
158
|
+
for (const candidate of CHROME_PATHS) {
|
|
159
|
+
const result = Bun.spawnSync({
|
|
160
|
+
cmd: ["sh", "-lc", `command -v "${candidate}" >/dev/null 2>&1 || test -x "${candidate}"`],
|
|
161
|
+
stderr: "ignore",
|
|
162
|
+
stdout: "ignore",
|
|
163
|
+
});
|
|
164
|
+
if (result.exitCode === 0) return candidate;
|
|
165
|
+
}
|
|
166
|
+
return null;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export function writeTempHtml(html: string): string {
|
|
170
|
+
const dir = mkdtempSync(join(tmpdir(), "zzhub-media-imgx-"));
|
|
171
|
+
const filePath = join(dir, "render.html");
|
|
172
|
+
writeFileSync(filePath, html, "utf8");
|
|
173
|
+
return filePath;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export function injectStaticRenderStyle(html: string): string {
|
|
177
|
+
if (html.includes("</head>")) {
|
|
178
|
+
return html.replace("</head>", `${STATIC_RENDER_STYLE}</head>`);
|
|
179
|
+
}
|
|
180
|
+
return `${STATIC_RENDER_STYLE}${html}`;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export function cleanupTempFile(path: string): void {
|
|
184
|
+
rmSync(dirname(path), { recursive: true, force: true });
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function measureChromeViewportInsets(chromePath: string): { width: number; height: number } {
|
|
188
|
+
const cached = chromeViewportInsetCache.get(chromePath);
|
|
189
|
+
if (cached) return cached;
|
|
190
|
+
|
|
191
|
+
const probeHtml = writeTempHtml(
|
|
192
|
+
injectStaticRenderStyle(
|
|
193
|
+
[
|
|
194
|
+
"<!doctype html>",
|
|
195
|
+
'<html><head><meta charset="utf-8"></head><body>',
|
|
196
|
+
'<pre id="out"></pre>',
|
|
197
|
+
"<script>",
|
|
198
|
+
"document.getElementById('out').textContent = JSON.stringify({",
|
|
199
|
+
" innerWidth: window.innerWidth,",
|
|
200
|
+
" innerHeight: window.innerHeight,",
|
|
201
|
+
" docClientWidth: document.documentElement.clientWidth,",
|
|
202
|
+
" docClientHeight: document.documentElement.clientHeight,",
|
|
203
|
+
"});",
|
|
204
|
+
"</script>",
|
|
205
|
+
"</body></html>",
|
|
206
|
+
].join(""),
|
|
207
|
+
),
|
|
208
|
+
);
|
|
209
|
+
const fileUrl = pathToFileURL(probeHtml).href;
|
|
210
|
+
const result = Bun.spawnSync({
|
|
211
|
+
cmd: [
|
|
212
|
+
chromePath,
|
|
213
|
+
"--headless",
|
|
214
|
+
"--disable-gpu",
|
|
215
|
+
"--no-sandbox",
|
|
216
|
+
"--allow-file-access-from-files",
|
|
217
|
+
"--disable-web-security=false",
|
|
218
|
+
"--hide-scrollbars",
|
|
219
|
+
`--window-size=${CHROME_VIEWPORT_PROBE_SIZE.width},${CHROME_VIEWPORT_PROBE_SIZE.height}`,
|
|
220
|
+
"--dump-dom",
|
|
221
|
+
fileUrl,
|
|
222
|
+
],
|
|
223
|
+
stdout: "pipe",
|
|
224
|
+
stderr: "pipe",
|
|
225
|
+
});
|
|
226
|
+
cleanupTempFile(probeHtml);
|
|
227
|
+
if (result.exitCode !== 0) {
|
|
228
|
+
const stderr = new TextDecoder().decode(result.stderr);
|
|
229
|
+
throw new Error(`Chrome viewport probe failed:\n${stderr}`);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const dumped = new TextDecoder().decode(result.stdout);
|
|
233
|
+
const match = dumped.match(/<pre id="out">([\s\S]*?)<\/pre>/);
|
|
234
|
+
if (!match) {
|
|
235
|
+
throw new Error("Chrome viewport probe failed: missing probe output");
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const measured = JSON.parse(match[1]) as {
|
|
239
|
+
innerWidth?: unknown;
|
|
240
|
+
innerHeight?: unknown;
|
|
241
|
+
};
|
|
242
|
+
const innerWidth = typeof measured.innerWidth === "number" ? measured.innerWidth : CHROME_VIEWPORT_PROBE_SIZE.width;
|
|
243
|
+
const innerHeight = typeof measured.innerHeight === "number" ? measured.innerHeight : CHROME_VIEWPORT_PROBE_SIZE.height;
|
|
244
|
+
const inset = {
|
|
245
|
+
width: Math.max(0, CHROME_VIEWPORT_PROBE_SIZE.width - innerWidth),
|
|
246
|
+
height: Math.max(0, CHROME_VIEWPORT_PROBE_SIZE.height - innerHeight),
|
|
247
|
+
};
|
|
248
|
+
chromeViewportInsetCache.set(chromePath, inset);
|
|
249
|
+
return inset;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
export function screenshotHtml(options: {
|
|
253
|
+
chromePath: string;
|
|
254
|
+
html: string;
|
|
255
|
+
outPath: string;
|
|
256
|
+
width: number;
|
|
257
|
+
height: number;
|
|
258
|
+
hideScrollbars?: boolean;
|
|
259
|
+
virtualTimeBudgetMs?: number;
|
|
260
|
+
}): void {
|
|
261
|
+
ensureParentDir(options.outPath);
|
|
262
|
+
const tempHtml = writeTempHtml(injectStaticRenderStyle(options.html));
|
|
263
|
+
const fileUrl = pathToFileURL(tempHtml).href;
|
|
264
|
+
const viewportInset = measureChromeViewportInsets(options.chromePath);
|
|
265
|
+
const tempShotDir = mkdtempSync(join(tmpdir(), "zzhub-media-shot-"));
|
|
266
|
+
const tempShotPath = join(tempShotDir, "shot.png");
|
|
267
|
+
const captureWidth = options.width + viewportInset.width;
|
|
268
|
+
const captureHeight = options.height + viewportInset.height;
|
|
269
|
+
const chromeArgs = [
|
|
270
|
+
options.chromePath,
|
|
271
|
+
"--headless",
|
|
272
|
+
"--disable-gpu",
|
|
273
|
+
"--no-sandbox",
|
|
274
|
+
"--allow-file-access-from-files",
|
|
275
|
+
"--disable-web-security=false",
|
|
276
|
+
];
|
|
277
|
+
if (options.hideScrollbars) chromeArgs.push("--hide-scrollbars");
|
|
278
|
+
if ((options.virtualTimeBudgetMs ?? 0) > 0) {
|
|
279
|
+
chromeArgs.push(`--virtual-time-budget=${options.virtualTimeBudgetMs}`);
|
|
280
|
+
}
|
|
281
|
+
chromeArgs.push(
|
|
282
|
+
`--screenshot=${tempShotPath}`,
|
|
283
|
+
`--window-size=${captureWidth},${captureHeight}`,
|
|
284
|
+
fileUrl,
|
|
285
|
+
);
|
|
286
|
+
|
|
287
|
+
const result = Bun.spawnSync({
|
|
288
|
+
cmd: chromeArgs,
|
|
289
|
+
stdout: "pipe",
|
|
290
|
+
stderr: "pipe",
|
|
291
|
+
});
|
|
292
|
+
cleanupTempFile(tempHtml);
|
|
293
|
+
if (result.exitCode !== 0) {
|
|
294
|
+
rmSync(tempShotDir, { recursive: true, force: true });
|
|
295
|
+
const stderr = new TextDecoder().decode(result.stderr);
|
|
296
|
+
throw new Error(`Chrome failed:\n${stderr}`);
|
|
297
|
+
}
|
|
298
|
+
cropTop({
|
|
299
|
+
inputPath: tempShotPath,
|
|
300
|
+
outPath: options.outPath,
|
|
301
|
+
width: options.width,
|
|
302
|
+
height: options.height,
|
|
303
|
+
});
|
|
304
|
+
rmSync(tempShotDir, { recursive: true, force: true });
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
export function dumpHtmlDom(options: {
|
|
308
|
+
chromePath: string;
|
|
309
|
+
html: string;
|
|
310
|
+
virtualTimeBudgetMs?: number;
|
|
311
|
+
}): string {
|
|
312
|
+
const tempHtml = writeTempHtml(injectStaticRenderStyle(options.html));
|
|
313
|
+
const fileUrl = pathToFileURL(tempHtml).href;
|
|
314
|
+
const chromeArgs = [
|
|
315
|
+
options.chromePath,
|
|
316
|
+
"--headless",
|
|
317
|
+
"--disable-gpu",
|
|
318
|
+
"--no-sandbox",
|
|
319
|
+
"--allow-file-access-from-files",
|
|
320
|
+
"--disable-web-security=false",
|
|
321
|
+
];
|
|
322
|
+
if ((options.virtualTimeBudgetMs ?? 0) > 0) {
|
|
323
|
+
chromeArgs.push(`--virtual-time-budget=${options.virtualTimeBudgetMs}`);
|
|
324
|
+
}
|
|
325
|
+
chromeArgs.push("--dump-dom", fileUrl);
|
|
326
|
+
|
|
327
|
+
const result = Bun.spawnSync({
|
|
328
|
+
cmd: chromeArgs,
|
|
329
|
+
stdout: "pipe",
|
|
330
|
+
stderr: "pipe",
|
|
331
|
+
});
|
|
332
|
+
cleanupTempFile(tempHtml);
|
|
333
|
+
if (result.exitCode !== 0) {
|
|
334
|
+
const stderr = new TextDecoder().decode(result.stderr);
|
|
335
|
+
throw new Error(`Chrome dump-dom failed:\n${stderr}`);
|
|
336
|
+
}
|
|
337
|
+
return new TextDecoder().decode(result.stdout);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
export function cropTop(options: {
|
|
341
|
+
inputPath: string;
|
|
342
|
+
outPath: string;
|
|
343
|
+
width: number;
|
|
344
|
+
height: number;
|
|
345
|
+
}): void {
|
|
346
|
+
const inputPath = resolveInputPath(options.inputPath);
|
|
347
|
+
ensureParentDir(options.outPath);
|
|
348
|
+
|
|
349
|
+
const source = PNG.sync.read(readFileSync(inputPath));
|
|
350
|
+
if (options.width > source.width || options.height > source.height) {
|
|
351
|
+
throw new Error(
|
|
352
|
+
`crop bounds exceed source image: requested ${options.width}x${options.height}, source ${source.width}x${source.height}`,
|
|
353
|
+
);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
const output = new PNG({ width: options.width, height: options.height });
|
|
357
|
+
PNG.bitblt(source, output, 0, 0, options.width, options.height, 0, 0);
|
|
358
|
+
writeFileSync(options.outPath, PNG.sync.write(output));
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
export function resolveAsset(path: string): string {
|
|
362
|
+
return resolve(SKILL_DIR, path);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
export function resolveInputPath(path: string): string {
|
|
366
|
+
const trimmed = path.trim();
|
|
367
|
+
if (trimmed.length === 0) return path;
|
|
368
|
+
if (isAbsolute(trimmed)) return trimmed;
|
|
369
|
+
|
|
370
|
+
const normalized = trimmed.replace(/\\/g, "/");
|
|
371
|
+
if (normalized.startsWith("zzhub-media-imgx/")) {
|
|
372
|
+
return resolve(SKILL_DIR, normalized.slice("zzhub-media-imgx/".length));
|
|
373
|
+
}
|
|
374
|
+
if (normalized.startsWith("assets/")) {
|
|
375
|
+
return resolve(SKILL_DIR, normalized);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
return resolve(trimmed);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
export function printSaved(outPath: string): void {
|
|
382
|
+
console.log(`✅ Saved to ${outPath}`);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
export function readImageSize(path: string): { width: number; height: number } | null {
|
|
386
|
+
const normalized = resolveInputPath(path);
|
|
387
|
+
try {
|
|
388
|
+
const size = imageSize(readFileSync(normalized));
|
|
389
|
+
if (
|
|
390
|
+
typeof size.width === "number" &&
|
|
391
|
+
typeof size.height === "number" &&
|
|
392
|
+
Number.isFinite(size.width) &&
|
|
393
|
+
Number.isFinite(size.height) &&
|
|
394
|
+
size.width > 0 &&
|
|
395
|
+
size.height > 0
|
|
396
|
+
) {
|
|
397
|
+
return { width: size.width, height: size.height };
|
|
398
|
+
}
|
|
399
|
+
} catch {
|
|
400
|
+
return null;
|
|
401
|
+
}
|
|
402
|
+
return null;
|
|
403
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { describe, it, expect } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
modularScale,
|
|
4
|
+
proportionalLineHeight,
|
|
5
|
+
computeSpacing,
|
|
6
|
+
MAJOR_THIRD,
|
|
7
|
+
} from "./typographic-scale";
|
|
8
|
+
|
|
9
|
+
describe("modularScale", () => {
|
|
10
|
+
it("returns base at step 0", () => {
|
|
11
|
+
expect(modularScale(MAJOR_THIRD, 0)).toBe(16);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it("grows by ratio each step", () => {
|
|
15
|
+
// 16 * 1.25^1 = 20, 16 * 1.25^2 = 25, 16 * 1.25^3 = 31.25 → 31
|
|
16
|
+
expect(modularScale(MAJOR_THIRD, 1)).toBe(20);
|
|
17
|
+
expect(modularScale(MAJOR_THIRD, 2)).toBe(25);
|
|
18
|
+
expect(modularScale(MAJOR_THIRD, 3)).toBe(31);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("handles negative steps", () => {
|
|
22
|
+
expect(modularScale(MAJOR_THIRD, -1)).toBe(13); // 16 / 1.25 = 12.8 → 13
|
|
23
|
+
expect(modularScale(MAJOR_THIRD, -2)).toBe(10); // 16 / 1.5625 = 10.24 → 10
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
describe("computeSpacing", () => {
|
|
28
|
+
it("uses the larger font size", () => {
|
|
29
|
+
expect(computeSpacing(32, 50, 0.5)).toBe(25); // max(32,50) * 0.5 = 25
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("scales with multiplier", () => {
|
|
33
|
+
const tight = computeSpacing(32, 32, 0.3);
|
|
34
|
+
const normal = computeSpacing(32, 32, 0.5);
|
|
35
|
+
const loose = computeSpacing(32, 32, 0.7);
|
|
36
|
+
expect(tight).toBeLessThan(normal);
|
|
37
|
+
expect(normal).toBeLessThan(loose);
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
describe("proportionalLineHeight", () => {
|
|
42
|
+
it("returns tighter ratio for larger fonts", () => {
|
|
43
|
+
const huge = proportionalLineHeight(132) / 132;
|
|
44
|
+
const body = proportionalLineHeight(32) / 32;
|
|
45
|
+
expect(huge).toBeLessThan(body);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("clamps within [1.05, 1.85]", () => {
|
|
49
|
+
expect(proportionalLineHeight(8) / 8).toBeLessThanOrEqual(1.85);
|
|
50
|
+
expect(proportionalLineHeight(200) / 200).toBeGreaterThanOrEqual(1.05);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("returns integer px values", () => {
|
|
54
|
+
expect(Number.isInteger(proportionalLineHeight(32))).toBe(true);
|
|
55
|
+
});
|
|
56
|
+
});
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
export type TypographicScale = {
|
|
2
|
+
base: number;
|
|
3
|
+
ratio: number;
|
|
4
|
+
};
|
|
5
|
+
|
|
6
|
+
export const MAJOR_THIRD: TypographicScale = { base: 16, ratio: 1.25 };
|
|
7
|
+
|
|
8
|
+
export function modularScale(scale: TypographicScale, step: number): number {
|
|
9
|
+
return Math.round(scale.base * Math.pow(scale.ratio, step));
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Smooth proportional line-height via log-curve interpolation.
|
|
14
|
+
* Large display fonts (132px) → ~1.05 ratio (tight leading).
|
|
15
|
+
* Small body fonts (8px) → ~1.82 ratio (generous leading).
|
|
16
|
+
* Clamped to [1.05, 1.85].
|
|
17
|
+
*/
|
|
18
|
+
export function proportionalLineHeight(fontSizePx: number): number {
|
|
19
|
+
// Guard against non-positive input (should never happen with real font sizes)
|
|
20
|
+
if (fontSizePx <= 0) return Math.round(8 * 1.85);
|
|
21
|
+
const logNorm = Math.log(fontSizePx / 8) / Math.log(200 / 8);
|
|
22
|
+
const ratio = 1.85 - logNorm * (1.85 - 1.05);
|
|
23
|
+
const clamped = Math.min(1.85, Math.max(1.05, ratio));
|
|
24
|
+
const exact = fontSizePx * clamped;
|
|
25
|
+
let rounded = Math.round(exact);
|
|
26
|
+
// Ensure the rounded value stays within [1.05, 1.85] ratio bounds
|
|
27
|
+
while (rounded / fontSizePx > 1.85) rounded--;
|
|
28
|
+
while (rounded / fontSizePx < 1.05) rounded++;
|
|
29
|
+
return rounded;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Inter-block spacing proportional to the dominant font size.
|
|
34
|
+
* @param fontSizeA - font size of the first block
|
|
35
|
+
* @param fontSizeB - font size of the second block
|
|
36
|
+
* @param multiplier - 0.3=tight (list items), 0.5=normal (paragraphs), 0.7=loose (pre-heading)
|
|
37
|
+
*/
|
|
38
|
+
export function computeSpacing(
|
|
39
|
+
fontSizeA: number,
|
|
40
|
+
fontSizeB: number,
|
|
41
|
+
multiplier: number,
|
|
42
|
+
): number {
|
|
43
|
+
return Math.round(Math.max(fontSizeA, fontSizeB) * multiplier);
|
|
44
|
+
}
|