From 657a062d0ced3463b0cf184858cde1829aac5d2c Mon Sep 17 00:00:00 2001 From: rca <_@r0m.me> Date: Mon, 1 Dec 2025 15:57:29 +0900 Subject: [PATCH] feat: replace build pipeline with ts cli --- package-lock.json | 75 +++++++++++++++---- package.json | 10 +-- scripts/convert.ts | 29 -------- scripts/ejs.js | 37 ---------- scripts/mdit.js | 46 ------------ src/cli.ts | 37 ++++++++++ src/config.ts | 19 +++++ src/logger.ts | 34 +++++++++ src/pipeline/assetManager.ts | 122 +++++++++++++++++++++++++++++++ src/pipeline/concatMarkdown.ts | 55 ++++++++++++++ src/pipeline/fileMarkers.ts | 6 ++ src/pipeline/generatePdf.ts | 96 ++++++++++++++++++++++++ src/pipeline/generateToc.ts | 19 +++++ src/pipeline/inlineAssets.ts | 13 ++++ src/pipeline/prepareWorkspace.ts | 10 +++ src/pipeline/renderMarkdown.ts | 117 +++++++++++++++++++++++++++++ src/pipeline/renderTemplate.ts | 31 ++++++++ src/utils/fs.ts | 29 ++++++++ tsconfig.json | 2 +- 19 files changed, 653 insertions(+), 134 deletions(-) delete mode 100644 scripts/convert.ts delete mode 100755 scripts/ejs.js delete mode 100755 scripts/mdit.js create mode 100644 src/cli.ts create mode 100644 src/config.ts create mode 100644 src/logger.ts create mode 100644 src/pipeline/assetManager.ts create mode 100644 src/pipeline/concatMarkdown.ts create mode 100644 src/pipeline/fileMarkers.ts create mode 100644 src/pipeline/generatePdf.ts create mode 100644 src/pipeline/generateToc.ts create mode 100644 src/pipeline/inlineAssets.ts create mode 100644 src/pipeline/prepareWorkspace.ts create mode 100644 src/pipeline/renderMarkdown.ts create mode 100644 src/pipeline/renderTemplate.ts create mode 100644 src/utils/fs.ts diff --git a/package-lock.json b/package-lock.json index 7626cef..2df01d1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,14 +1,16 @@ { - "name": "md2pdf-mod", + "name": "md2pdf-meow", "version": "1.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "md2pdf-mod", + "name": "md2pdf-meow", "version": "1.0.0", "license": "MIT", "dependencies": { + "@types/chalk": "^2.2.4", + "chalk": "^5.6.2", "puppeteer-html-pdf": "^4.0.8", "tsx": "^4.17.0" }, @@ -64,6 +66,19 @@ "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": { "version": "0.23.0", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.23.0.tgz", @@ -534,6 +549,15 @@ "integrity": "sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==", "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": { "version": "22.2.0", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.2.0.tgz", @@ -944,17 +968,14 @@ } }, "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==", - "license": "MIT", - "dependencies": { - "ansi-styles": "^3.2.1", - "escape-string-regexp": "^1.0.5", - "supports-color": "^5.3.0" - }, + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", "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": { @@ -1832,6 +1853,20 @@ "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": { "version": "4.3.6", "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.6.tgz", @@ -2407,7 +2442,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", - "license": "MIT", "engines": { "node": ">=4" } @@ -2870,6 +2904,20 @@ "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": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-5.2.0.tgz", @@ -4588,7 +4636,6 @@ "version": "5.5.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", - "license": "MIT", "dependencies": { "has-flag": "^3.0.0" }, diff --git a/package.json b/package.json index b31fd65..cbffd0e 100644 --- a/package.json +++ b/package.json @@ -3,13 +3,7 @@ "version": "1.0.0", "description": "Convert Markdown documents to PDF🐱", "scripts": { - "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" + "build": "tsx src/cli.ts" }, "author": "rca", "license": "MIT", @@ -28,6 +22,8 @@ "typescript": "^5.5.4" }, "dependencies": { + "@types/chalk": "^2.2.4", + "chalk": "^5.6.2", "puppeteer-html-pdf": "^4.0.8", "tsx": "^4.17.0" } diff --git a/scripts/convert.ts b/scripts/convert.ts deleted file mode 100644 index 0a05246..0000000 --- a/scripts/convert.ts +++ /dev/null @@ -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(); diff --git a/scripts/ejs.js b/scripts/ejs.js deleted file mode 100755 index 73d5bfd..0000000 --- a/scripts/ejs.js +++ /dev/null @@ -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); - }); \ No newline at end of file diff --git a/scripts/mdit.js b/scripts/mdit.js deleted file mode 100755 index 3b975ae..0000000 --- a/scripts/mdit.js +++ /dev/null @@ -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); - }); \ No newline at end of file diff --git a/src/cli.ts b/src/cli.ts new file mode 100644 index 0000000..143e28a --- /dev/null +++ b/src/cli.ts @@ -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(); diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..e5fc2b2 --- /dev/null +++ b/src/config.ts @@ -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'); diff --git a/src/logger.ts b/src/logger.ts new file mode 100644 index 0000000..a9b5112 --- /dev/null +++ b/src/logger.ts @@ -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)); + } +}; diff --git a/src/pipeline/assetManager.ts b/src/pipeline/assetManager.ts new file mode 100644 index 0000000..97deca3 --- /dev/null +++ b/src/pipeline/assetManager.ts @@ -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(); + private copiedTargets = new Set(); + + 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))) + ); + } +} diff --git a/src/pipeline/concatMarkdown.ts b/src/pipeline/concatMarkdown.ts new file mode 100644 index 0000000..b4b9d2f --- /dev/null +++ b/src/pipeline/concatMarkdown.ts @@ -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 { + 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 + }; +} diff --git a/src/pipeline/fileMarkers.ts b/src/pipeline/fileMarkers.ts new file mode 100644 index 0000000..44f3b29 --- /dev/null +++ b/src/pipeline/fileMarkers.ts @@ -0,0 +1,6 @@ +export const FILE_MARKER_PREFIX = ''; + +export function createFileMarker(relativePath: string) { + return `${FILE_MARKER_PREFIX}${relativePath}${FILE_MARKER_SUFFIX}`; +} diff --git a/src/pipeline/generatePdf.ts b/src/pipeline/generatePdf.ts new file mode 100644 index 0000000..c4be225 --- /dev/null +++ b/src/pipeline/generatePdf.ts @@ -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)} を作成しました`); +} diff --git a/src/pipeline/generateToc.ts b/src/pipeline/generateToc.ts new file mode 100644 index 0000000..c310c19 --- /dev/null +++ b/src/pipeline/generateToc.ts @@ -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)} に書き込みました`); +} diff --git a/src/pipeline/inlineAssets.ts b/src/pipeline/inlineAssets.ts new file mode 100644 index 0000000..d34d3ac --- /dev/null +++ b/src/pipeline/inlineAssets.ts @@ -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 を出力しました`); +} diff --git a/src/pipeline/prepareWorkspace.ts b/src/pipeline/prepareWorkspace.ts new file mode 100644 index 0000000..6ef2122 --- /dev/null +++ b/src/pipeline/prepareWorkspace.ts @@ -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('作業ディレクトリを初期化しました'); +} diff --git a/src/pipeline/renderMarkdown.ts b/src/pipeline/renderMarkdown.ts new file mode 100644 index 0000000..ccdced7 --- /dev/null +++ b/src/pipeline/renderMarkdown.ts @@ -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 = {}; + + 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)} を生成しました`); +} diff --git a/src/pipeline/renderTemplate.ts b/src/pipeline/renderTemplate.ts new file mode 100644 index 0000000..57219ce --- /dev/null +++ b/src/pipeline/renderTemplate.ts @@ -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 として保存しました`); +} diff --git a/src/utils/fs.ts b/src/utils/fs.ts new file mode 100644 index 0000000..5d39d0a --- /dev/null +++ b/src/utils/fs.ts @@ -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; + } +} diff --git a/tsconfig.json b/tsconfig.json index 386c1d7..e9cc2f1 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -3,7 +3,7 @@ "target": "es2022", "module": "es2022", "strict": true, - "moduleResolution": "node", + "moduleResolution": "nodenext", "esModuleInterop": true, "typeRoots": ["./node_modules/@types", "./src/types"], "emitDecoratorMetadata": true,