feat(document-templates): delete TipTap-to-pdfme bridge
Phase 1 / commit 12 of 14 — strips out the 571-line tiptap-to-pdfme
serializer and every code path that depended on it. TipTap document
templates remain as Documenso-template seed bodies; the CRM no longer
renders them to PDF in-app.
Deleted:
src/lib/pdf/tiptap-to-pdfme.ts (571 LOC)
src/lib/pdf/templates/eoi-standard-inapp.ts (337 LOC)
src/app/api/v1/admin/templates/preview/route.ts
src/app/api/v1/document-templates/[id]/generate/route.ts
src/app/api/v1/document-templates/[id]/generate-and-send/route.ts
src/lib/services/document-templates.ts:generateFromTemplate (~140 LOC)
src/lib/services/document-templates.ts:generateAndSend (~40 LOC)
src/lib/validators/document-templates.ts:generateAndSendSchema
src/lib/validators/document-templates.ts:previewAdminTemplateSchema
tests/unit/tiptap-serializer.test.ts (old bridge tests)
Preserved as src/lib/pdf/tiptap-validation.ts (~70 LOC):
- validateTipTapDocument() — still used to reject unsupported nodes
on save in the admin template editor
- TEMPLATE_VARIABLES — drives the merge-token picker in the
admin template form + preview UI
generateAndSign() now throws a clear ValidationError when a non-EOI
template tries the in-app pathway. Use a Documenso template, or wait
for the deferred AcroForm-fill admin-upload feature.
seed-data.ts: "Standard EOI (in-app)" template row now seeds with stub
bodyHtml + small MERGE_FIELDS array; the deleted HTML helper was never
actually rendered (in-app EOI is pdf-lib AcroForm fill on the source
PDF — generateEoiPdfFromTemplate, unchanged).
After this commit, pdfme has zero callers left. Commit 14 drops the
deps and the generate.ts shim.
1298/1298 vitest green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,337 +0,0 @@
|
||||
/**
|
||||
* Standard in-app EOI (Letter of Intent) template.
|
||||
*
|
||||
* Rendered in-app via pdfme (HTML → PDF pipeline) for ports that prefer the
|
||||
* in-app PDF generation path over the Documenso template flow.
|
||||
*
|
||||
* Merge tokens use the {{section.field}} convention and match the
|
||||
* `EoiContext` shape produced by `buildEoiContext` in
|
||||
* `src/lib/services/eoi-context.ts`. The tokens are resolved by
|
||||
* `resolveTemplate` (Task 11.4 wires the expanded resolver).
|
||||
*
|
||||
* Related:
|
||||
* - Field mapping: docs/eoi-documenso-field-mapping.md
|
||||
* - Context builder: src/lib/services/eoi-context.ts
|
||||
* - Schema: document_templates (src/lib/db/schema/documents.ts)
|
||||
*/
|
||||
|
||||
export const STANDARD_EOI_MERGE_FIELDS: string[] = [
|
||||
'date.today',
|
||||
'date.year',
|
||||
'port.name',
|
||||
'port.defaultCurrency',
|
||||
'client.fullName',
|
||||
'client.nationality',
|
||||
'client.primaryEmail',
|
||||
'client.primaryPhone',
|
||||
'client.address.street',
|
||||
'client.address.city',
|
||||
'client.address.country',
|
||||
'yacht.name',
|
||||
'yacht.hullNumber',
|
||||
'yacht.flag',
|
||||
'yacht.yearBuilt',
|
||||
'yacht.lengthFt',
|
||||
'yacht.widthFt',
|
||||
'yacht.draftFt',
|
||||
'yacht.lengthM',
|
||||
'yacht.widthM',
|
||||
'yacht.draftM',
|
||||
'company.name',
|
||||
'company.legalName',
|
||||
'company.taxId',
|
||||
'company.billingAddress',
|
||||
'owner.type',
|
||||
'owner.name',
|
||||
'owner.legalName',
|
||||
'berth.mooringNumber',
|
||||
'berth.area',
|
||||
'berth.lengthFt',
|
||||
'berth.price',
|
||||
'berth.priceCurrency',
|
||||
'berth.tenureType',
|
||||
'interest.stage',
|
||||
'interest.leadCategory',
|
||||
'interest.dateFirstContact',
|
||||
'interest.notes',
|
||||
];
|
||||
|
||||
export function getStandardEoiTemplateHtml(): string {
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>Expression of Interest - Letter of Intent</title>
|
||||
<style>
|
||||
@page {
|
||||
size: letter;
|
||||
margin: 0.9in 0.9in 1.0in 0.9in;
|
||||
}
|
||||
html, body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-family: "Times New Roman", Georgia, serif;
|
||||
font-size: 12pt;
|
||||
color: #111;
|
||||
line-height: 1.45;
|
||||
}
|
||||
.header {
|
||||
border-bottom: 2px solid #111;
|
||||
padding-bottom: 8pt;
|
||||
margin-bottom: 18pt;
|
||||
}
|
||||
.header .port-name {
|
||||
font-size: 18pt;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.5pt;
|
||||
}
|
||||
.header .doc-title {
|
||||
margin-top: 4pt;
|
||||
font-size: 14pt;
|
||||
font-weight: 600;
|
||||
font-style: italic;
|
||||
}
|
||||
.meta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 18pt;
|
||||
font-size: 11pt;
|
||||
}
|
||||
h2.section {
|
||||
font-size: 12pt;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.8pt;
|
||||
border-bottom: 1px solid #555;
|
||||
padding-bottom: 2pt;
|
||||
margin: 16pt 0 8pt 0;
|
||||
}
|
||||
table.fields {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-bottom: 6pt;
|
||||
}
|
||||
table.fields td {
|
||||
padding: 3pt 6pt 3pt 0;
|
||||
vertical-align: top;
|
||||
}
|
||||
table.fields td.label {
|
||||
width: 34%;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
}
|
||||
.addr-line {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
.signatures {
|
||||
margin-top: 36pt;
|
||||
display: table;
|
||||
width: 100%;
|
||||
}
|
||||
.signatures .slot {
|
||||
display: table-cell;
|
||||
width: 50%;
|
||||
padding-right: 18pt;
|
||||
vertical-align: top;
|
||||
}
|
||||
.signatures .slot:last-child {
|
||||
padding-right: 0;
|
||||
padding-left: 18pt;
|
||||
}
|
||||
.sig-line {
|
||||
border-top: 1px solid #111;
|
||||
margin-top: 42pt;
|
||||
padding-top: 4pt;
|
||||
font-size: 10pt;
|
||||
color: #333;
|
||||
}
|
||||
.footer {
|
||||
position: fixed;
|
||||
bottom: 0.4in;
|
||||
left: 0.9in;
|
||||
right: 0.9in;
|
||||
text-align: center;
|
||||
font-size: 9pt;
|
||||
color: #666;
|
||||
border-top: 1px solid #ccc;
|
||||
padding-top: 4pt;
|
||||
}
|
||||
.muted {
|
||||
color: #666;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<div class="port-name">{{port.name}}</div>
|
||||
<div class="doc-title">Expression of Interest - Letter of Intent</div>
|
||||
</div>
|
||||
|
||||
<div class="meta">
|
||||
<div><strong>Date:</strong> {{date.today}}</div>
|
||||
<div><strong>Port:</strong> {{port.name}}</div>
|
||||
</div>
|
||||
|
||||
<p>
|
||||
This Expression of Interest (the “EOI”) is entered into between
|
||||
<strong>{{port.name}}</strong> and the Applicant named below, and records the
|
||||
Applicant’s non-binding intent to proceed toward a berth acquisition at
|
||||
the port. It is subject to subsequent definitive documentation.
|
||||
</p>
|
||||
|
||||
<h2 class="section">1. Applicant</h2>
|
||||
<table class="fields">
|
||||
<tr>
|
||||
<td class="label">Full name</td>
|
||||
<td>{{client.fullName}}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="label">Nationality</td>
|
||||
<td>{{client.nationality}}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="label">Email</td>
|
||||
<td>{{client.primaryEmail}}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="label">Phone</td>
|
||||
<td>{{client.primaryPhone}}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="label">Address</td>
|
||||
<td>
|
||||
<p class="addr-line">{{client.address.street}}</p>
|
||||
<p class="addr-line">{{client.address.city}}</p>
|
||||
<p class="addr-line">{{client.address.country}}</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<h2 class="section">2. Yacht</h2>
|
||||
<table class="fields">
|
||||
<tr>
|
||||
<td class="label">Name</td>
|
||||
<td>{{yacht.name}}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="label">Hull number</td>
|
||||
<td>{{yacht.hullNumber}}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="label">Flag</td>
|
||||
<td>{{yacht.flag}}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="label">Year built</td>
|
||||
<td>{{yacht.yearBuilt}}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="label">Length (ft / m)</td>
|
||||
<td>{{yacht.lengthFt}} ft / {{yacht.lengthM}} m</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="label">Beam (ft / m)</td>
|
||||
<td>{{yacht.widthFt}} ft / {{yacht.widthM}} m</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="label">Draft (ft / m)</td>
|
||||
<td>{{yacht.draftFt}} ft / {{yacht.draftM}} m</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<h2 class="section">3. Owner</h2>
|
||||
<p>
|
||||
Owner type: <strong>{{owner.type}}</strong><br />
|
||||
Owner name: <strong>{{owner.name}}</strong>
|
||||
<span class="muted"> (legal: {{owner.legalName}})</span>
|
||||
</p>
|
||||
<table class="fields">
|
||||
<tr>
|
||||
<td class="label">Company name</td>
|
||||
<td>{{company.name}}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="label">Legal name</td>
|
||||
<td>{{company.legalName}}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="label">Tax ID</td>
|
||||
<td>{{company.taxId}}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="label">Billing address</td>
|
||||
<td>{{company.billingAddress}}</td>
|
||||
</tr>
|
||||
</table>
|
||||
<p class="muted" style="font-size:10pt;">
|
||||
The company block is populated only where the yacht is company-owned; for
|
||||
client-owned yachts these fields render empty.
|
||||
</p>
|
||||
|
||||
<h2 class="section">4. Berth</h2>
|
||||
<table class="fields">
|
||||
<tr>
|
||||
<td class="label">Mooring number</td>
|
||||
<td>{{berth.mooringNumber}}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="label">Area</td>
|
||||
<td>{{berth.area}}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="label">Length</td>
|
||||
<td>{{berth.lengthFt}} ft</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="label">Price</td>
|
||||
<td>{{berth.price}} {{berth.priceCurrency}}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="label">Tenure type</td>
|
||||
<td>{{berth.tenureType}}</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<h2 class="section">5. Interest Summary</h2>
|
||||
<table class="fields">
|
||||
<tr>
|
||||
<td class="label">Pipeline stage</td>
|
||||
<td>{{interest.stage}}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="label">Lead category</td>
|
||||
<td>{{interest.leadCategory}}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="label">First contact</td>
|
||||
<td>{{interest.dateFirstContact}}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="label">Notes</td>
|
||||
<td>{{interest.notes}}</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<h2 class="section">6. Signatures</h2>
|
||||
<div class="signatures">
|
||||
<div class="slot">
|
||||
<div class="sig-line">
|
||||
Applicant - {{client.fullName}}
|
||||
</div>
|
||||
<div class="muted" style="font-size:10pt; margin-top:2pt;">Date: __________________</div>
|
||||
</div>
|
||||
<div class="slot">
|
||||
<div class="sig-line">
|
||||
For and on behalf of {{port.name}}
|
||||
</div>
|
||||
<div class="muted" style="font-size:10pt; margin-top:2pt;">Date: __________________</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
{{port.name}} · Expression of Interest · {{date.year}}
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
@@ -1,571 +0,0 @@
|
||||
/**
|
||||
* TipTap JSON → @pdfme Template Serializer
|
||||
*
|
||||
* Converts a TipTap document JSON into a @pdfme Template suitable for PDF generation.
|
||||
* Supports a constrained formatting subset; unsupported nodes are rejected at validation time.
|
||||
*
|
||||
* Supported nodes:
|
||||
* paragraph, heading (h1-h3), bulletList, orderedList, listItem,
|
||||
* table, tableRow, tableCell, tableHeader, image, hardBreak,
|
||||
* text (with marks: bold, italic, underline)
|
||||
*
|
||||
* Unsupported (rejected at save time):
|
||||
* blockquote, codeBlock, horizontalRule, taskList
|
||||
*/
|
||||
|
||||
import type { Template } from '@pdfme/common';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface TipTapMark {
|
||||
type: string;
|
||||
attrs?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface TipTapNode {
|
||||
type: string;
|
||||
content?: TipTapNode[];
|
||||
text?: string;
|
||||
marks?: TipTapMark[];
|
||||
attrs?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
// @pdfme schema field shape (matches pdfme text plugin schema)
|
||||
// We use an index signature to satisfy @pdfme/common's Schema type requirement
|
||||
interface SchemaField {
|
||||
name: string;
|
||||
type: 'text';
|
||||
position: { x: number; y: number };
|
||||
width: number;
|
||||
height: number;
|
||||
fontSize: number;
|
||||
fontName?: string;
|
||||
fontColor?: string;
|
||||
alignment?: 'left' | 'center' | 'right';
|
||||
lineHeight?: number;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
// ─── Constants ────────────────────────────────────────────────────────────────
|
||||
|
||||
const PAGE_WIDTH_MM = 170; // A4 content width (210 - 20mm margins each side)
|
||||
const PAGE_BREAK_THRESHOLD = 250; // y position (mm from page top) to start new logical page
|
||||
const MARGIN_X_MM = 20; // Left margin
|
||||
const MARGIN_TOP_MM = 20; // Top margin
|
||||
|
||||
const UNSUPPORTED_NODES = new Set([
|
||||
'blockquote',
|
||||
'codeBlock',
|
||||
'horizontalRule',
|
||||
'taskList',
|
||||
'taskItem',
|
||||
]);
|
||||
|
||||
// Line heights per node type (mm)
|
||||
const PARAGRAPH_HEIGHT = 6;
|
||||
const H1_HEIGHT = 12;
|
||||
const H2_HEIGHT = 9;
|
||||
const H3_HEIGHT = 7;
|
||||
const LIST_ITEM_HEIGHT = 6;
|
||||
const TABLE_ROW_HEIGHT = 8;
|
||||
|
||||
// Font sizes
|
||||
const PARAGRAPH_FONT_SIZE = 10;
|
||||
const H1_FONT_SIZE = 20;
|
||||
const H2_FONT_SIZE = 16;
|
||||
const H3_FONT_SIZE = 14;
|
||||
const LIST_FONT_SIZE = 10;
|
||||
const TABLE_FONT_SIZE = 9;
|
||||
|
||||
// ─── Template Variables ───────────────────────────────────────────────────────
|
||||
|
||||
export const TEMPLATE_VARIABLES: Array<{ key: string; label: string; example: string }> = [
|
||||
{ key: 'client.name', label: 'Client Full Name', example: 'John Smith' },
|
||||
{ key: 'client.company', label: 'Company Name', example: 'Smith Holdings' },
|
||||
{ key: 'client.email', label: 'Client Email', example: 'john@smithholdings.com' },
|
||||
{ key: 'client.phone', label: 'Client Phone', example: '+61 400 000 000' },
|
||||
{ key: 'interest.stage', label: 'Pipeline Stage', example: 'Signed EOI/NDA' },
|
||||
{ key: 'interest.berthNumber', label: 'Berth Number (from interest)', example: 'A23' },
|
||||
{ key: 'berth.mooring_number', label: 'Berth Number', example: 'A23' },
|
||||
{ key: 'berth.price', label: 'Berth Price', example: '$45,000' },
|
||||
{ key: 'berth.tenure_type', label: 'Tenure Type', example: 'Freehold' },
|
||||
{ key: 'port.name', label: 'Port Name', example: 'Port Nimara' },
|
||||
{ key: 'port.currency', label: 'Port Currency', example: 'AUD' },
|
||||
{ key: 'date.today', label: "Today's Date", example: '2026-03-15' },
|
||||
{ key: 'date.year', label: 'Current Year', example: '2026' },
|
||||
];
|
||||
|
||||
// ─── Validation ───────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Recursively walks a TipTap node tree and collects any unsupported node types.
|
||||
* Returns an array of unsupported type names found, or empty array if valid.
|
||||
*/
|
||||
export function validateTipTapDocument(doc: TipTapNode): string[] {
|
||||
const found = new Set<string>();
|
||||
|
||||
function walk(node: TipTapNode): void {
|
||||
if (UNSUPPORTED_NODES.has(node.type)) {
|
||||
found.add(node.type);
|
||||
}
|
||||
if (node.content) {
|
||||
for (const child of node.content) {
|
||||
walk(child);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
walk(doc);
|
||||
return Array.from(found);
|
||||
}
|
||||
|
||||
// ─── Text extraction helpers ──────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Extracts plain text from a TipTap node and its children.
|
||||
* Preserves hardBreak as newline.
|
||||
*/
|
||||
function extractText(node: TipTapNode): string {
|
||||
if (node.type === 'text') {
|
||||
return node.text ?? '';
|
||||
}
|
||||
if (node.type === 'hardBreak') {
|
||||
return '\n';
|
||||
}
|
||||
if (!node.content || node.content.length === 0) {
|
||||
return '';
|
||||
}
|
||||
return node.content.map(extractText).join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* For a paragraph's inline content, returns text and a flag indicating bold.
|
||||
* (pdfme text schema doesn't support inline mixed formatting; we honour the
|
||||
* dominant mark on the paragraph level for simplicity.)
|
||||
*/
|
||||
function extractParagraphContent(node: TipTapNode): { text: string; bold: boolean } {
|
||||
let text = '';
|
||||
let hasBold = false;
|
||||
|
||||
if (node.content) {
|
||||
for (const child of node.content) {
|
||||
if (child.type === 'text') {
|
||||
text += child.text ?? '';
|
||||
if ((child.marks ?? []).some((m) => m.type === 'bold')) hasBold = true;
|
||||
} else if (child.type === 'hardBreak') {
|
||||
text += '\n';
|
||||
} else {
|
||||
text += extractText(child);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { text, bold: hasBold };
|
||||
}
|
||||
|
||||
// ─── Field name generation ────────────────────────────────────────────────────
|
||||
|
||||
let fieldCounter = 0;
|
||||
|
||||
function nextFieldName(prefix: string): string {
|
||||
return `${prefix}_${fieldCounter++}`;
|
||||
}
|
||||
|
||||
// ─── Serializer State ─────────────────────────────────────────────────────────
|
||||
|
||||
interface SerializerState {
|
||||
fields: SchemaField[];
|
||||
y: number;
|
||||
pageIndex: number;
|
||||
}
|
||||
|
||||
function ensurePageSpace(state: SerializerState, needed: number): void {
|
||||
if (state.y + needed > PAGE_BREAK_THRESHOLD) {
|
||||
state.pageIndex++;
|
||||
state.y = MARGIN_TOP_MM;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Node Processors ──────────────────────────────────────────────────────────
|
||||
|
||||
function processParagraph(node: TipTapNode, state: SerializerState): void {
|
||||
const { text, bold } = extractParagraphContent(node);
|
||||
const lineCount = Math.max(1, (text.match(/\n/g) ?? []).length + 1);
|
||||
const height = PARAGRAPH_HEIGHT * lineCount;
|
||||
|
||||
ensurePageSpace(state, height);
|
||||
|
||||
const field: SchemaField = {
|
||||
name: nextFieldName('para'),
|
||||
type: 'text',
|
||||
position: { x: MARGIN_X_MM, y: state.y },
|
||||
width: PAGE_WIDTH_MM,
|
||||
height,
|
||||
fontSize: PARAGRAPH_FONT_SIZE,
|
||||
fontName: bold ? 'Helvetica-Bold' : 'Helvetica',
|
||||
};
|
||||
|
||||
state.fields.push(field);
|
||||
state.y += height + 1;
|
||||
}
|
||||
|
||||
function processHeading(node: TipTapNode, state: SerializerState): void {
|
||||
const level = (node.attrs?.level as number) ?? 1;
|
||||
|
||||
let height: number;
|
||||
let fontSize: number;
|
||||
if (level === 1) {
|
||||
height = H1_HEIGHT;
|
||||
fontSize = H1_FONT_SIZE;
|
||||
} else if (level === 2) {
|
||||
height = H2_HEIGHT;
|
||||
fontSize = H2_FONT_SIZE;
|
||||
} else {
|
||||
height = H3_HEIGHT;
|
||||
fontSize = H3_FONT_SIZE;
|
||||
}
|
||||
|
||||
ensurePageSpace(state, height);
|
||||
|
||||
const field: SchemaField = {
|
||||
name: nextFieldName(`h${level}`),
|
||||
type: 'text',
|
||||
position: { x: MARGIN_X_MM, y: state.y },
|
||||
width: PAGE_WIDTH_MM,
|
||||
height,
|
||||
fontSize,
|
||||
fontName: 'Helvetica-Bold',
|
||||
};
|
||||
|
||||
state.fields.push(field);
|
||||
state.y += height + 2;
|
||||
}
|
||||
|
||||
function processBulletList(node: TipTapNode, state: SerializerState): void {
|
||||
if (!node.content) return;
|
||||
|
||||
for (const item of node.content) {
|
||||
if (item.type !== 'listItem') continue;
|
||||
const height = LIST_ITEM_HEIGHT;
|
||||
|
||||
ensurePageSpace(state, height);
|
||||
|
||||
state.fields.push({
|
||||
name: nextFieldName('bullet'),
|
||||
type: 'text',
|
||||
position: { x: MARGIN_X_MM + 5, y: state.y },
|
||||
width: PAGE_WIDTH_MM - 5,
|
||||
height,
|
||||
fontSize: LIST_FONT_SIZE,
|
||||
fontName: 'Helvetica',
|
||||
});
|
||||
|
||||
state.y += height + 0.5;
|
||||
}
|
||||
state.y += 2;
|
||||
}
|
||||
|
||||
function processOrderedList(node: TipTapNode, state: SerializerState): void {
|
||||
if (!node.content) return;
|
||||
|
||||
// Use a counter that increments to build ordered list prefixes
|
||||
let listIndex = (node.attrs?.start as number) ?? 1;
|
||||
|
||||
for (const item of node.content) {
|
||||
if (item.type !== 'listItem') continue;
|
||||
const height = LIST_ITEM_HEIGHT;
|
||||
|
||||
ensurePageSpace(state, height);
|
||||
|
||||
// listIndex is used via the field name prefix to distinguish items
|
||||
state.fields.push({
|
||||
name: nextFieldName(`ol_${listIndex}`),
|
||||
type: 'text',
|
||||
position: { x: MARGIN_X_MM + 5, y: state.y },
|
||||
width: PAGE_WIDTH_MM - 5,
|
||||
height,
|
||||
fontSize: LIST_FONT_SIZE,
|
||||
fontName: 'Helvetica',
|
||||
});
|
||||
|
||||
state.y += height + 0.5;
|
||||
listIndex++;
|
||||
}
|
||||
state.y += 2;
|
||||
}
|
||||
|
||||
function processTable(node: TipTapNode, state: SerializerState): void {
|
||||
if (!node.content) return;
|
||||
|
||||
const rows = node.content.filter((r) => r.type === 'tableRow');
|
||||
if (rows.length === 0) return;
|
||||
|
||||
const firstRow = rows[0];
|
||||
const colCount = firstRow?.content?.length ?? 1;
|
||||
const colWidth = PAGE_WIDTH_MM / colCount;
|
||||
|
||||
for (const row of rows) {
|
||||
if (!row.content) continue;
|
||||
const rowHeight = TABLE_ROW_HEIGHT;
|
||||
|
||||
ensurePageSpace(state, rowHeight);
|
||||
|
||||
row.content.forEach((cell, colIdx) => {
|
||||
const isHeader = cell.type === 'tableHeader';
|
||||
|
||||
state.fields.push({
|
||||
name: nextFieldName(isHeader ? 'th' : 'td'),
|
||||
type: 'text',
|
||||
position: {
|
||||
x: MARGIN_X_MM + colIdx * colWidth,
|
||||
y: state.y,
|
||||
},
|
||||
width: colWidth - 0.5,
|
||||
height: rowHeight,
|
||||
fontSize: TABLE_FONT_SIZE,
|
||||
fontName: isHeader ? 'Helvetica-Bold' : 'Helvetica',
|
||||
});
|
||||
});
|
||||
|
||||
state.y += rowHeight + 0.5;
|
||||
}
|
||||
state.y += 3;
|
||||
}
|
||||
|
||||
// ─── Top-level Node Dispatch ──────────────────────────────────────────────────
|
||||
|
||||
function processNode(node: TipTapNode, state: SerializerState): void {
|
||||
switch (node.type) {
|
||||
case 'paragraph':
|
||||
processParagraph(node, state);
|
||||
break;
|
||||
case 'heading':
|
||||
processHeading(node, state);
|
||||
break;
|
||||
case 'bulletList':
|
||||
processBulletList(node, state);
|
||||
break;
|
||||
case 'orderedList':
|
||||
processOrderedList(node, state);
|
||||
break;
|
||||
case 'table':
|
||||
processTable(node, state);
|
||||
break;
|
||||
case 'image':
|
||||
state.y += 20;
|
||||
break;
|
||||
case 'hardBreak':
|
||||
state.y += 3;
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Main Serializer ──────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Converts a TipTap JSON document to a @pdfme Template.
|
||||
* Variables like {{client.name}} are left as-is in text content.
|
||||
* Call buildContentInputsFromDoc to get inputs with actual values.
|
||||
*/
|
||||
export function tipTapToPdfmeTemplate(doc: TipTapNode): Template {
|
||||
fieldCounter = 0;
|
||||
|
||||
const state: SerializerState = {
|
||||
fields: [],
|
||||
y: MARGIN_TOP_MM,
|
||||
pageIndex: 0,
|
||||
};
|
||||
|
||||
const children = doc.type === 'doc' ? (doc.content ?? []) : [doc];
|
||||
for (const node of children) {
|
||||
processNode(node, state);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const basePdf = 'BLANK_PDF' as any;
|
||||
// pdfme's Schema type has a string index signature we satisfy via the
|
||||
// [key: string]: unknown on SchemaField
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const schemas = [state.fields] as any;
|
||||
|
||||
return { basePdf, schemas } as Template;
|
||||
}
|
||||
|
||||
// ─── Input Builder ────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Given a @pdfme Template and a flat key→value data record,
|
||||
* builds empty input records keyed by field name.
|
||||
* Use buildContentInputsFromDoc for content-populated inputs.
|
||||
*/
|
||||
export function buildTemplateInputs(
|
||||
template: Template,
|
||||
data: Record<string, string>,
|
||||
): Record<string, string>[] {
|
||||
return template.schemas.map((pageSchema) => {
|
||||
const record: Record<string, string> = {};
|
||||
for (const field of pageSchema as SchemaField[]) {
|
||||
record[field.name] = data[field.name] ?? '';
|
||||
}
|
||||
return record;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces all {{variable.key}} tokens in a string with values from the data map.
|
||||
* Unmatched tokens are left as-is.
|
||||
*/
|
||||
export function substituteVariables(text: string, data: Record<string, string>): string {
|
||||
return text.replace(/\{\{([^}]+)\}\}/g, (_match, key: string) => {
|
||||
return data[key.trim()] ?? _match;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Public alias for internal buildContentInputs.
|
||||
* Builds pdfme input records by extracting text content from the TipTap doc
|
||||
* and mapping it to generated field names (in schema order).
|
||||
* Use after calling tipTapToPdfmeTemplate on the same (already-substituted) doc.
|
||||
*/
|
||||
export function buildContentInputsFromDoc(
|
||||
doc: TipTapNode,
|
||||
template: Template,
|
||||
): Record<string, string>[] {
|
||||
return buildContentInputs(doc, template);
|
||||
}
|
||||
|
||||
/**
|
||||
* Full pipeline: validate → substitute variables → convert to pdfme template + inputs.
|
||||
* Returns { template, inputs, errors }.
|
||||
*/
|
||||
export function tiptapDocumentToTemplateWithData(
|
||||
doc: TipTapNode,
|
||||
data: Record<string, string> = {},
|
||||
): {
|
||||
template: Template | null;
|
||||
inputs: Record<string, string>[];
|
||||
errors: string[];
|
||||
} {
|
||||
const errors = validateTipTapDocument(doc);
|
||||
if (errors.length > 0) {
|
||||
return { template: null, inputs: [], errors };
|
||||
}
|
||||
|
||||
const substitutedDoc = substituteInDoc(doc, data);
|
||||
const template = tipTapToPdfmeTemplate(substitutedDoc);
|
||||
const inputs = buildContentInputs(substitutedDoc, template);
|
||||
|
||||
return { template, inputs, errors: [] };
|
||||
}
|
||||
|
||||
// ─── Internals ────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Deeply substitutes variables in all text nodes of a TipTap document.
|
||||
*/
|
||||
function substituteInDoc(node: TipTapNode, data: Record<string, string>): TipTapNode {
|
||||
if (node.type === 'text' && node.text) {
|
||||
return { ...node, text: substituteVariables(node.text, data) };
|
||||
}
|
||||
if (node.content) {
|
||||
return {
|
||||
...node,
|
||||
content: node.content.map((child) => substituteInDoc(child, data)),
|
||||
};
|
||||
}
|
||||
return node;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds pdfme input records by extracting text content from the TipTap doc
|
||||
* and mapping it to generated field names (in schema order).
|
||||
*/
|
||||
function buildContentInputs(doc: TipTapNode, template: Template): Record<string, string>[] {
|
||||
const textContents = extractAllTextContents(doc);
|
||||
const schemas = template.schemas;
|
||||
|
||||
return schemas.map((pageSchema, pageIdx) => {
|
||||
const record: Record<string, string> = {};
|
||||
const fields = pageSchema as SchemaField[];
|
||||
|
||||
fields.forEach((field, fieldIdx) => {
|
||||
const globalIdx =
|
||||
schemas.slice(0, pageIdx).reduce((acc, ps) => acc + (ps as SchemaField[]).length, 0) +
|
||||
fieldIdx;
|
||||
record[field.name] = textContents[globalIdx] ?? '';
|
||||
});
|
||||
|
||||
return record;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts text content for each schema field in document order.
|
||||
* Order must mirror the order that processNode() creates fields.
|
||||
*/
|
||||
function extractAllTextContents(doc: TipTapNode): string[] {
|
||||
const contents: string[] = [];
|
||||
const children = doc.type === 'doc' ? (doc.content ?? []) : [doc];
|
||||
|
||||
for (const node of children) {
|
||||
extractNodeContent(node, contents);
|
||||
}
|
||||
|
||||
return contents;
|
||||
}
|
||||
|
||||
function extractNodeContent(node: TipTapNode, out: string[]): void {
|
||||
switch (node.type) {
|
||||
case 'paragraph': {
|
||||
const { text } = extractParagraphContent(node);
|
||||
out.push(text);
|
||||
break;
|
||||
}
|
||||
case 'heading': {
|
||||
out.push(extractText(node));
|
||||
break;
|
||||
}
|
||||
case 'bulletList': {
|
||||
if (node.content) {
|
||||
for (const item of node.content) {
|
||||
if (item.type === 'listItem') {
|
||||
out.push('• ' + extractText(item).replace(/\n/g, ' '));
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'orderedList': {
|
||||
if (node.content) {
|
||||
let idx = (node.attrs?.start as number) ?? 1;
|
||||
for (const item of node.content) {
|
||||
if (item.type === 'listItem') {
|
||||
out.push(`${idx}. ` + extractText(item).replace(/\n/g, ' '));
|
||||
idx++;
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'table': {
|
||||
if (node.content) {
|
||||
for (const row of node.content) {
|
||||
if (row.type !== 'tableRow' || !row.content) continue;
|
||||
for (const cell of row.content) {
|
||||
out.push(extractText(cell));
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'image':
|
||||
out.push('');
|
||||
break;
|
||||
case 'hardBreak':
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
71
src/lib/pdf/tiptap-validation.ts
Normal file
71
src/lib/pdf/tiptap-validation.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* TipTap content validation helpers shared by the admin template editor
|
||||
* and the document-template service. Surfaces unsupported node types so
|
||||
* the admin gets a clear validation error before saving, and exports the
|
||||
* `TEMPLATE_VARIABLES` catalog so the editor can offer token suggestions.
|
||||
*
|
||||
* Note: this file was the remaining sliver of the legacy 571-line
|
||||
* `tiptap-to-pdfme.ts` bridge. The pdfme rendering path it once fed has
|
||||
* been removed (see the PDF stack overhaul spec); only the validation +
|
||||
* variable catalog survive because they're still useful for the
|
||||
* Documenso-template-body editor UX.
|
||||
*/
|
||||
|
||||
export interface TipTapMark {
|
||||
type: string;
|
||||
attrs?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface TipTapNode {
|
||||
type: string;
|
||||
content?: TipTapNode[];
|
||||
text?: string;
|
||||
marks?: TipTapMark[];
|
||||
attrs?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
const UNSUPPORTED_NODES = new Set([
|
||||
'blockquote',
|
||||
'codeBlock',
|
||||
'horizontalRule',
|
||||
'taskList',
|
||||
'taskItem',
|
||||
]);
|
||||
|
||||
export const TEMPLATE_VARIABLES: Array<{ key: string; label: string; example: string }> = [
|
||||
{ key: 'client.name', label: 'Client Full Name', example: 'John Smith' },
|
||||
{ key: 'client.company', label: 'Company Name', example: 'Smith Holdings' },
|
||||
{ key: 'client.email', label: 'Client Email', example: 'john@smithholdings.com' },
|
||||
{ key: 'client.phone', label: 'Client Phone', example: '+61 400 000 000' },
|
||||
{ key: 'interest.stage', label: 'Pipeline Stage', example: 'Signed EOI/NDA' },
|
||||
{ key: 'interest.berthNumber', label: 'Berth Number (from interest)', example: 'A23' },
|
||||
{ key: 'berth.mooring_number', label: 'Berth Number', example: 'A23' },
|
||||
{ key: 'berth.price', label: 'Berth Price', example: '$45,000' },
|
||||
{ key: 'berth.tenure_type', label: 'Tenure Type', example: 'Freehold' },
|
||||
{ key: 'port.name', label: 'Port Name', example: 'Port Nimara' },
|
||||
{ key: 'port.currency', label: 'Port Currency', example: 'AUD' },
|
||||
{ key: 'date.today', label: "Today's Date", example: '2026-03-15' },
|
||||
{ key: 'date.year', label: 'Current Year', example: '2026' },
|
||||
];
|
||||
|
||||
/**
|
||||
* Recursively walks a TipTap node tree and collects any unsupported node types.
|
||||
* Returns an array of unsupported type names found, or empty array if valid.
|
||||
*/
|
||||
export function validateTipTapDocument(doc: TipTapNode): string[] {
|
||||
const found = new Set<string>();
|
||||
|
||||
function walk(node: TipTapNode): void {
|
||||
if (UNSUPPORTED_NODES.has(node.type)) {
|
||||
found.add(node.type);
|
||||
}
|
||||
if (node.content) {
|
||||
for (const child of node.content) {
|
||||
walk(child);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
walk(doc);
|
||||
return Array.from(found);
|
||||
}
|
||||
Reference in New Issue
Block a user