@invompt/invoml 1.0.0-alpha.4
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/LICENSE +190 -0
- package/README.md +320 -0
- package/dist/cli/invoml.d.ts +2 -0
- package/dist/cli/invoml.js +78 -0
- package/dist/src/calculator.d.ts +3 -0
- package/dist/src/calculator.js +164 -0
- package/dist/src/discounts.d.ts +6 -0
- package/dist/src/discounts.js +46 -0
- package/dist/src/format.d.ts +3 -0
- package/dist/src/format.js +14 -0
- package/dist/src/html-css.d.ts +4 -0
- package/dist/src/html-css.js +300 -0
- package/dist/src/html-renderer.d.ts +5 -0
- package/dist/src/html-renderer.js +365 -0
- package/dist/src/index.d.ts +13 -0
- package/dist/src/index.js +8 -0
- package/dist/src/markdown.d.ts +8 -0
- package/dist/src/markdown.js +89 -0
- package/dist/src/parser.d.ts +10 -0
- package/dist/src/parser.js +16 -0
- package/dist/src/rounding.d.ts +5 -0
- package/dist/src/rounding.js +24 -0
- package/dist/src/schema.d.ts +6 -0
- package/dist/src/schema.js +33 -0
- package/dist/src/serializer.d.ts +8 -0
- package/dist/src/serializer.js +181 -0
- package/dist/src/style.d.ts +21 -0
- package/dist/src/style.js +70 -0
- package/dist/src/tax.d.ts +3 -0
- package/dist/src/tax.js +40 -0
- package/dist/src/types.d.ts +132 -0
- package/dist/src/types.js +10 -0
- package/invoml-v1.0.schema.json +258 -0
- package/package.json +65 -0
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
// src/html-renderer.ts — Orchestrates block rendering into a self-contained HTML document.
|
|
2
|
+
import { resolveStyle } from './style.js';
|
|
3
|
+
import { getCurrencyDecimals } from './rounding.js';
|
|
4
|
+
import { fmtNum } from './format.js';
|
|
5
|
+
import { escapeHtml, processInline, processMarkdown } from './markdown.js';
|
|
6
|
+
import { BASE_CSS, TEMPLATE_CSS } from './html-css.js';
|
|
7
|
+
// Alias for brevity within this module
|
|
8
|
+
const esc = escapeHtml;
|
|
9
|
+
// ─── Block renderers ──────────────────────────────────────────────────────────
|
|
10
|
+
function renderHeader(doc, blockStyle) {
|
|
11
|
+
const { meta } = doc;
|
|
12
|
+
const type = meta.documentType.replaceAll('_', ' ').toUpperCase();
|
|
13
|
+
const styleAttr = buildInlineStyle(blockStyle);
|
|
14
|
+
const metaItems = [];
|
|
15
|
+
metaItems.push(metaItem('Date', meta.issueDate, 'meta.issueDate'));
|
|
16
|
+
if (meta.dueDate)
|
|
17
|
+
metaItems.push(metaItem('Due', meta.dueDate, 'meta.dueDate'));
|
|
18
|
+
if (meta.expiryDate)
|
|
19
|
+
metaItems.push(metaItem('Expires', meta.expiryDate, 'meta.expiryDate'));
|
|
20
|
+
metaItems.push(metaItem('Currency', meta.currency, 'meta.currency'));
|
|
21
|
+
if (meta.reference)
|
|
22
|
+
metaItems.push(metaItem('Reference', meta.reference, 'meta.reference'));
|
|
23
|
+
if (meta.creditNoteReference)
|
|
24
|
+
metaItems.push(metaItem('Ref', meta.creditNoteReference, 'meta.creditNoteReference'));
|
|
25
|
+
return `
|
|
26
|
+
<header class="invoml-header" data-invoml-block="header"${styleAttr}>
|
|
27
|
+
<div class="invoml-header-title" data-invoml-field="meta.documentType" data-invoml-type="text">${esc(type)}</div>
|
|
28
|
+
<div class="invoml-header-number" data-invoml-field="meta.number" data-invoml-type="text">${esc(meta.number)}</div>
|
|
29
|
+
<div class="invoml-header-meta">
|
|
30
|
+
${metaItems.join('\n ')}
|
|
31
|
+
</div>
|
|
32
|
+
</header>`;
|
|
33
|
+
}
|
|
34
|
+
function metaItem(label, value, field) {
|
|
35
|
+
return `<div class="invoml-header-meta-item"><span class="invoml-header-meta-label">${esc(label)}</span><span class="invoml-header-meta-value" data-invoml-field="${esc(field)}" data-invoml-type="text">${esc(value)}</span></div>`;
|
|
36
|
+
}
|
|
37
|
+
function renderParty(role, party, blockStyle) {
|
|
38
|
+
if (!party)
|
|
39
|
+
return '';
|
|
40
|
+
const label = role === 'from' ? 'From' : 'Bill To';
|
|
41
|
+
const ariaLabel = role === 'from' ? 'Issued by' : 'Billed to';
|
|
42
|
+
const styleAttr = buildInlineStyle(blockStyle);
|
|
43
|
+
let inner;
|
|
44
|
+
if (party.content) {
|
|
45
|
+
inner = `<div class="invoml-party-details" data-invoml-field="${role}.content" data-invoml-type="markdown-block">${processMarkdown(party.content)}</div>`;
|
|
46
|
+
}
|
|
47
|
+
else {
|
|
48
|
+
const rows = [];
|
|
49
|
+
if (party.name)
|
|
50
|
+
rows.push(`<div class="invoml-party-name" data-invoml-field="${role}.name" data-invoml-type="markdown">${processInline(party.name)}</div>`);
|
|
51
|
+
const details = [];
|
|
52
|
+
if (party.address)
|
|
53
|
+
details.push(`<div data-invoml-field="${role}.address" data-invoml-type="text">${esc(party.address)}</div>`);
|
|
54
|
+
if (party.attention)
|
|
55
|
+
details.push(`<div data-invoml-field="${role}.attention" data-invoml-type="text">Attn: ${esc(party.attention)}</div>`);
|
|
56
|
+
if (party.email)
|
|
57
|
+
details.push(`<div data-invoml-field="${role}.email" data-invoml-type="text">${esc(party.email)}</div>`);
|
|
58
|
+
if (party.phone)
|
|
59
|
+
details.push(`<div data-invoml-field="${role}.phone" data-invoml-type="text">${esc(party.phone)}</div>`);
|
|
60
|
+
if (party.website)
|
|
61
|
+
details.push(`<div data-invoml-field="${role}.website" data-invoml-type="text">${esc(party.website)}</div>`);
|
|
62
|
+
if (party.taxId)
|
|
63
|
+
details.push(`<div data-invoml-field="${role}.taxId" data-invoml-type="text">Tax ID: ${esc(party.taxId)}</div>`);
|
|
64
|
+
if (party.businessNumber)
|
|
65
|
+
details.push(`<div data-invoml-field="${role}.businessNumber" data-invoml-type="text">Business No: ${esc(party.businessNumber)}</div>`);
|
|
66
|
+
if (details.length > 0) {
|
|
67
|
+
rows.push(`<div class="invoml-party-details">${details.join('')}</div>`);
|
|
68
|
+
}
|
|
69
|
+
inner = rows.join('\n');
|
|
70
|
+
}
|
|
71
|
+
return `
|
|
72
|
+
<div class="invoml-party invoml-party-${role}" data-invoml-block="${role}" aria-label="${ariaLabel}"${styleAttr}>
|
|
73
|
+
<div class="invoml-party-label">${esc(label)}</div>
|
|
74
|
+
${inner}
|
|
75
|
+
</div>`;
|
|
76
|
+
}
|
|
77
|
+
function renderItems(doc, blockStyle) {
|
|
78
|
+
const dp = getCurrencyDecimals(doc.meta.currency);
|
|
79
|
+
const fmt = (n) => fmtNum(n, dp);
|
|
80
|
+
const hasUnit = doc.items.some(i => i.unit);
|
|
81
|
+
const hasDiscount = doc.items.some(i => i.discount);
|
|
82
|
+
const hasTax = doc.items.some(i => i.taxAmount !== undefined);
|
|
83
|
+
const styleAttr = buildInlineStyle(blockStyle);
|
|
84
|
+
const thRight = (label) => `<th class="col-right">${esc(label)}</th>`;
|
|
85
|
+
const headers = [
|
|
86
|
+
`<th data-invoml-field="items.*.description">Description</th>`,
|
|
87
|
+
`<th class="col-right">Qty</th>`,
|
|
88
|
+
hasUnit ? `<th>Unit</th>` : '',
|
|
89
|
+
thRight('Unit Price'),
|
|
90
|
+
hasDiscount ? thRight('Discount') : '',
|
|
91
|
+
hasTax ? thRight('Tax') : '',
|
|
92
|
+
thRight('Amount'),
|
|
93
|
+
].filter(Boolean).join('\n ');
|
|
94
|
+
const rows = doc.items.map((item, idx) => {
|
|
95
|
+
const amount = item.amount ?? item.quantity * item.unitPrice;
|
|
96
|
+
const discountStr = item.discount
|
|
97
|
+
? typeof item.discount === 'string'
|
|
98
|
+
? item.discount
|
|
99
|
+
: item.discount.type === 'percentage'
|
|
100
|
+
? `${item.discount.value}%`
|
|
101
|
+
: fmt(item.discount.value)
|
|
102
|
+
: '';
|
|
103
|
+
const cells = [
|
|
104
|
+
`<td data-invoml-field="items.${idx}.description" data-invoml-type="markdown">${processInline(item.description)}</td>`,
|
|
105
|
+
`<td class="col-right" data-invoml-field="items.${idx}.quantity" data-invoml-type="number">${esc(String(item.quantity))}</td>`,
|
|
106
|
+
hasUnit ? `<td data-invoml-field="items.${idx}.unit" data-invoml-type="text">${esc(item.unit ?? '')}</td>` : '',
|
|
107
|
+
`<td class="col-right" data-invoml-field="items.${idx}.unitPrice" data-invoml-type="currency">${esc(fmt(item.unitPrice))}</td>`,
|
|
108
|
+
hasDiscount ? `<td class="col-right" data-invoml-field="items.${idx}.discount" data-invoml-type="text">${esc(discountStr)}</td>` : '',
|
|
109
|
+
hasTax ? `<td class="col-right" data-invoml-field="items.${idx}.taxAmount" data-invoml-type="currency" data-invoml-computed>${item.taxAmount !== undefined ? esc(fmt(item.taxAmount)) : ''}</td>` : '',
|
|
110
|
+
`<td class="col-right" data-invoml-field="items.${idx}.amount" data-invoml-type="currency" data-invoml-computed>${esc(fmt(amount))}</td>`,
|
|
111
|
+
].filter(Boolean).join('\n ');
|
|
112
|
+
return ` <tr>\n ${cells}\n </tr>`;
|
|
113
|
+
}).join('\n');
|
|
114
|
+
return `
|
|
115
|
+
<table class="invoml-items" data-invoml-block="items"${styleAttr}>
|
|
116
|
+
<thead>
|
|
117
|
+
<tr>
|
|
118
|
+
${headers}
|
|
119
|
+
</tr>
|
|
120
|
+
</thead>
|
|
121
|
+
<tbody>
|
|
122
|
+
${rows}
|
|
123
|
+
</tbody>
|
|
124
|
+
</table>`;
|
|
125
|
+
}
|
|
126
|
+
function renderTotals(totals, currency, blockStyle) {
|
|
127
|
+
if (!totals)
|
|
128
|
+
return '';
|
|
129
|
+
const dp = getCurrencyDecimals(currency);
|
|
130
|
+
const fmt = (n) => fmtNum(n, dp);
|
|
131
|
+
const styleAttr = buildInlineStyle(blockStyle);
|
|
132
|
+
const rows = [];
|
|
133
|
+
const row = (label, amount, extra = '', labelBold = false) => {
|
|
134
|
+
const labelClass = `invoml-totals-label${labelBold ? ' is-bold' : ''}`;
|
|
135
|
+
return `<div class="invoml-totals-row${extra}" aria-label="${esc(label)}">
|
|
136
|
+
<span class="${labelClass}">${esc(label)}</span>
|
|
137
|
+
<span class="invoml-totals-amount" data-invoml-computed>${esc(amount)}</span>
|
|
138
|
+
</div>`;
|
|
139
|
+
};
|
|
140
|
+
rows.push(row('Subtotal', fmt(totals.subtotal)));
|
|
141
|
+
if (totals.discountDetails) {
|
|
142
|
+
for (const d of totals.discountDetails) {
|
|
143
|
+
rows.push(row(d.label ?? 'Discount', `-${fmt(d.amount)}`));
|
|
144
|
+
}
|
|
145
|
+
rows.push(row('After Discounts', fmt(totals.afterDiscounts)));
|
|
146
|
+
}
|
|
147
|
+
if (totals.taxDetails) {
|
|
148
|
+
for (const t of totals.taxDetails) {
|
|
149
|
+
const suffix = t.inclusive ? ' (included)' : '';
|
|
150
|
+
rows.push(row(`${t.label ?? t.category}${suffix}`, fmt(t.amount)));
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
if (totals.withholdingTotal && totals.withholdingTotal > 0) {
|
|
154
|
+
rows.push(row('Withholding', `-${fmt(totals.withholdingTotal)}`));
|
|
155
|
+
}
|
|
156
|
+
rows.push(row(`Total (${currency})`, fmt(totals.total), ' is-grand', true));
|
|
157
|
+
if (totals.prepaidAmount && totals.prepaidAmount > 0) {
|
|
158
|
+
rows.push(row('Prepaid', `-${fmt(totals.prepaidAmount)}`));
|
|
159
|
+
rows.push(row('Amount Due', fmt(totals.amountDue), ' is-amount-due', true));
|
|
160
|
+
}
|
|
161
|
+
return `
|
|
162
|
+
<div class="invoml-totals" data-invoml-block="totals" aria-label="Invoice summary"${styleAttr}>
|
|
163
|
+
<div class="invoml-totals-inner">
|
|
164
|
+
${rows.join('\n ')}
|
|
165
|
+
</div>
|
|
166
|
+
</div>`;
|
|
167
|
+
}
|
|
168
|
+
function renderPayment(payment, blockStyle) {
|
|
169
|
+
if (!payment)
|
|
170
|
+
return '';
|
|
171
|
+
const styleAttr = buildInlineStyle(blockStyle);
|
|
172
|
+
const title = payment.title ?? 'Payment';
|
|
173
|
+
let inner;
|
|
174
|
+
if (payment.content) {
|
|
175
|
+
inner = `<div class="invoml-payment-details" data-invoml-field="payment.content" data-invoml-type="markdown-block">${processMarkdown(payment.content)}</div>`;
|
|
176
|
+
}
|
|
177
|
+
else {
|
|
178
|
+
const fields = [];
|
|
179
|
+
if (payment.beneficiary)
|
|
180
|
+
fields.push(`<strong>Beneficiary:</strong> ${esc(payment.beneficiary)}`);
|
|
181
|
+
if (payment.bank)
|
|
182
|
+
fields.push(`<strong>Bank:</strong> ${esc(payment.bank)}`);
|
|
183
|
+
if (payment.iban)
|
|
184
|
+
fields.push(`<strong>IBAN:</strong> ${esc(payment.iban)}`);
|
|
185
|
+
if (payment.swift)
|
|
186
|
+
fields.push(`<strong>SWIFT/BIC:</strong> ${esc(payment.swift)}`);
|
|
187
|
+
if (payment.routingNumber)
|
|
188
|
+
fields.push(`<strong>Routing:</strong> ${esc(payment.routingNumber)}`);
|
|
189
|
+
if (payment.accountNumber)
|
|
190
|
+
fields.push(`<strong>Account:</strong> ${esc(payment.accountNumber)}`);
|
|
191
|
+
if (payment.cryptoAddress)
|
|
192
|
+
fields.push(`<strong>Address:</strong> ${esc(payment.cryptoAddress)}`);
|
|
193
|
+
if (payment.cryptoNetwork)
|
|
194
|
+
fields.push(`<strong>Network:</strong> ${esc(payment.cryptoNetwork)}`);
|
|
195
|
+
inner = `<div class="invoml-payment-details">${fields.join('<br>\n')}</div>`;
|
|
196
|
+
}
|
|
197
|
+
return `
|
|
198
|
+
<section class="invoml-payment" data-invoml-block="payment">
|
|
199
|
+
<div class="invoml-payment-title" data-invoml-field="payment.title" data-invoml-type="text"${styleAttr}>${esc(title)}</div>
|
|
200
|
+
${inner}
|
|
201
|
+
</section>`;
|
|
202
|
+
}
|
|
203
|
+
function renderNotes(notes, blockStyle) {
|
|
204
|
+
if (!notes)
|
|
205
|
+
return '';
|
|
206
|
+
const styleAttr = buildInlineStyle(blockStyle);
|
|
207
|
+
return `
|
|
208
|
+
<footer class="invoml-notes" data-invoml-block="notes"${styleAttr}>
|
|
209
|
+
<div data-invoml-field="notes" data-invoml-type="markdown-block">${processMarkdown(notes)}</div>
|
|
210
|
+
</footer>`;
|
|
211
|
+
}
|
|
212
|
+
function renderSection(key, section, blockStyle) {
|
|
213
|
+
const styleAttr = buildInlineStyle(blockStyle);
|
|
214
|
+
return `
|
|
215
|
+
<section class="invoml-section" data-invoml-block="section:${esc(key)}" data-invoml-section="${esc(key)}"${styleAttr}>
|
|
216
|
+
<div class="invoml-section-title">${esc(section.title)}</div>
|
|
217
|
+
<div class="invoml-section-content" data-invoml-field="sections.${esc(key)}.content" data-invoml-type="markdown-block">${processMarkdown(section.content)}</div>
|
|
218
|
+
</section>`;
|
|
219
|
+
}
|
|
220
|
+
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
221
|
+
/**
|
|
222
|
+
* CSS properties allowed in style.blocks inline styles.
|
|
223
|
+
* Restricted to safe visual properties — layout visibility properties that could hide
|
|
224
|
+
* financial data (display: none, visibility: hidden, opacity: 0) are intentionally excluded.
|
|
225
|
+
*/
|
|
226
|
+
const ALLOWED_INLINE_CSS_PROPERTIES = new Set([
|
|
227
|
+
// Box model
|
|
228
|
+
'margin', 'margin-top', 'margin-right', 'margin-bottom', 'margin-left',
|
|
229
|
+
'padding', 'padding-top', 'padding-right', 'padding-bottom', 'padding-left',
|
|
230
|
+
'width', 'min-width', 'max-width',
|
|
231
|
+
// Display / layout (safe subset only — no display:none, no visibility:hidden)
|
|
232
|
+
'display', 'vertical-align', 'text-align',
|
|
233
|
+
'float', 'clear',
|
|
234
|
+
// Typography
|
|
235
|
+
'font', 'font-family', 'font-size', 'font-weight', 'font-style', 'font-variant',
|
|
236
|
+
'line-height', 'letter-spacing', 'word-spacing', 'text-decoration', 'text-transform',
|
|
237
|
+
'white-space',
|
|
238
|
+
// Color / background
|
|
239
|
+
'color', 'background', 'background-color',
|
|
240
|
+
'border', 'border-top', 'border-right', 'border-bottom', 'border-left',
|
|
241
|
+
'border-color', 'border-width', 'border-style', 'border-radius',
|
|
242
|
+
// CSS custom properties (--invoml-*)
|
|
243
|
+
]);
|
|
244
|
+
/**
|
|
245
|
+
* Dangerous property values for inline styles — patterns that could hide content
|
|
246
|
+
* or trigger data exfiltration even within a style="" attribute context.
|
|
247
|
+
*/
|
|
248
|
+
const INLINE_VALUE_FORBIDDEN_RE = /none|hidden|^0$|absolute|fixed|url\s*\(|expression\s*\(/i;
|
|
249
|
+
function isSafeInlineCssProperty(name, value) {
|
|
250
|
+
const nameLower = name.toLowerCase().trim();
|
|
251
|
+
// Allow CSS custom properties (--something) always
|
|
252
|
+
if (/^--[a-zA-Z][a-zA-Z0-9-]*$/.test(name))
|
|
253
|
+
return true;
|
|
254
|
+
if (!ALLOWED_INLINE_CSS_PROPERTIES.has(nameLower))
|
|
255
|
+
return false;
|
|
256
|
+
// Block values that hide content or exfiltrate data on any property
|
|
257
|
+
if (INLINE_VALUE_FORBIDDEN_RE.test(value.trim()))
|
|
258
|
+
return false;
|
|
259
|
+
return true;
|
|
260
|
+
}
|
|
261
|
+
function buildInlineStyle(props) {
|
|
262
|
+
const entries = Object.entries(props);
|
|
263
|
+
if (entries.length === 0)
|
|
264
|
+
return '';
|
|
265
|
+
const css = entries
|
|
266
|
+
.filter(([k, v]) => isSafeInlineCssProperty(k, v))
|
|
267
|
+
.map(([k, v]) => `${k}: ${v}`)
|
|
268
|
+
.join('; ');
|
|
269
|
+
if (css.length === 0)
|
|
270
|
+
return '';
|
|
271
|
+
return ` style="${esc(css)}"`;
|
|
272
|
+
}
|
|
273
|
+
/** Allowlist pattern for CSS property names: standard kebab-case (font-family) or CSS custom properties (--accent). */
|
|
274
|
+
const CSS_PROPERTY_NAME_RE = /^-{0,2}[a-zA-Z][a-zA-Z0-9-]*$/;
|
|
275
|
+
/** Characters/sequences forbidden in CSS property values when written into a <style> block. */
|
|
276
|
+
const CSS_VALUE_FORBIDDEN_RE = /[{}@;\n\r\\]|url\s*\(|expression\s*\(|<\//i;
|
|
277
|
+
function isSafeCssPropertyName(name) {
|
|
278
|
+
return CSS_PROPERTY_NAME_RE.test(name);
|
|
279
|
+
}
|
|
280
|
+
function isSafeCssPropertyValue(value) {
|
|
281
|
+
return !CSS_VALUE_FORBIDDEN_RE.test(value);
|
|
282
|
+
}
|
|
283
|
+
function buildContainerProperties(properties) {
|
|
284
|
+
const entries = Object.entries(properties);
|
|
285
|
+
if (entries.length === 0)
|
|
286
|
+
return '';
|
|
287
|
+
const decls = entries
|
|
288
|
+
.filter(([k, v]) => isSafeCssPropertyName(k) && isSafeCssPropertyValue(v))
|
|
289
|
+
.map(([k, v]) => ` ${k}: ${v};`)
|
|
290
|
+
.join('\n');
|
|
291
|
+
if (decls.length === 0)
|
|
292
|
+
return '';
|
|
293
|
+
return `.invoml-container {\n${decls}\n}`;
|
|
294
|
+
}
|
|
295
|
+
// ─── Main export ──────────────────────────────────────────────────────────────
|
|
296
|
+
/** Render an InvoML document (with computed totals in doc.totals) as a
|
|
297
|
+
* self-contained HTML string suitable for browser display, iframe embedding,
|
|
298
|
+
* or headless PDF generation. */
|
|
299
|
+
export function toHTML(doc) {
|
|
300
|
+
const style = resolveStyle(doc);
|
|
301
|
+
const template = doc.style?.template ?? '';
|
|
302
|
+
const styleParts = [BASE_CSS];
|
|
303
|
+
if (template && TEMPLATE_CSS[template]) {
|
|
304
|
+
styleParts.push(TEMPLATE_CSS[template]);
|
|
305
|
+
}
|
|
306
|
+
if (Object.keys(style.properties).length > 0) {
|
|
307
|
+
styleParts.push(buildContainerProperties(style.properties));
|
|
308
|
+
}
|
|
309
|
+
const titleType = doc.meta.documentType.replaceAll('_', ' ').toUpperCase();
|
|
310
|
+
const titleStr = `${titleType} ${doc.meta.number}`;
|
|
311
|
+
const blockHtmlParts = [];
|
|
312
|
+
for (const block of style.order) {
|
|
313
|
+
const blockStyle = style.blocks[block] ?? {};
|
|
314
|
+
if (block === 'header') {
|
|
315
|
+
blockHtmlParts.push(renderHeader(doc, blockStyle));
|
|
316
|
+
}
|
|
317
|
+
else if (block === 'from') {
|
|
318
|
+
if (doc.from)
|
|
319
|
+
blockHtmlParts.push(renderParty('from', doc.from, blockStyle));
|
|
320
|
+
}
|
|
321
|
+
else if (block === 'to') {
|
|
322
|
+
if (doc.to)
|
|
323
|
+
blockHtmlParts.push(renderParty('to', doc.to, blockStyle));
|
|
324
|
+
}
|
|
325
|
+
else if (block === 'items') {
|
|
326
|
+
blockHtmlParts.push(renderItems(doc, blockStyle));
|
|
327
|
+
}
|
|
328
|
+
else if (block === 'totals') {
|
|
329
|
+
if (doc.totals)
|
|
330
|
+
blockHtmlParts.push(renderTotals(doc.totals, doc.meta.currency, blockStyle));
|
|
331
|
+
}
|
|
332
|
+
else if (block === 'payment') {
|
|
333
|
+
if (doc.payment)
|
|
334
|
+
blockHtmlParts.push(renderPayment(doc.payment, blockStyle));
|
|
335
|
+
}
|
|
336
|
+
else if (block === 'notes') {
|
|
337
|
+
if (doc.notes)
|
|
338
|
+
blockHtmlParts.push(renderNotes(doc.notes, blockStyle));
|
|
339
|
+
}
|
|
340
|
+
else if (block.startsWith('section:')) {
|
|
341
|
+
const key = block.slice('section:'.length);
|
|
342
|
+
const section = doc.sections?.[key];
|
|
343
|
+
if (section)
|
|
344
|
+
blockHtmlParts.push(renderSection(key, section, blockStyle));
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
const containerAttrs = [
|
|
348
|
+
`class="invoml-container"`,
|
|
349
|
+
template ? `data-invoml-template="${esc(template)}"` : '',
|
|
350
|
+
].filter(Boolean).join(' ');
|
|
351
|
+
return `<!DOCTYPE html>
|
|
352
|
+
<html lang="en">
|
|
353
|
+
<head>
|
|
354
|
+
<meta charset="UTF-8">
|
|
355
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
356
|
+
<title>${esc(titleStr)}</title>
|
|
357
|
+
<style>${styleParts.join('\n')}</style>
|
|
358
|
+
</head>
|
|
359
|
+
<body>
|
|
360
|
+
<div ${containerAttrs}>
|
|
361
|
+
${blockHtmlParts.join('\n ')}
|
|
362
|
+
</div>
|
|
363
|
+
</body>
|
|
364
|
+
</html>`;
|
|
365
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export { getCurrencyDecimals } from './rounding.js';
|
|
2
|
+
export { parse } from './parser.js';
|
|
3
|
+
export type { ParseResult } from './parser.js';
|
|
4
|
+
export { validateSchema } from './schema.js';
|
|
5
|
+
export type { ValidationResult } from './schema.js';
|
|
6
|
+
export { calculate } from './calculator.js';
|
|
7
|
+
export { toJSON, toMarkdown } from './serializer.js';
|
|
8
|
+
export type { JSONOptions } from './serializer.js';
|
|
9
|
+
export { toHTML } from './html-renderer.js';
|
|
10
|
+
export { DEFAULT_ORDER, RESERVED_BLOCK_NAMES, validateStyle, resolveOrder, resolveStyle } from './style.js';
|
|
11
|
+
export type { StyleValidationResult } from './style.js';
|
|
12
|
+
export { CalculationError } from './types.js';
|
|
13
|
+
export type { InvoMLDocument, InvoMLTotals, InvoMLTaxDetail, InvoMLItem, InvoMLMeta, InvoMLDiscount, InvoMLParty, InvoMLPayment, InvoMLSection, InvoMLTaxSimple, InvoMLTaxFull, InvoMLTaxCategory, InvoMLStyle, } from './types.js';
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export { getCurrencyDecimals } from './rounding.js';
|
|
2
|
+
export { parse } from './parser.js';
|
|
3
|
+
export { validateSchema } from './schema.js';
|
|
4
|
+
export { calculate } from './calculator.js';
|
|
5
|
+
export { toJSON, toMarkdown } from './serializer.js';
|
|
6
|
+
export { toHTML } from './html-renderer.js';
|
|
7
|
+
export { DEFAULT_ORDER, RESERVED_BLOCK_NAMES, validateStyle, resolveOrder, resolveStyle } from './style.js';
|
|
8
|
+
export { CalculationError } from './types.js';
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/** Escape HTML special characters in plain text. */
|
|
2
|
+
export declare function escapeHtml(s: string): string;
|
|
3
|
+
/** Process inline markdown: bold, italic, underline, links.
|
|
4
|
+
* HTML is escaped first to prevent XSS — Markdown patterns are applied on safe text. */
|
|
5
|
+
export declare function processInline(text: string): string;
|
|
6
|
+
/** Process the supported markdown subset into HTML inline/block content.
|
|
7
|
+
* Supports: bold, italic, underline, links, unordered lists, ordered lists. */
|
|
8
|
+
export declare function processMarkdown(text: string): string;
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/** Escape HTML special characters in plain text. */
|
|
2
|
+
export function escapeHtml(s) {
|
|
3
|
+
return s
|
|
4
|
+
.replace(/&/g, '&')
|
|
5
|
+
.replace(/</g, '<')
|
|
6
|
+
.replace(/>/g, '>')
|
|
7
|
+
.replace(/"/g, '"')
|
|
8
|
+
.replace(/'/g, ''');
|
|
9
|
+
}
|
|
10
|
+
/** Process inline markdown: bold, italic, underline, links.
|
|
11
|
+
* HTML is escaped first to prevent XSS — Markdown patterns are applied on safe text. */
|
|
12
|
+
export function processInline(text) {
|
|
13
|
+
let result = escapeHtml(text);
|
|
14
|
+
// Links: only allow http, https, mailto schemes
|
|
15
|
+
result = result.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_match, linkText, url) => {
|
|
16
|
+
if (/^(https?:|mailto:)/i.test(url)) {
|
|
17
|
+
return `<a href="${escapeHtml(url)}">${linkText}</a>`;
|
|
18
|
+
}
|
|
19
|
+
return linkText;
|
|
20
|
+
});
|
|
21
|
+
// Bold **text**
|
|
22
|
+
result = result.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
|
|
23
|
+
// Italic *text*
|
|
24
|
+
result = result.replace(/\*([^*]+)\*/g, '<em>$1</em>');
|
|
25
|
+
// Underline __text__
|
|
26
|
+
result = result.replace(/__([^_]+)__/g, '<u>$1</u>');
|
|
27
|
+
return result;
|
|
28
|
+
}
|
|
29
|
+
/** Process the supported markdown subset into HTML inline/block content.
|
|
30
|
+
* Supports: bold, italic, underline, links, unordered lists, ordered lists. */
|
|
31
|
+
export function processMarkdown(text) {
|
|
32
|
+
const lines = text.split('\n');
|
|
33
|
+
const output = [];
|
|
34
|
+
let inUl = false;
|
|
35
|
+
let inOl = false;
|
|
36
|
+
function closeList() {
|
|
37
|
+
if (inUl) {
|
|
38
|
+
output.push('</ul>');
|
|
39
|
+
inUl = false;
|
|
40
|
+
}
|
|
41
|
+
if (inOl) {
|
|
42
|
+
output.push('</ol>');
|
|
43
|
+
inOl = false;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
for (let i = 0; i < lines.length; i++) {
|
|
47
|
+
const line = lines[i];
|
|
48
|
+
const ulMatch = /^[-*]\s+(.+)$/.exec(line);
|
|
49
|
+
const olMatch = /^\d+\.\s+(.+)$/.exec(line);
|
|
50
|
+
if (ulMatch) {
|
|
51
|
+
if (inOl) {
|
|
52
|
+
output.push('</ol>');
|
|
53
|
+
inOl = false;
|
|
54
|
+
}
|
|
55
|
+
if (!inUl) {
|
|
56
|
+
output.push('<ul>');
|
|
57
|
+
inUl = true;
|
|
58
|
+
}
|
|
59
|
+
output.push(`<li>${processInline(ulMatch[1])}</li>`);
|
|
60
|
+
}
|
|
61
|
+
else if (olMatch) {
|
|
62
|
+
if (inUl) {
|
|
63
|
+
output.push('</ul>');
|
|
64
|
+
inUl = false;
|
|
65
|
+
}
|
|
66
|
+
if (!inOl) {
|
|
67
|
+
output.push('<ol>');
|
|
68
|
+
inOl = true;
|
|
69
|
+
}
|
|
70
|
+
output.push(`<li>${processInline(olMatch[1])}</li>`);
|
|
71
|
+
}
|
|
72
|
+
else if (line.trim() === '') {
|
|
73
|
+
closeList();
|
|
74
|
+
if (i + 1 < lines.length && lines[i + 1].trim() !== '') {
|
|
75
|
+
output.push('<br>');
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
else {
|
|
79
|
+
closeList();
|
|
80
|
+
output.push(processInline(line));
|
|
81
|
+
if (i + 1 < lines.length && lines[i + 1].trim() !== '' &&
|
|
82
|
+
!/^[-*]\s/.test(lines[i + 1]) && !/^\d+\.\s/.test(lines[i + 1])) {
|
|
83
|
+
output.push('<br>');
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
closeList();
|
|
88
|
+
return output.join('\n');
|
|
89
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { InvoMLDocument } from './types.js';
|
|
2
|
+
export type ParseResult = {
|
|
3
|
+
success: true;
|
|
4
|
+
document: InvoMLDocument;
|
|
5
|
+
} | {
|
|
6
|
+
success: false;
|
|
7
|
+
errors: string[];
|
|
8
|
+
};
|
|
9
|
+
/** Parse a JSON string into a typed InvoML document. Validates against the JSON Schema before returning. */
|
|
10
|
+
export declare function parse(input: string): ParseResult;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { validateSchema } from './schema.js';
|
|
2
|
+
/** Parse a JSON string into a typed InvoML document. Validates against the JSON Schema before returning. */
|
|
3
|
+
export function parse(input) {
|
|
4
|
+
let doc;
|
|
5
|
+
try {
|
|
6
|
+
doc = JSON.parse(input);
|
|
7
|
+
}
|
|
8
|
+
catch (e) {
|
|
9
|
+
return { success: false, errors: [`Invalid JSON: ${e.message}`] };
|
|
10
|
+
}
|
|
11
|
+
const validation = validateSchema(doc);
|
|
12
|
+
if (!validation.valid) {
|
|
13
|
+
return { success: false, errors: validation.errors };
|
|
14
|
+
}
|
|
15
|
+
return { success: true, document: doc };
|
|
16
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import Decimal from 'decimal.js';
|
|
2
|
+
export declare const InternalDecimal: typeof Decimal;
|
|
3
|
+
/** Return the standard number of decimal places for a currency (ISO 4217 minor units). */
|
|
4
|
+
export declare function getCurrencyDecimals(currency: string): number;
|
|
5
|
+
export declare function roundHalfUp(value: number | Decimal, decimals?: number): number;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import Decimal from 'decimal.js';
|
|
2
|
+
// Library-private Decimal clone — avoids mutating the global Decimal config
|
|
3
|
+
// which would break consumers who also use decimal.js
|
|
4
|
+
export const InternalDecimal = Decimal.clone({ precision: 50, rounding: Decimal.ROUND_HALF_UP });
|
|
5
|
+
// ISO 4217 minor units — currencies that DON'T use 2 decimal places
|
|
6
|
+
const ZERO_DECIMAL = new Set([
|
|
7
|
+
'BIF', 'CLP', 'DJF', 'GNF', 'ISK', 'JPY', 'KMF', 'KRW',
|
|
8
|
+
'PYG', 'RWF', 'UGX', 'UYI', 'VND', 'VUV', 'XAF', 'XOF', 'XPF',
|
|
9
|
+
]);
|
|
10
|
+
const THREE_DECIMAL = new Set([
|
|
11
|
+
'BHD', 'IQD', 'JOD', 'KWD', 'LYD', 'OMR', 'TND',
|
|
12
|
+
]);
|
|
13
|
+
/** Return the standard number of decimal places for a currency (ISO 4217 minor units). */
|
|
14
|
+
export function getCurrencyDecimals(currency) {
|
|
15
|
+
if (ZERO_DECIMAL.has(currency))
|
|
16
|
+
return 0;
|
|
17
|
+
if (THREE_DECIMAL.has(currency))
|
|
18
|
+
return 3;
|
|
19
|
+
return 2;
|
|
20
|
+
}
|
|
21
|
+
export function roundHalfUp(value, decimals = 2) {
|
|
22
|
+
const d = new InternalDecimal(value.toString());
|
|
23
|
+
return d.toDecimalPlaces(decimals, InternalDecimal.ROUND_HALF_UP).toNumber();
|
|
24
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export interface ValidationResult {
|
|
2
|
+
valid: boolean;
|
|
3
|
+
errors: string[];
|
|
4
|
+
}
|
|
5
|
+
/** Validate an arbitrary value against the InvoML v1.0 JSON Schema. Useful for pre-validating AI output before calling `parse`. */
|
|
6
|
+
export declare function validateSchema(doc: unknown): ValidationResult;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import Ajv from 'ajv/dist/2020.js';
|
|
2
|
+
import addFormats from 'ajv-formats';
|
|
3
|
+
import { readFileSync, existsSync } from 'fs';
|
|
4
|
+
import { fileURLToPath } from 'url';
|
|
5
|
+
import { dirname, join } from 'path';
|
|
6
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
7
|
+
// dist/src/ needs ../../, src/ needs ../ — try dist path first, fall back to source
|
|
8
|
+
const distPath = join(__dirname, '..', '..', 'invoml-v1.0.schema.json');
|
|
9
|
+
const srcPath = join(__dirname, '..', 'invoml-v1.0.schema.json');
|
|
10
|
+
const schemaPath = existsSync(distPath) ? distPath : srcPath;
|
|
11
|
+
let ajvInstance = null;
|
|
12
|
+
let validateFn = null;
|
|
13
|
+
function getValidator() {
|
|
14
|
+
if (!validateFn) {
|
|
15
|
+
const schema = JSON.parse(readFileSync(schemaPath, 'utf8'));
|
|
16
|
+
ajvInstance = new Ajv({ allErrors: true, strict: false, validateFormats: true });
|
|
17
|
+
addFormats(ajvInstance);
|
|
18
|
+
validateFn = ajvInstance.compile(schema);
|
|
19
|
+
}
|
|
20
|
+
return validateFn;
|
|
21
|
+
}
|
|
22
|
+
/** Validate an arbitrary value against the InvoML v1.0 JSON Schema. Useful for pre-validating AI output before calling `parse`. */
|
|
23
|
+
export function validateSchema(doc) {
|
|
24
|
+
const validate = getValidator();
|
|
25
|
+
const valid = validate(doc);
|
|
26
|
+
if (valid)
|
|
27
|
+
return { valid: true, errors: [] };
|
|
28
|
+
const errors = (validate.errors ?? []).map(e => {
|
|
29
|
+
const path = e.instancePath || '/';
|
|
30
|
+
return `${path}: ${e.message}`;
|
|
31
|
+
});
|
|
32
|
+
return { valid: false, errors };
|
|
33
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { InvoMLDocument } from './types.js';
|
|
2
|
+
export interface JSONOptions {
|
|
3
|
+
compact?: boolean;
|
|
4
|
+
}
|
|
5
|
+
/** Serialize an InvoML document to a JSON string. Defaults to pretty-printed; pass `{ compact: true }` for minified output. */
|
|
6
|
+
export declare function toJSON(doc: InvoMLDocument, options?: JSONOptions): string;
|
|
7
|
+
/** Render an InvoML document (with computed totals in `doc.totals`) as a human-readable Markdown table. Follows the document's style.order or falls back to the default order. */
|
|
8
|
+
export declare function toMarkdown(doc: InvoMLDocument): string;
|