@cenk1cenk2/md-printer 3.3.0 → 3.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -92,7 +92,7 @@ Markdown files take YAML front-matter. For YAML input the same keys are top-leve
92
92
 
93
93
  | Key | Description |
94
94
  | ----------------- | --------------------------------------------------------------------------------------------- |
95
- | `template` | Built-in template name or path to a template directory. |
95
+ | `template` | Built-in template name, path to a template directory or an object, see [Template sources](#template-sources). |
96
96
  | `dest` | Output file, relative to the current directory, not to the input file. |
97
97
  | `outputFiletype` | `pdf` (default) or `html`. |
98
98
  | `language` | Language for template translations. |
@@ -177,7 +177,7 @@ md-printer document.md out.pdf --preview --no-open
177
177
 
178
178
  ### Built-in templates
179
179
 
180
- Every built-in template is a package of its own, `@cenk1cenk2/md-printer-template-<name>`, published from the `templates/` directory of this repository. md-printer depends on all of them, so they install with it, and each one brings the fonts, icons and stylesheets it uses as its own dependencies.
180
+ Every built-in template is a package of its own, `@cenk1cenk2/md-printer-template-<name>`. md-printer depends on all of them, so they install with it, and each one brings the fonts, icons and stylesheets it uses as its own dependencies. `default`, `kilic.dev` and `mail` are published from the `templates/` directory of this repository, `cv` from https://gitlab.kilic.dev/cenk/cv and `invoice` from https://gitlab.kilic.dev/cenk/invoices.
181
181
 
182
182
  | Name | Description |
183
183
  | ----------- | -------------------------------------------------------------------------------------------------------------- |
@@ -187,18 +187,83 @@ Every built-in template is a package of its own, `@cenk1cenk2/md-printer-templat
187
187
  | `cv` | CV in the table layout of a classic Word CV, from front-matter (`name`, `suffix`, `title`, `tagline`, `photo`) and Markdown sections placed with the `::section`, `:::details` / `::detail` / `:::pair`, `:::entry`, `::::group` / `:::course`, `::skill`, `:::note` and `:::languages` / `::language` components. The PDF is tagged and has an outline, so screening tools read it as text. See `test/cv.md`. |
188
188
  | `mail` | Plain content with no margin and Tailwind typography, meant for HTML output. |
189
189
 
190
+ ### Template sources
191
+
192
+ `-t` and a string `template` in front-matter take a built-in name or a path. A value without a path separator is a built-in name, anything with `/` or starting with `.` or `~` is a path, relative to the current directory.
193
+
194
+ `template` in front-matter can also be an object selecting the source by `type`. This is the only way to use a template from an npm package.
195
+
196
+ ```yaml
197
+ ---
198
+ template:
199
+ type: package
200
+ name: '@cenk1cenk2/md-printer-template-cv'
201
+ ---
202
+ ```
203
+
204
+ | `type` | Keys | Description |
205
+ | ----------- | -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
206
+ | `builtin` | `name` | The package `@cenk1cenk2/md-printer-template-<name>` from md-printer's own dependencies. |
207
+ | `package` | `name`, `directory?` | An npm package, resolved from the directory of the input file (its `node_modules`, walking up like Node does), then from the current directory, then from md-printer's own dependencies. The package directory is the template directory. `directory` selects a subdirectory of the package instead, and may not leave the package, symlinks included. |
208
+ | `directory` | `path` | A template directory, relative to the directory of the input file. |
209
+
210
+ The same on the command line, where flags win over front-matter. `--template-type` says how to read `-t`, and `--template-directory` is the `directory` of a package. A `--template-type directory` path is relative to the input file too. Without `--template-type`, `-t` is a built-in name or a path as above.
211
+
212
+ ```sh
213
+ md-printer document.md --template-type package -t @cenk1cenk2/md-printer-template-cv
214
+ md-printer document.md --template-type package -t my-templates --template-directory letters
215
+ md-printer document.md --template-type builtin -t cv
216
+ md-printer document.md --template-type directory -t ./template
217
+ ```
218
+
219
+ An unknown `type`, a missing or unknown key, or a `package` that is not installed fails the run. A package has to allow access to its `package.json`, so one with an `exports` map lists `./package.json` there.
220
+
190
221
  ### Authoring a template
191
222
 
192
- A template is a directory. Pass it with `-t ./path` or `template: ./path`. A value without a path separator is a built-in name, anything with `/` or starting with `.` or `~` is a path.
223
+ A template is a directory. Pass it with `-t ./path`, `template: ./path` or as one of the other [template sources](#template-sources). Its Svelte files and its TypeScript are kept apart, so ESLint, Prettier and tsconfig can target each group:
193
224
 
194
225
  ```
195
226
  my-template/
196
- Template.svelte required
197
- settings.json optional
198
- i18n/ optional, en.yaml, de.json, ...
199
- components/ optional, *.svelte placed from markdown, see Markdown components
227
+ package.json optional, see below
228
+ settings.json optional, always at the root
229
+ i18n/ optional, en.yaml, de.json, ...
230
+ template/
231
+ Template.svelte required
232
+ components/*.svelte optional, placed from markdown, see Markdown components
233
+ src/
234
+ extension.ts optional, see Extension module
235
+ *.ts optional, plain modules
236
+ ```
237
+
238
+ `files` in `settings.json` moves any of these. Every key is optional and defaults to the layout above:
239
+
240
+ ```json
241
+ {
242
+ "files": {
243
+ "template": "template/Template.svelte",
244
+ "components": "template/components",
245
+ "extension": "src/extension.ts",
246
+ "i18n": "i18n"
247
+ }
248
+ }
200
249
  ```
201
250
 
251
+ - `components`, `extension` and `i18n` also take an array, read in order: a component of a later folder replaces one of the same name, an export of a later extension module replaces one of the same name, and a key of a later i18n folder replaces the same key while nested keys merge. Use this to share components or translations between templates:
252
+
253
+ ```json
254
+ {
255
+ "files": {
256
+ "components": ["template/shared", "template/components"],
257
+ "extension": ["src/format.ts", "src/extension.ts"],
258
+ "i18n": ["i18n/shared", "i18n"]
259
+ }
260
+ }
261
+ ```
262
+
263
+ - Paths are relative to the template directory. A path leaving it, a path that does not exist and an unknown key each fail the build.
264
+ - A template written for 3.x keeps working: for each key `files` does not set, when the default location is missing, the root location is used if it exists, that is `Template.svelte`, `components/` and `extension.*`. A path set in `files` never falls back.
265
+ - A package keeps what it does not publish out with an `.npmignore`, as the built-in templates do, for example `release.config.cjs` and `CHANGELOG.md`.
266
+
202
267
  `Template.svelte` is a Svelte 5 component receiving these props:
203
268
 
204
269
  | Prop | Description |
@@ -208,6 +273,7 @@ my-template/
208
273
  | `t` | i18next translate function, loaded from `i18n/<language>.(yaml\|yml\|json)`. |
209
274
  | `language` | The resolved language. |
210
275
  | `signature` | `{ date, name?, issuer?, serial? }` while signing is enabled, otherwise `undefined`. |
276
+ | `extension` | The named exports of the [extension module](#extension-module), empty without one. |
211
277
 
212
278
  Set the document title with `<svelte:head><title>...</title></svelte:head>`.
213
279
 
@@ -227,6 +293,31 @@ Images are imported, which inlines them as data URIs:
227
293
  <img src={logo} alt="logo" />
228
294
  ```
229
295
 
296
+ #### Extension module
297
+
298
+ A template may contain one extension module, `src/extension.ts`, `.mts`, `.cts`, `.js`, `.mjs` or `.cjs`, so the template can bring its own functions. More than one at the default location fails the build, naming the files; to combine several modules, list them in `files.extension`.
299
+
300
+ ```ts
301
+ // src/extension.ts
302
+ export function money(value: number): string {
303
+ return `${value.toFixed(2)} EUR`
304
+ }
305
+ ```
306
+
307
+ It is compiled in the same bundle as the template, so TypeScript works and its imports resolve like those of the template's own modules, from its `node_modules` first. Its named exports are available as the `extension` prop of `Template.svelte`, through the `md-printer:extension` context with `getContext`, and in any component with the `getExtension` helper:
308
+
309
+ ```svelte
310
+ <script lang="ts">
311
+ import { getExtension } from '@cenk1cenk2/md-printer/components'
312
+
313
+ const { money } = getExtension<typeof import('../../src/extension.js')>()
314
+ </script>
315
+
316
+ <span>{money(12)}</span>
317
+ ```
318
+
319
+ The default export is left out. An extension only exposes its exports, md-printer calls none of them by itself. `--preview` rebuilds when the module changes, is added or is removed.
320
+
230
321
  #### Shared components
231
322
 
232
323
  Import from `@cenk1cenk2/md-printer/components`:
@@ -240,7 +331,9 @@ Import from `@cenk1cenk2/md-printer/components`:
240
331
  | `TotalPages` | Total page count, filled by Chrome. Header and footer only. |
241
332
  | `Title` | Document title, filled by Chrome. Header and footer only. |
242
333
  | `Date` | Print date, filled by Chrome. Header and footer only. |
243
- | `SignatureBlock` | Visible signature block for the `signature` prop. Needs Tailwind in a `<style lang="postcss">` block. Optional `labels` prop overrides its texts. |
334
+ | `Localized` | Text for the language of the document from props named after languages, `<Localized en="Invoice" de="Rechnung" />` or `:localized{en="Invoice" de="Rechnung"}`, for words that need no entry in `i18n/`. Takes the language, then its base language (`de` for `de-AT`), then `fallback`, otherwise nothing. In a template a prop can also be a snippet for markup. Plain text without a wrapper element. |
335
+ | `Translate` | Text translated with the `i18n/` directory of the template, `<Translate key="present" />` or `::translate{key="present"}`, inline as `:translate{key="present"}`. Plain text without a wrapper element. Optional `fallback` for a missing key, otherwise the key itself is printed. |
336
+ | `SignatureBlock` | Hands the signature, `{ date, name?, issuer?, serial? }`, to its `children` snippet, so the template draws the visible block with its own markup and styles. Takes the `signature` prop, or as `::signature-block` the signature of the document. Renders nothing without a signature or without `children`, so a template gives `::signature-block` its markup with its own `template/components/SignatureBlock.svelte`, see below. |
244
337
 
245
338
  ```svelte
246
339
  <script>
@@ -266,10 +359,32 @@ Import from `@cenk1cenk2/md-printer/components`:
266
359
 
267
360
  #### Markdown components
268
361
 
269
- Every `*.svelte` file in the template's `components/` directory can be placed from markdown, see [Components](#components). The shared components can be placed the same way, for example `::page-break` or `::signature-block`, except `Markdown`. A template component with the same name as a shared one replaces it.
362
+ Every `*.svelte` file in the template's `template/components/` directory can be placed from markdown, see [Components](#components). The shared components can be placed the same way, for example `::page-break` or `::signature-block`, except `Markdown`.
363
+
364
+ A template replaces or extends any shared component, `SignatureBlock` and `Translate` included, with a file of the same name in `template/components/`:
365
+
366
+ - **Replace:** `template/components/SignatureBlock.svelte` is what `::signature-block` renders from then on. This is about directives: a component that imports `SignatureBlock` from `@cenk1cenk2/md-printer/components` keeps getting the original, so import `./components/SignatureBlock.svelte` to use the replacement in the template itself.
367
+ - **Extend:** that import always reaches md-printer's original, also from a file with the same name, so a component can wrap it:
368
+
369
+ ```svelte
370
+ <!-- template/components/SignatureBlock.svelte -->
371
+ <script>
372
+ import { SignatureBlock } from '@cenk1cenk2/md-printer/components'
373
+
374
+ let props = $props()
375
+ </script>
376
+
377
+ <SignatureBlock {...props}>
378
+ {#snippet children(signature)}
379
+ <section class="my-8">
380
+ <strong>Digitally signed</strong> by {signature.name} on {signature.date}
381
+ </section>
382
+ {/snippet}
383
+ </SignatureBlock>
384
+ ```
270
385
 
271
386
  ```svelte
272
- <!-- components/Callout.svelte -->
387
+ <!-- template/components/Callout.svelte -->
273
388
  <script>
274
389
  import { getContext } from 'svelte'
275
390
 
@@ -309,7 +424,7 @@ md-printer invoice.yaml -t invoice --sign --sign-certificate ~/id-austria.pem
309
424
  - Signing only works for PDF output and is skipped with `--preview`.
310
425
  - The signing session has to run in your browser. md-printer opens a temporary local page in an app window of the discovered browser, using your normal profile (or with `xdg-open` when none is found), you log in with ID Austria in the A-Trust page, and paste the resulting signature (starts with `MII`) back into the local page. A paste that is cut short is refused on the page, so you can copy it again. It waits until the signature arrives, press Ctrl+C to abort.
311
426
  - The returned signature is always verified before it is embedded: its digest must match this document and it must be cryptographically valid. The result is printed with the signer name, serial and issuer. A failed verification fails the run, and with `--log-level debug` the returned signature is written next to the output as `<output>.cms.der`.
312
- - With a certificate, the signer name, issuer and serial are passed to the template as `signature`, so `SignatureBlock` can show who signed and when. The certificate must be valid at signing time, and the returned signature is rejected if it was not made with it. Without a certificate only the date is available.
427
+ - With a certificate, the signer name, issuer and serial are passed to the template as `signature`, so the template can show who signed and when through `SignatureBlock`. The certificate must be valid at signing time, and the returned signature is rejected if it was not made with it. Without a certificate only the date is available.
313
428
  - Verify the result at https://www.signaturpruefung.gv.at.
314
429
 
315
430
  ## Migrating from 2.x
@@ -334,58 +449,67 @@ Renders a markdown or yaml file through a Svelte template into a PDF or HTML doc
334
449
  USAGE
335
450
  $ md-printer [FILE] [OUTPUT] [--log-level
336
451
  silent|direct|fatal|error|warn|info|verbose|debug|trace] [-I] [-f
337
- md|yaml|yml] [-F pdf|html] [-O] [-t <value>] [-T <value>] [-l <value>] [-b
338
- <value>] [--continuous] [-p] [--host 127.0.0.1|localhost|::1] [--port
339
- <value>] [--open] [--sign] [--sign-provider a-trust] [--sign-certificate
340
- <value>]
452
+ md|yaml|yml] [-F pdf|html] [-O] [-t <value>] [--template-type <value>]
453
+ [--template-directory <value>] [-T <value>] [-l <value>] [-b <value>]
454
+ [--continuous] [-p] [--host 127.0.0.1|localhost|::1] [--port <value>]
455
+ [--open] [--sign] [--sign-provider a-trust] [--sign-certificate <value>]
341
456
 
342
457
  ARGUMENTS
343
458
  [FILE] Markdown or yaml file to render.
344
459
  [OUTPUT] Output file. Overwrites the one defined in front-matter.
345
460
 
346
461
  FLAGS
347
- -F, --output-filetype=<option> Output file type, defaults to pdf. Overwrites
348
- the one defined in front-matter.
349
- <options: pdf|html>
350
- -I, --stdin Read the input from stdin.
351
- -O, --stdout Write the output to stdout instead of a file.
352
- -T, --title=<value> Document title. Overwrites the one defined in
353
- front-matter or the template.
354
- -b, --browser=<value> Browser executable used for printing.
355
- Discovered automatically when omitted (Brave,
356
- Chrome, Chromium, Edge).
357
- -f, --input-filetype=<option> Input file type. Detected from the file
358
- extension, markdown for stdin and unknown
359
- extensions.
360
- <options: md|yaml|yml>
361
- -l, --language=<value> Language for template translations. Overwrites
362
- the one defined in front-matter and template
363
- settings.
364
- -p, --preview Rebuild whenever the input, the template or
365
- its assets change, show the HTML and the PDF
366
- in a live preview and rewrite the output file
367
- when there is one.
368
- -t, --template=<value> Built-in template name or path to a template
369
- directory, defaults to "default". Overwrites
370
- the one defined in front-matter.
371
- --continuous Render the PDF as a single continuous page
372
- without page breaks.
373
- --host=<option> [default: 127.0.0.1] Loopback host for the
374
- preview server, it serves the files next to
375
- the input and is never exposed to the network.
376
- <options: 127.0.0.1|localhost|::1>
377
- --[no-]open Open the preview window, --no-open keeps
378
- rebuilding and writing without one.
379
- --port=<value> Port for the preview server, random when 0.
380
- --sign Sign the PDF with a qualified electronic
381
- signature through ID Austria (A-Trust).
382
- --sign-certificate=<value> Signer certificate (DER or PEM) used for the
383
- visible signature block and to verify the
384
- returned signature.
385
- --sign-provider=<option> Signature provider, defaults to a-trust.
386
- Overwrites the one defined in front-matter and
387
- template settings.
388
- <options: a-trust>
462
+ -F, --output-filetype=<option> Output file type, defaults to pdf.
463
+ Overwrites the one defined in front-matter.
464
+ <options: pdf|html>
465
+ -I, --stdin Read the input from stdin.
466
+ -O, --stdout Write the output to stdout instead of a
467
+ file.
468
+ -T, --title=<value> Document title. Overwrites the one defined
469
+ in front-matter or the template.
470
+ -b, --browser=<value> Browser executable used for printing.
471
+ Discovered automatically when omitted
472
+ (Brave, Chrome, Chromium, Edge).
473
+ -f, --input-filetype=<option> Input file type. Detected from the file
474
+ extension, markdown for stdin and unknown
475
+ extensions.
476
+ <options: md|yaml|yml>
477
+ -l, --language=<value> Language for template translations.
478
+ Overwrites the one defined in front-matter
479
+ and template settings.
480
+ -p, --preview Rebuild whenever the input, the template or
481
+ its assets change, show the HTML and the PDF
482
+ in a live preview and rewrite the output
483
+ file when there is one.
484
+ -t, --template=<value> Built-in template name or path to a template
485
+ directory, defaults to "default", read as
486
+ --template-type when given. Overwrites the
487
+ one defined in front-matter.
488
+ --continuous Render the PDF as a single continuous page
489
+ without page breaks.
490
+ --host=<option> [default: 127.0.0.1] Loopback host for the
491
+ preview server, it serves the files next to
492
+ the input and is never exposed to the
493
+ network.
494
+ <options: 127.0.0.1|localhost|::1>
495
+ --[no-]open Open the preview window, --no-open keeps
496
+ rebuilding and writing without one.
497
+ --port=<value> Port for the preview server, random when 0.
498
+ --sign Sign the PDF with a qualified electronic
499
+ signature through ID Austria (A-Trust).
500
+ --sign-certificate=<value> Signer certificate (DER or PEM) used for the
501
+ visible signature block and to verify the
502
+ returned signature.
503
+ --sign-provider=<option> Signature provider, defaults to a-trust.
504
+ Overwrites the one defined in front-matter
505
+ and template settings.
506
+ <options: a-trust>
507
+ --template-directory=<value> Directory inside the package to use as the
508
+ template, only valid with --template-type
509
+ package.
510
+ --template-type=<value> How to read --template, one of builtin,
511
+ package or directory, like the type of the
512
+ object form in front-matter.
389
513
 
390
514
  CLI FLAGS
391
515
  --log-level=<option> [default: INFO, env: LOG_LEVEL] Set the log level of the
@@ -12,6 +12,8 @@ let MDPrinter = class MDPrinter extends Command {
12
12
  ["output-filetype"]: Flags.string({ char: "F" }),
13
13
  stdout: Flags.boolean({ char: "O" }),
14
14
  template: Flags.string({ char: "t" }),
15
+ ["template-type"]: Flags.string(),
16
+ ["template-directory"]: Flags.string(),
15
17
  title: Flags.string({ char: "T" }),
16
18
  language: Flags.string({ char: "l" }),
17
19
  browser: Flags.string({ char: "b" }),
@@ -0,0 +1,18 @@
1
+ <!--
2
+ @component
3
+ Text picked by the language of the document from props named after languages, such as `en` and `de`, for words that need no entry in `i18n/`. Takes the
4
+ language as it is, then its base language, then `fallback`. In a template a prop can also be a snippet. Plain text without a wrapper element, like Translate.
5
+ -->
6
+ <script>
7
+ import { getContext } from 'svelte'
8
+
9
+ /** @type {{ fallback?: string | import('svelte').Snippet } & Record<string, string | import('svelte').Snippet | undefined>} */
10
+ let { fallback, ...languages } = $props()
11
+
12
+ /** @type {import('../interfaces/template.interface.js').DocumentContext} */
13
+ const { language } = getContext('md-printer:document')
14
+
15
+ const value = $derived(languages[language] ?? languages[language.split('-')[0]] ?? fallback)
16
+ </script>
17
+
18
+ {#if typeof value === 'function'}{@render value()}{:else}{value ?? ''}{/if}
@@ -1,56 +1,18 @@
1
1
  <!--
2
2
  @component
3
- Visible block for a digitally signed document, filled from the `signature` prop md-printer passes to templates when signing is enabled.
4
- Styled with Tailwind classes, so the template needs a `<style lang="postcss">` block importing Tailwind.
3
+ Passes the signature of a digitally signed document to the `children` snippet, which draws the block, so the template owns its markup and styles. The
4
+ signature comes from the `signature` prop or, as a directive, from the document context. Renders nothing without a signature or without `children`.
5
5
  -->
6
6
  <script>
7
+ import { getContext } from 'svelte'
8
+
7
9
  /**
8
10
  * @type {{
9
- * signature: { date: string, name?: string, issuer?: string, serial?: string },
10
- * labels?: { title?: string, by?: string, date?: string, issuer?: string, verify?: string },
11
- * class?: string
11
+ * signature?: import('../interfaces/signature.interface.js').MdPrinterSignature,
12
+ * children?: import('svelte').Snippet<[import('../interfaces/signature.interface.js').MdPrinterSignature]>
12
13
  * }}
13
14
  */
14
- let { signature, labels = {}, class: className = '' } = $props()
15
-
16
- const text = $derived({
17
- title: 'Digitally signed',
18
- by: 'Signed by',
19
- date: 'Date',
20
- issuer: 'Certificate',
21
- verify: 'This document carries a qualified electronic signature. Verify it at',
22
- ...labels
23
- })
15
+ let { signature = getContext('md-printer:document').signature, children } = $props()
24
16
  </script>
25
17
 
26
- <div class="w-full overflow-hidden rounded-xl border-y-2 shadow-md shadow-gray-300 border-gray-900! text-xs {className}">
27
- <div class="flex items-center gap-3 px-5 py-3 bg-gray-900 text-white">
28
- <svg class="w-5 h-5 shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
29
- <path d="M12 3l7 3v6c0 4.5-3 7.5-7 9-4-1.5-7-4.5-7-9V6l7-3z" />
30
- <path d="M9 12l2 2 4-4" />
31
- </svg>
32
- <span class="font-extrabold uppercase tracking-wider">{text.title}</span>
33
- </div>
34
- <div class="flex flex-col px-5 py-2">
35
- {#if signature.name}
36
- <div class="flex justify-between gap-4 py-2">
37
- <span class="font-semibold text-gray-400">{text.by}</span>
38
- <span class="font-semibold text-right">{signature.name}</span>
39
- </div>
40
- {/if}
41
- <div class="flex justify-between gap-4 py-2 border-t border-t-gray-100/30">
42
- <span class="font-semibold text-gray-400">{text.date}</span>
43
- <span class="text-right">{signature.date}</span>
44
- </div>
45
- {#if signature.issuer}
46
- <div class="flex justify-between gap-4 py-2 border-t border-t-gray-100/30">
47
- <span class="font-semibold text-gray-400">{text.issuer}</span>
48
- <span class="text-right text-[0.6rem]">{signature.issuer} · {signature.serial}</span>
49
- </div>
50
- {/if}
51
- </div>
52
- <p class="px-5 py-3 bg-gray-50 text-[0.6rem] text-gray-400 leading-snug">
53
- {text.verify}
54
- <a href="https://www.signaturpruefung.gv.at" class="font-semibold text-gray-900 underline">signaturpruefung.gv.at</a>
55
- </p>
56
- </div>
18
+ {#if signature && children}{@render children(signature)}{/if}
@@ -0,0 +1,15 @@
1
+ <!--
2
+ @component
3
+ Text translated with the `i18n/` directory of the template. Plain text without a wrapper element, so it flows inside paragraphs and table cells.
4
+ -->
5
+ <script>
6
+ import { getContext } from 'svelte'
7
+
8
+ /** @type {{ key: string, fallback?: string }} */
9
+ let { key, fallback } = $props()
10
+
11
+ /** @type {import('../interfaces/template.interface.js').DocumentContext} */
12
+ const { t } = getContext('md-printer:document')
13
+ </script>
14
+
15
+ {t(key, { defaultValue: fallback ?? key })}
@@ -0,0 +1,8 @@
1
+ import { getContext } from 'svelte'
2
+
3
+ import type { TemplateExtension } from '../interfaces/template.interface.js'
4
+
5
+ /** The named exports of the extension module of the template, empty without one. */
6
+ export function getExtension<T extends TemplateExtension = TemplateExtension>(): T {
7
+ return getContext('md-printer:extension')
8
+ }
@@ -1,4 +1,6 @@
1
+ export { getExtension } from './extension.js'
1
2
  export { default as Date } from './Date.svelte'
3
+ export { default as Localized } from './Localized.svelte'
2
4
  export { default as Markdown } from './Markdown.svelte'
3
5
  export { default as Page } from './Page.svelte'
4
6
  export { default as PageBreak } from './PageBreak.svelte'
@@ -6,3 +8,4 @@ export { default as PageNumber } from './PageNumber.svelte'
6
8
  export { default as SignatureBlock } from './SignatureBlock.svelte'
7
9
  export { default as Title } from './Title.svelte'
8
10
  export { default as TotalPages } from './TotalPages.svelte'
11
+ export { default as Translate } from './Translate.svelte'
@@ -1,6 +1,6 @@
1
1
  import { AttachmentPosition } from "./attachment.constants.mjs";
2
- import { ContextKey, DEFAULT_HIGHLIGHT_STYLE, DEFAULT_LANGUAGE, DEFAULT_PDF_OPTIONS, DEFAULT_TEMPLATE, DIRECTIVE_TAG, Directory, I18N_ACCEPTED_TYPES, MediaType, ModuleSpecifier, PAGE_BREAK_CLASS, PRINTER_MODULE_OPTIONS, RenderMode, TAILWIND_STYLE_LANG, TEMPLATE_PACKAGE_PREFIX, TemplateFiles } from "./template.constants.mjs";
2
+ import { ContextKey, DEFAULT_HIGHLIGHT_STYLE, DEFAULT_LANGUAGE, DEFAULT_PDF_OPTIONS, DEFAULT_TEMPLATE, DIRECTIVE_TAG, Directory, I18N_ACCEPTED_TYPES, MediaType, ModuleSpecifier, PAGE_BREAK_CLASS, PRINTER_MODULE_OPTIONS, RenderMode, TAILWIND_STYLE_LANG, TEMPLATE_EXTENSION_FILES, TEMPLATE_LAYOUT, TEMPLATE_PACKAGE_PREFIX, TemplateFiles, TemplateSourceType } from "./template.constants.mjs";
3
3
  import { InputFileType, OutputFileType } from "./file.constants.mjs";
4
4
  import { PreviewHost } from "./preview.constants.mjs";
5
5
  import { SignatureProvider } from "./signature.constants.mjs";
6
- export { AttachmentPosition, ContextKey, DEFAULT_HIGHLIGHT_STYLE, DEFAULT_LANGUAGE, DEFAULT_PDF_OPTIONS, DEFAULT_TEMPLATE, DIRECTIVE_TAG, Directory, I18N_ACCEPTED_TYPES, InputFileType, MediaType, ModuleSpecifier, OutputFileType, PAGE_BREAK_CLASS, PRINTER_MODULE_OPTIONS, PreviewHost, RenderMode, SignatureProvider, TAILWIND_STYLE_LANG, TEMPLATE_PACKAGE_PREFIX, TemplateFiles };
6
+ export { AttachmentPosition, ContextKey, DEFAULT_HIGHLIGHT_STYLE, DEFAULT_LANGUAGE, DEFAULT_PDF_OPTIONS, DEFAULT_TEMPLATE, DIRECTIVE_TAG, Directory, I18N_ACCEPTED_TYPES, InputFileType, MediaType, ModuleSpecifier, OutputFileType, PAGE_BREAK_CLASS, PRINTER_MODULE_OPTIONS, PreviewHost, RenderMode, SignatureProvider, TAILWIND_STYLE_LANG, TEMPLATE_EXTENSION_FILES, TEMPLATE_LAYOUT, TEMPLATE_PACKAGE_PREFIX, TemplateFiles, TemplateSourceType };
@@ -4,6 +4,21 @@ let TemplateFiles = /* @__PURE__ */ function(TemplateFiles) {
4
4
  TemplateFiles["SETTINGS"] = "settings.json";
5
5
  return TemplateFiles;
6
6
  }({});
7
+ /** Where a template keeps its files unless `files` in its settings says otherwise, the root locations of 3.x come last. */
8
+ const TEMPLATE_LAYOUT = {
9
+ template: ["template/Template.svelte", "Template.svelte"],
10
+ components: ["template/components", "components"],
11
+ extension: ["src", "."],
12
+ i18n: ["i18n"]
13
+ };
14
+ const TEMPLATE_EXTENSION_FILES = [
15
+ "extension.ts",
16
+ "extension.mts",
17
+ "extension.cts",
18
+ "extension.js",
19
+ "extension.mjs",
20
+ "extension.cjs"
21
+ ];
7
22
  let RenderMode = /* @__PURE__ */ function(RenderMode) {
8
23
  RenderMode["BODY"] = "body";
9
24
  RenderMode["HEADER"] = "header";
@@ -17,6 +32,12 @@ let MediaType = /* @__PURE__ */ function(MediaType) {
17
32
  }({});
18
33
  /** Built-in templates are packages named with this prefix, which md-printer depends on. */
19
34
  const TEMPLATE_PACKAGE_PREFIX = "@cenk1cenk2/md-printer-template-";
35
+ let TemplateSourceType = /* @__PURE__ */ function(TemplateSourceType) {
36
+ TemplateSourceType["BUILTIN"] = "builtin";
37
+ TemplateSourceType["PACKAGE"] = "package";
38
+ TemplateSourceType["DIRECTORY"] = "directory";
39
+ return TemplateSourceType;
40
+ }({});
20
41
  let Directory = /* @__PURE__ */ function(Directory) {
21
42
  Directory["COMPONENTS"] = "components";
22
43
  Directory["I18N"] = "i18n";
@@ -26,6 +47,7 @@ let ModuleSpecifier = /* @__PURE__ */ function(ModuleSpecifier) {
26
47
  ModuleSpecifier["TEMPLATE"] = "md-printer:template";
27
48
  ModuleSpecifier["COMPONENTS"] = "@cenk1cenk2/md-printer/components";
28
49
  ModuleSpecifier["TEMPLATE_COMPONENTS"] = "md-printer:template-components";
50
+ ModuleSpecifier["EXTENSION"] = "md-printer:extension";
29
51
  return ModuleSpecifier;
30
52
  }({});
31
53
  let ContextKey = /* @__PURE__ */ function(ContextKey) {
@@ -33,6 +55,7 @@ let ContextKey = /* @__PURE__ */ function(ContextKey) {
33
55
  ContextKey["DOCUMENT"] = "md-printer:document";
34
56
  ContextKey["MARKDOWN"] = "md-printer:markdown";
35
57
  ContextKey["COMPONENTS"] = "md-printer:components";
58
+ ContextKey["EXTENSION"] = "md-printer:extension";
36
59
  return ContextKey;
37
60
  }({});
38
61
  const TAILWIND_STYLE_LANG = "postcss";
@@ -57,4 +80,4 @@ const DEFAULT_PDF_OPTIONS = {
57
80
  left: "20mm"
58
81
  }
59
82
  };
60
- export { ContextKey, DEFAULT_HIGHLIGHT_STYLE, DEFAULT_LANGUAGE, DEFAULT_PDF_OPTIONS, DEFAULT_TEMPLATE, DIRECTIVE_TAG, Directory, I18N_ACCEPTED_TYPES, MediaType, ModuleSpecifier, PAGE_BREAK_CLASS, PRINTER_MODULE_OPTIONS, RenderMode, TAILWIND_STYLE_LANG, TEMPLATE_PACKAGE_PREFIX, TemplateFiles };
83
+ export { ContextKey, DEFAULT_HIGHLIGHT_STYLE, DEFAULT_LANGUAGE, DEFAULT_PDF_OPTIONS, DEFAULT_TEMPLATE, DIRECTIVE_TAG, Directory, I18N_ACCEPTED_TYPES, MediaType, ModuleSpecifier, PAGE_BREAK_CLASS, PRINTER_MODULE_OPTIONS, RenderMode, TAILWIND_STYLE_LANG, TEMPLATE_EXTENSION_FILES, TEMPLATE_LAYOUT, TEMPLATE_PACKAGE_PREFIX, TemplateFiles, TemplateSourceType };
@@ -34,6 +34,8 @@ let MdPrinterHandler = class MdPrinterHandler {
34
34
  outputFiletype: flags["output-filetype"],
35
35
  stdout: flags.stdout,
36
36
  template: flags.template,
37
+ templateType: flags["template-type"],
38
+ templateDirectory: flags["template-directory"],
37
39
  title: flags.title,
38
40
  language: flags.language,
39
41
  continuous: flags.continuous,
@@ -1,6 +1,7 @@
1
1
  import { InputFileType, OutputFileType } from "../../constants/file.constants.mjs";
2
2
  import { PreviewHost } from "../../constants/preview.constants.mjs";
3
3
  import { SignatureProvider } from "../../constants/signature.constants.mjs";
4
+ import "../../constants/template.constants.mjs";
4
5
  import { z } from "zod";
5
6
  // src/modules/md-printer/md-printer.schema.ts
6
7
  const MdPrinterSchema = z.object({
@@ -9,7 +10,9 @@ const MdPrinterSchema = z.object({
9
10
  ["input-filetype"]: z.enum(InputFileType).optional().describe("Input file type. Detected from the file extension, markdown for stdin and unknown extensions."),
10
11
  ["output-filetype"]: z.enum(OutputFileType).optional().describe("Output file type, defaults to pdf. Overwrites the one defined in front-matter."),
11
12
  stdout: z.boolean().default(false).describe("Write the output to stdout instead of a file."),
12
- template: z.string().optional().describe("Built-in template name or path to a template directory, defaults to \"default\". Overwrites the one defined in front-matter."),
13
+ template: z.string().optional().describe("Built-in template name or path to a template directory, defaults to \"default\", read as --template-type when given. Overwrites the one defined in front-matter."),
14
+ ["template-type"]: z.string().optional().describe("How to read --template, one of builtin, package or directory, like the type of the object form in front-matter."),
15
+ ["template-directory"]: z.string().optional().describe("Directory inside the package to use as the template, only valid with --template-type package."),
13
16
  title: z.string().optional().describe("Document title. Overwrites the one defined in front-matter or the template."),
14
17
  language: z.string().optional().describe("Language for template translations. Overwrites the one defined in front-matter and template settings."),
15
18
  browser: z.string().optional().describe("Browser executable used for printing. Discovered automatically when omitted (Brave, Chrome, Chromium, Edge)."),
@@ -35,6 +38,14 @@ const MdPrinterSchema = z.object({
35
38
  code: "custom",
36
39
  message: "Reading from --stdin takes only the output file argument."
37
40
  });
41
+ if (flags["template-type"] && !flags.template) ctx.addIssue({
42
+ code: "custom",
43
+ message: "--template-type needs --template to read."
44
+ });
45
+ if (flags["template-directory"] !== void 0 && flags["template-type"] !== "package") ctx.addIssue({
46
+ code: "custom",
47
+ message: "--template-directory is only valid with --template-type package."
48
+ });
38
49
  if (flags.stdout && flags.preview) ctx.addIssue({
39
50
  code: "custom",
40
51
  message: "Writing to stdout is not supported with --preview."
@@ -14,14 +14,16 @@ let I18nService = class I18nService {
14
14
  this.fs = fs;
15
15
  this.parser = parser;
16
16
  }
17
- async load(directory, options) {
17
+ /** Loads the folders in order, a key of a later folder replacing the same key of an earlier one while nested keys merge. */
18
+ async load(directories, options) {
18
19
  const instance = i18next.createInstance();
19
20
  await instance.init({
20
21
  lng: options.language,
21
22
  fallbackLng: options.fallback ?? options.language ?? "en",
22
- resources: this.fs.exists(directory) ? await this.resources(directory) : {},
23
+ resources: {},
23
24
  interpolation: { escapeValue: false }
24
25
  });
26
+ for (const directory of directories.filter((directory) => this.fs.exists(directory))) for (const [language, { translation }] of Object.entries(await this.resources(directory))) instance.addResourceBundle(language, "translation", translation, true, true);
25
27
  return instance.getFixedT(options.language);
26
28
  }
27
29
  async resources(directory) {
@@ -5,6 +5,7 @@ import { RendererService } from "./renderer/renderer.service.mjs";
5
5
  import { SignatureVerificationError } from "./signature/cms.mjs";
6
6
  import { SignatureService } from "./signature/signature.service.mjs";
7
7
  import { inlineHtml } from "./template/assets.mjs";
8
+ import { TemplateSourceSchema, issues } from "./template/template.schema.mjs";
8
9
  import { TemplateService } from "./template/template.service.mjs";
9
10
  import { AttachmentPosition } from "../constants/attachment.constants.mjs";
10
11
  import { DEFAULT_PDF_OPTIONS } from "../constants/template.constants.mjs";
@@ -12,7 +13,7 @@ import { OutputFileType } from "../constants/file.constants.mjs";
12
13
  import __decorateMetadata from "../_virtual/_@oxc-project_runtime@0.157.0/helpers/esm/decorateMetadata.mjs";
13
14
  import __decorate from "../_virtual/_@oxc-project_runtime@0.157.0/helpers/esm/decorate.mjs";
14
15
  import { Injectable } from "@nestjs/common";
15
- import { basename, dirname, extname, join, resolve } from "path";
16
+ import { basename, dirname, extname, resolve } from "path";
16
17
  import { ConfigService, FileSystemService, LoggerService } from "@cenk1cenk2/oclif-common";
17
18
  import { cloneDeep, mergeWith, uniq } from "es-toolkit";
18
19
  import { parse } from "yaml";
@@ -65,13 +66,14 @@ let PrinterService = class PrinterService {
65
66
  }
66
67
  async options(flags, input) {
67
68
  const { data } = input;
68
- const template = this.template.resolve(flags.template ?? data.template ?? "default");
69
- const settings = await this.template.settings(template);
69
+ const source = this.templateSource(this.templateFlags(flags) ?? data.template) ?? "default";
70
+ const { directory: template, settings, files } = await this.template.resolve(source, input.directory);
70
71
  const output = flags.outputFiletype ?? data.outputFiletype ?? "pdf";
71
72
  if (!Object.values(OutputFileType).includes(output)) throw new Error(`Output file type is not supported: ${output}, use one of ${Object.values(OutputFileType).join(", ")}.`);
72
73
  const dest = flags.stdout ? void 0 : flags.output ?? data.dest ?? (input.file ? `${basename(input.file, extname(input.file))}.${output}` : void 0);
73
74
  return {
74
75
  template,
76
+ files,
75
77
  output,
76
78
  dest: dest && resolve(dest),
77
79
  title: flags.title ?? data.documentTitle,
@@ -87,7 +89,7 @@ let PrinterService = class PrinterService {
87
89
  };
88
90
  }
89
91
  bundle(options) {
90
- return this.template.bundle(options.template);
92
+ return this.template.bundle(options.files);
91
93
  }
92
94
  /**
93
95
  * Resolves the signing time and the visible signature description, which the template renders before the document is signed.
@@ -104,7 +106,7 @@ let PrinterService = class PrinterService {
104
106
  const { input, options } = ctx;
105
107
  const document = await this.template.render(options.template, bundle, {
106
108
  data: input.data,
107
- t: await this.i18n.load(join(options.template, "i18n"), {
109
+ t: await this.i18n.load(options.files.i18n, {
108
110
  ...options.i18n,
109
111
  language: options.language
110
112
  }),
@@ -158,6 +160,21 @@ let PrinterService = class PrinterService {
158
160
  throw err;
159
161
  });
160
162
  }
163
+ templateFlags({ template, templateType, templateDirectory }) {
164
+ if (!templateType) return template;
165
+ return {
166
+ type: templateType,
167
+ [templateType === "directory" ? "path" : "name"]: template,
168
+ ...templateDirectory !== void 0 && { directory: templateDirectory }
169
+ };
170
+ }
171
+ templateSource(value) {
172
+ if (value === void 0 || value === null) return;
173
+ if (typeof value === "string") return value;
174
+ const { success, data, error } = TemplateSourceSchema.safeParse(value);
175
+ if (!success) throw new Error(`Template is invalid: ${issues(error)}`);
176
+ return data;
177
+ }
161
178
  attachments(value) {
162
179
  if (value === void 0 || value === null) return [];
163
180
  if (!Array.isArray(value)) throw new Error("Front-matter attachments must be a list of { file, position, compress }.");
@@ -5,6 +5,7 @@ import __decorate from "../../_virtual/_@oxc-project_runtime@0.157.0/helpers/esm
5
5
  import { Injectable } from "@nestjs/common";
6
6
  import { dirname, join, resolve } from "path";
7
7
  import { LoggerService } from "@cenk1cenk2/oclif-common";
8
+ import { isEqual } from "es-toolkit";
8
9
  import { EventEmitter } from "events";
9
10
  import { watch } from "chokidar";
10
11
  import { debounce } from "perfect-debounce";
@@ -53,7 +54,7 @@ let SessionService = class SessionService extends EventEmitter {
53
54
  const file = resolve(path);
54
55
  if (this.ignored.has(file)) return;
55
56
  this.logger.debug("Changed: %s", file);
56
- if (this.modules.has(file) || this.bundle && dirname(file) === join(this.bundle.template, "components")) this.bundle = void 0;
57
+ if (this.modules.has(file) || this.bundle?.files.components.includes(dirname(file))) this.bundle = void 0;
57
58
  this.build();
58
59
  }
59
60
  async run() {
@@ -62,18 +63,17 @@ let SessionService = class SessionService extends EventEmitter {
62
63
  try {
63
64
  const input = await this.printer.input(this.flags);
64
65
  const options = await this.printer.options(this.flags, input);
65
- const template = join(options.template, "Template.svelte");
66
- this.modules.add(template);
66
+ const { files } = options;
67
+ this.modules = /* @__PURE__ */ new Set([...this.modules, files.template]);
67
68
  this.watch([
68
69
  input.file,
69
- template,
70
+ files.template,
71
+ ...files.candidates,
70
72
  join(options.template, "settings.json"),
71
- join(options.template, "i18n"),
72
- join(options.template, "components"),
73
73
  options.sign.certificate && resolve(options.sign.certificate)
74
74
  ]);
75
- if (this.bundle?.template !== options.template) this.bundle = {
76
- template: options.template,
75
+ if (!isEqual(this.bundle?.files, files)) this.bundle = {
76
+ files,
77
77
  bundle: this.printer.bundle(options)
78
78
  };
79
79
  const cached = this.bundle;
@@ -82,7 +82,7 @@ let SessionService = class SessionService extends EventEmitter {
82
82
  throw err;
83
83
  });
84
84
  if (this.bundle !== cached) return;
85
- this.modules = /* @__PURE__ */ new Set([template, ...bundle.modules]);
85
+ this.modules = /* @__PURE__ */ new Set([files.template, ...bundle.modules]);
86
86
  this.watch(bundle.modules);
87
87
  const ctx = {
88
88
  input,
@@ -1,3 +1,4 @@
1
+ import modules from 'md-printer:extension'
1
2
  import Template from 'md-printer:template'
2
3
  import components from 'md-printer:template-components'
3
4
  import { render } from 'svelte/server'
@@ -7,7 +8,10 @@ import Document from './Document.svelte'
7
8
  import Margin from './Margin.svelte'
8
9
  import type { DocumentShellProps, MarginShellProps, MarkdownNode, RenderedPart, TemplateProps } from '@interfaces'
9
10
 
10
- export { components }
11
+ // a CommonJS module also comes with module.exports as the default
12
+ const extension = Object.fromEntries(modules.flatMap((module) => Object.entries(module).filter(([name]) => name !== 'default')))
13
+
14
+ export { components, extension }
11
15
 
12
16
  /**
13
17
  * Entry of every template bundle, compiled at runtime together with the template it renders.
@@ -1,7 +1,7 @@
1
1
  import { dataUri, isInlinableAsset } from "./assets.mjs";
2
2
  import "../../constants/template.constants.mjs";
3
3
  import { readFile, readdir } from "fs/promises";
4
- import { basename, dirname, extname, join } from "path";
4
+ import { basename, extname, join } from "path";
5
5
  import { pathToFileURL } from "url";
6
6
  import { existsSync } from "fs";
7
7
  import { createRequire } from "module";
@@ -10,9 +10,10 @@ import { compile, preprocess } from "svelte/compiler";
10
10
  /**
11
11
  * `svelte` resolves to md-printer's copy, since templates can live outside any `node_modules`. `<style lang="postcss">` blocks are collected
12
12
  * instead of compiled per component, so the template's stylesheet goes through Tailwind once. `md-printer:template-components` maps the shared
13
- * components and the template's `components/*.svelte` by name, the template's winning.
13
+ * components and the `*.svelte` files of the template's components directory by name, the template's winning. `md-printer:extension` is the extension module of the
14
+ * template, or an empty module without one.
14
15
  */
15
- function sveltePlugin({ template, components, root, stylesheets }) {
16
+ function sveltePlugin({ template, components, templateComponents, root, stylesheets, extension }) {
16
17
  const require = createRequire(root);
17
18
  return {
18
19
  name: "md-printer:svelte",
@@ -20,6 +21,7 @@ function sveltePlugin({ template, components, root, stylesheets }) {
20
21
  if (id === "md-printer:template") return template;
21
22
  if (id === "@cenk1cenk2/md-printer/components") return components;
22
23
  if (id === "md-printer:template-components") return `\0${id}`;
24
+ if (id === "md-printer:extension") return `\0${id}`;
23
25
  if (id === "svelte" || id.startsWith("svelte/")) return {
24
26
  id: pathToFileURL(require.resolve(id)).href,
25
27
  external: true
@@ -28,15 +30,18 @@ function sveltePlugin({ template, components, root, stylesheets }) {
28
30
  },
29
31
  async load(id) {
30
32
  if (id === `\0md-printer:template-components`) {
31
- const directory = join(dirname(template), "components");
32
- const files = existsSync(directory) ? (await readdir(directory)).filter((file) => extname(file) === ".svelte").sort() : [];
33
+ const files = (await Promise.all((templateComponents ?? []).map(async (folder) => existsSync(folder) ? (await readdir(folder)).filter((file) => extname(file) === ".svelte").sort().map((file) => join(folder, file)) : []))).flat();
33
34
  return [
34
35
  `import * as shared from ${JSON.stringify("@cenk1cenk2/md-printer/components")}`,
35
- ...files.map((file, index) => `import $${index} from ${JSON.stringify(join(directory, file))}`),
36
- "const { Markdown, ...builtins } = shared",
36
+ ...files.map((file, index) => `import $${index} from ${JSON.stringify(file)}`),
37
+ "const { Markdown, getExtension, ...builtins } = shared",
37
38
  `export default { ...builtins, ${files.map((file, index) => `${JSON.stringify(basename(file, ".svelte"))}: $${index}`).join(", ")} }`
38
39
  ].join("\n");
39
40
  }
41
+ if (id === `\0md-printer:extension`) {
42
+ const modules = extension ?? [];
43
+ return [...modules.map((file, index) => `import * as $${index} from ${JSON.stringify(file)}`), `export default [${modules.map((_, index) => `$${index}`).join(", ")}]`].join("\n");
44
+ }
40
45
  if (!isInlinableAsset(id)) return null;
41
46
  return `export default ${JSON.stringify(dataUri(id, await readFile(id)))}`;
42
47
  },
@@ -0,0 +1,27 @@
1
+ import { TemplateSourceType } from "../../constants/template.constants.mjs";
2
+ import { z } from "zod";
3
+ const TemplateSourceSchema = z.discriminatedUnion("type", [
4
+ z.strictObject({
5
+ type: z.literal("builtin"),
6
+ name: z.string().regex(/^[^\\/]+$/, "Built-in template names have no path separator.")
7
+ }),
8
+ z.strictObject({
9
+ type: z.literal("package"),
10
+ name: z.string().regex(/^(@[a-z0-9-~][a-z0-9-._~]*\/)?[a-z0-9-~][a-z0-9-._~]*$/, "Not an npm package name."),
11
+ directory: z.string().optional()
12
+ }),
13
+ z.strictObject({
14
+ type: z.literal("directory"),
15
+ path: z.string().min(1)
16
+ })
17
+ ], { error: () => `Use a string or an object with a type of ${Object.values(TemplateSourceType).join(", ")}.` });
18
+ const TemplateSettingsSchema = z.looseObject({ files: z.strictObject({
19
+ template: z.string().min(1).optional(),
20
+ components: z.union([z.string().min(1), z.array(z.string().min(1)).min(1)]).optional(),
21
+ extension: z.union([z.string().min(1), z.array(z.string().min(1)).min(1)]).optional(),
22
+ i18n: z.union([z.string().min(1), z.array(z.string().min(1)).min(1)]).optional()
23
+ }).optional() });
24
+ function issues({ issues }) {
25
+ return issues.map(({ path, message }) => [...path, message].join(": ")).join(", ");
26
+ }
27
+ export { TemplateSettingsSchema, TemplateSourceSchema, issues };
@@ -1,12 +1,13 @@
1
- import { inlineCss, inlineHtml } from "./assets.mjs";
1
+ import { inlineCss, inlineHtml, resolveConfined } from "./assets.mjs";
2
2
  import { sveltePlugin } from "./svelte.plugin.mjs";
3
- import { TEMPLATE_PACKAGE_PREFIX } from "../../constants/template.constants.mjs";
3
+ import { TemplateSettingsSchema, issues } from "./template.schema.mjs";
4
+ import { TEMPLATE_EXTENSION_FILES, TEMPLATE_LAYOUT, TEMPLATE_PACKAGE_PREFIX } from "../../constants/template.constants.mjs";
4
5
  import __decorateMetadata from "../../_virtual/_@oxc-project_runtime@0.157.0/helpers/esm/decorateMetadata.mjs";
5
6
  import __decorate from "../../_virtual/_@oxc-project_runtime@0.157.0/helpers/esm/decorate.mjs";
6
7
  import { Injectable } from "@nestjs/common";
7
- import { dirname, isAbsolute, join, resolve } from "path";
8
+ import { dirname, isAbsolute, join, relative, resolve, sep } from "path";
8
9
  import { FileSystemService } from "@cenk1cenk2/oclif-common";
9
- import { fileURLToPath } from "url";
10
+ import { fileURLToPath, pathToFileURL } from "url";
10
11
  import { homedir } from "os";
11
12
  import { compile } from "@tailwindcss/node";
12
13
  import { Scanner } from "@tailwindcss/oxide";
@@ -45,21 +46,20 @@ let TemplateService = class TemplateService {
45
46
  constructor(fs) {
46
47
  this.fs = fs;
47
48
  }
48
- resolve(template) {
49
- const path = template.replace(/^~(?=\/|$)/, homedir());
50
- const directory = isAbsolute(path) || path.startsWith(".") || /[\\/]/.test(path) ? resolve(path) : this.builtin(path);
51
- if (!this.fs.exists(join(directory, "Template.svelte"))) throw new Error(`Template does not exist, expected Template.svelte in: ${directory}`);
52
- return directory;
53
- }
54
- async settings(directory) {
55
- const file = join(directory, "settings.json");
56
- if (!this.fs.exists(file)) return {};
57
- return JSON.parse(await this.fs.read(file));
49
+ async resolve(template, base) {
50
+ const directory = await this.locate(template, base);
51
+ const settings = await this.settings(directory);
52
+ return {
53
+ directory,
54
+ settings,
55
+ files: await this.paths(directory, settings.files)
56
+ };
58
57
  }
59
- async bundle(directory) {
58
+ async bundle(files) {
60
59
  const stylesheets = [];
61
- const { module: entry, modules } = await this.compile(_TemplateService.ENTRY, join(directory, "Template.svelte"), stylesheets);
60
+ const { module: entry, modules } = await this.compile(_TemplateService.ENTRY, stylesheets, files);
62
61
  return {
62
+ template: files.template,
63
63
  entry,
64
64
  modules,
65
65
  stylesheets,
@@ -67,17 +67,22 @@ let TemplateService = class TemplateService {
67
67
  };
68
68
  }
69
69
  async page(file) {
70
- const { module } = await this.compile(file, file, []);
70
+ const { module } = await this.compile(file, [], {
71
+ directory: dirname(file),
72
+ template: file
73
+ });
71
74
  return `<!doctype html>${render(module.default).body}`;
72
75
  }
73
76
  async render(directory, bundle, document, markdown, options) {
74
77
  const context = [
75
78
  ["md-printer:document", document],
76
79
  ["md-printer:markdown", markdown],
77
- ["md-printer:components", bundle.entry.components]
80
+ ["md-printer:components", bundle.entry.components],
81
+ ["md-printer:extension", bundle.entry.extension]
78
82
  ];
79
83
  const props = {
80
84
  ...document,
85
+ extension: bundle.entry.extension,
81
86
  content: bundle.entry.content(markdown, context)
82
87
  };
83
88
  const [body, header, footer] = await Promise.all([
@@ -108,16 +113,90 @@ let TemplateService = class TemplateService {
108
113
  dependencies
109
114
  };
110
115
  }
116
+ locate(template, base) {
117
+ if (typeof template === "string") {
118
+ const path = this.expand(template);
119
+ return isAbsolute(path) || path.startsWith(".") || /[\\/]/.test(path) ? resolve(path) : this.builtin(path);
120
+ }
121
+ switch (template.type) {
122
+ case "builtin": return this.builtin(template.name);
123
+ case "package": return this.package(template, base);
124
+ case "directory": return resolve(base, this.expand(template.path));
125
+ }
126
+ }
127
+ expand(path) {
128
+ return path.replace(/^~(?=\/|$)/, homedir());
129
+ }
111
130
  /** A built-in template is a package of its own, so it brings its own fonts, icons and stylesheets as dependencies. */
112
131
  builtin(name) {
113
132
  const pkg = `${TEMPLATE_PACKAGE_PREFIX}${name}`;
114
133
  try {
115
134
  return dirname(this.require.resolve(`${pkg}/package.json`));
116
135
  } catch {
117
- throw new Error(`Template does not exist, expected the built-in template package ${pkg} or a path to a directory with Template.svelte.`);
136
+ throw new Error(`Template does not exist, expected the built-in template package ${pkg} or a path to a template directory.`);
137
+ }
138
+ }
139
+ async package({ name, directory }, base) {
140
+ const root = this.installed(name, [.../* @__PURE__ */ new Set([base, process.cwd()]), _TemplateService.ROOT]);
141
+ if (directory === void 0) return root;
142
+ const confined = await resolveConfined(resolve(root, directory), root);
143
+ if (!confined) throw new Error(`Template directory "${directory}" of the package ${name} has to exist inside the package: ${root}`);
144
+ return confined;
145
+ }
146
+ installed(name, roots) {
147
+ for (const root of roots) try {
148
+ return dirname(createRequire(join(root, "package.json")).resolve(`${name}/package.json`));
149
+ } catch {
150
+ continue;
151
+ }
152
+ throw new Error(`Template does not exist, the package ${name} is not installed in any of: ${roots.join(", ")}`);
153
+ }
154
+ async settings(directory) {
155
+ const file = join(directory, "settings.json");
156
+ if (!this.fs.exists(file)) return {};
157
+ const { success, data, error } = TemplateSettingsSchema.safeParse(JSON.parse(await this.fs.read(file)));
158
+ if (!success) throw new Error(`Template settings are invalid in ${file}: ${issues(error)}`);
159
+ return data;
160
+ }
161
+ async paths(directory, files = {}) {
162
+ const template = files.template ? await this.configured(directory, "template", files.template) : this.first(directory, TEMPLATE_LAYOUT.template);
163
+ const components = files.components ? await Promise.all([files.components].flat().map((path) => this.configured(directory, "components", path))) : [this.first(directory, TEMPLATE_LAYOUT.components) ?? join(directory, TEMPLATE_LAYOUT.components[0])];
164
+ const i18n = files.i18n ? await Promise.all([files.i18n].flat().map((path) => this.configured(directory, "i18n", path))) : [this.first(directory, TEMPLATE_LAYOUT.i18n) ?? join(directory, TEMPLATE_LAYOUT.i18n[0])];
165
+ const extension = files.extension ? await Promise.all([files.extension].flat().map((path) => this.configured(directory, "extension", path))) : [this.extension(directory)].filter((path) => path !== void 0);
166
+ if (!template) throw new Error(`Template does not exist, expected ${TEMPLATE_LAYOUT.template.join(" or ")} in: ${directory}`);
167
+ return {
168
+ directory,
169
+ template,
170
+ components,
171
+ i18n,
172
+ extension,
173
+ candidates: [
174
+ ...files.template ? [template] : TEMPLATE_LAYOUT.template.map((path) => join(directory, path)),
175
+ ...files.components ? components : TEMPLATE_LAYOUT.components.map((path) => join(directory, path)),
176
+ ...files.i18n ? i18n : TEMPLATE_LAYOUT.i18n.map((path) => join(directory, path)),
177
+ ...files.extension ? extension : TEMPLATE_LAYOUT.extension.flatMap((path) => TEMPLATE_EXTENSION_FILES.map((file) => join(directory, path, file)))
178
+ ]
179
+ };
180
+ }
181
+ async configured(directory, key, value) {
182
+ const path = resolve(directory, value);
183
+ const rel = relative(directory, path);
184
+ const outside = rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel);
185
+ if (!outside && !this.fs.exists(path)) throw new Error(`Template settings point files.${key} to a path that does not exist: ${path}`);
186
+ if (outside || !await resolveConfined(path, directory)) throw new Error(`Template settings point files.${key} outside of the template directory: ${value}`);
187
+ return path;
188
+ }
189
+ first(directory, paths) {
190
+ return paths.map((path) => join(directory, path)).find((path) => this.fs.exists(path));
191
+ }
192
+ extension(directory) {
193
+ for (const location of TEMPLATE_LAYOUT.extension) {
194
+ const files = TEMPLATE_EXTENSION_FILES.filter((file) => this.fs.exists(join(directory, location, file)));
195
+ if (files.length > 1) throw new Error(`Template has more than one extension module, keep one of ${files.join(", ")} in: ${join(directory, location)}`);
196
+ if (files.length === 1) return join(directory, location, files[0]);
118
197
  }
119
198
  }
120
- async compile(input, template, stylesheets) {
199
+ async compile(input, stylesheets, files) {
121
200
  const build = await rolldown({
122
201
  input,
123
202
  platform: "node",
@@ -125,14 +204,16 @@ let TemplateService = class TemplateService {
125
204
  onLog(_, log, handler) {
126
205
  if (log.code === "UNRESOLVED_IMPORT") handler("error", {
127
206
  ...log,
128
- message: `${log.message} Install it in the template directory: ${dirname(template)}`
207
+ message: `${log.message} Install it in the template directory: ${files.directory}`
129
208
  });
130
209
  },
131
210
  plugins: [sveltePlugin({
132
- template,
211
+ template: files.template,
133
212
  components: _TemplateService.COMPONENTS,
213
+ templateComponents: files.components,
134
214
  root: _TemplateService.ROOT,
135
- stylesheets
215
+ stylesheets,
216
+ extension: files.extension
136
217
  })]
137
218
  });
138
219
  try {
@@ -140,8 +221,9 @@ let TemplateService = class TemplateService {
140
221
  format: "esm",
141
222
  sourcemap: "inline"
142
223
  });
224
+ const code = chunk.code.replaceAll("createRequire(import.meta.url)", `createRequire(${JSON.stringify(pathToFileURL(files.template).href)})`);
143
225
  return {
144
- module: await import(`data:text/javascript;base64,${Buffer.from(chunk.code).toString("base64")}`),
226
+ module: await import(`data:text/javascript;base64,${Buffer.from(code).toString("base64")}`),
145
227
  modules: chunk.moduleIds.filter((id) => isAbsolute(id))
146
228
  };
147
229
  } finally {
@@ -164,7 +246,7 @@ let TemplateService = class TemplateService {
164
246
  const theme = `:root { ${readFileSync(this.highlightTheme(highlight), "utf8")} pre:has(> code.hljs) { padding: 0; background: transparent; } }`;
165
247
  const compiler = await compile([theme, ...bundle.stylesheets.map(({ source }) => source)].join("\n"), {
166
248
  base: directory,
167
- from: bundle.stylesheets[0]?.file ?? join(directory, "Template.svelte"),
249
+ from: bundle.stylesheets[0]?.file ?? bundle.template,
168
250
  shouldRewriteUrls: true,
169
251
  onDependency: (path) => dependencies.push(path),
170
252
  customCssResolver: async (id, base) => this.resolveFrom(this.stylesheets, id, [
@@ -94,12 +94,26 @@
94
94
  },
95
95
  "template": {
96
96
  "char": "t",
97
- "description": "Built-in template name or path to a template directory, defaults to \"default\". Overwrites the one defined in front-matter.",
97
+ "description": "Built-in template name or path to a template directory, defaults to \"default\", read as --template-type when given. Overwrites the one defined in front-matter.",
98
98
  "name": "template",
99
99
  "hasDynamicHelp": false,
100
100
  "multiple": false,
101
101
  "type": "option"
102
102
  },
103
+ "template-type": {
104
+ "description": "How to read --template, one of builtin, package or directory, like the type of the object form in front-matter.",
105
+ "name": "template-type",
106
+ "hasDynamicHelp": false,
107
+ "multiple": false,
108
+ "type": "option"
109
+ },
110
+ "template-directory": {
111
+ "description": "Directory inside the package to use as the template, only valid with --template-type package.",
112
+ "name": "template-directory",
113
+ "hasDynamicHelp": false,
114
+ "multiple": false,
115
+ "type": "option"
116
+ },
103
117
  "title": {
104
118
  "char": "T",
105
119
  "description": "Document title. Overwrites the one defined in front-matter or the template.",
@@ -198,5 +212,5 @@
198
212
  "enableJsonFlag": false
199
213
  }
200
214
  },
201
- "version": "3.3.0"
215
+ "version": "3.4.1"
202
216
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cenk1cenk2/md-printer",
3
- "version": "3.3.0",
3
+ "version": "3.4.1",
4
4
  "description": "Render markdown or yaml through Svelte templates into PDF or HTML with Puppeteer.",
5
5
  "type": "module",
6
6
  "repository": {
@@ -49,13 +49,13 @@
49
49
  "cenk1cenk2"
50
50
  ],
51
51
  "dependencies": {
52
- "@cenk1cenk2/md-printer-template-cv": "1.0.0",
53
- "@cenk1cenk2/md-printer-template-default": "1.0.0",
54
- "@cenk1cenk2/md-printer-template-invoice": "1.0.0",
55
- "@cenk1cenk2/md-printer-template-kilic.dev": "1.0.0",
56
- "@cenk1cenk2/md-printer-template-mail": "1.0.0",
57
- "@cenk1cenk2/oclif-common": "^8.0.2",
58
- "@listr2/manager": "^4.3.3",
52
+ "@cenk1cenk2/md-printer-template-cv": "^1.0.0",
53
+ "@cenk1cenk2/md-printer-template-default": "1.1.0",
54
+ "@cenk1cenk2/md-printer-template-invoice": "^1.0.0",
55
+ "@cenk1cenk2/md-printer-template-kilic.dev": "1.1.0",
56
+ "@cenk1cenk2/md-printer-template-mail": "1.1.0",
57
+ "@cenk1cenk2/oclif-common": "^8.0.4",
58
+ "@listr2/manager": "^4.3.4",
59
59
  "@nestjs/common": "^12.3.2",
60
60
  "@oclif/core": "^5.1.11",
61
61
  "@oclif/plugin-help": "^7.0.6",
@@ -75,7 +75,7 @@
75
75
  "highlight.js": "^11.12.0",
76
76
  "i18next": "^26.7.1",
77
77
  "lightningcss": "^1.33.0",
78
- "listr2": "^11.4.0",
78
+ "listr2": "^11.4.1",
79
79
  "lowlight": "^3.3.0",
80
80
  "mime": "^4.1.2",
81
81
  "mupdf": "^1.28.1",
@@ -123,7 +123,7 @@
123
123
  "tsdown": "^0.23.4",
124
124
  "typescript": "^6.0.3",
125
125
  "vite": "^8.3.5",
126
- "vitest": "^5.0.5"
126
+ "vitest": "^5.1.0"
127
127
  },
128
128
  "bugs": {
129
129
  "url": "https://gitlab.kilic.dev/utils/md-printer/-/issues"