@imontene/mdrender 1.0.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 +20 -0
- package/LICENSE +21 -0
- package/README.md +243 -0
- package/dist/assets/theme-dark.css +40 -0
- package/dist/assets/theme.css +545 -0
- package/dist/assets.d.ts +45 -0
- package/dist/assets.js +109 -0
- package/dist/bin.d.ts +2 -0
- package/dist/bin.js +16 -0
- package/dist/browser/cache.d.ts +15 -0
- package/dist/browser/cache.js +30 -0
- package/dist/browser/detect.d.ts +17 -0
- package/dist/browser/detect.js +90 -0
- package/dist/browser/setup.d.ts +13 -0
- package/dist/browser/setup.js +69 -0
- package/dist/cli.d.ts +8 -0
- package/dist/cli.js +194 -0
- package/dist/config.d.ts +6 -0
- package/dist/config.js +32 -0
- package/dist/convert.d.ts +64 -0
- package/dist/convert.js +167 -0
- package/dist/diagnostics.d.ts +8 -0
- package/dist/diagnostics.js +6 -0
- package/dist/errors.d.ts +12 -0
- package/dist/errors.js +21 -0
- package/dist/exit-codes.d.ts +8 -0
- package/dist/exit-codes.js +8 -0
- package/dist/frontmatter.d.ts +10 -0
- package/dist/frontmatter.js +26 -0
- package/dist/html.d.ts +2 -0
- package/dist/html.js +12 -0
- package/dist/i18n.d.ts +9 -0
- package/dist/i18n.js +59 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.js +18 -0
- package/dist/inputs.d.ts +26 -0
- package/dist/inputs.js +118 -0
- package/dist/markdown/alerts.d.ts +6 -0
- package/dist/markdown/alerts.js +35 -0
- package/dist/markdown/env.d.ts +29 -0
- package/dist/markdown/env.js +16 -0
- package/dist/markdown/highlight.d.ts +6 -0
- package/dist/markdown/highlight.js +17 -0
- package/dist/markdown/images.d.ts +9 -0
- package/dist/markdown/images.js +93 -0
- package/dist/markdown/index.d.ts +27 -0
- package/dist/markdown/index.js +69 -0
- package/dist/markdown/math.d.ts +9 -0
- package/dist/markdown/math.js +147 -0
- package/dist/markdown/mermaid.d.ts +8 -0
- package/dist/markdown/mermaid.js +22 -0
- package/dist/markdown/task-lists.d.ts +6 -0
- package/dist/markdown/task-lists.js +40 -0
- package/dist/markdown/toc.d.ts +22 -0
- package/dist/markdown/toc.js +101 -0
- package/dist/options.d.ts +55 -0
- package/dist/options.js +131 -0
- package/dist/paths.d.ts +2 -0
- package/dist/paths.js +4 -0
- package/dist/preview.d.ts +22 -0
- package/dist/preview.js +126 -0
- package/dist/render/browser.d.ts +48 -0
- package/dist/render/browser.js +134 -0
- package/dist/render/fonts.d.ts +10 -0
- package/dist/render/fonts.js +47 -0
- package/dist/render/header-footer.d.ts +22 -0
- package/dist/render/header-footer.js +39 -0
- package/dist/render/katex.d.ts +2 -0
- package/dist/render/katex.js +19 -0
- package/dist/render/mermaid.d.ts +25 -0
- package/dist/render/mermaid.js +91 -0
- package/dist/render/page.d.ts +7 -0
- package/dist/render/page.js +23 -0
- package/dist/render/template.d.ts +22 -0
- package/dist/render/template.js +54 -0
- package/dist/run.d.ts +26 -0
- package/dist/run.js +71 -0
- package/dist/version.d.ts +1 -0
- package/dist/version.js +4 -0
- package/dist/watch.d.ts +25 -0
- package/dist/watch.js +128 -0
- package/package.json +97 -0
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { MERMAID_JS, readTextAsset } from "../assets.js";
|
|
2
|
+
import { mermaidPlaceholder } from "../markdown/mermaid.js";
|
|
3
|
+
import { fontFaceCss } from "./fonts.js";
|
|
4
|
+
import { escapeHtml } from "../html.js";
|
|
5
|
+
export { MERMAID_THEMES } from "../options.js";
|
|
6
|
+
/** Width of the text column on A4 with 20 mm margins. */
|
|
7
|
+
const DEFAULT_WIDTH_PX = 642;
|
|
8
|
+
let mermaidJs;
|
|
9
|
+
/**
|
|
10
|
+
* Render Mermaid diagrams to SVG in a blank page that holds no user content: only the diagram
|
|
11
|
+
* sources are passed in, as data. Mermaid runs with `securityLevel: "strict"`.
|
|
12
|
+
*/
|
|
13
|
+
export async function renderMermaid(session, blocks, theme = "neutral",
|
|
14
|
+
/** Width of the text column, so diagrams are laid out at their printed size. */
|
|
15
|
+
widthPx = DEFAULT_WIDTH_PX) {
|
|
16
|
+
mermaidJs ??= readTextAsset(MERMAID_JS);
|
|
17
|
+
const page = await session.newPage();
|
|
18
|
+
try {
|
|
19
|
+
await page.setContent(`<!doctype html><html><head><meta charset="utf-8"><style>${fontFaceCss()}
|
|
20
|
+
body { margin: 0; width: ${widthPx}px; font-family: "Inter", sans-serif; }</style>
|
|
21
|
+
</head><body></body></html>`);
|
|
22
|
+
await page.evaluate(mermaidJs);
|
|
23
|
+
return await page.evaluate(async (sources, theme) => {
|
|
24
|
+
// Runs in the browser. The project is compiled without DOM types, hence PageGlobals.
|
|
25
|
+
const { document, mermaid } = globalThis;
|
|
26
|
+
// Load the font weights Mermaid uses before it measures any text.
|
|
27
|
+
await Promise.all(["400", "600", "700"].map((w) => document.fonts.load(`${w} 16px Inter`)));
|
|
28
|
+
mermaid.initialize({
|
|
29
|
+
startOnLoad: false,
|
|
30
|
+
securityLevel: "strict",
|
|
31
|
+
theme,
|
|
32
|
+
fontFamily: "Inter, sans-serif",
|
|
33
|
+
themeVariables: { fontFamily: "Inter, sans-serif" },
|
|
34
|
+
htmlLabels: true,
|
|
35
|
+
});
|
|
36
|
+
const results = [];
|
|
37
|
+
for (const [i, source] of sources.entries()) {
|
|
38
|
+
try {
|
|
39
|
+
const { svg } = await mermaid.render(`mermaid-${i + 1}`, source);
|
|
40
|
+
results.push({ svg });
|
|
41
|
+
}
|
|
42
|
+
catch (error) {
|
|
43
|
+
results.push({ error: String(error?.message ?? error) });
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return results;
|
|
47
|
+
}, blocks.map((b) => b.source), theme);
|
|
48
|
+
}
|
|
49
|
+
finally {
|
|
50
|
+
await page.close();
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* One-line summary of a Mermaid error. Parse errors span several lines ("Parse error on line N:",
|
|
55
|
+
* a code excerpt, a caret, "Expecting ..."); the line number counts from the diagram, not the
|
|
56
|
+
* file, so the "Expecting ..." line is the useful part.
|
|
57
|
+
*/
|
|
58
|
+
function summarize(message) {
|
|
59
|
+
const lines = message
|
|
60
|
+
.split("\n")
|
|
61
|
+
.map((l) => l.trim())
|
|
62
|
+
.filter(Boolean);
|
|
63
|
+
const expecting = lines.find((l) => l.startsWith("Expecting"));
|
|
64
|
+
return expecting ?? lines[0] ?? "unknown error";
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Replace the Mermaid placeholders in `html` with rendered SVGs, or with a visible error box.
|
|
68
|
+
* Returns the new HTML and one error diagnostic per failed diagram.
|
|
69
|
+
*/
|
|
70
|
+
export function fillMermaid(html, nonce, blocks, results) {
|
|
71
|
+
const diagnostics = [];
|
|
72
|
+
blocks.forEach((block, i) => {
|
|
73
|
+
const result = results[i] ?? { error: "diagram was not rendered" };
|
|
74
|
+
let replacement;
|
|
75
|
+
if ("svg" in result) {
|
|
76
|
+
replacement = result.svg;
|
|
77
|
+
}
|
|
78
|
+
else {
|
|
79
|
+
const message = result.error.trim();
|
|
80
|
+
diagnostics.push({
|
|
81
|
+
severity: "error",
|
|
82
|
+
message: `Mermaid: ${summarize(message)}`,
|
|
83
|
+
line: block.line,
|
|
84
|
+
});
|
|
85
|
+
replacement = `<pre class="mermaid-error"><strong>Mermaid error</strong>\n${escapeHtml(message)}\n\n${escapeHtml(block.source)}</pre>`;
|
|
86
|
+
}
|
|
87
|
+
html = html.replace(mermaidPlaceholder(nonce, i), () => replacement);
|
|
88
|
+
});
|
|
89
|
+
return { html, diagnostics };
|
|
90
|
+
}
|
|
91
|
+
//# sourceMappingURL=mermaid.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { PageSize } from "../options.js";
|
|
2
|
+
export declare function lengthToPx(length: string): number;
|
|
3
|
+
/** Width of the text column in CSS pixels, so diagrams are laid out at their printed size. */
|
|
4
|
+
export declare function contentWidthPx(pageSize: PageSize, landscape: boolean, margin: {
|
|
5
|
+
left: string;
|
|
6
|
+
right: string;
|
|
7
|
+
}): number;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/** Paper sizes in millimetres, portrait (width, height). */
|
|
2
|
+
const PAPER_MM = {
|
|
3
|
+
A3: [297, 420],
|
|
4
|
+
A4: [210, 297],
|
|
5
|
+
A5: [148, 210],
|
|
6
|
+
Letter: [215.9, 279.4],
|
|
7
|
+
Legal: [215.9, 355.6],
|
|
8
|
+
Tabloid: [279.4, 431.8],
|
|
9
|
+
};
|
|
10
|
+
const PX_PER_UNIT = { px: 1, in: 96, cm: 96 / 2.54, mm: 96 / 25.4 };
|
|
11
|
+
export function lengthToPx(length) {
|
|
12
|
+
const match = /^(\d+(?:\.\d+)?)(mm|cm|in|px)$/.exec(length);
|
|
13
|
+
if (!match)
|
|
14
|
+
throw new Error(`invalid length: ${length}`);
|
|
15
|
+
return Number(match[1]) * PX_PER_UNIT[match[2]];
|
|
16
|
+
}
|
|
17
|
+
/** Width of the text column in CSS pixels, so diagrams are laid out at their printed size. */
|
|
18
|
+
export function contentWidthPx(pageSize, landscape, margin) {
|
|
19
|
+
const [width, height] = PAPER_MM[pageSize];
|
|
20
|
+
const paperPx = ((landscape ? height : width) * 96) / 25.4;
|
|
21
|
+
return Math.round(paperPx - lengthToPx(margin.left) - lengthToPx(margin.right));
|
|
22
|
+
}
|
|
23
|
+
//# sourceMappingURL=page.js.map
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { escapeHtml } from "../html.js";
|
|
2
|
+
export { escapeHtml };
|
|
3
|
+
/** Stylesheets of a built-in theme ("light" or "dark"). */
|
|
4
|
+
export declare function builtinThemeCss(theme: "light" | "dark"): string[];
|
|
5
|
+
export interface DocumentParts {
|
|
6
|
+
title: string;
|
|
7
|
+
body: string;
|
|
8
|
+
/** Include KaTeX's stylesheet and fonts (only needed when the document has math). */
|
|
9
|
+
math?: boolean;
|
|
10
|
+
lang?: string;
|
|
11
|
+
/** Front matter metadata. With `showTitle`, rendered as a title block before the body. */
|
|
12
|
+
subtitle?: string;
|
|
13
|
+
author?: string;
|
|
14
|
+
date?: string;
|
|
15
|
+
showTitle?: boolean;
|
|
16
|
+
/** Theme stylesheet contents. Defaults to the built-in light theme. */
|
|
17
|
+
themeCss?: string[];
|
|
18
|
+
/** Extra stylesheet contents, applied after the theme. */
|
|
19
|
+
extraCss?: string[];
|
|
20
|
+
}
|
|
21
|
+
/** Wrap a rendered Markdown fragment in a complete, self-contained HTML document. */
|
|
22
|
+
export declare function buildHtmlDocument(parts: DocumentParts): string;
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { readTextAsset } from "../assets.js";
|
|
2
|
+
import { escapeHtml } from "../html.js";
|
|
3
|
+
import { fontFaceCss } from "./fonts.js";
|
|
4
|
+
import { katexCss } from "./katex.js";
|
|
5
|
+
import { version } from "../version.js";
|
|
6
|
+
export { escapeHtml };
|
|
7
|
+
const builtinCss = new Map();
|
|
8
|
+
function asset(name) {
|
|
9
|
+
let css = builtinCss.get(name);
|
|
10
|
+
if (css === undefined) {
|
|
11
|
+
css = readTextAsset(`mdrender/assets/${name}`);
|
|
12
|
+
builtinCss.set(name, css);
|
|
13
|
+
}
|
|
14
|
+
return css;
|
|
15
|
+
}
|
|
16
|
+
/** Stylesheets of a built-in theme ("light" or "dark"). */
|
|
17
|
+
export function builtinThemeCss(theme) {
|
|
18
|
+
return theme === "dark" ? [asset("theme.css"), asset("theme-dark.css")] : [asset("theme.css")];
|
|
19
|
+
}
|
|
20
|
+
function titleBlock({ title, subtitle, author, date }) {
|
|
21
|
+
const meta = [author, date].filter(Boolean).map((part) => escapeHtml(part));
|
|
22
|
+
return [
|
|
23
|
+
`<header class="doc-header">`,
|
|
24
|
+
`<h1 class="doc-title">${escapeHtml(title)}</h1>`,
|
|
25
|
+
subtitle ? `<p class="doc-subtitle">${escapeHtml(subtitle)}</p>` : "",
|
|
26
|
+
meta.length ? `<p class="doc-meta">${meta.join(" · ")}</p>` : "",
|
|
27
|
+
`</header>`,
|
|
28
|
+
]
|
|
29
|
+
.filter(Boolean)
|
|
30
|
+
.join("\n");
|
|
31
|
+
}
|
|
32
|
+
/** Wrap a rendered Markdown fragment in a complete, self-contained HTML document. */
|
|
33
|
+
export function buildHtmlDocument(parts) {
|
|
34
|
+
const { title, body, math = false, lang, author, extraCss = [] } = parts;
|
|
35
|
+
const themeCss = parts.themeCss ?? builtinThemeCss("light");
|
|
36
|
+
const styles = [fontFaceCss(), ...(math ? [katexCss()] : []), ...themeCss, ...extraCss];
|
|
37
|
+
return `<!doctype html>
|
|
38
|
+
<html${lang ? ` lang="${escapeHtml(lang)}"` : ""}>
|
|
39
|
+
<head>
|
|
40
|
+
<meta charset="utf-8">
|
|
41
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
42
|
+
<meta name="generator" content="mdrender ${escapeHtml(version)}">
|
|
43
|
+
${author ? `<meta name="author" content="${escapeHtml(author)}">\n` : ""}<title>${escapeHtml(title)}</title>
|
|
44
|
+
${styles.map((css) => `<style>\n${css}\n</style>`).join("\n")}
|
|
45
|
+
</head>
|
|
46
|
+
<body>
|
|
47
|
+
<main class="markdown-body">
|
|
48
|
+
${parts.showTitle ? titleBlock(parts) + "\n" : ""}${body}
|
|
49
|
+
</main>
|
|
50
|
+
</body>
|
|
51
|
+
</html>
|
|
52
|
+
`;
|
|
53
|
+
}
|
|
54
|
+
//# sourceMappingURL=template.js.map
|
package/dist/run.d.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { BrowserPool, type RenderSettings } from "./convert.js";
|
|
2
|
+
import { ExitCode } from "./exit-codes.js";
|
|
3
|
+
import type { Job } from "./inputs.js";
|
|
4
|
+
import type { OutputFormat } from "./paths.js";
|
|
5
|
+
export interface Io {
|
|
6
|
+
out: (text: string) => void;
|
|
7
|
+
err: (text: string) => void;
|
|
8
|
+
/** Only warnings and errors: no output paths, summaries or progress. */
|
|
9
|
+
quiet?: boolean;
|
|
10
|
+
/** Also report the config file, browser and time of each conversion. */
|
|
11
|
+
verbose?: boolean;
|
|
12
|
+
}
|
|
13
|
+
/** Progress messages (stderr), silenced by --quiet. */
|
|
14
|
+
export declare const info: (io: Io, text: string) => void;
|
|
15
|
+
/** Display path: relative to the working directory when inside it. */
|
|
16
|
+
export declare function displayPath(file: string, cwd?: string): string;
|
|
17
|
+
/**
|
|
18
|
+
* Convert one file, report its diagnostics and return its exit code. Expected failures (bad
|
|
19
|
+
* options, render errors) are reported, not thrown, so a batch can go on with the next file.
|
|
20
|
+
*/
|
|
21
|
+
export declare function runJob(job: Job, format: OutputFormat, settings: RenderSettings, browsers: BrowserPool, io: Io): Promise<{
|
|
22
|
+
code: ExitCode;
|
|
23
|
+
dependencies: string[];
|
|
24
|
+
}>;
|
|
25
|
+
/** Convert every job with one shared browser. The exit code is the most severe one. */
|
|
26
|
+
export declare function runJobs(jobs: Job[], format: OutputFormat, settings: RenderSettings, io: Io, browsers?: BrowserPool): Promise<ExitCode>;
|
package/dist/run.js
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { BrowserPool, convertFile } from "./convert.js";
|
|
3
|
+
import { formatDiagnostic } from "./diagnostics.js";
|
|
4
|
+
import { MdRenderError } from "./errors.js";
|
|
5
|
+
import { ExitCode } from "./exit-codes.js";
|
|
6
|
+
/** Progress messages (stderr), silenced by --quiet. */
|
|
7
|
+
export const info = (io, text) => {
|
|
8
|
+
if (!io.quiet)
|
|
9
|
+
io.err(text);
|
|
10
|
+
};
|
|
11
|
+
/** Display path: relative to the working directory when inside it. */
|
|
12
|
+
export function displayPath(file, cwd = process.cwd()) {
|
|
13
|
+
const relative = path.relative(cwd, file);
|
|
14
|
+
return relative && !relative.startsWith("..") && !path.isAbsolute(relative) ? relative : file;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Convert one file, report its diagnostics and return its exit code. Expected failures (bad
|
|
18
|
+
* options, render errors) are reported, not thrown, so a batch can go on with the next file.
|
|
19
|
+
*/
|
|
20
|
+
export async function runJob(job, format, settings, browsers, io) {
|
|
21
|
+
const started = Date.now();
|
|
22
|
+
try {
|
|
23
|
+
const result = await convertFile({ ...settings, ...job, format, browsers });
|
|
24
|
+
for (const diagnostic of result.diagnostics) {
|
|
25
|
+
io.err(`${formatDiagnostic(diagnostic, displayPath(job.input))}\n`);
|
|
26
|
+
}
|
|
27
|
+
if (io.verbose && result.configFile)
|
|
28
|
+
io.err(`config: ${result.configFile}\n`);
|
|
29
|
+
if (!io.quiet)
|
|
30
|
+
io.out(`${result.output}\n`);
|
|
31
|
+
if (io.verbose)
|
|
32
|
+
io.err(`${displayPath(job.input)}: ${Date.now() - started} ms\n`);
|
|
33
|
+
// The file is still written (errors are shown inline) but the run counts as failed.
|
|
34
|
+
const failed = result.diagnostics.some((d) => d.severity === "error");
|
|
35
|
+
return { code: failed ? ExitCode.Render : ExitCode.Ok, dependencies: result.dependencies };
|
|
36
|
+
}
|
|
37
|
+
catch (error) {
|
|
38
|
+
if (error instanceof MdRenderError) {
|
|
39
|
+
io.err(`error: ${displayPath(job.input)}: ${error.message}\n`);
|
|
40
|
+
return { code: error.exitCode, dependencies: [] };
|
|
41
|
+
}
|
|
42
|
+
throw error;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
/** Convert every job with one shared browser. The exit code is the most severe one. */
|
|
46
|
+
export async function runJobs(jobs, format, settings, io, browsers = new BrowserPool(settings.browser, (message) => {
|
|
47
|
+
if (io.verbose)
|
|
48
|
+
io.err(`${message}\n`);
|
|
49
|
+
})) {
|
|
50
|
+
let worst = ExitCode.Ok;
|
|
51
|
+
let failed = 0;
|
|
52
|
+
try {
|
|
53
|
+
for (const job of jobs) {
|
|
54
|
+
const { code } = await runJob(job, format, settings, browsers, io);
|
|
55
|
+
if (code !== ExitCode.Ok)
|
|
56
|
+
failed++;
|
|
57
|
+
worst = Math.max(worst, code);
|
|
58
|
+
// Without a browser no other PDF can be produced either.
|
|
59
|
+
if (code === ExitCode.BrowserNotFound)
|
|
60
|
+
break;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
finally {
|
|
64
|
+
await browsers.close();
|
|
65
|
+
}
|
|
66
|
+
if (jobs.length > 1) {
|
|
67
|
+
info(io, `${jobs.length} files, ${jobs.length - failed} ok, ${failed} with errors\n`);
|
|
68
|
+
}
|
|
69
|
+
return worst;
|
|
70
|
+
}
|
|
71
|
+
//# sourceMappingURL=run.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const version: string;
|
package/dist/version.js
ADDED
package/dist/watch.d.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { type RenderSettings } from "./convert.js";
|
|
2
|
+
import { ExitCode } from "./exit-codes.js";
|
|
3
|
+
import { type OutputFormat } from "./paths.js";
|
|
4
|
+
import { type Io } from "./run.js";
|
|
5
|
+
export interface WatchOptions {
|
|
6
|
+
inputs: string[];
|
|
7
|
+
format: OutputFormat;
|
|
8
|
+
output?: string;
|
|
9
|
+
settings: RenderSettings;
|
|
10
|
+
io: Io;
|
|
11
|
+
/** Watching stops when this signal aborts (Ctrl+C in the CLI). */
|
|
12
|
+
signal: AbortSignal;
|
|
13
|
+
cwd?: string;
|
|
14
|
+
/** Wait for changes to settle before re-rendering (ms). */
|
|
15
|
+
debounceMs?: number;
|
|
16
|
+
}
|
|
17
|
+
/** Paths to watch for the command-line inputs: files, folders, or a glob's base folder. */
|
|
18
|
+
export declare function watchRoots(inputs: string[], cwd: string): string[];
|
|
19
|
+
/**
|
|
20
|
+
* Convert the inputs, then convert again whenever they change, until `signal` aborts.
|
|
21
|
+
*
|
|
22
|
+
* A changed Markdown file is re-rendered alone; any other change (config file, stylesheet,
|
|
23
|
+
* image) re-renders everything. New files under watched folders or globs are picked up.
|
|
24
|
+
*/
|
|
25
|
+
export declare function watchAndConvert(options: WatchOptions): Promise<ExitCode>;
|
package/dist/watch.js
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { watch } from "chokidar";
|
|
4
|
+
import { isDynamicPattern } from "tinyglobby";
|
|
5
|
+
import { BrowserPool } from "./convert.js";
|
|
6
|
+
import { MdRenderError } from "./errors.js";
|
|
7
|
+
import { ExitCode } from "./exit-codes.js";
|
|
8
|
+
import { expandInputs, planOutputs } from "./inputs.js";
|
|
9
|
+
import { isMarkdownFile } from "./paths.js";
|
|
10
|
+
import { displayPath, info, runJob } from "./run.js";
|
|
11
|
+
/** Paths to watch for the command-line inputs: files, folders, or a glob's base folder. */
|
|
12
|
+
export function watchRoots(inputs, cwd) {
|
|
13
|
+
return inputs.map((input) => {
|
|
14
|
+
const resolved = path.resolve(cwd, input);
|
|
15
|
+
if (existsSync(resolved))
|
|
16
|
+
return resolved;
|
|
17
|
+
const pattern = input.replace(/\\/g, "/");
|
|
18
|
+
if (!isDynamicPattern(pattern))
|
|
19
|
+
return resolved;
|
|
20
|
+
const fixed = [];
|
|
21
|
+
for (const part of pattern.split("/")) {
|
|
22
|
+
if (isDynamicPattern(part))
|
|
23
|
+
break;
|
|
24
|
+
fixed.push(part);
|
|
25
|
+
}
|
|
26
|
+
return path.resolve(cwd, fixed.join("/") || ".");
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
const time = () => new Date().toTimeString().slice(0, 8);
|
|
30
|
+
/**
|
|
31
|
+
* Convert the inputs, then convert again whenever they change, until `signal` aborts.
|
|
32
|
+
*
|
|
33
|
+
* A changed Markdown file is re-rendered alone; any other change (config file, stylesheet,
|
|
34
|
+
* image) re-renders everything. New files under watched folders or globs are picked up.
|
|
35
|
+
*/
|
|
36
|
+
export async function watchAndConvert(options) {
|
|
37
|
+
const { format, settings, io, signal, cwd = process.cwd() } = options;
|
|
38
|
+
const browsers = new BrowserPool(settings.browser, (message) => {
|
|
39
|
+
if (io.verbose)
|
|
40
|
+
io.err(`${message}\n`);
|
|
41
|
+
});
|
|
42
|
+
const dependencies = new Set();
|
|
43
|
+
let jobs = [];
|
|
44
|
+
let watcher = undefined;
|
|
45
|
+
const plan = () => planOutputs(expandInputs(options.inputs, cwd), format, options.output, cwd);
|
|
46
|
+
const convert = async (selected) => {
|
|
47
|
+
for (const job of selected) {
|
|
48
|
+
info(io, `[${time()}] ${displayPath(job.input, cwd)}\n`);
|
|
49
|
+
const result = await runJob(job, format, settings, browsers, io);
|
|
50
|
+
// Also watch the config files and stylesheets the documents use.
|
|
51
|
+
for (const dep of result.dependencies) {
|
|
52
|
+
if (!dependencies.has(dep)) {
|
|
53
|
+
dependencies.add(dep);
|
|
54
|
+
watcher?.add(dep);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
if (result.code === ExitCode.BrowserNotFound)
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
try {
|
|
62
|
+
jobs = plan();
|
|
63
|
+
}
|
|
64
|
+
catch (error) {
|
|
65
|
+
if (error instanceof MdRenderError) {
|
|
66
|
+
io.err(`error: ${error.message}\n`);
|
|
67
|
+
return error.exitCode;
|
|
68
|
+
}
|
|
69
|
+
throw error;
|
|
70
|
+
}
|
|
71
|
+
// Do not react to our own output files (e.g. HTML written next to the Markdown).
|
|
72
|
+
const outputs = () => new Set(jobs.map((j) => j.output));
|
|
73
|
+
watcher = watch(watchRoots(options.inputs, cwd), {
|
|
74
|
+
ignoreInitial: true,
|
|
75
|
+
ignored: (file) => {
|
|
76
|
+
const name = path.basename(file);
|
|
77
|
+
return name === "node_modules" || (name.startsWith(".") && name !== ".");
|
|
78
|
+
},
|
|
79
|
+
awaitWriteFinish: { stabilityThreshold: 80, pollInterval: 20 },
|
|
80
|
+
});
|
|
81
|
+
await new Promise((resolve) => watcher.once("ready", () => resolve()));
|
|
82
|
+
await convert(jobs);
|
|
83
|
+
info(io, `Watching for changes (Ctrl+C to stop)…\n`);
|
|
84
|
+
const pending = new Set();
|
|
85
|
+
let timer;
|
|
86
|
+
let running = Promise.resolve();
|
|
87
|
+
const flush = () => {
|
|
88
|
+
const changed = [...pending];
|
|
89
|
+
pending.clear();
|
|
90
|
+
running = running.then(async () => {
|
|
91
|
+
try {
|
|
92
|
+
jobs = plan();
|
|
93
|
+
}
|
|
94
|
+
catch (error) {
|
|
95
|
+
if (error instanceof MdRenderError) {
|
|
96
|
+
io.err(`error: ${error.message}\n`);
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
throw error;
|
|
100
|
+
}
|
|
101
|
+
const all = changed.some((file) => !isMarkdownFile(file) || dependencies.has(file));
|
|
102
|
+
const selected = all ? jobs : jobs.filter((job) => changed.includes(job.input));
|
|
103
|
+
await convert(selected);
|
|
104
|
+
});
|
|
105
|
+
};
|
|
106
|
+
watcher.on("all", (event, file) => {
|
|
107
|
+
const resolved = path.resolve(file);
|
|
108
|
+
if (event === "unlink" || event === "unlinkDir" || event === "addDir")
|
|
109
|
+
return;
|
|
110
|
+
if (outputs().has(resolved))
|
|
111
|
+
return;
|
|
112
|
+
pending.add(resolved);
|
|
113
|
+
clearTimeout(timer);
|
|
114
|
+
timer = setTimeout(flush, options.debounceMs ?? 150);
|
|
115
|
+
});
|
|
116
|
+
await new Promise((resolve) => {
|
|
117
|
+
if (signal.aborted)
|
|
118
|
+
resolve();
|
|
119
|
+
else
|
|
120
|
+
signal.addEventListener("abort", () => resolve(), { once: true });
|
|
121
|
+
});
|
|
122
|
+
clearTimeout(timer);
|
|
123
|
+
await running.catch(() => undefined);
|
|
124
|
+
await watcher.close();
|
|
125
|
+
await browsers.close();
|
|
126
|
+
return ExitCode.Ok;
|
|
127
|
+
}
|
|
128
|
+
//# sourceMappingURL=watch.js.map
|
package/package.json
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@imontene/mdrender",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Command-line app for Windows and Linux that renders Markdown — with LaTeX equations and Mermaid diagrams — to PDF and HTML using the Inter typeface",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"markdown",
|
|
7
|
+
"pdf",
|
|
8
|
+
"html",
|
|
9
|
+
"mermaid",
|
|
10
|
+
"katex",
|
|
11
|
+
"latex",
|
|
12
|
+
"cli",
|
|
13
|
+
"inter",
|
|
14
|
+
"converter"
|
|
15
|
+
],
|
|
16
|
+
"homepage": "https://github.com/imontene/MD_reder#readme",
|
|
17
|
+
"bugs": {
|
|
18
|
+
"url": "https://github.com/imontene/MD_reder/issues"
|
|
19
|
+
},
|
|
20
|
+
"repository": {
|
|
21
|
+
"type": "git",
|
|
22
|
+
"url": "https://github.com/imontene/MD_reder.git"
|
|
23
|
+
},
|
|
24
|
+
"license": "MIT",
|
|
25
|
+
"type": "module",
|
|
26
|
+
"bin": {
|
|
27
|
+
"mdrender": "dist/bin.js"
|
|
28
|
+
},
|
|
29
|
+
"exports": {
|
|
30
|
+
".": {
|
|
31
|
+
"types": "./dist/index.d.ts",
|
|
32
|
+
"default": "./dist/index.js"
|
|
33
|
+
},
|
|
34
|
+
"./package.json": "./package.json"
|
|
35
|
+
},
|
|
36
|
+
"types": "./dist/index.d.ts",
|
|
37
|
+
"files": [
|
|
38
|
+
"dist",
|
|
39
|
+
"!dist/**/*.map",
|
|
40
|
+
"README.md",
|
|
41
|
+
"LICENSE",
|
|
42
|
+
"CHANGELOG.md"
|
|
43
|
+
],
|
|
44
|
+
"engines": {
|
|
45
|
+
"node": ">=22.12"
|
|
46
|
+
},
|
|
47
|
+
"publishConfig": {
|
|
48
|
+
"access": "public",
|
|
49
|
+
"provenance": true
|
|
50
|
+
},
|
|
51
|
+
"scripts": {
|
|
52
|
+
"build": "node -e \"require('fs').rmSync('dist',{recursive:true,force:true})\" && tsc -p tsconfig.build.json && node scripts/copy-assets.mjs",
|
|
53
|
+
"dev": "tsx src/bin.ts",
|
|
54
|
+
"typecheck": "tsc --noEmit",
|
|
55
|
+
"lint": "eslint .",
|
|
56
|
+
"format": "prettier --write .",
|
|
57
|
+
"format:check": "prettier --check .",
|
|
58
|
+
"test": "vitest run",
|
|
59
|
+
"test:watch": "vitest",
|
|
60
|
+
"check": "npm run format:check && npm run lint && npm run typecheck && npm test",
|
|
61
|
+
"build:exe": "npm run build && node scripts/build-sea.mjs",
|
|
62
|
+
"prepublishOnly": "npm run check && npm run build",
|
|
63
|
+
"test:coverage": "vitest run --coverage"
|
|
64
|
+
},
|
|
65
|
+
"dependencies": {
|
|
66
|
+
"@fontsource/jetbrains-mono": "^5.3.0",
|
|
67
|
+
"@puppeteer/browsers": "^3.2.3",
|
|
68
|
+
"chokidar": "^5.0.0",
|
|
69
|
+
"commander": "^15.0.0",
|
|
70
|
+
"highlight.js": "^11.12.0",
|
|
71
|
+
"inter-ui": "^4.1.1",
|
|
72
|
+
"katex": "^0.19.0",
|
|
73
|
+
"markdown-it": "^15.0.2",
|
|
74
|
+
"markdown-it-anchor": "^10.0.0",
|
|
75
|
+
"markdown-it-footnote": "^4.0.0",
|
|
76
|
+
"mermaid": "^12.1.0",
|
|
77
|
+
"puppeteer-core": "^25.12.0",
|
|
78
|
+
"tinyglobby": "^0.2.17",
|
|
79
|
+
"yaml": "^2.9.1"
|
|
80
|
+
},
|
|
81
|
+
"devDependencies": {
|
|
82
|
+
"@eslint/js": "^10.0.1",
|
|
83
|
+
"@types/node": "^22.20.5",
|
|
84
|
+
"@vitest/coverage-v8": "^5.0.3",
|
|
85
|
+
"esbuild": "^0.28.2",
|
|
86
|
+
"eslint": "^10.12.0",
|
|
87
|
+
"eslint-config-prettier": "^10.1.8",
|
|
88
|
+
"globals": "^17.13.0",
|
|
89
|
+
"pdfjs-dist": "^6.4.299",
|
|
90
|
+
"postject": "^1.0.0-alpha.6",
|
|
91
|
+
"prettier": "^3.9.9",
|
|
92
|
+
"tsx": "^4.23.15",
|
|
93
|
+
"typescript": "^6.0.3",
|
|
94
|
+
"typescript-eslint": "^8.71.0",
|
|
95
|
+
"vitest": "^5.0.3"
|
|
96
|
+
}
|
|
97
|
+
}
|