Simplify CLI examples manifest loading

This commit is contained in:
hand-dot
2026-03-24 17:55:29 +09:00
parent 36b2f06998
commit 7ea06d0a25
7 changed files with 187 additions and 342 deletions

34
PLAN.md
View File

@@ -4,7 +4,7 @@ Last updated: 2026-03-24 JST
Latest committed checkpoint:
- `f7dda37f` `Fix CodeQL alerts in schemas`
- `36b2f069` `Harden CLI examples and contract tests`
## Context
@@ -93,33 +93,34 @@ Phase 2A で固定する対象:
- `validate` の unified job / stdin 対応
- `pdf2img -o` の directory-only 明確化
- implicit `output.pdf` の safety guard
- `examples`remote fetch + local cache + versioned manifest
- `examples`official manifest / template fetch を structured contract で扱うよう整理
- `signature` の公式 plugin 化
- official example font を unified job `options.font` に埋め込む対応
- playground asset manifest の metadata-aware 化
- manifest 掲載 templates の renderability を確認する CLI integration test 追加
- manifest 掲載 official examples を `examples` / `generate` の real CLI path で全件 green にする E2E 追加
- `pdf2size` の CLI test 追加
- unknown flag / malformed JSON / malformed stdin / invalid enum / overwrite などの cross-command contract test 追加
- version 表示の `0.0.0` 固定解消
- schema plugin 解決を export 追従の自動収集へ整理
### 2.4 Remaining Work
- `examples` の fetch / cache / versioning を CI gate 化する
- `--json` contract の cross-command failure matrix を詰める
- invalid args / offline behavior / overwrite policy の E2E matrix を詰める
- playground 側 official assets を CLI 契約に対して壊れにくく保つ
- official examples manifest の整合性を CI gate 化する
- playground 側 official assets を real CLI path に対して壊れにくく保つ
- examples metadata を CLI contract 観点でより明示的にする
- `validate` の出力拡張で inspection needs を吸収する
### 2.5 Exit Criteria
1. official examples の CLI 対応範囲が manifest / metadata で定義されている
2. CLI 対応 examples は CI で generate green
3. `examples` が remote fetch + local cache + versioned manifest を持ち、挙動が deterministic
2. CLI 対応 examples は CI で real CLI path の generate green
3. `examples` は current official manifest を取得して deterministic に利用できる
4. `--json` 指定時、全コマンドの成功失敗が JSON で parse 可能
5. invalid args / invalid enum / invalid number / invalid page range が fail-fast + non-zero
6. `validate` が template-only / unified job / stdin / `--strict` を一貫して扱える
7. font / plugin / malformed input が internal crash ではなく structured error になる
8. `generate` / `validate` / `pdf2img` / `pdf2size` が examples 初回取得とは独立にオフラインで成立する
8. `generate` / `validate` / `pdf2img` / `pdf2size` が examples 取得とは独立にオフラインで成立する
9. user-facing version 表示が workspace 内でも正しい
### 2.6 Fixed Policies
@@ -130,6 +131,7 @@ Examples:
- examples は onboarding 用サンプルであると同時に、将来の AI / RAG 資産として扱う
- manifest 掲載 assets は contract-first で整備する
- CLI で安定サポートできない asset は、official CLI-supported examples としては扱わない
- `examples` は convenience command として扱い、current official manifest を参照する
Plugin:
@@ -157,8 +159,8 @@ I/O:
Offline:
- `examples` 初回取得だけは network 前提でよい
- cache 済み examples と core commands は offline で成立させる
- core commands は local input があれば offline で成立させる
- `examples` は network 前提の convenience command として扱う
Validation / inspection:
@@ -255,10 +257,10 @@ AI に「雇用契約書を作って」などと依頼したときに、AI が
## Next Order
1. invalid args / JSON failure / offline behavior / overwrite safety の E2E matrix を追加する
2. examples fetch / cache / versioning を CI gate 化する
1. official examples manifest の整合性を CI で固定する
2. manifest 掲載 official examples の real CLI path green を CI で維持する
3. `validate` の出力拡張で inspection needs を吸収する
4. Phase 2A 完了後に `doctor` を再評価する
4. Phase 2A 完了判定を行い、その後に `doctor` を再評価する
## Verification Summary
@@ -269,13 +271,14 @@ AI に「雇用契約書を作って」などと依頼したときに、AI が
- playground は package root export 前提で動作する構成に整理済み
- official examples / manifest / plugin / font 周りの CLI integration test を追加済み
- manifest 掲載 official examples は real CLI path の `examples` / `generate` 経由で green を確認済み
- `--json` failure contract は cross-command test で固定を進めている
## Known Risks
| リスク | 対策 |
|--------|------|
| official examples と playground assets の乖離 | shared manifest と renderability test を維持する |
| remote fetch 前提での再現性低下 | versioned manifest + local cache + CI gate |
| examples manifest 更新漏れ | manifest 整合性 test と real CLI path test を CI で維持する |
| font / plugin 契約の揺れ | 短期 contract を固定し unsupported は structured error に寄せる |
| `--json` / invalid args の挙動差 | cross-command E2E matrix で固定する |
| `vp lint` の type-aware lint 完全移行未了 | 型検証 gate は当面 `tsc -b` を維持する |
@@ -290,5 +293,6 @@ AI に「雇用契約書を作って」などと依頼したときに、AI が
- examples fixture は localhost server ではなく preload した fetch shim で差し替える
- `inspect``validate` の出力拡張へ吸収する
- CLI は公式 plugin 専用の方針で進める
- Next priority は command 追加ではなく manifest 整合性と real CLI path green の CI 固定
- `doctor` は Phase 2A 完了後の operational UX として扱う
- rich text / markdown / `md2pdf` は別トラックとして spec から検討する

View File

@@ -328,17 +328,12 @@ $ pdfme pdf2size invoice.pdf --json
`https://playground.pdfme.com/template-assets/` で配信しているテンプレート資産を参照・出力する。AI エージェントがテンプレートを新規作成する際の構造参考として使える。
デフォルトでは CLI version に対応する version-pinned manifest を取得し、取得結果をローカル cache に保存する。remote が落ちても cache があれば継続利用できる。`--latest` を付けると unpinned の最新 manifest を取りにいく。
### 使い方
```bash
# テンプレート一覧
pdfme examples --list
# version pin を無視して最新 manifest を取得
pdfme examples --list --latest
# テンプレートを stdout に出力
pdfme examples invoice
@@ -478,8 +473,7 @@ Vite で `target: node20`, 全依存を external にして単一 `dist/index.js`
- **フォント複数指定**: citty が repeated string args を未サポートのため、カンマ区切り形式 (`--font "A=a.ttf,B=b.ttf"`) を使用
- **カスタムフォント形式**: 現時点の公式サポートは `.ttf` のみ。`.otf` / `.ttc` は unsupported error を返す
- **examples コマンド**: 初回取得はネットワーク接続が必要。manifest / template が cache 済みなら offline fallback で継続利用できる。取得先は `PDFME_EXAMPLES_BASE_URL` 環境変数で上書き可能
- **examples cache**: cache ルートは `PDFME_EXAMPLES_CACHE_DIR` で上書き可能。default は `~/.pdfme/examples`
- **examples コマンド**: current の official manifest / template を network 越しに取得する convenience command。取得先は `PDFME_EXAMPLES_BASE_URL` 環境変数で上書き可能
- **NotoSansJP の DL URL**: Google Fonts CDN の可変ウェイトフォント (~16MB) を使用。固定ウェイト版への切り替えでサイズ削減可能
## 今後の拡張 (v2 以降)

View File

@@ -21,19 +21,14 @@ const FONT_FIXTURES_DIR = resolve(
'fonts',
);
function createFixtureEnv(
cacheDir: string,
fetchMode: 'online' | 'offline' = 'online',
): NodeJS.ProcessEnv {
const homeDir = join(cacheDir, 'home');
function createFixtureEnv(rootDir: string): NodeJS.ProcessEnv {
const homeDir = join(rootDir, 'home');
return {
...process.env,
HOME: homeDir,
PDFME_EXAMPLES_BASE_URL: 'https://fixtures.example.com/template-assets',
PDFME_EXAMPLES_CACHE_DIR: cacheDir,
PDFME_TEST_ASSETS_DIR: ASSETS_DIR,
PDFME_TEST_FONT_FIXTURES_DIR: FONT_FIXTURES_DIR,
PDFME_TEST_FETCH_MODE: fetchMode,
};
}
@@ -64,8 +59,7 @@ describe('examples integration smoke', () => {
it('uses a playground example to generate a PDF through the CLI', () => {
mkdirSync(TMP, { recursive: true });
const cacheDir = join(TMP, 'cache');
const env = createFixtureEnv(cacheDir);
const env = createFixtureEnv(TMP);
const jobPath = join(TMP, 'invoice-job.json');
const pdfPath = join(TMP, 'invoice.pdf');
@@ -88,44 +82,25 @@ describe('examples integration smoke', () => {
expect(existsSync(pdfPath)).toBe(true);
});
it('uses the cached manifest after remote fetch failures', () => {
it('lists manifest metadata through the CLI', () => {
mkdirSync(TMP, { recursive: true });
const cacheDir = join(TMP, 'cache-offline');
const onlineResult = runCli(['examples', '--list', '--json'], {
env: createFixtureEnv(cacheDir, 'online'),
});
expect(onlineResult.exitCode).toBe(0);
expect(JSON.parse(onlineResult.stdout).source).toBe('remote');
const offlineResult = runCli(['examples', '--list', '--json'], {
env: createFixtureEnv(cacheDir, 'offline'),
});
expect(offlineResult.exitCode).toBe(0);
expect(JSON.parse(offlineResult.stdout).source).toBe('cache');
});
it('returns structured JSON when offline without a cached manifest', () => {
mkdirSync(TMP, { recursive: true });
const cacheDir = join(TMP, 'cache-empty-offline');
const result = runCli(['examples', '--list', '--json'], {
env: createFixtureEnv(cacheDir, 'offline'),
env: createFixtureEnv(TMP),
});
expect(result.exitCode).toBe(3);
expect(result.exitCode).toBe(0);
const payload = JSON.parse(result.stdout);
expect(payload.ok).toBe(false);
expect(payload.error.code).toBe('EIO');
expect(payload.error.message).toContain('Failed to load examples manifest');
expect(payload.ok).toBe(true);
expect(payload.source).toBe('remote');
expect(Array.isArray(payload.manifest.templates)).toBe(true);
expect(payload.manifest.templates.length).toBeGreaterThan(0);
});
it(
'generates every version-pinned playground example through examples -w and generate',
'generates every playground example through examples -w and generate',
() => {
mkdirSync(TMP, { recursive: true });
const cacheDir = join(TMP, 'cache-all');
const env = createFixtureEnv(cacheDir);
const env = createFixtureEnv(TMP);
const manifest = JSON.parse(readFileSync(join(ASSETS_DIR, 'manifest.json'), 'utf8')) as {
templates: Array<{ name: string }>;
};

View File

@@ -9,7 +9,6 @@ import {
getExampleTemplateNames,
} from '../src/example-templates.js';
import { OFFICIAL_EXAMPLE_FONT_URLS } from '../src/example-fonts.js';
import { CLI_VERSION } from '../src/version.js';
const __dirname = dirname(fileURLToPath(import.meta.url));
const TMP = join(__dirname, '..', '.test-tmp-examples');
@@ -19,21 +18,16 @@ describe('examples command', () => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
delete process.env.PDFME_EXAMPLES_BASE_URL;
delete process.env.PDFME_EXAMPLES_CACHE_DIR;
rmSync(TMP, { recursive: true, force: true });
});
it('fetches the version-pinned manifest for --list output', async () => {
process.env.PDFME_EXAMPLES_CACHE_DIR = TMP;
it('fetches manifest.json for --list output', async () => {
const fetchMock = vi.fn(async (input: string | URL | Request) => {
expect(String(input)).toBe(
`https://playground.pdfme.com/template-assets/manifests/${encodeURIComponent(CLI_VERSION)}.json`,
);
expect(String(input)).toBe('https://playground.pdfme.com/template-assets/manifest.json');
return new Response(
JSON.stringify({
schemaVersion: 1,
cliVersion: CLI_VERSION,
cliVersion: '0.1.0-alpha.0',
templates: [{ name: 'zeta' }, { name: 'alpha' }],
}),
{
@@ -47,7 +41,7 @@ describe('examples command', () => {
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined);
await examplesCmd.run!({
args: { list: true, name: undefined, latest: false, json: false },
args: { list: true, name: undefined, json: false },
rawArgs: [],
cmd: examplesCmd,
} as never);
@@ -59,144 +53,112 @@ describe('examples command', () => {
]);
});
it('falls back to the local cache when the remote manifest is unavailable', async () => {
process.env.PDFME_EXAMPLES_CACHE_DIR = TMP;
it('falls back to index.json when manifest.json is unavailable', async () => {
process.env.PDFME_EXAMPLES_BASE_URL = 'https://fixtures.example.com/template-assets';
vi.stubGlobal(
'fetch',
vi.fn(async (input: string | URL | Request) => {
const url = String(input);
if (url.endsWith('/manifest.json')) {
return new Response('missing', { status: 404 });
}
if (url.endsWith('/index.json')) {
return new Response(JSON.stringify([{ name: 'invoice', author: 'pdfme' }]), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
throw new Error(`Unexpected URL: ${url}`);
}),
);
const manifest = await getExampleManifest();
expect(manifest.source).toBe('remote');
expect(manifest.url).toBe('https://fixtures.example.com/template-assets/index.json');
expect(manifest.manifest.templates).toEqual([{ name: 'invoice', author: 'pdfme' }]);
const names = await getExampleTemplateNames();
expect(names).toEqual(['invoice']);
});
it('fetches a template referenced from the manifest', async () => {
process.env.PDFME_EXAMPLES_BASE_URL = 'https://fixtures.example.com/template-assets';
vi.stubGlobal(
'fetch',
vi.fn(async (input: string | URL | Request) => {
const url = String(input);
if (url.endsWith('/manifest.json')) {
return new Response(
JSON.stringify({
schemaVersion: 1,
cliVersion: '0.1.0-alpha.0',
templates: [{ name: 'invoice', path: 'invoice/template.json' }],
}),
{ status: 200, headers: { 'content-type': 'application/json' } },
);
}
if (url.endsWith('/invoice/template.json')) {
return new Response(
JSON.stringify({
basePdf: { width: 210, height: 297, padding: [20, 20, 20, 20] },
schemas: [[{ name: 'title', type: 'text' }]],
}),
{ status: 200, headers: { 'content-type': 'application/json' } },
);
}
throw new Error(`Unexpected URL: ${url}`);
}),
);
const template = await fetchExampleTemplate('invoice');
expect(template).toEqual({
basePdf: { width: 210, height: 297, padding: [20, 20, 20, 20] },
schemas: [[{ name: 'title', type: 'text' }]],
});
});
it('writes output files and emits structured JSON', async () => {
process.env.PDFME_EXAMPLES_BASE_URL = 'https://fixtures.example.com/template-assets';
mkdirSync(TMP, { recursive: true });
vi.stubGlobal(
'fetch',
vi.fn(async () =>
new Response(
JSON.stringify({
schemaVersion: 1,
cliVersion: CLI_VERSION,
templates: [{ name: 'invoice', author: 'pdfme' }],
}),
{
status: 200,
headers: { 'content-type': 'application/json' },
},
),
),
vi.fn(async (input: string | URL | Request) => {
const url = String(input);
if (url.endsWith('/manifest.json')) {
return new Response(
JSON.stringify({
schemaVersion: 1,
cliVersion: '0.1.0-alpha.0',
templates: [{ name: 'invoice', path: 'invoice/template.json' }],
}),
{ status: 200, headers: { 'content-type': 'application/json' } },
);
}
if (url.endsWith('/invoice/template.json')) {
return new Response(
JSON.stringify({
basePdf: { width: 210, height: 297, padding: [20, 20, 20, 20] },
schemas: [[{ name: 'title', type: 'text' }]],
}),
{ status: 200, headers: { 'content-type': 'application/json' } },
);
}
throw new Error(`Unexpected URL: ${url}`);
}),
);
const warm = await getExampleManifest();
expect(warm.source).toBe('remote');
vi.stubGlobal('fetch', vi.fn(async () => {
throw new Error('network down');
}));
const cached = await getExampleManifest();
expect(cached.source).toBe('cache');
expect(cached.manifest.templates).toEqual([{ name: 'invoice', author: 'pdfme' }]);
});
it('loads a template from cache when remote template fetch fails', async () => {
process.env.PDFME_EXAMPLES_CACHE_DIR = TMP;
process.env.PDFME_EXAMPLES_BASE_URL = 'https://fixtures.example.com/template-assets';
const fetchMock = vi.fn(async (input: string | URL | Request) => {
const url = String(input);
if (url.endsWith(`/manifests/${encodeURIComponent(CLI_VERSION)}.json`)) {
return new Response(
JSON.stringify({
schemaVersion: 1,
cliVersion: CLI_VERSION,
templates: [{ name: 'invoice', path: 'invoice/template.json' }],
}),
{ status: 200, headers: { 'content-type': 'application/json' } },
);
}
if (url.endsWith('/invoice/template.json')) {
return new Response(
JSON.stringify({
basePdf: { width: 210, height: 297, padding: [20, 20, 20, 20] },
schemas: [[{ name: 'title', type: 'text' }]],
}),
{ status: 200, headers: { 'content-type': 'application/json' } },
);
}
throw new Error(`Unexpected URL: ${url}`);
});
vi.stubGlobal('fetch', fetchMock);
const firstNames = await getExampleTemplateNames();
expect(firstNames).toEqual(['invoice']);
await fetchExampleTemplate('invoice');
vi.stubGlobal('fetch', vi.fn(async (input: string | URL | Request) => {
const url = String(input);
if (url.endsWith(`/manifests/${encodeURIComponent(CLI_VERSION)}.json`)) {
return new Response(
JSON.stringify({
schemaVersion: 1,
cliVersion: CLI_VERSION,
templates: [{ name: 'invoice', path: 'invoice/template.json' }],
}),
{ status: 200, headers: { 'content-type': 'application/json' } },
);
}
throw new Error('template fetch offline');
}));
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined);
await examplesCmd.run!({
args: {
list: false,
name: 'invoice',
output: undefined,
withInputs: true,
latest: false,
json: true,
},
rawArgs: [],
cmd: examplesCmd,
} as never);
const payload = JSON.parse(String(logSpy.mock.calls.at(-1)?.[0] ?? 'null'));
expect(payload.ok).toBe(true);
expect(payload.source).toBe('cache');
expect(payload.mode).toBe('job');
expect(payload.data.inputs).toEqual([{ title: 'Sample title' }]);
});
it('writes output files and emits structured JSON', async () => {
process.env.PDFME_EXAMPLES_CACHE_DIR = TMP;
process.env.PDFME_EXAMPLES_BASE_URL = 'https://fixtures.example.com/template-assets';
mkdirSync(TMP, { recursive: true });
vi.stubGlobal('fetch', vi.fn(async (input: string | URL | Request) => {
const url = String(input);
if (url.endsWith(`/manifests/${encodeURIComponent(CLI_VERSION)}.json`)) {
return new Response(
JSON.stringify({
schemaVersion: 1,
cliVersion: CLI_VERSION,
templates: [{ name: 'invoice', path: 'invoice/template.json' }],
}),
{ status: 200, headers: { 'content-type': 'application/json' } },
);
}
if (url.endsWith('/invoice/template.json')) {
return new Response(
JSON.stringify({
basePdf: { width: 210, height: 297, padding: [20, 20, 20, 20] },
schemas: [[{ name: 'title', type: 'text' }]],
}),
{ status: 200, headers: { 'content-type': 'application/json' } },
);
}
throw new Error(`Unexpected URL: ${url}`);
}));
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined);
const outputPath = join(TMP, 'job.json');
@@ -206,7 +168,6 @@ describe('examples command', () => {
name: 'invoice',
output: outputPath,
withInputs: true,
latest: false,
json: true,
},
rawArgs: [],
@@ -215,6 +176,7 @@ describe('examples command', () => {
const payload = JSON.parse(String(logSpy.mock.calls.at(-1)?.[0] ?? 'null'));
expect(payload.ok).toBe(true);
expect(payload.source).toBe('remote');
expect(payload.outputPath).toBe(outputPath);
const written = JSON.parse(readFileSync(outputPath, 'utf8'));
@@ -222,45 +184,47 @@ describe('examples command', () => {
});
it('embeds official example font URLs into unified jobs', async () => {
process.env.PDFME_EXAMPLES_CACHE_DIR = TMP;
process.env.PDFME_EXAMPLES_BASE_URL = 'https://fixtures.example.com/template-assets';
mkdirSync(TMP, { recursive: true });
vi.stubGlobal('fetch', vi.fn(async (input: string | URL | Request) => {
const url = String(input);
vi.stubGlobal(
'fetch',
vi.fn(async (input: string | URL | Request) => {
const url = String(input);
if (url.endsWith(`/manifests/${encodeURIComponent(CLI_VERSION)}.json`)) {
return new Response(
JSON.stringify({
schemaVersion: 1,
cliVersion: CLI_VERSION,
templates: [{ name: 'certificate-black', path: 'certificate-black/template.json' }],
}),
{ status: 200, headers: { 'content-type': 'application/json' } },
);
}
if (url.endsWith('/manifest.json')) {
return new Response(
JSON.stringify({
schemaVersion: 1,
cliVersion: '0.1.0-alpha.0',
templates: [{ name: 'certificate-black', path: 'certificate-black/template.json' }],
}),
{ status: 200, headers: { 'content-type': 'application/json' } },
);
}
if (url.endsWith('/certificate-black/template.json')) {
return new Response(
JSON.stringify({
basePdf: { width: 210, height: 297, padding: [20, 20, 20, 20] },
schemas: [[
{
name: 'signature',
type: 'text',
fontName: 'PinyonScript-Regular',
position: { x: 20, y: 20 },
width: 100,
height: 20,
},
]],
}),
{ status: 200, headers: { 'content-type': 'application/json' } },
);
}
if (url.endsWith('/certificate-black/template.json')) {
return new Response(
JSON.stringify({
basePdf: { width: 210, height: 297, padding: [20, 20, 20, 20] },
schemas: [[
{
name: 'signature',
type: 'text',
fontName: 'PinyonScript-Regular',
position: { x: 20, y: 20 },
width: 100,
height: 20,
},
]],
}),
{ status: 200, headers: { 'content-type': 'application/json' } },
);
}
throw new Error(`Unexpected URL: ${url}`);
}));
throw new Error(`Unexpected URL: ${url}`);
}),
);
const outputPath = join(TMP, 'certificate-job.json');
@@ -270,7 +234,6 @@ describe('examples command', () => {
name: 'certificate-black',
output: outputPath,
withInputs: true,
latest: false,
json: true,
},
rawArgs: [],

View File

@@ -3,7 +3,6 @@ import { join } from 'node:path';
const ASSETS_DIR = process.env.PDFME_TEST_ASSETS_DIR;
const FONT_FIXTURES_DIR = process.env.PDFME_TEST_FONT_FIXTURES_DIR;
const FETCH_MODE = process.env.PDFME_TEST_FETCH_MODE ?? 'online';
const BASE_URL = (process.env.PDFME_EXAMPLES_BASE_URL ?? 'https://fixtures.example.com/template-assets').replace(
/\/$/,
'',
@@ -50,13 +49,6 @@ function buildResponse(filePath) {
globalThis.fetch = async (input, init) => {
const url = getUrl(input);
if (FETCH_MODE === 'offline') {
if (url.startsWith(`${BASE_URL}/`) || url in fontFixtures) {
throw new Error(`fixture fetch offline for ${url}`);
}
return originalFetch(input, init);
}
const fontFixturePath = fontFixtures[url];
if (fontFixturePath) {
if (!existsSync(fontFixturePath)) {

View File

@@ -23,11 +23,6 @@ const examplesArgs = {
description: 'Output unified format with sample inputs',
default: false,
},
latest: {
type: 'boolean' as const,
description: 'Fetch the latest manifest instead of the version-pinned manifest',
default: false,
},
json: { type: 'boolean' as const, description: 'Machine-readable JSON output', default: false },
};
@@ -75,7 +70,7 @@ export default defineCommand({
let manifestResult;
try {
manifestResult = await getExampleManifest({ latest: args.latest });
manifestResult = await getExampleManifest();
} catch (error) {
fail(
`Failed to load examples manifest. ${error instanceof Error ? error.message : String(error)}`,
@@ -121,7 +116,6 @@ export default defineCommand({
let templateResult;
try {
templateResult = await fetchExampleTemplateWithSource(args.name, {
latest: args.latest,
manifest: manifestResult.manifest,
});
} catch (error) {

View File

@@ -1,6 +1,3 @@
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { homedir } from 'node:os';
import { dirname, join } from 'node:path';
import { CLI_VERSION } from './version.js';
export interface ExampleManifestEntry {
@@ -24,13 +21,13 @@ export interface ExampleManifest {
export interface ExampleManifestLoadResult {
manifest: ExampleManifest;
source: 'remote' | 'cache';
source: 'remote';
url?: string;
}
export interface ExampleTemplateLoadResult {
template: Record<string, unknown>;
source: 'remote' | 'cache';
source: 'remote';
url?: string;
}
@@ -38,10 +35,6 @@ export function getExamplesBaseUrl(): string {
return process.env.PDFME_EXAMPLES_BASE_URL ?? 'https://playground.pdfme.com/template-assets';
}
export function getExamplesCacheRoot(): string {
return process.env.PDFME_EXAMPLES_CACHE_DIR ?? join(homedir(), '.pdfme', 'examples');
}
async function fetchJson<T>(url: string): Promise<T> {
const response = await fetch(url, {
signal: AbortSignal.timeout(15000),
@@ -54,34 +47,21 @@ async function fetchJson<T>(url: string): Promise<T> {
return (await response.json()) as T;
}
export async function getExampleManifest(options: { latest?: boolean } = {}): Promise<ExampleManifestLoadResult> {
const latest = Boolean(options.latest);
const cachePath = getManifestCachePath(latest);
const manifestUrls = getManifestUrls(latest);
export async function getExampleManifest(): Promise<ExampleManifestLoadResult> {
let lastError: unknown;
for (const url of manifestUrls) {
for (const url of getManifestUrls()) {
try {
const manifest = normalizeManifest(await fetchJson<unknown>(url));
writeCachedJson(cachePath, manifest);
return { manifest, source: 'remote', url };
return { manifest: normalizeManifest(await fetchJson<unknown>(url)), source: 'remote', url };
} catch (error) {
lastError = error;
}
}
const cached = readCachedJson<unknown>(cachePath);
if (cached !== undefined) {
return { manifest: normalizeManifest(cached), source: 'cache' };
}
throw new Error(
`Could not load examples manifest from remote or cache. Cache path: ${cachePath}. ${formatError(lastError)}`,
);
throw new Error(`Could not load examples manifest. ${formatError(lastError)}`);
}
export async function getExampleTemplateNames(options: { latest?: boolean } = {}): Promise<string[]> {
const { manifest } = await getExampleManifest(options);
export async function getExampleTemplateNames(): Promise<string[]> {
const { manifest } = await getExampleManifest();
return manifest.templates
.map((entry) => entry.name)
.filter((name): name is string => typeof name === 'string' && name.length > 0)
@@ -90,7 +70,7 @@ export async function getExampleTemplateNames(options: { latest?: boolean } = {}
export async function fetchExampleTemplate(
name: string,
options: { latest?: boolean; manifest?: ExampleManifest } = {},
options: { manifest?: ExampleManifest } = {},
): Promise<Record<string, unknown>> {
const result = await fetchExampleTemplateWithSource(name, options);
return result.template;
@@ -98,10 +78,9 @@ export async function fetchExampleTemplate(
export async function fetchExampleTemplateWithSource(
name: string,
options: { latest?: boolean; manifest?: ExampleManifest } = {},
options: { manifest?: ExampleManifest } = {},
): Promise<ExampleTemplateLoadResult> {
const latest = Boolean(options.latest);
const manifest = options.manifest ?? (await getExampleManifest({ latest })).manifest;
const manifest = options.manifest ?? (await getExampleManifest()).manifest;
const entry = manifest.templates.find((template) => template.name === name);
if (!entry) {
@@ -110,68 +89,12 @@ export async function fetchExampleTemplateWithSource(
const relativePath = entry.path ?? `${name}/template.json`;
const templateUrl = `${getExamplesBaseUrl().replace(/\/$/, '')}/${relativePath}`;
const cachePath = getTemplateCachePath(name, latest);
try {
const template = await fetchJson<Record<string, unknown>>(templateUrl);
writeCachedJson(cachePath, template);
return { template, source: 'remote', url: templateUrl };
} catch (error) {
const cached = readCachedJson<Record<string, unknown>>(cachePath);
if (cached !== undefined) {
return { template: cached, source: 'cache' };
}
throw new Error(
`Could not load template "${name}" from remote or cache. Cache path: ${cachePath}. ${formatError(error)}`,
);
}
return { template: await fetchJson<Record<string, unknown>>(templateUrl), source: 'remote', url: templateUrl };
}
function getManifestUrls(latest: boolean): string[] {
function getManifestUrls(): string[] {
const baseUrl = getExamplesBaseUrl().replace(/\/$/, '');
if (latest) {
return [`${baseUrl}/manifest.json`, `${baseUrl}/index.json`];
}
return [
`${baseUrl}/manifests/${encodeURIComponent(CLI_VERSION)}.json`,
`${baseUrl}/manifest.json`,
`${baseUrl}/index.json`,
];
}
function getManifestCachePath(latest: boolean): string {
return join(getExamplesCacheDir(latest), 'manifest.json');
}
function getTemplateCachePath(name: string, latest: boolean): string {
return join(getExamplesCacheDir(latest), 'templates', `${name}.json`);
}
function getExamplesCacheDir(latest: boolean): string {
return join(getExamplesCacheRoot(), latest ? 'latest' : CLI_VERSION);
}
function readCachedJson<T>(filePath: string): T | undefined {
if (!existsSync(filePath)) {
return undefined;
}
try {
return JSON.parse(readFileSync(filePath, 'utf8')) as T;
} catch {
return undefined;
}
}
function writeCachedJson(filePath: string, value: unknown): void {
const dir = dirname(filePath);
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true });
}
writeFileSync(filePath, JSON.stringify(value, null, 2));
return [`${baseUrl}/manifest.json`, `${baseUrl}/index.json`];
}
function normalizeManifest(raw: unknown): ExampleManifest {