@cenk1cenk2/md-printer 2.20.12 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. package/README.md +332 -48
  2. package/dist/_virtual/_@oxc-project_runtime@0.153.0/helpers/esm/decorate.mjs +1 -2
  3. package/dist/_virtual/_@oxc-project_runtime@0.153.0/helpers/esm/decorateMetadata.mjs +1 -2
  4. package/dist/_virtual/_@oxc-project_runtime@0.153.0/helpers/esm/decorateParam.mjs +8 -0
  5. package/dist/commands/index.mjs +136 -257
  6. package/dist/components/Date.svelte +1 -0
  7. package/dist/components/Markdown.svelte +20 -0
  8. package/dist/components/Nodes.svelte +32 -0
  9. package/dist/components/Page.svelte +20 -0
  10. package/dist/components/PageBreak.svelte +5 -0
  11. package/dist/components/PageNumber.svelte +1 -0
  12. package/dist/components/SignatureBlock.svelte +56 -0
  13. package/dist/components/Title.svelte +1 -0
  14. package/dist/components/TotalPages.svelte +1 -0
  15. package/dist/components/index.ts +8 -0
  16. package/dist/constants/file.constants.mjs +1 -8
  17. package/dist/constants/index.mjs +3 -4
  18. package/dist/constants/template.constants.mjs +48 -10
  19. package/dist/interfaces/commands/index.mjs +1 -1
  20. package/dist/interfaces/commands/run.interface.mjs +1 -1
  21. package/dist/interfaces/index.mjs +1 -1
  22. package/dist/interfaces/markdown.interface.mjs +1 -0
  23. package/dist/interfaces/renderer.interface.mjs +1 -0
  24. package/dist/interfaces/session.interface.mjs +1 -0
  25. package/dist/interfaces/signature.interface.mjs +1 -0
  26. package/dist/interfaces/template.interface.mjs +1 -0
  27. package/dist/services/i18n.service.mjs +4 -8
  28. package/dist/services/index.mjs +13 -2
  29. package/dist/services/loopback.mjs +18 -0
  30. package/dist/services/markdown.service.mjs +123 -0
  31. package/dist/services/printer.module.mjs +41 -0
  32. package/dist/services/printer.service.mjs +171 -0
  33. package/dist/services/renderer/browser.mjs +41 -0
  34. package/dist/services/renderer/browser.service.mjs +56 -0
  35. package/dist/services/renderer/renderer.service.mjs +198 -0
  36. package/dist/services/session/Preview.svelte +238 -0
  37. package/dist/services/session/preview.service.mjs +187 -0
  38. package/dist/services/session/session.service.mjs +115 -0
  39. package/dist/services/signature/adapters/a-trust.adapter.mjs +106 -0
  40. package/dist/services/signature/adapters/a-trust.html +77 -0
  41. package/dist/services/signature/index.mjs +4 -0
  42. package/dist/services/signature/signature.adapter.mjs +4 -0
  43. package/dist/services/signature/signature.service.mjs +95 -0
  44. package/dist/services/template/Document.svelte +21 -0
  45. package/dist/services/template/Margin.svelte +15 -0
  46. package/dist/services/template/assets.mjs +127 -0
  47. package/dist/services/template/render.entry.ts +32 -0
  48. package/dist/services/template/svelte.plugin.mjs +66 -0
  49. package/dist/services/template/template.service.mjs +211 -0
  50. package/oclif.manifest.json +64 -33
  51. package/package.json +70 -35
  52. package/templates/default/Template.svelte +83 -0
  53. package/templates/default/settings.json +1 -3
  54. package/templates/invoice/Template.svelte +247 -0
  55. package/templates/invoice/components/Client.svelte +42 -0
  56. package/templates/invoice/components/Company.svelte +41 -0
  57. package/templates/invoice/components/Items.svelte +66 -0
  58. package/templates/invoice/components/Notes.svelte +21 -0
  59. package/templates/invoice/components/Payment.svelte +25 -0
  60. package/templates/invoice/components/Signature.svelte +34 -0
  61. package/templates/invoice/components/Totals.svelte +47 -0
  62. package/templates/invoice/i18n/de.yaml +6 -0
  63. package/templates/invoice/i18n/en.yaml +6 -0
  64. package/templates/invoice/round.js +2 -0
  65. package/templates/invoice/settings.json +1 -4
  66. package/templates/kilic.dev/Template.svelte +102 -0
  67. package/templates/kilic.dev/settings.json +1 -3
  68. package/templates/mail/Template.svelte +166 -0
  69. package/templates/mail/settings.json +1 -3
  70. package/dist/hooks/not-found.hook.mjs +0 -7
  71. package/dist/hooks/update-notifier.hook.mjs +0 -7
  72. package/dist/interfaces/pupeteer.mjs +0 -1
  73. package/templates/default/main.css +0 -73
  74. package/templates/invoice/.gitignore +0 -1
  75. package/templates/invoice/footer.html +0 -18
  76. package/templates/invoice/tailwind.css +0 -164
  77. package/templates/invoice/template.html.j2 +0 -189
  78. package/templates/kilic.dev/footer.html +0 -26
  79. package/templates/kilic.dev/header.html +0 -16
  80. package/templates/kilic.dev/main.css +0 -74
  81. package/templates/mail/.gitignore +0 -1
  82. package/templates/mail/tailwind.css +0 -145
  83. package/templates/mail/template.html.j2 +0 -14
@@ -0,0 +1,115 @@
1
+ import "../../constants/template.constants.mjs";
2
+ import __decorateMetadata from "../../_virtual/_@oxc-project_runtime@0.153.0/helpers/esm/decorateMetadata.mjs";
3
+ import __decorate from "../../_virtual/_@oxc-project_runtime@0.153.0/helpers/esm/decorate.mjs";
4
+ import { PrinterService } from "../printer.service.mjs";
5
+ import { dirname, join, resolve } from "path";
6
+ import { LoggerService } from "@cenk1cenk2/oclif-common";
7
+ import { Injectable } from "@nestjs/common";
8
+ import { EventEmitter } from "events";
9
+ import { watch } from "chokidar";
10
+ import { debounce } from "perfect-debounce";
11
+ //#region src/services/session/session.service.ts
12
+ let SessionService = class SessionService extends EventEmitter {
13
+ logger;
14
+ printer;
15
+ watcher;
16
+ bundle;
17
+ stopped = false;
18
+ modules = /* @__PURE__ */ new Set();
19
+ watched = /* @__PURE__ */ new Set();
20
+ version = 0;
21
+ building;
22
+ flags;
23
+ ignored = /* @__PURE__ */ new Set();
24
+ build = debounce(() => this.stopped ? Promise.resolve() : this.building = this.run(), 50);
25
+ constructor(logger, printer) {
26
+ super();
27
+ this.logger = logger;
28
+ this.printer = printer;
29
+ this.logger.setup(this.constructor.name);
30
+ }
31
+ async start(flags) {
32
+ this.flags = flags;
33
+ this.watcher = watch([], {
34
+ ignoreInitial: true,
35
+ atomic: true
36
+ }).on("change", (path) => this.changed(path)).on("add", (path) => this.changed(path)).on("unlink", (path) => this.changed(path));
37
+ await this.build();
38
+ }
39
+ async onModuleDestroy() {
40
+ await this.stop();
41
+ }
42
+ ignore(path) {
43
+ this.ignored.add(resolve(path));
44
+ }
45
+ async stop() {
46
+ this.stopped = true;
47
+ this.build.cancel();
48
+ await this.building;
49
+ await this.watcher?.close();
50
+ this.watcher = void 0;
51
+ }
52
+ changed(path) {
53
+ const file = resolve(path);
54
+ if (this.ignored.has(file)) return;
55
+ 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
+ this.build();
58
+ }
59
+ async run() {
60
+ const started = performance.now();
61
+ this.emit("building");
62
+ try {
63
+ const input = await this.printer.input(this.flags);
64
+ const options = await this.printer.options(this.flags, input);
65
+ const template = join(options.template, "Template.svelte");
66
+ this.modules.add(template);
67
+ this.watch([
68
+ input.file,
69
+ template,
70
+ join(options.template, "settings.json"),
71
+ join(options.template, "i18n"),
72
+ join(options.template, "components"),
73
+ options.sign.certificate && resolve(options.sign.certificate)
74
+ ]);
75
+ if (this.bundle?.template !== options.template) this.bundle = {
76
+ template: options.template,
77
+ bundle: this.printer.bundle(options)
78
+ };
79
+ const cached = this.bundle;
80
+ const bundle = await cached.bundle.catch((err) => {
81
+ if (this.bundle === cached) this.bundle = void 0;
82
+ throw err;
83
+ });
84
+ if (this.bundle !== cached) return;
85
+ this.modules = /* @__PURE__ */ new Set([template, ...bundle.modules]);
86
+ this.watch(bundle.modules);
87
+ const ctx = {
88
+ input,
89
+ options
90
+ };
91
+ await this.printer.prepare(ctx);
92
+ const document = await this.printer.document(ctx, bundle);
93
+ this.watch(document.dependencies);
94
+ const { content, assets } = await this.printer.output(ctx, document);
95
+ this.watch(assets);
96
+ this.emit("ready", {
97
+ version: ++this.version,
98
+ ms: Math.round(performance.now() - started),
99
+ ctx,
100
+ document,
101
+ output: content
102
+ });
103
+ } catch (err) {
104
+ this.emit("failed", err instanceof Error ? err : new Error(String(err)));
105
+ }
106
+ }
107
+ watch(paths) {
108
+ const fresh = paths.filter((path) => path && !this.watched.has(path));
109
+ for (const path of fresh) this.watched.add(path);
110
+ if (fresh.length) this.watcher.add(fresh);
111
+ }
112
+ };
113
+ SessionService = __decorate([Injectable(), __decorateMetadata("design:paramtypes", [typeof LoggerService === "undefined" ? Object : LoggerService, typeof PrinterService === "undefined" ? Object : PrinterService])], SessionService);
114
+ //#endregion
115
+ export { SessionService };
@@ -0,0 +1,106 @@
1
+ import __decorateMetadata from "../../../_virtual/_@oxc-project_runtime@0.153.0/helpers/esm/decorateMetadata.mjs";
2
+ import __decorate from "../../../_virtual/_@oxc-project_runtime@0.153.0/helpers/esm/decorate.mjs";
3
+ import { isLoopbackRequest } from "../../loopback.mjs";
4
+ import { SignatureAdapter } from "../signature.adapter.mjs";
5
+ import { readFile } from "fs/promises";
6
+ import { LoggerService } from "@cenk1cenk2/oclif-common";
7
+ import { Injectable } from "@nestjs/common";
8
+ import { spawn } from "child_process";
9
+ import { once } from "events";
10
+ import { createServer } from "http";
11
+ import getRawBody from "raw-body";
12
+ //#region src/services/signature/adapters/a-trust.adapter.ts
13
+ let ATrustAdapter = class ATrustAdapter extends SignatureAdapter {
14
+ logger;
15
+ name = "a-trust";
16
+ constructor(logger) {
17
+ super();
18
+ this.logger = logger;
19
+ this.logger.setup(this.constructor.name);
20
+ }
21
+ async sign(request) {
22
+ return this.collect(await this.session(this.request(request)));
23
+ }
24
+ request({ pdf, excluded: [from, to] }) {
25
+ return [
26
+ "<?xml version=\"1.0\" encoding=\"UTF-8\"?>",
27
+ "<sl:CreateCMSSignatureRequest xmlns:sl=\"http://www.buergerkarte.at/namespaces/securitylayer/1.2#\" Structure=\"detached\" PAdESCompatibility=\"true\">",
28
+ "<sl:KeyboxIdentifier>SecureSignatureKeypair</sl:KeyboxIdentifier>",
29
+ "<sl:DataObject>",
30
+ "<sl:MetaInfo><sl:MimeType>application/pdf</sl:MimeType></sl:MetaInfo>",
31
+ `<sl:Content><sl:Base64Content>${Buffer.from(pdf).fill(0, from, to + 1).toString("base64")}</sl:Base64Content></sl:Content>`,
32
+ `<sl:ExcludedByteRange><sl:From>${from}</sl:From><sl:To>${to}</sl:To></sl:ExcludedByteRange>`,
33
+ "</sl:DataObject>",
34
+ "</sl:CreateCMSSignatureRequest>"
35
+ ].join("");
36
+ }
37
+ async session(request) {
38
+ let url = "https://service.a-trust.at/mobile/https-security-layer-request/default.aspx";
39
+ let response = await fetch(url, {
40
+ method: "POST",
41
+ body: new URLSearchParams([["XMLRequest", request]]),
42
+ redirect: "manual"
43
+ });
44
+ while (response.status === 301 || response.status === 302) {
45
+ url = new URL(response.headers.get("location"), url).href;
46
+ response = await fetch(url, { redirect: "manual" });
47
+ }
48
+ if (!response.ok || !url.includes("/identification.aspx")) throw new Error(`A-Trust did not start a signing session, ended at ${url} with ${response.status}: ${(await response.text()).slice(0, 500)}`);
49
+ this.logger.debug("A-Trust signing session started: %s", url);
50
+ return url;
51
+ }
52
+ async collect(login) {
53
+ let resolve;
54
+ const result = new Promise((done) => {
55
+ resolve = done;
56
+ });
57
+ const page = await readFile(new URL("./a-trust.html", import.meta.url));
58
+ const server = createServer(async (req, res) => {
59
+ if (!isLoopbackRequest(req, req.method === "POST" ? `http://127.0.0.1:${server.address().port}` : void 0)) {
60
+ res.writeHead(403);
61
+ return res.end();
62
+ }
63
+ if (req.method === "GET" && req.url === "/session") {
64
+ res.setHeader("content-type", "application/json");
65
+ res.writeHead(200);
66
+ return res.end(JSON.stringify({ login }));
67
+ }
68
+ if (req.method !== "POST" || req.url !== "/signature") {
69
+ res.setHeader("content-type", "text/html; charset=utf-8");
70
+ res.writeHead(200);
71
+ return res.end(page);
72
+ }
73
+ const body = await getRawBody(req, {
74
+ limit: "1mb",
75
+ encoding: true
76
+ }).catch((err) => {
77
+ res.writeHead(err.status ?? 400);
78
+ res.end();
79
+ });
80
+ if (body === void 0) return;
81
+ const cms = (body.match(/<(?:\w+:)?CMSSignature>([^<]+)</)?.[1] ?? body).replace(/\s+/g, "");
82
+ if (!/^MII[A-Za-z0-9+/=]+$/.test(cms)) {
83
+ res.writeHead(400);
84
+ return res.end("This does not look like a signature, it should start with MII.");
85
+ }
86
+ res.writeHead(200);
87
+ res.end("Signature received, you can close this tab.");
88
+ resolve(Buffer.from(cms, "base64"));
89
+ });
90
+ server.listen(0, "127.0.0.1");
91
+ await once(server, "listening");
92
+ const url = `http://127.0.0.1:${server.address().port}/`;
93
+ this.logger.info("Sign the document in the browser, then paste the signature back: %s", url);
94
+ this.open(url);
95
+ return result.finally(() => server.close());
96
+ }
97
+ open(url) {
98
+ spawn("xdg-open", [url], {
99
+ stdio: "ignore",
100
+ detached: true
101
+ }).on("error", (err) => this.logger.warn("Can not open the browser, open the link manually: %s", err.message)).unref();
102
+ }
103
+ };
104
+ ATrustAdapter = __decorate([Injectable(), __decorateMetadata("design:paramtypes", [typeof LoggerService === "undefined" ? Object : LoggerService])], ATrustAdapter);
105
+ //#endregion
106
+ export { ATrustAdapter };
@@ -0,0 +1,77 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>md-printer signature</title>
7
+ <style>
8
+ body {
9
+ font-family: system-ui, sans-serif;
10
+ margin: 0;
11
+ padding: 16px;
12
+ background: #f4f4f5;
13
+ color: #18181b;
14
+ }
15
+ main {
16
+ max-width: 560px;
17
+ margin: 0 auto;
18
+ display: grid;
19
+ gap: 12px;
20
+ }
21
+ iframe {
22
+ width: 100%;
23
+ height: 600px;
24
+ border: 0;
25
+ border-radius: 12px;
26
+ background: #fff;
27
+ }
28
+ textarea {
29
+ width: 100%;
30
+ height: 96px;
31
+ box-sizing: border-box;
32
+ border-radius: 8px;
33
+ padding: 8px;
34
+ font: 12px ui-monospace, monospace;
35
+ }
36
+ button {
37
+ width: 100%;
38
+ padding: 10px;
39
+ border: 0;
40
+ border-radius: 8px;
41
+ background: #18181b;
42
+ color: #fff;
43
+ font-weight: 600;
44
+ }
45
+ </style>
46
+ </head>
47
+ <body>
48
+ <main id="main">
49
+ <iframe id="login"></iframe>
50
+ <p>Log in and approve the signature. When the signature starting with <code>MII</code> shows up above, paste it here.</p>
51
+ <form id="form">
52
+ <textarea name="signature" placeholder="MII..." required></textarea>
53
+ <button>Sign PDF</button>
54
+ </form>
55
+ </main>
56
+ <script>
57
+ fetch('/session')
58
+ .then((response) => response.json())
59
+ .then(({ login }) => (document.getElementById('login').src = login))
60
+
61
+ document.getElementById('form').addEventListener('submit', async (event) => {
62
+ event.preventDefault()
63
+
64
+ const response = await fetch('/signature', { method: 'POST', body: new FormData(event.target).get('signature') })
65
+ const message = document.createElement('p')
66
+
67
+ message.textContent = await response.text()
68
+
69
+ if (response.ok) {
70
+ document.getElementById('main').replaceChildren(message)
71
+ } else {
72
+ event.target.append(message)
73
+ }
74
+ })
75
+ </script>
76
+ </body>
77
+ </html>
@@ -0,0 +1,4 @@
1
+ import { SignatureAdapter } from "./signature.adapter.mjs";
2
+ import { ATrustAdapter } from "./adapters/a-trust.adapter.mjs";
3
+ import { SignatureService } from "./signature.service.mjs";
4
+ export { ATrustAdapter, SignatureAdapter, SignatureService };
@@ -0,0 +1,4 @@
1
+ //#region src/services/signature/signature.adapter.ts
2
+ var SignatureAdapter = class {};
3
+ //#endregion
4
+ export { SignatureAdapter };
@@ -0,0 +1,95 @@
1
+ import __decorateMetadata from "../../_virtual/_@oxc-project_runtime@0.153.0/helpers/esm/decorateMetadata.mjs";
2
+ import __decorate from "../../_virtual/_@oxc-project_runtime@0.153.0/helpers/esm/decorate.mjs";
3
+ import { ATrustAdapter } from "./adapters/a-trust.adapter.mjs";
4
+ import { isAbsolute, join } from "path";
5
+ import { FileSystemService } from "@cenk1cenk2/oclif-common";
6
+ import { Injectable } from "@nestjs/common";
7
+ import { plainAddPlaceholder } from "@signpdf/placeholder-plain";
8
+ import { SUBFILTER_ETSI_CADES_DETACHED, findByteRange, removeTrailingNewLine } from "@signpdf/utils";
9
+ import { X509Certificate } from "crypto";
10
+ import { homedir } from "os";
11
+ //#region src/services/signature/signature.service.ts
12
+ var _SignatureService;
13
+ let SignatureService = class SignatureService {
14
+ static {
15
+ _SignatureService = this;
16
+ }
17
+ fs;
18
+ static LENGTH = 16384;
19
+ adapters;
20
+ constructor(fs, aTrust) {
21
+ this.fs = fs;
22
+ this.adapters = [aTrust];
23
+ }
24
+ get providers() {
25
+ return this.adapters.map((adapter) => adapter.name);
26
+ }
27
+ async certificate(file, date) {
28
+ const path = file.replace(/^~(?=\/|$)/, homedir());
29
+ const certificate = new X509Certificate(await this.fs.extra.readFile(isAbsolute(path) ? path : join(process.cwd(), path)));
30
+ if (date < certificate.validFromDate || date > certificate.validToDate) throw new Error(`Signing certificate is not valid at ${date.toISOString()}, it is valid from ${certificate.validFrom} to ${certificate.validTo}.`);
31
+ return certificate;
32
+ }
33
+ describe(date, certificate) {
34
+ return {
35
+ date: date.toISOString().replace("T", " ").replace(/\.\d+Z$/, " UTC"),
36
+ name: certificate && this.commonName(certificate.subject),
37
+ issuer: certificate && this.commonName(certificate.issuer),
38
+ serial: certificate?.serialNumber
39
+ };
40
+ }
41
+ async sign(pdf, options) {
42
+ const request = this.prepare(pdf, options);
43
+ const cms = await this.adapter(options.provider).sign(request);
44
+ if (options.certificate && !cms.includes(options.certificate.raw)) throw new Error("The returned signature was not created with the configured certificate, the signature block would show the wrong signer.");
45
+ return this.embed(request, cms);
46
+ }
47
+ adapter(provider) {
48
+ if (!provider) return this.adapters[0];
49
+ const adapter = this.adapters.find((adapter) => adapter.name === provider);
50
+ if (!adapter) throw new Error(`Unknown signature provider "${provider}", available: ${this.providers.join(", ")}`);
51
+ return adapter;
52
+ }
53
+ prepare(pdf, options) {
54
+ const prepared = removeTrailingNewLine(plainAddPlaceholder({
55
+ pdfBuffer: pdf,
56
+ reason: options.reason ?? "",
57
+ location: options.location ?? "",
58
+ contactInfo: options.contact ?? "",
59
+ name: "",
60
+ signingTime: options.date,
61
+ signatureLength: _SignatureService.LENGTH,
62
+ subFilter: SUBFILTER_ETSI_CADES_DETACHED,
63
+ appName: "md-printer"
64
+ }));
65
+ const { byteRangePlaceholder, byteRangePlaceholderPosition } = findByteRange(prepared);
66
+ if (!byteRangePlaceholder) throw new Error("Can not find the signature byte range placeholder in the PDF.");
67
+ const byteRangeEnd = byteRangePlaceholderPosition + byteRangePlaceholder.length;
68
+ const from = prepared.indexOf("<", prepared.indexOf("/Contents ", byteRangeEnd));
69
+ const to = prepared.indexOf(">", from);
70
+ return {
71
+ pdf: Buffer.concat([
72
+ prepared.subarray(0, byteRangePlaceholderPosition),
73
+ Buffer.from(`/ByteRange [0 ${from} ${to + 1} ${prepared.length - to - 1}]`.padEnd(byteRangePlaceholder.length, " ")),
74
+ prepared.subarray(byteRangeEnd)
75
+ ]),
76
+ excluded: [from, to]
77
+ };
78
+ }
79
+ embed({ pdf, excluded: [from, to] }, cms) {
80
+ const signature = cms.toString("hex");
81
+ const length = to - from - 1;
82
+ if (signature.length > length) throw new Error(`Signature is ${signature.length} bytes but the placeholder only fits ${length}.`);
83
+ return Buffer.concat([
84
+ pdf.subarray(0, from + 1),
85
+ Buffer.from(signature.padEnd(length, "0")),
86
+ pdf.subarray(to)
87
+ ]);
88
+ }
89
+ commonName(name) {
90
+ return name.split("\n").find((part) => part.startsWith("CN="))?.slice(3);
91
+ }
92
+ };
93
+ SignatureService = _SignatureService = __decorate([Injectable(), __decorateMetadata("design:paramtypes", [typeof FileSystemService === "undefined" ? Object : FileSystemService, typeof ATrustAdapter === "undefined" ? Object : ATrustAdapter])], SignatureService);
94
+ //#endregion
95
+ export { SignatureService };
@@ -0,0 +1,21 @@
1
+ <!--
2
+ @component
3
+ HTML document around the rendered template body.
4
+ -->
5
+ <script>
6
+ /** @type {import('@interfaces').DocumentShellProps} */
7
+ let { lang, title, head, body } = $props()
8
+ </script>
9
+
10
+ <html {lang}>
11
+ <head>
12
+ <meta charset="utf-8" />
13
+ {#if title !== undefined}
14
+ <title>{title}</title>
15
+ {/if}
16
+ <!-- eslint-disable-next-line svelte/no-at-html-tags -- styles and head elements rendered by md-printer -->
17
+ {@html head}
18
+ </head>
19
+ <!-- eslint-disable-next-line svelte/no-at-html-tags -- the template body rendered by md-printer -->
20
+ <body>{@html body}</body>
21
+ </html>
@@ -0,0 +1,15 @@
1
+ <!--
2
+ @component
3
+ Wrapper for a header or footer snippet printed into Chrome's isolated page margin, which ignores the page stylesheet and defaults to a tiny font.
4
+ -->
5
+ <script>
6
+ /** @type {import('@interfaces').MarginShellProps} */
7
+ let { styles, body } = $props()
8
+ </script>
9
+
10
+ <!-- eslint-disable-next-line svelte/no-at-html-tags -- styles collected by md-printer for the isolated margin document -->
11
+ {@html styles}
12
+ <div style="font-size: 10px; width: 100%; box-sizing: border-box; -webkit-print-color-adjust: exact; print-color-adjust: exact">
13
+ <!-- eslint-disable-next-line svelte/no-at-html-tags -- the header or footer snippet rendered by md-printer -->
14
+ {@html body}
15
+ </div>
@@ -0,0 +1,127 @@
1
+ import { readFile, realpath } from "fs/promises";
2
+ import { isAbsolute, relative, sep } from "path";
3
+ import { toHtml } from "hast-util-to-html";
4
+ import { visit } from "unist-util-visit";
5
+ import { fileURLToPath, pathToFileURL } from "url";
6
+ import { fromHtml } from "hast-util-from-html";
7
+ import { transform, transformStyleAttribute } from "lightningcss";
8
+ import mime from "mime";
9
+ import { parseSrcset, stringifySrcset } from "srcset";
10
+ //#region src/services/template/assets.ts
11
+ const REFERENCES = {
12
+ img: ["src", "srcSet"],
13
+ source: ["src", "srcSet"],
14
+ input: ["src"],
15
+ video: ["poster"],
16
+ image: ["href", "xLinkHref"]
17
+ };
18
+ function mimeType(path) {
19
+ return mime.getType(path) ?? "application/octet-stream";
20
+ }
21
+ /**
22
+ * Images and fonts, the assets that can be embedded into a page as data URIs.
23
+ */
24
+ function isInlinableAsset(path) {
25
+ return /^(image|font)\//.test(mime.getType(path) ?? "");
26
+ }
27
+ function dataUri(path, content) {
28
+ return `data:${mimeType(path)};base64,${content.toString("base64")}`;
29
+ }
30
+ /**
31
+ * Resolves symlinks first, so a link inside the directory can not reach a file outside of it.
32
+ */
33
+ async function resolveConfined(path, base) {
34
+ const [real, root] = await Promise.all([realpath(path), realpath(base)]).catch(() => []);
35
+ if (!real) return;
36
+ const rel = relative(root, real);
37
+ return rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel) ? void 0 : real;
38
+ }
39
+ /**
40
+ * Replaces local image references in HTML with data URIs: the image attributes, `style` attributes and `<style>` elements.
41
+ * Remote references are fetched as well when `remote` is set, which Chrome's isolated header/footer templates need.
42
+ * Document content is `confined` to its directory, the same rule the PDF renderer applies to the assets a page loads.
43
+ */
44
+ async function inlineHtml(markup, base, options = {}) {
45
+ const assets = /* @__PURE__ */ new Set();
46
+ const inline = inliner(base, options, assets);
47
+ const tree = fromHtml(markup, { fragment: !/^<!doctype/i.test(markup) });
48
+ const pending = [];
49
+ visit(tree, "element", (node) => {
50
+ const { properties } = node;
51
+ for (const name of REFERENCES[node.tagName] ?? []) {
52
+ const value = properties[name];
53
+ if (typeof value !== "string") continue;
54
+ pending.push((async () => {
55
+ properties[name] = name === "srcSet" ? stringifySrcset(await Promise.all(parseSrcset(value).map(async (candidate) => ({
56
+ ...candidate,
57
+ url: await inline(candidate.url) ?? candidate.url
58
+ })))) : await inline(value) ?? value;
59
+ })());
60
+ }
61
+ if (typeof properties.style === "string") {
62
+ const style = properties.style;
63
+ pending.push(stylesheet(style, inline, true).then((css) => {
64
+ properties.style = css;
65
+ }));
66
+ }
67
+ if (node.tagName === "style") {
68
+ for (const child of node.children) if (child.type === "text") pending.push(stylesheet(child.value, inline).then((css) => {
69
+ child.value = css;
70
+ }));
71
+ }
72
+ });
73
+ await Promise.all(pending);
74
+ return {
75
+ content: toHtml(tree, { characterReferences: { useNamedReferences: true } }),
76
+ assets: [...assets]
77
+ };
78
+ }
79
+ /**
80
+ * Replaces local `url()` references in CSS with data URIs, with the same options as `inlineHtml`.
81
+ */
82
+ async function inlineCss(css, base, options = {}) {
83
+ const assets = /* @__PURE__ */ new Set();
84
+ return {
85
+ content: await stylesheet(css, inliner(base, options, assets)),
86
+ assets: [...assets]
87
+ };
88
+ }
89
+ function inliner(base, { remote = false, confined = false, cache = /* @__PURE__ */ new Map() }, assets) {
90
+ return async (reference) => {
91
+ const asset = await (cache.get(reference) ?? cache.set(reference, load(reference, base, remote, confined)).get(reference));
92
+ if (asset?.path) assets.add(asset.path);
93
+ return asset?.uri;
94
+ };
95
+ }
96
+ async function stylesheet(css, inline, attribute = false) {
97
+ const options = {
98
+ filename: "style.css",
99
+ code: Buffer.from(css),
100
+ analyzeDependencies: { preserveImports: true },
101
+ errorRecovery: true
102
+ };
103
+ const { code, dependencies } = attribute ? transformStyleAttribute({
104
+ ...options,
105
+ analyzeDependencies: true
106
+ }) : transform(options);
107
+ return (await Promise.all(dependencies.map(async (dependency) => [`"${dependency.placeholder}"`, JSON.stringify(dependency.type === "url" && await inline(dependency.url) || dependency.url)]))).reduce((result, [placeholder, value]) => result.replace(placeholder, () => value), code.toString());
108
+ }
109
+ async function load(reference, base, remote, confined) {
110
+ const url = reference.startsWith("#") ? void 0 : URL.parse(reference, pathToFileURL(`${base}${sep}`));
111
+ if (url?.protocol === "http:" || url?.protocol === "https:") {
112
+ if (!remote) return;
113
+ const response = await fetch(url).catch(() => void 0);
114
+ if (!response?.ok) return;
115
+ return { uri: `data:${response.headers.get("content-type") ?? mimeType(url.pathname)};base64,${Buffer.from(await response.arrayBuffer()).toString("base64")}` };
116
+ }
117
+ if (url?.protocol !== "file:" || url.host) return;
118
+ const path = fileURLToPath(url);
119
+ const file = confined ? await resolveConfined(path, base) : path;
120
+ if (!file || !isInlinableAsset(path)) return;
121
+ return readFile(file).then((content) => ({
122
+ uri: dataUri(path, content),
123
+ path: file
124
+ }), () => void 0);
125
+ }
126
+ //#endregion
127
+ export { dataUri, inlineCss, inlineHtml, isInlinableAsset, mimeType, resolveConfined };
@@ -0,0 +1,32 @@
1
+ import Template from 'md-printer:template'
2
+ import components from 'md-printer:template-components'
3
+ import { render } from 'svelte/server'
4
+
5
+ import Document from './Document.svelte'
6
+ import Margin from './Margin.svelte'
7
+ import Nodes from '../../components/Nodes.svelte'
8
+ import type { DocumentShellProps, MarginShellProps, MarkdownNode, RenderedPart, TemplateProps } from '@interfaces'
9
+
10
+ export { components }
11
+
12
+ /**
13
+ * Entry of every template bundle, compiled at runtime together with the template it renders.
14
+ */
15
+ export function part(props: TemplateProps, context: [string, unknown][]): Promise<RenderedPart> {
16
+ return Promise.resolve(render(Template, { props, context: new Map(context) }))
17
+ }
18
+
19
+ /**
20
+ * The markdown as one HTML string, for templates that place it with `{@html content}`. Plain HTML is kept as is, without Svelte's hydration markers.
21
+ */
22
+ export function content(nodes: MarkdownNode[], context: [string, unknown][]): string {
23
+ return nodes.map((node) => (node.type === 'html' ? node.html : render(Nodes, { props: { nodes: [node] }, context: new Map(context) }).body)).join('')
24
+ }
25
+
26
+ export function document(props: DocumentShellProps): string {
27
+ return `<!doctype html>${render(Document, { props }).body}`
28
+ }
29
+
30
+ export function margin(props: MarginShellProps): string {
31
+ return render(Margin, { props }).body
32
+ }
@@ -0,0 +1,66 @@
1
+ import "../../constants/template.constants.mjs";
2
+ import { dataUri, isInlinableAsset } from "./assets.mjs";
3
+ import { readFile, readdir } from "fs/promises";
4
+ import { basename, dirname, extname, join } from "path";
5
+ import { pathToFileURL } from "url";
6
+ import { existsSync } from "fs";
7
+ import { createRequire } from "module";
8
+ import { compile, preprocess } from "svelte/compiler";
9
+ //#region src/services/template/svelte.plugin.ts
10
+ /**
11
+ * `svelte` resolves to md-printer's copy, since templates can live outside any `node_modules`. `<style lang="postcss">` blocks are collected
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.
14
+ */
15
+ function sveltePlugin({ template, components, root, stylesheets }) {
16
+ const require = createRequire(root);
17
+ return {
18
+ name: "md-printer:svelte",
19
+ resolveId(id) {
20
+ if (id === "md-printer:template") return template;
21
+ if (id === "@cenk1cenk2/md-printer/components") return components;
22
+ if (id === "md-printer:template-components") return `\0${id}`;
23
+ if (id === "svelte" || id.startsWith("svelte/")) return {
24
+ id: pathToFileURL(require.resolve(id)).href,
25
+ external: true
26
+ };
27
+ return null;
28
+ },
29
+ async load(id) {
30
+ 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
+ return [
34
+ `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",
37
+ `export default { ...builtins, ${files.map((file, index) => `${JSON.stringify(basename(file, ".svelte"))}: $${index}`).join(", ")} }`
38
+ ].join("\n");
39
+ }
40
+ if (!isInlinableAsset(id)) return null;
41
+ return `export default ${JSON.stringify(dataUri(id, await readFile(id)))}`;
42
+ },
43
+ async transform(code, id) {
44
+ if (extname(id) !== ".svelte") return null;
45
+ const { code: source } = await preprocess(code, { style: ({ content, attributes }) => {
46
+ if (attributes.lang !== "postcss") return;
47
+ stylesheets.push({
48
+ file: id,
49
+ source: content
50
+ });
51
+ return { code: "" };
52
+ } }, { filename: id });
53
+ const { js } = compile(source, {
54
+ filename: id,
55
+ generate: "server",
56
+ css: "injected"
57
+ });
58
+ return {
59
+ code: js.code,
60
+ map: js.map
61
+ };
62
+ }
63
+ };
64
+ }
65
+ //#endregion
66
+ export { sveltePlugin };