From 7ea06d0a255ddebb613f00a109d0c585e21f45b6 Mon Sep 17 00:00:00 2001 From: hand-dot Date: Tue, 24 Mar 2026 17:55:29 +0900 Subject: [PATCH] Simplify CLI examples manifest loading --- PLAN.md | 34 +- packages/cli/README.md | 8 +- .../__tests__/examples.integration.test.ts | 49 +-- packages/cli/__tests__/examples.test.ts | 317 ++++++++---------- .../fixtures/fetch-fixture-loader.mjs | 8 - packages/cli/src/commands/examples.ts | 8 +- packages/cli/src/example-templates.ts | 105 +----- 7 files changed, 187 insertions(+), 342 deletions(-) diff --git a/PLAN.md b/PLAN.md index dde8e4fe..3b6bcc51 100644 --- a/PLAN.md +++ b/PLAN.md @@ -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 から検討する diff --git a/packages/cli/README.md b/packages/cli/README.md index 9bafd705..b6eefa73 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -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 以降) diff --git a/packages/cli/__tests__/examples.integration.test.ts b/packages/cli/__tests__/examples.integration.test.ts index 00febfba..58e6bf0d 100644 --- a/packages/cli/__tests__/examples.integration.test.ts +++ b/packages/cli/__tests__/examples.integration.test.ts @@ -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 }>; }; diff --git a/packages/cli/__tests__/examples.test.ts b/packages/cli/__tests__/examples.test.ts index 49e79bc9..4d63a629 100644 --- a/packages/cli/__tests__/examples.test.ts +++ b/packages/cli/__tests__/examples.test.ts @@ -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: [], diff --git a/packages/cli/__tests__/fixtures/fetch-fixture-loader.mjs b/packages/cli/__tests__/fixtures/fetch-fixture-loader.mjs index 29a4a39d..c6e05432 100644 --- a/packages/cli/__tests__/fixtures/fetch-fixture-loader.mjs +++ b/packages/cli/__tests__/fixtures/fetch-fixture-loader.mjs @@ -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)) { diff --git a/packages/cli/src/commands/examples.ts b/packages/cli/src/commands/examples.ts index 5b38e931..4bbef8b2 100644 --- a/packages/cli/src/commands/examples.ts +++ b/packages/cli/src/commands/examples.ts @@ -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) { diff --git a/packages/cli/src/example-templates.ts b/packages/cli/src/example-templates.ts index f8ab740c..d24f5c12 100644 --- a/packages/cli/src/example-templates.ts +++ b/packages/cli/src/example-templates.ts @@ -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; - 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(url: string): Promise { const response = await fetch(url, { signal: AbortSignal.timeout(15000), @@ -54,34 +47,21 @@ async function fetchJson(url: string): Promise { return (await response.json()) as T; } -export async function getExampleManifest(options: { latest?: boolean } = {}): Promise { - const latest = Boolean(options.latest); - const cachePath = getManifestCachePath(latest); - const manifestUrls = getManifestUrls(latest); - +export async function getExampleManifest(): Promise { let lastError: unknown; - for (const url of manifestUrls) { + for (const url of getManifestUrls()) { try { - const manifest = normalizeManifest(await fetchJson(url)); - writeCachedJson(cachePath, manifest); - return { manifest, source: 'remote', url }; + return { manifest: normalizeManifest(await fetchJson(url)), source: 'remote', url }; } catch (error) { lastError = error; } } - const cached = readCachedJson(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 { - const { manifest } = await getExampleManifest(options); +export async function getExampleTemplateNames(): Promise { + 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> { 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 { - 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>(templateUrl); - writeCachedJson(cachePath, template); - return { template, source: 'remote', url: templateUrl }; - } catch (error) { - const cached = readCachedJson>(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>(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(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 {