feat(playground): standardize template metadata

This commit is contained in:
hand-dot
2026-05-18 12:20:46 +09:00
parent f28ce8a50c
commit c0bc2ba7af
35 changed files with 616 additions and 391 deletions

View File

@@ -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'),
},
];

View File

@@ -110,7 +110,12 @@ describe('file workspace helpers', () => {
invoice.addFile('template.json', serializeTemplateForFileWorkspace(blankTemplate));
invoice.addFile(
'metadata.json',
JSON.stringify({ description: 'Invoice template', tags: ['Invoice'], title: 'Invoice' }),
JSON.stringify({
description: 'Invoice template',
sourceKind: 'designer',
tags: ['Invoice'],
title: 'Invoice',
}),
);
root
.addDirectory('.cache')
@@ -197,6 +202,8 @@ describe('file workspace helpers', () => {
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": [');
});
});

View File

@@ -1,8 +1,10 @@
{
"order": 130,
"title": "A4 Blank",
"description": "A clean blank A4 document for starting from scratch in Designer.",
"sourceKind": "designer",
"tags": [
"Blank",
"Starter"
]
],
"order": 130
}

View File

@@ -1,5 +1,7 @@
{
"title": "Address Label 10",
"description": "A 10-label address sheet for shipping and mailing workflows.",
"sourceKind": "designer",
"tags": [
"Labels",
"Shipping"

View File

@@ -1,5 +1,7 @@
{
"title": "Address Label 30",
"description": "A compact 30-label address sheet for dense mailing labels.",
"sourceKind": "designer",
"tags": [
"Labels",
"Shipping"

View File

@@ -1,5 +1,7 @@
{
"title": "Address Label 6",
"description": "A larger 6-label address sheet with room for longer addresses.",
"sourceKind": "designer",
"tags": [
"Labels",
"Shipping"

View File

@@ -1,8 +1,10 @@
{
"order": 120,
"title": "Certificate Black",
"description": "A high-contrast certificate layout with a formal dark theme.",
"sourceKind": "designer",
"tags": [
"Certificate",
"Award"
]
],
"order": 120
}

View File

@@ -1,5 +1,7 @@
{
"title": "Certificate Blue",
"description": "A polished blue certificate layout for awards and completion documents.",
"sourceKind": "designer",
"tags": [
"Certificate",
"Award"

View File

@@ -1,5 +1,7 @@
{
"title": "Certificate Gold",
"description": "A warm gold certificate layout with a classic presentation style.",
"sourceKind": "designer",
"tags": [
"Certificate",
"Award"

View File

@@ -1,5 +1,7 @@
{
"title": "Certificate White",
"description": "A minimal certificate layout that works well with light branding.",
"sourceKind": "designer",
"tags": [
"Certificate",
"Award"

View File

@@ -1,5 +1,7 @@
{
"title": "Social Insurance Enrollment Form",
"description": "A Japanese social insurance form template for structured government-style documents.",
"sourceKind": "designer",
"tags": [
"Government",
"CJK",

View File

@@ -1,5 +1,7 @@
{
"title": "Inline Markdown MVT",
"description": "A focused demo of inline markdown and MultiVariableText editing.",
"sourceKind": "designer",
"tags": [
"Markdown",
"MVT",

View File

@@ -1,5 +1,7 @@
{
"title": "Invoice Blue",
"description": "A blue invoice variant for a more branded business document.",
"sourceKind": "designer",
"tags": [
"Invoice",
"Business",

View File

@@ -1,5 +1,7 @@
{
"title": "Invoice Green",
"description": "A green invoice variant with a calm accounting-oriented look.",
"sourceKind": "designer",
"tags": [
"Invoice",
"Business",

View File

@@ -1,5 +1,7 @@
{
"title": "Japanese Invoice Landscape",
"description": "A landscape Japanese invoice layout for wider table content.",
"sourceKind": "designer",
"tags": [
"Invoice",
"Business",

View File

@@ -1,5 +1,7 @@
{
"title": "Japanese Invoice",
"description": "A simple Japanese invoice layout with CJK font usage.",
"sourceKind": "designer",
"tags": [
"Invoice",
"Business",

View File

@@ -1,5 +1,7 @@
{
"title": "Invoice White",
"description": "A restrained white invoice layout with a clean printable style.",
"sourceKind": "designer",
"tags": [
"Invoice",
"Business",

View File

@@ -1,10 +1,12 @@
{
"order": 90,
"title": "Invoice",
"description": "A practical invoice with customer details, line items, totals, and payment notes.",
"sourceKind": "designer",
"tags": [
"Invoice",
"Business",
"Table",
"Visual"
]
],
"order": 90
}

View File

@@ -1,5 +1,7 @@
{
"title": "Location Arrow",
"description": "A location marker template that highlights points with arrow indicators.",
"sourceKind": "designer",
"tags": [
"Map",
"Visual"

View File

@@ -1,5 +1,7 @@
{
"title": "Location Number",
"description": "A location marker template that labels points with numbered badges.",
"sourceKind": "designer",
"tags": [
"Map",
"Visual"

View File

@@ -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"
}
]
}

View File

@@ -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"
}
]
}

View File

@@ -1,5 +1,7 @@
{
"title": "Sales Quotation",
"description": "A sales quotation template with product rows and business summary fields.",
"sourceKind": "designer",
"tags": [
"Quote",
"Business",

View File

@@ -1,10 +1,12 @@
{
"order": 110,
"title": "Pedigree",
"description": "A pedigree-style relationship chart for structured family or lineage data.",
"sourceKind": "designer",
"tags": [
"Chart",
"QR",
"Image",
"Visual"
]
],
"order": 110
}

View File

@@ -1,8 +1,10 @@
{
"order": 140,
"title": "QR Lines",
"description": "A QR code template with line-based metadata and compact supporting text.",
"sourceKind": "designer",
"tags": [
"QR",
"Label"
]
],
"order": 140
}

View File

@@ -1,5 +1,7 @@
{
"title": "QR Title",
"description": "A QR code template with a prominent title and simple scan instructions.",
"sourceKind": "designer",
"tags": [
"QR",
"Label"

View File

@@ -1,10 +1,12 @@
{
"order": 100,
"title": "Quotes",
"description": "A quote document layout for proposals, estimates, and short commercial offers.",
"sourceKind": "designer",
"tags": [
"Quote",
"Business",
"Table",
"Visual"
]
],
"order": 100
}

View File

@@ -1,5 +1,7 @@
{
"title": "Z-Fold Brochure",
"description": "A z-fold brochure layout for tri-fold print and promotional material.",
"sourceKind": "designer",
"tags": [
"Brochure",
"Print"

View File

@@ -90,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;
@@ -127,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}".`,
);
@@ -167,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();
@@ -190,7 +204,7 @@ function buildTemplateEntry(name, templateJson, rawMetadata) {
description: metadata.description,
order: metadata.order,
sourceKind,
tags: metadata.tags ?? [],
tags: metadata.tags,
title: metadata.title,
};
}
@@ -212,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);

View File

@@ -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<Template> =>
return template as Template;
};
export const getTemplateMetadataById = async (
templateId: string,
): Promise<TemplateAssetMetadata> => {
const response = await fetch(`/template-assets/${templateId}/metadata.json`);
if (!response.ok) {
throw new Error(`Failed to load template metadata: ${response.statusText}`);
}
const metadata = (await response.json()) as Partial<TemplateAssetMetadata>;
if (!metadata.title?.trim()) {
throw new Error(`Template metadata "${templateId}" must include title.`);
}
if (!metadata.description?.trim()) {
throw new Error(`Template metadata "${templateId}" must include description.`);
}
if (!isTemplateAssetSourceKind(metadata.sourceKind)) {
throw new Error(`Template metadata "${templateId}" must include sourceKind.`);
}
if (!metadata.tags || metadata.tags.length === 0) {
throw new Error(`Template metadata "${templateId}" must include tags.`);
}
return {
description: metadata.description,
order: metadata.order,
sourceKind: metadata.sourceKind,
tags: metadata.tags,
title: metadata.title.trim(),
};
};
export const getDefaultPlaygroundTemplate = () => getTemplateById(DEFAULT_PLAYGROUND_TEMPLATE_ID);
export const getDefaultPlaygroundTemplateMetadata = () =>
getTemplateMetadataById(DEFAULT_PLAYGROUND_TEMPLATE_ID);

View File

@@ -2,7 +2,7 @@ export type AuthoringStarterKind = 'jsx' | 'md2pdf';
export type AuthoringStarter = {
assetName: string;
description?: string;
description: string;
id: string;
kind: AuthoringStarterKind;
label: string;
@@ -10,11 +10,11 @@ export type AuthoringStarter = {
};
type TemplateAssetEntry = {
description?: string;
description: string;
name: string;
sourceKind?: string;
sourceKind: string;
sourcePath?: string;
title?: string;
title: string;
};
const TEMPLATE_ASSETS_BASE_PATH = '/template-assets';
@@ -40,7 +40,7 @@ export const loadAuthoringStarters = async (
description: entry.description,
id: getAuthoringStarterId(entry.name, kind),
kind,
label: entry.title ?? entry.name,
label: entry.title,
sourcePath: entry.sourcePath!,
}));
};

View File

@@ -17,7 +17,7 @@ type SourceKind = 'designer' | 'jsx' | 'md2pdf';
export type FileWorkspaceMetadata = {
description?: string;
order?: number;
sourceKind?: SourceKind;
sourceKind: SourceKind;
tags: string[];
title?: string;
};
@@ -393,7 +393,7 @@ const buildTemplateEntry = async (
name,
order: metadata.order,
path: `${name}/template.json`,
sourceKind: metadata.sourceKind ?? inferSourceKind(name),
sourceKind: metadata.sourceKind,
tags: metadata.tags,
template: readResult.template,
templateDirectoryHandle: directoryHandle,
@@ -585,7 +585,7 @@ export const subscribeTemplateEntryChanges = (
export const createBlankTemplateEntry = async (
rootHandle: FileSystemDirectoryHandle,
title = 'untitled-template',
title = 'Untitled Template',
) => {
const name = await createUniqueDirectoryName(rootHandle, title);
const directoryHandle = await rootHandle.getDirectoryHandle(name, { create: true });
@@ -603,7 +603,9 @@ export const createBlankTemplateEntry = async (
`${JSON.stringify(
{
description: 'A blank template created from the pdfme Playground.',
sourceKind: 'designer',
tags: ['Blank', 'Starter'],
title: title.trim() || titleFromDirectoryName(name),
},
null,
2,
@@ -636,6 +638,7 @@ export const createTemplateEntryFromTemplate = async (
`${JSON.stringify(
{
description: 'A template saved from the pdfme Playground.',
sourceKind: 'designer',
tags: ['Designer'],
title: title.trim() || titleFromDirectoryName(name),
},

View File

@@ -5,11 +5,12 @@ import { Code2, Copy, Download, Save } from 'lucide-react';
import { cloneDeep, Template, checkTemplate, Lang, isBlankPdf } from '@pdfme/common';
import { Designer } from '@pdfme/ui';
import {
fromKebabCase,
getFontsData,
getTemplateById,
getTemplateMetadataById,
getBlankTemplate,
getDefaultPlaygroundTemplate,
getDefaultPlaygroundTemplateMetadata,
readFile,
generatePDF,
downloadJsonFile,
@@ -295,7 +296,12 @@ function DesignerApp() {
refreshedCollection.rootHandle,
refreshedEntry.name,
);
toast.success(`Saved ${refreshedEntry.path}`);
toast.success(
<ProjectSavedToast
formPath={`/form-viewer?workspace=${encodeURIComponent(refreshedEntry.name)}`}
title={refreshedEntry.path}
/>,
);
} finally {
isSavingFileWorkspaceRef.current = false;
}
@@ -382,18 +388,25 @@ function DesignerApp() {
template = project.template;
} else if (templateIdFromQuery) {
setActiveFileWorkspaceEntry(null, null);
const templateJson = await getTemplateById(templateIdFromQuery);
const [templateJson, metadata] = await Promise.all([
getTemplateById(templateIdFromQuery),
getTemplateMetadataById(templateIdFromQuery),
]);
checkTemplate(templateJson);
template = templateJson;
setCurrentProjectTitle(fromKebabCase(templateIdFromQuery));
setCurrentProjectTitle(metadata.title);
} else {
setActiveFileWorkspaceEntry(null, null);
project = getActivePlaygroundProject();
if (project) {
template = project.template;
} else {
template = await getDefaultPlaygroundTemplate();
setCurrentProjectTitle(fromKebabCase('invoice'));
const [defaultTemplate, metadata] = await Promise.all([
getDefaultPlaygroundTemplate(),
getDefaultPlaygroundTemplateMetadata(),
]);
template = defaultTemplate;
setCurrentProjectTitle(metadata.title);
}
}

View File

@@ -4,11 +4,12 @@ import { toast } from 'react-toastify';
import { Template, checkTemplate, getInputFromTemplate, Lang } from '@pdfme/common';
import { Form, Viewer } from '@pdfme/ui';
import {
fromKebabCase,
getFontsData,
getTemplateById,
getTemplateMetadataById,
getBlankTemplate,
getDefaultPlaygroundTemplate,
getDefaultPlaygroundTemplateMetadata,
generatePDF,
isJsonString,
translations,
@@ -133,10 +134,13 @@ function FormAndViewerApp() {
diskVersionRef.current = null;
setFileWorkspaceEntry(null);
setFileWorkspaceStatus(null);
const templateJson = await getTemplateById(templateIdFromQuery);
const [templateJson, metadata] = await Promise.all([
getTemplateById(templateIdFromQuery),
getTemplateMetadataById(templateIdFromQuery),
]);
checkTemplate(templateJson);
template = templateJson;
setProjectTitle(fromKebabCase(templateIdFromQuery));
setProjectTitle(metadata.title);
} else {
fileWorkspaceEntryRef.current = null;
diskVersionRef.current = null;
@@ -147,8 +151,12 @@ function FormAndViewerApp() {
template = project.template;
inputs = project.inputs;
} else {
template = await getDefaultPlaygroundTemplate();
setProjectTitle(fromKebabCase('invoice'));
const [defaultTemplate, metadata] = await Promise.all([
getDefaultPlaygroundTemplate(),
getDefaultPlaygroundTemplateMetadata(),
]);
template = defaultTemplate;
setProjectTitle(metadata.title);
}
}
@@ -247,7 +255,12 @@ function FormAndViewerApp() {
currentTemplateRef.current = nextTemplate;
currentInputsRef.current = nextInputs;
setProjectTitle(savedProject.title);
toast.success(<ProjectSavedToast title={savedProject.title} />);
toast.success(
<ProjectSavedToast
formPath={`/form-viewer?project=${encodeURIComponent(savedProject.id)}`}
title={savedProject.title}
/>,
);
};
const onResetInputs = () => {
@@ -366,8 +379,12 @@ function FormAndViewerApp() {
<div className="flex gap-1">
<PlaygroundButton onClick={onGetInputs}>Get</PlaygroundButton>
<PlaygroundButton onClick={onSetInputs}>Set</PlaygroundButton>
<PlaygroundButton onClick={() => void onSaveInputs()}>Save</PlaygroundButton>
<PlaygroundButton onClick={() => void onSaveInputs(true)}>Save As</PlaygroundButton>
<PlaygroundButton onClick={() => void onSaveInputs()}>
{fileWorkspaceEntry ? 'Save Local Copy' : 'Save'}
</PlaygroundButton>
<PlaygroundButton onClick={() => void onSaveInputs(true)}>
{fileWorkspaceEntry ? 'Save As Local Copy' : 'Save As'}
</PlaygroundButton>
<PlaygroundButton onClick={onResetInputs}>Reset</PlaygroundButton>
</div>
),

View File

@@ -12,12 +12,11 @@ import {
MoreHorizontal,
Pencil,
PencilRuler,
RefreshCw,
Trash2,
Upload,
} from 'lucide-react';
import { toast } from 'react-toastify';
import { downloadJsonFile, fromKebabCase, readFile } from '../helper';
import { downloadJsonFile, readFile } from '../helper';
import PlaygroundButton from '../components/PlaygroundButton';
import { getAuthoringStarterId, type AuthoringStarterKind } from '../lib/authoringStarters';
import {
@@ -62,16 +61,16 @@ type TemplateData = {
name: string;
author: string;
basePdfKind?: string;
description?: string;
description: string;
fieldCount?: number;
fontNames?: string[];
hasCJK?: boolean;
pageCount?: number;
schemaTypes?: string[];
sourceKind?: Exclude<GenerationFilter, 'all'>;
sourceKind: Exclude<GenerationFilter, 'all'>;
sourcePath?: string;
tags?: string[];
title?: string;
tags: string[];
title: string;
};
type UIType = 'designer' | 'form-viewer';
@@ -114,7 +113,13 @@ const generationFilters: Array<{ label: string; value: GenerationFilter }> = [
];
const getTemplateGeneration = (template: TemplateData): Exclude<GenerationFilter, 'all'> =>
template.sourceKind ?? 'designer';
template.sourceKind;
const getGenerationLabel = (generation: Exclude<GenerationFilter, 'all'>) => {
if (generation === 'jsx') return 'JSX';
if (generation === 'md2pdf') return 'md2pdf';
return 'Designer';
};
const getAuthoringPreset = (template: TemplateData): AuthoringPreset | null => {
const kind = getTemplateGeneration(template);
@@ -127,7 +132,7 @@ const getAuthoringPreset = (template: TemplateData): AuthoringPreset | null => {
};
const getTemplateTags = (template: TemplateData) => {
const tags = new Set(template.tags ?? []);
const tags = new Set(template.tags);
return [...tags].sort((a, b) => {
const aIndex = tagSortOrder.indexOf(a);
@@ -442,7 +447,6 @@ function TemplatesApp() {
const [mountedCollection, setMountedCollection] = useState<FileWorkspaceCollection | null>(null);
const [lastFolderName, setLastFolderName] = useState<string | null>(null);
const [isOpeningFolder, setIsOpeningFolder] = useState(false);
const [isRefreshingFolder, setIsRefreshingFolder] = useState(false);
const [generationFilter, setGenerationFilter] = useState<GenerationFilter>('all');
const [tagFilter, setTagFilter] = useState('all');
@@ -450,7 +454,6 @@ function TemplatesApp() {
const refreshMountedCollection = useCallback(() => {
if (!mountedCollection) return;
setIsRefreshingFolder(true);
void refreshTemplateCollection(mountedCollection)
.then((collection) => {
setMountedCollection(collection);
@@ -459,8 +462,7 @@ function TemplatesApp() {
.catch((error) => {
console.error(error);
toast.error(error instanceof Error ? error.message : 'Failed to refresh folder');
})
.finally(() => setIsRefreshingFolder(false));
});
}, [mountedCollection]);
const tagOptions = useMemo(() => {
@@ -685,6 +687,28 @@ function TemplatesApp() {
toast.info('Disconnected mounted folder');
};
const onCreateMountedTemplate = async () => {
if (!mountedCollection) return;
const title = window.prompt('Template name', 'Untitled Template') ?? '';
if (!title.trim()) return;
try {
const entry = await createBlankTemplateEntry(mountedCollection.rootHandle, title);
const nextCollection = await refreshTemplateCollection({
...mountedCollection,
selectedTemplateName: entry.name,
});
const nextEntry = findTemplateEntry(nextCollection, entry.name) ?? entry;
setMountedCollection(nextCollection);
setLastFolderName(nextCollection.rootName);
await navigateToMountedTemplate(nextCollection, nextEntry, 'designer');
} catch (error) {
console.error(error);
toast.error(error instanceof Error ? error.message : 'Failed to create template');
}
};
const onImportTemplateJson = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
e.target.value = '';
@@ -759,125 +783,131 @@ function TemplatesApp() {
<div className="bg-white">
<div className="mx-auto max-w-2xl px-4 py-8 sm:px-6 sm:py-12 lg:max-w-7xl lg:px-8">
<div className="mb-10 rounded-lg border border-green-200 bg-green-50 p-5">
<div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
<div>
<h2 className="text-2xl font-bold text-gray-900">My Workspace</h2>
<p className="mt-2 max-w-3xl text-sm text-green-900">
Save templates from Designer, JSX, or md2pdf as local projects. A project keeps the
generated template, inputs, and source when available.
</p>
</div>
<div className="flex flex-col gap-2 sm:flex-row">
<PlaygroundButton
disabled={!fileWorkspaceSupported || isOpeningFolder}
onClick={() => void onOpenFolder()}
variant="secondary"
>
<FolderOpen className="size-4" />
{isOpeningFolder ? 'Opening...' : 'Open Folder'}
</PlaygroundButton>
<PlaygroundButton
onClick={() => importTemplateInputRef.current?.click()}
variant="secondary"
>
<Upload className="size-4" />
Import Template JSON
</PlaygroundButton>
<input
ref={importTemplateInputRef}
type="file"
accept="application/json"
className="sr-only"
onChange={onImportTemplateJson}
/>
<PlaygroundButton onClick={() => navigate('/designer?new=1')} variant="primary">
<PencilRuler className="size-4" />
New Template
</PlaygroundButton>
</div>
<div>
<h2 className="text-2xl font-bold text-gray-900">My Workspace</h2>
<p className="mt-2 max-w-3xl text-sm text-green-900">
Work with templates saved in this browser, or mount a folder to edit template files
directly on disk.
</p>
</div>
{projects.length > 0 ? (
<div className="mt-5 grid grid-cols-1 gap-y-8 sm:grid-cols-2 sm:gap-x-6 lg:grid-cols-4 xl:gap-x-8">
{projects.map((project) => (
<GalleryCard
key={project.id}
tag={getProjectKindLabel(project.kind)}
title={project.title}
description={
<p className="text-xs text-gray-500">
Updated {new Date(project.updatedAt).toLocaleString()}
</p>
}
thumbnail={
<ProjectThumbnailImage project={project} onCreated={refreshProjects} />
}
actions={
<div className="grid grid-cols-[minmax(0,1fr)_minmax(0,1fr)_auto] gap-2">
<PlaygroundButton
onClick={() =>
navigateToProject(project, project.source ? 'source' : 'designer')
}
>
{project.source ? (
<>
<Code2 className="size-4" />
Source
</>
) : (
<>
<PencilRuler className="size-4" />
Designer
</>
)}
</PlaygroundButton>
<PlaygroundButton onClick={() => navigateToProject(project, 'form-viewer')}>
Preview
</PlaygroundButton>
<ProjectMoreActions
project={project}
onOpenDesigner={(item) => navigateToProject(item, 'designer')}
onRenameProject={onRenameProject}
onDuplicateProject={onDuplicateProject}
onDownloadProjectTemplate={onDownloadProjectTemplate}
onDeleteProject={onDeleteProject}
/>
</div>
}
<div className="mt-5 border-t border-green-200 pt-5">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<h3 className="text-lg font-bold text-gray-900">Browser Projects</h3>
<p className="mt-1 text-sm text-green-900">
Drafts stored in this browser. They include template JSON, form inputs, and source
code when available.
</p>
</div>
<div className="flex flex-col gap-2 sm:flex-row">
<PlaygroundButton
onClick={() => importTemplateInputRef.current?.click()}
variant="secondary"
>
<Upload className="size-4" />
Import Template JSON
</PlaygroundButton>
<input
ref={importTemplateInputRef}
type="file"
accept="application/json"
className="sr-only"
onChange={onImportTemplateJson}
/>
))}
<PlaygroundButton onClick={() => navigate('/designer?new=1')} variant="primary">
<PencilRuler className="size-4" />
New Local Template
</PlaygroundButton>
</div>
</div>
) : (
<div className="mt-5 rounded-md border border-dashed border-green-300 bg-white px-4 py-6 text-sm text-green-900">
No local projects yet. Start from a sample, JSX, md2pdf, or a blank Designer template.
</div>
)}
{projects.length > 0 ? (
<div className="mt-5 grid grid-cols-1 gap-y-8 sm:grid-cols-2 sm:gap-x-6 lg:grid-cols-4 xl:gap-x-8">
{projects.map((project) => (
<GalleryCard
key={project.id}
tag={`Local ${getProjectKindLabel(project.kind)}`}
title={project.title}
description={
<p className="text-xs text-gray-500">
Updated {new Date(project.updatedAt).toLocaleString()}
</p>
}
thumbnail={
<ProjectThumbnailImage project={project} onCreated={refreshProjects} />
}
actions={
<div className="grid grid-cols-[minmax(0,1fr)_minmax(0,1fr)_auto] gap-2">
<PlaygroundButton
onClick={() =>
navigateToProject(project, project.source ? 'source' : 'designer')
}
>
{project.source ? (
<>
<Code2 className="size-4" />
Source
</>
) : (
<>
<PencilRuler className="size-4" />
Designer
</>
)}
</PlaygroundButton>
<PlaygroundButton onClick={() => navigateToProject(project, 'form-viewer')}>
Preview
</PlaygroundButton>
<ProjectMoreActions
project={project}
onOpenDesigner={(item) => navigateToProject(item, 'designer')}
onRenameProject={onRenameProject}
onDuplicateProject={onDuplicateProject}
onDownloadProjectTemplate={onDownloadProjectTemplate}
onDeleteProject={onDeleteProject}
/>
</div>
}
/>
))}
</div>
) : (
<div className="mt-5 rounded-md border border-dashed border-green-300 bg-white px-4 py-6 text-sm text-green-900">
No browser projects yet. Create a local template, import JSON, or save from
Designer, JSX, or md2pdf.
</div>
)}
</div>
<div className="mt-6 border-t border-green-200 pt-5">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<h3 className="text-lg font-bold text-gray-900">Mounted Folder</h3>
<p className="mt-1 text-sm text-green-900">
Edit a template-assets style folder directly on disk.
Templates in this section are read from and saved back to template files on disk.
</p>
</div>
<div className="flex flex-col gap-2 sm:flex-row">
{mountedCollection && (
{!mountedCollection && (
<PlaygroundButton
disabled={isRefreshingFolder}
onClick={() => void refreshMountedCollection()}
variant="secondary"
disabled={!fileWorkspaceSupported || isOpeningFolder}
onClick={() => void onOpenFolder()}
variant="primary"
>
<RefreshCw className="size-4" />
Refresh
<FolderOpen className="size-4" />
{isOpeningFolder ? 'Opening...' : 'Open Folder'}
</PlaygroundButton>
)}
{!mountedCollection && lastFolderName && (
<PlaygroundButton
disabled={!fileWorkspaceSupported || isOpeningFolder}
onClick={() => void onReopenFolder()}
title={lastFolderName}
variant="secondary"
>
<FolderOpen className="size-4" />
Reopen last folder
Reopen Folder
</PlaygroundButton>
)}
{mountedCollection && (
@@ -886,12 +916,18 @@ function TemplatesApp() {
Disconnect
</PlaygroundButton>
)}
{mountedCollection && (
<PlaygroundButton onClick={() => void onCreateMountedTemplate()} variant="primary">
<PencilRuler className="size-4" />
New Mounted Template
</PlaygroundButton>
)}
</div>
</div>
{!fileWorkspaceSupported && (
<div className="mt-4 rounded-md border border-dashed border-green-300 bg-white px-4 py-4 text-sm text-green-900">
Folder workspaces need a Chromium browser in a secure context. Template JSON import
and download are still available.
Folder workspaces need a Chromium browser in a secure context. Browser projects,
JSON import, and JSON download are still available.
</div>
)}
{mountedCollection && (
@@ -913,7 +949,7 @@ function TemplatesApp() {
{mountedCollection.entries.map((entry) => (
<GalleryCard
key={entry.name}
tag="Mounted"
tag={`Disk ${getGenerationLabel(entry.sourceKind)}`}
title={entry.title}
tags={entry.tags}
description={
@@ -954,7 +990,6 @@ function TemplatesApp() {
)
}
>
<Eye className="size-4" />
Form/Viewer
</PlaygroundButton>
</div>
@@ -964,7 +999,8 @@ function TemplatesApp() {
</div>
) : (
<div className="mt-4 rounded-md border border-dashed border-green-300 bg-white px-4 py-4 text-sm text-green-900">
No valid template directories are mounted.
No valid template directories are mounted. Create a mounted template to write a
new folder with template.json.
</div>
)}
</>
@@ -1042,7 +1078,7 @@ function TemplatesApp() {
{filteredTemplates.map((template, index) => {
const { name, author } = template;
const authoringPreset = getAuthoringPreset(template);
const title = template.title ?? fromKebabCase(name);
const title = template.title;
const generation = getTemplateGeneration(template);
const tag =
generation === 'jsx' ? 'JSX' : generation === 'md2pdf' ? 'md2pdf' : 'Designer';
@@ -1064,7 +1100,7 @@ function TemplatesApp() {
tags={tags}
description={
<div className="space-y-3">
<p>{template.description ?? 'A ready-to-edit pdfme sample template.'}</p>
<p>{template.description}</p>
<p className="flex items-center gap-2 text-xs text-gray-500">
by{' '}
{avatarUrlMap[author] && (