@davidsneighbour/draft-pipeline 0.1.1-rc.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/.env.example ADDED
@@ -0,0 +1,27 @@
1
+ # Markdown source directory (recursive search: **/*.md, **/*.markdown)
2
+ MARKDOWN_INPUT_DIR=./book
3
+
4
+ # Build output directory for css + generated PDFs
5
+ OUTPUT_DIR=./dist
6
+ OUTPUT_CSS_FILE=./dist/output.css
7
+
8
+ # Tailwind input CSS entrypoint
9
+ TAILWIND_INPUT_CSS=./styles/pdf.css
10
+
11
+ # PDF rendering templates
12
+ HEADER_TEMPLATE_PATH=./templates/header.html
13
+ FOOTER_TEMPLATE_PATH=./templates/footer.html
14
+ DOCUMENT_TEMPLATE_PATH=./templates/document.html
15
+ BOOK_LAYOUT_CSS_PATH=./styles/pdf-book-layout.css
16
+
17
+ # Upload behavior
18
+ REMARKABLE_UPLOAD_ENABLED=false
19
+ REMARKABLE_HOST=remarkable
20
+ REMARKABLE_XOCHITL_DIR=.local/share/remarkable/xochitl
21
+
22
+ # Target location in reMarkable xochitl metadata
23
+ # Required when upload is enabled
24
+ REMARKABLE_PARENT_FOLDER_UUID=
25
+
26
+ # Optional: only for human-readable documentation
27
+ REMARKABLE_PARENT_FOLDER_NAME=Book of Hugo
package/.release-it.ts ADDED
@@ -0,0 +1,85 @@
1
+ import type { Config } from 'release-it';
2
+
3
+ const config = {
4
+ npm: {
5
+ publish: true,
6
+ },
7
+ git: {
8
+ requireCleanWorkingDir: true,
9
+ commit: true,
10
+ commitMessage: 'chore(release): v${version}',
11
+ tag: true,
12
+ tagName: 'v${version}',
13
+ push: true,
14
+ pushArgs: ['--follow-tags'],
15
+ },
16
+ github: {
17
+ release: true,
18
+ releaseName: 'v${version}',
19
+ skipChecks: true,
20
+ tokenRef: 'GITHUB_TOKEN_DEV',
21
+ },
22
+ plugins: {
23
+ '@release-it/conventional-changelog': {
24
+ infile: 'CHANGELOG.md',
25
+ preset: {
26
+ name: 'conventionalcommits',
27
+ types: [
28
+ { type: 'content', section: 'Content' },
29
+ { type: 'feat', section: 'Features' },
30
+ { type: 'fix', section: 'Bug Fixes' },
31
+ { type: 'build', section: 'Other Changes' },
32
+ { type: 'chore', section: 'Other Changes' },
33
+ { type: 'ci', section: 'Other Changes' },
34
+ { type: 'docs', section: 'Other Changes' },
35
+ { type: 'perf', section: 'Other Changes' },
36
+ { type: 'refactor', section: 'Other Changes' },
37
+ { type: 'revert', section: 'Other Changes' },
38
+ { type: 'style', section: 'Other Changes' },
39
+ { type: 'test', section: 'Other Changes' },
40
+ ],
41
+ },
42
+ whatBump(commits: Array<{ type?: string; notes?: unknown[] }>) {
43
+ let level: 2 | 1 | 0 | null = null;
44
+
45
+ for (const commit of commits) {
46
+ const notes = Array.isArray(commit.notes) ? commit.notes : [];
47
+ const type = typeof commit.type === 'string' ? commit.type : '';
48
+
49
+ if (notes.length > 0) {
50
+ return {
51
+ level: 0,
52
+ reason: 'There are BREAKING CHANGES.',
53
+ };
54
+ }
55
+
56
+ if (type === 'feat' || type === 'content') {
57
+ level = 1;
58
+ continue;
59
+ }
60
+
61
+ if (
62
+ level === null &&
63
+ ['fix', 'build', 'chore', 'ci', 'docs', 'perf', 'refactor', 'revert', 'style', 'test'].includes(type)
64
+ ) {
65
+ level = 2;
66
+ }
67
+ }
68
+
69
+ if (level === null) {
70
+ return false;
71
+ }
72
+
73
+ return {
74
+ level,
75
+ reason:
76
+ level === 1
77
+ ? 'There are feat/content commits.'
78
+ : 'There are patch-level changes.',
79
+ };
80
+ }
81
+ },
82
+ },
83
+ } satisfies Config;
84
+
85
+ export default config;
package/CHANGELOG.md ADDED
@@ -0,0 +1,20 @@
1
+ # Changelog
2
+
3
+ ## [0.1.1-rc.0](https://github.com/davidsneighbour/draft-pipeline/compare/v0.0.1...v0.1.1-rc.0) (2026-03-15)
4
+
5
+ ### Other Changes
6
+
7
+ * **fix:** enable npm package publishing ([267e186](https://github.com/davidsneighbour/draft-pipeline/commit/267e18692536dc3ea7c978bee7805de8c7336a11))
8
+ * **fix:** make this package public ([41559ea](https://github.com/davidsneighbour/draft-pipeline/commit/41559eaa2c84b4d60c3fefd6ce38a1a8ea2fac29))
9
+
10
+ ## 0.0.1 (2026-03-15)
11
+
12
+ ### Other Changes
13
+
14
+ * add release setup via release-it ([3cc45e1](https://github.com/davidsneighbour/draft-pipeline/commit/3cc45e1d55dc01a7f5f1514fb30a1b66f8baa284))
15
+ * extend todo list ([4aa0ecf](https://github.com/davidsneighbour/draft-pipeline/commit/4aa0ecfa2806374ea914e7bcb46baff3f69d8c2c))
16
+ * extend todo list ([3bd13c6](https://github.com/davidsneighbour/draft-pipeline/commit/3bd13c64a5769e155d2d9b78b08acba186e24fe2))
17
+ * initial commit, current state ([8b30599](https://github.com/davidsneighbour/draft-pipeline/commit/8b30599978fdd9e044c473a40ba137a49401e6e3))
18
+ * initial commit, license file added ([57438c2](https://github.com/davidsneighbour/draft-pipeline/commit/57438c27781d05f745335c112b2996ec0279ec7b))
19
+
20
+ All notable changes to this project will be documented in this file.
package/LICENSE.md ADDED
@@ -0,0 +1,9 @@
1
+ # MIT License
2
+
3
+ Copyright (c) 2026 Patrick Kollitsch
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
6
+
7
+ The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
8
+
9
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,85 @@
1
+ # draft-pipeline
2
+
3
+ A standalone package extracted from the `bookofhugo.dev` toolchain for:
4
+
5
+ 1. Markdown → print-style PDF generation.
6
+ 2. Tailwind CSS compilation for PDF styling.
7
+ 3. Optional upload of built PDFs to a reMarkable tablet over SSH.
8
+
9
+ ## Extracted requirements and procedures
10
+
11
+ ### Markdown to PDF requirements
12
+
13
+ - Input is a directory tree containing Markdown (`.md`, `.markdown`).
14
+ - Front matter is read with `gray-matter`.
15
+ - Files with `b/pdf/ignore: true` are skipped.
16
+ - Markdown content is rendered via `marked`.
17
+ - PDF generation runs through headless Playwright Chromium.
18
+ - Header/footer/document HTML templates are applied with token replacement.
19
+ - Output PDF names are flattened/sanitized from source path segments.
20
+
21
+ ### Upload to reMarkable requirements
22
+
23
+ - SSH access to a configured reMarkable host (`REMARKABLE_HOST`).
24
+ - Target xochitl data directory (`REMARKABLE_XOCHITL_DIR`).
25
+ - Folder UUID where documents should be attached (`REMARKABLE_PARENT_FOLDER_UUID`).
26
+ - Upload is explicitly toggleable with `REMARKABLE_UPLOAD_ENABLED=true|false`.
27
+
28
+ ### Procedure flow
29
+
30
+ 1. Build Tailwind CSS from `TAILWIND_INPUT_CSS` to `OUTPUT_CSS_FILE`.
31
+ 2. Convert Markdown files from `MARKDOWN_INPUT_DIR` into PDFs in `OUTPUT_DIR`.
32
+ 3. If upload is enabled, upload generated PDFs to reMarkable and restart xochitl.
33
+
34
+ ## Setup
35
+
36
+ ```bash
37
+ npm install
38
+ cp .env.example .env
39
+ ```
40
+
41
+ Edit `.env` as needed.
42
+
43
+ ## Commands
44
+
45
+ ```bash
46
+ npm run css # compile tailwind css
47
+ npm run pdf # generate PDFs only
48
+ npm run upload # upload only (if enabled)
49
+ npm run build # css + pdf + upload
50
+ npm run release # create a stable release (git tag + npm + GitHub)
51
+ npm run release:pre # create an rc pre-release
52
+ ```
53
+
54
+ Or with the CLI:
55
+
56
+ ```bash
57
+ node src/cli.mjs build
58
+ ```
59
+
60
+ ## Configuration defaults
61
+
62
+ Defaults are designed for a repository that mirrors this package structure.
63
+
64
+ - Templates:
65
+ - `./templates/header.html`
66
+ - `./templates/footer.html`
67
+ - `./templates/document.html`
68
+ - Tailwind CSS:
69
+ - input `./styles/pdf.css`
70
+ - output `./dist/output.css`
71
+ - Markdown input: `./book`
72
+ - PDF output: `./dist`
73
+ - Upload disabled by default.
74
+ - reMarkable host default: `remarkable`.
75
+
76
+ ## Graceful errors
77
+
78
+ The package intentionally fails with direct explanations when:
79
+
80
+ - input/template/css files are not readable,
81
+ - no markdown files are found,
82
+ - upload is enabled but host is unreachable,
83
+ - upload is enabled but folder UUID is missing,
84
+ - no PDFs are available for upload,
85
+ - Tailwind build command fails.
package/ToDo.md ADDED
@@ -0,0 +1,57 @@
1
+ ## ToDo
2
+
3
+ ### .env setup
4
+
5
+ - [ ] create a way to setup a custom `.pipeline.env` with demo content
6
+ - [ ] set `.pipeline.env` as default env file and add a CLI parameter to set path to .env file
7
+
8
+ ### Override templates
9
+
10
+ - [ ] add options to override each individual template with local path
11
+
12
+ ### Add print ready PDF output
13
+
14
+ - [ ] add bleeds to PDF output and make it configurable (only when CLI parameter `--printready` is added)
15
+
16
+ ### Deprecate dotenv usage
17
+
18
+ - [ ] require Node version that supports native .env integration
19
+ - [ ] use native .env integration instead of dotenv
20
+
21
+ ### Add release pipeline via release-it
22
+
23
+ - [x] add release-it configuration
24
+ - [x] add `npm run release` script using release-it config
25
+ - [x] add npm package release
26
+ - [x] add github release
27
+ - [x] add conventional commits changelog generation via release-it plugin
28
+ - [x] add `npm run release:pre` to create pre-release packages
29
+
30
+ ### Fix/Add config order
31
+
32
+ - [ ] make sure that all configuration can be done via .env file AND config file AND CLI parameters (overrides in that order)
33
+ - [ ] make debuggable from where we receive the configuration (env, config, cli)
34
+ - [ ] make config debuggable (output list or json)
35
+
36
+ ### Documentation
37
+
38
+ - [ ] add section explaining how to configure (env vs. config vs. cli)
39
+ - [ ] add section explaining all parameters (CLI, add env and config names as note to each parameter)
40
+
41
+ ### Sensible configuration defaults
42
+
43
+ - [ ] add and document sensible configuration defaults for all options
44
+
45
+ ### Isolate reMarkable integration
46
+
47
+ - [ ] explain in docs that reMarkable has a system of UUID and meta data files, that this is created on the fly and then uploaded via SSH to the reMarkable
48
+ - [ ] add links to official docs or explanations how to setup for SSH access (cable required, host name configuration via /etc/hosts or IP address required)
49
+ - [ ] isolate the remarkable upload into it's on little plugin/system and enable/disable via config option
50
+ - [ ] requires explicit enable/disable via config option
51
+
52
+ ### Add SSH integration
53
+
54
+ - [ ] enable upload via ssh to any location (add config parameters)
55
+ - [ ] explore if this should/could be done via rsync so we have more target options for this
56
+ - [ ] add in parallel to the remarkable setup
57
+ - [ ] requires explicit enable/disable via config option
package/package.json ADDED
@@ -0,0 +1,34 @@
1
+ {
2
+ "name": "@davidsneighbour/draft-pipeline",
3
+ "version": "0.1.1-rc.0",
4
+ "description": "Reusable markdown-to-pdf and optional reMarkable upload toolkit",
5
+ "type": "module",
6
+ "bin": {
7
+ "draft-pipeline": "./src/cli.mjs"
8
+ },
9
+ "scripts": {
10
+ "css": "node ./src/cli.mjs css",
11
+ "pdf": "node ./src/cli.mjs pdf",
12
+ "upload": "node ./src/cli.mjs upload",
13
+ "build": "node ./src/cli.mjs build",
14
+ "check": "node --check ./src/*.mjs",
15
+ "release": "release-it --config .release-it.ts",
16
+ "release:pre": "release-it --config .release-it.ts --preRelease=rc"
17
+ },
18
+ "dependencies": {
19
+ "@tailwindcss/cli": "4.2.1",
20
+ "dotenv": "17.3.1",
21
+ "fast-glob": "3.3.3",
22
+ "gray-matter": "4.0.3",
23
+ "marked": "17.0.4",
24
+ "playwright": "1.58.2",
25
+ "tailwindcss": "4.2.1"
26
+ },
27
+ "devDependencies": {
28
+ "@release-it/conventional-changelog": "10.0.6",
29
+ "release-it": "19.2.4"
30
+ },
31
+ "publishConfig": {
32
+ "access": "public"
33
+ }
34
+ }
@@ -0,0 +1,21 @@
1
+ import { mkdir } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { spawnSync } from 'node:child_process';
4
+ import { assertReadableFile } from './config.mjs';
5
+
6
+ export async function buildCss(config) {
7
+ await assertReadableFile(config.tailwindInputCss, 'Tailwind input CSS');
8
+ await mkdir(path.dirname(config.outputCssFile), { recursive: true });
9
+
10
+ const result = spawnSync(
11
+ 'npx',
12
+ ['@tailwindcss/cli', '-i', config.tailwindInputCss, '-o', config.outputCssFile],
13
+ { stdio: 'inherit' },
14
+ );
15
+
16
+ if (result.status !== 0) {
17
+ throw new Error(
18
+ 'Tailwind CSS build failed. Ensure @tailwindcss/cli is installed and TAILWIND_INPUT_CSS points to a valid file.',
19
+ );
20
+ }
21
+ }
package/src/cli.mjs ADDED
@@ -0,0 +1,60 @@
1
+ #!/usr/bin/env node
2
+ import process from 'node:process';
3
+ import { loadConfig } from './config.mjs';
4
+ import { buildCss } from './build-css.mjs';
5
+ import { renderMarkdownDirectory } from './md-to-pdf.mjs';
6
+ import { uploadToRemarkable } from './upload-remarkable.mjs';
7
+
8
+ function printHelp() {
9
+ console.log(`draft-pipeline - A tool to convert markdown files to PDFs and upload them to reMarkable.
10
+
11
+ Commands:
12
+ css Build Tailwind CSS
13
+ pdf Convert markdown files to PDFs
14
+ upload Upload generated PDFs to reMarkable (when enabled)
15
+ build Run css + pdf + upload
16
+
17
+ Configuration:
18
+ Configure via .env file (see .env.example).
19
+ `);
20
+ }
21
+
22
+ async function main() {
23
+ const command = process.argv[2] ?? 'build';
24
+
25
+ if (command === '--help' || command === '-h' || command === 'help') {
26
+ printHelp();
27
+ return;
28
+ }
29
+
30
+ const config = loadConfig(process.cwd());
31
+
32
+ if (command === 'css') {
33
+ await buildCss(config);
34
+ return;
35
+ }
36
+
37
+ if (command === 'pdf') {
38
+ await renderMarkdownDirectory(config, { verbose: true });
39
+ return;
40
+ }
41
+
42
+ if (command === 'upload') {
43
+ await uploadToRemarkable(config);
44
+ return;
45
+ }
46
+
47
+ if (command === 'build') {
48
+ await buildCss(config);
49
+ await renderMarkdownDirectory(config, { verbose: true });
50
+ await uploadToRemarkable(config);
51
+ return;
52
+ }
53
+
54
+ throw new Error(`Unknown command: ${command}. Run with --help.`);
55
+ }
56
+
57
+ main().catch((error) => {
58
+ console.error(`Error: ${error instanceof Error ? error.message : String(error)}`);
59
+ process.exitCode = 1;
60
+ });
package/src/config.mjs ADDED
@@ -0,0 +1,62 @@
1
+ import { access } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import process from 'node:process';
4
+ import { constants } from 'node:fs';
5
+ import { config as loadDotEnv } from 'dotenv';
6
+
7
+ const DEFAULTS = {
8
+ MARKDOWN_INPUT_DIR: './book',
9
+ OUTPUT_DIR: './dist',
10
+ OUTPUT_CSS_FILE: './dist/output.css',
11
+ TAILWIND_INPUT_CSS: './styles/pdf.css',
12
+ HEADER_TEMPLATE_PATH: './templates/header.html',
13
+ FOOTER_TEMPLATE_PATH: './templates/footer.html',
14
+ DOCUMENT_TEMPLATE_PATH: './templates/document.html',
15
+ BOOK_LAYOUT_CSS_PATH: './styles/pdf-book-layout.css',
16
+ REMARKABLE_UPLOAD_ENABLED: 'false',
17
+ REMARKABLE_HOST: 'remarkable',
18
+ REMARKABLE_XOCHITL_DIR: '.local/share/remarkable/xochitl',
19
+ REMARKABLE_PARENT_FOLDER_UUID: '',
20
+ REMARKABLE_PARENT_FOLDER_NAME: 'Book of Hugo',
21
+ };
22
+
23
+ export function loadConfig(cwd = process.cwd()) {
24
+ loadDotEnv({ path: path.join(cwd, '.env'), override: false });
25
+
26
+ const env = { ...DEFAULTS, ...process.env };
27
+
28
+ const config = {
29
+ cwd,
30
+ markdownInputDir: path.resolve(cwd, env.MARKDOWN_INPUT_DIR),
31
+ outputDir: path.resolve(cwd, env.OUTPUT_DIR),
32
+ outputCssFile: path.resolve(cwd, env.OUTPUT_CSS_FILE),
33
+ tailwindInputCss: path.resolve(cwd, env.TAILWIND_INPUT_CSS),
34
+ headerTemplatePath: path.resolve(cwd, env.HEADER_TEMPLATE_PATH),
35
+ footerTemplatePath: path.resolve(cwd, env.FOOTER_TEMPLATE_PATH),
36
+ documentTemplatePath: path.resolve(cwd, env.DOCUMENT_TEMPLATE_PATH),
37
+ bookLayoutCssPath: path.resolve(cwd, env.BOOK_LAYOUT_CSS_PATH),
38
+ remarkableUploadEnabled: String(env.REMARKABLE_UPLOAD_ENABLED).toLowerCase() === 'true',
39
+ remarkableHost: env.REMARKABLE_HOST,
40
+ remarkableXochitlDir: env.REMARKABLE_XOCHITL_DIR,
41
+ remarkableParentFolderUuid: env.REMARKABLE_PARENT_FOLDER_UUID,
42
+ remarkableParentFolderName: env.REMARKABLE_PARENT_FOLDER_NAME,
43
+ };
44
+
45
+ return config;
46
+ }
47
+
48
+ export async function assertReadableFile(filePath, label) {
49
+ try {
50
+ await access(filePath, constants.R_OK);
51
+ } catch {
52
+ throw new Error(`${label} is not readable: ${filePath}`);
53
+ }
54
+ }
55
+
56
+ export async function assertReadableDir(dirPath, label) {
57
+ try {
58
+ await access(dirPath, constants.R_OK);
59
+ } catch {
60
+ throw new Error(`${label} is not readable: ${dirPath}`);
61
+ }
62
+ }
@@ -0,0 +1,153 @@
1
+ import { mkdir, readFile } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import fg from 'fast-glob';
4
+ import matter from 'gray-matter';
5
+ import { marked } from 'marked';
6
+ import { chromium } from 'playwright';
7
+ import { assertReadableDir, assertReadableFile } from './config.mjs';
8
+
9
+ function escapeHtml(value) {
10
+ return value
11
+ .replaceAll('&', '&amp;')
12
+ .replaceAll('<', '&lt;')
13
+ .replaceAll('>', '&gt;')
14
+ .replaceAll('"', '&quot;')
15
+ .replaceAll("'", '&#39;');
16
+ }
17
+
18
+ function shouldIgnorePdf(data) {
19
+ return data?.['b/pdf/ignore'] === true || data?.['b/pdf/ignore'] === 'true';
20
+ }
21
+
22
+ function sanitiseSegment(value) {
23
+ return value
24
+ .normalize('NFKD')
25
+ .replace(/[\u0300-\u036f]/g, '')
26
+ .toLowerCase()
27
+ .replace(/[^a-z0-9]+/g, '-')
28
+ .replace(/^-+|-+$/g, '')
29
+ .replace(/-{2,}/g, '-');
30
+ }
31
+
32
+ function buildFlatPdfFileName(relativePath) {
33
+ const parsed = path.parse(relativePath);
34
+ const segments = [...(parsed.dir ? parsed.dir.split(path.sep) : []), parsed.name]
35
+ .map(sanitiseSegment)
36
+ .filter(Boolean);
37
+
38
+ return `${segments.join('-') || 'document'}.pdf`;
39
+ }
40
+
41
+ function applyTemplate(template, replacements) {
42
+ return template.replaceAll(/\{\{\s*([a-zA-Z0-9_]+)\s*\}\}/g, (token, key) => replacements[key] ?? token);
43
+ }
44
+
45
+ async function buildJobs(inputDir, outputDir) {
46
+ const files = await fg(['**/*.md', '**/*.markdown'], {
47
+ cwd: inputDir,
48
+ onlyFiles: true,
49
+ absolute: true,
50
+ });
51
+
52
+ const used = new Set();
53
+ const jobs = [];
54
+
55
+ for (const absolutePath of files.sort()) {
56
+ const relativePath = path.relative(inputDir, absolutePath);
57
+ const parsed = matter(await readFile(absolutePath, 'utf8'));
58
+
59
+ if (shouldIgnorePdf(parsed.data)) {
60
+ continue;
61
+ }
62
+
63
+ const sourceName = path.basename(relativePath, path.extname(relativePath));
64
+ const title = typeof parsed.data?.title === 'string' ? parsed.data.title.trim() || sourceName : sourceName;
65
+
66
+ let outputName = buildFlatPdfFileName(relativePath);
67
+ if (used.has(outputName)) {
68
+ const p = path.parse(outputName);
69
+ let i = 2;
70
+ while (used.has(`${p.name}-${i}${p.ext}`)) i += 1;
71
+ outputName = `${p.name}-${i}${p.ext}`;
72
+ }
73
+
74
+ used.add(outputName);
75
+ jobs.push({ absolutePath, title, outputPdfPath: path.join(outputDir, outputName), fileName: path.basename(relativePath) });
76
+ }
77
+
78
+ return jobs;
79
+ }
80
+
81
+ export async function renderMarkdownDirectory(config, { verbose = false } = {}) {
82
+ await assertReadableDir(config.markdownInputDir, 'Markdown input directory');
83
+ await assertReadableFile(config.outputCssFile, 'Compiled CSS file');
84
+ await assertReadableFile(config.bookLayoutCssPath, 'Book layout CSS file');
85
+ await assertReadableFile(config.documentTemplatePath, 'Document template');
86
+ await assertReadableFile(config.headerTemplatePath, 'Header template');
87
+ await assertReadableFile(config.footerTemplatePath, 'Footer template');
88
+
89
+ await mkdir(config.outputDir, { recursive: true });
90
+
91
+ const [css, bookCss, htmlTemplate, headerTemplate, footerTemplate] = await Promise.all([
92
+ readFile(config.outputCssFile, 'utf8'),
93
+ readFile(config.bookLayoutCssPath, 'utf8'),
94
+ readFile(config.documentTemplatePath, 'utf8'),
95
+ readFile(config.headerTemplatePath, 'utf8'),
96
+ readFile(config.footerTemplatePath, 'utf8'),
97
+ ]);
98
+
99
+ const jobs = await buildJobs(config.markdownInputDir, config.outputDir);
100
+ if (jobs.length === 0) {
101
+ throw new Error(`No markdown files found in ${config.markdownInputDir}. Add *.md files or change MARKDOWN_INPUT_DIR.`);
102
+ }
103
+
104
+ if (verbose) {
105
+ console.log(`Rendering ${jobs.length} markdown file(s) from ${config.markdownInputDir}`);
106
+ }
107
+
108
+ const browser = await chromium.launch({ headless: true });
109
+ try {
110
+ for (const job of jobs) {
111
+ const parsed = matter(await readFile(job.absolutePath, 'utf8'));
112
+ const htmlBody = marked.parse(parsed.content, { gfm: true, breaks: false });
113
+ const html = applyTemplate(htmlTemplate, {
114
+ documentTitle: escapeHtml(job.title),
115
+ css: `${css}\n${bookCss}`,
116
+ htmlBody,
117
+ marginTop: '20mm',
118
+ marginBottom: '20mm',
119
+ marginInner: '1.5cm',
120
+ marginOuter: '1cm',
121
+ });
122
+
123
+ const page = await browser.newPage();
124
+ try {
125
+ await page.setContent(html, { waitUntil: 'load' });
126
+ await page.pdf({
127
+ path: job.outputPdfPath,
128
+ width: '7in',
129
+ height: '10in',
130
+ printBackground: true,
131
+ displayHeaderFooter: true,
132
+ headerTemplate: applyTemplate(headerTemplate, {
133
+ fileName: escapeHtml(job.fileName),
134
+ flatPdfName: escapeHtml(path.basename(job.outputPdfPath)),
135
+ title: escapeHtml(job.title),
136
+ }),
137
+ footerTemplate: applyTemplate(footerTemplate, {
138
+ fileName: escapeHtml(job.fileName),
139
+ flatPdfName: escapeHtml(path.basename(job.outputPdfPath)),
140
+ title: escapeHtml(job.title),
141
+ }),
142
+ margin: { top: '20mm', right: '0mm', bottom: '20mm', left: '0mm' },
143
+ });
144
+ } finally {
145
+ await page.close();
146
+ }
147
+
148
+ console.log(`Created PDF: ${job.outputPdfPath}`);
149
+ }
150
+ } finally {
151
+ await browser.close();
152
+ }
153
+ }
@@ -0,0 +1,110 @@
1
+ import { readdir } from 'node:fs/promises';
2
+ import { basename, join } from 'node:path';
3
+ import { randomUUID } from 'node:crypto';
4
+ import { spawnSync } from 'node:child_process';
5
+
6
+ function ssh(host, args, options = {}) {
7
+ return spawnSync('ssh', [host, ...args], { encoding: 'utf8', ...options });
8
+ }
9
+
10
+ function scp(host, localPath, remotePath) {
11
+ return spawnSync('scp', [localPath, `${host}:${remotePath}`], { stdio: 'inherit' });
12
+ }
13
+
14
+ function remarkableAvailable(host) {
15
+ const result = spawnSync('ssh', ['-o', 'BatchMode=yes', '-o', 'ConnectTimeout=3', host, 'exit'], { stdio: 'ignore' });
16
+ return result.status === 0;
17
+ }
18
+
19
+ async function getPdfFiles(outputDir) {
20
+ const entries = await readdir(outputDir, { withFileTypes: true });
21
+ return entries.filter((entry) => entry.isFile() && entry.name.endsWith('.pdf')).map((entry) => join(outputDir, entry.name)).sort();
22
+ }
23
+
24
+ function resolveParentId(config) {
25
+ if (config.remarkableParentFolderUuid) {
26
+ return config.remarkableParentFolderUuid;
27
+ }
28
+
29
+ throw new Error(
30
+ 'Upload needs REMARKABLE_PARENT_FOLDER_UUID. Set it in .env or disable uploads with REMARKABLE_UPLOAD_ENABLED=false.',
31
+ );
32
+ }
33
+
34
+ function purgeFolder(config, parentId) {
35
+ const script = `
36
+ cd ${config.remarkableXochitlDir}
37
+ for f in *.metadata; do
38
+ if grep -q '\"parent\": \"${parentId}\"' "$f"; then
39
+ uuid="\${f%.metadata}"
40
+ rm -rf "\${uuid}"*
41
+ fi
42
+ done
43
+ `;
44
+
45
+ ssh(config.remarkableHost, [script], { stdio: 'inherit' });
46
+ }
47
+
48
+ function uploadPdf(config, parentId, file) {
49
+ const uuid = randomUUID();
50
+ const name = basename(file, '.pdf');
51
+
52
+ const metadata = JSON.stringify(
53
+ {
54
+ deleted: false,
55
+ lastModified: String(Date.now()),
56
+ metadatamodified: false,
57
+ modified: false,
58
+ parent: parentId,
59
+ pinned: false,
60
+ synced: false,
61
+ type: 'DocumentType',
62
+ version: 1,
63
+ visibleName: name,
64
+ },
65
+ null,
66
+ 1,
67
+ );
68
+
69
+ const content = JSON.stringify({ fileType: 'pdf', pageCount: 0 }, null, 1);
70
+
71
+ const tmpMeta = `/tmp/${uuid}.metadata`;
72
+ const tmpContent = `/tmp/${uuid}.content`;
73
+
74
+ spawnSync('bash', ['-lc', `cat > ${tmpMeta} <<'JSON'\n${metadata}\nJSON`]);
75
+ spawnSync('bash', ['-lc', `cat > ${tmpContent} <<'JSON'\n${content}\nJSON`]);
76
+
77
+ scp(config.remarkableHost, file, `${config.remarkableXochitlDir}/${uuid}.pdf`);
78
+ scp(config.remarkableHost, tmpMeta, `${config.remarkableXochitlDir}/${uuid}.metadata`);
79
+ scp(config.remarkableHost, tmpContent, `${config.remarkableXochitlDir}/${uuid}.content`);
80
+ }
81
+
82
+ export async function uploadToRemarkable(config, { purge = true } = {}) {
83
+ if (!config.remarkableUploadEnabled) {
84
+ console.log('Upload skipped: REMARKABLE_UPLOAD_ENABLED=false');
85
+ return;
86
+ }
87
+
88
+ if (!remarkableAvailable(config.remarkableHost)) {
89
+ throw new Error(`Cannot reach reMarkable host \`${config.remarkableHost}\` over SSH.`);
90
+ }
91
+
92
+ const files = await getPdfFiles(config.outputDir);
93
+ if (files.length === 0) {
94
+ throw new Error(`No PDF files found in ${config.outputDir}. Run build first.`);
95
+ }
96
+
97
+ const parentId = resolveParentId(config);
98
+ if (purge) {
99
+ console.log(`Purging existing documents in folder UUID ${parentId}...`);
100
+ purgeFolder(config, parentId);
101
+ }
102
+
103
+ for (const file of files) {
104
+ console.log(`Uploading ${file}`);
105
+ uploadPdf(config, parentId, file);
106
+ }
107
+
108
+ console.log('Restarting xochitl...');
109
+ ssh(config.remarkableHost, ['systemctl restart xochitl'], { stdio: 'inherit' });
110
+ }
@@ -0,0 +1,23 @@
1
+ @page :left {
2
+ margin-top: var(--pdf-margin-top);
3
+ margin-right: var(--pdf-margin-inner);
4
+ margin-bottom: var(--pdf-margin-bottom);
5
+ margin-left: var(--pdf-margin-outer);
6
+ }
7
+
8
+ @page :right {
9
+ margin-top: var(--pdf-margin-top);
10
+ margin-right: var(--pdf-margin-outer);
11
+ margin-bottom: var(--pdf-margin-bottom);
12
+ margin-left: var(--pdf-margin-inner);
13
+ }
14
+
15
+ main.document h1 {
16
+ break-before: right;
17
+ page-break-before: right;
18
+ }
19
+
20
+ main.document > h1:first-child {
21
+ break-before: auto;
22
+ page-break-before: auto;
23
+ }
package/styles/pdf.css ADDED
@@ -0,0 +1,18 @@
1
+ @import "tailwindcss";
2
+
3
+ :root {
4
+ color-scheme: light;
5
+ }
6
+
7
+ @page {
8
+ size: auto;
9
+ }
10
+
11
+ html {
12
+ @apply h-full w-full;
13
+ @apply bg-white text-gray-800;
14
+ }
15
+
16
+ body {
17
+ @apply bg-white text-gray-800;
18
+ }
@@ -0,0 +1,16 @@
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>{{documentTitle}}</title>
7
+ <style>
8
+ {{css}}
9
+ </style>
10
+ </head>
11
+ <body style="--pdf-margin-top: {{marginTop}}; --pdf-margin-bottom: {{marginBottom}}; --pdf-margin-inner: {{marginInner}}; --pdf-margin-outer: {{marginOuter}};">
12
+ <main class="document prose prose-slate max-w-none">
13
+ {{htmlBody}}
14
+ </main>
15
+ </body>
16
+ </html>
@@ -0,0 +1,25 @@
1
+ <div style="font-size: 9px; color: #6b7280; font-family: Arial, sans-serif; width: 100%; box-sizing: border-box; padding: 0 12mm;">
2
+ <span class="pageNumber" style="display:none;"></span>
3
+ <div id="footer-odd" style="display:flex; align-items:center; justify-content:space-between; width:100%;">
4
+ <span>{{title}}</span>
5
+ <span><span class="pageNumber"></span>/<span class="totalPages"></span></span>
6
+ </div>
7
+ <div id="footer-even" style="display:none; align-items:center; justify-content:space-between; width:100%;">
8
+ <span><span class="pageNumber"></span>/<span class="totalPages"></span></span>
9
+ <span>{{title}}</span>
10
+ </div>
11
+ </div>
12
+ <script>
13
+ (function () {
14
+ var pageNumberElement = document.querySelector('.pageNumber');
15
+ var pageNumber = Number((pageNumberElement && pageNumberElement.textContent) || '1');
16
+ var isEven = pageNumber % 2 === 0;
17
+ var oddFooter = document.getElementById('footer-odd');
18
+ var evenFooter = document.getElementById('footer-even');
19
+
20
+ if (oddFooter && evenFooter) {
21
+ oddFooter.style.display = isEven ? 'none' : 'flex';
22
+ evenFooter.style.display = isEven ? 'flex' : 'none';
23
+ }
24
+ })();
25
+ </script>
@@ -0,0 +1,25 @@
1
+ <div style="font-size: 9px; color: #6b7280; font-family: Arial, sans-serif; width: 100%; box-sizing: border-box; padding: 0 12mm;">
2
+ <span class="pageNumber" style="display:none;"></span>
3
+ <div id="header-odd" style="display:flex; align-items:center; justify-content:space-between; width:100%;">
4
+ <span>{{title}}</span>
5
+ <span>{{fileName}}</span>
6
+ </div>
7
+ <div id="header-even" style="display:none; align-items:center; justify-content:space-between; width:100%;">
8
+ <span>{{fileName}}</span>
9
+ <span>{{title}}</span>
10
+ </div>
11
+ </div>
12
+ <script>
13
+ (function () {
14
+ var pageNumberElement = document.querySelector('.pageNumber');
15
+ var pageNumber = Number((pageNumberElement && pageNumberElement.textContent) || '1');
16
+ var isEven = pageNumber % 2 === 0;
17
+ var oddHeader = document.getElementById('header-odd');
18
+ var evenHeader = document.getElementById('header-even');
19
+
20
+ if (oddHeader && evenHeader) {
21
+ oddHeader.style.display = isEven ? 'none' : 'flex';
22
+ evenHeader.style.display = isEven ? 'flex' : 'none';
23
+ }
24
+ })();
25
+ </script>