From ab24257920da95e05f9efb69b40dfa7845c03f56 Mon Sep 17 00:00:00 2001 From: Kyohei Fukuda Date: Mon, 18 May 2026 14:54:46 +0900 Subject: [PATCH] [codex] add mounted file workspace to Playground (#1511) * docs(playground): plan file workspace * docs(playground): refine file workspace plan * feat(playground): add mounted file workspace * fix(playground): address file workspace review * chore(playground): remove stale workspace scaffolding * fix(cli): read per-template example metadata * fix(playground): add form link after designer save * feat(playground): standardize template metadata * feat(playground): manage mounted workspace metadata * feat(playground): rename mounted folders from metadata title * fix(playground): harden mounted workspace refresh --- .../__tests__/examples.integration.test.ts | 159 ++-- playground/README.md | 4 +- playground/e2e/authoringStarterFixtures.ts | 10 +- playground/e2e/fileWorkspace.test.ts | 281 ++++++ playground/e2e/templateInputs.test.ts | 49 + .../template-assets/a4-blank/metadata.json | 10 + .../address-label-10/metadata.json | 9 + .../address-label-30/metadata.json | 9 + .../address-label-6/metadata.json | 9 + .../certificate-black/metadata.json | 10 + .../certificate-blue/metadata.json | 9 + .../certificate-gold/metadata.json | 9 + .../certificate-white/metadata.json | 9 + .../metadata.json | 10 + .../inline-markdown-mvt/metadata.json | 10 + .../invoice-blue/metadata.json | 10 + .../invoice-green/metadata.json | 10 + .../invoice-ja-simple-landscape/metadata.json | 10 + .../invoice-ja-simple/metadata.json | 10 + .../invoice-white/metadata.json | 10 + .../template-assets/invoice/metadata.json | 12 + .../location-arrow/metadata.json | 9 + .../location-number/metadata.json | 9 + .../public/template-assets/manifest.json | 246 ++--- .../template-assets/manifests/6.1.2.json | 246 ++--- .../public/template-assets/metadata.json | 104 --- .../new-sale-quotation/metadata.json | 10 + .../template-assets/pedigree/metadata.json | 12 + .../template-assets/qr-lines/metadata.json | 10 + .../template-assets/qr-title/metadata.json | 9 + .../template-assets/quotes/metadata.json | 12 + .../z-fold-brochure/metadata.json | 9 + .../scripts/generate-templates-list-json.mjs | 41 +- .../src/components/ProjectSavedToast.tsx | 25 +- playground/src/helper.ts | 53 +- playground/src/lib/authoringStarters.ts | 10 +- playground/src/lib/fileWorkspace.ts | 841 +++++++++++++++++ playground/src/lib/templateInputs.ts | 24 + playground/src/routes/Designer.tsx | 434 ++++++++- playground/src/routes/FormAndViewer.tsx | 167 +++- playground/src/routes/Templates.tsx | 846 ++++++++++++++++-- playground/src/vite-env.d.ts | 50 ++ 42 files changed, 3265 insertions(+), 561 deletions(-) create mode 100644 playground/e2e/fileWorkspace.test.ts create mode 100644 playground/e2e/templateInputs.test.ts create mode 100644 playground/public/template-assets/a4-blank/metadata.json create mode 100644 playground/public/template-assets/address-label-10/metadata.json create mode 100644 playground/public/template-assets/address-label-30/metadata.json create mode 100644 playground/public/template-assets/address-label-6/metadata.json create mode 100644 playground/public/template-assets/certificate-black/metadata.json create mode 100644 playground/public/template-assets/certificate-blue/metadata.json create mode 100644 playground/public/template-assets/certificate-gold/metadata.json create mode 100644 playground/public/template-assets/certificate-white/metadata.json create mode 100644 playground/public/template-assets/hihokensha-shikaku-shutoku-todoke/metadata.json create mode 100644 playground/public/template-assets/inline-markdown-mvt/metadata.json create mode 100644 playground/public/template-assets/invoice-blue/metadata.json create mode 100644 playground/public/template-assets/invoice-green/metadata.json create mode 100644 playground/public/template-assets/invoice-ja-simple-landscape/metadata.json create mode 100644 playground/public/template-assets/invoice-ja-simple/metadata.json create mode 100644 playground/public/template-assets/invoice-white/metadata.json create mode 100644 playground/public/template-assets/invoice/metadata.json create mode 100644 playground/public/template-assets/location-arrow/metadata.json create mode 100644 playground/public/template-assets/location-number/metadata.json delete mode 100644 playground/public/template-assets/metadata.json create mode 100644 playground/public/template-assets/new-sale-quotation/metadata.json create mode 100644 playground/public/template-assets/pedigree/metadata.json create mode 100644 playground/public/template-assets/qr-lines/metadata.json create mode 100644 playground/public/template-assets/qr-title/metadata.json create mode 100644 playground/public/template-assets/quotes/metadata.json create mode 100644 playground/public/template-assets/z-fold-brochure/metadata.json create mode 100644 playground/src/lib/fileWorkspace.ts create mode 100644 playground/src/lib/templateInputs.ts diff --git a/packages/cli/__tests__/examples.integration.test.ts b/packages/cli/__tests__/examples.integration.test.ts index 2ddcd79b..78fdc97e 100644 --- a/packages/cli/__tests__/examples.integration.test.ts +++ b/packages/cli/__tests__/examples.integration.test.ts @@ -11,7 +11,6 @@ const PRELOAD = pathToFileURL(join(__dirname, 'fixtures', 'fetch-fixture-loader. const TMP = join(__dirname, '..', '.test-tmp-examples-integration'); const ASSETS_DIR = resolve(__dirname, '..', '..', '..', 'playground', 'public', 'template-assets'); const MANIFEST_PATH = join(ASSETS_DIR, 'manifest.json'); -const METADATA_PATH = join(ASSETS_DIR, 'metadata.json'); const VERSIONED_MANIFEST_DIR = join(ASSETS_DIR, 'manifests'); const FONT_FIXTURES_DIR = resolve( __dirname, @@ -87,7 +86,9 @@ function readJson(filePath: string): T { function listPlaygroundTemplateNames(): string[] { return readdirSync(ASSETS_DIR, { withFileTypes: true }) - .filter((entry) => entry.isDirectory() && existsSync(join(ASSETS_DIR, entry.name, 'template.json'))) + .filter( + (entry) => entry.isDirectory() && existsSync(join(ASSETS_DIR, entry.name, 'template.json')), + ) .map((entry) => entry.name) .sort(); } @@ -99,12 +100,16 @@ function normalizeSchemas(rawSchemas: unknown): Array { if (Array.isArray(page)) { - return page.filter((schema): schema is Record => typeof schema === 'object' && schema !== null); + return page.filter( + (schema): schema is Record => + typeof schema === 'object' && schema !== null, + ); } if (typeof page === 'object' && page !== null) { return Object.values(page).filter( - (schema): schema is Record => typeof schema === 'object' && schema !== null, + (schema): schema is Record => + typeof schema === 'object' && schema !== null, ); } @@ -115,7 +120,9 @@ function normalizeSchemas(rawSchemas: unknown): Array>): boolean { return schemas.some((schema) => ['content', 'title', 'placeholder'].some( - (key) => typeof schema[key] === 'string' && /[\u3040-\u30ff\u3400-\u9fff\uf900-\ufaff]/.test(schema[key]), + (key) => + typeof schema[key] === 'string' && + /[\u3040-\u30ff\u3400-\u9fff\uf900-\ufaff]/.test(schema[key]), ), ); } @@ -142,9 +149,8 @@ function inferSourceKind(name: string): string { } function readTemplateMetadata(name: string): Record { - const metadata = readJson>>(METADATA_PATH); const itemMetadataPath = join(ASSETS_DIR, name, 'metadata.json'); - if (!existsSync(itemMetadataPath)) return metadata[name] ?? {}; + if (!existsSync(itemMetadataPath)) return {}; return readJson>(itemMetadataPath); } @@ -170,7 +176,8 @@ function buildExpectedManifestEntry(name: string): ExampleManifestEntry { const entry: ExampleManifestEntry = { name, - author: typeof template.author === 'string' && template.author.length > 0 ? template.author : 'pdfme', + author: + typeof template.author === 'string' && template.author.length > 0 ? template.author : 'pdfme', path: `${name}/template.json`, thumbnailPath: `${name}/thumbnail.png`, ...(sourcePath ? { sourcePath } : {}), @@ -220,9 +227,12 @@ describe('examples integration smoke', () => { const jobPath = join(TMP, 'invoice-job.json'); const pdfPath = join(TMP, 'invoice.pdf'); - const examplesResult = runCli(['examples', 'invoice', '--withInputs', '-o', jobPath, '--json'], { - env, - }); + const examplesResult = runCli( + ['examples', 'invoice', '--withInputs', '-o', jobPath, '--json'], + { + env, + }, + ); expect(examplesResult.exitCode).toBe(0); const examplesPayload = JSON.parse(examplesResult.stdout); @@ -289,7 +299,18 @@ describe('examples integration smoke', () => { const result = spawnSync( 'node', - ['--import', PRELOAD, CLI, 'examples', 'invoice', '--withInputs', '-o', jobPath, '-v', '--json'], + [ + '--import', + PRELOAD, + CLI, + 'examples', + 'invoice', + '--withInputs', + '-o', + jobPath, + '-v', + '--json', + ], { encoding: 'utf8', timeout: 60000, @@ -308,67 +329,63 @@ describe('examples integration smoke', () => { expect(result.stderr).toContain(`Output: ${jobPath}`); }); - it( - 'exports every playground example through examples -w and generates authoring starters', - () => { - mkdirSync(TMP, { recursive: true }); - const env = createFixtureEnv(TMP); - const manifest = readJson(MANIFEST_PATH); - const generatedTemplateNames: string[] = []; + it('exports every playground example through examples -w and generates authoring starters', () => { + mkdirSync(TMP, { recursive: true }); + const env = createFixtureEnv(TMP); + const manifest = readJson(MANIFEST_PATH); + const generatedTemplateNames: string[] = []; - for (const { name, sourceKind } of manifest.templates) { - const jobPath = join(TMP, `${name}.job.json`); - const pdfPath = join(TMP, `${name}.pdf`); + for (const { name, sourceKind } of manifest.templates) { + const jobPath = join(TMP, `${name}.job.json`); + const pdfPath = join(TMP, `${name}.pdf`); - const examplesResult = runCli(['examples', name, '--withInputs', '-o', jobPath, '--json'], { - env, - }); - if (examplesResult.exitCode !== 0) { - throw new Error( - `Example "${name}" failed to export.\nstdout:\n${examplesResult.stdout}\nstderr:\n${examplesResult.stderr}`, - ); - } - - const examplePayload = JSON.parse(examplesResult.stdout); - expect(examplePayload.ok).toBe(true); - expect(examplePayload.command).toBe('examples'); - expect(examplePayload.outputPath).toBe(jobPath); - - const job = JSON.parse(readFileSync(jobPath, 'utf8')); - expect(job).toHaveProperty('template'); - expect(Array.isArray(job.inputs)).toBe(true); - expect(existsSync(jobPath)).toBe(true); - - // Full PDF rendering for every playground template is covered by the generator - // integration snapshots. Keep this CLI test focused on exported job validity for - // all examples, then run PDF generation for the generated authoring starters where - // sample input shape regressions are most likely. - if (sourceKind === 'designer') { - continue; - } - - const generateResult = runCli(['generate', jobPath, '-o', pdfPath, '--json'], { env }); - if (generateResult.exitCode !== 0) { - throw new Error( - `Example "${name}" failed to generate via CLI.\nJob:\n${JSON.stringify(job, null, 2)}\nstdout:\n${generateResult.stdout}\nstderr:\n${generateResult.stderr}`, - ); - } - - const payload = JSON.parse(generateResult.stdout); - expect(payload.ok).toBe(true); - expect(payload.command).toBe('generate'); - expect(payload.outputPath).toBe(pdfPath); - expect(existsSync(pdfPath)).toBe(true); - generatedTemplateNames.push(name); + const examplesResult = runCli(['examples', name, '--withInputs', '-o', jobPath, '--json'], { + env, + }); + if (examplesResult.exitCode !== 0) { + throw new Error( + `Example "${name}" failed to export.\nstdout:\n${examplesResult.stdout}\nstderr:\n${examplesResult.stderr}`, + ); } - expect(generatedTemplateNames.sort()).toEqual( - manifest.templates - .filter((entry) => entry.sourceKind !== 'designer') - .map((entry) => entry.name) - .sort(), - ); - }, - 180000, - ); + const examplePayload = JSON.parse(examplesResult.stdout); + expect(examplePayload.ok).toBe(true); + expect(examplePayload.command).toBe('examples'); + expect(examplePayload.outputPath).toBe(jobPath); + + const job = JSON.parse(readFileSync(jobPath, 'utf8')); + expect(job).toHaveProperty('template'); + expect(Array.isArray(job.inputs)).toBe(true); + expect(existsSync(jobPath)).toBe(true); + + // Full PDF rendering for every playground template is covered by the generator + // integration snapshots. Keep this CLI test focused on exported job validity for + // all examples, then run PDF generation for the generated authoring starters where + // sample input shape regressions are most likely. + if (sourceKind === 'designer') { + continue; + } + + const generateResult = runCli(['generate', jobPath, '-o', pdfPath, '--json'], { env }); + if (generateResult.exitCode !== 0) { + throw new Error( + `Example "${name}" failed to generate via CLI.\nJob:\n${JSON.stringify(job, null, 2)}\nstdout:\n${generateResult.stdout}\nstderr:\n${generateResult.stderr}`, + ); + } + + const payload = JSON.parse(generateResult.stdout); + expect(payload.ok).toBe(true); + expect(payload.command).toBe('generate'); + expect(payload.outputPath).toBe(pdfPath); + expect(existsSync(pdfPath)).toBe(true); + generatedTemplateNames.push(name); + } + + expect(generatedTemplateNames.sort()).toEqual( + manifest.templates + .filter((entry) => entry.sourceKind !== 'designer') + .map((entry) => entry.name) + .sort(), + ); + }, 180000); }); diff --git a/playground/README.md b/playground/README.md index bc58670d..c7ec6130 100644 --- a/playground/README.md +++ b/playground/README.md @@ -34,9 +34,7 @@ Use this flow for normal pdfme templates: - Example: `playground/public/template-assets/invoice-blue`. 2. Put `template.json` in that directory. - You can create it with the playground Designer and download the Template JSON. -3. Add gallery metadata. - - Prefer `playground/public/template-assets//metadata.json` for new templates. - - `playground/public/template-assets/metadata.json` is still supported for existing shared metadata. +3. Add gallery metadata in `playground/public/template-assets//metadata.json`. Example: diff --git a/playground/e2e/authoringStarterFixtures.ts b/playground/e2e/authoringStarterFixtures.ts index ec1b2f4d..2ccc8fcc 100644 --- a/playground/e2e/authoringStarterFixtures.ts +++ b/playground/e2e/authoringStarterFixtures.ts @@ -26,17 +26,17 @@ export const readAuthoringStarterFixtures = (kind: 'jsx' | 'md2pdf'): AuthoringS if (!fs.existsSync(sourcePath) || !fs.existsSync(metadataPath)) return []; const metadata = JSON.parse(fs.readFileSync(metadataPath, 'utf-8')) as { - description?: string; - sourceKind?: string; - title?: string; + description: string; + sourceKind: string; + title: string; }; if (metadata.sourceKind !== kind) return []; return [ { assetName: entry.name, - description: metadata.description ?? '', - label: metadata.title ?? entry.name, + description: metadata.description, + label: metadata.title, source: fs.readFileSync(sourcePath, 'utf-8'), }, ]; diff --git a/playground/e2e/fileWorkspace.test.ts b/playground/e2e/fileWorkspace.test.ts new file mode 100644 index 00000000..e1ce5977 --- /dev/null +++ b/playground/e2e/fileWorkspace.test.ts @@ -0,0 +1,281 @@ +import type { Template } from '@pdfme/common'; +import { + FileWorkspaceTemplateDeletedError, + FileWorkspaceTemplateInvalidError, + createBlankTemplateEntry, + createTemplateEntryFromTemplate, + readTemplateEntry, + scanTemplateCollection, + serializeTemplateForFileWorkspace, + writeTemplateEntry, + writeTemplateMetadata, +} from '../src/lib/fileWorkspace'; + +class MemoryFileHandle { + readonly kind = 'file'; + lastModified = 1; + + constructor( + readonly name: string, + private content: string, + ) {} + + async createWritable() { + const chunks: Array = []; + return { + close: async () => { + const parts = await Promise.all( + chunks.map(async (chunk) => { + if (typeof chunk === 'string') return chunk; + if (chunk instanceof Blob) return chunk.text(); + return new TextDecoder().decode(chunk as BufferSource); + }), + ); + this.content = parts.join(''); + this.lastModified += 1; + }, + write: async (data: Blob | BufferSource | string) => { + chunks.push(data); + }, + } as FileSystemWritableFileStream; + } + + async getFile() { + return new File([this.content], this.name, { + lastModified: this.lastModified, + type: this.name.endsWith('.json') ? 'application/json' : 'application/octet-stream', + }); + } + + get text() { + return this.content; + } + + set text(content: string) { + this.content = content; + this.lastModified += 1; + } +} + +class MemoryDirectoryHandle { + readonly kind = 'directory'; + private children = new Map(); + + constructor(readonly name: string) {} + + addDirectory(name: string) { + const directory = new MemoryDirectoryHandle(name); + this.children.set(name, directory); + return directory; + } + + addFile(name: string, content: string) { + const file = new MemoryFileHandle(name, content); + this.children.set(name, file); + return file; + } + + async *entries() { + for (const entry of this.children.entries()) { + yield entry; + } + } + + async getDirectoryHandle(name: string, options: { create?: boolean } = {}) { + const child = this.children.get(name); + if (child instanceof MemoryDirectoryHandle) return child; + if (!child && options.create) return this.addDirectory(name); + throw new Error(`Directory not found: ${name}`); + } + + async getFileHandle(name: string, options: { create?: boolean } = {}) { + const child = this.children.get(name); + if (child instanceof MemoryFileHandle) return child; + if (!child && options.create) return this.addFile(name, ''); + throw new Error(`File not found: ${name}`); + } + + async removeEntry(name: string, _options: { recursive?: boolean } = {}) { + if (!this.children.delete(name)) throw new Error(`Entry not found: ${name}`); + } +} + +const blankTemplate: Template = { + basePdf: { height: 100, padding: [0, 0, 0, 0], width: 100 }, + schemas: [[]], +}; + +describe('file workspace helpers', () => { + it('scans one-level template directories and skips invalid template JSON', async () => { + const root = new MemoryDirectoryHandle('templates'); + const invoice = root.addDirectory('invoice'); + invoice.addFile('template.json', serializeTemplateForFileWorkspace(blankTemplate)); + invoice.addFile( + 'metadata.json', + JSON.stringify({ + description: 'Invoice template', + sourceKind: 'designer', + tags: ['Invoice'], + title: 'Invoice', + }), + ); + root + .addDirectory('.cache') + .addFile('template.json', serializeTemplateForFileWorkspace(blankTemplate)); + root.addDirectory('broken').addFile('template.json', '{'); + + const collection = await scanTemplateCollection(root as unknown as FileSystemDirectoryHandle); + + expect(collection.entries).toHaveLength(1); + expect(collection.entries[0]?.name).toBe('invoice'); + expect(collection.entries[0]?.title).toBe('Invoice'); + expect(collection.invalidEntries.map((entry) => entry.name)).toEqual(['broken']); + }); + + it('writes pretty template JSON back to the selected template file', async () => { + const root = new MemoryDirectoryHandle('templates'); + const invoice = root.addDirectory('invoice'); + const templateFile = invoice.addFile( + 'template.json', + serializeTemplateForFileWorkspace(blankTemplate), + ); + const collection = await scanTemplateCollection(root as unknown as FileSystemDirectoryHandle); + const entry = collection.entries[0]; + if (!entry) throw new Error('Missing test entry'); + + const nextTemplate: Template = { + ...blankTemplate, + pdfmeVersion: 'test', + }; + const saved = await writeTemplateEntry(entry, nextTemplate); + + expect(saved.template.pdfmeVersion).toBe('test'); + expect(templateFile.text).toBe(serializeTemplateForFileWorkspace(nextTemplate)); + }); + + it('updates editable metadata fields and renames the template directory', async () => { + const root = new MemoryDirectoryHandle('templates'); + const invoice = root.addDirectory('invoice'); + invoice.addFile('template.json', serializeTemplateForFileWorkspace(blankTemplate)); + invoice.addFile( + 'metadata.json', + JSON.stringify({ + customField: 'keep me', + description: 'Old description', + order: 7, + sourceKind: 'designer', + tags: ['Old'], + title: 'Old title', + }), + ); + const collection = await scanTemplateCollection(root as unknown as FileSystemDirectoryHandle); + const entry = collection.entries[0]; + if (!entry) throw new Error('Missing test entry'); + + const updated = await writeTemplateMetadata(collection, entry, { + description: 'New description', + tags: ['Invoice', 'Business', 'Invoice'], + title: 'New title', + }); + const renamedDirectory = await root.getDirectoryHandle('new-title'); + const metadataFile = await renamedDirectory.getFileHandle('metadata.json'); + + expect(updated.name).toBe('new-title'); + expect(updated.title).toBe('New title'); + expect(updated.description).toBe('New description'); + expect(updated.tags).toEqual(['Invoice', 'Business']); + await expect(root.getDirectoryHandle('invoice')).rejects.toThrow('Directory not found'); + expect(JSON.parse(metadataFile.text)).toEqual({ + title: 'New title', + description: 'New description', + sourceKind: 'designer', + tags: ['Invoice', 'Business'], + order: 7, + customField: 'keep me', + }); + }); + + it('copies a template with metadata and source into a collection', async () => { + const root = new MemoryDirectoryHandle('templates'); + const collection = await scanTemplateCollection(root as unknown as FileSystemDirectoryHandle); + + const entry = await createTemplateEntryFromTemplate(collection, blankTemplate, 'JSX Project', { + description: 'Copied project', + source: { + content: 'export default ;', + language: 'jsx', + }, + sourceKind: 'jsx', + tags: ['JSX', 'Copied'], + }); + const directory = await root.getDirectoryHandle('jsx-project'); + const sourceFile = await directory.getFileHandle('source.tsx'); + const metadataFile = await directory.getFileHandle('metadata.json'); + + expect(entry.name).toBe('jsx-project'); + expect(entry.sourceKind).toBe('jsx'); + expect(sourceFile.text).toBe('export default ;'); + expect(JSON.parse(metadataFile.text)).toEqual({ + title: 'JSX Project', + description: 'Copied project', + sourceKind: 'jsx', + tags: ['JSX', 'Copied'], + }); + }); + + it('reports disk version changes when template JSON changes externally', async () => { + const root = new MemoryDirectoryHandle('templates'); + const invoice = root.addDirectory('invoice'); + const templateFile = invoice.addFile( + 'template.json', + serializeTemplateForFileWorkspace(blankTemplate), + ); + const collection = await scanTemplateCollection(root as unknown as FileSystemDirectoryHandle); + const entry = collection.entries[0]; + if (!entry) throw new Error('Missing test entry'); + + templateFile.text = serializeTemplateForFileWorkspace({ + ...blankTemplate, + pdfmeVersion: 'external', + }); + const readResult = await readTemplateEntry(entry); + + expect(readResult.diskVersion).not.toBe(entry.diskVersion); + expect(readResult.template.pdfmeVersion).toBe('external'); + }); + + it('throws typed errors for deleted or invalid template JSON', async () => { + const root = new MemoryDirectoryHandle('templates'); + const invoice = root.addDirectory('invoice'); + const templateFile = invoice.addFile( + 'template.json', + serializeTemplateForFileWorkspace(blankTemplate), + ); + const collection = await scanTemplateCollection(root as unknown as FileSystemDirectoryHandle); + const entry = collection.entries[0]; + if (!entry) throw new Error('Missing test entry'); + + templateFile.text = '{'; + await expect(readTemplateEntry(entry)).rejects.toBeInstanceOf( + FileWorkspaceTemplateInvalidError, + ); + + await invoice.removeEntry('template.json'); + await expect(readTemplateEntry(entry)).rejects.toBeInstanceOf( + FileWorkspaceTemplateDeletedError, + ); + }); + + it('creates an untitled blank template when a collection is empty', async () => { + const root = new MemoryDirectoryHandle('templates'); + + const entry = await createBlankTemplateEntry(root as unknown as FileSystemDirectoryHandle); + const directory = await root.getDirectoryHandle('untitled-template'); + const templateFile = await directory.getFileHandle('template.json'); + + expect(entry.name).toBe('untitled-template'); + expect(entry.sourceKind).toBe('designer'); + expect(entry.title).toBe('Untitled Template'); + expect(templateFile.text).toContain('"schemas": ['); + }); +}); diff --git a/playground/e2e/templateInputs.test.ts b/playground/e2e/templateInputs.test.ts new file mode 100644 index 00000000..8bee5432 --- /dev/null +++ b/playground/e2e/templateInputs.test.ts @@ -0,0 +1,49 @@ +import type { Template } from '@pdfme/common'; +import { reconcileInputsWithTemplate } from '../src/lib/templateInputs'; + +const templateWithFields = ( + fields: Array<{ content?: string; name: string; readOnly?: boolean }>, +) => + ({ + basePdf: { height: 100, padding: [0, 0, 0, 0], width: 100 }, + schemas: [ + fields.map((field) => ({ + height: 10, + position: { x: 0, y: 0 }, + type: 'text', + width: 10, + ...field, + })), + ], + }) as Template; + +describe('template input reconciliation', () => { + it('keeps values for matching field names and uses defaults for new fields', () => { + const template = templateWithFields([ + { content: 'Default customer', name: 'customer' }, + { content: 'Default total', name: 'total' }, + { content: 'Internal', name: 'internal', readOnly: true }, + ]); + + expect(reconcileInputsWithTemplate(template, [{ customer: 'Ada', old: 'removed' }])).toEqual([ + { + customer: 'Ada', + total: 'Default total', + }, + ]); + }); + + it('falls back to generated defaults when there are no previous inputs', () => { + const template = templateWithFields([{ content: 'Hello', name: 'message' }]); + + expect(reconcileInputsWithTemplate(template, null)).toEqual([{ message: 'Hello' }]); + }); + + it('drops extra previous input rows during template reload', () => { + const template = templateWithFields([{ content: 'Hello', name: 'message' }]); + + expect( + reconcileInputsWithTemplate(template, [{ message: 'Ada' }, { message: 'Grace' }]), + ).toEqual([{ message: 'Ada' }]); + }); +}); diff --git a/playground/public/template-assets/a4-blank/metadata.json b/playground/public/template-assets/a4-blank/metadata.json new file mode 100644 index 00000000..8f8ba871 --- /dev/null +++ b/playground/public/template-assets/a4-blank/metadata.json @@ -0,0 +1,10 @@ +{ + "title": "A4 Blank", + "description": "A clean blank A4 document for starting from scratch in Designer.", + "sourceKind": "designer", + "tags": [ + "Blank", + "Starter" + ], + "order": 130 +} diff --git a/playground/public/template-assets/address-label-10/metadata.json b/playground/public/template-assets/address-label-10/metadata.json new file mode 100644 index 00000000..34bb8347 --- /dev/null +++ b/playground/public/template-assets/address-label-10/metadata.json @@ -0,0 +1,9 @@ +{ + "title": "Address Label 10", + "description": "A 10-label address sheet for shipping and mailing workflows.", + "sourceKind": "designer", + "tags": [ + "Labels", + "Shipping" + ] +} diff --git a/playground/public/template-assets/address-label-30/metadata.json b/playground/public/template-assets/address-label-30/metadata.json new file mode 100644 index 00000000..a26f4035 --- /dev/null +++ b/playground/public/template-assets/address-label-30/metadata.json @@ -0,0 +1,9 @@ +{ + "title": "Address Label 30", + "description": "A compact 30-label address sheet for dense mailing labels.", + "sourceKind": "designer", + "tags": [ + "Labels", + "Shipping" + ] +} diff --git a/playground/public/template-assets/address-label-6/metadata.json b/playground/public/template-assets/address-label-6/metadata.json new file mode 100644 index 00000000..d20ef648 --- /dev/null +++ b/playground/public/template-assets/address-label-6/metadata.json @@ -0,0 +1,9 @@ +{ + "title": "Address Label 6", + "description": "A larger 6-label address sheet with room for longer addresses.", + "sourceKind": "designer", + "tags": [ + "Labels", + "Shipping" + ] +} diff --git a/playground/public/template-assets/certificate-black/metadata.json b/playground/public/template-assets/certificate-black/metadata.json new file mode 100644 index 00000000..f64d6189 --- /dev/null +++ b/playground/public/template-assets/certificate-black/metadata.json @@ -0,0 +1,10 @@ +{ + "title": "Certificate Black", + "description": "A high-contrast certificate layout with a formal dark theme.", + "sourceKind": "designer", + "tags": [ + "Certificate", + "Award" + ], + "order": 120 +} diff --git a/playground/public/template-assets/certificate-blue/metadata.json b/playground/public/template-assets/certificate-blue/metadata.json new file mode 100644 index 00000000..40bb4193 --- /dev/null +++ b/playground/public/template-assets/certificate-blue/metadata.json @@ -0,0 +1,9 @@ +{ + "title": "Certificate Blue", + "description": "A polished blue certificate layout for awards and completion documents.", + "sourceKind": "designer", + "tags": [ + "Certificate", + "Award" + ] +} diff --git a/playground/public/template-assets/certificate-gold/metadata.json b/playground/public/template-assets/certificate-gold/metadata.json new file mode 100644 index 00000000..1ed256aa --- /dev/null +++ b/playground/public/template-assets/certificate-gold/metadata.json @@ -0,0 +1,9 @@ +{ + "title": "Certificate Gold", + "description": "A warm gold certificate layout with a classic presentation style.", + "sourceKind": "designer", + "tags": [ + "Certificate", + "Award" + ] +} diff --git a/playground/public/template-assets/certificate-white/metadata.json b/playground/public/template-assets/certificate-white/metadata.json new file mode 100644 index 00000000..62807ccb --- /dev/null +++ b/playground/public/template-assets/certificate-white/metadata.json @@ -0,0 +1,9 @@ +{ + "title": "Certificate White", + "description": "A minimal certificate layout that works well with light branding.", + "sourceKind": "designer", + "tags": [ + "Certificate", + "Award" + ] +} diff --git a/playground/public/template-assets/hihokensha-shikaku-shutoku-todoke/metadata.json b/playground/public/template-assets/hihokensha-shikaku-shutoku-todoke/metadata.json new file mode 100644 index 00000000..33fa4314 --- /dev/null +++ b/playground/public/template-assets/hihokensha-shikaku-shutoku-todoke/metadata.json @@ -0,0 +1,10 @@ +{ + "title": "Social Insurance Enrollment Form", + "description": "A Japanese social insurance form template for structured government-style documents.", + "sourceKind": "designer", + "tags": [ + "Government", + "CJK", + "Form" + ] +} diff --git a/playground/public/template-assets/inline-markdown-mvt/metadata.json b/playground/public/template-assets/inline-markdown-mvt/metadata.json new file mode 100644 index 00000000..4357daed --- /dev/null +++ b/playground/public/template-assets/inline-markdown-mvt/metadata.json @@ -0,0 +1,10 @@ +{ + "title": "Inline Markdown MVT", + "description": "A focused demo of inline markdown and MultiVariableText editing.", + "sourceKind": "designer", + "tags": [ + "Markdown", + "MVT", + "Form" + ] +} diff --git a/playground/public/template-assets/invoice-blue/metadata.json b/playground/public/template-assets/invoice-blue/metadata.json new file mode 100644 index 00000000..e49f6faf --- /dev/null +++ b/playground/public/template-assets/invoice-blue/metadata.json @@ -0,0 +1,10 @@ +{ + "title": "Invoice Blue", + "description": "A blue invoice variant for a more branded business document.", + "sourceKind": "designer", + "tags": [ + "Invoice", + "Business", + "Table" + ] +} diff --git a/playground/public/template-assets/invoice-green/metadata.json b/playground/public/template-assets/invoice-green/metadata.json new file mode 100644 index 00000000..40690e2b --- /dev/null +++ b/playground/public/template-assets/invoice-green/metadata.json @@ -0,0 +1,10 @@ +{ + "title": "Invoice Green", + "description": "A green invoice variant with a calm accounting-oriented look.", + "sourceKind": "designer", + "tags": [ + "Invoice", + "Business", + "Table" + ] +} diff --git a/playground/public/template-assets/invoice-ja-simple-landscape/metadata.json b/playground/public/template-assets/invoice-ja-simple-landscape/metadata.json new file mode 100644 index 00000000..4c926237 --- /dev/null +++ b/playground/public/template-assets/invoice-ja-simple-landscape/metadata.json @@ -0,0 +1,10 @@ +{ + "title": "Japanese Invoice Landscape", + "description": "A landscape Japanese invoice layout for wider table content.", + "sourceKind": "designer", + "tags": [ + "Invoice", + "Business", + "CJK" + ] +} diff --git a/playground/public/template-assets/invoice-ja-simple/metadata.json b/playground/public/template-assets/invoice-ja-simple/metadata.json new file mode 100644 index 00000000..cce5f610 --- /dev/null +++ b/playground/public/template-assets/invoice-ja-simple/metadata.json @@ -0,0 +1,10 @@ +{ + "title": "Japanese Invoice", + "description": "A simple Japanese invoice layout with CJK font usage.", + "sourceKind": "designer", + "tags": [ + "Invoice", + "Business", + "CJK" + ] +} diff --git a/playground/public/template-assets/invoice-white/metadata.json b/playground/public/template-assets/invoice-white/metadata.json new file mode 100644 index 00000000..cf6d978f --- /dev/null +++ b/playground/public/template-assets/invoice-white/metadata.json @@ -0,0 +1,10 @@ +{ + "title": "Invoice White", + "description": "A restrained white invoice layout with a clean printable style.", + "sourceKind": "designer", + "tags": [ + "Invoice", + "Business", + "Table" + ] +} diff --git a/playground/public/template-assets/invoice/metadata.json b/playground/public/template-assets/invoice/metadata.json new file mode 100644 index 00000000..06e79272 --- /dev/null +++ b/playground/public/template-assets/invoice/metadata.json @@ -0,0 +1,12 @@ +{ + "title": "Invoice", + "description": "A practical invoice with customer details, line items, totals, and payment notes.", + "sourceKind": "designer", + "tags": [ + "Invoice", + "Business", + "Table", + "Visual" + ], + "order": 90 +} diff --git a/playground/public/template-assets/location-arrow/metadata.json b/playground/public/template-assets/location-arrow/metadata.json new file mode 100644 index 00000000..26758b40 --- /dev/null +++ b/playground/public/template-assets/location-arrow/metadata.json @@ -0,0 +1,9 @@ +{ + "title": "Location Arrow", + "description": "A location marker template that highlights points with arrow indicators.", + "sourceKind": "designer", + "tags": [ + "Map", + "Visual" + ] +} diff --git a/playground/public/template-assets/location-number/metadata.json b/playground/public/template-assets/location-number/metadata.json new file mode 100644 index 00000000..9997e41e --- /dev/null +++ b/playground/public/template-assets/location-number/metadata.json @@ -0,0 +1,9 @@ +{ + "title": "Location Number", + "description": "A location marker template that labels points with numbered badges.", + "sourceKind": "designer", + "tags": [ + "Map", + "Visual" + ] +} diff --git a/playground/public/template-assets/manifest.json b/playground/public/template-assets/manifest.json index 27a118e6..9ca3a0b4 100644 --- a/playground/public/template-assets/manifest.json +++ b/playground/public/template-assets/manifest.json @@ -27,7 +27,8 @@ "Business", "Table", "Visual" - ] + ], + "title": "Invoice" }, { "name": "quotes", @@ -54,7 +55,8 @@ "Business", "Table", "Visual" - ] + ], + "title": "Quotes" }, { "name": "pedigree", @@ -80,7 +82,8 @@ "QR", "Image", "Visual" - ] + ], + "title": "Pedigree" }, { "name": "certificate-black", @@ -103,7 +106,8 @@ "tags": [ "Certificate", "Award" - ] + ], + "title": "Certificate Black" }, { "name": "a4-blank", @@ -122,7 +126,8 @@ "tags": [ "Blank", "Starter" - ] + ], + "title": "A4 Blank" }, { "name": "qr-lines", @@ -144,7 +149,8 @@ "tags": [ "QR", "Label" - ] + ], + "title": "QR Lines" }, { "name": "address-label-10", @@ -164,7 +170,8 @@ "tags": [ "Labels", "Shipping" - ] + ], + "title": "Address Label 10" }, { "name": "address-label-30", @@ -184,7 +191,8 @@ "tags": [ "Labels", "Shipping" - ] + ], + "title": "Address Label 30" }, { "name": "address-label-6", @@ -204,7 +212,8 @@ "tags": [ "Labels", "Shipping" - ] + ], + "title": "Address Label 6" }, { "name": "md2pdf-article", @@ -251,7 +260,8 @@ "tags": [ "Certificate", "Award" - ] + ], + "title": "Certificate Blue" }, { "name": "certificate-gold", @@ -273,7 +283,8 @@ "tags": [ "Certificate", "Award" - ] + ], + "title": "Certificate Gold" }, { "name": "certificate-white", @@ -295,7 +306,8 @@ "tags": [ "Certificate", "Award" - ] + ], + "title": "Certificate White" }, { "name": "jsx-form-fields", @@ -325,29 +337,6 @@ ], "title": "Form fields" }, - { - "name": "hihokensha-shikaku-shutoku-todoke", - "author": "EedgeY", - "path": "hihokensha-shikaku-shutoku-todoke/template.json", - "thumbnailPath": "hihokensha-shikaku-shutoku-todoke/thumbnail.png", - "pageCount": 1, - "fieldCount": 104, - "schemaTypes": [ - "text" - ], - "fontNames": [ - "NotoSansJP" - ], - "hasCJK": true, - "basePdfKind": "dataUri", - "description": "A Japanese social insurance form template for structured government-style documents.", - "sourceKind": "designer", - "tags": [ - "Government", - "CJK", - "Form" - ] - }, { "name": "inline-markdown-mvt", "author": "pdfme", @@ -370,7 +359,52 @@ "Markdown", "MVT", "Form" - ] + ], + "title": "Inline Markdown MVT" + }, + { + "name": "invoice-blue", + "author": "pdfme", + "path": "invoice-blue/template.json", + "thumbnailPath": "invoice-blue/thumbnail.png", + "pageCount": 1, + "fieldCount": 35, + "schemaTypes": [ + "text" + ], + "fontNames": [], + "hasCJK": false, + "basePdfKind": "dataUri", + "description": "A blue invoice variant for a more branded business document.", + "sourceKind": "designer", + "tags": [ + "Invoice", + "Business", + "Table" + ], + "title": "Invoice Blue" + }, + { + "name": "invoice-green", + "author": "pdfme", + "path": "invoice-green/template.json", + "thumbnailPath": "invoice-green/thumbnail.png", + "pageCount": 1, + "fieldCount": 25, + "schemaTypes": [ + "text" + ], + "fontNames": [], + "hasCJK": false, + "basePdfKind": "dataUri", + "description": "A green invoice variant with a calm accounting-oriented look.", + "sourceKind": "designer", + "tags": [ + "Invoice", + "Business", + "Table" + ], + "title": "Invoice Green" }, { "name": "jsx-invoice", @@ -406,10 +440,10 @@ "title": "Invoice layout" }, { - "name": "invoice-blue", + "name": "invoice-white", "author": "pdfme", - "path": "invoice-blue/template.json", - "thumbnailPath": "invoice-blue/thumbnail.png", + "path": "invoice-white/template.json", + "thumbnailPath": "invoice-white/thumbnail.png", "pageCount": 1, "fieldCount": 35, "schemaTypes": [ @@ -418,34 +452,14 @@ "fontNames": [], "hasCJK": false, "basePdfKind": "dataUri", - "description": "A blue invoice variant for a more branded business document.", + "description": "A restrained white invoice layout with a clean printable style.", "sourceKind": "designer", "tags": [ "Invoice", "Business", "Table" - ] - }, - { - "name": "invoice-green", - "author": "pdfme", - "path": "invoice-green/template.json", - "thumbnailPath": "invoice-green/thumbnail.png", - "pageCount": 1, - "fieldCount": 25, - "schemaTypes": [ - "text" ], - "fontNames": [], - "hasCJK": false, - "basePdfKind": "dataUri", - "description": "A green invoice variant with a calm accounting-oriented look.", - "sourceKind": "designer", - "tags": [ - "Invoice", - "Business", - "Table" - ] + "title": "Invoice White" }, { "name": "invoice-ja-simple", @@ -472,7 +486,8 @@ "Invoice", "Business", "CJK" - ] + ], + "title": "Japanese Invoice" }, { "name": "invoice-ja-simple-landscape", @@ -499,28 +514,8 @@ "Invoice", "Business", "CJK" - ] - }, - { - "name": "invoice-white", - "author": "pdfme", - "path": "invoice-white/template.json", - "thumbnailPath": "invoice-white/thumbnail.png", - "pageCount": 1, - "fieldCount": 35, - "schemaTypes": [ - "text" ], - "fontNames": [], - "hasCJK": false, - "basePdfKind": "dataUri", - "description": "A restrained white invoice layout with a clean printable style.", - "sourceKind": "designer", - "tags": [ - "Invoice", - "Business", - "Table" - ] + "title": "Japanese Invoice Landscape" }, { "name": "jsx-japanese-notice", @@ -594,7 +589,8 @@ "tags": [ "Map", "Visual" - ] + ], + "title": "Location Arrow" }, { "name": "location-number", @@ -615,32 +611,8 @@ "tags": [ "Map", "Visual" - ] - }, - { - "name": "new-sale-quotation", - "author": "pdfme", - "path": "new-sale-quotation/template.json", - "thumbnailPath": "new-sale-quotation/thumbnail.png", - "pageCount": 3, - "fieldCount": 49, - "schemaTypes": [ - "image", - "line", - "rectangle", - "table", - "text" ], - "fontNames": [], - "hasCJK": false, - "basePdfKind": "blank", - "description": "A sales quotation template with product rows and business summary fields.", - "sourceKind": "designer", - "tags": [ - "Quote", - "Business", - "Table" - ] + "title": "Location Number" }, { "name": "md2pdf-overview", @@ -689,7 +661,8 @@ "tags": [ "QR", "Label" - ] + ], + "title": "QR Title" }, { "name": "md2pdf-release-notes", @@ -771,6 +744,56 @@ ], "title": "Research paper" }, + { + "name": "new-sale-quotation", + "author": "pdfme", + "path": "new-sale-quotation/template.json", + "thumbnailPath": "new-sale-quotation/thumbnail.png", + "pageCount": 3, + "fieldCount": 49, + "schemaTypes": [ + "image", + "line", + "rectangle", + "table", + "text" + ], + "fontNames": [], + "hasCJK": false, + "basePdfKind": "blank", + "description": "A sales quotation template with product rows and business summary fields.", + "sourceKind": "designer", + "tags": [ + "Quote", + "Business", + "Table" + ], + "title": "Sales Quotation" + }, + { + "name": "hihokensha-shikaku-shutoku-todoke", + "author": "EedgeY", + "path": "hihokensha-shikaku-shutoku-todoke/template.json", + "thumbnailPath": "hihokensha-shikaku-shutoku-todoke/thumbnail.png", + "pageCount": 1, + "fieldCount": 104, + "schemaTypes": [ + "text" + ], + "fontNames": [ + "NotoSansJP" + ], + "hasCJK": true, + "basePdfKind": "dataUri", + "description": "A Japanese social insurance form template for structured government-style documents.", + "sourceKind": "designer", + "tags": [ + "Government", + "CJK", + "Form" + ], + "title": "Social Insurance Enrollment Form" + }, { "name": "z-fold-brochure", "author": "hitomi-t260g", @@ -796,7 +819,8 @@ "tags": [ "Brochure", "Print" - ] + ], + "title": "Z-Fold Brochure" } ] } \ No newline at end of file diff --git a/playground/public/template-assets/manifests/6.1.2.json b/playground/public/template-assets/manifests/6.1.2.json index 27a118e6..9ca3a0b4 100644 --- a/playground/public/template-assets/manifests/6.1.2.json +++ b/playground/public/template-assets/manifests/6.1.2.json @@ -27,7 +27,8 @@ "Business", "Table", "Visual" - ] + ], + "title": "Invoice" }, { "name": "quotes", @@ -54,7 +55,8 @@ "Business", "Table", "Visual" - ] + ], + "title": "Quotes" }, { "name": "pedigree", @@ -80,7 +82,8 @@ "QR", "Image", "Visual" - ] + ], + "title": "Pedigree" }, { "name": "certificate-black", @@ -103,7 +106,8 @@ "tags": [ "Certificate", "Award" - ] + ], + "title": "Certificate Black" }, { "name": "a4-blank", @@ -122,7 +126,8 @@ "tags": [ "Blank", "Starter" - ] + ], + "title": "A4 Blank" }, { "name": "qr-lines", @@ -144,7 +149,8 @@ "tags": [ "QR", "Label" - ] + ], + "title": "QR Lines" }, { "name": "address-label-10", @@ -164,7 +170,8 @@ "tags": [ "Labels", "Shipping" - ] + ], + "title": "Address Label 10" }, { "name": "address-label-30", @@ -184,7 +191,8 @@ "tags": [ "Labels", "Shipping" - ] + ], + "title": "Address Label 30" }, { "name": "address-label-6", @@ -204,7 +212,8 @@ "tags": [ "Labels", "Shipping" - ] + ], + "title": "Address Label 6" }, { "name": "md2pdf-article", @@ -251,7 +260,8 @@ "tags": [ "Certificate", "Award" - ] + ], + "title": "Certificate Blue" }, { "name": "certificate-gold", @@ -273,7 +283,8 @@ "tags": [ "Certificate", "Award" - ] + ], + "title": "Certificate Gold" }, { "name": "certificate-white", @@ -295,7 +306,8 @@ "tags": [ "Certificate", "Award" - ] + ], + "title": "Certificate White" }, { "name": "jsx-form-fields", @@ -325,29 +337,6 @@ ], "title": "Form fields" }, - { - "name": "hihokensha-shikaku-shutoku-todoke", - "author": "EedgeY", - "path": "hihokensha-shikaku-shutoku-todoke/template.json", - "thumbnailPath": "hihokensha-shikaku-shutoku-todoke/thumbnail.png", - "pageCount": 1, - "fieldCount": 104, - "schemaTypes": [ - "text" - ], - "fontNames": [ - "NotoSansJP" - ], - "hasCJK": true, - "basePdfKind": "dataUri", - "description": "A Japanese social insurance form template for structured government-style documents.", - "sourceKind": "designer", - "tags": [ - "Government", - "CJK", - "Form" - ] - }, { "name": "inline-markdown-mvt", "author": "pdfme", @@ -370,7 +359,52 @@ "Markdown", "MVT", "Form" - ] + ], + "title": "Inline Markdown MVT" + }, + { + "name": "invoice-blue", + "author": "pdfme", + "path": "invoice-blue/template.json", + "thumbnailPath": "invoice-blue/thumbnail.png", + "pageCount": 1, + "fieldCount": 35, + "schemaTypes": [ + "text" + ], + "fontNames": [], + "hasCJK": false, + "basePdfKind": "dataUri", + "description": "A blue invoice variant for a more branded business document.", + "sourceKind": "designer", + "tags": [ + "Invoice", + "Business", + "Table" + ], + "title": "Invoice Blue" + }, + { + "name": "invoice-green", + "author": "pdfme", + "path": "invoice-green/template.json", + "thumbnailPath": "invoice-green/thumbnail.png", + "pageCount": 1, + "fieldCount": 25, + "schemaTypes": [ + "text" + ], + "fontNames": [], + "hasCJK": false, + "basePdfKind": "dataUri", + "description": "A green invoice variant with a calm accounting-oriented look.", + "sourceKind": "designer", + "tags": [ + "Invoice", + "Business", + "Table" + ], + "title": "Invoice Green" }, { "name": "jsx-invoice", @@ -406,10 +440,10 @@ "title": "Invoice layout" }, { - "name": "invoice-blue", + "name": "invoice-white", "author": "pdfme", - "path": "invoice-blue/template.json", - "thumbnailPath": "invoice-blue/thumbnail.png", + "path": "invoice-white/template.json", + "thumbnailPath": "invoice-white/thumbnail.png", "pageCount": 1, "fieldCount": 35, "schemaTypes": [ @@ -418,34 +452,14 @@ "fontNames": [], "hasCJK": false, "basePdfKind": "dataUri", - "description": "A blue invoice variant for a more branded business document.", + "description": "A restrained white invoice layout with a clean printable style.", "sourceKind": "designer", "tags": [ "Invoice", "Business", "Table" - ] - }, - { - "name": "invoice-green", - "author": "pdfme", - "path": "invoice-green/template.json", - "thumbnailPath": "invoice-green/thumbnail.png", - "pageCount": 1, - "fieldCount": 25, - "schemaTypes": [ - "text" ], - "fontNames": [], - "hasCJK": false, - "basePdfKind": "dataUri", - "description": "A green invoice variant with a calm accounting-oriented look.", - "sourceKind": "designer", - "tags": [ - "Invoice", - "Business", - "Table" - ] + "title": "Invoice White" }, { "name": "invoice-ja-simple", @@ -472,7 +486,8 @@ "Invoice", "Business", "CJK" - ] + ], + "title": "Japanese Invoice" }, { "name": "invoice-ja-simple-landscape", @@ -499,28 +514,8 @@ "Invoice", "Business", "CJK" - ] - }, - { - "name": "invoice-white", - "author": "pdfme", - "path": "invoice-white/template.json", - "thumbnailPath": "invoice-white/thumbnail.png", - "pageCount": 1, - "fieldCount": 35, - "schemaTypes": [ - "text" ], - "fontNames": [], - "hasCJK": false, - "basePdfKind": "dataUri", - "description": "A restrained white invoice layout with a clean printable style.", - "sourceKind": "designer", - "tags": [ - "Invoice", - "Business", - "Table" - ] + "title": "Japanese Invoice Landscape" }, { "name": "jsx-japanese-notice", @@ -594,7 +589,8 @@ "tags": [ "Map", "Visual" - ] + ], + "title": "Location Arrow" }, { "name": "location-number", @@ -615,32 +611,8 @@ "tags": [ "Map", "Visual" - ] - }, - { - "name": "new-sale-quotation", - "author": "pdfme", - "path": "new-sale-quotation/template.json", - "thumbnailPath": "new-sale-quotation/thumbnail.png", - "pageCount": 3, - "fieldCount": 49, - "schemaTypes": [ - "image", - "line", - "rectangle", - "table", - "text" ], - "fontNames": [], - "hasCJK": false, - "basePdfKind": "blank", - "description": "A sales quotation template with product rows and business summary fields.", - "sourceKind": "designer", - "tags": [ - "Quote", - "Business", - "Table" - ] + "title": "Location Number" }, { "name": "md2pdf-overview", @@ -689,7 +661,8 @@ "tags": [ "QR", "Label" - ] + ], + "title": "QR Title" }, { "name": "md2pdf-release-notes", @@ -771,6 +744,56 @@ ], "title": "Research paper" }, + { + "name": "new-sale-quotation", + "author": "pdfme", + "path": "new-sale-quotation/template.json", + "thumbnailPath": "new-sale-quotation/thumbnail.png", + "pageCount": 3, + "fieldCount": 49, + "schemaTypes": [ + "image", + "line", + "rectangle", + "table", + "text" + ], + "fontNames": [], + "hasCJK": false, + "basePdfKind": "blank", + "description": "A sales quotation template with product rows and business summary fields.", + "sourceKind": "designer", + "tags": [ + "Quote", + "Business", + "Table" + ], + "title": "Sales Quotation" + }, + { + "name": "hihokensha-shikaku-shutoku-todoke", + "author": "EedgeY", + "path": "hihokensha-shikaku-shutoku-todoke/template.json", + "thumbnailPath": "hihokensha-shikaku-shutoku-todoke/thumbnail.png", + "pageCount": 1, + "fieldCount": 104, + "schemaTypes": [ + "text" + ], + "fontNames": [ + "NotoSansJP" + ], + "hasCJK": true, + "basePdfKind": "dataUri", + "description": "A Japanese social insurance form template for structured government-style documents.", + "sourceKind": "designer", + "tags": [ + "Government", + "CJK", + "Form" + ], + "title": "Social Insurance Enrollment Form" + }, { "name": "z-fold-brochure", "author": "hitomi-t260g", @@ -796,7 +819,8 @@ "tags": [ "Brochure", "Print" - ] + ], + "title": "Z-Fold Brochure" } ] } \ No newline at end of file diff --git a/playground/public/template-assets/metadata.json b/playground/public/template-assets/metadata.json deleted file mode 100644 index 7b7ec8c0..00000000 --- a/playground/public/template-assets/metadata.json +++ /dev/null @@ -1,104 +0,0 @@ -{ - "a4-blank": { - "order": 130, - "description": "A clean blank A4 document for starting from scratch in Designer.", - "tags": ["Blank", "Starter"] - }, - "address-label-10": { - "description": "A 10-label address sheet for shipping and mailing workflows.", - "tags": ["Labels", "Shipping"] - }, - "address-label-30": { - "description": "A compact 30-label address sheet for dense mailing labels.", - "tags": ["Labels", "Shipping"] - }, - "address-label-6": { - "description": "A larger 6-label address sheet with room for longer addresses.", - "tags": ["Labels", "Shipping"] - }, - "certificate-black": { - "order": 120, - "description": "A high-contrast certificate layout with a formal dark theme.", - "tags": ["Certificate", "Award"] - }, - "certificate-blue": { - "description": "A polished blue certificate layout for awards and completion documents.", - "tags": ["Certificate", "Award"] - }, - "certificate-gold": { - "description": "A warm gold certificate layout with a classic presentation style.", - "tags": ["Certificate", "Award"] - }, - "certificate-white": { - "description": "A minimal certificate layout that works well with light branding.", - "tags": ["Certificate", "Award"] - }, - "hihokensha-shikaku-shutoku-todoke": { - "description": "A Japanese social insurance form template for structured government-style documents.", - "tags": ["Government", "CJK", "Form"] - }, - "inline-markdown-mvt": { - "description": "A focused demo of inline markdown and MultiVariableText editing.", - "tags": ["Markdown", "MVT", "Form"] - }, - "invoice": { - "order": 90, - "description": "A practical invoice with customer details, line items, totals, and payment notes.", - "tags": ["Invoice", "Business", "Table", "Visual"] - }, - "invoice-blue": { - "description": "A blue invoice variant for a more branded business document.", - "tags": ["Invoice", "Business", "Table"] - }, - "invoice-green": { - "description": "A green invoice variant with a calm accounting-oriented look.", - "tags": ["Invoice", "Business", "Table"] - }, - "invoice-ja-simple": { - "description": "A simple Japanese invoice layout with CJK font usage.", - "tags": ["Invoice", "Business", "CJK"] - }, - "invoice-ja-simple-landscape": { - "description": "A landscape Japanese invoice layout for wider table content.", - "tags": ["Invoice", "Business", "CJK"] - }, - "invoice-white": { - "description": "A restrained white invoice layout with a clean printable style.", - "tags": ["Invoice", "Business", "Table"] - }, - "location-arrow": { - "description": "A location marker template that highlights points with arrow indicators.", - "tags": ["Map", "Visual"] - }, - "location-number": { - "description": "A location marker template that labels points with numbered badges.", - "tags": ["Map", "Visual"] - }, - "new-sale-quotation": { - "description": "A sales quotation template with product rows and business summary fields.", - "tags": ["Quote", "Business", "Table"] - }, - "pedigree": { - "order": 110, - "description": "A pedigree-style relationship chart for structured family or lineage data.", - "tags": ["Chart", "QR", "Image", "Visual"] - }, - "qr-lines": { - "order": 140, - "description": "A QR code template with line-based metadata and compact supporting text.", - "tags": ["QR", "Label"] - }, - "qr-title": { - "description": "A QR code template with a prominent title and simple scan instructions.", - "tags": ["QR", "Label"] - }, - "quotes": { - "order": 100, - "description": "A quote document layout for proposals, estimates, and short commercial offers.", - "tags": ["Quote", "Business", "Table", "Visual"] - }, - "z-fold-brochure": { - "description": "A z-fold brochure layout for tri-fold print and promotional material.", - "tags": ["Brochure", "Print"] - } -} diff --git a/playground/public/template-assets/new-sale-quotation/metadata.json b/playground/public/template-assets/new-sale-quotation/metadata.json new file mode 100644 index 00000000..3c5b05c4 --- /dev/null +++ b/playground/public/template-assets/new-sale-quotation/metadata.json @@ -0,0 +1,10 @@ +{ + "title": "Sales Quotation", + "description": "A sales quotation template with product rows and business summary fields.", + "sourceKind": "designer", + "tags": [ + "Quote", + "Business", + "Table" + ] +} diff --git a/playground/public/template-assets/pedigree/metadata.json b/playground/public/template-assets/pedigree/metadata.json new file mode 100644 index 00000000..2f26c037 --- /dev/null +++ b/playground/public/template-assets/pedigree/metadata.json @@ -0,0 +1,12 @@ +{ + "title": "Pedigree", + "description": "A pedigree-style relationship chart for structured family or lineage data.", + "sourceKind": "designer", + "tags": [ + "Chart", + "QR", + "Image", + "Visual" + ], + "order": 110 +} diff --git a/playground/public/template-assets/qr-lines/metadata.json b/playground/public/template-assets/qr-lines/metadata.json new file mode 100644 index 00000000..2dfaeb70 --- /dev/null +++ b/playground/public/template-assets/qr-lines/metadata.json @@ -0,0 +1,10 @@ +{ + "title": "QR Lines", + "description": "A QR code template with line-based metadata and compact supporting text.", + "sourceKind": "designer", + "tags": [ + "QR", + "Label" + ], + "order": 140 +} diff --git a/playground/public/template-assets/qr-title/metadata.json b/playground/public/template-assets/qr-title/metadata.json new file mode 100644 index 00000000..c562dc02 --- /dev/null +++ b/playground/public/template-assets/qr-title/metadata.json @@ -0,0 +1,9 @@ +{ + "title": "QR Title", + "description": "A QR code template with a prominent title and simple scan instructions.", + "sourceKind": "designer", + "tags": [ + "QR", + "Label" + ] +} diff --git a/playground/public/template-assets/quotes/metadata.json b/playground/public/template-assets/quotes/metadata.json new file mode 100644 index 00000000..607d7229 --- /dev/null +++ b/playground/public/template-assets/quotes/metadata.json @@ -0,0 +1,12 @@ +{ + "title": "Quotes", + "description": "A quote document layout for proposals, estimates, and short commercial offers.", + "sourceKind": "designer", + "tags": [ + "Quote", + "Business", + "Table", + "Visual" + ], + "order": 100 +} diff --git a/playground/public/template-assets/z-fold-brochure/metadata.json b/playground/public/template-assets/z-fold-brochure/metadata.json new file mode 100644 index 00000000..32e9d7e5 --- /dev/null +++ b/playground/public/template-assets/z-fold-brochure/metadata.json @@ -0,0 +1,9 @@ +{ + "title": "Z-Fold Brochure", + "description": "A z-fold brochure layout for tri-fold print and promotional material.", + "sourceKind": "designer", + "tags": [ + "Brochure", + "Print" + ] +} diff --git a/playground/scripts/generate-templates-list-json.mjs b/playground/scripts/generate-templates-list-json.mjs index 8f1ea2f6..d20339e6 100644 --- a/playground/scripts/generate-templates-list-json.mjs +++ b/playground/scripts/generate-templates-list-json.mjs @@ -8,7 +8,6 @@ const __dirname = path.dirname(__filename); const templatesDir = path.join(__dirname, '..', 'public', 'template-assets'); const indexFilePath = path.join(templatesDir, 'index.json'); -const metadataFilePath = path.join(templatesDir, 'metadata.json'); const manifestFilePath = path.join(templatesDir, 'manifest.json'); const versionedManifestDir = path.join(templatesDir, 'manifests'); @@ -68,20 +67,6 @@ function generateTemplatesListJson() { function loadTemplateMetadata() { const metadataByTemplate = {}; - if (!fs.existsSync(metadataFilePath)) { - return loadPerTemplateMetadata(metadataByTemplate); - } - - const parsed = JSON.parse(fs.readFileSync(metadataFilePath, 'utf8')); - if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { - throw new Error('template-assets/metadata.json must be an object keyed by template name.'); - } - - Object.assign(metadataByTemplate, parsed); - return loadPerTemplateMetadata(metadataByTemplate); -} - -function loadPerTemplateMetadata(metadataByTemplate) { const items = fs.readdirSync(templatesDir, { withFileTypes: true }); for (const item of items) { if (!item.isDirectory() || item.name.startsWith('.')) continue; @@ -105,7 +90,9 @@ function normalizeMetadata(rawMetadata) { } const metadata = {}; - if (typeof rawMetadata.title === 'string') metadata.title = rawMetadata.title; + if (typeof rawMetadata.title === 'string' && rawMetadata.title.trim()) { + metadata.title = rawMetadata.title.trim(); + } if (typeof rawMetadata.description === 'string') metadata.description = rawMetadata.description; if (typeof rawMetadata.order === 'number' && Number.isFinite(rawMetadata.order)) { metadata.order = rawMetadata.order; @@ -142,15 +129,21 @@ function validateTemplateMetadata(metadataByTemplate, templateDirs) { for (const [name, rawMetadata] of Object.entries(metadataByTemplate)) { const metadata = normalizeMetadata(rawMetadata); + if (!metadata.title) { + throw new Error(`template asset metadata entry "${name}" must include title.`); + } if (!metadata.description) { throw new Error(`template asset metadata entry "${name}" must include description.`); } + if (!metadata.sourceKind) { + throw new Error(`template asset metadata entry "${name}" must include sourceKind.`); + } if (!metadata.tags || metadata.tags.length === 0) { throw new Error(`template asset metadata entry "${name}" must include tags.`); } const inferredSourceKind = inferSourceKind(name); - if (metadata.sourceKind && metadata.sourceKind !== inferredSourceKind) { + if (metadata.sourceKind !== inferredSourceKind) { throw new Error( `template asset metadata entry "${name}" has sourceKind "${metadata.sourceKind}", expected "${inferredSourceKind}".`, ); @@ -182,7 +175,13 @@ function buildTemplateEntry(name, templateJson, rawMetadata) { const schemas = normalizeSchemas(templateJson.schemas); const flattenedSchemas = schemas.flat(); const metadata = normalizeMetadata(rawMetadata); - const sourceKind = metadata.sourceKind ?? inferSourceKind(name); + if (!metadata.title) { + throw new Error(`template asset metadata entry "${name}" must include title.`); + } + if (!metadata.sourceKind) { + throw new Error(`template asset metadata entry "${name}" must include sourceKind.`); + } + const sourceKind = metadata.sourceKind; const schemaTypes = [ ...new Set(flattenedSchemas.map((schema) => schema.type).filter(Boolean)), ].sort(); @@ -205,7 +204,7 @@ function buildTemplateEntry(name, templateJson, rawMetadata) { description: metadata.description, order: metadata.order, sourceKind, - tags: metadata.tags ?? [], + tags: metadata.tags, title: metadata.title, }; } @@ -227,9 +226,7 @@ function compareTemplateEntries(a, b) { if (a.order != null) return -1; if (b.order != null) return 1; - const aTitle = a.title ?? a.name; - const bTitle = b.title ?? b.name; - const titleResult = aTitle.localeCompare(bTitle); + const titleResult = a.title.localeCompare(b.title); if (titleResult !== 0) return titleResult; return a.name.localeCompare(b.name); diff --git a/playground/src/components/ProjectSavedToast.tsx b/playground/src/components/ProjectSavedToast.tsx index 7f1f8e49..20eda866 100644 --- a/playground/src/components/ProjectSavedToast.tsx +++ b/playground/src/components/ProjectSavedToast.tsx @@ -1,19 +1,30 @@ import { Link } from 'react-router-dom'; type ProjectSavedToastProps = { + formPath?: string; title: string; }; -export default function ProjectSavedToast({ title }: ProjectSavedToastProps) { +export default function ProjectSavedToast({ formPath, title }: ProjectSavedToastProps) { return (

Saved "{title}"

- - View in Templates - +
+ + View in Templates + + {formPath && ( + + Open Form + + )} +
); } diff --git a/playground/src/helper.ts b/playground/src/helper.ts index f242feed..81f807fe 100644 --- a/playground/src/helper.ts +++ b/playground/src/helper.ts @@ -10,12 +10,20 @@ import { Form, Viewer, Designer } from '@pdfme/ui'; import { generate, generateForm } from '@pdfme/generator'; import { getPlugins } from './plugins'; -export function fromKebabCase(str: string): string { - return str - .split('-') - .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) - .join(' '); -} +const templateAssetSourceKinds = ['designer', 'jsx', 'md2pdf'] as const; + +type TemplateAssetSourceKind = (typeof templateAssetSourceKinds)[number]; + +export type TemplateAssetMetadata = { + description: string; + order?: number; + sourceKind: TemplateAssetSourceKind; + tags: string[]; + title: string; +}; + +const isTemplateAssetSourceKind = (value: unknown): value is TemplateAssetSourceKind => + templateAssetSourceKinds.includes(value as TemplateAssetSourceKind); export const getFontsData = (): Font => ({ ...getDefaultFont(), @@ -150,4 +158,37 @@ export const getTemplateById = async (templateId: string): Promise