@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 +27 -0
- package/.release-it.ts +85 -0
- package/CHANGELOG.md +20 -0
- package/LICENSE.md +9 -0
- package/README.md +85 -0
- package/ToDo.md +57 -0
- package/package.json +34 -0
- package/src/build-css.mjs +21 -0
- package/src/cli.mjs +60 -0
- package/src/config.mjs +62 -0
- package/src/md-to-pdf.mjs +153 -0
- package/src/upload-remarkable.mjs +110 -0
- package/styles/pdf-book-layout.css +23 -0
- package/styles/pdf.css +18 -0
- package/templates/document.html +16 -0
- package/templates/footer.html +25 -0
- package/templates/header.html +25 -0
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('&', '&')
|
|
12
|
+
.replaceAll('<', '<')
|
|
13
|
+
.replaceAll('>', '>')
|
|
14
|
+
.replaceAll('"', '"')
|
|
15
|
+
.replaceAll("'", ''');
|
|
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,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>
|