mirror of
https://github.com/r-ca/md2pdf-meow.git
synced 2026-02-11 02:57:33 +00:00
Compare commits
No commits in common. "23e4bdea2bb498f25f2dabdc58b7aaccbe40f868" and "899593dcacafba3a2941080b88017a9ce3261693" have entirely different histories.
23e4bdea2b
...
899593dcac
40
README.md
40
README.md
@ -1,41 +1,7 @@
|
|||||||
# md2pdf-meow 🐈
|
# md2pdf-meow 🐈
|
||||||
|
|
||||||
TypeScript ベースの Markdown → HTML → PDF 変換パイプラインです。ビルド時には日本語メッセージ付きのログを流しながら、Markdown の連結・DocToc の生成・テンプレート描画・アセットのインライン化・Puppeteer を使った PDF 生成を一括で実行します。
|
## 概要
|
||||||
|
- WIP
|
||||||
## 使い方
|
|
||||||
1. 依存関係をインストール: `npm install`
|
|
||||||
2. 変換を実行: `npm run build`
|
|
||||||
- `documents/files.txt` に列挙された Markdown が連結され、`dist/result.pdf` と `dist/all.html` が生成されます。
|
|
||||||
- Chrome / Chromium が見つからない場合はエラーで停止し、`PUPPETEER_EXECUTABLE_PATH` の設定が案内されます。
|
|
||||||
|
|
||||||
## 技術メモ
|
|
||||||
- パイプライン全体は `tsx src/cli.ts` で実行される TypeScript 製 CLI で、各工程ごとに `INFO / SUCC / WARN / ERROR` ログを順番に出力します。
|
|
||||||
- `src/pipeline/renderMarkdown.ts` と `src/pipeline/renderTemplate.ts` は [2SC1815J/md2pdf](https://github.com/2SC1815J/md2pdf) で提供されていた `scripts/mdit.js` / `scripts/ejs.js` を MIT ライセンスの条件を保ったまま TypeScript へ移植し、プロジェクト内で利用できるようにしています。
|
|
||||||
- 画像やスタイルシートは `html-inline` を通して HTML に埋め込み、オフラインでも崩れない成果物を出力します。
|
|
||||||
- `documents/` 配下の Markdown からは相対パスで画像を参照でき、ビルド時に `work/assets/` へ自動コピーしたうえで HTML / PDF に反映します。外部 URL やデータ URI もそのまま扱えます。
|
|
||||||
- PDF 生成は `puppeteer-html-pdf` を使い、Chrome 不在時には明示的に検出するようになっています。
|
|
||||||
|
|
||||||
### メタ情報のカスタマイズ
|
|
||||||
- テンプレート固有のタイトル・著者・発行日などは `documents/_meta.yaml`(または `_meta.yml`)に記述します(存在しない場合はテンプレートのデフォルト値を使用)。
|
|
||||||
- 例:
|
|
||||||
```yaml
|
|
||||||
title: md2pdf-meow サンプル
|
|
||||||
author: ろむねこ
|
|
||||||
published: 2024/07/01
|
|
||||||
description: モバイルオーダー用ドキュメント
|
|
||||||
copyright: © 2024 rca
|
|
||||||
frontCover:
|
|
||||||
title: md2pdf-meow
|
|
||||||
published: 2024/07/01
|
|
||||||
description: 表紙だけ別文言を載せる場合に利用
|
|
||||||
backCover:
|
|
||||||
title: md2pdf-meow
|
|
||||||
pubDate: 2024/07/01
|
|
||||||
copyright: rca
|
|
||||||
description: 裏表紙に掲載する紹介文
|
|
||||||
```
|
|
||||||
- `frontCover` / `backCover` の各値は任意で、未指定時には `title` や `published` などの共通値が利用されます。`description` もそれぞれ上書き可能です。
|
|
||||||
- `description` は `<meta name="description">` と表紙/裏表紙の本文に表示されます。長すぎる場合は YAML 側で適宜整形してください。
|
|
||||||
|
|
||||||
## 移植元
|
## 移植元
|
||||||
- https://github.com/2SC1815J/md2pdf
|
https://github.com/2SC1815J/md2pdf
|
||||||
|
|||||||
@ -1,5 +0,0 @@
|
|||||||
title: 寿がきやモバイルオーダーシステム 設計書
|
|
||||||
author: さkすあsかづさdkす
|
|
||||||
published: 2025/12/01
|
|
||||||
description: DESCRIPTIONあるよ〜
|
|
||||||
copyright: COPYRIGHT
|
|
||||||
102
package-lock.json
generated
102
package-lock.json
generated
@ -1,20 +1,16 @@
|
|||||||
{
|
{
|
||||||
"name": "md2pdf-meow",
|
"name": "md2pdf-mod",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "md2pdf-meow",
|
"name": "md2pdf-mod",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/chalk": "^2.2.4",
|
|
||||||
"@types/yaml": "^1.9.7",
|
|
||||||
"chalk": "^5.6.2",
|
|
||||||
"puppeteer-html-pdf": "^4.0.8",
|
"puppeteer-html-pdf": "^4.0.8",
|
||||||
"tsx": "^4.17.0",
|
"tsx": "^4.17.0"
|
||||||
"yaml": "^2.8.2"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"anchor-markdown-header": "^0.5.7",
|
"anchor-markdown-header": "^0.5.7",
|
||||||
@ -68,19 +64,6 @@
|
|||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/highlight/node_modules/chalk": {
|
|
||||||
"version": "2.4.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz",
|
|
||||||
"integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==",
|
|
||||||
"dependencies": {
|
|
||||||
"ansi-styles": "^3.2.1",
|
|
||||||
"escape-string-regexp": "^1.0.5",
|
|
||||||
"supports-color": "^5.3.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=4"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/aix-ppc64": {
|
"node_modules/@esbuild/aix-ppc64": {
|
||||||
"version": "0.23.0",
|
"version": "0.23.0",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.23.0.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.23.0.tgz",
|
||||||
@ -551,15 +534,6 @@
|
|||||||
"integrity": "sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==",
|
"integrity": "sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@types/chalk": {
|
|
||||||
"version": "2.2.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/@types/chalk/-/chalk-2.2.4.tgz",
|
|
||||||
"integrity": "sha512-pb/QoGqtCpH2famSp72qEsXkNzcErlVmiXlQ/ww+5AddD8TmmYS7EWg5T20YiNCAiTgs8pMf2G8SJG5h/ER1ZQ==",
|
|
||||||
"deprecated": "This is a stub types definition. chalk provides its own type definitions, so you do not need this installed.",
|
|
||||||
"dependencies": {
|
|
||||||
"chalk": "*"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@types/node": {
|
"node_modules/@types/node": {
|
||||||
"version": "22.2.0",
|
"version": "22.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.2.0.tgz",
|
||||||
@ -570,15 +544,6 @@
|
|||||||
"undici-types": "~6.13.0"
|
"undici-types": "~6.13.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@types/yaml": {
|
|
||||||
"version": "1.9.7",
|
|
||||||
"resolved": "https://registry.npmjs.org/@types/yaml/-/yaml-1.9.7.tgz",
|
|
||||||
"integrity": "sha512-8WMXRDD1D+wCohjfslHDgICd2JtMATZU8CkhH8LVJqcJs6dyYj5TGptzP8wApbmEullGBSsCEzzap73DQ1HJaA==",
|
|
||||||
"deprecated": "This is a stub types definition. yaml provides its own type definitions, so you do not need this installed.",
|
|
||||||
"dependencies": {
|
|
||||||
"yaml": "*"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@types/yauzl": {
|
"node_modules/@types/yauzl": {
|
||||||
"version": "2.10.3",
|
"version": "2.10.3",
|
||||||
"resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz",
|
"resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz",
|
||||||
@ -979,14 +944,17 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/chalk": {
|
"node_modules/chalk": {
|
||||||
"version": "5.6.2",
|
"version": "2.4.2",
|
||||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz",
|
"resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz",
|
||||||
"integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==",
|
"integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==",
|
||||||
"engines": {
|
"license": "MIT",
|
||||||
"node": "^12.17.0 || ^14.13 || >=16.0.0"
|
"dependencies": {
|
||||||
|
"ansi-styles": "^3.2.1",
|
||||||
|
"escape-string-regexp": "^1.0.5",
|
||||||
|
"supports-color": "^5.3.0"
|
||||||
},
|
},
|
||||||
"funding": {
|
"engines": {
|
||||||
"url": "https://github.com/chalk/chalk?sponsor=1"
|
"node": ">=4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/character-entities": {
|
"node_modules/character-entities": {
|
||||||
@ -1864,20 +1832,6 @@
|
|||||||
"node": ">=4"
|
"node": ">=4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/eslint/node_modules/chalk": {
|
|
||||||
"version": "2.4.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz",
|
|
||||||
"integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==",
|
|
||||||
"dev": true,
|
|
||||||
"dependencies": {
|
|
||||||
"ansi-styles": "^3.2.1",
|
|
||||||
"escape-string-regexp": "^1.0.5",
|
|
||||||
"supports-color": "^5.3.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=4"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/eslint/node_modules/debug": {
|
"node_modules/eslint/node_modules/debug": {
|
||||||
"version": "4.3.6",
|
"version": "4.3.6",
|
||||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.6.tgz",
|
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.6.tgz",
|
||||||
@ -2453,6 +2407,7 @@
|
|||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz",
|
||||||
"integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==",
|
"integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==",
|
||||||
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=4"
|
"node": ">=4"
|
||||||
}
|
}
|
||||||
@ -2915,20 +2870,6 @@
|
|||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/inquirer/node_modules/chalk": {
|
|
||||||
"version": "2.4.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz",
|
|
||||||
"integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==",
|
|
||||||
"dev": true,
|
|
||||||
"dependencies": {
|
|
||||||
"ansi-styles": "^3.2.1",
|
|
||||||
"escape-string-regexp": "^1.0.5",
|
|
||||||
"supports-color": "^5.3.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=4"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/inquirer/node_modules/strip-ansi": {
|
"node_modules/inquirer/node_modules/strip-ansi": {
|
||||||
"version": "5.2.0",
|
"version": "5.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-5.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-5.2.0.tgz",
|
||||||
@ -4647,6 +4588,7 @@
|
|||||||
"version": "5.5.0",
|
"version": "5.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz",
|
||||||
"integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==",
|
"integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==",
|
||||||
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"has-flag": "^3.0.0"
|
"has-flag": "^3.0.0"
|
||||||
},
|
},
|
||||||
@ -5497,20 +5439,6 @@
|
|||||||
"node": ">=10"
|
"node": ">=10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/yaml": {
|
|
||||||
"version": "2.8.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz",
|
|
||||||
"integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==",
|
|
||||||
"bin": {
|
|
||||||
"yaml": "bin.mjs"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 14.6"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/eemeli"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/yargs": {
|
"node_modules/yargs": {
|
||||||
"version": "17.7.2",
|
"version": "17.7.2",
|
||||||
"resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz",
|
"resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz",
|
||||||
|
|||||||
14
package.json
14
package.json
@ -3,7 +3,13 @@
|
|||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"description": "Convert Markdown documents to PDF🐱",
|
"description": "Convert Markdown documents to PDF🐱",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsx src/cli.ts"
|
"build:step1": "npx minicat $(sed 's|^|documents/|' documents/files.txt) > work/all.md",
|
||||||
|
"build:step2": "npx doctoc --notitle --maxlevel 3 work/all.md",
|
||||||
|
"build:step3": "node scripts/mdit.js work/all.md work/all_md.html",
|
||||||
|
"build:step4": "node scripts/ejs.js template/template.html work/all.html",
|
||||||
|
"build:step5": "npx html-inline work/all.html -b doc -o dist/all.html",
|
||||||
|
"build:step6": "npx tsx scripts/convert.ts",
|
||||||
|
"build": "npm run build:step1 && npm run build:step2 && npm run build:step3 && npm run build:step4 && npm run build:step5 && npm run build:step6"
|
||||||
},
|
},
|
||||||
"author": "rca",
|
"author": "rca",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
@ -22,11 +28,7 @@
|
|||||||
"typescript": "^5.5.4"
|
"typescript": "^5.5.4"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/chalk": "^2.2.4",
|
|
||||||
"@types/yaml": "^1.9.7",
|
|
||||||
"chalk": "^5.6.2",
|
|
||||||
"puppeteer-html-pdf": "^4.0.8",
|
"puppeteer-html-pdf": "^4.0.8",
|
||||||
"tsx": "^4.17.0",
|
"tsx": "^4.17.0"
|
||||||
"yaml": "^2.8.2"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
29
scripts/convert.ts
Normal file
29
scripts/convert.ts
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
import PuppeteerHTMLPDF from 'puppeteer-html-pdf';
|
||||||
|
|
||||||
|
async function generatePDF() {
|
||||||
|
const htmlPdf = new PuppeteerHTMLPDF();
|
||||||
|
|
||||||
|
htmlPdf.setOptions({
|
||||||
|
format: "A4",
|
||||||
|
margin: {
|
||||||
|
top: "16mm",
|
||||||
|
right: "16mm",
|
||||||
|
bottom: "16mm",
|
||||||
|
left: "16mm"
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const html = await htmlPdf.readFile(`${__dirname}/../dist/all.html`, "utf8");
|
||||||
|
|
||||||
|
const pdfBuffer = await htmlPdf.create(html);
|
||||||
|
const path = `${__dirname}/../dist/result.pdf`;
|
||||||
|
await htmlPdf.writeFile(pdfBuffer, path);
|
||||||
|
|
||||||
|
console.log("PDF created successfully");
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error creating PDF", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
generatePDF();
|
||||||
37
scripts/ejs.js
Executable file
37
scripts/ejs.js
Executable file
@ -0,0 +1,37 @@
|
|||||||
|
/*
|
||||||
|
* md2html
|
||||||
|
* Copyright 2019 2SC1815J, MIT license
|
||||||
|
*/
|
||||||
|
'use strict';
|
||||||
|
if (process.argv.length < 4) {
|
||||||
|
console.error('Usage: node ejs.js template.html output.html');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { promisify } = require('util');
|
||||||
|
const ejs = require('ejs');
|
||||||
|
const tidy = require('htmltidy2');
|
||||||
|
const fs = require('fs');
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
const text = await promisify(ejs.renderFile)(process.argv[2]);
|
||||||
|
const options = {
|
||||||
|
doctype: 'html5',
|
||||||
|
indent: 'auto',
|
||||||
|
wrap: 0,
|
||||||
|
tidyMark: false,
|
||||||
|
quoteAmpersand: false,
|
||||||
|
hideComments: true,
|
||||||
|
dropEmptyElements: false,
|
||||||
|
newline: 'LF'
|
||||||
|
};
|
||||||
|
const tidied = await promisify(tidy.tidy)(text, options);
|
||||||
|
await promisify(fs.writeFile)(process.argv[3], tidied, 'utf8');
|
||||||
|
})()
|
||||||
|
.then(() => {
|
||||||
|
console.log('Done.');
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
console.error(err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
46
scripts/mdit.js
Executable file
46
scripts/mdit.js
Executable file
@ -0,0 +1,46 @@
|
|||||||
|
/*
|
||||||
|
* md2html
|
||||||
|
* Copyright 2019 2SC1815J, MIT license
|
||||||
|
*/
|
||||||
|
'use strict';
|
||||||
|
if (process.argv.length < 4) {
|
||||||
|
console.error('Usage: node mdit.js input.md output.html');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const header_instances = {};
|
||||||
|
const anchor = require('anchor-markdown-header');
|
||||||
|
const mdit = require('markdown-it')(
|
||||||
|
{
|
||||||
|
html: true
|
||||||
|
})
|
||||||
|
.use(require('markdown-it-named-headers'), {
|
||||||
|
slugify: function(header) {
|
||||||
|
if (header_instances[header] !== void 0) {
|
||||||
|
header_instances[header]++;
|
||||||
|
} else {
|
||||||
|
header_instances[header] = 0;
|
||||||
|
}
|
||||||
|
const match = anchor(header, 'github.com', header_instances[header]).match(/]\(#(.+?)\)$/);
|
||||||
|
return match ? decodeURI(match[1]) : header;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.use(require('markdown-it-implicit-figures'), {
|
||||||
|
figcaption: true
|
||||||
|
});
|
||||||
|
|
||||||
|
const { promisify } = require('util');
|
||||||
|
const fs = require('fs');
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
const md = await promisify(fs.readFile)(process.argv[2], 'utf8');
|
||||||
|
const html = mdit.render(md);
|
||||||
|
await promisify(fs.writeFile)(process.argv[3], html, 'utf8');
|
||||||
|
})()
|
||||||
|
.then(() => {
|
||||||
|
console.log('Done.');
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
console.error(err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
40
src/cli.ts
40
src/cli.ts
@ -1,40 +0,0 @@
|
|||||||
import { DIST_INLINE_HTML, DIST_PDF, TEMPLATE_FILE, WORK_MARKDOWN_HTML, WORK_TEMPLATE_HTML } from './config.js';
|
|
||||||
import { logger } from './logger.js';
|
|
||||||
import { concatMarkdown } from './pipeline/concatMarkdown.js';
|
|
||||||
import { generatePdf } from './pipeline/generatePdf.js';
|
|
||||||
import { injectToc } from './pipeline/generateToc.js';
|
|
||||||
import { inlineAssets } from './pipeline/inlineAssets.js';
|
|
||||||
import { prepareWorkspace } from './pipeline/prepareWorkspace.js';
|
|
||||||
import { AssetManager } from './pipeline/assetManager.js';
|
|
||||||
import { renderMarkdownToHtml } from './pipeline/renderMarkdown.js';
|
|
||||||
import { renderTemplate } from './pipeline/renderTemplate.js';
|
|
||||||
import { loadMeta } from './pipeline/loadMeta.js';
|
|
||||||
|
|
||||||
async function runPipeline() {
|
|
||||||
logger.info('md2pdf-meow のビルドを開始します。');
|
|
||||||
await prepareWorkspace();
|
|
||||||
const assetManager = new AssetManager();
|
|
||||||
const { outputPath: markdownPath, orderedFiles } = await concatMarkdown(assetManager);
|
|
||||||
assetManager.report();
|
|
||||||
await injectToc(markdownPath);
|
|
||||||
await renderMarkdownToHtml(markdownPath, WORK_MARKDOWN_HTML, assetManager, orderedFiles);
|
|
||||||
const meta = await loadMeta();
|
|
||||||
await renderTemplate(TEMPLATE_FILE, WORK_TEMPLATE_HTML, meta);
|
|
||||||
await inlineAssets(WORK_TEMPLATE_HTML, DIST_INLINE_HTML);
|
|
||||||
await generatePdf(DIST_INLINE_HTML, DIST_PDF);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function main() {
|
|
||||||
try {
|
|
||||||
await runPipeline();
|
|
||||||
logger.succ('PDF 生成パイプラインが完了しました 🐈');
|
|
||||||
} catch (error) {
|
|
||||||
logger.error(`ビルドに失敗しました: ${(error as Error).message}`);
|
|
||||||
if (error instanceof Error && error.stack) {
|
|
||||||
console.error(error.stack);
|
|
||||||
}
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
main();
|
|
||||||
@ -1,19 +0,0 @@
|
|||||||
import path from 'node:path';
|
|
||||||
import { fileURLToPath } from 'node:url';
|
|
||||||
|
|
||||||
const __filename = fileURLToPath(import.meta.url);
|
|
||||||
const __dirname = path.dirname(__filename);
|
|
||||||
|
|
||||||
export const ROOT_DIR = path.resolve(__dirname, '..');
|
|
||||||
export const DOCUMENTS_DIR = path.join(ROOT_DIR, 'documents');
|
|
||||||
export const DOCUMENT_LIST = path.join(DOCUMENTS_DIR, 'files.txt');
|
|
||||||
export const WORK_DIR = path.join(ROOT_DIR, 'work');
|
|
||||||
export const DIST_DIR = path.join(ROOT_DIR, 'dist');
|
|
||||||
export const WORK_ASSETS_DIR = path.join(WORK_DIR, 'assets');
|
|
||||||
export const DIST_ASSETS_DIR = path.join(DIST_DIR, 'assets');
|
|
||||||
export const TEMPLATE_FILE = path.join(ROOT_DIR, 'template', 'template.html');
|
|
||||||
export const WORK_MARKDOWN = path.join(WORK_DIR, 'all.md');
|
|
||||||
export const WORK_MARKDOWN_HTML = path.join(WORK_DIR, 'all_md.html');
|
|
||||||
export const WORK_TEMPLATE_HTML = path.join(WORK_DIR, 'all.html');
|
|
||||||
export const DIST_INLINE_HTML = path.join(DIST_DIR, 'all.html');
|
|
||||||
export const DIST_PDF = path.join(DIST_DIR, 'result.pdf');
|
|
||||||
@ -1,34 +0,0 @@
|
|||||||
import chalk from 'chalk';
|
|
||||||
|
|
||||||
// 'SUCC' を型に追加
|
|
||||||
type LogLevel = 'INFO' | 'WARN' | 'ERROR' | 'SUCC';
|
|
||||||
|
|
||||||
function format(level: LogLevel, message: string): string {
|
|
||||||
switch (level) {
|
|
||||||
case 'INFO':
|
|
||||||
return `${chalk.cyan.bold('[INFO]')} ${message}`;
|
|
||||||
case 'WARN':
|
|
||||||
return `${chalk.yellow.bold('[WARN]')} ${message}`;
|
|
||||||
case 'ERROR':
|
|
||||||
return `${chalk.red.bold('[ERROR]')} ${chalk.red(message)}`;
|
|
||||||
case 'SUCC':
|
|
||||||
return `${chalk.green.bold('[SUCC]')} ${message}`;
|
|
||||||
default:
|
|
||||||
return `[${level}] ${message}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const logger = {
|
|
||||||
info(message: string) {
|
|
||||||
console.log(format('INFO', message));
|
|
||||||
},
|
|
||||||
warn(message: string) {
|
|
||||||
console.warn(format('WARN', message));
|
|
||||||
},
|
|
||||||
error(message: string) {
|
|
||||||
console.error(format('ERROR', message));
|
|
||||||
},
|
|
||||||
succ(message: string) {
|
|
||||||
console.log(format('SUCC', message));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@ -1,140 +0,0 @@
|
|||||||
import MarkdownIt from 'markdown-it';
|
|
||||||
import type Token from 'markdown-it/lib/token';
|
|
||||||
import path from 'node:path';
|
|
||||||
import { promises as fs } from 'node:fs';
|
|
||||||
|
|
||||||
import { DOCUMENTS_DIR, WORK_DIR } from '../config.js';
|
|
||||||
import { logger } from '../logger.js';
|
|
||||||
import { ensureDir, pathExists } from '../utils/fs.js';
|
|
||||||
|
|
||||||
const scanner = new MarkdownIt({ html: true });
|
|
||||||
const LOG_PATH_MAX = 80;
|
|
||||||
|
|
||||||
function isExternalSource(value: string) {
|
|
||||||
return /^[a-zA-Z][\w+.-]*:/.test(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeSource(value: string) {
|
|
||||||
let normalized = value.trim();
|
|
||||||
if (normalized.startsWith('<') && normalized.endsWith('>')) {
|
|
||||||
normalized = normalized.slice(1, -1).trim();
|
|
||||||
}
|
|
||||||
normalized = normalized.replace(/\\/g, '/');
|
|
||||||
try {
|
|
||||||
normalized = decodeURI(normalized);
|
|
||||||
} catch {
|
|
||||||
// ignore decode errors
|
|
||||||
}
|
|
||||||
return normalized;
|
|
||||||
}
|
|
||||||
|
|
||||||
function splitSegments(relativePath: string) {
|
|
||||||
return relativePath.split(/[\\/]/).filter(Boolean);
|
|
||||||
}
|
|
||||||
|
|
||||||
function encodeSegments(segments: string[]) {
|
|
||||||
return segments.map((segment) => encodeURIComponent(segment));
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildTarget(relativeSegments: string[]) {
|
|
||||||
const encodedSegments = encodeSegments(relativeSegments);
|
|
||||||
const targetRelativePosix = ['assets', ...encodedSegments].join('/');
|
|
||||||
const targetPath = path.join(WORK_DIR, 'assets', ...encodedSegments);
|
|
||||||
return { targetRelativePosix, targetPath };
|
|
||||||
}
|
|
||||||
|
|
||||||
function abbreviatePath(value: string) {
|
|
||||||
if (value.length <= LOG_PATH_MAX) {
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
const keep = Math.floor((LOG_PATH_MAX - 3) / 2);
|
|
||||||
return `${value.slice(0, keep)}...${value.slice(-keep)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractImageSources(tokens: Token[]) {
|
|
||||||
const sources: string[] = [];
|
|
||||||
for (const token of tokens) {
|
|
||||||
if (token.type === 'inline' && token.children) {
|
|
||||||
for (const child of token.children) {
|
|
||||||
if (child.type === 'image') {
|
|
||||||
const src = child.attrGet('src');
|
|
||||||
if (src) {
|
|
||||||
sources.push(src);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return sources;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class AssetManager {
|
|
||||||
private assetMap = new Map<string, string>();
|
|
||||||
private copiedCount = 0;
|
|
||||||
|
|
||||||
private key(filePath: string, src: string) {
|
|
||||||
return `${filePath}::${src}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
async scanMarkdown(filePath: string, markdown: string) {
|
|
||||||
const tokens = scanner.parse(markdown, {});
|
|
||||||
const sources = extractImageSources(tokens);
|
|
||||||
for (const source of sources) {
|
|
||||||
await this.registerAsset(filePath, source);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async registerAsset(filePath: string, rawSource: string) {
|
|
||||||
const normalized = normalizeSource(rawSource);
|
|
||||||
if (!normalized || isExternalSource(normalized)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (path.isAbsolute(normalized)) {
|
|
||||||
logger.warn(`絶対パスの画像参照には対応していません: ${normalized}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const absoluteSource = path.resolve(path.dirname(filePath), normalized);
|
|
||||||
if (!(await pathExists(absoluteSource))) {
|
|
||||||
logger.warn(`画像ファイルが見つかりません: ${normalized} (参照元: ${path.relative('.', filePath)})`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let relativeFromDocuments = path.relative(DOCUMENTS_DIR, absoluteSource);
|
|
||||||
if (relativeFromDocuments.startsWith('..')) {
|
|
||||||
relativeFromDocuments = path.basename(absoluteSource);
|
|
||||||
}
|
|
||||||
|
|
||||||
const segments = splitSegments(relativeFromDocuments);
|
|
||||||
const { targetRelativePosix, targetPath } = buildTarget(segments);
|
|
||||||
const targetRelativePath = path.relative('.', targetPath);
|
|
||||||
|
|
||||||
logAssetCopyStart(relativeFromDocuments, targetRelativePath);
|
|
||||||
await ensureDir(path.dirname(targetPath));
|
|
||||||
await fs.copyFile(absoluteSource, targetPath);
|
|
||||||
this.copiedCount++;
|
|
||||||
|
|
||||||
this.assetMap.set(this.key(filePath, rawSource), targetRelativePosix);
|
|
||||||
if (rawSource !== normalized) {
|
|
||||||
this.assetMap.set(this.key(filePath, normalized), targetRelativePosix);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
resolve(filePath: string, src: string) {
|
|
||||||
return (
|
|
||||||
this.assetMap.get(this.key(filePath, src)) ??
|
|
||||||
this.assetMap.get(this.key(filePath, normalizeSource(src)))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
report() {
|
|
||||||
logger.succ(`asset copy total: ${this.copiedCount} file(s)`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function logAssetCopyStart(source: string, target: string) {
|
|
||||||
logger.info('├─ asset copy');
|
|
||||||
logger.info(`│ ├─ src: ${abbreviatePath(source)}`);
|
|
||||||
logger.info(`│ └─ dst: ${abbreviatePath(target)}`);
|
|
||||||
}
|
|
||||||
@ -1,55 +0,0 @@
|
|||||||
import path from 'node:path';
|
|
||||||
|
|
||||||
import { DOCUMENTS_DIR, DOCUMENT_LIST, WORK_MARKDOWN } from '../config.js';
|
|
||||||
import { logger } from '../logger.js';
|
|
||||||
import { AssetManager } from './assetManager.js';
|
|
||||||
import { createFileMarker } from './fileMarkers.js';
|
|
||||||
import { pathExists, readText, writeText } from '../utils/fs.js';
|
|
||||||
|
|
||||||
export interface MarkdownBundleResult {
|
|
||||||
outputPath: string;
|
|
||||||
orderedFiles: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeEntries(rawList: string) {
|
|
||||||
return rawList
|
|
||||||
.split(/\r?\n/)
|
|
||||||
.map((line) => line.trim())
|
|
||||||
.filter((line) => line.length > 0 && !line.startsWith('#'));
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function concatMarkdown(assetManager: AssetManager): Promise<MarkdownBundleResult> {
|
|
||||||
logger.info('documents/files.txt を読み込み Markdown を連結します…');
|
|
||||||
const rawList = await readText(DOCUMENT_LIST);
|
|
||||||
const entries = normalizeEntries(rawList);
|
|
||||||
|
|
||||||
if (entries.length === 0) {
|
|
||||||
throw new Error('documents/files.txt に有効なエントリがありません。');
|
|
||||||
}
|
|
||||||
|
|
||||||
const orderedFiles = entries.map((entry) => path.resolve(DOCUMENTS_DIR, entry));
|
|
||||||
for (const filePath of orderedFiles) {
|
|
||||||
if (!(await pathExists(filePath))) {
|
|
||||||
throw new Error(`Markdown ファイルが見つかりません: ${path.relative(DOCUMENTS_DIR, filePath)}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const parts: string[] = [];
|
|
||||||
for (const filePath of orderedFiles) {
|
|
||||||
logger.info(` - ${path.relative(DOCUMENTS_DIR, filePath)} を連結対象に追加しました`);
|
|
||||||
const markdown = await readText(filePath);
|
|
||||||
await assetManager.scanMarkdown(filePath, markdown);
|
|
||||||
const relativePath = path.relative(DOCUMENTS_DIR, filePath).replace(/\\/g, '/');
|
|
||||||
const marker = createFileMarker(relativePath);
|
|
||||||
parts.push(`${marker}\n${markdown.trim()}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const combined = parts.join('\n\n\n') + '\n';
|
|
||||||
await writeText(WORK_MARKDOWN, combined);
|
|
||||||
logger.succ(`連結済み Markdown を ${path.relative('.', WORK_MARKDOWN)} に保存しました`);
|
|
||||||
|
|
||||||
return {
|
|
||||||
outputPath: WORK_MARKDOWN,
|
|
||||||
orderedFiles
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@ -1,6 +0,0 @@
|
|||||||
export const FILE_MARKER_PREFIX = '<!-- md2pdf-file:';
|
|
||||||
export const FILE_MARKER_SUFFIX = '-->';
|
|
||||||
|
|
||||||
export function createFileMarker(relativePath: string) {
|
|
||||||
return `${FILE_MARKER_PREFIX}${relativePath}${FILE_MARKER_SUFFIX}`;
|
|
||||||
}
|
|
||||||
@ -1,96 +0,0 @@
|
|||||||
import path from 'node:path';
|
|
||||||
import puppeteer from 'puppeteer';
|
|
||||||
import PuppeteerHTMLPDF from 'puppeteer-html-pdf';
|
|
||||||
|
|
||||||
import { logger } from '../logger.js';
|
|
||||||
import { pathExists } from '../utils/fs.js';
|
|
||||||
|
|
||||||
async function getEnvExecutable() {
|
|
||||||
const envPath = process.env.PUPPETEER_EXECUTABLE_PATH;
|
|
||||||
if (envPath && (await pathExists(envPath))) {
|
|
||||||
return envPath;
|
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getPlatformCandidates() {
|
|
||||||
if (process.platform === 'win32') {
|
|
||||||
return [
|
|
||||||
'C:\\\\Program Files\\\\Google\\\\Chrome\\\\Application\\\\chrome.exe',
|
|
||||||
'C:\\\\Program Files (x86)\\\\Google\\\\Chrome\\\\Application\\\\chrome.exe',
|
|
||||||
'C:\\\\Program Files\\\\Microsoft\\\\Edge\\\\Application\\\\msedge.exe'
|
|
||||||
];
|
|
||||||
}
|
|
||||||
if (process.platform === 'darwin') {
|
|
||||||
return [
|
|
||||||
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
|
||||||
'/Applications/Chromium.app/Contents/MacOS/Chromium',
|
|
||||||
'/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge'
|
|
||||||
];
|
|
||||||
}
|
|
||||||
return [
|
|
||||||
process.env.CHROME_PATH,
|
|
||||||
'/usr/bin/google-chrome',
|
|
||||||
'/usr/bin/google-chrome-stable',
|
|
||||||
'/usr/bin/chromium-browser',
|
|
||||||
'/usr/bin/chromium',
|
|
||||||
'/usr/bin/microsoft-edge',
|
|
||||||
'/snap/bin/chromium'
|
|
||||||
].filter((value): value is string => Boolean(value));
|
|
||||||
}
|
|
||||||
|
|
||||||
async function findChromiumExecutable() {
|
|
||||||
const envPath = await getEnvExecutable();
|
|
||||||
if (envPath) {
|
|
||||||
return envPath;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const internalPath = puppeteer.executablePath?.();
|
|
||||||
if (internalPath && (await pathExists(internalPath))) {
|
|
||||||
return internalPath;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
logger.warn(`Puppeteer の実行ファイル検出で問題が発生しました: ${(error as Error).message}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const candidates = getPlatformCandidates();
|
|
||||||
for (const candidate of candidates) {
|
|
||||||
if (await pathExists(candidate)) {
|
|
||||||
return candidate;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function generatePdf(htmlPath: string, pdfPath: string) {
|
|
||||||
logger.info('Chromium / Chrome の実行ファイルを確認しています…');
|
|
||||||
const executablePath = await findChromiumExecutable();
|
|
||||||
|
|
||||||
if (!executablePath) {
|
|
||||||
throw new Error(
|
|
||||||
'Chromium / Chrome の実行ファイルが見つかりませんでした。' +
|
|
||||||
'PUPPETEER_EXECUTABLE_PATH を設定するか、Chrome/Chromium をインストールしてください。'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const htmlPdf = new PuppeteerHTMLPDF();
|
|
||||||
await htmlPdf.setOptions({
|
|
||||||
format: 'A4',
|
|
||||||
margin: {
|
|
||||||
top: '16mm',
|
|
||||||
right: '16mm',
|
|
||||||
bottom: '16mm',
|
|
||||||
left: '16mm'
|
|
||||||
},
|
|
||||||
printBackground: true,
|
|
||||||
executablePath
|
|
||||||
});
|
|
||||||
|
|
||||||
logger.info('HTML から PDF を生成しています…');
|
|
||||||
const html = await htmlPdf.readFile(htmlPath, 'utf8');
|
|
||||||
const pdfBuffer = await htmlPdf.create(html);
|
|
||||||
await htmlPdf.writeFile(pdfBuffer, pdfPath);
|
|
||||||
logger.succ(`${path.relative('.', pdfPath)} を作成しました`);
|
|
||||||
}
|
|
||||||
@ -1,19 +0,0 @@
|
|||||||
import path from 'node:path';
|
|
||||||
import transform from 'doctoc/lib/transform';
|
|
||||||
|
|
||||||
import { logger } from '../logger.js';
|
|
||||||
import { readText, writeText } from '../utils/fs.js';
|
|
||||||
|
|
||||||
export async function injectToc(markdownPath: string) {
|
|
||||||
logger.info('目次情報 (DocToc) を更新しています…');
|
|
||||||
const current = await readText(markdownPath);
|
|
||||||
const result = transform(current, 'github.com', 3, undefined, true);
|
|
||||||
|
|
||||||
if (!result.transformed) {
|
|
||||||
logger.warn('DocToc の対象ヘッダーが見つからなかったため、目次は更新されませんでした。');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await writeText(markdownPath, result.data);
|
|
||||||
logger.succ(`DocToc を ${path.relative('.', markdownPath)} に書き込みました`);
|
|
||||||
}
|
|
||||||
@ -1,13 +0,0 @@
|
|||||||
import fs from 'node:fs';
|
|
||||||
import path from 'node:path';
|
|
||||||
import { pipeline } from 'node:stream/promises';
|
|
||||||
import htmlInline from 'html-inline';
|
|
||||||
|
|
||||||
import { logger } from '../logger.js';
|
|
||||||
|
|
||||||
export async function inlineAssets(inputPath: string, outputPath: string) {
|
|
||||||
logger.info('HTML 内の CSS / 画像 / スクリプトをインライン展開しています…');
|
|
||||||
const transformer = htmlInline({ basedir: path.dirname(inputPath) });
|
|
||||||
await pipeline(fs.createReadStream(inputPath), transformer, fs.createWriteStream(outputPath));
|
|
||||||
logger.succ(`${path.relative('.', outputPath)} にインライン済み HTML を出力しました`);
|
|
||||||
}
|
|
||||||
@ -1,142 +0,0 @@
|
|||||||
import path from 'node:path';
|
|
||||||
import { parse as parseYaml } from 'yaml';
|
|
||||||
|
|
||||||
import { DOCUMENTS_DIR } from '../config.js';
|
|
||||||
import { logger } from '../logger.js';
|
|
||||||
import { pathExists, readText } from '../utils/fs.js';
|
|
||||||
|
|
||||||
export interface DocumentMetaSection {
|
|
||||||
title?: string;
|
|
||||||
author?: string;
|
|
||||||
published?: string;
|
|
||||||
pubDate?: string;
|
|
||||||
copyright?: string;
|
|
||||||
description?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DocumentMeta {
|
|
||||||
title: string;
|
|
||||||
author: string;
|
|
||||||
published: string;
|
|
||||||
description?: string;
|
|
||||||
copyright: string;
|
|
||||||
frontCover?: DocumentMetaSection;
|
|
||||||
backCover?: DocumentMetaSection;
|
|
||||||
}
|
|
||||||
|
|
||||||
const META_FILES = ['_meta.yaml', '_meta.yml'];
|
|
||||||
|
|
||||||
const DEFAULT_META: DocumentMeta = {
|
|
||||||
title: 'Template',
|
|
||||||
author: 'ろむねこ',
|
|
||||||
published: '0000/000/00',
|
|
||||||
copyright: 'template',
|
|
||||||
description: ''
|
|
||||||
};
|
|
||||||
|
|
||||||
function toRecord(value: unknown): Record<string, unknown> | undefined {
|
|
||||||
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
|
||||||
return value as Record<string, unknown>;
|
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
function pickString(value: unknown): string | undefined {
|
|
||||||
if (typeof value === 'string') {
|
|
||||||
const trimmed = value.trim();
|
|
||||||
return trimmed.length > 0 ? trimmed : undefined;
|
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildSection(value: unknown): DocumentMetaSection | undefined {
|
|
||||||
const record = toRecord(value);
|
|
||||||
if (!record) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
const section: DocumentMetaSection = {};
|
|
||||||
const title = pickString(record.title);
|
|
||||||
const author = pickString(record.author);
|
|
||||||
const published = pickString(record.published);
|
|
||||||
const pubDate = pickString(record.pubDate);
|
|
||||||
const copyright = pickString(record.copyright);
|
|
||||||
const description = pickString(record.description);
|
|
||||||
|
|
||||||
if (title) section.title = title;
|
|
||||||
if (author) section.author = author;
|
|
||||||
if (published) section.published = published;
|
|
||||||
if (pubDate) section.pubDate = pubDate;
|
|
||||||
if (copyright) section.copyright = copyright;
|
|
||||||
if (description) section.description = description;
|
|
||||||
|
|
||||||
return Object.keys(section).length > 0 ? section : undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
function mergeMeta(partial: Record<string, unknown>): DocumentMeta {
|
|
||||||
return {
|
|
||||||
title: pickString(partial.title) ?? DEFAULT_META.title,
|
|
||||||
author: pickString(partial.author) ?? DEFAULT_META.author,
|
|
||||||
published: pickString(partial.published) ?? DEFAULT_META.published,
|
|
||||||
description: pickString(partial.description) ?? DEFAULT_META.description,
|
|
||||||
copyright: pickString(partial.copyright) ?? DEFAULT_META.copyright,
|
|
||||||
frontCover: buildSection(partial.frontCover),
|
|
||||||
backCover: buildSection(partial.backCover)
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadYamlMeta(filePath: string) {
|
|
||||||
const raw = await readText(filePath);
|
|
||||||
try {
|
|
||||||
const parsed = parseYaml(raw);
|
|
||||||
const record = toRecord(parsed);
|
|
||||||
if (!record) {
|
|
||||||
throw new Error('YAML の内容がオブジェクトではありません。');
|
|
||||||
}
|
|
||||||
return mergeMeta(record);
|
|
||||||
} catch (error) {
|
|
||||||
throw new Error(`YAML を解析できませんでした (${path.basename(filePath)}): ${(error as Error).message}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function loadMeta(): Promise<DocumentMeta> {
|
|
||||||
for (const filename of META_FILES) {
|
|
||||||
const fullPath = path.join(DOCUMENTS_DIR, filename);
|
|
||||||
if (await pathExists(fullPath)) {
|
|
||||||
logger.info(`メタ情報ファイル ${filename} を読み込みます`);
|
|
||||||
const meta = await loadYamlMeta(fullPath);
|
|
||||||
logMetaInfo(meta, filename);
|
|
||||||
logger.succ('メタ情報の読み込みが完了しました');
|
|
||||||
return meta;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
logger.info('メタ情報ファイルが見つからなかったため、テンプレートのデフォルト値を使用します');
|
|
||||||
logMetaInfo(DEFAULT_META, 'default');
|
|
||||||
logger.succ('メタ情報の読み込みが完了しました');
|
|
||||||
return DEFAULT_META;
|
|
||||||
}
|
|
||||||
|
|
||||||
function logMetaInfo(meta: DocumentMeta, source: string) {
|
|
||||||
logger.info(`├─ meta source: ${source}`);
|
|
||||||
logger.info(`│ ├─ title: ${meta.title}`);
|
|
||||||
logger.info(`│ ├─ author: ${meta.author}`);
|
|
||||||
logger.info(`│ ├─ published: ${meta.published}`);
|
|
||||||
if (meta.description) {
|
|
||||||
logger.info(`│ ├─ description: ${meta.description}`);
|
|
||||||
}
|
|
||||||
logger.info(`│ └─ copyright: ${meta.copyright}`);
|
|
||||||
logSection('frontCover', meta.frontCover);
|
|
||||||
logSection('backCover', meta.backCover);
|
|
||||||
}
|
|
||||||
|
|
||||||
function logSection(label: string, section?: DocumentMetaSection) {
|
|
||||||
if (!section) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
logger.info(`├─ ${label}:`);
|
|
||||||
if (section.title) logger.info(`│ ├─ title: ${section.title}`);
|
|
||||||
if (section.author) logger.info(`│ ├─ author: ${section.author}`);
|
|
||||||
if (section.published) logger.info(`│ ├─ published: ${section.published}`);
|
|
||||||
if (section.pubDate) logger.info(`│ ├─ pubDate: ${section.pubDate}`);
|
|
||||||
if (section.description) logger.info(`│ ├─ description: ${section.description}`);
|
|
||||||
if (section.copyright) logger.info(`│ └─ copyright: ${section.copyright}`);
|
|
||||||
}
|
|
||||||
@ -1,10 +0,0 @@
|
|||||||
import { DIST_DIR, WORK_DIR } from '../config.js';
|
|
||||||
import { logger } from '../logger.js';
|
|
||||||
import { resetDir } from '../utils/fs.js';
|
|
||||||
|
|
||||||
export async function prepareWorkspace() {
|
|
||||||
logger.info('作業ディレクトリを初期化しています…');
|
|
||||||
await resetDir(WORK_DIR);
|
|
||||||
await resetDir(DIST_DIR);
|
|
||||||
logger.succ('作業ディレクトリを初期化しました');
|
|
||||||
}
|
|
||||||
@ -1,117 +0,0 @@
|
|||||||
import MarkdownIt from 'markdown-it';
|
|
||||||
import implicitFigures from 'markdown-it-implicit-figures';
|
|
||||||
import namedHeaders from 'markdown-it-named-headers';
|
|
||||||
import anchor from 'anchor-markdown-header';
|
|
||||||
import path from 'node:path';
|
|
||||||
|
|
||||||
import { DOCUMENTS_DIR } from '../config.js';
|
|
||||||
import { logger } from '../logger.js';
|
|
||||||
import { readText, writeText } from '../utils/fs.js';
|
|
||||||
import { AssetManager } from './assetManager.js';
|
|
||||||
import { FILE_MARKER_PREFIX, FILE_MARKER_SUFFIX } from './fileMarkers.js';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 元々の scripts/mdit.js (2SC1815J/md2pdf, MIT License) を TypeScript へ移植したレンダラー。
|
|
||||||
* スラッグ生成ロジックはフォーク元の実装をベースにしている。
|
|
||||||
*/
|
|
||||||
function createRenderer(assetManager: AssetManager) {
|
|
||||||
const headerInstances: Record<string, number> = {};
|
|
||||||
|
|
||||||
const renderer = new MarkdownIt({ html: true })
|
|
||||||
.use(namedHeaders, {
|
|
||||||
slugify(header: string) {
|
|
||||||
if (headerInstances[header] !== undefined) {
|
|
||||||
headerInstances[header]++;
|
|
||||||
} else {
|
|
||||||
headerInstances[header] = 0;
|
|
||||||
}
|
|
||||||
const slug = anchor(header, 'github.com', headerInstances[header]);
|
|
||||||
const match = slug.match(/]\(#(.+?)\)$/);
|
|
||||||
return match ? decodeURI(match[1]) : header;
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.use(implicitFigures, {
|
|
||||||
figcaption: true
|
|
||||||
});
|
|
||||||
|
|
||||||
const defaultImageRule = renderer.renderer.rules.image;
|
|
||||||
renderer.renderer.rules.image = (tokens, idx, options, env, self) => {
|
|
||||||
const token = tokens[idx];
|
|
||||||
const src = token.attrGet('src');
|
|
||||||
const sourceFile = (env as { currentFile?: string }).currentFile;
|
|
||||||
if (src && sourceFile) {
|
|
||||||
const resolved = assetManager.resolve(sourceFile, src);
|
|
||||||
if (resolved) {
|
|
||||||
token.attrSet('src', resolved);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (defaultImageRule) {
|
|
||||||
return defaultImageRule(tokens, idx, options, env, self);
|
|
||||||
}
|
|
||||||
return self.renderToken(tokens, idx, options);
|
|
||||||
};
|
|
||||||
|
|
||||||
return renderer;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface MarkdownSegment {
|
|
||||||
filePath: string;
|
|
||||||
content: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
function toAbsoluteDocumentPath(relativePath: string) {
|
|
||||||
return path.resolve(DOCUMENTS_DIR, relativePath.trim());
|
|
||||||
}
|
|
||||||
|
|
||||||
function splitByFileMarkers(markdown: string, defaultFile?: string): MarkdownSegment[] {
|
|
||||||
const segments: MarkdownSegment[] = [];
|
|
||||||
const markerRegex = new RegExp(`${FILE_MARKER_PREFIX}(.*?)${FILE_MARKER_SUFFIX}`, 'g');
|
|
||||||
let currentFile = defaultFile;
|
|
||||||
let lastIndex = 0;
|
|
||||||
let match: RegExpExecArray | null;
|
|
||||||
|
|
||||||
while ((match = markerRegex.exec(markdown)) !== null) {
|
|
||||||
const chunk = markdown.slice(lastIndex, match.index);
|
|
||||||
if (chunk && currentFile) {
|
|
||||||
segments.push({ filePath: currentFile, content: chunk });
|
|
||||||
}
|
|
||||||
currentFile = toAbsoluteDocumentPath(match[1]);
|
|
||||||
lastIndex = markerRegex.lastIndex;
|
|
||||||
}
|
|
||||||
|
|
||||||
const tail = markdown.slice(lastIndex);
|
|
||||||
if (tail && currentFile) {
|
|
||||||
segments.push({ filePath: currentFile, content: tail });
|
|
||||||
}
|
|
||||||
|
|
||||||
return segments;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function renderMarkdownToHtml(
|
|
||||||
markdownPath: string,
|
|
||||||
outputPath: string,
|
|
||||||
assetManager: AssetManager,
|
|
||||||
orderedFiles: string[]
|
|
||||||
) {
|
|
||||||
logger.info('Markdown から HTML へ変換しています…');
|
|
||||||
const markdown = await readText(markdownPath);
|
|
||||||
const renderer = createRenderer(assetManager);
|
|
||||||
const defaultFile = orderedFiles[0];
|
|
||||||
const segments = splitByFileMarkers(markdown, defaultFile);
|
|
||||||
|
|
||||||
if (segments.length === 0) {
|
|
||||||
const html = renderer.render(markdown);
|
|
||||||
await writeText(outputPath, html);
|
|
||||||
logger.succ(`${path.relative('.', outputPath)} を生成しました`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const htmlParts: string[] = [];
|
|
||||||
for (const segment of segments) {
|
|
||||||
htmlParts.push(renderer.render(segment.content, { currentFile: segment.filePath }));
|
|
||||||
}
|
|
||||||
|
|
||||||
const html = htmlParts.join('');
|
|
||||||
await writeText(outputPath, html);
|
|
||||||
logger.succ(`${path.relative('.', outputPath)} を生成しました`);
|
|
||||||
}
|
|
||||||
@ -1,32 +0,0 @@
|
|||||||
import { promisify } from 'node:util';
|
|
||||||
import path from 'node:path';
|
|
||||||
import ejs from 'ejs';
|
|
||||||
import tidy from 'htmltidy2';
|
|
||||||
|
|
||||||
import { logger } from '../logger.js';
|
|
||||||
import { writeText } from '../utils/fs.js';
|
|
||||||
import { DocumentMeta } from './loadMeta.js';
|
|
||||||
|
|
||||||
const renderFile = promisify(ejs.renderFile);
|
|
||||||
const tidyHtml = promisify(tidy.tidy);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* scripts/ejs.js (2SC1815J/md2pdf, MIT License) を TypeScript 化し、
|
|
||||||
* HTML Tidy 設定もフォーク元に合わせている。
|
|
||||||
*/
|
|
||||||
export async function renderTemplate(templatePath: string, outputPath: string, meta: DocumentMeta) {
|
|
||||||
logger.info('テンプレート HTML を描画しています…');
|
|
||||||
const rendered = await renderFile(templatePath, { meta });
|
|
||||||
const tidied = await tidyHtml(rendered, {
|
|
||||||
doctype: 'html5',
|
|
||||||
indent: 'auto',
|
|
||||||
wrap: 0,
|
|
||||||
tidyMark: false,
|
|
||||||
quoteAmpersand: false,
|
|
||||||
hideComments: true,
|
|
||||||
dropEmptyElements: false,
|
|
||||||
newline: 'LF'
|
|
||||||
});
|
|
||||||
await writeText(outputPath, tidied);
|
|
||||||
logger.succ(`${path.relative('.', outputPath)} を整形済み HTML として保存しました`);
|
|
||||||
}
|
|
||||||
@ -1,29 +0,0 @@
|
|||||||
import { promises as fs } from 'node:fs';
|
|
||||||
import path from 'node:path';
|
|
||||||
|
|
||||||
export async function ensureDir(targetPath: string) {
|
|
||||||
await fs.mkdir(targetPath, { recursive: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function resetDir(targetPath: string) {
|
|
||||||
await fs.rm(targetPath, { recursive: true, force: true });
|
|
||||||
await fs.mkdir(targetPath, { recursive: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function readText(filePath: string) {
|
|
||||||
return fs.readFile(filePath, 'utf8');
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function writeText(filePath: string, content: string) {
|
|
||||||
await ensureDir(path.dirname(filePath));
|
|
||||||
await fs.writeFile(filePath, content, 'utf8');
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function pathExists(filePath: string) {
|
|
||||||
try {
|
|
||||||
await fs.access(filePath);
|
|
||||||
return true;
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -3,46 +3,25 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="openaction" content="#view=fit">
|
<meta name="openaction" content="#view=fit">
|
||||||
<meta name="author" content="<%= meta.author %>" />
|
<meta name="author" content="r-ca" />
|
||||||
<% if (meta.description && meta.description.length > 0) { %>
|
<title>Template</title>
|
||||||
<meta name="description" content="<%= meta.description %>" />
|
|
||||||
<% } %>
|
|
||||||
<title><%= meta.title %></title>
|
|
||||||
<link rel="stylesheet" href="../css/github-markdown.css">
|
<link rel="stylesheet" href="../css/github-markdown.css">
|
||||||
<link rel="stylesheet" href="../css/custom.css">
|
<link rel="stylesheet" href="../css/custom.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<%
|
|
||||||
const frontCover = meta.frontCover || {};
|
|
||||||
const backCover = meta.backCover || {};
|
|
||||||
const frontTitle = frontCover.title || meta.title;
|
|
||||||
const frontAuthor = frontCover.author || meta.author;
|
|
||||||
const frontPublished = frontCover.published || frontCover.pubDate || meta.published;
|
|
||||||
const frontDescription = frontCover.description || meta.description;
|
|
||||||
const backTitle = backCover.title || meta.title;
|
|
||||||
const backPublished = backCover.pubDate || backCover.published || meta.published;
|
|
||||||
const backCopyright = backCover.copyright || meta.copyright;
|
|
||||||
const backDescription = backCover.description || meta.description;
|
|
||||||
%>
|
|
||||||
<div class="FrontCover">
|
<div class="FrontCover">
|
||||||
<h1><%= frontTitle %></h1>
|
<h1>Template</h1>
|
||||||
<div class="Published"><%= frontPublished %></div>
|
<div class="Published">0000/000/00</div>
|
||||||
<div class="Author"><%= frontAuthor %></div>
|
<div class="Author">ろむねこ</div>
|
||||||
<% if (frontDescription) { %>
|
|
||||||
<p class="Description"><%= frontDescription %></p>
|
|
||||||
<% } %>
|
|
||||||
</div>
|
</div>
|
||||||
<article class="markdown-body">
|
<article class="markdown-body">
|
||||||
<%- include('../work/all_md.html') %>
|
<%- include('../work/all_md.html') %>
|
||||||
</article>
|
</article>
|
||||||
<div class="BackCover">
|
<div class="BackCover">
|
||||||
<% if (backDescription) { %>
|
|
||||||
<p class="Description"><%= backDescription %></p>
|
|
||||||
<% } %>
|
|
||||||
<div class="Colophon">
|
<div class="Colophon">
|
||||||
<span class="Title"><%= backTitle %></span><br>
|
<span class="Title">Template</span><br>
|
||||||
<span class="PubDate"><%= backPublished %></span><br>
|
<span class="PubDate">0000/000/00</span><br>
|
||||||
<span class="Copyright"><%= backCopyright %></span>
|
<span class="Copyright">template</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@ -3,7 +3,7 @@
|
|||||||
"target": "es2022",
|
"target": "es2022",
|
||||||
"module": "es2022",
|
"module": "es2022",
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"moduleResolution": "nodenext",
|
"moduleResolution": "node",
|
||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
"typeRoots": ["./node_modules/@types", "./src/types"],
|
"typeRoots": ["./node_modules/@types", "./src/types"],
|
||||||
"emitDecoratorMetadata": true,
|
"emitDecoratorMetadata": true,
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user