xhs-md-renderer 0.1.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.
Files changed (40) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +49 -0
  3. package/bin/xhs-md-render.js +3 -0
  4. package/dist/browser-renderer.d.ts +8 -0
  5. package/dist/browser-renderer.js +156 -0
  6. package/dist/browser-renderer.js.map +1 -0
  7. package/dist/index.d.ts +2 -0
  8. package/dist/index.js +418 -0
  9. package/dist/index.js.map +1 -0
  10. package/dist/vendor/core/image.d.ts +27 -0
  11. package/dist/vendor/core/image.js +265 -0
  12. package/dist/vendor/core/image.js.map +1 -0
  13. package/dist/vendor/core/index.d.ts +6 -0
  14. package/dist/vendor/core/index.js +6 -0
  15. package/dist/vendor/core/index.js.map +1 -0
  16. package/dist/vendor/core/layout.d.ts +22 -0
  17. package/dist/vendor/core/layout.js +276 -0
  18. package/dist/vendor/core/layout.js.map +1 -0
  19. package/dist/vendor/core/models.d.ts +138 -0
  20. package/dist/vendor/core/models.js +2 -0
  21. package/dist/vendor/core/models.js.map +1 -0
  22. package/dist/vendor/core/node-images.d.ts +2 -0
  23. package/dist/vendor/core/node-images.js +327 -0
  24. package/dist/vendor/core/node-images.js.map +1 -0
  25. package/dist/vendor/core/node.d.ts +31 -0
  26. package/dist/vendor/core/node.js +199 -0
  27. package/dist/vendor/core/node.js.map +1 -0
  28. package/dist/vendor/core/parser.d.ts +6 -0
  29. package/dist/vendor/core/parser.js +158 -0
  30. package/dist/vendor/core/parser.js.map +1 -0
  31. package/dist/vendor/core/preview.d.ts +6 -0
  32. package/dist/vendor/core/preview.js +325 -0
  33. package/dist/vendor/core/preview.js.map +1 -0
  34. package/dist/vendor/core/themes.d.ts +21 -0
  35. package/dist/vendor/core/themes.js +124 -0
  36. package/dist/vendor/core/themes.js.map +1 -0
  37. package/package.json +57 -0
  38. package/web-dist/assets/index-Br8RJhby.js +45 -0
  39. package/web-dist/assets/index-CwEw55IG.css +1 -0
  40. package/web-dist/index.html +13 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 xxih
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,49 @@
1
+ # xhs-md-renderer
2
+
3
+ Render Markdown into Xiaohongshu-friendly image pages from the command line.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ npm install -g xhs-md-renderer
9
+ ```
10
+
11
+ Or run it on demand:
12
+
13
+ ```bash
14
+ npx xhs-md-renderer --input ./note.md --output ./.xhs-output
15
+ ```
16
+
17
+ ## Usage
18
+
19
+ ```bash
20
+ xhs-md-renderer --input ./note.md --output ./.xhs-output
21
+ ```
22
+
23
+ Common options:
24
+
25
+ - `--renderer auto|node|browser`
26
+ - `--title "My Note"`
27
+ - `--theme paper`
28
+ - `--font-family "PingFang SC, sans-serif"`
29
+ - `--font-size 16`
30
+ - `--config-dir ./.xhs-md-renderer`
31
+
32
+ ## Config Directory
33
+
34
+ The CLI looks for a project config directory named `.xhs-md-renderer` in this order:
35
+
36
+ 1. `--config-dir <path>`
37
+ 2. Search upward from the input Markdown file directory
38
+ 3. Built-in defaults
39
+
40
+ Supported files inside the config directory:
41
+
42
+ - `render.json`
43
+ - `avatar.png|jpg|jpeg|webp|gif`
44
+
45
+ ## Notes
46
+
47
+ - The Node renderer works best when the machine has usable CJK fonts installed.
48
+ - The browser renderer needs a local Chromium-based browser.
49
+ - Every export writes `manifest.json`, `pages.json`, and `layout-report.json`.
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env node
2
+
3
+ import "../dist/index.js";
@@ -0,0 +1,8 @@
1
+ import type { ExportBundle, RenderConfigOverrides } from "./vendor/core/index.js";
2
+ export declare function writeBrowserExportBundle(input: {
3
+ markdown: string;
4
+ title?: string;
5
+ outputDir: string;
6
+ renderConfig?: RenderConfigOverrides;
7
+ markdownFilePath?: string;
8
+ }): Promise<ExportBundle>;
@@ -0,0 +1,156 @@
1
+ import { mkdir, readFile, stat, writeFile } from "node:fs/promises";
2
+ import { createServer } from "node:http";
3
+ import { extname, join, normalize, resolve } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { chromium } from "playwright-core";
6
+ import { prepareExportDocument } from "./vendor/core/node.js";
7
+ const WEB_DIST_CANDIDATES = [
8
+ fileURLToPath(new URL("../web-dist", import.meta.url)),
9
+ fileURLToPath(new URL("../../../apps/web/dist", import.meta.url))
10
+ ];
11
+ const DEFAULT_BROWSER_PATHS = [
12
+ process.env.XHS_MD_BROWSER_EXECUTABLE_PATH,
13
+ process.env.CHROME_PATH,
14
+ "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
15
+ "/Applications/Chromium.app/Contents/MacOS/Chromium",
16
+ "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
17
+ "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser",
18
+ "/usr/bin/google-chrome",
19
+ "/usr/bin/google-chrome-stable",
20
+ "/usr/bin/chromium-browser",
21
+ "/usr/bin/chromium"
22
+ ];
23
+ const MIME_TYPES = {
24
+ ".css": "text/css; charset=utf-8",
25
+ ".html": "text/html; charset=utf-8",
26
+ ".ico": "image/x-icon",
27
+ ".js": "text/javascript; charset=utf-8",
28
+ ".json": "application/json; charset=utf-8",
29
+ ".png": "image/png",
30
+ ".svg": "image/svg+xml; charset=utf-8"
31
+ };
32
+ async function isFile(targetPath) {
33
+ try {
34
+ return (await stat(targetPath)).isFile();
35
+ }
36
+ catch {
37
+ return false;
38
+ }
39
+ }
40
+ async function resolveBrowserExecutablePath() {
41
+ for (const browserPath of DEFAULT_BROWSER_PATHS) {
42
+ if (!browserPath) {
43
+ continue;
44
+ }
45
+ if (await isFile(browserPath)) {
46
+ return browserPath;
47
+ }
48
+ }
49
+ throw new Error("No supported local Chromium browser found. Set XHS_MD_BROWSER_EXECUTABLE_PATH or install Chrome, Chromium, Edge, or Brave.");
50
+ }
51
+ async function ensureWebDistDir() {
52
+ for (const rootDir of WEB_DIST_CANDIDATES) {
53
+ const distIndexPath = join(rootDir, "index.html");
54
+ if (await isFile(distIndexPath)) {
55
+ return rootDir;
56
+ }
57
+ }
58
+ throw new Error(`Web dist not found. Expected one of: ${WEB_DIST_CANDIDATES.join(", ")}. Run npm run build first.`);
59
+ }
60
+ async function startStaticServer(rootDir) {
61
+ const server = createServer(async (request, response) => {
62
+ const requestUrl = new URL(request.url ?? "/", "http://127.0.0.1");
63
+ const pathname = requestUrl.pathname === "/" ? "/index.html" : requestUrl.pathname;
64
+ const candidatePath = normalize(join(rootDir, pathname));
65
+ const safePath = candidatePath.startsWith(rootDir) ? candidatePath : join(rootDir, "index.html");
66
+ const filePath = (await isFile(safePath)) ? safePath : join(rootDir, "index.html");
67
+ try {
68
+ const fileBuffer = await readFile(filePath);
69
+ response.writeHead(200, {
70
+ "Content-Type": MIME_TYPES[extname(filePath)] ?? "application/octet-stream",
71
+ "Cache-Control": "no-store"
72
+ });
73
+ response.end(fileBuffer);
74
+ }
75
+ catch {
76
+ response.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" });
77
+ response.end("Not found");
78
+ }
79
+ });
80
+ await new Promise((resolvePromise, rejectPromise) => {
81
+ server.once("error", rejectPromise);
82
+ server.listen(0, "127.0.0.1", () => resolvePromise());
83
+ });
84
+ const address = server.address();
85
+ if (!address || typeof address === "string") {
86
+ throw new Error("Unable to resolve local preview server address.");
87
+ }
88
+ return {
89
+ origin: `http://127.0.0.1:${address.port}`,
90
+ close: async () => {
91
+ await new Promise((resolvePromise, rejectPromise) => {
92
+ server.close((error) => (error ? rejectPromise(error) : resolvePromise()));
93
+ });
94
+ }
95
+ };
96
+ }
97
+ export async function writeBrowserExportBundle(input) {
98
+ const rootDir = await ensureWebDistDir();
99
+ const prepared = await prepareExportDocument(input);
100
+ const outputDir = resolve(input.outputDir);
101
+ const pagesDir = join(outputDir, "pages");
102
+ const browserExecutablePath = await resolveBrowserExecutablePath();
103
+ const server = await startStaticServer(rootDir);
104
+ let browser;
105
+ try {
106
+ browser = await chromium.launch({
107
+ executablePath: browserExecutablePath,
108
+ headless: true
109
+ });
110
+ const page = await browser.newPage({
111
+ viewport: {
112
+ width: Math.min(prepared.config.width, 1600),
113
+ height: Math.min(prepared.config.height, 1200)
114
+ },
115
+ deviceScaleFactor: 1
116
+ });
117
+ await page.addInitScript((payload) => {
118
+ window.__XHS_RENDER_PAYLOAD__ =
119
+ payload;
120
+ }, prepared);
121
+ await page.goto(`${server.origin}/?mode=render`, {
122
+ waitUntil: "networkidle"
123
+ });
124
+ await page.waitForFunction(() => window.__XHS_RENDER_STATUS__?.ready === true);
125
+ await mkdir(pagesDir, { recursive: true });
126
+ await writeFile(join(outputDir, "manifest.json"), JSON.stringify(prepared.manifest, null, 2), "utf8");
127
+ await writeFile(join(outputDir, "layout-report.json"), JSON.stringify(prepared.layoutReport, null, 2), "utf8");
128
+ await writeFile(join(outputDir, "pages.json"), JSON.stringify(prepared.pages, null, 2), "utf8");
129
+ const bundlePages = [];
130
+ for (const manifestPage of prepared.manifest.pages) {
131
+ const locator = page.locator(`[data-export-page="${manifestPage.id}"]`);
132
+ const png = await locator.screenshot({
133
+ type: "png"
134
+ });
135
+ const outputFilePath = join(pagesDir, manifestPage.fileName);
136
+ await writeFile(outputFilePath, png);
137
+ bundlePages.push({
138
+ model: prepared.pages[manifestPage.index],
139
+ fileName: manifestPage.fileName,
140
+ png
141
+ });
142
+ }
143
+ return {
144
+ manifest: prepared.manifest,
145
+ layoutReport: prepared.layoutReport,
146
+ pages: bundlePages
147
+ };
148
+ }
149
+ finally {
150
+ if (browser) {
151
+ await browser.close();
152
+ }
153
+ await server.close();
154
+ }
155
+ }
156
+ //# sourceMappingURL=browser-renderer.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"browser-renderer.js","sourceRoot":"","sources":["../src/browser-renderer.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/index.js ADDED
@@ -0,0 +1,418 @@
1
+ #!/usr/bin/env node
2
+ import { readFile, stat } from "node:fs/promises";
3
+ import { dirname, join, resolve } from "node:path";
4
+ import { parseArgs } from "node:util";
5
+ import { writeExportBundle } from "./vendor/core/node.js";
6
+ import { writeBrowserExportBundle } from "./browser-renderer.js";
7
+ const DEFAULT_CONFIG_DIR_NAME = ".xhs-md-renderer";
8
+ const DEFAULT_CONFIG_FILE_NAME = "render.json";
9
+ const DEFAULT_AVATAR_FILE_NAMES = [
10
+ "avatar.png",
11
+ "avatar.jpg",
12
+ "avatar.jpeg",
13
+ "avatar.webp",
14
+ "avatar.gif"
15
+ ];
16
+ function printHelp() {
17
+ console.log(`
18
+ xhs-md-render
19
+
20
+ Usage:
21
+ xhs-md-render --input <file.md> --output <dir> [--config-dir ./.xhs-md-renderer]
22
+ [--renderer auto|node|browser]
23
+ [--title "My Note"] [--theme default]
24
+ [--font-family "..."] [--font-size 16]
25
+ [--name "小明"] [--handle "@xiaoming"]
26
+ [--date "2026/03/16"] [--hide-date]
27
+ [--footer-left "左侧文案"] [--footer-right "右侧文案"] [--hide-footer]
28
+ [--avatar ./avatar.png]
29
+ [--body-bottom-padding 180] [--warning-threshold 180]
30
+
31
+ Default config discovery:
32
+ 1. If --config-dir is provided, read that directory.
33
+ 2. Otherwise search upward from the input Markdown directory for .xhs-md-renderer/.
34
+ 3. If found, read .xhs-md-renderer/render.json and optional avatar.* files.
35
+ `.trim());
36
+ }
37
+ function parseNumericFlag(raw, flagName) {
38
+ if (raw === undefined) {
39
+ return undefined;
40
+ }
41
+ const parsed = Number(raw);
42
+ if (!Number.isFinite(parsed)) {
43
+ throw new Error(`${flagName} must be a number.`);
44
+ }
45
+ return parsed;
46
+ }
47
+ function parseCliOptions(argv) {
48
+ const { values } = parseArgs({
49
+ args: argv,
50
+ options: {
51
+ input: { type: "string", short: "i" },
52
+ output: { type: "string", short: "o" },
53
+ renderer: { type: "string" },
54
+ title: { type: "string", short: "t" },
55
+ theme: { type: "string" },
56
+ "font-family": { type: "string" },
57
+ "font-size": { type: "string" },
58
+ name: { type: "string" },
59
+ handle: { type: "string" },
60
+ date: { type: "string" },
61
+ "hide-date": { type: "boolean" },
62
+ "hide-footer": { type: "boolean" },
63
+ "footer-left": { type: "string" },
64
+ "footer-right": { type: "string" },
65
+ "config-dir": { type: "string" },
66
+ avatar: { type: "string" },
67
+ "body-bottom-padding": { type: "string" },
68
+ "warning-threshold": { type: "string" },
69
+ help: { type: "boolean", short: "h" }
70
+ },
71
+ allowPositionals: false
72
+ });
73
+ if (values.help) {
74
+ printHelp();
75
+ process.exit(0);
76
+ }
77
+ if (!values.input || !values.output) {
78
+ printHelp();
79
+ throw new Error("Both --input and --output are required.");
80
+ }
81
+ const options = {
82
+ input: values.input,
83
+ output: values.output
84
+ };
85
+ if (values.renderer !== undefined) {
86
+ if (values.renderer !== "auto" && values.renderer !== "browser" && values.renderer !== "node") {
87
+ throw new Error("--renderer must be one of: auto, browser, node.");
88
+ }
89
+ options.renderer = values.renderer;
90
+ }
91
+ if (values.title !== undefined) {
92
+ options.title = values.title;
93
+ }
94
+ if (values.theme !== undefined) {
95
+ options.themeId = values.theme;
96
+ }
97
+ if (values["font-family"] !== undefined) {
98
+ options.fontFamily = values["font-family"];
99
+ }
100
+ const fontSize = parseNumericFlag(values["font-size"], "--font-size");
101
+ const bodyBottomPadding = parseNumericFlag(values["body-bottom-padding"], "--body-bottom-padding");
102
+ const warningThreshold = parseNumericFlag(values["warning-threshold"], "--warning-threshold");
103
+ if (fontSize !== undefined) {
104
+ options.fontSize = fontSize;
105
+ }
106
+ if (bodyBottomPadding !== undefined) {
107
+ options.bodyBottomPadding = bodyBottomPadding;
108
+ }
109
+ if (warningThreshold !== undefined) {
110
+ options.warningThreshold = warningThreshold;
111
+ }
112
+ if (values.name !== undefined) {
113
+ options.name = values.name;
114
+ }
115
+ if (values.handle !== undefined) {
116
+ options.handle = values.handle;
117
+ }
118
+ if (values.date !== undefined) {
119
+ options.dateText = values.date;
120
+ options.showDate = true;
121
+ }
122
+ if (values["hide-date"]) {
123
+ options.showDate = false;
124
+ }
125
+ if (values["footer-left"] !== undefined) {
126
+ options.footerLeft = values["footer-left"];
127
+ }
128
+ if (values["footer-right"] !== undefined) {
129
+ options.footerRight = values["footer-right"];
130
+ }
131
+ if (values["hide-footer"]) {
132
+ options.showFooter = false;
133
+ }
134
+ if (values["config-dir"] !== undefined) {
135
+ options.configDir = values["config-dir"];
136
+ }
137
+ if (values.avatar !== undefined) {
138
+ options.avatarPath = values.avatar;
139
+ }
140
+ return options;
141
+ }
142
+ async function isDirectory(targetPath) {
143
+ try {
144
+ return (await stat(targetPath)).isDirectory();
145
+ }
146
+ catch {
147
+ return false;
148
+ }
149
+ }
150
+ async function isFile(targetPath) {
151
+ try {
152
+ return (await stat(targetPath)).isFile();
153
+ }
154
+ catch {
155
+ return false;
156
+ }
157
+ }
158
+ async function discoverConfigDir(inputPath, explicitConfigDir) {
159
+ if (explicitConfigDir) {
160
+ const resolvedConfigDir = resolve(explicitConfigDir);
161
+ if (!(await isDirectory(resolvedConfigDir))) {
162
+ throw new Error(`Config directory not found: ${resolvedConfigDir}`);
163
+ }
164
+ return resolvedConfigDir;
165
+ }
166
+ let currentDir = dirname(inputPath);
167
+ while (true) {
168
+ const candidate = join(currentDir, DEFAULT_CONFIG_DIR_NAME);
169
+ if (await isDirectory(candidate)) {
170
+ return candidate;
171
+ }
172
+ const parentDir = dirname(currentDir);
173
+ if (parentDir === currentDir) {
174
+ return undefined;
175
+ }
176
+ currentDir = parentDir;
177
+ }
178
+ }
179
+ async function readConfigFile(configDir) {
180
+ if (!configDir) {
181
+ return { config: {} };
182
+ }
183
+ const configFilePath = join(configDir, DEFAULT_CONFIG_FILE_NAME);
184
+ if (!(await isFile(configFilePath))) {
185
+ return {
186
+ configFilePath,
187
+ config: {}
188
+ };
189
+ }
190
+ const raw = await readFile(configFilePath, "utf8");
191
+ const parsed = JSON.parse(raw);
192
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
193
+ throw new Error(`Invalid config file: ${configFilePath}`);
194
+ }
195
+ return {
196
+ configFilePath,
197
+ config: parsed
198
+ };
199
+ }
200
+ function mergeRenderConfigOverrides(base = {}, override = {}) {
201
+ const mergedProfile = {
202
+ ...(base.profile ?? {}),
203
+ ...(override.profile ?? {})
204
+ };
205
+ const mergedLayout = {
206
+ ...(base.layout ?? {}),
207
+ ...(override.layout ?? {})
208
+ };
209
+ return {
210
+ ...base,
211
+ ...override,
212
+ ...(Object.keys(mergedProfile).length === 0 ? {} : { profile: mergedProfile }),
213
+ ...(Object.keys(mergedLayout).length === 0 ? {} : { layout: mergedLayout })
214
+ };
215
+ }
216
+ function buildCliRenderConfig(options) {
217
+ const profile = {
218
+ ...(options.name === undefined ? {} : { name: options.name }),
219
+ ...(options.handle === undefined ? {} : { handle: options.handle }),
220
+ ...(options.showDate === undefined ? {} : { showDate: options.showDate }),
221
+ ...(options.dateText === undefined ? {} : { dateText: options.dateText }),
222
+ ...(options.showFooter === undefined ? {} : { showFooter: options.showFooter }),
223
+ ...(options.footerLeft === undefined ? {} : { footerLeft: options.footerLeft }),
224
+ ...(options.footerRight === undefined ? {} : { footerRight: options.footerRight })
225
+ };
226
+ const layout = {
227
+ ...(options.bodyBottomPadding === undefined
228
+ ? {}
229
+ : { bodyBottomPadding: options.bodyBottomPadding }),
230
+ ...(options.warningThreshold === undefined
231
+ ? {}
232
+ : { warningThreshold: options.warningThreshold })
233
+ };
234
+ return {
235
+ ...(options.themeId === undefined ? {} : { themeId: options.themeId }),
236
+ ...(options.fontFamily === undefined ? {} : { fontFamily: options.fontFamily }),
237
+ ...(options.fontSize === undefined ? {} : { fontSize: options.fontSize }),
238
+ ...(Object.keys(profile).length === 0 ? {} : { profile }),
239
+ ...(Object.keys(layout).length === 0 ? {} : { layout })
240
+ };
241
+ }
242
+ function toMimeType(filePath) {
243
+ const lowerPath = filePath.toLowerCase();
244
+ if (lowerPath.endsWith(".png")) {
245
+ return "image/png";
246
+ }
247
+ if (lowerPath.endsWith(".jpg") || lowerPath.endsWith(".jpeg")) {
248
+ return "image/jpeg";
249
+ }
250
+ if (lowerPath.endsWith(".webp")) {
251
+ return "image/webp";
252
+ }
253
+ if (lowerPath.endsWith(".gif")) {
254
+ return "image/gif";
255
+ }
256
+ throw new Error(`Unsupported avatar file type: ${filePath}`);
257
+ }
258
+ async function readAvatarAsDataUrl(filePath) {
259
+ const avatarBuffer = await readFile(filePath);
260
+ return `data:${toMimeType(filePath)};base64,${avatarBuffer.toString("base64")}`;
261
+ }
262
+ async function detectDefaultAvatarPath(configDir) {
263
+ for (const fileName of DEFAULT_AVATAR_FILE_NAMES) {
264
+ const candidate = join(configDir, fileName);
265
+ if (await isFile(candidate)) {
266
+ return candidate;
267
+ }
268
+ }
269
+ return undefined;
270
+ }
271
+ async function resolveAvatarOverride(input) {
272
+ if (input.cliAvatarPath) {
273
+ const resolvedAvatarPath = resolve(input.cliAvatarPath);
274
+ if (!(await isFile(resolvedAvatarPath))) {
275
+ throw new Error(`Avatar file not found: ${resolvedAvatarPath}`);
276
+ }
277
+ return {
278
+ avatarSrc: await readAvatarAsDataUrl(resolvedAvatarPath),
279
+ avatarSourcePath: resolvedAvatarPath
280
+ };
281
+ }
282
+ if (!input.configDir) {
283
+ return {};
284
+ }
285
+ if (input.fileProfile?.avatarPath) {
286
+ const resolvedAvatarPath = resolve(input.configDir, input.fileProfile.avatarPath);
287
+ if (!(await isFile(resolvedAvatarPath))) {
288
+ throw new Error(`Avatar file not found: ${resolvedAvatarPath}`);
289
+ }
290
+ return {
291
+ avatarSrc: await readAvatarAsDataUrl(resolvedAvatarPath),
292
+ avatarSourcePath: resolvedAvatarPath
293
+ };
294
+ }
295
+ const defaultAvatarPath = await detectDefaultAvatarPath(input.configDir);
296
+ if (!defaultAvatarPath) {
297
+ return {};
298
+ }
299
+ return {
300
+ avatarSrc: await readAvatarAsDataUrl(defaultAvatarPath),
301
+ avatarSourcePath: defaultAvatarPath
302
+ };
303
+ }
304
+ function stripProfileFileOnlyFields(profile) {
305
+ if (!profile) {
306
+ return undefined;
307
+ }
308
+ const { avatarPath: _avatarPath, ...profileConfig } = profile;
309
+ return profileConfig;
310
+ }
311
+ function printLayoutSummary(layoutReport) {
312
+ console.log("Layout feedback:");
313
+ for (const page of layoutReport.pages) {
314
+ if (page.status === "overflow") {
315
+ console.log(` - Page ${page.pageNumber} [overflow] exceeded safe area by ${page.overflowAmount}px. ${page.recommendation}`);
316
+ continue;
317
+ }
318
+ if (page.status === "warning") {
319
+ console.log(` - Page ${page.pageNumber} [warning] remaining bottom space ${page.remainingBottomSpace}px, below warning threshold ${page.warningThreshold}px. ${page.recommendation}`);
320
+ continue;
321
+ }
322
+ console.log(` - Page ${page.pageNumber} [ok] remaining bottom space ${page.remainingBottomSpace}px.`);
323
+ }
324
+ }
325
+ function shouldFallbackToBrowser(error) {
326
+ if (!(error instanceof Error)) {
327
+ return false;
328
+ }
329
+ return error.message.includes("Unsupported OpenType signature ttcf");
330
+ }
331
+ async function main() {
332
+ const options = parseCliOptions(process.argv.slice(2));
333
+ const inputPath = resolve(options.input);
334
+ const outputPath = resolve(options.output);
335
+ const markdown = await readFile(inputPath, "utf8");
336
+ const configDir = await discoverConfigDir(inputPath, options.configDir);
337
+ const { configFilePath, config } = await readConfigFile(configDir);
338
+ const strippedProfile = stripProfileFileOnlyFields(config.renderConfig?.profile);
339
+ const fileRenderConfig = {
340
+ ...(config.renderConfig ?? {}),
341
+ ...(strippedProfile === undefined ? {} : { profile: strippedProfile })
342
+ };
343
+ const avatarResolveInput = {};
344
+ if (options.avatarPath !== undefined) {
345
+ avatarResolveInput.cliAvatarPath = options.avatarPath;
346
+ }
347
+ if (configDir !== undefined) {
348
+ avatarResolveInput.configDir = configDir;
349
+ }
350
+ if (config.renderConfig?.profile !== undefined) {
351
+ avatarResolveInput.fileProfile = config.renderConfig.profile;
352
+ }
353
+ const avatarOverride = await resolveAvatarOverride(avatarResolveInput);
354
+ const mergedRenderConfig = mergeRenderConfigOverrides(fileRenderConfig, buildCliRenderConfig(options));
355
+ if (avatarOverride.avatarSrc) {
356
+ mergedRenderConfig.profile = {
357
+ ...(mergedRenderConfig.profile ?? {}),
358
+ avatarSrc: avatarOverride.avatarSrc
359
+ };
360
+ }
361
+ const exportInput = {
362
+ markdown,
363
+ markdownFilePath: inputPath,
364
+ outputDir: outputPath,
365
+ renderConfig: mergedRenderConfig
366
+ };
367
+ const resolvedTitle = options.title ?? config.title;
368
+ if (resolvedTitle !== undefined) {
369
+ exportInput.title = resolvedTitle;
370
+ }
371
+ let bundle;
372
+ let rendererUsed;
373
+ if (options.renderer === "browser") {
374
+ bundle = await writeBrowserExportBundle(exportInput);
375
+ rendererUsed = "browser";
376
+ }
377
+ else if (options.renderer === "node") {
378
+ bundle = await writeExportBundle(exportInput);
379
+ rendererUsed = "node";
380
+ }
381
+ else {
382
+ try {
383
+ bundle = await writeExportBundle(exportInput);
384
+ rendererUsed = "node";
385
+ }
386
+ catch (error) {
387
+ if (!shouldFallbackToBrowser(error)) {
388
+ throw error;
389
+ }
390
+ console.warn("Node renderer does not support the requested TTC font. Falling back to browser renderer.");
391
+ bundle = await writeBrowserExportBundle(exportInput);
392
+ rendererUsed = "browser";
393
+ }
394
+ }
395
+ if (configDir) {
396
+ console.log(`Config directory: ${configDir}`);
397
+ }
398
+ else {
399
+ console.log(`Config directory: not found (searched upward from ${dirname(inputPath)} for ${DEFAULT_CONFIG_DIR_NAME})`);
400
+ }
401
+ if (configFilePath && (await isFile(configFilePath))) {
402
+ console.log(`Config file: ${configFilePath}`);
403
+ }
404
+ if (avatarOverride.avatarSourcePath) {
405
+ console.log(`Avatar source: ${avatarOverride.avatarSourcePath}`);
406
+ }
407
+ console.log(`Renderer: ${rendererUsed}`);
408
+ printLayoutSummary(bundle.layoutReport);
409
+ console.log(`Rendered ${bundle.pages.length} pages.`);
410
+ console.log(`Output: ${outputPath}`);
411
+ console.log(`Layout report: ${join(outputPath, "layout-report.json")}`);
412
+ }
413
+ main().catch((error) => {
414
+ const message = error instanceof Error ? error.message : String(error);
415
+ console.error(message);
416
+ process.exit(1);
417
+ });
418
+ //# sourceMappingURL=index.js.map