@pablo_clueless/printr 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +29 -0
- package/LICENSE +21 -0
- package/README.md +103 -0
- package/dist/cli.js +177 -0
- package/dist/convert.js +71 -0
- package/dist/styles.js +124 -0
- package/package.json +60 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to **printr** are documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
|
|
8
|
+
## [Unreleased]
|
|
9
|
+
|
|
10
|
+
## [0.1.0] - 2026-06-22
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- Initial release: a CLI that prints Markdown and plain-text files as PDFs.
|
|
14
|
+
- Markdown rendering with GitHub-flavored styling (`markdown-it`) and
|
|
15
|
+
syntax-highlighted code blocks (`highlight.js`).
|
|
16
|
+
- High-fidelity PDF output through headless Chrome (`puppeteer`); generated HTML
|
|
17
|
+
is fully self-contained so no external resources are fetched while rendering.
|
|
18
|
+
- Plain-text files (any non-Markdown extension) rendered verbatim in a
|
|
19
|
+
monospace layout.
|
|
20
|
+
- Batch conversion with glob support (e.g. `printr "docs/**/*.md"`), reusing a
|
|
21
|
+
single browser instance across files for speed.
|
|
22
|
+
- `--watch` / `-w` mode: re-renders a file whenever it changes, debounced and
|
|
23
|
+
serialized, with recursive directory watching for `**` patterns and pickup of
|
|
24
|
+
newly created files matching a glob.
|
|
25
|
+
- CLI options: `--output`/`-o`, `--out-dir`/`-d`, `--format`/`-f`,
|
|
26
|
+
`--margin`/`-m`, `--title`/`-t`.
|
|
27
|
+
|
|
28
|
+
[Unreleased]: https://example.com/printr/compare/v0.1.0...HEAD
|
|
29
|
+
[0.1.0]: https://example.com/printr/releases/tag/v0.1.0
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 smsnmicheal
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# printr
|
|
2
|
+
|
|
3
|
+
> Print **Markdown** and **plain-text** files as nicely styled PDFs โ straight from your terminal.
|
|
4
|
+
|
|
5
|
+
Markdown is rendered with GitHub-flavored styling and syntax-highlighted code
|
|
6
|
+
blocks, then printed to PDF through headless Chrome (Puppeteer) for high
|
|
7
|
+
fidelity. Plain-text files are rendered verbatim in a clean monospace layout.
|
|
8
|
+
The generated HTML is fully self-contained, so no external resources are
|
|
9
|
+
fetched while rendering.
|
|
10
|
+
|
|
11
|
+
## Features
|
|
12
|
+
|
|
13
|
+
- ๐ GitHub-flavored Markdown styling with print-tuned page breaks
|
|
14
|
+
- ๐จ Syntax highlighting for code blocks via `highlight.js`
|
|
15
|
+
- ๐จ๏ธ High-fidelity PDF output through headless Chrome
|
|
16
|
+
- ๐ Batch conversion with glob patterns, reusing one browser for speed
|
|
17
|
+
- ๐ `--watch` mode that re-renders on every save
|
|
18
|
+
- ๐งพ Plain-text files rendered verbatim in monospace
|
|
19
|
+
|
|
20
|
+
## Install
|
|
21
|
+
|
|
22
|
+
Run it on demand without installing:
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
npx @pablo_clueless/printr report.md
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Or install globally to get the `printr` command everywhere:
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
npm install -g @pablo_clueless/printr
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
> **Chromium download:** on first install Puppeteer downloads a matching
|
|
35
|
+
> Chromium build (~150 MB). To reuse an existing Chrome instead, set
|
|
36
|
+
> `PUPPETEER_EXECUTABLE_PATH` to its path and install with
|
|
37
|
+
> `PUPPETEER_SKIP_DOWNLOAD=1`.
|
|
38
|
+
|
|
39
|
+
**Requirements:** Node.js >= 18.
|
|
40
|
+
|
|
41
|
+
## Usage
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
# Single file โ writes report.pdf beside the source
|
|
45
|
+
printr report.md
|
|
46
|
+
|
|
47
|
+
# Explicit output path
|
|
48
|
+
printr report.md -o ~/Desktop/report.pdf
|
|
49
|
+
|
|
50
|
+
# Batch convert with a glob, into one folder
|
|
51
|
+
printr "docs/**/*.md" -d out/
|
|
52
|
+
|
|
53
|
+
# Letter paper, tighter margins
|
|
54
|
+
printr notes.txt -f Letter -m 15mm
|
|
55
|
+
|
|
56
|
+
# Watch and re-render on every save (Ctrl+C to stop)
|
|
57
|
+
printr "docs/**/*.md" -d out/ --watch
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Quote glob patterns (`"docs/**/*.md"`) so your shell passes them to `printr`
|
|
61
|
+
rather than expanding them itself.
|
|
62
|
+
|
|
63
|
+
## Options
|
|
64
|
+
|
|
65
|
+
| Flag | Description | Default |
|
|
66
|
+
| ---- | ----------- | ------- |
|
|
67
|
+
| `-o, --output <file>` | Output PDF path (single input only) | beside source |
|
|
68
|
+
| `-d, --out-dir <dir>` | Folder for output PDFs | source folder |
|
|
69
|
+
| `-f, --format <fmt>` | Paper format: `A4`, `Letter`, `Legal`, โฆ | `A4` |
|
|
70
|
+
| `-m, --margin <size>` | Page margin on all sides, e.g. `20mm`, `1in` | `20mm` |
|
|
71
|
+
| `-t, --title <title>` | Document title (single input only) | filename |
|
|
72
|
+
| `-w, --watch` | Re-render whenever an input file changes | off |
|
|
73
|
+
| `-h, --help` | Show help | |
|
|
74
|
+
|
|
75
|
+
## Supported inputs
|
|
76
|
+
|
|
77
|
+
- **Markdown:** `.md`, `.markdown`, `.mdown`, `.mkd`
|
|
78
|
+
- **Plain text:** anything else is rendered verbatim in monospace.
|
|
79
|
+
|
|
80
|
+
## Examples
|
|
81
|
+
|
|
82
|
+
A ready-to-try sample lives in [`examples/`](examples/):
|
|
83
|
+
|
|
84
|
+
```bash
|
|
85
|
+
printr examples/sample.md -d examples/out/
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
## Contributing
|
|
89
|
+
|
|
90
|
+
```bash
|
|
91
|
+
git clone https://github.com/pablo-clueless/printr.git
|
|
92
|
+
cd printr
|
|
93
|
+
npm install # installs deps and downloads Chromium
|
|
94
|
+
npm run dev -- examples/sample.md # run from source without building
|
|
95
|
+
npm run build # compile TypeScript to dist/
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
Issues and pull requests are welcome. See [CHANGELOG.md](CHANGELOG.md) for the
|
|
99
|
+
release history.
|
|
100
|
+
|
|
101
|
+
## License
|
|
102
|
+
|
|
103
|
+
[MIT](LICENSE)
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { renderFileToPdf } from "./convert.js";
|
|
3
|
+
import { writeFile, mkdir, stat } from "node:fs/promises";
|
|
4
|
+
import puppeteer from "puppeteer";
|
|
5
|
+
import { watch } from "node:fs";
|
|
6
|
+
import { Command } from "commander";
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
import { glob } from "glob";
|
|
9
|
+
const program = new Command();
|
|
10
|
+
program
|
|
11
|
+
.name("printr")
|
|
12
|
+
.description("Print Markdown and text files as nicely styled PDFs.")
|
|
13
|
+
.argument("<inputs...>", "files or globs to convert (.md, .markdown, .txt, โฆ)")
|
|
14
|
+
.option("-o, --output <file>", "output PDF path (single input only)")
|
|
15
|
+
.option("-d, --out-dir <dir>", "directory for output PDFs (defaults beside each source)")
|
|
16
|
+
.option("-f, --format <format>", "paper format: A4, Letter, Legal, โฆ", "A4")
|
|
17
|
+
.option("-m, --margin <size>", "page margin on all sides, e.g. 20mm or 1in", "20mm")
|
|
18
|
+
.option("-t, --title <title>", "document title (single input only)")
|
|
19
|
+
.option("-w, --watch", "watch inputs and re-render on change (Ctrl+C to stop)")
|
|
20
|
+
.showHelpAfterError()
|
|
21
|
+
.action(async (inputs, opts) => {
|
|
22
|
+
try {
|
|
23
|
+
await run(inputs, opts);
|
|
24
|
+
}
|
|
25
|
+
catch (err) {
|
|
26
|
+
console.error(`printr: ${err.message}`);
|
|
27
|
+
process.exitCode = 1;
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
async function resolveInputs(inputs) {
|
|
31
|
+
const resolved = new Set();
|
|
32
|
+
for (const input of inputs) {
|
|
33
|
+
// A literal existing path should be used as-is (handles names with glob chars).
|
|
34
|
+
const isFile = await stat(input).then((s) => s.isFile()).catch(() => false);
|
|
35
|
+
if (isFile) {
|
|
36
|
+
resolved.add(path.resolve(input));
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
const matches = await glob(input, { nodir: true, windowsPathsNoEscape: true });
|
|
40
|
+
for (const m of matches)
|
|
41
|
+
resolved.add(path.resolve(m));
|
|
42
|
+
}
|
|
43
|
+
return [...resolved].sort();
|
|
44
|
+
}
|
|
45
|
+
function outputPathFor(file, opts) {
|
|
46
|
+
if (opts.output)
|
|
47
|
+
return path.resolve(opts.output);
|
|
48
|
+
const base = path.basename(file, path.extname(file)) + ".pdf";
|
|
49
|
+
const dir = opts.outDir ? path.resolve(opts.outDir) : path.dirname(file);
|
|
50
|
+
return path.join(dir, base);
|
|
51
|
+
}
|
|
52
|
+
/** Render a single source file and write its PDF, logging the result. */
|
|
53
|
+
async function renderOne(browser, file, opts, renderOpts) {
|
|
54
|
+
const out = outputPathFor(file, opts);
|
|
55
|
+
await mkdir(path.dirname(out), { recursive: true });
|
|
56
|
+
const pdf = await renderFileToPdf(browser, file, renderOpts);
|
|
57
|
+
await writeFile(out, pdf);
|
|
58
|
+
console.log(`${path.relative(process.cwd(), file)} โ ${path.relative(process.cwd(), out)}`);
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* For a single input pattern, determine which directory to watch and whether
|
|
62
|
+
* it must be watched recursively. The watch root is the leading portion of the
|
|
63
|
+
* pattern before the first segment containing glob magic.
|
|
64
|
+
*/
|
|
65
|
+
function watchRootFor(input) {
|
|
66
|
+
const recursive = input.includes("**");
|
|
67
|
+
const parts = input.split(/[\\/]/);
|
|
68
|
+
const base = [];
|
|
69
|
+
for (const part of parts) {
|
|
70
|
+
if (/[*?[\]{}!()+@]/.test(part))
|
|
71
|
+
break;
|
|
72
|
+
base.push(part);
|
|
73
|
+
}
|
|
74
|
+
const basePath = base.length ? base.join(path.sep) : ".";
|
|
75
|
+
return { dir: path.resolve(basePath), recursive };
|
|
76
|
+
}
|
|
77
|
+
async function run(inputs, opts) {
|
|
78
|
+
const files = await resolveInputs(inputs);
|
|
79
|
+
if (files.length === 0) {
|
|
80
|
+
throw new Error("no matching files found");
|
|
81
|
+
}
|
|
82
|
+
if (opts.output && files.length > 1) {
|
|
83
|
+
throw new Error("--output can only be used with a single input file");
|
|
84
|
+
}
|
|
85
|
+
if (opts.title && files.length > 1) {
|
|
86
|
+
throw new Error("--title can only be used with a single input file");
|
|
87
|
+
}
|
|
88
|
+
const renderOpts = {
|
|
89
|
+
format: opts.format,
|
|
90
|
+
margin: opts.margin,
|
|
91
|
+
title: opts.title,
|
|
92
|
+
};
|
|
93
|
+
const browser = await puppeteer.launch({
|
|
94
|
+
headless: true,
|
|
95
|
+
args: ["--no-sandbox", "--disable-setuid-sandbox"],
|
|
96
|
+
});
|
|
97
|
+
// Initial render of everything that currently matches.
|
|
98
|
+
for (const file of files) {
|
|
99
|
+
await renderOne(browser, file, opts, renderOpts);
|
|
100
|
+
}
|
|
101
|
+
console.log(`Done. Converted ${files.length} file${files.length === 1 ? "" : "s"}.`);
|
|
102
|
+
if (!opts.watch) {
|
|
103
|
+
await browser.close();
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
await startWatch(browser, inputs, opts, renderOpts);
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Watch the directories backing each input pattern and re-render a file
|
|
110
|
+
* whenever it changes. Re-resolving the patterns on each event means newly
|
|
111
|
+
* created files that match a glob are picked up too. The browser is kept open
|
|
112
|
+
* for the lifetime of the watch.
|
|
113
|
+
*/
|
|
114
|
+
async function startWatch(browser, inputs, opts, renderOpts) {
|
|
115
|
+
// Deduplicate watch roots; a recursive root supersedes a non-recursive one
|
|
116
|
+
// for the same directory.
|
|
117
|
+
const roots = new Map();
|
|
118
|
+
for (const input of inputs) {
|
|
119
|
+
const isFile = await stat(input).then((s) => s.isFile()).catch(() => false);
|
|
120
|
+
const { dir, recursive } = isFile
|
|
121
|
+
? { dir: path.dirname(path.resolve(input)), recursive: false }
|
|
122
|
+
: watchRootFor(input);
|
|
123
|
+
roots.set(dir, (roots.get(dir) ?? false) || recursive);
|
|
124
|
+
}
|
|
125
|
+
const watchers = [];
|
|
126
|
+
const debounce = new Map();
|
|
127
|
+
let rendering = Promise.resolve();
|
|
128
|
+
const handleChange = (root, filename) => {
|
|
129
|
+
if (!filename)
|
|
130
|
+
return;
|
|
131
|
+
const full = path.resolve(root, filename);
|
|
132
|
+
const prev = debounce.get(full);
|
|
133
|
+
if (prev)
|
|
134
|
+
clearTimeout(prev);
|
|
135
|
+
debounce.set(full, setTimeout(() => {
|
|
136
|
+
debounce.delete(full);
|
|
137
|
+
// Serialize renders so concurrent saves don't open many pages at once.
|
|
138
|
+
rendering = rendering.then(async () => {
|
|
139
|
+
const matched = await resolveInputs(inputs);
|
|
140
|
+
if (!matched.includes(full))
|
|
141
|
+
return; // not one of our inputs
|
|
142
|
+
const exists = await stat(full).then((s) => s.isFile()).catch(() => false);
|
|
143
|
+
if (!exists)
|
|
144
|
+
return; // file was deleted mid-edit
|
|
145
|
+
try {
|
|
146
|
+
await renderOne(browser, full, opts, renderOpts);
|
|
147
|
+
}
|
|
148
|
+
catch (err) {
|
|
149
|
+
console.error(`printr: failed to render ${filename}: ${err.message}`);
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
}, 120));
|
|
153
|
+
};
|
|
154
|
+
for (const [dir, recursive] of roots) {
|
|
155
|
+
try {
|
|
156
|
+
const w = watch(dir, { recursive }, (_event, filename) => handleChange(dir, filename));
|
|
157
|
+
watchers.push(w);
|
|
158
|
+
console.log(`Watching ${path.relative(process.cwd(), dir) || "."}${recursive ? " (recursive)" : ""} โฆ`);
|
|
159
|
+
}
|
|
160
|
+
catch (err) {
|
|
161
|
+
console.error(`printr: cannot watch ${dir}: ${err.message}`);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
console.log("Press Ctrl+C to stop.");
|
|
165
|
+
// Keep the process alive until interrupted, then clean up.
|
|
166
|
+
await new Promise((resolve) => {
|
|
167
|
+
const shutdown = () => {
|
|
168
|
+
for (const w of watchers)
|
|
169
|
+
w.close();
|
|
170
|
+
browser.close().finally(() => resolve());
|
|
171
|
+
};
|
|
172
|
+
process.once("SIGINT", shutdown);
|
|
173
|
+
process.once("SIGTERM", shutdown);
|
|
174
|
+
});
|
|
175
|
+
console.log("\nStopped.");
|
|
176
|
+
}
|
|
177
|
+
program.parseAsync();
|
package/dist/convert.js
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import MarkdownIt from "markdown-it";
|
|
3
|
+
import hljs from "highlight.js";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { githubMarkdownCss } from "./styles.js";
|
|
6
|
+
const MARKDOWN_EXTS = new Set([".md", ".markdown", ".mdown", ".mkd"]);
|
|
7
|
+
const md = new MarkdownIt({
|
|
8
|
+
html: true,
|
|
9
|
+
linkify: true,
|
|
10
|
+
typographer: true,
|
|
11
|
+
highlight(code, lang) {
|
|
12
|
+
if (lang && hljs.getLanguage(lang)) {
|
|
13
|
+
try {
|
|
14
|
+
return hljs.highlight(code, { language: lang, ignoreIllegals: true })
|
|
15
|
+
.value;
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
/* fall through to auto */
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
return hljs.highlightAuto(code).value;
|
|
22
|
+
},
|
|
23
|
+
});
|
|
24
|
+
function escapeHtml(s) {
|
|
25
|
+
return s
|
|
26
|
+
.replace(/&/g, "&")
|
|
27
|
+
.replace(/</g, "<")
|
|
28
|
+
.replace(/>/g, ">");
|
|
29
|
+
}
|
|
30
|
+
/** Build the full self-contained HTML document for a source file. */
|
|
31
|
+
export function buildHtml(source, filePath, options = {}) {
|
|
32
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
33
|
+
const isMarkdown = MARKDOWN_EXTS.has(ext);
|
|
34
|
+
const title = options.title ?? path.basename(filePath);
|
|
35
|
+
const bodyHtml = isMarkdown
|
|
36
|
+
? md.render(source)
|
|
37
|
+
: // Plain text: preserve it verbatim inside a code block.
|
|
38
|
+
`<pre class="plain-text">${escapeHtml(source)}</pre>`;
|
|
39
|
+
return `<!DOCTYPE html>
|
|
40
|
+
<html lang="en">
|
|
41
|
+
<head>
|
|
42
|
+
<meta charset="utf-8">
|
|
43
|
+
<title>${escapeHtml(title)}</title>
|
|
44
|
+
<style>${githubMarkdownCss}
|
|
45
|
+
.plain-text { background: transparent; padding: 0; font-size: 0.9em; }
|
|
46
|
+
${options.extraCss ?? ""}</style>
|
|
47
|
+
</head>
|
|
48
|
+
<body><article class="markdown-body">${bodyHtml}</article></body>
|
|
49
|
+
</html>`;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Render a single source file to a PDF buffer using an already-launched
|
|
53
|
+
* browser. Reusing the browser across files keeps batch conversion fast.
|
|
54
|
+
*/
|
|
55
|
+
export async function renderFileToPdf(browser, filePath, options = {}) {
|
|
56
|
+
const source = await readFile(filePath, "utf8");
|
|
57
|
+
const html = buildHtml(source, filePath, options);
|
|
58
|
+
const page = await browser.newPage();
|
|
59
|
+
try {
|
|
60
|
+
await page.setContent(html, { waitUntil: "load" });
|
|
61
|
+
const margin = options.margin ?? "20mm";
|
|
62
|
+
return await page.pdf({
|
|
63
|
+
format: (options.format ?? "A4"),
|
|
64
|
+
printBackground: true,
|
|
65
|
+
margin: { top: margin, bottom: margin, left: margin, right: margin },
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
finally {
|
|
69
|
+
await page.close();
|
|
70
|
+
}
|
|
71
|
+
}
|
package/dist/styles.js
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GitHub-flavored Markdown stylesheet plus a highlight.js theme, scoped for
|
|
3
|
+
* print. Kept inline so the produced HTML is fully self-contained and Chrome
|
|
4
|
+
* never has to fetch external resources while rendering the PDF.
|
|
5
|
+
*/
|
|
6
|
+
export const githubMarkdownCss = `
|
|
7
|
+
:root {
|
|
8
|
+
--fg: #1f2328;
|
|
9
|
+
--muted: #59636e;
|
|
10
|
+
--border: #d1d9e0;
|
|
11
|
+
--border-muted: #d1d9e0b3;
|
|
12
|
+
--accent: #0969da;
|
|
13
|
+
--code-bg: #f6f8fa;
|
|
14
|
+
--code-fg: #1f2328;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
* { box-sizing: border-box; }
|
|
18
|
+
|
|
19
|
+
body {
|
|
20
|
+
color: var(--fg);
|
|
21
|
+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans",
|
|
22
|
+
Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji";
|
|
23
|
+
font-size: 11pt;
|
|
24
|
+
line-height: 1.55;
|
|
25
|
+
word-wrap: break-word;
|
|
26
|
+
margin: 0;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
.markdown-body > *:first-child { margin-top: 0 !important; }
|
|
30
|
+
.markdown-body > *:last-child { margin-bottom: 0 !important; }
|
|
31
|
+
|
|
32
|
+
h1, h2, h3, h4, h5, h6 {
|
|
33
|
+
margin-top: 1.4em;
|
|
34
|
+
margin-bottom: 0.6em;
|
|
35
|
+
font-weight: 600;
|
|
36
|
+
line-height: 1.25;
|
|
37
|
+
}
|
|
38
|
+
h1 { font-size: 1.9em; padding-bottom: 0.3em; border-bottom: 1px solid var(--border-muted); }
|
|
39
|
+
h2 { font-size: 1.45em; padding-bottom: 0.3em; border-bottom: 1px solid var(--border-muted); }
|
|
40
|
+
h3 { font-size: 1.2em; }
|
|
41
|
+
h4 { font-size: 1em; }
|
|
42
|
+
h5 { font-size: 0.9em; }
|
|
43
|
+
h6 { font-size: 0.85em; color: var(--muted); }
|
|
44
|
+
|
|
45
|
+
p, blockquote, ul, ol, dl, table, pre { margin-top: 0; margin-bottom: 1em; }
|
|
46
|
+
|
|
47
|
+
a { color: var(--accent); text-decoration: none; }
|
|
48
|
+
a:hover { text-decoration: underline; }
|
|
49
|
+
|
|
50
|
+
blockquote {
|
|
51
|
+
padding: 0 1em;
|
|
52
|
+
color: var(--muted);
|
|
53
|
+
border-left: 0.25em solid var(--border);
|
|
54
|
+
margin-left: 0;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
ul, ol { padding-left: 2em; }
|
|
58
|
+
li + li { margin-top: 0.25em; }
|
|
59
|
+
li > p { margin-top: 0.5em; }
|
|
60
|
+
|
|
61
|
+
code, kbd, pre, samp {
|
|
62
|
+
font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas,
|
|
63
|
+
"Liberation Mono", monospace;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
code {
|
|
67
|
+
font-size: 0.88em;
|
|
68
|
+
background: var(--code-bg);
|
|
69
|
+
padding: 0.2em 0.4em;
|
|
70
|
+
border-radius: 6px;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
pre {
|
|
74
|
+
padding: 1em;
|
|
75
|
+
overflow: auto;
|
|
76
|
+
font-size: 0.85em;
|
|
77
|
+
line-height: 1.45;
|
|
78
|
+
background: var(--code-bg);
|
|
79
|
+
border-radius: 6px;
|
|
80
|
+
white-space: pre-wrap;
|
|
81
|
+
word-break: break-word;
|
|
82
|
+
}
|
|
83
|
+
pre code {
|
|
84
|
+
background: transparent;
|
|
85
|
+
padding: 0;
|
|
86
|
+
font-size: inherit;
|
|
87
|
+
border-radius: 0;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
table {
|
|
91
|
+
border-collapse: collapse;
|
|
92
|
+
display: block;
|
|
93
|
+
width: max-content;
|
|
94
|
+
max-width: 100%;
|
|
95
|
+
overflow: auto;
|
|
96
|
+
}
|
|
97
|
+
table th, table td { padding: 6px 13px; border: 1px solid var(--border); }
|
|
98
|
+
table th { font-weight: 600; }
|
|
99
|
+
table tr:nth-child(2n) { background: var(--code-bg); }
|
|
100
|
+
|
|
101
|
+
img { max-width: 100%; }
|
|
102
|
+
|
|
103
|
+
hr {
|
|
104
|
+
height: 1px;
|
|
105
|
+
border: 0;
|
|
106
|
+
background: var(--border);
|
|
107
|
+
margin: 1.6em 0;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/* Page-break behavior for print */
|
|
111
|
+
h1, h2, h3, h4, h5, h6 { break-after: avoid-page; }
|
|
112
|
+
pre, blockquote, table, img { break-inside: avoid; }
|
|
113
|
+
|
|
114
|
+
/* highlight.js โ GitHub light theme */
|
|
115
|
+
.hljs { color: var(--code-fg); background: var(--code-bg); }
|
|
116
|
+
.hljs-comment, .hljs-quote { color: #6a737d; }
|
|
117
|
+
.hljs-keyword, .hljs-selector-tag, .hljs-doctag, .hljs-type, .hljs-name, .hljs-strong { color: #d73a49; }
|
|
118
|
+
.hljs-literal, .hljs-number, .hljs-variable, .hljs-template-variable, .hljs-tag .hljs-attr { color: #005cc5; }
|
|
119
|
+
.hljs-string, .hljs-regexp, .hljs-addition, .hljs-attribute, .hljs-meta .hljs-string { color: #032f62; }
|
|
120
|
+
.hljs-title, .hljs-section, .hljs-built_in, .hljs-title.class_, .hljs-title.function_ { color: #6f42c1; }
|
|
121
|
+
.hljs-attr, .hljs-attribute, .hljs-symbol, .hljs-bullet, .hljs-link { color: #e36209; }
|
|
122
|
+
.hljs-emphasis { font-style: italic; }
|
|
123
|
+
.hljs-strong { font-weight: 600; }
|
|
124
|
+
`;
|
package/package.json
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@pablo_clueless/printr",
|
|
3
|
+
"version": "0.1.2",
|
|
4
|
+
"description": "Print Markdown and text files as nicely styled PDFs.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": "smsnmicheal <smsnmicheal@gmail.com>",
|
|
8
|
+
"homepage": "https://github.com/pablo-clueless/printr#readme",
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "git+https://github.com/pablo-clueless/printr.git"
|
|
12
|
+
},
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/pablo-clueless/printr/issues"
|
|
15
|
+
},
|
|
16
|
+
"keywords": [
|
|
17
|
+
"markdown",
|
|
18
|
+
"pdf",
|
|
19
|
+
"cli",
|
|
20
|
+
"puppeteer",
|
|
21
|
+
"markdown-to-pdf",
|
|
22
|
+
"print",
|
|
23
|
+
"highlight.js",
|
|
24
|
+
"github-markdown"
|
|
25
|
+
],
|
|
26
|
+
"bin": {
|
|
27
|
+
"printr": "dist/cli.js"
|
|
28
|
+
},
|
|
29
|
+
"publishConfig": {
|
|
30
|
+
"access": "public"
|
|
31
|
+
},
|
|
32
|
+
"files": [
|
|
33
|
+
"dist",
|
|
34
|
+
"README.md",
|
|
35
|
+
"CHANGELOG.md",
|
|
36
|
+
"LICENSE"
|
|
37
|
+
],
|
|
38
|
+
"scripts": {
|
|
39
|
+
"build": "tsc",
|
|
40
|
+
"dev": "tsx src/cli.ts",
|
|
41
|
+
"start": "node dist/cli.js",
|
|
42
|
+
"prepublishOnly": "npm run build"
|
|
43
|
+
},
|
|
44
|
+
"engines": {
|
|
45
|
+
"node": ">=18"
|
|
46
|
+
},
|
|
47
|
+
"dependencies": {
|
|
48
|
+
"commander": "^12.1.0",
|
|
49
|
+
"glob": "^11.0.0",
|
|
50
|
+
"highlight.js": "^11.10.0",
|
|
51
|
+
"markdown-it": "^14.1.0",
|
|
52
|
+
"puppeteer": "^24.15.0"
|
|
53
|
+
},
|
|
54
|
+
"devDependencies": {
|
|
55
|
+
"@types/markdown-it": "^14.1.2",
|
|
56
|
+
"@types/node": "^22.0.0",
|
|
57
|
+
"tsx": "^4.19.0",
|
|
58
|
+
"typescript": "^5.6.0"
|
|
59
|
+
}
|
|
60
|
+
}
|