From 8a65b037800ed04877c25080741a9fe99ffd1ff0 Mon Sep 17 00:00:00 2001 From: rca <_@r0m.me> Date: Mon, 1 Dec 2025 16:11:28 +0900 Subject: [PATCH] feat: support yaml-based template meta --- README.md | 19 ++++++ documents/_meta.yaml | 13 ++++ package-lock.json | 27 ++++++++- package.json | 4 +- src/cli.ts | 4 +- src/pipeline/loadMeta.ts | 108 +++++++++++++++++++++++++++++++++ src/pipeline/renderTemplate.ts | 5 +- template/template.html | 19 +++--- 8 files changed, 186 insertions(+), 13 deletions(-) create mode 100644 documents/_meta.yaml create mode 100644 src/pipeline/loadMeta.ts diff --git a/README.md b/README.md index 3343422..bb22f5f 100644 --- a/README.md +++ b/README.md @@ -15,5 +15,24 @@ TypeScript ベースの Markdown → HTML → PDF 変換パイプラインです - `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 + backCover: + title: md2pdf-meow + pubDate: 2024/07/01 + copyright: rca + ``` +- `frontCover` / `backCover` の各値は任意で、未指定時には `title` や `published` などの共通値が利用されます。 + ## 移植元 - https://github.com/2SC1815J/md2pdf diff --git a/documents/_meta.yaml b/documents/_meta.yaml new file mode 100644 index 0000000..627d411 --- /dev/null +++ b/documents/_meta.yaml @@ -0,0 +1,13 @@ +title: Template +author: ろむねこ +published: 0000/000/00 +description: "" +copyright: template +frontCover: + title: Template + author: ろむねこ + published: 0000/000/00 +backCover: + title: Template + pubDate: 0000/000/00 + copyright: template diff --git a/package-lock.json b/package-lock.json index 2df01d1..abc7b6a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,9 +10,11 @@ "license": "MIT", "dependencies": { "@types/chalk": "^2.2.4", + "@types/yaml": "^1.9.7", "chalk": "^5.6.2", "puppeteer-html-pdf": "^4.0.8", - "tsx": "^4.17.0" + "tsx": "^4.17.0", + "yaml": "^2.8.2" }, "devDependencies": { "anchor-markdown-header": "^0.5.7", @@ -568,6 +570,15 @@ "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": { "version": "2.10.3", "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz", @@ -5486,6 +5497,20 @@ "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": { "version": "17.7.2", "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", diff --git a/package.json b/package.json index cbffd0e..641a78e 100644 --- a/package.json +++ b/package.json @@ -23,8 +23,10 @@ }, "dependencies": { "@types/chalk": "^2.2.4", + "@types/yaml": "^1.9.7", "chalk": "^5.6.2", "puppeteer-html-pdf": "^4.0.8", - "tsx": "^4.17.0" + "tsx": "^4.17.0", + "yaml": "^2.8.2" } } diff --git a/src/cli.ts b/src/cli.ts index 143e28a..0c2f490 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -8,6 +8,7 @@ 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 のビルドを開始します。'); @@ -16,7 +17,8 @@ async function runPipeline() { 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); + 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); } diff --git a/src/pipeline/loadMeta.ts b/src/pipeline/loadMeta.ts new file mode 100644 index 0000000..84ac725 --- /dev/null +++ b/src/pipeline/loadMeta.ts @@ -0,0 +1,108 @@ +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; +} + +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 | undefined { + if (value && typeof value === 'object' && !Array.isArray(value)) { + return value as Record; + } + 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); + + 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; + + return Object.keys(section).length > 0 ? section : undefined; +} + +function mergeMeta(partial: Record): 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 { + for (const filename of META_FILES) { + const fullPath = path.join(DOCUMENTS_DIR, filename); + if (await pathExists(fullPath)) { + logger.info(`メタ情報ファイル ${filename} を読み込みます`); + return loadYamlMeta(fullPath); + } + } + logger.info('メタ情報ファイルが見つからなかったため、テンプレートのデフォルト値を使用します'); + return DEFAULT_META; +} diff --git a/src/pipeline/renderTemplate.ts b/src/pipeline/renderTemplate.ts index 57219ce..d2caa59 100644 --- a/src/pipeline/renderTemplate.ts +++ b/src/pipeline/renderTemplate.ts @@ -5,6 +5,7 @@ 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); @@ -13,9 +14,9 @@ const tidyHtml = promisify(tidy.tidy); * scripts/ejs.js (2SC1815J/md2pdf, MIT License) を TypeScript 化し、 * HTML Tidy 設定もフォーク元に合わせている。 */ -export async function renderTemplate(templatePath: string, outputPath: string) { +export async function renderTemplate(templatePath: string, outputPath: string, meta: DocumentMeta) { logger.info('テンプレート HTML を描画しています…'); - const rendered = await renderFile(templatePath); + const rendered = await renderFile(templatePath, { meta }); const tidied = await tidyHtml(rendered, { doctype: 'html5', indent: 'auto', diff --git a/template/template.html b/template/template.html index 10684cd..28d680d 100644 --- a/template/template.html +++ b/template/template.html @@ -3,25 +3,28 @@ - - Template + + <% if (meta.description && meta.description.length > 0) { %> + + <% } %> + <%= meta.title %>
-

Template

-
0000/000/00
-
ろむねこ
+

<%= (meta.frontCover && meta.frontCover.title) || meta.title %>

+
<%= (meta.frontCover && (meta.frontCover.published || meta.frontCover.pubDate)) || meta.published %>
+
<%= (meta.frontCover && meta.frontCover.author) || meta.author %>
<%- include('../work/all_md.html') %>
- Template
- 0000/000/00
- template + <%= (meta.backCover && meta.backCover.title) || meta.title %>
+ <%= (meta.backCover && (meta.backCover.pubDate || meta.backCover.published)) || meta.published %>
+ <%= (meta.backCover && meta.backCover.copyright) || meta.copyright %>