Template
mirror of
https://github.com/r-ca/md2pdf-meow.git
synced 2026-11-08 19:37:36 +00:00
feat: replace build pipeline with ts cli
This commit is contained in:
1 parent
899593dcac
commit
657a062d0c
19 files changed
+653
-134
No files matched your search
@@ -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)))
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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)} を作成しました`);
|
||||
}
|
||||
@@ -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)} に書き込みました`);
|
||||
}
|
||||
@@ -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 を出力しました`);
|
||||
}
|
||||
@@ -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('作業ディレクトリを初期化しました');
|
||||
}
|
||||
@@ -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)} を生成しました`);
|
||||
}
|
||||
@@ -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 として保存しました`);
|
||||
}
|
||||
Reference in new issue
Block a user