@imontene/mdrender 1.0.1 → 2.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.
- package/CHANGELOG.md +33 -0
- package/README.md +88 -29
- package/dist/assets/density-compact.css +74 -0
- package/dist/assets/theme.css +19 -0
- package/dist/assets.js +5 -1
- package/dist/cli.js +48 -3
- package/dist/config.d.ts +7 -0
- package/dist/config.js +11 -2
- package/dist/convert.d.ts +7 -1
- package/dist/convert.js +57 -16
- package/dist/doctor.d.ts +30 -0
- package/dist/doctor.js +278 -0
- package/dist/inputs.js +15 -2
- package/dist/markdown/code-blocks.d.ts +9 -0
- package/dist/markdown/code-blocks.js +21 -0
- package/dist/markdown/index.js +3 -0
- package/dist/options.d.ts +44 -8
- package/dist/options.js +107 -5
- package/dist/render/browser.d.ts +4 -1
- package/dist/render/browser.js +8 -4
- package/dist/render/mermaid.d.ts +3 -1
- package/dist/render/mermaid.js +12 -4
- package/dist/render/page.d.ts +5 -0
- package/dist/render/page.js +6 -0
- package/dist/render/template.d.ts +5 -1
- package/dist/render/template.js +12 -2
- package/dist/write.d.ts +9 -0
- package/dist/write.js +41 -0
- package/package.json +1 -1
package/dist/doctor.d.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
export type CheckStatus = "ok" | "warn" | "fail" | "info";
|
|
2
|
+
export interface Check {
|
|
3
|
+
name: string;
|
|
4
|
+
status: CheckStatus;
|
|
5
|
+
detail: string;
|
|
6
|
+
/** What to do about a warning or failure. */
|
|
7
|
+
hint?: string;
|
|
8
|
+
}
|
|
9
|
+
export interface DoctorOptions {
|
|
10
|
+
/** Explicit browser executable (as with --browser). */
|
|
11
|
+
browser?: string;
|
|
12
|
+
/** Config file to check; undefined: nearest to `cwd`; false: none. */
|
|
13
|
+
config?: string | false;
|
|
14
|
+
/** Start the browser and convert a sample document (the slow part). Default: true. */
|
|
15
|
+
render?: boolean;
|
|
16
|
+
cwd?: string;
|
|
17
|
+
env?: Record<string, string | undefined>;
|
|
18
|
+
platform?: NodeJS.Platform;
|
|
19
|
+
}
|
|
20
|
+
export interface DoctorReport {
|
|
21
|
+
version: string;
|
|
22
|
+
checks: Check[];
|
|
23
|
+
ok: boolean;
|
|
24
|
+
}
|
|
25
|
+
/** Where `mdrender` resolves on the PATH, like `which`/`where`. */
|
|
26
|
+
export declare function findOnPath(env: Record<string, string | undefined>, platform?: NodeJS.Platform): string | undefined;
|
|
27
|
+
/** Inspect the installation, browser and configuration, and try a real conversion. */
|
|
28
|
+
export declare function runDoctor(options?: DoctorOptions): Promise<DoctorReport>;
|
|
29
|
+
/** Plain-text report; ASCII markers so it reads well in any Windows console. */
|
|
30
|
+
export declare function formatReport(report: DoctorReport): string;
|
package/dist/doctor.js
ADDED
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
import { existsSync, mkdtempSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import sea from "node:sea";
|
|
5
|
+
import { assetPath, PACKAGE_JSON } from "./assets.js";
|
|
6
|
+
import { cacheDir, installedBrowser } from "./browser/cache.js";
|
|
7
|
+
import { findBrowser } from "./browser/detect.js";
|
|
8
|
+
import { findConfigFile, readConfig } from "./config.js";
|
|
9
|
+
import { BrowserPool, convertFile } from "./convert.js";
|
|
10
|
+
import { DEFAULT_FONT_SIZE, mergeOptions, resolveMargins } from "./options.js";
|
|
11
|
+
import { version } from "./version.js";
|
|
12
|
+
const MIN_NODE = [22, 12];
|
|
13
|
+
function runningAsExecutable() {
|
|
14
|
+
try {
|
|
15
|
+
return sea.isSea();
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
return false;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
function isFile(file) {
|
|
22
|
+
try {
|
|
23
|
+
return statSync(file).isFile();
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
/** Where `mdrender` resolves on the PATH, like `which`/`where`. */
|
|
30
|
+
export function findOnPath(env, platform = process.platform) {
|
|
31
|
+
const pathValue = Object.entries(env).find(([key]) => key.toUpperCase() === "PATH")?.[1] ?? "";
|
|
32
|
+
const separator = platform === "win32" ? ";" : ":";
|
|
33
|
+
const names = platform === "win32"
|
|
34
|
+
? ["mdrender.exe", "mdrender.cmd", "mdrender.ps1", "mdrender"]
|
|
35
|
+
: ["mdrender"];
|
|
36
|
+
const join = platform === "win32" ? path.win32.join : path.posix.join;
|
|
37
|
+
for (const dir of pathValue.split(separator).filter(Boolean)) {
|
|
38
|
+
for (const name of names) {
|
|
39
|
+
const candidate = join(dir, name);
|
|
40
|
+
if (isFile(candidate))
|
|
41
|
+
return candidate;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return undefined;
|
|
45
|
+
}
|
|
46
|
+
function installationChecks(env, platform) {
|
|
47
|
+
const checks = [];
|
|
48
|
+
const executable = runningAsExecutable();
|
|
49
|
+
checks.push({
|
|
50
|
+
name: "mdrender",
|
|
51
|
+
status: "ok",
|
|
52
|
+
detail: executable
|
|
53
|
+
? `${version}, standalone executable (${process.execPath})`
|
|
54
|
+
: `${version}, npm package (${path.dirname(assetPath(PACKAGE_JSON))})`,
|
|
55
|
+
});
|
|
56
|
+
if (!executable) {
|
|
57
|
+
const [major = 0, minor = 0] = process.versions.node.split(".").map(Number);
|
|
58
|
+
const [needMajor, needMinor] = MIN_NODE;
|
|
59
|
+
const supported = major > needMajor || (major === needMajor && minor >= needMinor);
|
|
60
|
+
checks.push({
|
|
61
|
+
name: "Node.js",
|
|
62
|
+
status: supported ? "ok" : "fail",
|
|
63
|
+
detail: `${process.versions.node} (${process.execPath})`,
|
|
64
|
+
hint: supported ? undefined : `mdrender needs Node.js ${needMajor}.${needMinor} or newer`,
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
checks.push({
|
|
68
|
+
name: "System",
|
|
69
|
+
status: "info",
|
|
70
|
+
detail: `${platform} ${process.arch}, ${os.release()}`,
|
|
71
|
+
});
|
|
72
|
+
const onPath = findOnPath(env, platform);
|
|
73
|
+
checks.push(onPath
|
|
74
|
+
? { name: "PATH", status: "ok", detail: `mdrender found at ${onPath}` }
|
|
75
|
+
: {
|
|
76
|
+
name: "PATH",
|
|
77
|
+
status: "warn",
|
|
78
|
+
detail: "the mdrender command is not in any PATH folder",
|
|
79
|
+
hint: "you can only run it by its full path or with npx; add its folder to PATH " +
|
|
80
|
+
"(for npm: the output of `npm prefix -g`, plus /bin on Linux)",
|
|
81
|
+
});
|
|
82
|
+
return checks;
|
|
83
|
+
}
|
|
84
|
+
function browserSource(found, explicit, env, downloaded) {
|
|
85
|
+
if (explicit)
|
|
86
|
+
return "from --browser";
|
|
87
|
+
if (env.MDRENDER_BROWSER)
|
|
88
|
+
return "from MDRENDER_BROWSER";
|
|
89
|
+
if (downloaded && path.resolve(found) === path.resolve(downloaded)) {
|
|
90
|
+
return "downloaded by mdrender setup";
|
|
91
|
+
}
|
|
92
|
+
return "installed on this system";
|
|
93
|
+
}
|
|
94
|
+
function configChecks(options, cwd) {
|
|
95
|
+
if (options.config === false) {
|
|
96
|
+
return [{ name: "Config", status: "info", detail: "config files disabled (--no-config)" }];
|
|
97
|
+
}
|
|
98
|
+
const file = options.config ? path.resolve(cwd, options.config) : findConfigFile(cwd);
|
|
99
|
+
if (!file) {
|
|
100
|
+
const defaults = mergeOptions();
|
|
101
|
+
return [
|
|
102
|
+
{
|
|
103
|
+
name: "Config",
|
|
104
|
+
status: "info",
|
|
105
|
+
detail: `no mdrender.config.json in ${cwd} or its parent folders; defaults apply`,
|
|
106
|
+
},
|
|
107
|
+
settingsCheck(defaults),
|
|
108
|
+
];
|
|
109
|
+
}
|
|
110
|
+
if (!existsSync(file)) {
|
|
111
|
+
return [{ name: "Config", status: "fail", detail: `not found: ${file}` }];
|
|
112
|
+
}
|
|
113
|
+
try {
|
|
114
|
+
const config = readConfig(file);
|
|
115
|
+
const checks = [
|
|
116
|
+
config.warnings.length
|
|
117
|
+
? {
|
|
118
|
+
name: "Config",
|
|
119
|
+
status: "warn",
|
|
120
|
+
detail: `${file}\n${config.warnings.map((w) => ` - ${w}`).join("\n")}`,
|
|
121
|
+
hint: "fix or remove the options above; they are ignored",
|
|
122
|
+
}
|
|
123
|
+
: { name: "Config", status: "ok", detail: file },
|
|
124
|
+
];
|
|
125
|
+
checks.push(settingsCheck(mergeOptions(config.options)));
|
|
126
|
+
return checks;
|
|
127
|
+
}
|
|
128
|
+
catch (error) {
|
|
129
|
+
return [{ name: "Config", status: "fail", detail: error.message }];
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
function settingsCheck(options) {
|
|
133
|
+
const m = resolveMargins(options);
|
|
134
|
+
return {
|
|
135
|
+
name: "Settings",
|
|
136
|
+
status: "info",
|
|
137
|
+
detail: `${options.pageSize} ${options.orientation}, margins ${m.top} ${m.right} ${m.bottom} ` +
|
|
138
|
+
`${m.left} (top right bottom left), ${options.density} density, ` +
|
|
139
|
+
`${options.fontSize ?? DEFAULT_FONT_SIZE} text, language ${options.lang}, theme ` +
|
|
140
|
+
path.basename(options.theme),
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
const SAMPLE = `# mdrender doctor
|
|
144
|
+
|
|
145
|
+
Equation: $e^{i\\pi} + 1 = 0$
|
|
146
|
+
|
|
147
|
+
\`\`\`mermaid
|
|
148
|
+
flowchart LR
|
|
149
|
+
A[Markdown] --> B[PDF]
|
|
150
|
+
\`\`\`
|
|
151
|
+
`;
|
|
152
|
+
async function browserChecks(options, env, platform) {
|
|
153
|
+
const checks = [];
|
|
154
|
+
const dir = cacheDir(env, platform);
|
|
155
|
+
const downloaded = installedBrowser(dir);
|
|
156
|
+
checks.push({
|
|
157
|
+
name: "Setup cache",
|
|
158
|
+
status: "info",
|
|
159
|
+
detail: downloaded ? `${dir} (contains ${downloaded})` : `${dir} (no downloaded browser)`,
|
|
160
|
+
});
|
|
161
|
+
let executable;
|
|
162
|
+
try {
|
|
163
|
+
executable = findBrowser(options.browser, {
|
|
164
|
+
env,
|
|
165
|
+
platform,
|
|
166
|
+
downloaded: () => downloaded,
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
catch (error) {
|
|
170
|
+
checks.push({
|
|
171
|
+
name: "Browser",
|
|
172
|
+
status: "fail",
|
|
173
|
+
detail: error.message.split("\n")[0],
|
|
174
|
+
hint: "run `mdrender setup` to download one, install Chrome/Edge/Chromium, or point to " +
|
|
175
|
+
"one with --browser <path> or MDRENDER_BROWSER",
|
|
176
|
+
});
|
|
177
|
+
return checks;
|
|
178
|
+
}
|
|
179
|
+
checks.push({
|
|
180
|
+
name: "Browser",
|
|
181
|
+
status: "ok",
|
|
182
|
+
detail: `${executable} (${browserSource(executable, options.browser, env, downloaded)})`,
|
|
183
|
+
});
|
|
184
|
+
if (options.render === false)
|
|
185
|
+
return checks;
|
|
186
|
+
const notes = [];
|
|
187
|
+
const pool = new BrowserPool(executable, (message) => notes.push(message));
|
|
188
|
+
const work = mkdtempSync(path.join(os.tmpdir(), "mdrender-doctor-"));
|
|
189
|
+
try {
|
|
190
|
+
let started = Date.now();
|
|
191
|
+
const session = await pool.get();
|
|
192
|
+
const browserVersion = await session.version();
|
|
193
|
+
const unsandboxed = notes.some((note) => note.includes("sandbox"));
|
|
194
|
+
checks.push({
|
|
195
|
+
name: "Browser start",
|
|
196
|
+
status: unsandboxed ? "warn" : "ok",
|
|
197
|
+
detail: `${browserVersion} in ${Date.now() - started} ms`,
|
|
198
|
+
hint: unsandboxed
|
|
199
|
+
? "started without Chrome's sandbox (blocked by the system, e.g. Ubuntu 23.10+); " +
|
|
200
|
+
"conversions still work. A browser installed from a package avoids it"
|
|
201
|
+
: undefined,
|
|
202
|
+
});
|
|
203
|
+
const input = path.join(work, "doctor.md");
|
|
204
|
+
writeFileSync(input, SAMPLE);
|
|
205
|
+
started = Date.now();
|
|
206
|
+
const result = await convertFile({
|
|
207
|
+
input,
|
|
208
|
+
output: path.join(work, "doctor.pdf"),
|
|
209
|
+
format: "pdf",
|
|
210
|
+
config: false,
|
|
211
|
+
browsers: pool,
|
|
212
|
+
});
|
|
213
|
+
const problems = result.diagnostics.map((d) => d.message);
|
|
214
|
+
checks.push({
|
|
215
|
+
name: "Test conversion",
|
|
216
|
+
status: problems.length ? "fail" : "ok",
|
|
217
|
+
detail: problems.length
|
|
218
|
+
? problems.join("; ")
|
|
219
|
+
: `PDF with an equation and a Mermaid diagram in ${Date.now() - started} ms`,
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
catch (error) {
|
|
223
|
+
checks.push({
|
|
224
|
+
name: "Browser start",
|
|
225
|
+
status: "fail",
|
|
226
|
+
detail: error.message.split("\n")[0],
|
|
227
|
+
hint: "try another browser with --browser <path>, or `mdrender setup --force`",
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
finally {
|
|
231
|
+
await pool.close();
|
|
232
|
+
rmSync(work, { recursive: true, force: true });
|
|
233
|
+
}
|
|
234
|
+
return checks;
|
|
235
|
+
}
|
|
236
|
+
function networkChecks(env) {
|
|
237
|
+
const proxy = ["HTTPS_PROXY", "https_proxy", "HTTP_PROXY", "http_proxy"].find((k) => env[k]);
|
|
238
|
+
return proxy
|
|
239
|
+
? [{ name: "Proxy", status: "info", detail: `${proxy} is set (used by mdrender setup)` }]
|
|
240
|
+
: [];
|
|
241
|
+
}
|
|
242
|
+
/** Inspect the installation, browser and configuration, and try a real conversion. */
|
|
243
|
+
export async function runDoctor(options = {}) {
|
|
244
|
+
const env = options.env ?? process.env;
|
|
245
|
+
const platform = options.platform ?? process.platform;
|
|
246
|
+
const cwd = options.cwd ?? process.cwd();
|
|
247
|
+
const checks = [
|
|
248
|
+
...installationChecks(env, platform),
|
|
249
|
+
...(await browserChecks(options, env, platform)),
|
|
250
|
+
...configChecks(options, cwd),
|
|
251
|
+
...networkChecks(env),
|
|
252
|
+
];
|
|
253
|
+
return { version, checks, ok: !checks.some((check) => check.status === "fail") };
|
|
254
|
+
}
|
|
255
|
+
const LABEL = {
|
|
256
|
+
ok: "[ok] ",
|
|
257
|
+
warn: "[warn]",
|
|
258
|
+
fail: "[fail]",
|
|
259
|
+
info: "[info]",
|
|
260
|
+
};
|
|
261
|
+
/** Plain-text report; ASCII markers so it reads well in any Windows console. */
|
|
262
|
+
export function formatReport(report) {
|
|
263
|
+
const width = Math.max(...report.checks.map((check) => check.name.length));
|
|
264
|
+
const lines = report.checks.map((check) => {
|
|
265
|
+
const [first, ...rest] = check.detail.split("\n");
|
|
266
|
+
const head = `${LABEL[check.status]} ${check.name.padEnd(width)} ${first}`;
|
|
267
|
+
const more = rest.map((line) => `${" ".repeat(width + 9)}${line}`);
|
|
268
|
+
const hint = check.hint ? [`${" ".repeat(width + 9)}-> ${check.hint}`] : [];
|
|
269
|
+
return [head, ...more, ...hint].join("\n");
|
|
270
|
+
});
|
|
271
|
+
const failures = report.checks.filter((c) => c.status === "fail").length;
|
|
272
|
+
const warnings = report.checks.filter((c) => c.status === "warn").length;
|
|
273
|
+
const summary = report.ok
|
|
274
|
+
? `All good${warnings ? ` (${warnings} warning${warnings > 1 ? "s" : ""})` : ""}.`
|
|
275
|
+
: `${failures} problem${failures > 1 ? "s" : ""} found.`;
|
|
276
|
+
return `mdrender doctor\n\n${lines.join("\n")}\n\n${summary}\n`;
|
|
277
|
+
}
|
|
278
|
+
//# sourceMappingURL=doctor.js.map
|
package/dist/inputs.js
CHANGED
|
@@ -95,7 +95,7 @@ export function planOutputs(inputs, format, output, cwd = process.cwd()) {
|
|
|
95
95
|
if (several) {
|
|
96
96
|
throw new OptionsError(`with several input files, --output must be a folder: ${output}`);
|
|
97
97
|
}
|
|
98
|
-
return [{ input: inputs[0].file, output: outDir }];
|
|
98
|
+
return checkJobs([{ input: inputs[0].file, output: outDir }], inputs);
|
|
99
99
|
}
|
|
100
100
|
const jobs = inputs.map(({ file, root }) => {
|
|
101
101
|
const name = path.parse(file).name + ext;
|
|
@@ -104,9 +104,22 @@ export function planOutputs(inputs, format, output, cwd = process.cwd()) {
|
|
|
104
104
|
const relativeDir = root ? path.relative(root, path.dirname(file)) : "";
|
|
105
105
|
return { input: file, output: path.join(outDir, relativeDir, name) };
|
|
106
106
|
});
|
|
107
|
+
return checkJobs(jobs, inputs);
|
|
108
|
+
}
|
|
109
|
+
/** Path comparison key: Windows paths are case-insensitive. */
|
|
110
|
+
const pathKey = (file) => process.platform === "win32" ? path.resolve(file).toLowerCase() : path.resolve(file);
|
|
111
|
+
/**
|
|
112
|
+
* Refuse plans that would destroy data: an output that is one of the input Markdown files
|
|
113
|
+
* (`mdrender doc.md -o doc.md`), or two inputs written to the same output.
|
|
114
|
+
*/
|
|
115
|
+
function checkJobs(jobs, inputs) {
|
|
116
|
+
const sources = new Set(inputs.map(({ file }) => pathKey(file)));
|
|
107
117
|
const targets = new Map();
|
|
108
118
|
for (const job of jobs) {
|
|
109
|
-
const key =
|
|
119
|
+
const key = pathKey(job.output);
|
|
120
|
+
if (sources.has(key)) {
|
|
121
|
+
throw new OptionsError(`refusing to overwrite the input file ${job.output}; choose another --output`);
|
|
122
|
+
}
|
|
110
123
|
const other = targets.get(key);
|
|
111
124
|
if (other) {
|
|
112
125
|
throw new OptionsError(`${other} and ${job.input} would both be written to ${job.output}`);
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { MarkdownIt } from "markdown-it";
|
|
2
|
+
/**
|
|
3
|
+
* Code blocks up to this many lines are kept on one page; longer ones may break across pages
|
|
4
|
+
* (the theme keeps at least a few lines on each side), so a long listing does not push itself
|
|
5
|
+
* to the next page and leave a large gap behind.
|
|
6
|
+
*/
|
|
7
|
+
export declare const SHORT_CODE_LINES = 12;
|
|
8
|
+
/** Mark short fenced and indented code blocks with `keep-together` (see SHORT_CODE_LINES). */
|
|
9
|
+
export declare function codeBlocks(md: MarkdownIt): void;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Code blocks up to this many lines are kept on one page; longer ones may break across pages
|
|
3
|
+
* (the theme keeps at least a few lines on each side), so a long listing does not push itself
|
|
4
|
+
* to the next page and leave a large gap behind.
|
|
5
|
+
*/
|
|
6
|
+
export const SHORT_CODE_LINES = 12;
|
|
7
|
+
const KEEP_CLASS = "keep-together";
|
|
8
|
+
function markShort(html, content) {
|
|
9
|
+
const lines = content.replace(/\n$/, "").split("\n").length;
|
|
10
|
+
if (lines > SHORT_CODE_LINES || !html.startsWith("<pre"))
|
|
11
|
+
return html;
|
|
12
|
+
return html.replace(/^<pre(?: class="([^"]*)")?/, (_m, classes) => classes ? `<pre class="${classes} ${KEEP_CLASS}"` : `<pre class="${KEEP_CLASS}"`);
|
|
13
|
+
}
|
|
14
|
+
/** Mark short fenced and indented code blocks with `keep-together` (see SHORT_CODE_LINES). */
|
|
15
|
+
export function codeBlocks(md) {
|
|
16
|
+
for (const rule of ["fence", "code_block"]) {
|
|
17
|
+
const render = md.renderer.rules[rule];
|
|
18
|
+
md.renderer.rules[rule] = (tokens, idx, options, env, self) => markShort(render(tokens, idx, options, env, self), tokens[idx].content);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
//# sourceMappingURL=code-blocks.js.map
|
package/dist/markdown/index.js
CHANGED
|
@@ -4,6 +4,7 @@ import anchor from "markdown-it-anchor";
|
|
|
4
4
|
import footnote from "markdown-it-footnote";
|
|
5
5
|
import { DEFAULT_LANG } from "../i18n.js";
|
|
6
6
|
import { alerts } from "./alerts.js";
|
|
7
|
+
import { codeBlocks } from "./code-blocks.js";
|
|
7
8
|
import { inlineChildLines } from "./env.js";
|
|
8
9
|
import { highlightCode } from "./highlight.js";
|
|
9
10
|
import { inlineLocalImages } from "./images.js";
|
|
@@ -25,6 +26,8 @@ export function createMarkdown() {
|
|
|
25
26
|
md.use(tocMarker);
|
|
26
27
|
md.use(inlineChildLines);
|
|
27
28
|
md.use(inlineLocalImages);
|
|
29
|
+
// Before math and mermaid, which hand their non-code fences on to this renderer.
|
|
30
|
+
md.use(codeBlocks);
|
|
28
31
|
md.use(math);
|
|
29
32
|
md.use(mermaid);
|
|
30
33
|
return md;
|
package/dist/options.d.ts
CHANGED
|
@@ -1,15 +1,30 @@
|
|
|
1
1
|
import { MdRenderError } from "./errors.js";
|
|
2
2
|
export declare const PAGE_SIZES: readonly ["A3", "A4", "A5", "Letter", "Legal", "Tabloid"];
|
|
3
3
|
export type PageSize = (typeof PAGE_SIZES)[number];
|
|
4
|
+
export declare const ORIENTATIONS: readonly ["portrait", "landscape"];
|
|
5
|
+
export type Orientation = (typeof ORIENTATIONS)[number];
|
|
6
|
+
export type Margins = {
|
|
7
|
+
top: string;
|
|
8
|
+
right: string;
|
|
9
|
+
bottom: string;
|
|
10
|
+
left: string;
|
|
11
|
+
};
|
|
4
12
|
export declare const MERMAID_THEMES: readonly ["default", "neutral", "dark", "forest", "base"];
|
|
5
13
|
export type MermaidTheme = (typeof MERMAID_THEMES)[number];
|
|
6
14
|
export declare const BUILTIN_THEMES: readonly ["light", "dark"];
|
|
15
|
+
export declare const DENSITIES: readonly ["normal", "compact"];
|
|
16
|
+
export type Density = (typeof DENSITIES)[number];
|
|
7
17
|
/** Rendering options, merged from defaults < config file < front matter < command line. */
|
|
8
18
|
export interface DocumentOptions {
|
|
9
19
|
pageSize: PageSize;
|
|
10
20
|
/** CSS margin shorthand: 1 to 4 lengths in mm, cm, in or px. */
|
|
11
21
|
margin: string;
|
|
12
|
-
|
|
22
|
+
/** Single sides, overriding the shorthand (see `resolveMargins`). */
|
|
23
|
+
marginTop?: string;
|
|
24
|
+
marginRight?: string;
|
|
25
|
+
marginBottom?: string;
|
|
26
|
+
marginLeft?: string;
|
|
27
|
+
orientation: Orientation;
|
|
13
28
|
toc: boolean;
|
|
14
29
|
/** Deepest heading level listed in the table of contents. */
|
|
15
30
|
tocDepth: number;
|
|
@@ -20,6 +35,10 @@ export interface DocumentOptions {
|
|
|
20
35
|
footer?: string;
|
|
21
36
|
/** Mermaid theme; when unset, "neutral" for light pages and "dark" for the dark theme. */
|
|
22
37
|
mermaidTheme?: MermaidTheme;
|
|
38
|
+
/** Vertical spacing: "compact" tightens line height, paragraph and heading spacing. */
|
|
39
|
+
density: Density;
|
|
40
|
+
/** Base font size of the text, e.g. "10pt"; when unset, the theme's (10.5pt). */
|
|
41
|
+
fontSize?: string;
|
|
23
42
|
/** "light", "dark", or the absolute path of a stylesheet that replaces the built-in theme. */
|
|
24
43
|
theme: string;
|
|
25
44
|
/** Extra stylesheets (absolute paths), applied after the built-in theme. */
|
|
@@ -36,12 +55,11 @@ export declare class OptionsError extends MdRenderError {
|
|
|
36
55
|
constructor(message: string);
|
|
37
56
|
}
|
|
38
57
|
/** Parse a CSS margin shorthand into the four sides, or throw. */
|
|
39
|
-
export declare function parseMargin(margin: string):
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
};
|
|
58
|
+
export declare function parseMargin(margin: string): Margins;
|
|
59
|
+
/** Text size of the built-in themes (theme.css), when --font-size is not given. */
|
|
60
|
+
export declare const DEFAULT_FONT_SIZE = "10.5pt";
|
|
61
|
+
/** A font size in CSS pixels ("10.5pt" → 14). */
|
|
62
|
+
export declare function fontSizePx(size: string): number;
|
|
45
63
|
/**
|
|
46
64
|
* Validate options from a config file, front matter or the command line.
|
|
47
65
|
*
|
|
@@ -51,5 +69,23 @@ export declare function parseMargin(margin: string): {
|
|
|
51
69
|
export declare function normalizeOptions(raw: Record<string, unknown>, source: string, baseDir: string,
|
|
52
70
|
/** How to name a key in error messages (e.g. as a command-line flag). */
|
|
53
71
|
label?: (key: string) => string): Partial<DocumentOptions>;
|
|
54
|
-
/**
|
|
72
|
+
/**
|
|
73
|
+
* Merge option layers, later ones winning. CSS lists accumulate instead of replacing. A layer
|
|
74
|
+
* that sets the `margin` shorthand also replaces single-side margins from earlier layers, so
|
|
75
|
+
* `--margin 15mm` on the command line is not partly undone by `marginLeft` in a config file.
|
|
76
|
+
*/
|
|
55
77
|
export declare function mergeOptions(...layers: Partial<DocumentOptions>[]): DocumentOptions;
|
|
78
|
+
/** The four page margins: the `margin` shorthand, with single-side options on top. */
|
|
79
|
+
export declare function resolveMargins(options: DocumentOptions): Margins;
|
|
80
|
+
/** Every option name accepted in a config file, in camelCase. */
|
|
81
|
+
export declare const OPTION_KEYS: readonly string[];
|
|
82
|
+
export interface UnknownOption {
|
|
83
|
+
key: string;
|
|
84
|
+
/** Closest known option, written in the same style (camelCase or kebab-case) as `key`. */
|
|
85
|
+
suggestion?: string;
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Keys that are not options, with the closest known option when one is near: catches typos
|
|
89
|
+
* such as "pagesize" or "margn" that would otherwise be ignored silently.
|
|
90
|
+
*/
|
|
91
|
+
export declare function unknownOptions(raw: Record<string, unknown>): UnknownOption[];
|
package/dist/options.js
CHANGED
|
@@ -3,15 +3,18 @@ import { ExitCode } from "./exit-codes.js";
|
|
|
3
3
|
import { MdRenderError } from "./errors.js";
|
|
4
4
|
import { DEFAULT_LANG } from "./i18n.js";
|
|
5
5
|
export const PAGE_SIZES = ["A3", "A4", "A5", "Letter", "Legal", "Tabloid"];
|
|
6
|
+
export const ORIENTATIONS = ["portrait", "landscape"];
|
|
6
7
|
export const MERMAID_THEMES = ["default", "neutral", "dark", "forest", "base"];
|
|
7
8
|
export const BUILTIN_THEMES = ["light", "dark"];
|
|
9
|
+
export const DENSITIES = ["normal", "compact"];
|
|
8
10
|
export const DEFAULT_OPTIONS = {
|
|
9
|
-
pageSize: "
|
|
10
|
-
margin: "
|
|
11
|
-
|
|
11
|
+
pageSize: "Letter",
|
|
12
|
+
margin: "10mm",
|
|
13
|
+
orientation: "portrait",
|
|
12
14
|
toc: false,
|
|
13
15
|
tocDepth: 3,
|
|
14
16
|
pageNumbers: true,
|
|
17
|
+
density: "normal",
|
|
15
18
|
theme: "light",
|
|
16
19
|
css: [],
|
|
17
20
|
lang: DEFAULT_LANG,
|
|
@@ -51,6 +54,34 @@ const oneOf = (choices) => (value, describe) => {
|
|
|
51
54
|
return match;
|
|
52
55
|
throw new OptionsError(`${describe} must be one of: ${choices.join(", ")}`);
|
|
53
56
|
};
|
|
57
|
+
const length = (value, describe) => {
|
|
58
|
+
const raw = text(value, describe, "");
|
|
59
|
+
const trimmed = raw.trim();
|
|
60
|
+
if (LENGTH.test(trimmed))
|
|
61
|
+
return trimmed;
|
|
62
|
+
throw new OptionsError(`${describe} must be a length in mm, cm, in or px, e.g. "10mm"`);
|
|
63
|
+
};
|
|
64
|
+
/** Text size of the built-in themes (theme.css), when --font-size is not given. */
|
|
65
|
+
export const DEFAULT_FONT_SIZE = "10.5pt";
|
|
66
|
+
/** Font sizes accepted by --font-size, in points or pixels. */
|
|
67
|
+
const FONT_SIZE = /^(\d+(?:\.\d+)?)(pt|px)$/;
|
|
68
|
+
const FONT_SIZE_PT = { min: 6, max: 24 };
|
|
69
|
+
/** A font size in CSS pixels ("10.5pt" → 14). */
|
|
70
|
+
export function fontSizePx(size) {
|
|
71
|
+
const match = FONT_SIZE.exec(size);
|
|
72
|
+
if (!match)
|
|
73
|
+
throw new Error(`invalid font size: ${size}`);
|
|
74
|
+
return match[2] === "pt" ? (Number(match[1]) * 4) / 3 : Number(match[1]);
|
|
75
|
+
}
|
|
76
|
+
const fontSize = (value, describe) => {
|
|
77
|
+
const size = text(value, describe, "").trim();
|
|
78
|
+
if (FONT_SIZE.test(size)) {
|
|
79
|
+
const pt = (fontSizePx(size) * 3) / 4;
|
|
80
|
+
if (pt >= FONT_SIZE_PT.min && pt <= FONT_SIZE_PT.max)
|
|
81
|
+
return size;
|
|
82
|
+
}
|
|
83
|
+
throw new OptionsError(`${describe} must be a size in pt or px from ${FONT_SIZE_PT.min}pt to ${FONT_SIZE_PT.max}pt, e.g. "10pt"`);
|
|
84
|
+
};
|
|
54
85
|
const VALIDATORS = {
|
|
55
86
|
pageSize: oneOf(PAGE_SIZES),
|
|
56
87
|
margin: (value, describe) => {
|
|
@@ -63,7 +94,11 @@ const VALIDATORS = {
|
|
|
63
94
|
}
|
|
64
95
|
return margin;
|
|
65
96
|
},
|
|
66
|
-
|
|
97
|
+
marginTop: length,
|
|
98
|
+
marginRight: length,
|
|
99
|
+
marginBottom: length,
|
|
100
|
+
marginLeft: length,
|
|
101
|
+
orientation: oneOf(ORIENTATIONS),
|
|
67
102
|
toc: bool,
|
|
68
103
|
tocDepth: (value, describe) => {
|
|
69
104
|
const depth = Number(value);
|
|
@@ -75,6 +110,8 @@ const VALIDATORS = {
|
|
|
75
110
|
header: text,
|
|
76
111
|
footer: text,
|
|
77
112
|
mermaidTheme: oneOf(MERMAID_THEMES),
|
|
113
|
+
density: oneOf(DENSITIES),
|
|
114
|
+
fontSize,
|
|
78
115
|
theme: (value, describe, baseDir) => {
|
|
79
116
|
const theme = text(value, describe, baseDir);
|
|
80
117
|
const builtin = BUILTIN_THEMES.find((t) => t === theme.toLowerCase());
|
|
@@ -109,6 +146,12 @@ label = (key) => `"${key}"`) {
|
|
|
109
146
|
for (const [key, value] of Object.entries(raw)) {
|
|
110
147
|
if (value === undefined || value === null)
|
|
111
148
|
continue;
|
|
149
|
+
// `landscape: true` is kept as a shorthand for `orientation: landscape`.
|
|
150
|
+
if (toCamel(key) === "landscape") {
|
|
151
|
+
const landscape = bool(value, `${source}: ${label(key)}`, baseDir);
|
|
152
|
+
result.orientation = landscape ? "landscape" : "portrait";
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
112
155
|
const name = toCamel(key);
|
|
113
156
|
const validate = VALIDATORS[name];
|
|
114
157
|
if (!validate)
|
|
@@ -117,15 +160,74 @@ label = (key) => `"${key}"`) {
|
|
|
117
160
|
}
|
|
118
161
|
return result;
|
|
119
162
|
}
|
|
120
|
-
|
|
163
|
+
const SIDES = ["marginTop", "marginRight", "marginBottom", "marginLeft"];
|
|
164
|
+
/**
|
|
165
|
+
* Merge option layers, later ones winning. CSS lists accumulate instead of replacing. A layer
|
|
166
|
+
* that sets the `margin` shorthand also replaces single-side margins from earlier layers, so
|
|
167
|
+
* `--margin 15mm` on the command line is not partly undone by `marginLeft` in a config file.
|
|
168
|
+
*/
|
|
121
169
|
export function mergeOptions(...layers) {
|
|
122
170
|
const merged = { ...DEFAULT_OPTIONS, css: [] };
|
|
123
171
|
for (const layer of layers) {
|
|
124
172
|
const { css, ...rest } = layer;
|
|
173
|
+
if (rest.margin !== undefined)
|
|
174
|
+
for (const side of SIDES)
|
|
175
|
+
delete merged[side];
|
|
125
176
|
Object.assign(merged, rest);
|
|
126
177
|
if (css)
|
|
127
178
|
merged.css.push(...css);
|
|
128
179
|
}
|
|
129
180
|
return merged;
|
|
130
181
|
}
|
|
182
|
+
/** The four page margins: the `margin` shorthand, with single-side options on top. */
|
|
183
|
+
export function resolveMargins(options) {
|
|
184
|
+
const base = parseMargin(options.margin);
|
|
185
|
+
return {
|
|
186
|
+
top: options.marginTop ?? base.top,
|
|
187
|
+
right: options.marginRight ?? base.right,
|
|
188
|
+
bottom: options.marginBottom ?? base.bottom,
|
|
189
|
+
left: options.marginLeft ?? base.left,
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
/** Edit distance between two strings (insertions, deletions, substitutions). */
|
|
193
|
+
function editDistance(a, b) {
|
|
194
|
+
let previous = Array.from({ length: b.length + 1 }, (_, j) => j);
|
|
195
|
+
for (let i = 1; i <= a.length; i++) {
|
|
196
|
+
const current = [i];
|
|
197
|
+
for (let j = 1; j <= b.length; j++) {
|
|
198
|
+
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
|
|
199
|
+
current[j] = Math.min(previous[j] + 1, current[j - 1] + 1, previous[j - 1] + cost);
|
|
200
|
+
}
|
|
201
|
+
previous = current;
|
|
202
|
+
}
|
|
203
|
+
return previous[b.length];
|
|
204
|
+
}
|
|
205
|
+
const toKebab = (key) => key.replace(/[A-Z]/g, (c) => "-" + c.toLowerCase());
|
|
206
|
+
/** Every option name accepted in a config file, in camelCase. */
|
|
207
|
+
export const OPTION_KEYS = [...Object.keys(VALIDATORS), "landscape"];
|
|
208
|
+
/**
|
|
209
|
+
* Keys that are not options, with the closest known option when one is near: catches typos
|
|
210
|
+
* such as "pagesize" or "margn" that would otherwise be ignored silently.
|
|
211
|
+
*/
|
|
212
|
+
export function unknownOptions(raw) {
|
|
213
|
+
const unknown = [];
|
|
214
|
+
for (const key of Object.keys(raw)) {
|
|
215
|
+
if (OPTION_KEYS.includes(toCamel(key)))
|
|
216
|
+
continue;
|
|
217
|
+
const plain = key.toLowerCase().replace(/[-_]/g, "");
|
|
218
|
+
let best;
|
|
219
|
+
let bestDistance = Infinity;
|
|
220
|
+
for (const known of OPTION_KEYS) {
|
|
221
|
+
const distance = editDistance(plain, known.toLowerCase());
|
|
222
|
+
if (distance < bestDistance) {
|
|
223
|
+
best = known;
|
|
224
|
+
bestDistance = distance;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
const close = best !== undefined && bestDistance <= Math.max(1, Math.floor(plain.length / 4));
|
|
228
|
+
const suggestion = close ? (key.includes("-") ? toKebab(best) : best) : undefined;
|
|
229
|
+
unknown.push({ key, suggestion });
|
|
230
|
+
}
|
|
231
|
+
return unknown;
|
|
232
|
+
}
|
|
131
233
|
//# sourceMappingURL=options.js.map
|
package/dist/render/browser.d.ts
CHANGED
|
@@ -8,7 +8,6 @@ import { type Page, type PDFOptions } from "puppeteer-core";
|
|
|
8
8
|
*/
|
|
9
9
|
export declare function shouldRetryWithoutSandbox(error: unknown, args: string[], platform?: NodeJS.Platform): boolean;
|
|
10
10
|
export interface PrintOptions {
|
|
11
|
-
output: string;
|
|
12
11
|
format?: PDFOptions["format"];
|
|
13
12
|
landscape?: boolean;
|
|
14
13
|
margin?: {
|
|
@@ -41,8 +40,12 @@ export declare class BrowserSession {
|
|
|
41
40
|
* rendered by then (math by KaTeX, diagrams by Mermaid), and any <script> in the Markdown
|
|
42
41
|
* must not run.
|
|
43
42
|
*/
|
|
43
|
+
/** Returns the PDF bytes (the caller writes them) and the URLs that were blocked. */
|
|
44
44
|
printPdf(html: string, options: PrintOptions): Promise<{
|
|
45
|
+
pdf: Uint8Array;
|
|
45
46
|
blocked: string[];
|
|
46
47
|
}>;
|
|
48
|
+
/** Browser name and version, e.g. "HeadlessChrome/141.0.7390.37". */
|
|
49
|
+
version(): Promise<string>;
|
|
47
50
|
close(): Promise<void>;
|
|
48
51
|
}
|