feat: replace build pipeline with ts cli

This commit is contained in:
rca 2025-12-01 15:57:29 +09:00
parent 899593dcac
commit 657a062d0c
19 changed files with 653 additions and 134 deletions

75
package-lock.json generated
View File

@ -1,14 +1,16 @@
{ {
"name": "md2pdf-mod", "name": "md2pdf-meow",
"version": "1.0.0", "version": "1.0.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "md2pdf-mod", "name": "md2pdf-meow",
"version": "1.0.0", "version": "1.0.0",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@types/chalk": "^2.2.4",
"chalk": "^5.6.2",
"puppeteer-html-pdf": "^4.0.8", "puppeteer-html-pdf": "^4.0.8",
"tsx": "^4.17.0" "tsx": "^4.17.0"
}, },
@ -64,6 +66,19 @@
"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",
@ -534,6 +549,15 @@
"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",
@ -944,17 +968,14 @@
} }
}, },
"node_modules/chalk": { "node_modules/chalk": {
"version": "2.4.2", "version": "5.6.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz",
"integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==",
"license": "MIT",
"dependencies": {
"ansi-styles": "^3.2.1",
"escape-string-regexp": "^1.0.5",
"supports-color": "^5.3.0"
},
"engines": { "engines": {
"node": ">=4" "node": "^12.17.0 || ^14.13 || >=16.0.0"
},
"funding": {
"url": "https://github.com/chalk/chalk?sponsor=1"
} }
}, },
"node_modules/character-entities": { "node_modules/character-entities": {
@ -1832,6 +1853,20 @@
"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",
@ -2407,7 +2442,6 @@
"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"
} }
@ -2870,6 +2904,20 @@
"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",
@ -4588,7 +4636,6 @@
"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"
}, },

View File

@ -3,13 +3,7 @@
"version": "1.0.0", "version": "1.0.0",
"description": "Convert Markdown documents to PDF🐱", "description": "Convert Markdown documents to PDF🐱",
"scripts": { "scripts": {
"build:step1": "npx minicat $(sed 's|^|documents/|' documents/files.txt) > work/all.md", "build": "tsx src/cli.ts"
"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",
@ -28,6 +22,8 @@
"typescript": "^5.5.4" "typescript": "^5.5.4"
}, },
"dependencies": { "dependencies": {
"@types/chalk": "^2.2.4",
"chalk": "^5.6.2",
"puppeteer-html-pdf": "^4.0.8", "puppeteer-html-pdf": "^4.0.8",
"tsx": "^4.17.0" "tsx": "^4.17.0"
} }

View File

@ -1,29 +0,0 @@
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();

View File

@ -1,37 +0,0 @@
/*
* 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);
});

View File

@ -1,46 +0,0 @@
/*
* 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);
});

37
src/cli.ts Normal file
View File

@ -0,0 +1,37 @@
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';
async function runPipeline() {
logger.info('md2pdf-meow のビルドを開始します。');
await prepareWorkspace();
const assetManager = new AssetManager();
const { outputPath: markdownPath, orderedFiles } = await concatMarkdown(assetManager);
await injectToc(markdownPath);
await renderMarkdownToHtml(markdownPath, WORK_MARKDOWN_HTML, assetManager, orderedFiles);
await renderTemplate(TEMPLATE_FILE, WORK_TEMPLATE_HTML);
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();

19
src/config.ts Normal file
View File

@ -0,0 +1,19 @@
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');

34
src/logger.ts Normal file
View File

@ -0,0 +1,34 @@
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));
}
};

View File

@ -0,0 +1,122 @@
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 });
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 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 copiedTargets = new Set<string>();
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);
if (!this.copiedTargets.has(targetPath)) {
await ensureDir(path.dirname(targetPath));
await fs.copyFile(absoluteSource, targetPath);
this.copiedTargets.add(targetPath);
logger.succ(`画像をコピー: ${path.relative('.', targetPath)} (元: ${relativeFromDocuments})`);
}
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)))
);
}
}

View File

@ -0,0 +1,55 @@
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
};
}

View File

@ -0,0 +1,6 @@
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}`;
}

View File

@ -0,0 +1,96 @@
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)} を作成しました`);
}

View File

@ -0,0 +1,19 @@
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)} に書き込みました`);
}

View File

@ -0,0 +1,13 @@
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 を出力しました`);
}

View File

@ -0,0 +1,10 @@
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('作業ディレクトリを初期化しました');
}

View File

@ -0,0 +1,117 @@
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)} を生成しました`);
}

View File

@ -0,0 +1,31 @@
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';
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) {
logger.info('テンプレート HTML を描画しています…');
const rendered = await renderFile(templatePath);
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 として保存しました`);
}

29
src/utils/fs.ts Normal file
View File

@ -0,0 +1,29 @@
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;
}
}

View File

@ -3,7 +3,7 @@
"target": "es2022", "target": "es2022",
"module": "es2022", "module": "es2022",
"strict": true, "strict": true,
"moduleResolution": "node", "moduleResolution": "nodenext",
"esModuleInterop": true, "esModuleInterop": true,
"typeRoots": ["./node_modules/@types", "./src/types"], "typeRoots": ["./node_modules/@types", "./src/types"],
"emitDecoratorMetadata": true, "emitDecoratorMetadata": true,