@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 CHANGED
@@ -1,5 +1,38 @@
1
1
  # Cambios
2
2
 
3
+ ## 2.1.0 — 2026-10-15
4
+
5
+ - `mdrender doctor`: revisa instalación (versión, Node.js, `PATH`), navegador (cuál y por qué,
6
+ arranque y versión, sandbox), una conversión real de prueba, el `mdrender.config.json`
7
+ aplicado (errores, opciones mal escritas, valores efectivos), la caché de `setup` y el proxy.
8
+ `--quick`, `--json`; código 1 si encuentra un problema.
9
+ - Mejor uso de la página:
10
+ - Los bloques de código largos se parten entre páginas (al menos 4 líneas a cada lado) en
11
+ vez de saltar enteros y dejar hasta media página en blanco; los de hasta 12 líneas siguen
12
+ juntos.
13
+ - `--density compact` (o `density: compact`): interlineado y espacios más ajustados.
14
+ - `--font-size <tamaño>` (o `fontSize`): tamaño del texto, por defecto 10.5pt.
15
+ - Mermaid usa el tamaño de letra del documento (antes 16 px) y los diagramas de secuencia no
16
+ repiten los actores al pie.
17
+ - Imágenes y diagramas más altos que la página se reducen para caber en una sola.
18
+
19
+ ## 2.0.0 — 2026-10-13
20
+
21
+ - Márgenes por lado: `--margin-top`, `--margin-right`, `--margin-bottom`, `--margin-left`
22
+ (también `marginTop`… en front matter y `mdrender.config.json`), por encima de `--margin`.
23
+ - Orientación con `--orientation portrait|landscape` (`--landscape` sigue funcionando).
24
+ - **Cambian los valores por defecto**: hoja **Letter** (antes A4) y márgenes de **10 mm**
25
+ (antes 20 mm). Para el formato anterior: `--page-size A4 --margin 20mm`, o ponerlo en
26
+ `mdrender.config.json`.
27
+ - El proceso de publicación falla al inicio, con un error claro, si falta `NPM_TOKEN`.
28
+ - Nunca sobrescribe el `.md` de entrada: `mdrender doc.md -o doc.md` termina con un error.
29
+ - Avisa de opciones desconocidas en `mdrender.config.json` y sugiere la correcta
30
+ (`"margn"` → `"margin"`).
31
+ - Escritura segura: el PDF/HTML se escribe en un archivo temporal y se renombra al final; un
32
+ fallo no deja archivos a medias y conserva la versión anterior. Mensaje claro si el archivo
33
+ está abierto en otro programa.
34
+ - Avisa si un encabezado o pie no cabe (margen superior o inferior menor de 10 mm).
35
+
3
36
  ## 1.0.1 — 2026-10-12
4
37
 
5
38
  - Corrige un error EPIPE sin capturar cuando la salida se corta con una tubería
package/README.md CHANGED
@@ -73,7 +73,7 @@ npm link # deja disponible el comando `mdrender`
73
73
  mdrender documento.md
74
74
 
75
75
  # Elegir salida, tamaño de página y tabla de contenidos
76
- mdrender documento.md -o salida/informe.pdf --page-size Letter --toc
76
+ mdrender documento.md -o salida/informe.pdf --page-size A4 --toc
77
77
 
78
78
  # HTML autocontenido
79
79
  mdrender documento.md --format html
@@ -89,14 +89,39 @@ mdrender docs/ -o pdf/ --watch
89
89
  # Vista previa en el navegador que se recarga sola al guardar
90
90
  mdrender preview documento.md
91
91
 
92
+ # Más contenido por página
93
+ mdrender documento.md --density compact --font-size 10pt
94
+
92
95
  # Tema oscuro, o un CSS propio que reemplaza el tema
93
96
  mdrender documento.md --theme dark
94
97
  mdrender documento.md --theme mi-tema.css
95
98
 
96
99
  # Descargar un navegador si no tienes Chrome, Edge ni Chromium
97
100
  mdrender setup
101
+
102
+ # Diagnóstico: instalación, navegador, configuración y una conversión de prueba
103
+ mdrender doctor
98
104
  ```
99
105
 
106
+ ### Si algo no funciona: `mdrender doctor`
107
+
108
+ Revisa la versión y el Node.js, si `mdrender` está en el `PATH`, qué navegador se usa (y por
109
+ qué), lo arranca y convierte un documento de prueba con una ecuación y un diagrama, e indica
110
+ qué `mdrender.config.json` se aplica, con sus opciones mal escritas y los valores efectivos.
111
+ Cada problema viene con su solución sugerida; termina con código 1 si encuentra alguno.
112
+
113
+ ```text
114
+ [ok] mdrender 2.0.0, npm package (/usr/lib/node_modules/@imontene/mdrender)
115
+ [ok] Node.js 22.23.3
116
+ [warn] PATH the mdrender command is not in any PATH folder
117
+ -> add its folder to PATH (for npm: the output of `npm prefix -g`…)
118
+ [ok] Browser C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe
119
+ [ok] Test conversion PDF with an equation and a Mermaid diagram in 1840 ms
120
+ ```
121
+
122
+ `--quick` omite el arranque del navegador y la conversión; `--json` produce el informe en JSON
123
+ para compartirlo.
124
+
100
125
  En Windows (PowerShell o CMD) los comandos son idénticos:
101
126
 
102
127
  ```powershell
@@ -109,30 +134,54 @@ equipo); `--port` fija el puerto y `--no-open` no abre el navegador.
109
134
 
110
135
  ### Opciones
111
136
 
112
- | Opción | Descripción | Por defecto |
113
- | ---------------------------- | ----------------------------------------------------------- | ------------------ |
114
- | `-o, --output <ruta>` | Archivo de salida, o carpeta con varios archivos | junto al `.md` |
115
- | `-f, --format <pdf\|html>` | Formato de salida | `pdf` |
116
- | `--page-size <tamaño>` | `A3`, `A4`, `A5`, `Letter`, `Legal`, `Tabloid` | `A4` |
117
- | `--margin <valor>` | Márgenes como en CSS: `20mm`, `15mm 20mm`, `1in 0.75in`… | `20mm` |
118
- | `--landscape` | Orientación horizontal | — |
119
- | `--toc` | Tabla de contenidos al inicio (o donde pongas `[[toc]]`) | — |
120
- | `--toc-depth <n>` | Nivel de título más profundo en la tabla de contenidos | `3` |
121
- | `--header <texto>` | Encabezado de página (ver abajo) | — |
122
- | `--footer <texto>` | Pie de página (ver abajo) | `{page} / {pages}` |
123
- | `--no-page-numbers` | Sin el pie con números de página | — |
124
- | `--theme <light\|dark\|css>` | `light`, `dark` o un `.css` que reemplaza el tema | `light` |
125
- | `--css <archivo.css>` | CSS adicional (se puede repetir) | — |
126
- | `--mermaid-theme <tema>` | `default`, `neutral`, `dark`, `forest`, `base` | `neutral` / `dark` |
127
- | `--lang <código>` | Idioma del documento; `en` para inglés (`pt`, `fr`, `de`…) | `es` |
128
- | `--title <texto>` | Título del documento (muestra un bloque de título) | primer `# título` |
129
- | `--config <archivo>` | Archivo de configuración | el más cercano |
130
- | `--no-config` | Ignorar `mdrender.config.json` | — |
131
- | `--browser <ruta>` | Ruta a Chrome/Edge/Chromium (o variable `MDRENDER_BROWSER`) | autodetección |
132
- | `--allow-remote` | Permitir que el PDF descargue imágenes/CSS de internet | bloqueado |
133
- | `-w, --watch` | Regenerar al guardar | — |
134
- | `-q, --quiet` | Mostrar solo advertencias y errores | — |
135
- | `-v, --verbose` | Mostrar también configuración, navegador y tiempos | — |
137
+ | Opción | Descripción | Por defecto |
138
+ | ----------------------------- | ------------------------------------------------------------ | ------------------ |
139
+ | `-o, --output <ruta>` | Archivo de salida, o carpeta con varios archivos | junto al `.md` |
140
+ | `-f, --format <pdf\|html>` | Formato de salida | `pdf` |
141
+ | `--page-size <tamaño>` | `Letter`, `A4`, `Legal` (también `A3`, `A5`, `Tabloid`) | `Letter` |
142
+ | `--margin <valor>` | Las cuatro márgenes como en CSS: `10mm`, `15mm 20mm`… | `10mm` |
143
+ | `--margin-top <medida>` | Margen superior, p. ej. `15mm` (prevalece sobre `--margin`) | `10mm` |
144
+ | `--margin-right <medida>` | Margen derecho | `10mm` |
145
+ | `--margin-bottom <medida>` | Margen inferior | `10mm` |
146
+ | `--margin-left <medida>` | Margen izquierdo | `10mm` |
147
+ | `--orientation <orientación>` | `portrait` (vertical) o `landscape` (horizontal) | `portrait` |
148
+ | `--landscape` | Atajo de `--orientation landscape` | — |
149
+ | `--toc` | Tabla de contenidos al inicio (o donde pongas `[[toc]]`) | — |
150
+ | `--toc-depth <n>` | Nivel de título más profundo en la tabla de contenidos | `3` |
151
+ | `--header <texto>` | Encabezado de página (ver abajo) | — |
152
+ | `--footer <texto>` | Pie de página (ver abajo) | `{page} / {pages}` |
153
+ | `--no-page-numbers` | Sin el pie con números de página | — |
154
+ | `--density <densidad>` | `normal` o `compact` (menos espacio entre líneas y bloques) | `normal` |
155
+ | `--font-size <tamaño>` | Tamaño del texto en `pt` o `px`, de 6 a 24 pt; p. ej. `10pt` | `10.5pt` |
156
+ | `--theme <light\|dark\|css>` | `light`, `dark` o un `.css` que reemplaza el tema | `light` |
157
+ | `--css <archivo.css>` | CSS adicional (se puede repetir) | — |
158
+ | `--mermaid-theme <tema>` | `default`, `neutral`, `dark`, `forest`, `base` | `neutral` / `dark` |
159
+ | `--lang <código>` | Idioma del documento; `en` para inglés (`pt`, `fr`, `de`…) | `es` |
160
+ | `--title <texto>` | Título del documento (muestra un bloque de título) | primer `# título` |
161
+ | `--config <archivo>` | Archivo de configuración | el más cercano |
162
+ | `--no-config` | Ignorar `mdrender.config.json` | — |
163
+ | `--browser <ruta>` | Ruta a Chrome/Edge/Chromium (o variable `MDRENDER_BROWSER`) | autodetección |
164
+ | `--allow-remote` | Permitir que el PDF descargue imágenes/CSS de internet | bloqueado |
165
+ | `-w, --watch` | Regenerar al guardar | — |
166
+ | `-q, --quiet` | Mostrar solo advertencias y errores | — |
167
+ | `-v, --verbose` | Mostrar también configuración, navegador y tiempos | — |
168
+
169
+ **Página**: por defecto hoja **Letter vertical con márgenes de 10 mm**. Las medidas aceptan
170
+ `mm`, `cm`, `in` o `px`. `--margin` fija las cuatro márgenes a la vez y `--margin-top`,
171
+ `--margin-right`, `--margin-bottom` y `--margin-left` ajustan una sola, por encima de
172
+ `--margin`. Ejemplo: `--page-size A4 --orientation landscape --margin 15mm --margin-left 25mm`.
173
+ El encabezado y el pie se dibujan dentro de las márgenes superior e inferior: con menos de
174
+ 10 mm pueden tocar el texto y mdrender lo advierte.
175
+
176
+ **Aprovechar la página**: `--density compact` reduce interlineado, espacios entre párrafos y
177
+ títulos, y `--font-size 10pt` achica el texto (los diagramas Mermaid siguen el mismo tamaño).
178
+ Juntos ahorran entre un 15 y un 25 % de páginas. Además, siempre:
179
+
180
+ - Los bloques de código largos continúan en la página siguiente (con al menos 4 líneas a cada
181
+ lado del corte) en lugar de saltar enteros y dejar un hueco; los de hasta 12 líneas no se
182
+ parten.
183
+ - Las imágenes y diagramas más altos que la página se reducen para caber en una sola.
184
+ - Los diagramas de secuencia no repiten los actores al pie.
136
185
 
137
186
  **Encabezado y pie**: texto con marcadores `{page}`, `{pages}`, `{title}`, `{author}` y
138
187
  `{date}`; `|` separa columnas izquierda, centro y derecha. Ejemplos:
@@ -151,7 +200,9 @@ subtitle: Resultados del trimestre
151
200
  author: Ana Pérez
152
201
  date: 2026-10-04
153
202
  toc: true
154
- page-size: Letter
203
+ page-size: A4
204
+ orientation: landscape
205
+ margin-left: 25mm
155
206
  header: "{title} | | {date}"
156
207
  ---
157
208
  ```
@@ -159,8 +210,11 @@ header: "{title} | | {date}"
159
210
  ```json
160
211
  {
161
212
  "lang": "es",
162
- "pageSize": "A4",
163
- "margin": "25mm 20mm",
213
+ "pageSize": "Letter",
214
+ "margin": "15mm",
215
+ "marginLeft": "25mm",
216
+ "density": "compact",
217
+ "fontSize": "10pt",
164
218
  "css": "estilos/empresa.css"
165
219
  }
166
220
  ```
@@ -199,6 +253,10 @@ fecha). Las rutas de `css` y `theme` son relativas al archivo donde aparecen.
199
253
  como imágenes referenciadas desde el `.md`.
200
254
  - Mermaid corre en una página aparte, sin contenido del usuario, con `securityLevel: "strict"`.
201
255
  - La vista previa solo escucha en `127.0.0.1`.
256
+ - Nunca sobrescribe un `.md` de entrada, y escribe cada PDF/HTML en un archivo temporal que
257
+ renombra al final: si algo falla, el archivo anterior queda intacto.
258
+ - Las opciones mal escritas en `mdrender.config.json` generan una advertencia con la opción
259
+ correcta sugerida.
202
260
  - En Ubuntu 23.10+ el navegador descargado con `mdrender setup` no puede usar el sandbox de
203
261
  Chrome (restricción de AppArmor); mdrender lo inicia entonces sin sandbox, lo que es seguro
204
262
  aquí porque las páginas no ejecutan JavaScript del documento ni acceden a la red
@@ -231,7 +289,8 @@ El CI (GitHub Actions) prueba en **Windows y Linux** con Node 22 y 24, construye
231
289
  ejecutable y valida `mdrender setup`. Para publicar una versión, sube una etiqueta `v*` o
232
290
  lanza a mano **Actions → Release → Run workflow** con la versión (debe coincidir con
233
291
  `package.json`): construye los ejecutables, crea la etiqueta y el GitHub Release con sus sumas
234
- SHA-256, y publica en npm si el secreto `NPM_TOKEN` está configurado.
292
+ SHA-256, y publica en npm. Requiere el secreto de repositorio `NPM_TOKEN`; si falta, la
293
+ publicación se detiene al inicio, antes de publicar nada.
235
294
 
236
295
  Plan completo y decisiones de diseño: [docs/PLAN_MAESTRO.md](docs/PLAN_MAESTRO.md) ·
237
296
  cambios: [CHANGELOG.md](CHANGELOG.md).
@@ -0,0 +1,74 @@
1
+ /* mdrender compact density (--density compact): loaded after the theme, tightens vertical spacing. */
2
+
3
+ body {
4
+ line-height: 1.5;
5
+ }
6
+
7
+ p,
8
+ ul,
9
+ ol,
10
+ blockquote,
11
+ pre,
12
+ table,
13
+ dl {
14
+ margin-bottom: 0.8em;
15
+ }
16
+
17
+ h1,
18
+ h2,
19
+ h3,
20
+ h4,
21
+ h5,
22
+ h6 {
23
+ margin: 1.3em 0 0.5em;
24
+ }
25
+
26
+ h1 {
27
+ font-size: 1.9em;
28
+ }
29
+
30
+ h2 {
31
+ font-size: 1.4em;
32
+ }
33
+
34
+ h3 {
35
+ font-size: 1.18em;
36
+ }
37
+
38
+ li + li {
39
+ margin-top: 0.15em;
40
+ }
41
+
42
+ pre {
43
+ padding: 0.7em 0.9em;
44
+ }
45
+
46
+ th,
47
+ td {
48
+ padding: 0.35em 0.7em;
49
+ }
50
+
51
+ .math-display {
52
+ margin: 0.8em 0;
53
+ }
54
+
55
+ .mermaid-diagram {
56
+ margin: 0.9em 0;
57
+ }
58
+
59
+ .doc-header {
60
+ margin-bottom: 1.5em;
61
+ }
62
+
63
+ .toc {
64
+ margin-bottom: 1.5em;
65
+ padding: 0.8em 1.2em;
66
+ }
67
+
68
+ hr {
69
+ margin: 1.5em 0;
70
+ }
71
+
72
+ hr.footnotes-sep {
73
+ margin: 1.8em 0 0.8em;
74
+ }
@@ -243,6 +243,14 @@ pre {
243
243
  background: var(--color-subtle-bg);
244
244
  white-space: pre-wrap;
245
245
  overflow-wrap: anywhere;
246
+ /* Long listings may continue on the next page, keeping a few lines on each side. */
247
+ break-inside: auto;
248
+ orphans: 4;
249
+ widows: 4;
250
+ }
251
+
252
+ /* Short blocks (marked by mdrender) stay on one page. */
253
+ pre.keep-together {
246
254
  break-inside: avoid;
247
255
  }
248
256
 
@@ -335,6 +343,17 @@ div.math-error {
335
343
  height: auto;
336
344
  }
337
345
 
346
+ /*
347
+ * In the PDF, images and diagrams are scaled down to fit the page height (mdrender sets
348
+ * --page-content-height), so they are never cut across pages.
349
+ */
350
+ @media print {
351
+ img,
352
+ .mermaid-diagram svg {
353
+ max-height: calc(var(--page-content-height) - 6mm);
354
+ }
355
+ }
356
+
338
357
  /* Mermaid's HTML labels are <p>/<span> inside <foreignObject>: keep document styles out. */
339
358
  .mermaid-diagram foreignObject p {
340
359
  margin: 0;
package/dist/assets.js CHANGED
@@ -85,7 +85,11 @@ export const MONO_STYLESHEETS = [
85
85
  ];
86
86
  export const KATEX_CSS = "katex/dist/katex.min.css";
87
87
  export const MERMAID_JS = "mermaid/dist/mermaid.min.js";
88
- export const THEMES = ["mdrender/assets/theme.css", "mdrender/assets/theme-dark.css"];
88
+ export const THEMES = [
89
+ "mdrender/assets/theme.css",
90
+ "mdrender/assets/theme-dark.css",
91
+ "mdrender/assets/density-compact.css",
92
+ ];
89
93
  export const PACKAGE_JSON = "mdrender/package.json";
90
94
  /** Fonts referenced by a stylesheet as url(...woff2), as asset keys. */
91
95
  export function woff2References(cssKey, css) {
package/dist/cli.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { Command, CommanderError, Option } from "commander";
2
2
  import { setup } from "./browser/setup.js";
3
+ import { formatReport, runDoctor } from "./doctor.js";
3
4
  import { MdRenderError } from "./errors.js";
4
5
  import { ExitCode } from "./exit-codes.js";
5
6
  import { expandInputs, planOutputs } from "./inputs.js";
@@ -12,12 +13,20 @@ import { watchAndConvert } from "./watch.js";
12
13
  const DOCUMENT_FLAGS = [
13
14
  "pageSize",
14
15
  "margin",
16
+ "marginTop",
17
+ "marginRight",
18
+ "marginBottom",
19
+ "marginLeft",
20
+ // --landscape before --orientation so an explicit --orientation wins.
15
21
  "landscape",
22
+ "orientation",
16
23
  "toc",
17
24
  "tocDepth",
18
25
  "header",
19
26
  "footer",
20
27
  "pageNumbers",
28
+ "density",
29
+ "fontSize",
21
30
  "css",
22
31
  "theme",
23
32
  "mermaidTheme",
@@ -33,14 +42,21 @@ const collect = (value, previous = []) => [...previous, value];
33
42
  /** Options shared by `mdrender` (render) and `mdrender preview`. */
34
43
  function addDocumentOptions(command) {
35
44
  return command
36
- .option("--page-size <size>", `paper size: ${PAGE_SIZES.join(", ")} (default: A4)`)
37
- .option("--margin <margin>", 'page margins, e.g. "20mm" or "15mm 20mm" (default: 20mm)')
38
- .option("--landscape", "landscape orientation")
45
+ .option("--page-size <size>", `paper size: ${PAGE_SIZES.join(", ")} (default: Letter)`)
46
+ .option("--orientation <orientation>", "portrait or landscape (default: portrait)")
47
+ .option("--landscape", "same as --orientation landscape")
48
+ .option("--margin <margin>", 'all page margins, CSS-style: "10mm", "15mm 20mm"… (default: 10mm)')
49
+ .option("--margin-top <length>", "top margin, e.g. 15mm (overrides --margin)")
50
+ .option("--margin-right <length>", "right margin")
51
+ .option("--margin-bottom <length>", "bottom margin")
52
+ .option("--margin-left <length>", "left margin")
39
53
  .option("--toc", "insert a table of contents (or place it with [[toc]])")
40
54
  .option("--toc-depth <n>", "deepest heading level in the table of contents (default: 3)")
41
55
  .option("--header <text>", 'page header, e.g. "{title} | | {date}"')
42
56
  .option("--footer <text>", 'page footer (default: "{page} / {pages}")')
43
57
  .option("--no-page-numbers", "no default page-number footer")
58
+ .option("--density <density>", "normal or compact (tighter spacing) (default: normal)")
59
+ .option("--font-size <size>", "text size, e.g. 10pt or 11pt (default: 10.5pt)")
44
60
  .option("--theme <theme>", "light, dark, or a .css file replacing the theme (default: light)")
45
61
  .option("--css <file>", "extra stylesheet (repeatable)", collect)
46
62
  .option("--mermaid-theme <theme>", `Mermaid theme: ${MERMAID_THEMES.join(", ")} (default: neutral; dark with --theme dark)`)
@@ -69,6 +85,7 @@ function buildRenderProgram(io) {
69
85
  Commands:
70
86
  mdrender preview <file> live HTML preview in the browser (see: mdrender preview --help)
71
87
  mdrender setup download a headless Chrome if no browser is installed
88
+ mdrender doctor check the installation, browser and configuration
72
89
 
73
90
  Header/footer placeholders: {page} {pages} {title} {author} {date}; "|" separates
74
91
  left | center | right columns.
@@ -132,6 +149,32 @@ async function runSetup(argv, io) {
132
149
  return parsed;
133
150
  return setup({ io, force: Boolean(program.opts().force) });
134
151
  }
152
+ function buildDoctorProgram(io) {
153
+ const program = new Command()
154
+ .name("mdrender doctor")
155
+ .description("Check the installation, browser and configuration, and try a real conversion.\n" +
156
+ "Exits with 1 when a problem is found.")
157
+ .option("--browser <path>", "check this browser instead of the detected one")
158
+ .option("--config <file>", "check this config file (default: nearest mdrender.config.json)")
159
+ .option("--no-config", "ignore mdrender.config.json files")
160
+ .option("--quick", "skip starting the browser and the test conversion")
161
+ .option("--json", "print the report as JSON");
162
+ return configure(program, io);
163
+ }
164
+ async function runDoctorCommand(argv, io) {
165
+ const program = buildDoctorProgram(io);
166
+ const parsed = await parse(program, argv);
167
+ if (parsed !== undefined)
168
+ return parsed;
169
+ const options = program.opts();
170
+ const report = await runDoctor({
171
+ browser: options.browser,
172
+ config: options.config,
173
+ render: !options.quick,
174
+ });
175
+ io.out(options.json ? `${JSON.stringify(report, null, 2)}\n` : formatReport(report));
176
+ return report.ok ? ExitCode.Ok : ExitCode.Usage;
177
+ }
135
178
  async function runPreview(argv, io, signal) {
136
179
  const program = buildPreviewProgram(io);
137
180
  const parsed = await parse(program, argv);
@@ -181,6 +224,8 @@ export async function main(argv, io = defaultIo, { signal = new AbortController(
181
224
  return await runPreview(argv.slice(1), io, signal);
182
225
  if (argv[0] === "setup")
183
226
  return await runSetup(argv.slice(1), io);
227
+ if (argv[0] === "doctor")
228
+ return await runDoctorCommand(argv.slice(1), io);
184
229
  return await runRender(argv, io, signal);
185
230
  }
186
231
  catch (error) {
package/dist/config.d.ts CHANGED
@@ -2,5 +2,12 @@ import { type DocumentOptions } from "./options.js";
2
2
  export declare const CONFIG_FILE = "mdrender.config.json";
3
3
  /** Find `mdrender.config.json` in `startDir` or the nearest parent directory. */
4
4
  export declare function findConfigFile(startDir: string): string | undefined;
5
+ export interface LoadedConfig {
6
+ options: Partial<DocumentOptions>;
7
+ /** Problems that do not stop the conversion, such as misspelled option names. */
8
+ warnings: string[];
9
+ }
5
10
  /** Read and validate a config file. Relative paths inside it resolve against its folder. */
6
11
  export declare function loadConfig(file: string): Partial<DocumentOptions>;
12
+ /** Like `loadConfig`, also reporting unknown option names (likely typos). */
13
+ export declare function readConfig(file: string): LoadedConfig;
package/dist/config.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { existsSync, readFileSync } from "node:fs";
2
2
  import path from "node:path";
3
- import { normalizeOptions, OptionsError } from "./options.js";
3
+ import { normalizeOptions, OptionsError, unknownOptions } from "./options.js";
4
4
  export const CONFIG_FILE = "mdrender.config.json";
5
5
  /** Find `mdrender.config.json` in `startDir` or the nearest parent directory. */
6
6
  export function findConfigFile(startDir) {
@@ -17,6 +17,10 @@ export function findConfigFile(startDir) {
17
17
  }
18
18
  /** Read and validate a config file. Relative paths inside it resolve against its folder. */
19
19
  export function loadConfig(file) {
20
+ return readConfig(file).options;
21
+ }
22
+ /** Like `loadConfig`, also reporting unknown option names (likely typos). */
23
+ export function readConfig(file) {
20
24
  let raw;
21
25
  try {
22
26
  raw = JSON.parse(readFileSync(file, "utf8"));
@@ -27,6 +31,11 @@ export function loadConfig(file) {
27
31
  if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
28
32
  throw new OptionsError(`${file}: the configuration must be a JSON object`);
29
33
  }
30
- return normalizeOptions(raw, file, path.dirname(file));
34
+ const record = raw;
35
+ const relative = path.relative(process.cwd(), file);
36
+ const shown = relative && !relative.startsWith("..") && !path.isAbsolute(relative) ? relative : file;
37
+ const warnings = unknownOptions(record).map(({ key, suggestion }) => `${shown}: unknown option "${key}"` +
38
+ (suggestion ? ` (did you mean "${suggestion}"?)` : " (ignored)"));
39
+ return { options: normalizeOptions(record, file, path.dirname(file)), warnings };
31
40
  }
32
41
  //# sourceMappingURL=config.js.map
package/dist/convert.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { Diagnostic } from "./diagnostics.js";
2
- import { type DocumentOptions, type MermaidTheme } from "./options.js";
2
+ import { type DocumentOptions, type Margins, type MermaidTheme } from "./options.js";
3
3
  import type { OutputFormat } from "./paths.js";
4
4
  import { BrowserSession } from "./render/browser.js";
5
5
  /** Settings shared by every file of a run (batch, watch or preview). */
@@ -53,6 +53,12 @@ export declare class BrowserPool {
53
53
  get(): Promise<BrowserSession>;
54
54
  close(): Promise<void>;
55
55
  }
56
+ /**
57
+ * Chrome draws the header and footer inside the top and bottom margins. Measured: below about
58
+ * 10 mm the header touches or overlaps the first line of text (and the footer the last line).
59
+ */
60
+ export declare const MIN_HEADER_FOOTER_MARGIN = "10mm";
61
+ export declare function headerFooterSpaceWarnings(margin: Margins, hasHeader: boolean, hasFooter: boolean): Diagnostic[];
56
62
  /** Mermaid theme: explicit choice, else dark diagrams for the dark page theme. */
57
63
  export declare function effectiveMermaidTheme(options: DocumentOptions): MermaidTheme;
58
64
  /**
package/dist/convert.js CHANGED
@@ -1,18 +1,19 @@
1
1
  import { readFileSync } from "node:fs";
2
- import { mkdir, readFile, writeFile } from "node:fs/promises";
2
+ import { readFile } from "node:fs/promises";
3
3
  import path from "node:path";
4
4
  import { findBrowser } from "./browser/detect.js";
5
- import { findConfigFile, loadConfig } from "./config.js";
5
+ import { findConfigFile, readConfig } from "./config.js";
6
6
  import { extractFrontMatter } from "./frontmatter.js";
7
7
  import { strings } from "./i18n.js";
8
8
  import { renderMarkdown } from "./markdown/index.js";
9
9
  import { buildToc, tocPlaceholder } from "./markdown/toc.js";
10
- import { mergeOptions, normalizeOptions, OptionsError, parseMargin, } from "./options.js";
10
+ import { DEFAULT_FONT_SIZE, fontSizePx, mergeOptions, normalizeOptions, OptionsError, resolveMargins, } from "./options.js";
11
11
  import { BrowserSession } from "./render/browser.js";
12
12
  import { DEFAULT_FOOTER, headerFooterTemplate } from "./render/header-footer.js";
13
13
  import { fillMermaid, renderMermaid } from "./render/mermaid.js";
14
- import { contentWidthPx } from "./render/page.js";
15
- import { buildHtmlDocument, builtinThemeCss } from "./render/template.js";
14
+ import { contentHeightPx, contentWidthPx, lengthToPx } from "./render/page.js";
15
+ import { buildHtmlDocument, builtinThemeCss, densityCss } from "./render/template.js";
16
+ import { writeFileAtomic } from "./write.js";
16
17
  /** Starts one browser on first use and keeps it for later conversions. */
17
18
  export class BrowserPool {
18
19
  executable;
@@ -51,6 +52,40 @@ function readStylesheet(file) {
51
52
  function themeStylesheets(theme) {
52
53
  return theme === "light" || theme === "dark" ? builtinThemeCss(theme) : [readStylesheet(theme)];
53
54
  }
55
+ /**
56
+ * Chrome draws the header and footer inside the top and bottom margins. Measured: below about
57
+ * 10 mm the header touches or overlaps the first line of text (and the footer the last line).
58
+ */
59
+ export const MIN_HEADER_FOOTER_MARGIN = "10mm";
60
+ export function headerFooterSpaceWarnings(margin, hasHeader, hasFooter) {
61
+ const minimum = lengthToPx(MIN_HEADER_FOOTER_MARGIN);
62
+ const warnings = [];
63
+ // Half a pixel of slack: "0.39in" and "10mm" are the same margin once Chrome rounds them.
64
+ if (hasHeader && lengthToPx(margin.top) < minimum - 0.5) {
65
+ warnings.push({
66
+ severity: "warning",
67
+ message: `the header may overlap the text: top margin is ${margin.top}, ` +
68
+ `at least ${MIN_HEADER_FOOTER_MARGIN} is needed (e.g. --margin-top 15mm)`,
69
+ });
70
+ }
71
+ if (hasFooter && lengthToPx(margin.bottom) < minimum - 0.5) {
72
+ warnings.push({
73
+ severity: "warning",
74
+ message: `the footer may overlap the text: bottom margin is ${margin.bottom}, ` +
75
+ `at least ${MIN_HEADER_FOOTER_MARGIN} is needed ` +
76
+ "(e.g. --margin-bottom 15mm, or --no-page-numbers for no footer)",
77
+ });
78
+ }
79
+ return warnings;
80
+ }
81
+ /** Font size, if set, and the page's text height (used to fit images and diagrams). */
82
+ function layoutCss(options, margin) {
83
+ const height = contentHeightPx(options.pageSize, options.orientation === "landscape", margin);
84
+ const rules = [`:root { --page-content-height: ${height}px; }`];
85
+ if (options.fontSize)
86
+ rules.push(`html { font-size: ${options.fontSize}; }`);
87
+ return rules.join("\n");
88
+ }
54
89
  /** Mermaid theme: explicit choice, else dark diagrams for the dark page theme. */
55
90
  export function effectiveMermaidTheme(options) {
56
91
  return options.mermaidTheme ?? (options.theme === "dark" ? "dark" : "neutral");
@@ -69,12 +104,16 @@ export async function renderDocument(inputPath, settings, browsers) {
69
104
  : settings.config
70
105
  ? path.resolve(settings.config)
71
106
  : findConfigFile(baseDir);
72
- const options = mergeOptions(configFile ? loadConfig(configFile) : {}, normalizeOptions(frontMatter.data, `${input} (front matter)`, baseDir), settings.overrides ?? {});
73
- const margin = parseMargin(options.margin);
107
+ const config = configFile ? readConfig(configFile) : { options: {}, warnings: [] };
108
+ const options = mergeOptions(config.options, normalizeOptions(frontMatter.data, `${input} (front matter)`, baseDir), settings.overrides ?? {});
109
+ const margin = resolveMargins(options);
74
110
  const themeCss = themeStylesheets(options.theme);
75
111
  const extraCss = options.css.map(readStylesheet);
76
112
  const md = renderMarkdown(frontMatter.body, { baseDir, lang: options.lang });
77
- const diagnostics = [...md.diagnostics];
113
+ const diagnostics = [
114
+ ...config.warnings.map((message) => ({ severity: "warning", message })),
115
+ ...md.diagnostics,
116
+ ];
78
117
  const title = options.title ?? md.title ?? path.parse(input).name;
79
118
  // Table of contents: at the [[toc]] marker, or at the start with --toc.
80
119
  let body = md.html;
@@ -88,9 +127,10 @@ export async function renderDocument(inputPath, settings, browsers) {
88
127
  // Mermaid needs a real DOM to lay out diagrams, also for HTML output.
89
128
  if (md.mermaid.length > 0) {
90
129
  const session = await browsers.get();
91
- const width = contentWidthPx(options.pageSize, options.landscape, margin);
130
+ const width = contentWidthPx(options.pageSize, options.orientation === "landscape", margin);
92
131
  const theme = effectiveMermaidTheme(options);
93
- const results = await renderMermaid(session, md.mermaid, theme, width);
132
+ const fontPx = fontSizePx(options.fontSize ?? DEFAULT_FONT_SIZE);
133
+ const results = await renderMermaid(session, md.mermaid, theme, width, fontPx);
94
134
  const filled = fillMermaid(body, md.nonce, md.mermaid, results);
95
135
  body = filled.html;
96
136
  diagnostics.push(...filled.diagnostics);
@@ -105,6 +145,7 @@ export async function renderDocument(inputPath, settings, browsers) {
105
145
  date: options.date,
106
146
  showTitle: options.title !== undefined,
107
147
  themeCss,
148
+ layoutCss: [...densityCss(options.density), layoutCss(options, margin)],
108
149
  extraCss,
109
150
  });
110
151
  diagnostics.sort((a, b) => (a.line ?? 0) - (b.line ?? 0));
@@ -122,17 +163,16 @@ export async function convertFile(convert) {
122
163
  try {
123
164
  const doc = await renderDocument(convert.input, convert, browsers);
124
165
  const { options } = doc;
125
- await mkdir(path.dirname(output), { recursive: true });
126
166
  if (convert.format === "pdf") {
127
- const margin = parseMargin(options.margin);
167
+ const margin = resolveMargins(options);
128
168
  const values = { title: doc.title, author: options.author, date: options.date };
129
169
  const footer = options.footer ?? (options.pageNumbers ? DEFAULT_FOOTER : undefined);
130
170
  const hasHeaderFooter = options.header !== undefined || footer !== undefined;
171
+ doc.diagnostics.push(...headerFooterSpaceWarnings(margin, options.header !== undefined, footer !== undefined));
131
172
  const session = await browsers.get();
132
- const { blocked } = await session.printPdf(doc.html, {
133
- output,
173
+ const { pdf, blocked } = await session.printPdf(doc.html, {
134
174
  format: options.pageSize,
135
- landscape: options.landscape,
175
+ landscape: options.orientation === "landscape",
136
176
  margin,
137
177
  headerTemplate: hasHeaderFooter
138
178
  ? headerFooterTemplate(options.header, values, margin)
@@ -147,9 +187,10 @@ export async function convertFile(convert) {
147
187
  message: `resource not loaded: ${url}${hint}`,
148
188
  });
149
189
  }
190
+ await writeFileAtomic(output, pdf);
150
191
  }
151
192
  else {
152
- await writeFile(output, doc.html, "utf8");
193
+ await writeFileAtomic(output, doc.html);
153
194
  }
154
195
  return {
155
196
  output,