domma-cms 0.15.0 → 0.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- package/plugins/analytics/admin/templates/analytics.html +52 -1
- package/plugins/analytics/admin/views/analytics.js +157 -32
- package/plugins/analytics/config.js +10 -2
- package/plugins/analytics/plugin.js +214 -25
- package/plugins/analytics/plugin.json +9 -5
- package/plugins/analytics/public/inject-body.html +25 -7
- package/plugins/blog/admin/templates/blog.html +25 -2
- package/plugins/blog/admin/views/blog.js +72 -56
- package/plugins/blog/admin/views/post-editor.js +98 -79
- package/plugins/blog/plugin.js +133 -0
- package/plugins/blog/plugin.json +3 -3
- package/plugins/blog/templates/post.html +2 -1
- package/plugins/invoice/admin/templates/editor.html +129 -0
- package/plugins/invoice/admin/templates/index.html +43 -0
- package/plugins/invoice/admin/templates/issuers.html +5 -0
- package/plugins/invoice/admin/templates/receivers.html +5 -0
- package/plugins/invoice/admin/views/editor.js +267 -0
- package/plugins/invoice/admin/views/index.js +155 -0
- package/plugins/invoice/admin/views/issuers.js +23 -0
- package/plugins/invoice/admin/views/party-view.js +148 -0
- package/plugins/invoice/admin/views/receivers.js +22 -0
- package/plugins/invoice/collections/invoice-issuers/schema.json +16 -0
- package/plugins/invoice/collections/invoice-receivers/schema.json +15 -0
- package/plugins/invoice/collections/invoices/schema.json +27 -0
- package/plugins/invoice/config.js +16 -0
- package/plugins/invoice/plugin.js +283 -0
- package/plugins/invoice/plugin.json +85 -0
- package/plugins/invoice/templates/invoice-print.html +213 -0
- package/server/services/markdown.js +24 -4
- package/server/services/renderer.js +9 -3
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Invoice Plugin — Server
|
|
3
|
+
*
|
|
4
|
+
* Endpoints (mounted at /api/plugins/invoice):
|
|
5
|
+
* GET /issuers admin list issuers
|
|
6
|
+
* POST /issuers admin create issuer
|
|
7
|
+
* PUT /issuers/:id admin update issuer
|
|
8
|
+
* DELETE /issuers/:id admin delete issuer
|
|
9
|
+
*
|
|
10
|
+
* GET /receivers admin list receivers
|
|
11
|
+
* POST /receivers admin create receiver
|
|
12
|
+
* PUT /receivers/:id admin update receiver
|
|
13
|
+
* DELETE /receivers/:id admin delete receiver
|
|
14
|
+
*
|
|
15
|
+
* GET /invoices admin list invoices (with computed totals)
|
|
16
|
+
* GET /invoices/:id admin single invoice with computed totals
|
|
17
|
+
* POST /invoices admin create invoice (auto-numbers if number omitted)
|
|
18
|
+
* PUT /invoices/:id admin update invoice
|
|
19
|
+
* DELETE /invoices/:id admin delete invoice
|
|
20
|
+
* GET /invoices/:id/print admin printable HTML (browser to PDF)
|
|
21
|
+
*/
|
|
22
|
+
import path from 'path';
|
|
23
|
+
import fs from 'fs/promises';
|
|
24
|
+
import {fileURLToPath} from 'url';
|
|
25
|
+
|
|
26
|
+
import defaultConfig from './config.js';
|
|
27
|
+
import {createEntry, deleteEntry, getEntry, listEntries, updateEntry} from '../../server/services/collections.js';
|
|
28
|
+
|
|
29
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
30
|
+
const TEMPLATE_PATH = path.join(__dirname, 'templates', 'invoice-print.html');
|
|
31
|
+
|
|
32
|
+
const ISSUERS_SLUG = 'invoice-issuers';
|
|
33
|
+
const RECEIVERS_SLUG = 'invoice-receivers';
|
|
34
|
+
const INVOICES_SLUG = 'invoices';
|
|
35
|
+
|
|
36
|
+
function escapeHtml(str) {
|
|
37
|
+
return String(str ?? '')
|
|
38
|
+
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
39
|
+
.replace(/"/g, '"').replace(/'/g, ''');
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function flattenList(result) {
|
|
43
|
+
const arr = Array.isArray(result) ? result : (result?.entries || []);
|
|
44
|
+
return arr.map((e) => ({ id: e.id, ...(e.data ?? e) }));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function toRecord(entry) {
|
|
48
|
+
return { id: entry.id, ...(entry.data ?? entry) };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function round2(n) {
|
|
52
|
+
return Math.round((Number(n) + Number.EPSILON) * 100) / 100;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function computeTotals(invoice) {
|
|
56
|
+
const items = Array.isArray(invoice.lineItems) ? invoice.lineItems : [];
|
|
57
|
+
let subtotal = 0;
|
|
58
|
+
for (const li of items) {
|
|
59
|
+
const qty = Number(li.quantity) || 0;
|
|
60
|
+
const price = Number(li.unitPrice) || 0;
|
|
61
|
+
subtotal += qty * price;
|
|
62
|
+
}
|
|
63
|
+
subtotal = round2(subtotal);
|
|
64
|
+
const vatRate = invoice.vatEnabled ? (Number(invoice.vatRate) || 0) : 0;
|
|
65
|
+
const vat = round2(subtotal * (vatRate / 100));
|
|
66
|
+
const total = round2(subtotal + vat);
|
|
67
|
+
return { subtotal, vat, total, vatRate };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function withTotals(invoice) {
|
|
71
|
+
return { ...invoice, totals: computeTotals(invoice) };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function formatMoney(amount, currency = 'GBP') {
|
|
75
|
+
try {
|
|
76
|
+
return new Intl.NumberFormat('en-GB', { style: 'currency', currency }).format(Number(amount) || 0);
|
|
77
|
+
} catch {
|
|
78
|
+
return `${currency} ${Number(amount).toFixed(2)}`;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function formatDate(iso) {
|
|
83
|
+
if (!iso) return '';
|
|
84
|
+
return new Date(iso).toLocaleDateString('en-GB', { day: 'numeric', month: 'long', year: 'numeric' });
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async function nextInvoiceNumber(prefix, padding) {
|
|
88
|
+
const year = new Date().getFullYear();
|
|
89
|
+
const all = flattenList(await listEntries(INVOICES_SLUG, { limit: 100000 }));
|
|
90
|
+
const re = new RegExp(`^${prefix}-${year}-(\\d+)$`);
|
|
91
|
+
let max = 0;
|
|
92
|
+
for (const inv of all) {
|
|
93
|
+
const m = re.exec(String(inv.number || ''));
|
|
94
|
+
if (m) {
|
|
95
|
+
const n = parseInt(m[1], 10);
|
|
96
|
+
if (n > max) max = n;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return `${prefix}-${year}-${String(max + 1).padStart(padding, '0')}`;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async function loadPrintTemplate() {
|
|
103
|
+
return fs.readFile(TEMPLATE_PATH, 'utf8');
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function fillTemplate(template, vars) {
|
|
107
|
+
return template.replace(/\{\{(\w+)\}\}/g, (_, key) => {
|
|
108
|
+
const v = vars[key];
|
|
109
|
+
return v === undefined || v === null ? '' : String(v);
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export default async function invoicePlugin(fastify, options) {
|
|
114
|
+
const { authenticate, requireAdmin } = options.auth;
|
|
115
|
+
const settings = { ...defaultConfig, ...(options.settings || {}) };
|
|
116
|
+
const adminGuard = { preHandler: [authenticate, requireAdmin] };
|
|
117
|
+
|
|
118
|
+
// Issuers
|
|
119
|
+
fastify.get('/issuers', adminGuard, async () => {
|
|
120
|
+
return flattenList(await listEntries(ISSUERS_SLUG, { limit: 1000 }));
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
fastify.post('/issuers', adminGuard, async (request, reply) => {
|
|
124
|
+
const body = request.body ?? {};
|
|
125
|
+
if (!body.name || typeof body.name !== 'string' || !body.name.trim()) {
|
|
126
|
+
return reply.code(400).send({ error: 'name is required' });
|
|
127
|
+
}
|
|
128
|
+
const entry = await createEntry(ISSUERS_SLUG, body, { source: 'admin' });
|
|
129
|
+
return reply.code(201).send(toRecord(entry));
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
fastify.put('/issuers/:id', adminGuard, async (request, reply) => {
|
|
133
|
+
const existing = await getEntry(ISSUERS_SLUG, request.params.id);
|
|
134
|
+
if (!existing) return reply.code(404).send({ error: 'Issuer not found' });
|
|
135
|
+
const updated = await updateEntry(ISSUERS_SLUG, request.params.id, { ...existing.data, ...(request.body ?? {}) });
|
|
136
|
+
return toRecord(updated);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
fastify.delete('/issuers/:id', adminGuard, async (request, reply) => {
|
|
140
|
+
const existing = await getEntry(ISSUERS_SLUG, request.params.id);
|
|
141
|
+
if (!existing) return reply.code(404).send({ error: 'Issuer not found' });
|
|
142
|
+
await deleteEntry(ISSUERS_SLUG, request.params.id);
|
|
143
|
+
return { ok: true };
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
// Receivers
|
|
147
|
+
fastify.get('/receivers', adminGuard, async () => {
|
|
148
|
+
return flattenList(await listEntries(RECEIVERS_SLUG, { limit: 1000 }));
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
fastify.post('/receivers', adminGuard, async (request, reply) => {
|
|
152
|
+
const body = request.body ?? {};
|
|
153
|
+
if (!body.name || typeof body.name !== 'string' || !body.name.trim()) {
|
|
154
|
+
return reply.code(400).send({ error: 'name is required' });
|
|
155
|
+
}
|
|
156
|
+
const entry = await createEntry(RECEIVERS_SLUG, body, { source: 'admin' });
|
|
157
|
+
return reply.code(201).send(toRecord(entry));
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
fastify.put('/receivers/:id', adminGuard, async (request, reply) => {
|
|
161
|
+
const existing = await getEntry(RECEIVERS_SLUG, request.params.id);
|
|
162
|
+
if (!existing) return reply.code(404).send({ error: 'Receiver not found' });
|
|
163
|
+
const updated = await updateEntry(RECEIVERS_SLUG, request.params.id, { ...existing.data, ...(request.body ?? {}) });
|
|
164
|
+
return toRecord(updated);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
fastify.delete('/receivers/:id', adminGuard, async (request, reply) => {
|
|
168
|
+
const existing = await getEntry(RECEIVERS_SLUG, request.params.id);
|
|
169
|
+
if (!existing) return reply.code(404).send({ error: 'Receiver not found' });
|
|
170
|
+
await deleteEntry(RECEIVERS_SLUG, request.params.id);
|
|
171
|
+
return { ok: true };
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
// Invoices
|
|
175
|
+
fastify.get('/invoices', adminGuard, async () => {
|
|
176
|
+
const list = flattenList(await listEntries(INVOICES_SLUG, { limit: 100000, sort: 'createdAt', order: 'desc' }));
|
|
177
|
+
return list.map(withTotals);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
fastify.get('/invoices/:id', adminGuard, async (request, reply) => {
|
|
181
|
+
const entry = await getEntry(INVOICES_SLUG, request.params.id);
|
|
182
|
+
if (!entry) return reply.code(404).send({ error: 'Invoice not found' });
|
|
183
|
+
return withTotals(toRecord(entry));
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
fastify.post('/invoices', adminGuard, async (request, reply) => {
|
|
187
|
+
const body = request.body ?? {};
|
|
188
|
+
if (!body.issuerId) return reply.code(400).send({ error: 'issuerId is required' });
|
|
189
|
+
if (!body.receiverId) return reply.code(400).send({ error: 'receiverId is required' });
|
|
190
|
+
|
|
191
|
+
const data = {
|
|
192
|
+
number: body.number || await nextInvoiceNumber(settings.numberPrefix, settings.numberPadding),
|
|
193
|
+
issuerId: String(body.issuerId),
|
|
194
|
+
receiverId: String(body.receiverId),
|
|
195
|
+
issueDate: body.issueDate || new Date().toISOString().slice(0, 10),
|
|
196
|
+
dueDate: body.dueDate || '',
|
|
197
|
+
status: body.status || 'draft',
|
|
198
|
+
currency: body.currency || settings.defaultCurrency,
|
|
199
|
+
vatEnabled: body.vatEnabled !== undefined ? Boolean(body.vatEnabled) : true,
|
|
200
|
+
vatRate: body.vatRate !== undefined ? Number(body.vatRate) : settings.defaultVatRate,
|
|
201
|
+
lineItems: Array.isArray(body.lineItems) ? body.lineItems : [],
|
|
202
|
+
notes: body.notes ?? ''
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
const entry = await createEntry(INVOICES_SLUG, data, { source: 'admin' });
|
|
206
|
+
return reply.code(201).send(withTotals(toRecord(entry)));
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
fastify.put('/invoices/:id', adminGuard, async (request, reply) => {
|
|
210
|
+
const existing = await getEntry(INVOICES_SLUG, request.params.id);
|
|
211
|
+
if (!existing) return reply.code(404).send({ error: 'Invoice not found' });
|
|
212
|
+
const merged = { ...existing.data, ...(request.body ?? {}) };
|
|
213
|
+
if (Array.isArray(request.body?.lineItems)) merged.lineItems = request.body.lineItems;
|
|
214
|
+
const updated = await updateEntry(INVOICES_SLUG, request.params.id, merged);
|
|
215
|
+
return withTotals(toRecord(updated));
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
fastify.delete('/invoices/:id', adminGuard, async (request, reply) => {
|
|
219
|
+
const existing = await getEntry(INVOICES_SLUG, request.params.id);
|
|
220
|
+
if (!existing) return reply.code(404).send({ error: 'Invoice not found' });
|
|
221
|
+
await deleteEntry(INVOICES_SLUG, request.params.id);
|
|
222
|
+
return { ok: true };
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
// Print view (HTML; user prints to PDF from browser)
|
|
226
|
+
fastify.get('/invoices/:id/print', adminGuard, async (request, reply) => {
|
|
227
|
+
const entry = await getEntry(INVOICES_SLUG, request.params.id);
|
|
228
|
+
if (!entry) return reply.code(404).send('Invoice not found');
|
|
229
|
+
const invoice = withTotals(toRecord(entry));
|
|
230
|
+
|
|
231
|
+
const [issuerEntry, receiverEntry] = await Promise.all([
|
|
232
|
+
invoice.issuerId ? getEntry(ISSUERS_SLUG, invoice.issuerId) : null,
|
|
233
|
+
invoice.receiverId ? getEntry(RECEIVERS_SLUG, invoice.receiverId) : null
|
|
234
|
+
]);
|
|
235
|
+
const issuer = issuerEntry ? toRecord(issuerEntry) : {};
|
|
236
|
+
const receiver = receiverEntry ? toRecord(receiverEntry) : {};
|
|
237
|
+
|
|
238
|
+
const currency = invoice.currency || settings.defaultCurrency;
|
|
239
|
+
const itemsHtml = (invoice.lineItems || []).map((li) => {
|
|
240
|
+
const qty = Number(li.quantity) || 0;
|
|
241
|
+
const price = Number(li.unitPrice) || 0;
|
|
242
|
+
const lineTotal = round2(qty * price);
|
|
243
|
+
return `<tr>
|
|
244
|
+
<td>${escapeHtml(li.description || '')}</td>
|
|
245
|
+
<td class="num">${qty}</td>
|
|
246
|
+
<td class="num">${formatMoney(price, currency)}</td>
|
|
247
|
+
<td class="num">${formatMoney(lineTotal, currency)}</td>
|
|
248
|
+
</tr>`;
|
|
249
|
+
}).join('');
|
|
250
|
+
|
|
251
|
+
const vatRow = invoice.vatEnabled
|
|
252
|
+
? `<tr><th>VAT (${escapeHtml(String(invoice.totals.vatRate))}%)</th><td class="num">${formatMoney(invoice.totals.vat, currency)}</td></tr>`
|
|
253
|
+
: '';
|
|
254
|
+
|
|
255
|
+
const template = await loadPrintTemplate();
|
|
256
|
+
const html = fillTemplate(template, {
|
|
257
|
+
number: escapeHtml(invoice.number || ''),
|
|
258
|
+
issueDate: escapeHtml(formatDate(invoice.issueDate)),
|
|
259
|
+
dueDate: invoice.dueDate ? `<div><strong>Due:</strong> ${escapeHtml(formatDate(invoice.dueDate))}</div>` : '',
|
|
260
|
+
status: escapeHtml((invoice.status || 'draft').toUpperCase()),
|
|
261
|
+
issuerName: escapeHtml(issuer.name || ''),
|
|
262
|
+
issuerLogo: issuer.logoUrl ? `<img src="${escapeHtml(issuer.logoUrl)}" alt="${escapeHtml(issuer.name || '')}" class="logo">` : '',
|
|
263
|
+
issuerAddress: escapeHtml(issuer.address || '').replace(/\n/g, '<br>'),
|
|
264
|
+
issuerEmail: escapeHtml(issuer.email || ''),
|
|
265
|
+
issuerPhone: escapeHtml(issuer.phone || ''),
|
|
266
|
+
issuerVat: issuer.vatNumber ? `<div>VAT: ${escapeHtml(issuer.vatNumber)}</div>` : '',
|
|
267
|
+
receiverName: escapeHtml(receiver.company || receiver.name || ''),
|
|
268
|
+
receiverAddress: escapeHtml(receiver.address || '').replace(/\n/g, '<br>'),
|
|
269
|
+
receiverEmail: escapeHtml(receiver.email || ''),
|
|
270
|
+
receiverVat: receiver.vatNumber ? `<div>VAT: ${escapeHtml(receiver.vatNumber)}</div>` : '',
|
|
271
|
+
items: itemsHtml || '<tr><td colspan="4" class="muted">No line items.</td></tr>',
|
|
272
|
+
subtotal: formatMoney(invoice.totals.subtotal, currency),
|
|
273
|
+
vatRow,
|
|
274
|
+
total: formatMoney(invoice.totals.total, currency),
|
|
275
|
+
notes: invoice.notes ? `<div class="notes"><strong>Notes</strong><br>${escapeHtml(invoice.notes).replace(/\n/g, '<br>')}</div>` : '',
|
|
276
|
+
bankDetails: issuer.bankDetails ? `<div class="bank"><strong>Payment</strong><br>${escapeHtml(issuer.bankDetails).replace(/\n/g, '<br>')}</div>` : '',
|
|
277
|
+
footerNote: escapeHtml(settings.footerNote || ''),
|
|
278
|
+
autoPrint: request.query.print === '1' ? '<script>window.addEventListener("load",()=>setTimeout(()=>window.print(),200));</script>' : ''
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
reply.type('text/html').send(html);
|
|
282
|
+
});
|
|
283
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "invoice",
|
|
3
|
+
"displayName": "Invoices",
|
|
4
|
+
"version": "1.0.0",
|
|
5
|
+
"description": "Lightweight invoice generator. Manage issuers, receivers, line items, optional VAT, and produce a print-ready invoice you can save as PDF from the browser.",
|
|
6
|
+
"author": "Darryl Waterhouse",
|
|
7
|
+
"date": "2026-05-04",
|
|
8
|
+
"icon": "file-text",
|
|
9
|
+
"admin": {
|
|
10
|
+
"css": [
|
|
11
|
+
"admin/css/index.css"
|
|
12
|
+
],
|
|
13
|
+
"sidebar": [
|
|
14
|
+
{
|
|
15
|
+
"id": "invoice",
|
|
16
|
+
"text": "Invoices",
|
|
17
|
+
"icon": "file-text",
|
|
18
|
+
"url": "#/plugins/invoice",
|
|
19
|
+
"section": "#/plugins/invoice",
|
|
20
|
+
"children": [
|
|
21
|
+
{
|
|
22
|
+
"id": "invoice-list",
|
|
23
|
+
"text": "Invoices",
|
|
24
|
+
"url": "#/plugins/invoice"
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
"id": "invoice-issuers",
|
|
28
|
+
"text": "Issuers",
|
|
29
|
+
"url": "#/plugins/invoice/issuers"
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
"id": "invoice-receivers",
|
|
33
|
+
"text": "Receivers",
|
|
34
|
+
"url": "#/plugins/invoice/receivers"
|
|
35
|
+
}
|
|
36
|
+
]
|
|
37
|
+
}
|
|
38
|
+
],
|
|
39
|
+
"routes": [
|
|
40
|
+
{
|
|
41
|
+
"path": "/plugins/invoice",
|
|
42
|
+
"view": "plugin-invoice-list",
|
|
43
|
+
"title": "Invoices - Domma CMS"
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
"path": "/plugins/invoice/new",
|
|
47
|
+
"view": "plugin-invoice-editor",
|
|
48
|
+
"title": "New Invoice - Domma CMS"
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
"path": "/plugins/invoice/edit/:id",
|
|
52
|
+
"view": "plugin-invoice-editor",
|
|
53
|
+
"title": "Edit Invoice - Domma CMS"
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
"path": "/plugins/invoice/issuers",
|
|
57
|
+
"view": "plugin-invoice-issuers",
|
|
58
|
+
"title": "Issuers - Domma CMS"
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
"path": "/plugins/invoice/receivers",
|
|
62
|
+
"view": "plugin-invoice-receivers",
|
|
63
|
+
"title": "Receivers - Domma CMS"
|
|
64
|
+
}
|
|
65
|
+
],
|
|
66
|
+
"views": {
|
|
67
|
+
"plugin-invoice-list": {
|
|
68
|
+
"entry": "invoice/admin/views/index.js?v=a7963ce7",
|
|
69
|
+
"exportName": "indexView"
|
|
70
|
+
},
|
|
71
|
+
"plugin-invoice-editor": {
|
|
72
|
+
"entry": "invoice/admin/views/editor.js?v=897f0259",
|
|
73
|
+
"exportName": "editorView"
|
|
74
|
+
},
|
|
75
|
+
"plugin-invoice-issuers": {
|
|
76
|
+
"entry": "invoice/admin/views/issuers.js?v=ce720b84",
|
|
77
|
+
"exportName": "issuersView"
|
|
78
|
+
},
|
|
79
|
+
"plugin-invoice-receivers": {
|
|
80
|
+
"entry": "invoice/admin/views/receivers.js?v=c4b34b26",
|
|
81
|
+
"exportName": "receiversView"
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<title>Invoice {{number}}</title>
|
|
6
|
+
<style>
|
|
7
|
+
:root { color-scheme: light; }
|
|
8
|
+
* { box-sizing: border-box; }
|
|
9
|
+
body {
|
|
10
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
|
|
11
|
+
color: #222;
|
|
12
|
+
background: #f3f3f3;
|
|
13
|
+
margin: 0;
|
|
14
|
+
padding: 24px;
|
|
15
|
+
font-size: 14px;
|
|
16
|
+
line-height: 1.5;
|
|
17
|
+
}
|
|
18
|
+
.invoice {
|
|
19
|
+
max-width: 800px;
|
|
20
|
+
margin: 0 auto;
|
|
21
|
+
background: #fff;
|
|
22
|
+
padding: 48px 56px;
|
|
23
|
+
box-shadow: 0 4px 20px rgba(0,0,0,.08);
|
|
24
|
+
}
|
|
25
|
+
.top {
|
|
26
|
+
display: flex;
|
|
27
|
+
justify-content: space-between;
|
|
28
|
+
align-items: flex-start;
|
|
29
|
+
gap: 24px;
|
|
30
|
+
border-bottom: 2px solid #e5e5e5;
|
|
31
|
+
padding-bottom: 24px;
|
|
32
|
+
margin-bottom: 32px;
|
|
33
|
+
}
|
|
34
|
+
.logo { max-height: 80px; max-width: 240px; display: block; margin-bottom: 12px; }
|
|
35
|
+
.meta { text-align: right; }
|
|
36
|
+
.meta h1 { margin: 0; font-size: 28px; letter-spacing: 1px; color: #000; }
|
|
37
|
+
.meta .number { font-size: 18px; color: #555; margin-top: 4px; }
|
|
38
|
+
.status {
|
|
39
|
+
display: inline-block;
|
|
40
|
+
padding: 4px 10px;
|
|
41
|
+
border-radius: 4px;
|
|
42
|
+
font-size: 11px;
|
|
43
|
+
font-weight: 600;
|
|
44
|
+
letter-spacing: .5px;
|
|
45
|
+
background: #eee;
|
|
46
|
+
color: #444;
|
|
47
|
+
margin-top: 8px;
|
|
48
|
+
}
|
|
49
|
+
.parties {
|
|
50
|
+
display: grid;
|
|
51
|
+
grid-template-columns: 1fr 1fr;
|
|
52
|
+
gap: 32px;
|
|
53
|
+
margin-bottom: 32px;
|
|
54
|
+
}
|
|
55
|
+
.party h3 {
|
|
56
|
+
margin: 0 0 8px;
|
|
57
|
+
font-size: 11px;
|
|
58
|
+
text-transform: uppercase;
|
|
59
|
+
letter-spacing: 1px;
|
|
60
|
+
color: #888;
|
|
61
|
+
}
|
|
62
|
+
.party .name { font-weight: 700; font-size: 15px; margin-bottom: 4px; }
|
|
63
|
+
.party .lines { color: #555; font-size: 13px; }
|
|
64
|
+
table.items {
|
|
65
|
+
width: 100%;
|
|
66
|
+
border-collapse: collapse;
|
|
67
|
+
margin-bottom: 24px;
|
|
68
|
+
}
|
|
69
|
+
table.items th {
|
|
70
|
+
text-align: left;
|
|
71
|
+
font-size: 11px;
|
|
72
|
+
text-transform: uppercase;
|
|
73
|
+
letter-spacing: 1px;
|
|
74
|
+
color: #888;
|
|
75
|
+
padding: 8px 10px;
|
|
76
|
+
border-bottom: 2px solid #e5e5e5;
|
|
77
|
+
}
|
|
78
|
+
table.items td {
|
|
79
|
+
padding: 10px;
|
|
80
|
+
border-bottom: 1px solid #f0f0f0;
|
|
81
|
+
}
|
|
82
|
+
table.items td.num, table.items th.num { text-align: right; }
|
|
83
|
+
.totals {
|
|
84
|
+
width: 320px;
|
|
85
|
+
margin-left: auto;
|
|
86
|
+
margin-bottom: 32px;
|
|
87
|
+
}
|
|
88
|
+
.totals table { width: 100%; border-collapse: collapse; }
|
|
89
|
+
.totals th {
|
|
90
|
+
text-align: left;
|
|
91
|
+
padding: 8px 10px;
|
|
92
|
+
font-weight: 500;
|
|
93
|
+
color: #555;
|
|
94
|
+
}
|
|
95
|
+
.totals td.num {
|
|
96
|
+
text-align: right;
|
|
97
|
+
padding: 8px 10px;
|
|
98
|
+
font-variant-numeric: tabular-nums;
|
|
99
|
+
}
|
|
100
|
+
.totals .grand th, .totals .grand td {
|
|
101
|
+
font-size: 17px;
|
|
102
|
+
font-weight: 700;
|
|
103
|
+
color: #000;
|
|
104
|
+
border-top: 2px solid #222;
|
|
105
|
+
padding-top: 12px;
|
|
106
|
+
}
|
|
107
|
+
.footer {
|
|
108
|
+
margin-top: 32px;
|
|
109
|
+
padding-top: 16px;
|
|
110
|
+
border-top: 1px solid #e5e5e5;
|
|
111
|
+
font-size: 12px;
|
|
112
|
+
color: #888;
|
|
113
|
+
}
|
|
114
|
+
.notes, .bank {
|
|
115
|
+
margin-top: 24px;
|
|
116
|
+
padding: 12px 16px;
|
|
117
|
+
background: #fafafa;
|
|
118
|
+
border-radius: 4px;
|
|
119
|
+
font-size: 13px;
|
|
120
|
+
}
|
|
121
|
+
.muted { color: #999; font-style: italic; text-align: center; }
|
|
122
|
+
.actions {
|
|
123
|
+
max-width: 800px;
|
|
124
|
+
margin: 0 auto 16px;
|
|
125
|
+
display: flex;
|
|
126
|
+
justify-content: flex-end;
|
|
127
|
+
gap: 8px;
|
|
128
|
+
}
|
|
129
|
+
.actions button {
|
|
130
|
+
background: #222;
|
|
131
|
+
color: #fff;
|
|
132
|
+
border: 0;
|
|
133
|
+
padding: 8px 16px;
|
|
134
|
+
font-size: 13px;
|
|
135
|
+
border-radius: 4px;
|
|
136
|
+
cursor: pointer;
|
|
137
|
+
}
|
|
138
|
+
@media print {
|
|
139
|
+
body { background: #fff; padding: 0; }
|
|
140
|
+
.invoice { box-shadow: none; padding: 24px; }
|
|
141
|
+
.actions { display: none; }
|
|
142
|
+
}
|
|
143
|
+
</style>
|
|
144
|
+
</head>
|
|
145
|
+
<body>
|
|
146
|
+
|
|
147
|
+
<div class="actions">
|
|
148
|
+
<button onclick="window.print()">Print / Save as PDF</button>
|
|
149
|
+
</div>
|
|
150
|
+
|
|
151
|
+
<div class="invoice">
|
|
152
|
+
<div class="top">
|
|
153
|
+
<div>
|
|
154
|
+
{{issuerLogo}}
|
|
155
|
+
<div class="party">
|
|
156
|
+
<div class="name">{{issuerName}}</div>
|
|
157
|
+
<div class="lines">{{issuerAddress}}</div>
|
|
158
|
+
<div class="lines">{{issuerEmail}}</div>
|
|
159
|
+
<div class="lines">{{issuerPhone}}</div>
|
|
160
|
+
{{issuerVat}}
|
|
161
|
+
</div>
|
|
162
|
+
</div>
|
|
163
|
+
<div class="meta">
|
|
164
|
+
<h1>INVOICE</h1>
|
|
165
|
+
<div class="number">{{number}}</div>
|
|
166
|
+
<div>{{issueDate}}</div>
|
|
167
|
+
{{dueDate}}
|
|
168
|
+
<div class="status">{{status}}</div>
|
|
169
|
+
</div>
|
|
170
|
+
</div>
|
|
171
|
+
|
|
172
|
+
<div class="parties">
|
|
173
|
+
<div class="party">
|
|
174
|
+
<h3>Billed To</h3>
|
|
175
|
+
<div class="name">{{receiverName}}</div>
|
|
176
|
+
<div class="lines">{{receiverAddress}}</div>
|
|
177
|
+
<div class="lines">{{receiverEmail}}</div>
|
|
178
|
+
{{receiverVat}}
|
|
179
|
+
</div>
|
|
180
|
+
</div>
|
|
181
|
+
|
|
182
|
+
<table class="items">
|
|
183
|
+
<thead>
|
|
184
|
+
<tr>
|
|
185
|
+
<th>Description</th>
|
|
186
|
+
<th class="num">Qty</th>
|
|
187
|
+
<th class="num">Unit</th>
|
|
188
|
+
<th class="num">Total</th>
|
|
189
|
+
</tr>
|
|
190
|
+
</thead>
|
|
191
|
+
<tbody>
|
|
192
|
+
{{items}}
|
|
193
|
+
</tbody>
|
|
194
|
+
</table>
|
|
195
|
+
|
|
196
|
+
<div class="totals">
|
|
197
|
+
<table>
|
|
198
|
+
<tr><th>Subtotal</th><td class="num">{{subtotal}}</td></tr>
|
|
199
|
+
{{vatRow}}
|
|
200
|
+
<tr class="grand"><th>Total</th><td class="num">{{total}}</td></tr>
|
|
201
|
+
</table>
|
|
202
|
+
</div>
|
|
203
|
+
|
|
204
|
+
{{notes}}
|
|
205
|
+
{{bankDetails}}
|
|
206
|
+
|
|
207
|
+
<div class="footer">{{footerNote}}</div>
|
|
208
|
+
</div>
|
|
209
|
+
|
|
210
|
+
{{autoPrint}}
|
|
211
|
+
|
|
212
|
+
</body>
|
|
213
|
+
</html>
|
|
@@ -763,7 +763,7 @@ export function scrubCodeRegions(markdown) {
|
|
|
763
763
|
* @param {string} markdown
|
|
764
764
|
* @returns {string}
|
|
765
765
|
*/
|
|
766
|
-
function processPluginShortcodes(markdown) {
|
|
766
|
+
async function processPluginShortcodes(markdown) {
|
|
767
767
|
const processors = getShortcodeProcessors();
|
|
768
768
|
if (!processors.length) return markdown;
|
|
769
769
|
|
|
@@ -771,14 +771,34 @@ function processPluginShortcodes(markdown) {
|
|
|
771
771
|
let result = scrubbed;
|
|
772
772
|
const context = {parseShortcodeAttrs, scrubCodeRegions, marked, processCardBlocks, processGridBlocks, escapeAttr};
|
|
773
773
|
|
|
774
|
+
async function replaceAsync(input, regex, mapMatch) {
|
|
775
|
+
const matches = [];
|
|
776
|
+
input.replace(regex, (...args) => {
|
|
777
|
+
const offset = args[args.length - 2];
|
|
778
|
+
matches.push({ args: args.slice(0, -2), full: args[0], offset });
|
|
779
|
+
return args[0];
|
|
780
|
+
});
|
|
781
|
+
if (!matches.length) return input;
|
|
782
|
+
const out = await Promise.all(matches.map((m) => Promise.resolve(mapMatch(...m.args))));
|
|
783
|
+
let res = '';
|
|
784
|
+
let lastEnd = 0;
|
|
785
|
+
matches.forEach((m, i) => {
|
|
786
|
+
res += input.slice(lastEnd, m.offset) + out[i];
|
|
787
|
+
lastEnd = m.offset + m.full.length;
|
|
788
|
+
});
|
|
789
|
+
return res + input.slice(lastEnd);
|
|
790
|
+
}
|
|
791
|
+
|
|
774
792
|
for (const {name, handler} of processors) {
|
|
775
793
|
// Self-closing: [name attrs /]
|
|
776
|
-
result =
|
|
794
|
+
result = await replaceAsync(
|
|
795
|
+
result,
|
|
777
796
|
new RegExp(`\\[${name}([^\\]]*)\\s*\\/\\]`, 'gi'),
|
|
778
797
|
(_, attrStr) => handler(attrStr, null, context)
|
|
779
798
|
);
|
|
780
799
|
// Wrapping: [name attrs]...[/name]
|
|
781
|
-
result =
|
|
800
|
+
result = await replaceAsync(
|
|
801
|
+
result,
|
|
782
802
|
new RegExp(`\\[${name}([^\\]]*)\\]([\\s\\S]*?)\\[\\/${name}\\]`, 'gi'),
|
|
783
803
|
(_, attrStr, body) => handler(attrStr, body, context)
|
|
784
804
|
);
|
|
@@ -2901,7 +2921,7 @@ export async function parseMarkdown(raw) {
|
|
|
2901
2921
|
const withStaticBlock = await processStaticBlocks(withView);
|
|
2902
2922
|
const withDconfig = processDConfigBlocks(withStaticBlock);
|
|
2903
2923
|
const withEffects = processEffectsBlocks(withDconfig);
|
|
2904
|
-
const withPluginShortcodes = processPluginShortcodes(withEffects);
|
|
2924
|
+
const withPluginShortcodes = await processPluginShortcodes(withEffects);
|
|
2905
2925
|
const withTabs = processTabsBlocks(withPluginShortcodes);
|
|
2906
2926
|
const withAccordion = processAccordionBlocks(withTabs);
|
|
2907
2927
|
const withCarousel = processCarouselBlocks(withAccordion);
|
|
@@ -312,11 +312,17 @@ export async function renderBlogPage(templatePath, data = {}, seoMeta = {}) {
|
|
|
312
312
|
const seoDescription = escapeHtml(seoMeta.description ?? site.seo?.defaultDescription ?? '');
|
|
313
313
|
const ogImage = escapeHtml(seoMeta.ogImage ?? '');
|
|
314
314
|
|
|
315
|
+
const ogType = escapeHtml(seoMeta.ogType ?? 'website');
|
|
315
316
|
const ogTags = [
|
|
316
317
|
`<meta property="og:title" content="${seoTitle}">`,
|
|
317
318
|
`<meta property="og:description" content="${seoDescription}">`,
|
|
318
|
-
`<meta property="og:
|
|
319
|
-
|
|
319
|
+
`<meta property="og:type" content="${ogType}">`,
|
|
320
|
+
ogImage ? `<meta property="og:image" content="${ogImage}">` : '',
|
|
321
|
+
`<meta name="twitter:card" content="${ogImage ? 'summary_large_image' : 'summary'}">`,
|
|
322
|
+
`<meta name="twitter:title" content="${seoTitle}">`,
|
|
323
|
+
`<meta name="twitter:description" content="${seoDescription}">`,
|
|
324
|
+
ogImage ? `<meta name="twitter:image" content="${ogImage}">` : ''
|
|
325
|
+
].filter(Boolean).join('\n');
|
|
320
326
|
|
|
321
327
|
const {fontLink, fontOverride} = buildFontVars(site.fontFamily, site.fontSize);
|
|
322
328
|
const fontStyleTag = fontOverride ? `<style>${fontOverride}</style>` : '';
|
|
@@ -363,7 +369,7 @@ export async function renderBlogPage(templatePath, data = {}, seoMeta = {}) {
|
|
|
363
369
|
headInjectLate: [injection.headLate, customCssTag, navbarStyleTag].filter(Boolean).join('\n'),
|
|
364
370
|
bodyEndInject: [
|
|
365
371
|
injection.bodyEnd,
|
|
366
|
-
(
|
|
372
|
+
(seoMeta.usedComponents || [])
|
|
367
373
|
.map(n => `<script type="module" src="/api/components/${n}.js"></script>`)
|
|
368
374
|
.join('\n')
|
|
369
375
|
].filter(Boolean).join('\n'),
|