mirror of
https://github.com/pdfme/pdfme.git
synced 2026-04-17 20:49:43 -04:00
Simplify CLI examples manifest loading
This commit is contained in:
34
PLAN.md
34
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 から検討する
|
||||
|
||||
@@ -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 以降)
|
||||
|
||||
@@ -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 }>;
|
||||
};
|
||||
|
||||
@@ -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: [],
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user