feat(invoices): M4 PDF rendering — pdf-lib renderer + preview + download

Adds client-side PDF generation via pdf-lib (Helvetica standard fonts,
~7KB output, no font bytes shipped).

Renderer (pdf/renderer.ts)
- renderInvoicePdf(invoice, settings) → Uint8Array
- renderInvoicePdfBlob(...) → Blob for iframe / download / email attach
- Layout sections: header (sender + meta), recipient, subject, lines
  table with wrapping + description row, totals with per-rate VAT
  breakdown, notes, terms, footer
- Pagination: lines table opens a continuation page if content would
  overflow into the QR-Bill reserved area; continuation pages redraw
  the table header

Template (pdf/templates/default.ts)
- A4, margins in mm, emerald accent matching app icon
- Reserves 105mm at page bottom for the Swiss QR-Bill (M5) so the
  body never collides with that region

DetailView integration
- Live PDF preview in an iframe — re-renders when invoice.updatedAt
  changes (mutations bump the timestamp)
- Blob URLs revoked on render / unmount to avoid memory leaks
- "PDF herunterladen" button produces a Rechnung-{number}.pdf download
- Structured-data view moved behind <details> so the PDF is the primary
  surface; raw data still accessible for debugging

pdf-lib dep added to @mana/web.

Plan: docs/plans/invoices-module.md §M4.
Next: M5 swissqrbill (Zahlteil in the reserved region).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-20 15:57:30 +02:00
parent 0077752456
commit 2dc298a796
5 changed files with 1012 additions and 280 deletions

View file

@ -28,7 +28,6 @@
"@tailwindcss/vite": "^4.1.7",
"@types/node": "^22.10.5",
"@vite-pwa/sveltekit": "^1.1.0",
"workbox-window": "^7.3.0",
"@vitest/coverage-v8": "^4.1.2",
"@vitest/ui": "^4.1.2",
"autoprefixer": "^10.4.20",
@ -44,7 +43,8 @@
"tslib": "^2.8.1",
"typescript": "^5.9.3",
"vite": "^6.0.7",
"vitest": "^4.1.2"
"vitest": "^4.1.2",
"workbox-window": "^7.3.0"
},
"dependencies": {
"@calc/shared": "workspace:*",
@ -76,13 +76,14 @@
"@mana/shared-utils": "workspace:*",
"@mana/spiral-db": "workspace:*",
"@mana/wallpaper-generator": "workspace:*",
"@quotes/content": "workspace:*",
"@types/pako": "^2.0.4",
"@types/suncalc": "^1.9.2",
"@quotes/content": "workspace:*",
"date-fns": "^4.1.0",
"dexie": "^4.0.11",
"marked": "^17.0.5",
"pako": "^2.1.0",
"pdf-lib": "^1.17.1",
"rrule": "^2.8.1",
"suncalc": "^1.9.0",
"svelte-dnd-action": "^0.9.68",

View file

@ -0,0 +1,549 @@
/**
* PDF renderer turns an invoice + sender settings into a PDF Uint8Array.
*
* Layout (top-down, single page for short invoices):
*
* MARGIN.top
* Sender block Meta block
*
* Rechnung an:
* Client name + address
*
* RECHNUNG {number}
* {subject}
*
* Position | Menge | Preis | MwSt | Total
* ...
*
* Total: CHF x.y
*
* Notizen
* Zahlungsbedingungen
*
* (footer)
* BODY_MIN_Y
* [reserved for QR-Bill M5]
*
*
* Pagination kicks in only if the lines table overflows. Overflow pages
* do not reserve QR space (only the page with the total does).
*
* Standard 14 PDF fonts only (Helvetica / Helvetica-Bold) keeps the
* output tiny (~7KB) and avoids shipping font bytes. Upgrade path: embed
* Inter/Roboto via fetch in M7 when we care about brand typography.
*/
import { PDFDocument, StandardFonts, rgb, type PDFPage, type PDFFont } from 'pdf-lib';
import type { Invoice, InvoiceSettings } from '../types';
import { CURRENCIES } from '../constants';
import {
A4,
MARGIN,
BODY_MIN_Y,
FONT_SIZE,
COLORS,
LINE_COLS,
SPACE,
LINE_HEIGHT,
} from './templates/default';
// ─── Small geometry helpers ────────────────────────────────
type Color = { r: number; g: number; b: number };
const toRgb = (c: Color) => rgb(c.r, c.g, c.b);
interface RenderContext {
doc: PDFDocument;
page: PDFPage;
regular: PDFFont;
bold: PDFFont;
/** Current Y cursor (drops as we render downward). */
y: number;
/** Right edge of the usable page area. */
rightX: number;
/** Left edge (same as MARGIN.left by default). */
leftX: number;
/** Content width (page - left - right). */
contentWidth: number;
}
function newPage(ctx: RenderContext, withQrReserve: boolean): RenderContext {
const page = ctx.doc.addPage([A4.width, A4.height]);
return {
...ctx,
page,
y: A4.height - MARGIN.top,
// Footer / QR reserve only applies to the first page — continuation
// pages can use the full body height.
// (The caller passes `withQrReserve=false` for continuation pages.)
...(withQrReserve ? {} : {}),
};
}
function textWidth(font: PDFFont, text: string, size: number): number {
return font.widthOfTextAtSize(text, size);
}
function drawText(
ctx: RenderContext,
text: string,
x: number,
y: number,
opts: { size?: number; font?: PDFFont; color?: Color } = {}
): void {
ctx.page.drawText(text, {
x,
y,
size: opts.size ?? FONT_SIZE.body,
font: opts.font ?? ctx.regular,
color: toRgb(opts.color ?? COLORS.text),
});
}
/**
* Word-wrap for a given max width. Returns the list of lines (caller draws).
* Tokenises on spaces + newlines; doesn't break mid-word (unusual for
* invoice text, acceptable trade-off for the MVP).
*/
function wrapLines(font: PDFFont, text: string, size: number, maxWidth: number): string[] {
const out: string[] = [];
for (const paragraph of text.split('\n')) {
const words = paragraph.split(/\s+/).filter(Boolean);
if (words.length === 0) {
out.push('');
continue;
}
let current = '';
for (const w of words) {
const probe = current ? `${current} ${w}` : w;
if (textWidth(font, probe, size) <= maxWidth) {
current = probe;
} else {
if (current) out.push(current);
current = w;
}
}
if (current) out.push(current);
}
return out;
}
function drawRightAligned(
ctx: RenderContext,
text: string,
rightX: number,
y: number,
opts: { size?: number; font?: PDFFont; color?: Color } = {}
): void {
const size = opts.size ?? FONT_SIZE.body;
const font = opts.font ?? ctx.regular;
const w = textWidth(font, text, size);
drawText(ctx, text, rightX - w, y, opts);
}
/**
* Draw horizontally-wrapped multi-line text starting at (x, y), dropping y
* as it goes. Returns the new y (below the block).
*/
function drawBlock(
ctx: RenderContext,
text: string,
x: number,
y: number,
opts: {
size?: number;
font?: PDFFont;
color?: Color;
maxWidth: number;
lineHeight?: number;
}
): number {
const size = opts.size ?? FONT_SIZE.body;
const font = opts.font ?? ctx.regular;
const lh = size * (opts.lineHeight ?? LINE_HEIGHT.normal);
const lines = wrapLines(font, text, size, opts.maxWidth);
let cursor = y;
for (const line of lines) {
drawText(ctx, line, x, cursor, { size, font, color: opts.color });
cursor -= lh;
}
return cursor;
}
function formatAmount(minor: number, currency: keyof typeof CURRENCIES): string {
const { symbol, minorUnit } = CURRENCIES[currency];
return `${symbol} ${(minor / minorUnit).toFixed(2)}`;
}
// ─── Section renderers ────────────────────────────────────
function renderHeader(ctx: RenderContext, invoice: Invoice, settings: InvoiceSettings): number {
// Left: sender
let leftY = ctx.y;
drawText(ctx, settings.senderName || '—', ctx.leftX, leftY, {
size: FONT_SIZE.brand,
font: ctx.bold,
});
leftY -= FONT_SIZE.brand * LINE_HEIGHT.tight;
const senderBody = [settings.senderAddress, settings.senderEmail, settings.senderIban]
.filter(Boolean)
.join('\n');
leftY = drawBlock(ctx, senderBody, ctx.leftX, leftY, {
size: FONT_SIZE.small,
color: COLORS.muted,
maxWidth: ctx.contentWidth * 0.55,
lineHeight: LINE_HEIGHT.normal,
});
// Right: invoice meta (number, issue date, due date)
let rightY = ctx.y;
drawRightAligned(ctx, 'RECHNUNG', ctx.rightX, rightY, {
size: FONT_SIZE.small,
font: ctx.bold,
color: COLORS.muted,
});
rightY -= FONT_SIZE.small * LINE_HEIGHT.tight;
drawRightAligned(ctx, invoice.number, ctx.rightX, rightY, {
size: FONT_SIZE.brand,
font: ctx.bold,
});
rightY -= FONT_SIZE.brand * LINE_HEIGHT.tight + SPACE.sm;
const metaRows: [string, string][] = [
['Datum', invoice.issueDate],
['Fällig am', invoice.dueDate],
];
if (settings.senderVatNumber) metaRows.push(['MwSt-Nr.', settings.senderVatNumber]);
for (const [label, value] of metaRows) {
drawRightAligned(ctx, `${label}: ${value}`, ctx.rightX, rightY, {
size: FONT_SIZE.small,
color: COLORS.muted,
});
rightY -= FONT_SIZE.small * LINE_HEIGHT.normal;
}
// Return the lower of the two Ys so the next section clears both columns.
return Math.min(leftY, rightY) - SPACE.lg;
}
function renderRecipient(ctx: RenderContext, invoice: Invoice, y: number): number {
drawText(ctx, 'Rechnung an', ctx.leftX, y, {
size: FONT_SIZE.small,
font: ctx.bold,
color: COLORS.muted,
});
y -= FONT_SIZE.small * LINE_HEIGHT.normal;
drawText(ctx, invoice.clientSnapshot.name, ctx.leftX, y, {
size: FONT_SIZE.body,
font: ctx.bold,
});
y -= FONT_SIZE.body * LINE_HEIGHT.tight;
if (invoice.clientSnapshot.address) {
y = drawBlock(ctx, invoice.clientSnapshot.address, ctx.leftX, y, {
maxWidth: ctx.contentWidth * 0.55,
});
}
if (invoice.clientSnapshot.vatNumber) {
drawText(ctx, `MwSt-Nr.: ${invoice.clientSnapshot.vatNumber}`, ctx.leftX, y, {
size: FONT_SIZE.small,
color: COLORS.muted,
});
y -= FONT_SIZE.small * LINE_HEIGHT.normal;
}
return y - SPACE.lg;
}
function renderSubject(ctx: RenderContext, invoice: Invoice, y: number): number {
if (!invoice.subject) return y;
drawText(ctx, invoice.subject, ctx.leftX, y, {
size: FONT_SIZE.h1,
font: ctx.bold,
});
return y - FONT_SIZE.h1 * LINE_HEIGHT.normal - SPACE.md;
}
/**
* Draws the invoice lines table. If it overflows, opens a continuation page
* and keeps going. Returns { ctx, y } the ctx may have swapped pages so
* later sections must use the returned one.
*/
function renderLinesTable(ctx: RenderContext, invoice: Invoice): { ctx: RenderContext; y: number } {
let cur = ctx;
let y = cur.y;
const cw = cur.contentWidth;
const colX = {
title: cur.leftX,
qty: cur.leftX + cw * LINE_COLS.title,
unit: cur.leftX + cw * (LINE_COLS.title + LINE_COLS.qty),
unitPrice: cur.leftX + cw * (LINE_COLS.title + LINE_COLS.qty + LINE_COLS.unit),
vat: cur.leftX + cw * (LINE_COLS.title + LINE_COLS.qty + LINE_COLS.unit + LINE_COLS.unitPrice),
total:
cur.leftX +
cw * (LINE_COLS.title + LINE_COLS.qty + LINE_COLS.unit + LINE_COLS.unitPrice + LINE_COLS.vat),
};
const rightEdge = cur.rightX;
const drawHeaderRow = () => {
drawText(cur, 'Position', colX.title, y, {
size: FONT_SIZE.tableHeader,
font: cur.bold,
color: COLORS.muted,
});
drawText(cur, 'Menge', colX.qty, y, {
size: FONT_SIZE.tableHeader,
font: cur.bold,
color: COLORS.muted,
});
drawText(cur, 'Einheit', colX.unit, y, {
size: FONT_SIZE.tableHeader,
font: cur.bold,
color: COLORS.muted,
});
drawRightAligned(cur, 'Einzelpreis', colX.vat - SPACE.sm, y, {
size: FONT_SIZE.tableHeader,
font: cur.bold,
color: COLORS.muted,
});
drawText(cur, 'MwSt.', colX.vat, y, {
size: FONT_SIZE.tableHeader,
font: cur.bold,
color: COLORS.muted,
});
drawRightAligned(cur, 'Total', rightEdge, y, {
size: FONT_SIZE.tableHeader,
font: cur.bold,
color: COLORS.muted,
});
y -= FONT_SIZE.tableHeader * LINE_HEIGHT.normal;
cur.page.drawLine({
start: { x: cur.leftX, y: y + SPACE.xs },
end: { x: rightEdge, y: y + SPACE.xs },
thickness: 0.5,
color: toRgb(COLORS.border),
});
y -= SPACE.sm;
};
drawHeaderRow();
for (const line of invoice.lines) {
// Wrap the title so long descriptions don't run into the qty column.
const titleMax = cw * LINE_COLS.title - SPACE.sm;
const titleLines = wrapLines(cur.regular, line.title || '—', FONT_SIZE.tableCell, titleMax);
const descriptionLines = line.description
? wrapLines(cur.regular, line.description, FONT_SIZE.small, titleMax)
: [];
const rowHeight =
Math.max(
titleLines.length * FONT_SIZE.tableCell * LINE_HEIGHT.tight +
descriptionLines.length * FONT_SIZE.small * LINE_HEIGHT.tight,
FONT_SIZE.tableCell * LINE_HEIGHT.normal
) + SPACE.sm;
// Paginate: if this row would cross into the reserved footer, open
// a new page and redraw the header there.
if (y - rowHeight < BODY_MIN_Y) {
cur = newPage(cur, false);
y = cur.y;
drawHeaderRow();
}
// Title + optional description, wrapped
let titleY = y;
for (const tl of titleLines) {
drawText(cur, tl, colX.title, titleY, { size: FONT_SIZE.tableCell });
titleY -= FONT_SIZE.tableCell * LINE_HEIGHT.tight;
}
for (const dl of descriptionLines) {
drawText(cur, dl, colX.title, titleY, {
size: FONT_SIZE.small,
color: COLORS.muted,
});
titleY -= FONT_SIZE.small * LINE_HEIGHT.tight;
}
// Single-line fields (qty / unit / price / vat / total)
const qtyText = String(line.quantity);
drawText(cur, qtyText, colX.qty, y, { size: FONT_SIZE.tableCell });
if (line.unit) {
drawText(cur, line.unit, colX.unit, y, {
size: FONT_SIZE.tableCell,
color: COLORS.muted,
});
}
drawRightAligned(cur, formatAmount(line.unitPrice, invoice.currency), colX.vat - SPACE.sm, y, {
size: FONT_SIZE.tableCell,
});
drawText(cur, `${line.vatRate}%`, colX.vat, y, {
size: FONT_SIZE.tableCell,
color: COLORS.muted,
});
const rowTotal = line.quantity * line.unitPrice * (1 - (line.discount ?? 0) / 100);
drawRightAligned(cur, formatAmount(rowTotal, invoice.currency), rightEdge, y, {
size: FONT_SIZE.tableCell,
});
y -= rowHeight;
}
// Separator before totals
cur.page.drawLine({
start: { x: cur.leftX, y },
end: { x: rightEdge, y },
thickness: 0.5,
color: toRgb(COLORS.border),
});
return { ctx: cur, y: y - SPACE.md };
}
function renderTotals(ctx: RenderContext, invoice: Invoice, y: number): number {
const labelX = ctx.rightX - 180;
const rows: [string, string, boolean][] = [];
rows.push(['Netto', formatAmount(invoice.totals.net, invoice.currency), false]);
for (const b of invoice.totals.vatBreakdown) {
rows.push([`MwSt. ${b.rate}%`, formatAmount(b.tax, invoice.currency), false]);
}
rows.push(['Total', formatAmount(invoice.totals.gross, invoice.currency), true]);
for (const [label, value, gross] of rows) {
const size = gross ? FONT_SIZE.h2 : FONT_SIZE.body;
const font = gross ? ctx.bold : ctx.regular;
drawText(ctx, label, labelX, y, { size, font });
drawRightAligned(ctx, value, ctx.rightX, y, { size, font });
y -= size * LINE_HEIGHT.normal;
if (gross) break;
}
// Underline the gross row
ctx.page.drawLine({
start: { x: labelX, y: y + SPACE.xs },
end: { x: ctx.rightX, y: y + SPACE.xs },
thickness: 1,
color: toRgb(COLORS.accent),
});
return y - SPACE.lg;
}
function renderNotesAndTerms(ctx: RenderContext, invoice: Invoice, y: number): number {
if (invoice.notes) {
drawText(ctx, 'Notizen', ctx.leftX, y, {
size: FONT_SIZE.small,
font: ctx.bold,
color: COLORS.muted,
});
y -= FONT_SIZE.small * LINE_HEIGHT.normal;
y = drawBlock(ctx, invoice.notes, ctx.leftX, y, {
size: FONT_SIZE.body,
maxWidth: ctx.contentWidth,
});
y -= SPACE.md;
}
if (invoice.terms) {
drawText(ctx, 'Zahlungsbedingungen', ctx.leftX, y, {
size: FONT_SIZE.small,
font: ctx.bold,
color: COLORS.muted,
});
y -= FONT_SIZE.small * LINE_HEIGHT.normal;
y = drawBlock(ctx, invoice.terms, ctx.leftX, y, {
size: FONT_SIZE.body,
maxWidth: ctx.contentWidth,
});
}
return y;
}
function renderFooter(ctx: RenderContext, settings: InvoiceSettings): void {
if (!settings.footer) return;
const y = MARGIN.bottom + ctx.contentWidth * 0; // just above the margin
drawBlock(ctx, settings.footer, ctx.leftX, MARGIN.bottom + FONT_SIZE.small * 2.5, {
size: FONT_SIZE.small,
color: COLORS.muted,
maxWidth: ctx.contentWidth,
});
// Hairline rule above footer
ctx.page.drawLine({
start: { x: ctx.leftX, y: MARGIN.bottom + FONT_SIZE.small * 3 },
end: { x: ctx.rightX, y: MARGIN.bottom + FONT_SIZE.small * 3 },
thickness: 0.25,
color: toRgb(COLORS.border),
});
void y;
}
// ─── Public API ───────────────────────────────────────────
/**
* Render an invoice to PDF bytes. Call-site is responsible for wrapping the
* output into a Blob, iframe URL, or File attachment.
*/
export async function renderInvoicePdf(
invoice: Invoice,
settings: InvoiceSettings
): Promise<Uint8Array> {
const doc = await PDFDocument.create();
doc.setTitle(`Rechnung ${invoice.number}`);
doc.setAuthor(settings.senderName || 'Mana');
doc.setCreator('Mana — Rechnungen');
doc.setProducer('pdf-lib');
doc.setCreationDate(new Date());
const [regular, bold] = await Promise.all([
doc.embedFont(StandardFonts.Helvetica),
doc.embedFont(StandardFonts.HelveticaBold),
]);
const page = doc.addPage([A4.width, A4.height]);
const leftX = MARGIN.left;
const rightX = A4.width - MARGIN.right;
let ctx: RenderContext = {
doc,
page,
regular,
bold,
y: A4.height - MARGIN.top,
leftX,
rightX,
contentWidth: rightX - leftX,
};
ctx.y = renderHeader(ctx, invoice, settings);
ctx.y = renderRecipient(ctx, invoice, ctx.y);
ctx.y = renderSubject(ctx, invoice, ctx.y);
const linesResult = renderLinesTable(ctx, invoice);
ctx = linesResult.ctx;
ctx.y = linesResult.y;
ctx.y = renderTotals(ctx, invoice, ctx.y);
ctx.y = renderNotesAndTerms(ctx, invoice, ctx.y);
// Footer only draws on the *first* page — it contains the sender's
// legal line and a separator. Later we'll add page numbers for the
// multi-page case.
const firstPage = doc.getPage(0);
const firstCtx: RenderContext = { ...ctx, page: firstPage };
renderFooter(firstCtx, settings);
return await doc.save();
}
/**
* Convenience: render + wrap into a Blob, ready for object URL / download /
* email attachment. The MIME type is `application/pdf`.
*/
export async function renderInvoicePdfBlob(
invoice: Invoice,
settings: InvoiceSettings
): Promise<Blob> {
const bytes = await renderInvoicePdf(invoice, settings);
// `.slice(0)` copies into a fresh ArrayBuffer-backed Uint8Array so the
// Blob constructor types match (SharedArrayBuffer is not a BlobPart).
return new Blob([bytes.slice(0) as BlobPart], { type: 'application/pdf' });
}

View file

@ -0,0 +1,90 @@
/**
* Default PDF layout constants A4, margins chosen so a Swiss QR-Bill
* (M5) will fit in the bottom ~105mm without further layout changes.
*
* pdf-lib coordinates: origin is BOTTOM-LEFT, Y increases upward. All
* "top-of-page" anchors therefore compute `pageHeight - offset`.
* Units are PDF points (1pt = 1/72 inch 0.353mm).
*/
// ─── Page & units ─────────────────────────────────────────
/** A4 in points (210 × 297 mm). */
export const A4 = { width: 595.28, height: 841.89 } as const;
export const MM_TO_PT = 72 / 25.4;
export function mm(millimetres: number): number {
return millimetres * MM_TO_PT;
}
// ─── Margins ─────────────────────────────────────────────
export const MARGIN = {
top: mm(20),
right: mm(20),
bottom: mm(20),
left: mm(20),
} as const;
/**
* Reserved vertical space at the bottom of the invoice page for the QR-Bill
* (M5). Covers the whole Zahlteil + Empfangsschein strip including the
* perforation indicator. The renderer must never draw into this region.
* If the invoice body overflows, it paginates to a continuation page.
*/
export const QR_BILL_RESERVED = mm(105);
/** Minimum Y below which the first-page body must not descend. */
export const BODY_MIN_Y = MARGIN.bottom + QR_BILL_RESERVED;
// ─── Typography ───────────────────────────────────────────
export const FONT_SIZE = {
brand: 14,
h1: 18,
h2: 11,
body: 10,
small: 8.5,
tableHeader: 9,
tableCell: 9.5,
} as const;
export const LINE_HEIGHT = {
tight: 1.25,
normal: 1.4,
loose: 1.6,
} as const;
// ─── Colors (RGB 0..1 for pdf-lib) ────────────────────────
export const COLORS = {
text: { r: 0.06, g: 0.09, b: 0.16 }, // slate-900
muted: { r: 0.39, g: 0.45, b: 0.55 }, // slate-500
border: { r: 0.89, g: 0.91, b: 0.94 }, // slate-200
accent: { r: 0.02, g: 0.59, b: 0.41 }, // emerald-600 — matches app icon
danger: { r: 0.73, g: 0.11, b: 0.11 },
surfaceMuted: { r: 0.97, g: 0.98, b: 0.99 }, // slate-50
} as const;
// ─── Lines table geometry ─────────────────────────────────
// Column widths are fractions of the content width; sum must equal 1.
export const LINE_COLS = {
title: 0.46,
qty: 0.09,
unit: 0.08,
unitPrice: 0.14,
vat: 0.08,
total: 0.15,
} as const;
// ─── Spacing scale ────────────────────────────────────────
export const SPACE = {
xs: mm(1),
sm: mm(2),
md: mm(4),
lg: mm(8),
xl: mm(12),
} as const;

View file

@ -6,8 +6,10 @@
import { goto } from '$app/navigation';
import StatusBadge from '../components/StatusBadge.svelte';
import { invoicesStore } from '../stores/invoices.svelte';
import { invoiceSettingsStore } from '../stores/settings.svelte';
import { renderInvoicePdfBlob } from '../pdf/renderer';
import { formatAmount } from '../queries';
import type { Invoice } from '../types';
import type { Invoice, InvoiceSettings } from '../types';
import { STATUS_LABELS } from '../constants';
interface Props {
@ -19,6 +21,62 @@
let actionError = $state<string | null>(null);
let busy = $state(false);
// ─── PDF preview ─────────────────────────────────────────
// Render lazily on mount, whenever the invoice content changes, or after
// a mutation (status transitions don't re-render the PDF body but do
// change the output — e.g. paid watermark in M4+). Revoke the previous
// blob URL before creating a new one so we don't leak memory.
let pdfUrl = $state<string | null>(null);
let pdfError = $state<string | null>(null);
let renderingPdf = $state(false);
async function renderPdf() {
renderingPdf = true;
pdfError = null;
try {
const settings: InvoiceSettings = await invoiceSettingsStore.get();
const blob = await renderInvoicePdfBlob(invoice, settings);
if (pdfUrl) URL.revokeObjectURL(pdfUrl);
pdfUrl = URL.createObjectURL(blob);
} catch (e) {
pdfError = e instanceof Error ? e.message : 'PDF-Rendering fehlgeschlagen';
} finally {
renderingPdf = false;
}
}
// Re-render when invoice id or updatedAt changes (mutations bump updatedAt).
$effect(() => {
void invoice.id;
void invoice.updatedAt;
renderPdf();
});
// Blob URLs leak memory until revoked. Clean up on unmount.
$effect(() => {
return () => {
if (pdfUrl) URL.revokeObjectURL(pdfUrl);
};
});
async function downloadPdf() {
try {
const settings: InvoiceSettings = await invoiceSettingsStore.get();
const blob = await renderInvoicePdfBlob(invoice, settings);
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `Rechnung-${invoice.number}.pdf`;
document.body.appendChild(a);
a.click();
a.remove();
// Revoke after the browser has started the download.
setTimeout(() => URL.revokeObjectURL(url), 1000);
} catch (e) {
pdfError = e instanceof Error ? e.message : 'Download fehlgeschlagen';
}
}
async function run(label: string, fn: () => Promise<void>) {
actionError = null;
busy = true;
@ -91,6 +149,7 @@
Als bezahlt markieren
</button>
{/if}
<button class="btn" onclick={downloadPdf}>PDF herunterladen</button>
<button class="btn" onclick={onDuplicate} disabled={busy}>Duplizieren</button>
{#if invoice.status !== 'paid' && invoice.status !== 'void'}
<button class="btn btn-danger" onclick={onVoid} disabled={busy}> Stornieren </button>
@ -104,80 +163,103 @@
<div class="error">{actionError}</div>
{/if}
<section class="block">
<h3>Empfänger</h3>
<div class="client">
<div class="client-name">{invoice.clientSnapshot.name}</div>
{#if invoice.clientSnapshot.address}
<pre class="client-address">{invoice.clientSnapshot.address}</pre>
{/if}
{#if invoice.clientSnapshot.email}
<div class="client-meta">{invoice.clientSnapshot.email}</div>
{/if}
{#if invoice.clientSnapshot.vatNumber}
<div class="client-meta">MwSt-Nr.: {invoice.clientSnapshot.vatNumber}</div>
<section class="block pdf-preview-block">
<div class="preview-head">
<h3>Vorschau</h3>
{#if renderingPdf}
<span class="preview-status">Rendert …</span>
{/if}
</div>
{#if pdfError}
<div class="error">PDF-Fehler: {pdfError}</div>
{:else if pdfUrl}
<iframe
class="pdf-frame"
src={pdfUrl}
title="Vorschau Rechnung {invoice.number}"
loading="lazy"
></iframe>
{/if}
</section>
<section class="block">
<h3>Positionen</h3>
<table class="lines">
<thead>
<tr>
<th>Position</th>
<th>Menge</th>
<th>Einzelpreis</th>
<th>MwSt.</th>
<th class="right">Netto</th>
</tr>
</thead>
<tbody>
{#each invoice.lines as line (line.id)}
<details class="raw-details">
<summary>Strukturierte Daten anzeigen</summary>
<section class="block">
<h3>Empfänger</h3>
<div class="client">
<div class="client-name">{invoice.clientSnapshot.name}</div>
{#if invoice.clientSnapshot.address}
<pre class="client-address">{invoice.clientSnapshot.address}</pre>
{/if}
{#if invoice.clientSnapshot.email}
<div class="client-meta">{invoice.clientSnapshot.email}</div>
{/if}
{#if invoice.clientSnapshot.vatNumber}
<div class="client-meta">MwSt-Nr.: {invoice.clientSnapshot.vatNumber}</div>
{/if}
</div>
</section>
<section class="block">
<h3>Positionen</h3>
<table class="lines">
<thead>
<tr>
<td>
<div>{line.title}</div>
{#if line.description}<div class="muted">{line.description}</div>{/if}
</td>
<td>{line.quantity}{line.unit ? ` ${line.unit}` : ''}</td>
<td>{formatAmount(line.unitPrice, invoice.currency)}</td>
<td>{line.vatRate}%</td>
<td class="right">
{formatAmount(line.quantity * line.unitPrice, invoice.currency)}
</td>
<th>Position</th>
<th>Menge</th>
<th>Einzelpreis</th>
<th>MwSt.</th>
<th class="right">Netto</th>
</tr>
</thead>
<tbody>
{#each invoice.lines as line (line.id)}
<tr>
<td>
<div>{line.title}</div>
{#if line.description}<div class="muted">{line.description}</div>{/if}
</td>
<td>{line.quantity}{line.unit ? ` ${line.unit}` : ''}</td>
<td>{formatAmount(line.unitPrice, invoice.currency)}</td>
<td>{line.vatRate}%</td>
<td class="right">
{formatAmount(line.quantity * line.unitPrice, invoice.currency)}
</td>
</tr>
{/each}
</tbody>
</table>
</section>
<section class="block totals-block">
<h3>Summe</h3>
<dl class="totals">
<dt>Netto</dt>
<dd>{formatAmount(invoice.totals.net, invoice.currency)}</dd>
{#each invoice.totals.vatBreakdown as b (b.rate)}
<dt>MwSt. {b.rate}%</dt>
<dd>{formatAmount(b.tax, invoice.currency)}</dd>
{/each}
</tbody>
</table>
</section>
<section class="block totals-block">
<h3>Summe</h3>
<dl class="totals">
<dt>Netto</dt>
<dd>{formatAmount(invoice.totals.net, invoice.currency)}</dd>
{#each invoice.totals.vatBreakdown as b (b.rate)}
<dt>MwSt. {b.rate}%</dt>
<dd>{formatAmount(b.tax, invoice.currency)}</dd>
{/each}
<dt class="gross">Total</dt>
<dd class="gross">{formatAmount(invoice.totals.gross, invoice.currency)}</dd>
</dl>
</section>
{#if invoice.notes}
<section class="block">
<h3>Notizen</h3>
<p class="prose">{invoice.notes}</p>
<dt class="gross">Total</dt>
<dd class="gross">{formatAmount(invoice.totals.gross, invoice.currency)}</dd>
</dl>
</section>
{/if}
{#if invoice.terms}
<section class="block">
<h3>Zahlungsbedingungen</h3>
<p class="prose">{invoice.terms}</p>
</section>
{/if}
{#if invoice.notes}
<section class="block">
<h3>Notizen</h3>
<p class="prose">{invoice.notes}</p>
</section>
{/if}
{#if invoice.terms}
<section class="block">
<h3>Zahlungsbedingungen</h3>
<p class="prose">{invoice.terms}</p>
</section>
{/if}
</details>
<footer class="meta">
<div>Status: {STATUS_LABELS[invoice.status].de}</div>
@ -269,6 +351,48 @@
border-color: #fecaca;
}
.pdf-preview-block {
gap: 0.5rem;
}
.preview-head {
display: flex;
justify-content: space-between;
align-items: baseline;
}
.preview-status {
font-size: 0.8rem;
color: var(--color-text-muted, #64748b);
}
.pdf-frame {
width: 100%;
height: 860px;
border: 1px solid var(--color-border, #e2e8f0);
border-radius: 0.4rem;
background: var(--color-surface-muted, #f8fafc);
}
.raw-details {
border: 1px solid var(--color-border, #e2e8f0);
border-radius: 0.5rem;
padding: 0.75rem 1rem;
}
.raw-details summary {
cursor: pointer;
font-size: 0.85rem;
color: var(--color-text-muted, #64748b);
user-select: none;
}
.raw-details[open] summary {
margin-bottom: 1rem;
padding-bottom: 0.5rem;
border-bottom: 1px solid var(--color-border, #e2e8f0);
}
.error {
background: #fef2f2;
border: 1px solid #fecaca;

390
pnpm-lock.yaml generated
View file

@ -138,14 +138,14 @@ importers:
version: link:../../../../packages/shared-landing-ui
astro:
specifier: ^5.16.0
version: 5.18.1(@azure/storage-blob@12.31.0)(@types/node@20.19.39)(ioredis@5.10.1)(jiti@1.21.7)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3)
version: 5.18.1(@azure/storage-blob@12.31.0)(@types/node@20.19.39)(ioredis@5.10.1)(jiti@2.6.1)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3)
typescript:
specifier: ^5.9.2
version: 5.9.3
devDependencies:
'@astrojs/tailwind':
specifier: ^6.0.2
version: 6.0.2(astro@5.18.1(@azure/storage-blob@12.31.0)(@types/node@20.19.39)(ioredis@5.10.1)(jiti@1.21.7)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3))(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.3))
version: 6.0.2(astro@5.18.1(@azure/storage-blob@12.31.0)(@types/node@20.19.39)(ioredis@5.10.1)(jiti@2.6.1)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3))(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.3))
'@tailwindcss/typography':
specifier: ^0.5.18
version: 0.5.19(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.3))
@ -154,13 +154,13 @@ importers:
version: 20.19.39
eslint:
specifier: ^9.0.0
version: 9.39.4(jiti@1.21.7)
version: 9.39.4(jiti@2.6.1)
eslint-config-prettier:
specifier: ^9.1.0
version: 9.1.2(eslint@9.39.4(jiti@1.21.7))
version: 9.1.2(eslint@9.39.4(jiti@2.6.1))
eslint-plugin-astro:
specifier: ^1.0.0
version: 1.6.0(eslint@9.39.4(jiti@1.21.7))
version: 1.6.0(eslint@9.39.4(jiti@2.6.1))
prettier:
specifier: ^3.6.2
version: 3.8.1
@ -253,10 +253,10 @@ importers:
version: 3.7.2
'@astrojs/tailwind':
specifier: ^6.0.0
version: 6.0.2(astro@5.18.1(@azure/storage-blob@12.31.0)(@types/node@24.12.2)(ioredis@5.10.1)(jiti@2.6.1)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3))(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.3))
version: 6.0.2(astro@5.18.1(@azure/storage-blob@12.31.0)(@types/node@24.12.2)(ioredis@5.10.1)(jiti@1.21.7)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3))(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.3))
astro:
specifier: ^5.16.11
version: 5.18.1(@azure/storage-blob@12.31.0)(@types/node@24.12.2)(ioredis@5.10.1)(jiti@2.6.1)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3)
version: 5.18.1(@azure/storage-blob@12.31.0)(@types/node@24.12.2)(ioredis@5.10.1)(jiti@1.21.7)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3)
tailwindcss:
specifier: ^3.4.17
version: 3.4.19(tsx@4.21.0)(yaml@2.8.3)
@ -594,6 +594,9 @@ importers:
pako:
specifier: ^2.1.0
version: 2.1.0
pdf-lib:
specifier: ^1.17.1
version: 1.17.1
rrule:
specifier: ^2.8.1
version: 2.8.1
@ -6378,6 +6381,12 @@ packages:
cpu: [x64]
os: [win32]
'@pdf-lib/standard-fonts@1.0.0':
resolution: {integrity: sha512-hU30BK9IUN/su0Mn9VdlVKsWBS6GyhVfqjwl1FjZN4TxP6cCw0jP2w7V3Hf5uX7M0AZJ16vey9yE0ny7Sa59ZA==}
'@pdf-lib/upng@1.0.1':
resolution: {integrity: sha512-dQK2FUMQtowVP00mtIksrlZhdFXQZPC+taih1q4CvPZ5vqdxR/LKBaFg0oAfzd1GlHZXXSPdQfzQnt+ViGvEIQ==}
'@petamoriken/float16@3.9.3':
resolution: {integrity: sha512-8awtpHXCx/bNpFt4mt2xdkgtgVvKqty8VbjHI/WWWQuEw+KLzFot3f4+LkQY9YmOtq7A5GdOnqoIC8Pdygjk2g==}
@ -13403,6 +13412,9 @@ packages:
resolution: {integrity: sha512-7vQ2xh0ZmjPjsuWONR68nqzb+QNfpPh7pdT6n6YDAthWAQiUkSACVegSswY5zPNONGYFWebFVgdnS5/m/Qqn+w==}
hasBin: true
pako@1.0.11:
resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==}
pako@2.1.0:
resolution: {integrity: sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==}
@ -13503,6 +13515,9 @@ packages:
pathe@2.0.3:
resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
pdf-lib@1.17.1:
resolution: {integrity: sha512-V/mpyJAoTsN4cnP31vc0wfNA1+p20evqqnap0KLoRUN0Yk/p3wN52DOEsL4oBFcLdb76hlpKPtzJIgo67j/XLw==}
pend@1.2.0:
resolution: {integrity: sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==}
@ -15301,6 +15316,9 @@ packages:
resolution: {integrity: sha512-NoZ4roiN7LnbKn9QqE1amc9DJfzvZXxF4xDavcOWt1BPkdx+m+0gJuPM+S0vCe7zTJMYUP0R8pO2XMr+Y8oLIg==}
engines: {node: '>=6'}
tslib@1.14.1:
resolution: {integrity: sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==}
tslib@2.8.1:
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
@ -16668,16 +16686,6 @@ snapshots:
transitivePeerDependencies:
- ts-node
'@astrojs/tailwind@6.0.2(astro@5.18.1(@azure/storage-blob@12.31.0)(@types/node@20.19.39)(ioredis@5.10.1)(jiti@1.21.7)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3))(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.3))':
dependencies:
astro: 5.18.1(@azure/storage-blob@12.31.0)(@types/node@20.19.39)(ioredis@5.10.1)(jiti@1.21.7)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3)
autoprefixer: 10.4.27(postcss@8.5.8)
postcss: 8.5.8
postcss-load-config: 4.0.2(postcss@8.5.8)
tailwindcss: 3.4.19(tsx@4.21.0)(yaml@2.8.3)
transitivePeerDependencies:
- ts-node
'@astrojs/tailwind@6.0.2(astro@5.18.1(@azure/storage-blob@12.31.0)(@types/node@20.19.39)(ioredis@5.10.1)(jiti@2.6.1)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3))(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.3))':
dependencies:
astro: 5.18.1(@azure/storage-blob@12.31.0)(@types/node@20.19.39)(ioredis@5.10.1)(jiti@2.6.1)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3)
@ -16698,6 +16706,16 @@ snapshots:
transitivePeerDependencies:
- ts-node
'@astrojs/tailwind@6.0.2(astro@5.18.1(@azure/storage-blob@12.31.0)(@types/node@24.12.2)(ioredis@5.10.1)(jiti@1.21.7)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3))(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.3))':
dependencies:
astro: 5.18.1(@azure/storage-blob@12.31.0)(@types/node@24.12.2)(ioredis@5.10.1)(jiti@1.21.7)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3)
autoprefixer: 10.4.27(postcss@8.5.8)
postcss: 8.5.8
postcss-load-config: 4.0.2(postcss@8.5.8)
tailwindcss: 3.4.19(tsx@4.21.0)(yaml@2.8.3)
transitivePeerDependencies:
- ts-node
'@astrojs/tailwind@6.0.2(astro@5.18.1(@azure/storage-blob@12.31.0)(@types/node@24.12.2)(ioredis@5.10.1)(jiti@2.6.1)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3))(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.3))':
dependencies:
astro: 5.18.1(@azure/storage-blob@12.31.0)(@types/node@24.12.2)(ioredis@5.10.1)(jiti@2.6.1)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3)
@ -18857,11 +18875,6 @@ snapshots:
'@esbuild/win32-x64@0.27.7':
optional: true
'@eslint-community/eslint-utils@4.9.1(eslint@9.39.4(jiti@1.21.7))':
dependencies:
eslint: 9.39.4(jiti@1.21.7)
eslint-visitor-keys: 3.4.3
'@eslint-community/eslint-utils@4.9.1(eslint@9.39.4(jiti@2.6.1))':
dependencies:
eslint: 9.39.4(jiti@2.6.1)
@ -20581,6 +20594,14 @@ snapshots:
'@pagefind/windows-x64@1.5.0':
optional: true
'@pdf-lib/standard-fonts@1.0.0':
dependencies:
pako: 1.0.11
'@pdf-lib/upng@1.0.1':
dependencies:
pako: 1.0.11
'@petamoriken/float16@3.9.3': {}
'@pixi/colord@2.9.6': {}
@ -23135,7 +23156,7 @@ snapshots:
obug: 2.1.1
std-env: 4.0.0
tinyrainbow: 3.1.0
vitest: 4.1.3(@opentelemetry/api@1.9.1)(@types/node@24.12.2)(@vitest/coverage-v8@4.1.3)(@vitest/ui@4.1.3)(jsdom@29.0.2(@noble/hashes@2.0.1))(vite@6.4.2(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
vitest: 4.1.3(@opentelemetry/api@1.9.1)(@types/node@22.19.17)(@vitest/coverage-v8@4.1.3)(@vitest/ui@4.1.3)(jsdom@29.0.2(@noble/hashes@2.0.1))(vite@6.4.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
'@vitest/expect@4.1.3':
dependencies:
@ -23197,7 +23218,7 @@ snapshots:
sirv: 3.0.2
tinyglobby: 0.2.15
tinyrainbow: 3.1.0
vitest: 4.1.3(@opentelemetry/api@1.9.1)(@types/node@24.12.2)(@vitest/coverage-v8@4.1.3)(@vitest/ui@4.1.3)(jsdom@29.0.2(@noble/hashes@2.0.1))(vite@6.4.2(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
vitest: 4.1.3(@opentelemetry/api@1.9.1)(@types/node@22.19.17)(@vitest/coverage-v8@4.1.3)(@vitest/ui@4.1.3)(jsdom@29.0.2(@noble/hashes@2.0.1))(vite@6.4.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
'@vitest/utils@4.1.3':
dependencies:
@ -23628,108 +23649,6 @@ snapshots:
transitivePeerDependencies:
- supports-color
astro@5.18.1(@azure/storage-blob@12.31.0)(@types/node@20.19.39)(ioredis@5.10.1)(jiti@1.21.7)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3):
dependencies:
'@astrojs/compiler': 2.13.1
'@astrojs/internal-helpers': 0.7.6
'@astrojs/markdown-remark': 6.3.11
'@astrojs/telemetry': 3.3.0
'@capsizecss/unpack': 4.0.0
'@oslojs/encoding': 1.1.0
'@rollup/pluginutils': 5.3.0(rollup@4.60.1)
acorn: 8.16.0
aria-query: 5.3.2
axobject-query: 4.1.0
boxen: 8.0.1
ci-info: 4.4.0
clsx: 2.1.1
common-ancestor-path: 1.0.1
cookie: 1.1.1
cssesc: 3.0.0
debug: 4.4.3
deterministic-object-hash: 2.0.2
devalue: 5.7.0
diff: 8.0.4
dlv: 1.1.3
dset: 3.1.4
es-module-lexer: 1.7.0
esbuild: 0.27.7
estree-walker: 3.0.3
flattie: 1.1.1
fontace: 0.4.1
github-slugger: 2.0.0
html-escaper: 3.0.3
http-cache-semantics: 4.2.0
import-meta-resolve: 4.2.0
js-yaml: 4.1.1
magic-string: 0.30.21
magicast: 0.5.2
mrmime: 2.0.1
neotraverse: 0.6.18
p-limit: 6.2.0
p-queue: 8.1.1
package-manager-detector: 1.6.0
piccolore: 0.1.3
picomatch: 4.0.4
prompts: 2.4.2
rehype: 13.0.2
semver: 7.7.4
shiki: 3.23.0
smol-toml: 1.6.1
svgo: 4.0.1
tinyexec: 1.0.4
tinyglobby: 0.2.15
tsconfck: 3.1.6(typescript@5.9.3)
ultrahtml: 1.6.0
unifont: 0.7.4
unist-util-visit: 5.1.0
unstorage: 1.17.5(@azure/storage-blob@12.31.0)(ioredis@5.10.1)
vfile: 6.0.3
vite: 6.4.2(@types/node@20.19.39)(jiti@1.21.7)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
vitefu: 1.1.3(vite@6.4.2(@types/node@20.19.39)(jiti@1.21.7)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
xxhash-wasm: 1.1.0
yargs-parser: 21.1.1
yocto-spinner: 0.2.3
zod: 3.25.76
zod-to-json-schema: 3.25.2(zod@3.25.76)
zod-to-ts: 1.2.0(typescript@5.9.3)(zod@3.25.76)
optionalDependencies:
sharp: 0.34.5
transitivePeerDependencies:
- '@azure/app-configuration'
- '@azure/cosmos'
- '@azure/data-tables'
- '@azure/identity'
- '@azure/keyvault-secrets'
- '@azure/storage-blob'
- '@capacitor/preferences'
- '@deno/kv'
- '@netlify/blobs'
- '@planetscale/database'
- '@types/node'
- '@upstash/redis'
- '@vercel/blob'
- '@vercel/functions'
- '@vercel/kv'
- aws4fetch
- db0
- idb-keyval
- ioredis
- jiti
- less
- lightningcss
- rollup
- sass
- sass-embedded
- stylus
- sugarss
- supports-color
- terser
- tsx
- typescript
- uploadthing
- yaml
astro@5.18.1(@azure/storage-blob@12.31.0)(@types/node@20.19.39)(ioredis@5.10.1)(jiti@2.6.1)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3):
dependencies:
'@astrojs/compiler': 2.13.1
@ -23934,6 +23853,108 @@ snapshots:
- uploadthing
- yaml
astro@5.18.1(@azure/storage-blob@12.31.0)(@types/node@24.12.2)(ioredis@5.10.1)(jiti@1.21.7)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3):
dependencies:
'@astrojs/compiler': 2.13.1
'@astrojs/internal-helpers': 0.7.6
'@astrojs/markdown-remark': 6.3.11
'@astrojs/telemetry': 3.3.0
'@capsizecss/unpack': 4.0.0
'@oslojs/encoding': 1.1.0
'@rollup/pluginutils': 5.3.0(rollup@4.60.1)
acorn: 8.16.0
aria-query: 5.3.2
axobject-query: 4.1.0
boxen: 8.0.1
ci-info: 4.4.0
clsx: 2.1.1
common-ancestor-path: 1.0.1
cookie: 1.1.1
cssesc: 3.0.0
debug: 4.4.3
deterministic-object-hash: 2.0.2
devalue: 5.7.0
diff: 8.0.4
dlv: 1.1.3
dset: 3.1.4
es-module-lexer: 1.7.0
esbuild: 0.27.7
estree-walker: 3.0.3
flattie: 1.1.1
fontace: 0.4.1
github-slugger: 2.0.0
html-escaper: 3.0.3
http-cache-semantics: 4.2.0
import-meta-resolve: 4.2.0
js-yaml: 4.1.1
magic-string: 0.30.21
magicast: 0.5.2
mrmime: 2.0.1
neotraverse: 0.6.18
p-limit: 6.2.0
p-queue: 8.1.1
package-manager-detector: 1.6.0
piccolore: 0.1.3
picomatch: 4.0.4
prompts: 2.4.2
rehype: 13.0.2
semver: 7.7.4
shiki: 3.23.0
smol-toml: 1.6.1
svgo: 4.0.1
tinyexec: 1.0.4
tinyglobby: 0.2.15
tsconfck: 3.1.6(typescript@5.9.3)
ultrahtml: 1.6.0
unifont: 0.7.4
unist-util-visit: 5.1.0
unstorage: 1.17.5(@azure/storage-blob@12.31.0)(ioredis@5.10.1)
vfile: 6.0.3
vite: 6.4.2(@types/node@24.12.2)(jiti@1.21.7)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
vitefu: 1.1.3(vite@6.4.2(@types/node@24.12.2)(jiti@1.21.7)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
xxhash-wasm: 1.1.0
yargs-parser: 21.1.1
yocto-spinner: 0.2.3
zod: 3.25.76
zod-to-json-schema: 3.25.2(zod@3.25.76)
zod-to-ts: 1.2.0(typescript@5.9.3)(zod@3.25.76)
optionalDependencies:
sharp: 0.34.5
transitivePeerDependencies:
- '@azure/app-configuration'
- '@azure/cosmos'
- '@azure/data-tables'
- '@azure/identity'
- '@azure/keyvault-secrets'
- '@azure/storage-blob'
- '@capacitor/preferences'
- '@deno/kv'
- '@netlify/blobs'
- '@planetscale/database'
- '@types/node'
- '@upstash/redis'
- '@vercel/blob'
- '@vercel/functions'
- '@vercel/kv'
- aws4fetch
- db0
- idb-keyval
- ioredis
- jiti
- less
- lightningcss
- rollup
- sass
- sass-embedded
- stylus
- sugarss
- supports-color
- terser
- tsx
- typescript
- uploadthing
- yaml
astro@5.18.1(@azure/storage-blob@12.31.0)(@types/node@24.12.2)(ioredis@5.10.1)(jiti@2.6.1)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3):
dependencies:
'@astrojs/compiler': 2.13.1
@ -25737,11 +25758,6 @@ snapshots:
eslint: 9.39.4(jiti@2.6.1)
semver: 7.7.4
eslint-compat-utils@0.6.5(eslint@9.39.4(jiti@1.21.7)):
dependencies:
eslint: 9.39.4(jiti@1.21.7)
semver: 7.7.4
eslint-compat-utils@0.6.5(eslint@9.39.4(jiti@2.6.1)):
dependencies:
eslint: 9.39.4(jiti@2.6.1)
@ -25751,10 +25767,6 @@ snapshots:
dependencies:
eslint: 9.39.4(jiti@2.6.1)
eslint-config-prettier@9.1.2(eslint@9.39.4(jiti@1.21.7)):
dependencies:
eslint: 9.39.4(jiti@1.21.7)
eslint-config-prettier@9.1.2(eslint@9.39.4(jiti@2.6.1)):
dependencies:
eslint: 9.39.4(jiti@2.6.1)
@ -25799,20 +25811,6 @@ snapshots:
transitivePeerDependencies:
- supports-color
eslint-plugin-astro@1.6.0(eslint@9.39.4(jiti@1.21.7)):
dependencies:
'@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@1.21.7))
'@jridgewell/sourcemap-codec': 1.5.5
'@typescript-eslint/types': 8.58.0
astro-eslint-parser: 1.4.0
eslint: 9.39.4(jiti@1.21.7)
eslint-compat-utils: 0.6.5(eslint@9.39.4(jiti@1.21.7))
globals: 16.5.0
postcss: 8.5.8
postcss-selector-parser: 7.1.1
transitivePeerDependencies:
- supports-color
eslint-plugin-astro@1.6.0(eslint@9.39.4(jiti@2.6.1)):
dependencies:
'@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.6.1))
@ -25986,47 +25984,6 @@ snapshots:
eslint-visitor-keys@5.0.1: {}
eslint@9.39.4(jiti@1.21.7):
dependencies:
'@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@1.21.7))
'@eslint-community/regexpp': 4.12.2
'@eslint/config-array': 0.21.2
'@eslint/config-helpers': 0.4.2
'@eslint/core': 0.17.0
'@eslint/eslintrc': 3.3.5
'@eslint/js': 9.39.4
'@eslint/plugin-kit': 0.4.1
'@humanfs/node': 0.16.7
'@humanwhocodes/module-importer': 1.0.1
'@humanwhocodes/retry': 0.4.3
'@types/estree': 1.0.8
ajv: 6.14.0
chalk: 4.1.2
cross-spawn: 7.0.6
debug: 4.4.3
escape-string-regexp: 4.0.0
eslint-scope: 8.4.0
eslint-visitor-keys: 4.2.1
espree: 10.4.0
esquery: 1.7.0
esutils: 2.0.3
fast-deep-equal: 3.1.3
file-entry-cache: 8.0.0
find-up: 5.0.0
glob-parent: 6.0.2
ignore: 5.3.2
imurmurhash: 0.1.4
is-glob: 4.0.3
json-stable-stringify-without-jsonify: 1.0.1
lodash.merge: 4.6.2
minimatch: 3.1.5
natural-compare: 1.4.0
optionator: 0.9.4
optionalDependencies:
jiti: 1.21.7
transitivePeerDependencies:
- supports-color
eslint@9.39.4(jiti@2.6.1):
dependencies:
'@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.6.1))
@ -30221,6 +30178,8 @@ snapshots:
'@pagefind/windows-arm64': 1.5.0
'@pagefind/windows-x64': 1.5.0
pako@1.0.11: {}
pako@2.1.0: {}
parent-module@1.0.1:
@ -30333,6 +30292,13 @@ snapshots:
pathe@2.0.3: {}
pdf-lib@1.17.1:
dependencies:
'@pdf-lib/standard-fonts': 1.0.0
'@pdf-lib/upng': 1.0.1
pako: 1.0.11
tslib: 1.14.1
pend@1.2.0: {}
performance-now@2.1.0:
@ -32548,6 +32514,8 @@ snapshots:
minimist: 1.2.8
strip-bom: 3.0.0
tslib@1.14.1: {}
tslib@2.8.1: {}
tsm@2.3.0:
@ -32986,23 +32954,6 @@ snapshots:
lightningcss: 1.32.0
terser: 5.46.1
vite@6.4.2(@types/node@20.19.39)(jiti@1.21.7)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3):
dependencies:
esbuild: 0.25.12
fdir: 6.5.0(picomatch@4.0.4)
picomatch: 4.0.4
postcss: 8.5.8
rollup: 4.60.1
tinyglobby: 0.2.15
optionalDependencies:
'@types/node': 20.19.39
fsevents: 2.3.3
jiti: 1.21.7
lightningcss: 1.32.0
terser: 5.46.1
tsx: 4.21.0
yaml: 2.8.3
vite@6.4.2(@types/node@20.19.39)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3):
dependencies:
esbuild: 0.25.12
@ -33037,6 +32988,23 @@ snapshots:
tsx: 4.21.0
yaml: 2.8.3
vite@6.4.2(@types/node@24.12.2)(jiti@1.21.7)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3):
dependencies:
esbuild: 0.25.12
fdir: 6.5.0(picomatch@4.0.4)
picomatch: 4.0.4
postcss: 8.5.8
rollup: 4.60.1
tinyglobby: 0.2.15
optionalDependencies:
'@types/node': 24.12.2
fsevents: 2.3.3
jiti: 1.21.7
lightningcss: 1.32.0
terser: 5.46.1
tsx: 4.21.0
yaml: 2.8.3
vite@6.4.2(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3):
dependencies:
esbuild: 0.25.12
@ -33054,10 +33022,6 @@ snapshots:
tsx: 4.21.0
yaml: 2.8.3
vitefu@1.1.3(vite@6.4.2(@types/node@20.19.39)(jiti@1.21.7)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)):
optionalDependencies:
vite: 6.4.2(@types/node@20.19.39)(jiti@1.21.7)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
vitefu@1.1.3(vite@6.4.2(@types/node@20.19.39)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)):
optionalDependencies:
vite: 6.4.2(@types/node@20.19.39)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
@ -33066,6 +33030,10 @@ snapshots:
optionalDependencies:
vite: 6.4.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
vitefu@1.1.3(vite@6.4.2(@types/node@24.12.2)(jiti@1.21.7)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)):
optionalDependencies:
vite: 6.4.2(@types/node@24.12.2)(jiti@1.21.7)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
vitefu@1.1.3(vite@6.4.2(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)):
optionalDependencies:
vite: 6.4.2(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)