first commit

This commit is contained in:
2026-05-29 08:20:56 +01:00
commit 949299f58d
6368 changed files with 527176 additions and 0 deletions

View File

@@ -0,0 +1,16 @@
# `docs`
Documentation utilities, SVG compression, PDF handling, and sitemap generation.
<!-- begin:auto-docs -->
## Packages in `docs`
4 packages.
| Package | Description |
|---|---|
| [`@suaveplan/compress-svg`](./compress-svg/README.md) | SVG optimisation and minification for TypeScript. Pure TypeScript — no DOMParser, no external SVG library. Runs in Bun, Node.js, browsers, and edge runtimes. |
| [`@suaveplan/docs`](./docs/README.md) | Server-tier markdown documentation toolkit for Suaveplan. Discovers every `.md` file under a repo root, parses each via `gray-matter` + `remark` into a structu… |
| [`@suaveplan/pdf`](./pdf/README.md) | Server-tier PDF 1.4 generation toolkit. **Chunk A foundation** — pure utilities + cross-cutting wiring (typed error registry, Zod boundary schemas, OpenTelemet… |
| [`@suaveplan/sitemap`](./sitemap/README.md) | Sitemap generation and parsing for the sitemaps.org XML protocol. Produces valid `sitemap.xml`, sitemap index documents, and parses existing sitemaps. Supports… |
<!-- end:auto-docs -->

View File

@@ -0,0 +1 @@
Documentation utilities, SVG compression, PDF handling, and sitemap generation.

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,74 @@
# compress-svg — Core SVG Compression Function
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`compressSvg` is the primary entry point for the `@suaveplan/compress-svg` package. It accepts an SVG string and a set of options, runs the SVG through the configured plugin pipeline (optionally in multiple passes until the output stabilises), and returns a structured `CompressResult` with the optimised SVG string and byte-size metrics. The function is instrumented with OpenTelemetry counters and histograms for production observability.
## Features
- Accepts any SVG string and optional `CompressOptions` configuration
- Uses the standard plugin pipeline model: each plugin can transform or remove any node in the tree
- Multipass loop: re-runs plugins until two consecutive passes produce identical output or `MAX_MULTIPASS_ITERATIONS` is reached
- `multipass: false` to run exactly one pass
- Propagates `SvgCompressError(PARSE_FAILED)` from the parser
- Wraps plugin exceptions in `SvgCompressError(PLUGIN_FAILED)` with the original error as `cause`
- Throws `SvgCompressError(INVALID_SVG)` if the plugin pipeline removes all nodes
- OTel: counter `svg_compress_total`, histogram `svg_compress_duration_ms`, histogram `svg_compress_ratio`
- Returns `originalSize`, `compressedSize`, `ratio`, and `passCount` in the result
## Usage: Basic Example
```typescript
import { compressSvg } from '@suaveplan/compress-svg';
const svg = '<svg xmlns="http://www.w3.org/2000/svg"><!-- comment --><rect width="100" height="100"/></svg>';
const result = compressSvg(svg);
console.log(result.data); // '<svg xmlns="http://www.w3.org/2000/svg"><rect width="100" height="100"/></svg>'
console.log(result.ratio); // e.g. 0.82
console.log(result.passCount); // 2
```
## Usage: Advanced Example
```typescript
import { compressSvg, PRESET_AGGRESSIVE, createMangleIds } from '@suaveplan/compress-svg';
const result = compressSvg(largeSvg, {
plugins: [...PRESET_AGGRESSIVE, createMangleIds()],
floatPrecision: 1,
transformPrecision: 3,
multipass: true,
});
if (result.ratio < 0.5) {
console.log(`Compressed to ${(result.ratio * 100).toFixed(1)}% of original`);
}
```
## API Reference
### `compressSvg(svg: string, options?: CompressOptions): CompressResult`
| Parameter | Type | Description |
|-----------|------|-------------|
| `svg` | `string` | The SVG string to compress |
| `options.plugins` | `SvgPlugin[]` | Plugin pipeline; defaults to `PRESET_DEFAULT` |
| `options.floatPrecision` | `number` | Decimal places for path data; default `3` |
| `options.transformPrecision` | `number` | Decimal places for transforms; default `5` |
| `options.multipass` | `boolean` | Re-run until stable; default `true` |
**Returns:** `CompressResult` — `{ data, originalSize, compressedSize, ratio, passCount }`
**Throws:**
- `SvgCompressError(PARSE_FAILED)` — SVG string is not valid
- `SvgCompressError(PLUGIN_FAILED)` — a plugin threw an exception
- `SvgCompressError(INVALID_SVG)` — the pipeline removed all nodes
## Implementation Notes
The multipass loop terminates when `encoder.encode(serialized).byteLength` equals the previous pass's size, meaning no plugin produced any further change. The maximum iteration count `MAX_MULTIPASS_ITERATIONS` (10) guards against pathological cycles where two plugins reverse each other's changes. Plugin application recurses depth-first: the plugin is applied to each node before its children are visited, so a plugin returning `null` removes the entire subtree without visiting descendants.

View File

@@ -0,0 +1,87 @@
# svg-parser — SVG Parser and Serializer
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`parseSvg` and `serializeSvg` are the low-level I/O layer for `@suaveplan/compress-svg`. The parser is a pure-TypeScript push parser that processes SVG strings character-by-character without requiring `DOMParser`, making it safe to use in Node.js, Bun, edge runtimes, and workers. `serializeSvg` produces compact, whitespace-free output optimised for minimum byte count.
## Features
- `parseSvg(svg)` — converts an SVG string to an `SvgNode` tree; no DOM dependency; handles element, text, comment, CDATA, and doctype nodes
- `serializeSvg(node)` — converts an `SvgNode` tree back to a compact SVG string; self-closing tags for empty elements
- Handles XML declarations (`<?xml ...?>`) by silently skipping them (irrelevant to the SVG tree)
- Decodes HTML/XML character entities (`&amp;`, `&lt;`, `&gt;`, `&quot;`, `&apos;`, numeric `&#...;` and `&#x...;`)
- Throws `SvgCompressError(SVG_COMPRESS_PARSE_FAILED)` with a descriptive message on malformed input
- Supports both double-quoted and single-quoted attribute values
- Supports boolean (value-less) attributes
## Usage: Basic Example
```typescript
import { parseSvg, serializeSvg } from '@suaveplan/compress-svg';
const node = parseSvg('<svg xmlns="http://www.w3.org/2000/svg"><circle r="40"/></svg>');
console.log(node.name); // 'svg'
console.log(node.children![0]!.name); // 'circle'
const output = serializeSvg(node);
// '<svg xmlns="http://www.w3.org/2000/svg"><circle r="40"/></svg>'
```
## Usage: Advanced Example
```typescript
import { parseSvg, serializeSvg } from '@suaveplan/compress-svg';
import { SvgCompressError, SVG_COMPRESS_CODES } from '@suaveplan/compress-svg';
function safeRoundTrip(svg: string): string | null {
try {
return serializeSvg(parseSvg(svg));
} catch (e) {
if (e instanceof SvgCompressError && e.code === SVG_COMPRESS_CODES.PARSE_FAILED) {
return null;
}
throw e;
}
}
// Handles comments, CDATA, and nested structures
const svg = `
<?xml version="1.0"?>
<!-- icon -->
<svg><![CDATA[raw]]><g><rect/></g></svg>
`;
const output = safeRoundTrip(svg.trim());
// '<svg><![CDATA[raw]]><g><rect/></g></svg>'
```
## API Reference
### `parseSvg(svg: string): SvgNode`
Parses an SVG string into a tree of `SvgNode` objects. The returned node is always the root element. XML declarations are silently skipped. Doctype nodes are captured as children of the virtual root but not returned as the root element — the root is always the first element node.
**Throws:** `SvgCompressError(SVG_COMPRESS_PARSE_FAILED)` when:
- Input is empty or all whitespace
- A tag is left unclosed
- A closing tag does not match the open tag
- There is no root element in the input
### `serializeSvg(node: SvgNode): string`
Serializes a single `SvgNode` (and all its descendants) to a compact SVG string. Empty element nodes (no children) are serialized as self-closing (`<rect/>`). No whitespace or newlines are added between elements.
| Node type | Output format |
|------------|---------------------------------------|
| `element` | `<name attrs>children</name>` or `<name attrs/>` |
| `text` | Raw text value |
| `comment` | `<!--value-->` |
| `cdata` | `<![CDATA[value]]>` |
| `doctype` | `<!DOCTYPE value>` |
## Implementation Notes
The parser is a single-pass, index-advancing loop over the input string. It maintains an explicit stack of open elements rather than recursing, so deeply nested SVGs do not risk stack overflow. Attribute parsing uses a single regex that correctly handles double-quoted, single-quoted, and unquoted values. The `findTagEnd` helper handles `>` characters inside attribute values by tracking quote state. Entity decoding covers the five XML predefined entities plus numeric decimal and hexadecimal code points. The serializer uses a switch statement over node type, making it branch-exhaustive with TypeScript's `never` check at compile time.

View File

@@ -0,0 +1,67 @@
# cleanup-attrs — Remove Redundant SVG Attributes
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`cleanupAttrs` strips attributes whose values match the SVG specification defaults and trims whitespace from all remaining attribute values. Many SVG authoring tools emit default attribute values explicitly (e.g. `fill="black"`, `opacity="1"`) that carry no information. Removing them reduces file size without changing the rendered output.
## Features
- Removes attributes whose trimmed value matches the SVG default (sourced from `DEFAULT_ATTRS` in constants)
- Removes attributes with empty string values after trimming
- Trims leading/trailing whitespace from all attribute values
- Passes non-element nodes through unchanged
- Included in `PRESET_DEFAULT`, `PRESET_SAFE`, and `PRESET_AGGRESSIVE`
## Usage: Basic Example
```typescript
import { compressSvg } from '@suaveplan/compress-svg';
import { cleanupAttrs } from '@suaveplan/compress-svg/plugins/cleanup-attrs';
const svg = '<svg><rect fill="black" opacity="1" x="10" fill-opacity="1"/></svg>';
const result = compressSvg(svg, { plugins: [cleanupAttrs] });
// result.data === '<svg><rect x="10"/></svg>'
```
## Usage: Advanced Example
```typescript
import { cleanupAttrs } from '@suaveplan/compress-svg/plugins/cleanup-attrs';
const node = {
type: 'element' as const,
name: 'rect',
attributes: { fill: 'red', opacity: '1', style: '', x: ' 10 ' },
children: [],
};
const result = cleanupAttrs.apply(node, { floatPrecision: 3, transformPrecision: 5 });
// result.attributes === { fill: 'red', x: '10' }
// opacity removed (default), style removed (empty), x trimmed
```
## API Reference
### `cleanupAttrs: SvgPlugin`
| Property | Value |
|----------|-------|
| `name` | `"cleanupAttrs"` |
| `apply` | `(node, options) => SvgNode \| null` |
Iterates over each attribute:
1. Trims the value.
2. Drops the attribute if the trimmed value is empty.
3. Drops the attribute if `DEFAULT_ATTRS[key] === trimmedValue`.
4. Otherwise keeps the attribute with the trimmed value.
**Returns the node unchanged** for non-element nodes.
**Default attributes removed** (partial list): `fill="black"`, `fill-opacity="1"`, `stroke-opacity="1"`, `opacity="1"`, `display="inline"`, `visibility="visible"`, `overflow="visible"`.
## Implementation Notes
The `DEFAULT_ATTRS` constant is defined centrally in `src/constants/index.ts` so all plugins share the same definition. The plugin always returns a new node object with a fresh `attributes` record, even if no attributes were removed — callers should not rely on reference equality after the plugin runs. Only exact-string matches against the default values are checked; no numeric normalisation is performed (e.g. `opacity="1.0"` is not recognised as a default).

View File

@@ -0,0 +1,68 @@
# collapse-groups — Collapse Redundant SVG Group Elements
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`collapseGroups` removes unnecessary `<g>` elements that wrap a single child by hoisting the child and merging any group attributes onto it. SVG authoring tools often emit groups around individual elements for organisational reasons; when those groups carry no meaningful attributes (or carry only non-conflicting ones), collapsing them reduces element count and serialised size.
## Features
- Collapses `<g>` elements with exactly one child by returning the child directly
- Merges group attributes onto the child (group attrs take lower precedence than child's own attrs)
- Refuses to collapse groups with `id` attributes (the id may be referenced by `<use>` or scripts)
- Refuses to collapse when the child already has an attribute that the group also has (conflict avoidance)
- Passes through all non-element nodes and all elements that are not `<g>` unchanged
- Included in `PRESET_DEFAULT`, `PRESET_SAFE`, and `PRESET_AGGRESSIVE`
## Usage: Basic Example
```typescript
import { compressSvg } from '@suaveplan/compress-svg';
import { collapseGroups } from '@suaveplan/compress-svg/plugins/collapse-groups';
const svg = '<svg><g><rect width="10" height="10"/></g></svg>';
const result = compressSvg(svg, { plugins: [collapseGroups] });
// result.data === '<svg><rect width="10" height="10"/></svg>'
```
## Usage: Advanced Example
```typescript
import { collapseGroups } from '@suaveplan/compress-svg/plugins/collapse-groups';
// Group with transferable attrs
const group = {
type: 'element' as const,
name: 'g',
attributes: { opacity: '0.5', class: 'layer' },
children: [
{ type: 'element' as const, name: 'rect', attributes: { fill: 'red' }, children: [] },
],
};
const result = collapseGroups.apply(group, { floatPrecision: 3, transformPrecision: 5 });
// result.name === 'rect', result.attributes === { opacity: '0.5', class: 'layer', fill: 'red' }
```
## API Reference
### `collapseGroups: SvgPlugin`
| Property | Value |
|----------|-------|
| `name` | `"collapseGroups"` |
| `apply` | `(node, options) => SvgNode \| null` |
**Returns the single child** (with merged attrs) when:
- `node.type === 'element'` and `node.name === 'g'`
- `node.attributes` does not contain `id`
- `node.children` has exactly one element-type child
- No attribute key in `node.attributes` conflicts with a key in `child.attributes`
**Returns the node unchanged** in all other cases.
## Implementation Notes
The merge order is `{ ...groupAttrs, ...childAttrs }`, meaning the child's attributes override the group's attributes. In practice, this should never occur because the conflict check guards against it — but the spread order is defined here for clarity. The `id` guard exists because removing a group with an `id` would break `url(#id)` references in `fill`, `filter`, `clip-path`, `mask`, and `<use>` elements; the mangleIds plugin handles those separately.

View File

@@ -0,0 +1,59 @@
# convert-path-data — Reduce Float Precision in SVG Path Data
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`convertPathData` reduces the number of decimal places in the `d` attribute of `<path>` elements to the configured `floatPrecision`. Path coordinates output by design tools routinely carry many more decimal places than the display resolution requires — coordinates like `M 10.000000001 20.000000001` are visually indistinguishable from `M 10 20`. Rounding reduces the serialised size of path data proportionally to the number of coordinates and original precision.
## Features
- Rounds all numeric values in the `d` attribute to `floatPrecision` decimal places
- Handles all path commands: `M`, `L`, `C`, `S`, `Q`, `T`, `A`, `Z` and their lowercase variants
- Supports scientific notation in coordinates (`1.5e-4`)
- Passes through non-`<path>` elements unchanged
- Passes through `<path>` elements that have no `d` attribute
- Passes through non-element nodes unchanged
## Usage: Basic Example
```typescript
import { compressSvg } from '@suaveplan/compress-svg';
import { convertPathData } from '@suaveplan/compress-svg/plugins/convert-path-data';
const svg = '<svg><path d="M 10.12345678 20.98765432 L 30 40"/></svg>';
const result = compressSvg(svg, { plugins: [convertPathData], floatPrecision: 3 });
// result.data contains 'd="M 10.123 20.988 L 30 40"'
```
## Usage: Advanced Example
```typescript
import { convertPathData } from '@suaveplan/compress-svg/plugins/convert-path-data';
const node = {
type: 'element' as const,
name: 'path',
attributes: { d: 'C 1.111111 2.222222 3.333333 4.444444 5.555555 6.666666' },
children: [],
};
const result = convertPathData.apply(node, { floatPrecision: 2, transformPrecision: 5 });
// result.attributes.d === 'C 1.11 2.22 3.33 4.44 5.56 6.67'
```
## API Reference
### `convertPathData: SvgPlugin`
| Property | Value |
|----------|-------|
| `name` | `"convertPathData"` |
| `apply` | `(node, options) => SvgNode \| null` |
Reads `options.floatPrecision` (integer, 0–20). Applies `Number.toFixed(precision)` to every numeric token in the `d` attribute string, then converts back to the minimal string representation via `parseFloat(n.toFixed(p)).toString()` to strip trailing zeros.
## Implementation Notes
The rounding regex matches the full range of SVG numeric literals including optional sign, decimal point, and scientific-notation exponent. Each matched token is parsed as a 64-bit float, rounded, and re-stringified; no attempt is made to normalise the command letters or collapse redundant moves. The plugin operates purely on the string level, making it independent of any path-command parsing library and keeping the implementation small and auditable.

View File

@@ -0,0 +1,57 @@
# convert-transforms — Reduce Float Precision in SVG Transform Attributes
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`convertTransforms` reduces the decimal precision of numeric values in `transform` attributes to `transformPrecision` decimal places. Transform matrices output by CAD tools, Inkscape, and Adobe Illustrator often carry 10–15 decimal places where 5 is more than sufficient for any practical rendering context. Rounding reduces file size without any visible effect on the final image.
## Features
- Rounds all numeric values in the `transform` attribute to `transformPrecision` decimal places
- Handles all transform function forms: `translate`, `scale`, `rotate`, `skewX`, `skewY`, `matrix`
- Passes through elements without a `transform` attribute unchanged (returns the same reference)
- Passes through non-element nodes unchanged
- Not included in `PRESET_DEFAULT` or `PRESET_SAFE` (only in `PRESET_AGGRESSIVE`) to avoid modifying transforms without explicit opt-in
## Usage: Basic Example
```typescript
import { compressSvg } from '@suaveplan/compress-svg';
import { convertTransforms } from '@suaveplan/compress-svg/plugins/convert-transforms';
const svg = '<svg><rect transform="translate(10.123456789 20.987654321)"/></svg>';
const result = compressSvg(svg, { plugins: [convertTransforms], transformPrecision: 3 });
// result.data contains 'transform="translate(10.123 20.988)"'
```
## Usage: Advanced Example
```typescript
import { convertTransforms } from '@suaveplan/compress-svg/plugins/convert-transforms';
const node = {
type: 'element' as const,
name: 'g',
attributes: { transform: 'matrix(1.000001 0.000001 -0.000001 1.000001 10.123456 20.987654)' },
children: [],
};
const result = convertTransforms.apply(node, { floatPrecision: 3, transformPrecision: 2 });
// result.attributes.transform === 'matrix(1 0 0 1 10.12 20.99)'
```
## API Reference
### `convertTransforms: SvgPlugin`
| Property | Value |
|----------|-------|
| `name` | `"convertTransforms"` |
| `apply` | `(node, options) => SvgNode \| null` |
Reads `options.transformPrecision` (separate from `floatPrecision` because transforms often need more decimal places than path coordinates). Applies `parseFloat(n.toFixed(precision)).toString()` to every numeric token in the `transform` attribute string.
## Implementation Notes
The rounding implementation is identical to `convertPathData` except it operates on the `transform` attribute and reads `transformPrecision` instead of `floatPrecision`. Both plugins share a local `roundNumbers` helper function. The separation into two distinct plugins allows callers to apply path-data rounding without touching transforms (useful when animating transforms with JavaScript that expects exact values).

View File

@@ -0,0 +1,67 @@
# mangle-ids — Replace Element IDs with Short Sequential Identifiers
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`mangleIds` replaces verbose `id` attribute values with short sequential identifiers (`a`, `b`, ..., `z`, `A`, ..., `Z`, `aa`, `ab`, ...) and rewrites all `href` and `xlink:href` reference attributes that point to the old IDs. This significantly reduces the size of SVGs that use many named elements for gradients, patterns, clip paths, filters, or `<use>` references.
## Features
- Replaces `id` attribute values with the shortest possible unique identifier
- Rewrites `href="#originalId"` and `xlink:href="#originalId"` to point to the new mangled ID
- Stateful per-instance ID map — create a fresh instance per compression run via `createMangleIds()`
- The exported `mangleIds` singleton is suitable for single-pass use; use `createMangleIds()` in test or multi-run scenarios
- Only in `PRESET_AGGRESSIVE`; opt-in only because mangling IDs breaks external CSS selectors that target the SVG by ID
## Usage: Basic Example
```typescript
import { compressSvg } from '@suaveplan/compress-svg';
import { createMangleIds } from '@suaveplan/compress-svg/plugins/mangle-ids';
const svg = '<svg><defs><linearGradient id="myGradient"/></defs><rect fill="url(#myGradient)"/></svg>';
const result = compressSvg(svg, { plugins: [createMangleIds()] });
// IDs in result.data are short: id="a", not id="myGradient"
```
## Usage: Advanced Example
```typescript
import { createMangleIds } from '@suaveplan/compress-svg/plugins/mangle-ids';
const plugin = createMangleIds();
const opts = { floatPrecision: 3, transformPrecision: 5 };
const def = { type: 'element' as const, name: 'path', attributes: { id: 'clipPath1' }, children: [] };
const use = { type: 'element' as const, name: 'use', attributes: { href: '#clipPath1' }, children: [] };
const mangledDef = plugin.apply(def, opts);
const mangledUse = plugin.apply(use, opts);
const newId = mangledDef!.attributes!.id; // e.g. 'a'
// mangledUse.attributes.href === '#a'
```
## API Reference
### `createMangleIds(): SvgPlugin`
Factory function that returns a new stateful `mangleIds` plugin instance. Each call creates an independent ID counter and map, so multiple independent SVGs can be compressed without ID collisions.
### `mangleIds: SvgPlugin`
Pre-created singleton instance. Suitable for single-use scenarios where a fresh instance is created per module load.
| Property | Value |
|----------|-------|
| `name` | `"mangleIds"` |
| `apply` | `(node, options) => SvgNode \| null` |
**Rewrites `id`** on element nodes to the next sequential short identifier.
**Rewrites `href`** and `xlink:href`** on element nodes when the value is a fragment reference (`#...`).
## Implementation Notes
Short IDs are generated with a base-52 alphabet (lowercase `a–z` then uppercase `A–Z`). The sequence is 0→`a`, 1→`b`, ..., 25→`z`, 26→`A`, ..., 51→`Z`, 52→`aa`, 53→`ab`, etc. This avoids numeric IDs (which are invalid XML `id` values starting with a digit) and produces the shortest possible identifiers. The ID map is keyed by original ID string, so multiple nodes with the same original ID receive the same mangled ID — though well-formed SVG should not have duplicate IDs.

View File

@@ -0,0 +1,60 @@
# remove-comments — Remove XML Comment Nodes
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`removeComments` is a stateless SVG plugin that removes all XML comment nodes (`<!-- ... -->`) from the SVG tree. Comments are human-readable annotations with no visual effect on the rendered image, so they can be safely eliminated to reduce file size. This plugin is included in all three built-in presets.
## Features
- Removes any `SvgNode` with `type: 'comment'` by returning `null`
- Passes all other node types through unchanged (elements, text, CDATA, doctype)
- Stateless — safe to share across multiple compression passes and parallel uses
- Zero configuration — no options are read from `SvgPluginOptions`
## Usage: Basic Example
```typescript
import { compressSvg } from '@suaveplan/compress-svg';
import { removeComments } from '@suaveplan/compress-svg/plugins/remove-comments';
const svg = '<svg><!-- this is a comment --><rect width="100"/></svg>';
const result = compressSvg(svg, { plugins: [removeComments] });
// result.data === '<svg><rect width="100"/></svg>'
```
## Usage: Advanced Example
```typescript
import { removeComments } from '@suaveplan/compress-svg/plugins/remove-comments';
import type { SvgNode, SvgPluginOptions } from '@suaveplan/compress-svg';
const opts: SvgPluginOptions = { floatPrecision: 3, transformPrecision: 5 };
const commentNode: SvgNode = { type: 'comment', value: ' copyright 2024 ' };
const result = removeComments.apply(commentNode, opts);
// result === null (node is removed)
const elementNode: SvgNode = { type: 'element', name: 'svg', children: [] };
const kept = removeComments.apply(elementNode, opts);
// kept === elementNode (same reference)
```
## API Reference
### `removeComments: SvgPlugin`
| Property | Value |
|----------|-------|
| `name` | `"removeComments"` |
| `apply` | `(node, options) => SvgNode \| null` |
**Returns `null`** when `node.type === 'comment'`, causing the plugin pipeline to remove the node from its parent's children.
**Returns the node unchanged** for all other node types.
## Implementation Notes
The plugin is a plain object literal satisfying the `SvgPlugin` interface. Because it holds no mutable state, it is safe to use as a module-level singleton and to include in multiple preset arrays without aliasing hazards. The `apply` method does not read any `SvgPluginOptions` fields because comment removal is unconditional — there is no configurable threshold or condition for keeping comments.

View File

@@ -0,0 +1,56 @@
# remove-doctype — Remove DOCTYPE Declarations
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`removeDoctype` is a stateless SVG plugin that removes `doctype` nodes from the SVG tree. DOCTYPE declarations are required only for XML validation and are not needed by browsers or SVG renderers processing inline SVG. Removing them reduces file size and eliminates external DTD references that can slow down parsing.
## Features
- Removes `SvgNode` instances with `type: 'doctype'` by returning `null`
- Passes all other node types through unchanged
- Stateless, no configuration required
- Included in `PRESET_DEFAULT`, `PRESET_SAFE`, and `PRESET_AGGRESSIVE`
## Usage: Basic Example
```typescript
import { compressSvg } from '@suaveplan/compress-svg';
import { removeDoctype } from '@suaveplan/compress-svg/plugins/remove-doctype';
const svg = '<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "..."><svg><rect/></svg>';
const result = compressSvg(svg, { plugins: [removeDoctype] });
// result.data === '<svg><rect/></svg>'
```
## Usage: Advanced Example
```typescript
import { removeDoctype } from '@suaveplan/compress-svg/plugins/remove-doctype';
const doctype = {
type: 'doctype' as const,
value: 'svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"',
};
const result = removeDoctype.apply(doctype, { floatPrecision: 3, transformPrecision: 5 });
// result === null
```
## API Reference
### `removeDoctype: SvgPlugin`
| Property | Value |
|----------|-------|
| `name` | `"removeDoctype"` |
| `apply` | `(node, options) => SvgNode \| null` |
**Returns `null`** when `node.type === 'doctype'`.
**Returns the node unchanged** for all other nodes.
## Implementation Notes
DOCTYPE nodes are typically at the top level, not nested inside elements. The `parseSvg` parser adds them as children of a temporary root container before finding the real SVG root element, so the compressor's tree walk will still encounter them. This plugin removes them before `serializeSvg` runs, ensuring clean output with no DOCTYPE declaration.

View File

@@ -0,0 +1,57 @@
# remove-metadata — Remove SVG Metadata Elements
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`removeMetadata` is a stateless SVG plugin that removes `<metadata>` elements from the SVG tree. Metadata elements carry machine-readable annotations (RDF, Dublin Core, license info) that are invisible to renderers and not needed in production assets. Removing them reduces file size without any visual change.
## Features
- Removes `SvgNode` elements with `name: 'metadata'` by returning `null`
- Passes all other node types through unchanged
- Stateless singleton safe for multi-pass use and preset sharing
- Included in `PRESET_DEFAULT`, `PRESET_SAFE`, and `PRESET_AGGRESSIVE`
## Usage: Basic Example
```typescript
import { compressSvg } from '@suaveplan/compress-svg';
import { removeMetadata } from '@suaveplan/compress-svg/plugins/remove-metadata';
const svg = '<svg><metadata><dc:title>My Icon</dc:title></metadata><circle r="10"/></svg>';
const result = compressSvg(svg, { plugins: [removeMetadata] });
// result.data === '<svg><circle r="10"/></svg>'
```
## Usage: Advanced Example
```typescript
import { removeMetadata } from '@suaveplan/compress-svg/plugins/remove-metadata';
const node = {
type: 'element' as const,
name: 'metadata',
children: [{ type: 'element' as const, name: 'rdf:RDF', children: [] }],
};
const result = removeMetadata.apply(node, { floatPrecision: 3, transformPrecision: 5 });
// result === null (entire subtree removed)
```
## API Reference
### `removeMetadata: SvgPlugin`
| Property | Value |
|----------|-------|
| `name` | `"removeMetadata"` |
| `apply` | `(node, options) => SvgNode \| null` |
**Returns `null`** when `node.type === 'element' && node.name === 'metadata'`.
**Returns the node unchanged** for all other nodes.
## Implementation Notes
The plugin targets only the `<metadata>` element itself. Because the compressor's `applyPlugins` function passes the `<metadata>` element to the plugin before recursing into its children, returning `null` removes the entire subtree in one step — the children never need to be visited. This makes metadata removal O(1) in the size of the metadata subtree.

View File

@@ -0,0 +1,102 @@
# common — Shared Types and Zod Schemas
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Defines the public type surface for `@suaveplan/compress-svg`. All interfaces, Zod schemas, and option bundles live here so that consumers can import types independently of the implementation. Using a centralised types module avoids circular imports between the parser, plugin system, and core compressor.
## Features
- `SvgNode` — recursive tree node representing any part of an SVG document: elements, text, comments, CDATA sections, and doctypes
- `SvgPlugin` — the single-function contract every compression plugin must satisfy; returning `null` removes the node from the tree
- `SvgPluginOptions` — shared options passed to every plugin during a compression pass
- `CompressOptions` — top-level user-facing configuration for `compressSvg`; all fields optional with sensible defaults
- `CompressResult` — structured result returned by `compressSvg` with size metrics and pass count
- `CompressOptionsSchema` — Zod schema for validating user-supplied `CompressOptions` at runtime boundaries
- `CompressResultSchema` — Zod schema for validating `CompressResult` at serialisation/deserialisation boundaries
## Usage: Basic Example
```typescript
import type { SvgNode, SvgPlugin, CompressOptions } from '@suaveplan/compress-svg';
const myPlugin: SvgPlugin = {
name: 'my-plugin',
apply(node: SvgNode): SvgNode | null {
if (node.type === 'comment') return null;
return node;
},
};
const options: CompressOptions = {
floatPrecision: 2,
multipass: true,
plugins: [myPlugin],
};
```
## Usage: Advanced Example
```typescript
import { CompressOptionsSchema, CompressResultSchema } from '@suaveplan/compress-svg';
function parseOptions(raw: unknown) {
return CompressOptionsSchema.parse(raw);
}
function validateResult(raw: unknown) {
return CompressResultSchema.parse(raw);
}
const opts = parseOptions({ floatPrecision: 3, mangleIds: true });
```
## API Reference
### `interface SvgNode`
| Field | Type | Description |
|--------------|--------------------------------|------------------------------------------|
| `type` | `'element' \| 'text' \| 'comment' \| 'cdata' \| 'doctype'` | Node discriminant |
| `name` | `string \| undefined` | Element tag name (for `element` nodes) |
| `attributes` | `Record<string, string> \| undefined` | Element attributes |
| `children` | `SvgNode[] \| undefined` | Child nodes (for `element` nodes) |
| `value` | `string \| undefined` | Text content (for text/comment/cdata/doctype) |
### `interface SvgPlugin`
| Field | Type | Description |
|----------|------------------------------------------------|-------------------------------|
| `name` | `string` | Plugin identifier |
| `apply` | `(node, options) => SvgNode \| null` | Transform or remove the node |
### `interface CompressOptions`
| Field | Type | Default | Description |
|---------------------|-----------------|----------|---------------------------------------|
| `plugins` | `SvgPlugin[]` | standard | Override the full plugin pipeline |
| `floatPrecision` | `number` | `3` | Decimal places for path coordinates |
| `transformPrecision`| `number` | `5` | Decimal places for transform values |
| `multipass` | `boolean` | `true` | Re-run until output size stabilises |
| `removeComments` | `boolean` | `true` | Enable `removeComments` plugin |
| `removeMetadata` | `boolean` | `true` | Enable `removeMetadata` plugin |
| `removeDoctype` | `boolean` | `true` | Enable `removeDoctype` plugin |
| `collapseGroups` | `boolean` | `true` | Enable `collapseGroups` plugin |
| `mergeStyles` | `boolean` | `true` | Enable `mergeStyles` plugin |
| `mangleIds` | `boolean` | `false` | Enable `mangleIds` plugin |
### `interface CompressResult`
| Field | Type | Description |
|------------------|----------|------------------------------------------|
| `data` | `string` | Optimised SVG string |
| `originalSize` | `number` | Byte length of input |
| `compressedSize` | `number` | Byte length of output |
| `ratio` | `number` | `compressedSize / originalSize` |
| `passCount` | `number` | Number of multipass iterations performed |
## Implementation Notes
`CompressOptionsSchema` uses `.strict()` to reject unknown keys at validation time, preventing silent misconfiguration. The `plugins` field is intentionally omitted from the Zod schema because `SvgPlugin` contains function values that are not Zod-serialisable — callers pass plugin arrays directly through the TypeScript interface. `CompressResultSchema` validates the numeric invariants (non-negative sizes, positive pass count) that the core compressor always satisfies but which downstream consumers may need to re-validate after deserialization.

View File

@@ -0,0 +1,76 @@
# @suaveplan/docs - Server-tier markdown documentation toolkit: project-wide discovery, mdast-typed parsing, bidirectional reference graph, hierarchy builder, full-text scored search, typed DocsError registry, OpenTelemetry counters + histograms, Zod-validated DiscoverOptions, AbortSignal propagation.
Server-tier markdown documentation toolkit for Suaveplan.
Discovers every `.md` file under a repo root, parses each via `gray-matter` +
`remark` into a structured `DocFile`, builds a bidirectional reference graph,
identifies orphans, materialises a per-category hierarchy tree, and answers
scored full-text search queries. Ships a typed `DocsError` namespace, eight
OpenTelemetry instruments, Zod-validated `DiscoverOptions`, and `AbortSignal`
support throughout the async surface.
<!-- begin:auto-docs -->
## Documentation tree
- [discovery](./src/discovery/discover.md) — `discoverDocs` walks a project root directory and returns one structured `DocFile` per markdown file it can read. It i…
- [discover](./src/discovery/discover.md) — `discoverDocs` walks a project root directory and returns one structured `DocFile` per markdown file it can read. It i…
- [errors](./src/errors/errors.md) — The `errors` module owns the typed-error surface of `@suaveplan/docs`. Every failure path the package can take surfaces…
- [errors](./src/errors/errors.md) — The `errors` module owns the typed-error surface of `@suaveplan/docs`. Every failure path the package can take surfaces…
- [build-graph](./src/graph/build-graph.md) — `buildGraph` consumes the flat array returned by `discoverDocs` (or any `DocFile[]` you construct from `parseDoc` direc…
- [find-orphans](./src/graph/find-orphans.md) — `findOrphans` projects the `orphans` array of a `DocGraph` into an actionable `OrphanInfo[]` sorted by package name, pa…
- [hierarchy](./src/hierarchy/build-hierarchy.md) — `buildHierarchy` materialises a `DocGraph` into a tree (`DocHierarchy`) by treating every README as a root and recursiv…
- [build-hierarchy](./src/hierarchy/build-hierarchy.md) — `buildHierarchy` materialises a `DocGraph` into a tree (`DocHierarchy`) by treating every README as a root and recursiv…
- [observability](./src/observability/observability.md) — The `observability` module assembles the OpenTelemetry instruments emitted by `@suaveplan/docs`. `createDocsObservabili…
- [observability](./src/observability/observability.md) — The `observability` module assembles the OpenTelemetry instruments emitted by `@suaveplan/docs`. `createDocsObservabili…
- [parser](./src/parser/parse.md) — `parseDoc` turns the raw text of a single markdown file into a structured `DocFile` by composing `gray-matter` (frontma…
- [parse](./src/parser/parse.md) — `parseDoc` turns the raw text of a single markdown file into a structured `DocFile` by composing `gray-matter` (frontma…
- [search](./src/search/search.md) — `searchDocs` answers a free-text query against a `DocFile[]` and returns ranked hits with snippet context. It is intent…
- [search](./src/search/search.md) — `searchDocs` answers a free-text query against a `DocFile[]` and returns ranked hits with snippet context. It is intent…
<!-- end:auto-docs -->
## Install
```bash
bun add @suaveplan/docs
```
## Quick start
```ts
import {
buildGraph,
buildHierarchy,
discoverDocs,
findOrphans,
searchDocs,
} from "@suaveplan/docs";
const docs = await discoverDocs({ rootDir: "/path/to/project" });
const graph = buildGraph(docs);
const tree = buildHierarchy(graph);
const orphans = findOrphans(graph);
const hits = searchDocs(docs, "vault");
console.log(
`${docs.length} docs, ${tree.roots.length} packages, ${orphans.length} orphans, ${hits.length} matches`
);
```
## Modules
- [`discovery/discover.md`](./src/discovery/discover.md) — project-wide
`.md` discovery with `AbortSignal` and IO-error surfacing.
- [`parser/parse.md`](./src/parser/parse.md) — typed mdast parser with
frontmatter, H1 extraction, and link harvesting.
- [`graph/build-graph.md`](./src/graph/build-graph.md) — bidirectional
reference graph + roots + orphans.
- [`graph/find-orphans.md`](./src/graph/find-orphans.md) — orphan listing
with `suggestedParent` README pairing.
- [`hierarchy/build-hierarchy.md`](./src/hierarchy/build-hierarchy.md) —
hierarchy tree with configurable `maxDepth`.
- [`search/search.md`](./src/search/search.md) — scored search with snippet
context.
- [`errors/errors.md`](./src/errors/errors.md) — `DocsError` +
`DOCS_CODES` registry.
- [`observability/observability.md`](./src/observability/observability.md) —
OTel counters + histograms factory.

View File

@@ -0,0 +1,86 @@
# discoverDocs
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`discoverDocs` walks a project root directory and returns one structured
`DocFile` per markdown file it can read. It is the entry point for every
downstream operation in `@suaveplan/docs` — graph building, hierarchy
materialisation, search index construction, and link auditing all run against
the array it produces. Discovery is intentionally lenient with per-file IO
errors: a single unreadable `*.md` does not abort the sweep of a 10 000-file
project. Instead, the failure increments the `docs_io_error_total` counter
and the surrounding walk continues.
## Features
- Zod-validated `DiscoverOptions` (`rootDir`, `ignore`, `includeApps`,
`signal`) — bad arguments raise `DocsError(DOCS.INVALID_OPTIONS)`.
- `AbortSignal` propagation — checked before glob, after glob, and every 64
parsed files. Aborts surface as `DocsError(DOCS.ABORTED)`.
- `apps/` opt-in via `includeApps: true`.
- Default ignores: `node_modules/`, `dist/`, `.git/`, `coverage/`, `.turbo/`.
- Per-file IO errors counted (`docs_io_error_total{code}`) and dropped from
the result without throwing.
- `docs_discover_duration_ms` histogram + `docs_discovered_total` counter
recorded on every call.
- Output is sorted ascending by `relativePath` via `localeCompare`.
## Usage: Basic
```ts
import { discoverDocs } from "@suaveplan/docs";
const docs = await discoverDocs({ rootDir: "/repo" });
console.log(`Found ${docs.length} docs`);
```
## Usage: Advanced
```ts
import { createDocsObservability, discoverDocs } from "@suaveplan/docs";
const observability = createDocsObservability();
const controller = new AbortController();
setTimeout(() => controller.abort(), 30_000);
const docs = await discoverDocs(
{
rootDir: "/repo",
includeApps: true,
ignore: ["**/__snapshots__/**", "**/CHANGELOG.md"],
signal: controller.signal,
},
observability
);
```
## API Reference
### `discoverDocs(options, observability?): Promise<DocFile[]>`
- **`options`** — `DiscoverOptions`. `rootDir` (string, non-empty) is
required. Optional: `ignore: string[]`, `includeApps: boolean`,
`signal: AbortSignal`.
- **`observability`** — optional `DocsObservability` bundle. When omitted a
no-op bundle is materialised via `@opentelemetry/api`'s default providers.
- **Returns** — `Promise<DocFile[]>` sorted by `relativePath`.
- **Throws** — `DocsError(DOCS.INVALID_OPTIONS)` on schema failure;
`DocsError(DOCS.GLOB_FAILED)` on a directory-level IO failure;
`DocsError(DOCS.ABORTED)` when the signal aborts mid-scan.
## Implementation Notes
The glob set is `["packages/**/*.md", "*.md"]` (plus `"apps/**/*.md"` when
`includeApps: true`). Per-file `readFile` errors are caught individually so a
single permission-denied file does not abort the whole sweep — callers
needing hard-fail behaviour can read `docs_io_error_total` after the call.
The `ABORT_CHECK_BATCH` of 64 is a balance between cancellation latency and
microtask overhead; under typical project sweeps (~3 000 docs) it surfaces
abort within ~50 ms of `controller.abort()`. Parser failures escalate to
`DOCS.PARSE_FAILED` via `parseDoc` and are counted on both `parseErrorTotal`
and `ioErrorTotal` so dashboards can partition the cause without losing the
aggregate failure rate.

View File

@@ -0,0 +1,102 @@
# errors
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The `errors` module owns the typed-error surface of `@suaveplan/docs`. Every
failure path the package can take surfaces a `DocsError` whose `code` is
drawn from a frozen registry — callers (loggers, retry policies, monitoring
pipelines) classify failures programmatically without ever parsing message
strings. The registry is exhaustive: eight codes cover every observable
failure mode, and every code is reachable from a real call site exercised by
tests.
## Features
- Frozen registry via `defineCodes("DOCS", […] as const)` — eight stable
namespaced codes.
- `DocsError extends SuaveplanError<DocsErrorCode>` — inherits `cause`,
`timestamp`, `context`, `traceId`, and OTel span-event integration.
- `asDocsError(code, cause, fallback)` — lifts an arbitrary throwable into a
typed `DocsError`, preserving the cause and adopting its message when
present.
- Tree-shakeable: importing only `DOCS_CODES` does not pull `DocsError`.
## Usage: Basic
```ts
import { DOCS_CODES, DocsError } from "@suaveplan/docs";
throw new DocsError(
DOCS_CODES.INVALID_OPTIONS,
"rootDir must be a non-empty string"
);
```
## Usage: Advanced
```ts
import { asDocsError, DOCS_CODES, DocsError } from "@suaveplan/docs";
try {
await someFsOperation();
} catch (cause) {
// Lift an unknown throwable into the typed contract without losing
// the original stack / message.
throw asDocsError(
DOCS_CODES.READ_FAILED,
cause,
"failed to read documentation file"
);
}
// Downstream: discriminate by code, never by message.
try {
await runDiscovery();
} catch (err) {
if (err instanceof DocsError && err.code === DOCS_CODES.ABORTED) {
// Expected — caller cancelled the scan.
return [];
}
throw err;
}
```
## API Reference
### `DOCS_CODES`
Frozen record mapping reason → fully-qualified namespaced string. Keys:
`INVALID_OPTIONS`, `READ_FAILED`, `PARSE_FAILED`, `GLOB_FAILED`, `ABORTED`,
`LINK_RESOLUTION_FAILED`, `HIERARCHY_CYCLE_LIMIT`, `SEARCH_INVALID_QUERY`.
Values are `"DOCS.<reason>"`.
### `DocsErrorCode`
Type alias — union of every value in `DOCS_CODES`.
### `DocsError`
Class extending `SuaveplanError<DocsErrorCode>`. Constructor:
`new DocsError(code, message, options?)`. `options.cause` accepts any
`Error` (preserved verbatim on `.cause`).
### `asDocsError(code, cause, fallbackMessage)`
Returns a fresh `DocsError`. When `cause` is an `Error` with a non-empty
message, that message is adopted; otherwise `fallbackMessage` is used. The
returned error's `.cause` is always an `Error` instance (string and other
non-Error values are wrapped via `new Error(String(value))`).
## Implementation Notes
`DOCS_CODES` is deliberately not exported as a TypeScript `enum` — `enum`
emits runtime code and resists tree-shaking, while a `Object.freeze`d record
created by `defineCodes` is statically inferable, runtime-immutable, and
zero-cost. The frozen registry is the proposal expressed in code: removing
an entry to satisfy an unused-export gate violates §2c of the OpenSpec
AGENTS — every code is wired into a real `throw` or counter label inside
the package.

View File

@@ -0,0 +1,78 @@
# buildGraph
## Purpose
`buildGraph` consumes the flat array returned by `discoverDocs` (or any
`DocFile[]` you construct from `parseDoc` directly) and produces a
bidirectional reference graph: every doc is indexed by id, every outbound
markdown link is resolved against the input set, and the inverse
`referencedBy` map is populated atomically. The graph is the foundation for
`findOrphans`, `buildHierarchy`, and downstream link-integrity audits, so
correctness here propagates everywhere.
## Features
- Indexes every input doc by id (`nodes`) and by `relativePath` (`pathIndex`,
internal).
- Resolves relative markdown links across three strategies: direct path
match, `+.md` append, and basename fallback.
- Strips `#fragment` suffixes before resolution.
- Rejects `http(s):`, `mailto:`, and `#`-prefixed hrefs.
- Drops self-references (link from a doc to itself produces no edge).
- Designates every README (`isReadme === true`) as a root.
- Computes `orphans` as non-README, non-root docs with no inbound edges.
- Mutates `link.resolvedId` in place so downstream code can read the
resolved id off the original `DocLink` reference.
- Records `docs_graph_build_duration_ms` histogram on every call.
## Usage: Basic
```ts
import { buildGraph, discoverDocs } from "@suaveplan/docs";
const docs = await discoverDocs({ rootDir: "/repo" });
const graph = buildGraph(docs);
console.log(graph.nodes.size, graph.roots.length, graph.orphans.length);
```
## Usage: Advanced
```ts
import {
buildGraph,
createDocsObservability,
discoverDocs,
} from "@suaveplan/docs";
const observability = createDocsObservability();
const docs = await discoverDocs({ rootDir: "/repo" }, observability);
const graph = buildGraph(docs, observability);
for (const [docId, refs] of graph.references) {
const doc = graph.nodes.get(docId)!;
if (refs.size === 0 && doc.isReadme) {
console.warn(`README ${doc.relativePath} links to nothing`);
}
}
```
## API Reference
### `buildGraph(docs, observability?): DocGraph`
- **`docs`** — array of `DocFile` values (typically from `discoverDocs`).
- **`observability`** — optional `DocsObservability` bundle.
- **Returns** — `DocGraph` with `nodes`, `references`, `referencedBy`,
`orphans`, and `roots` fields. Maps and sets are mutable; do not share
the graph across mutating consumers without copying.
## Implementation Notes
Link resolution runs in two passes: first the `pathIndex` is populated so
every doc can resolve forward links to every other doc (including those that
appear later in the array). The basename fallback exists to support
README-relative shortcuts like `[String](string.md)` from a sibling, but it
costs `O(D)` per unresolved link in a doc with `D` known paths. Orphan
detection deliberately excludes the `"root"` category so top-level
`CHANGELOG.md` / `CONTRIBUTING.md` / etc. don't get flagged as orphans — they
have no package home, so there is no README to link from.

View File

@@ -0,0 +1,82 @@
# findOrphans
## Purpose
`findOrphans` projects the `orphans` array of a `DocGraph` into an
actionable `OrphanInfo[]` sorted by package name, pairing each orphan with
the README of the same package (when one exists) as a natural target for
adding a link. It is the workhorse for project doc-completeness audits — a
green orphan count means every non-README doc is reachable from a README,
which is the suaveplan policy.
## Features
- One-to-one mapping from `graph.orphans` to `OrphanInfo`.
- `suggestedParent` populated when a README in the same package exists,
omitted otherwise (the field is genuinely optional, not always present).
- Stable sort by `doc.packageName` via `localeCompare`.
- Pure, synchronous, deterministic — safe to call inside test assertions
and CI link-checkers without observability wiring.
## Usage: Basic
```ts
import { buildGraph, discoverDocs, findOrphans } from "@suaveplan/docs";
const docs = await discoverDocs({ rootDir: "/repo" });
const graph = buildGraph(docs);
const orphans = findOrphans(graph);
for (const { doc, suggestedParent } of orphans) {
console.log(`${doc.relativePath}`);
if (suggestedParent) {
console.log(` → add link in ${suggestedParent.relativePath}`);
}
}
```
## Usage: Advanced
```ts
import {
buildGraph,
discoverDocs,
findOrphans,
type OrphanInfo,
} from "@suaveplan/docs";
const docs = await discoverDocs({ rootDir: "/repo" });
const graph = buildGraph(docs);
const orphans: OrphanInfo[] = findOrphans(graph);
// Audit: every orphan must have a suggestedParent — anything without one is
// a "homeless" doc that lives outside any package and must move.
const homeless = orphans.filter((o) => o.suggestedParent === undefined);
if (homeless.length > 0) {
const report = homeless
.map((o) => ` - ${o.doc.relativePath} (${o.doc.packageName})`)
.join("\n");
throw new Error(`Homeless orphan docs:\n${report}`);
}
```
## API Reference
### `findOrphans(graph): OrphanInfo[]`
- **`graph`** — a `DocGraph` produced by `buildGraph`.
- **Returns** — array of `OrphanInfo` values:
- `doc: DocFile` — the orphan itself.
- `suggestedParent?: DocFile` — the README of the same package when one
exists; omitted otherwise.
## Implementation Notes
`suggestedParent` is matched on `packageName` equality with `isReadme`. The
matching is strict because the upstream policy is "every package has at most
one README"; if two READMEs exist for the same `packageName` (e.g. a
malformed project where a sub-folder also carries `README.md`), the first
hit from `graph.roots` wins — that is the deliberate behaviour, since
`graph.roots` is itself the canonical list of READMEs and any duplication is
a defect to be fixed upstream. The sort is stable across runs because
`localeCompare` is deterministic and `Array.prototype.sort` in ES2019+
guarantees stability.

View File

@@ -0,0 +1,88 @@
# buildHierarchy
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`buildHierarchy` materialises a `DocGraph` into a tree (`DocHierarchy`) by
treating every README as a root and recursively descending into referenced
documents. The walk is cycle-safe — each id is visited at most once per root
— and is bounded by a configurable `maxDepth` (default 8). It is the input
for documentation-site sidebars, the source-of-truth for "what package owns
this doc" audits, and the contract behind any hand-rolled doc TOC.
## Features
- One `DocNode` per README in `graph.roots`.
- `byCategory` groups roots by `DocCategory` for category-coloured sidebars.
- Recursion is depth-bounded; pass `{ maxDepth: 0 }` to flatten or
`{ maxDepth: N }` for a finer-grained walk.
- Children sorted with READMEs first, then alphabetically by title — so
sub-package READMEs appear above their leaf docs.
- `orphans` field mirrors `graph.orphans` so downstream consumers don't need
to thread the graph through to render an "unreachable docs" panel.
- Pure, synchronous, deterministic.
## Usage: Basic
```ts
import {
buildGraph,
buildHierarchy,
discoverDocs,
} from "@suaveplan/docs";
const docs = await discoverDocs({ rootDir: "/repo" });
const graph = buildGraph(docs);
const tree = buildHierarchy(graph);
console.log(tree.roots.length, Object.keys(tree.byCategory).length);
```
## Usage: Advanced
```ts
import {
buildGraph,
buildHierarchy,
type DocNode,
discoverDocs,
} from "@suaveplan/docs";
const docs = await discoverDocs({ rootDir: "/repo" });
const graph = buildGraph(docs);
const tree = buildHierarchy(graph, { maxDepth: 3 });
function walk(node: DocNode, depth = 0): void {
console.log(`${" ".repeat(depth)}${node.doc.title}`);
for (const child of node.children) walk(child, depth + 1);
}
for (const root of tree.roots) walk(root);
```
## API Reference
### `buildHierarchy(graph, options?): DocHierarchy`
- **`graph`** — a `DocGraph` from `buildGraph`.
- **`options.maxDepth`** — maximum descent depth from any root (default 8).
Pass `0` for flat root-only output.
- **Returns** — `DocHierarchy` with:
- `roots: DocNode[]` — one per README in the graph.
- `byCategory: Partial<Record<DocCategory, DocNode[]>>` — roots grouped
by category, sorted alphabetically by package name within each bucket.
- `orphans: DocFile[]` — verbatim from `graph.orphans`.
## Implementation Notes
Cycle safety is enforced via a `visited: Set<string>` instantiated per root.
Two roots that reference each other will each see the other as a child on
the first descent but not the second — the visited set is intentionally
not shared across roots because doing so would prevent a README from being
listed under both `byCategory.foundation` and an `Identity` README that also
links to it. The depth limit defaults to 8 because eight directory levels
covers every observed nesting, and a budget that low keeps a
pathological hand-edited project doc set from exploding into millions of
nodes. `maxDepth: 0` yields root nodes with empty `children` arrays — useful
for "list every package" callers that don't care about doc nesting.

View File

@@ -0,0 +1,89 @@
# observability
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The `observability` module assembles the OpenTelemetry instruments emitted
by `@suaveplan/docs`. `createDocsObservability` returns a single bundle —
meter, tracer, four counters, four histograms — that the rest of the
package threads through every public entrypoint. The factory falls back to
the no-op providers from `@opentelemetry/api` when no meter / tracer is
supplied, so callers without an OTel SDK pay no runtime cost and every
public method is safe to call from a freshly imported test process.
## Features
- Four counters: `discoveredTotal`, `parseErrorTotal`, `ioErrorTotal`,
`searchTotal`.
- Four histograms (units: `ms`): `discoverDurationMs`, `parseDurationMs`,
`graphBuildDurationMs`, `searchDurationMs`.
- Caller injection — `opts.observability` returns the supplied bundle
verbatim (zero allocation) for hot-path callers.
- Explicit `meter` / `tracer` override — accepts any
`@opentelemetry/api` `Meter` and `Tracer`, including SDK-backed ones.
- Defaults via `metrics.getMeter("@suaveplan/docs")` and
`trace.getTracer("@suaveplan/docs")` so collectors automatically
attribute the data to this package.
## Usage: Basic
```ts
import { createDocsObservability } from "@suaveplan/docs";
const obs = createDocsObservability();
obs.counters.discoveredTotal.add(1);
obs.histograms.discoverDurationMs.record(42.3);
```
## Usage: Advanced
```ts
import { metrics, trace } from "@opentelemetry/api";
import {
createDocsObservability,
discoverDocs,
} from "@suaveplan/docs";
// Use a project-specific meter so dashboards can filter on the namespace.
const meter = metrics.getMeter("my-app.docs-pipeline");
const tracer = trace.getTracer("my-app.docs-pipeline");
const observability = createDocsObservability({ meter, tracer });
// Pass the same bundle to every package call so a single trace spans
// discovery, parsing, graph build, and search.
const docs = await discoverDocs({ rootDir: "/repo" }, observability);
```
## API Reference
### `createDocsObservability(opts?): DocsObservability`
- **`opts.observability`** — when supplied, returned verbatim.
- **`opts.meter`** — `Meter` to use (defaults to `metrics.getMeter`).
- **`opts.tracer`** — `Tracer` to use (defaults to `trace.getTracer`).
- **Returns** — `DocsObservability` bundle with `meter`, `tracer`,
`counters`, `histograms` fields.
### `DocsObservability`
Bundle of:
- `counters: DocsCounters` — `discoveredTotal`, `parseErrorTotal`,
`ioErrorTotal`, `searchTotal`.
- `histograms: DocsHistograms` — `discoverDurationMs`, `parseDurationMs`,
`graphBuildDurationMs`, `searchDurationMs`.
## Implementation Notes
Counters and histograms are created via the helpers in
`@suaveplan/opentelemetry`, which normalise the optional `description` /
`unit` arguments away from the OTel API's awkward optional-object shape.
The no-op provider path is critical for the test process — every test in
`@suaveplan/docs` creates an observability bundle implicitly via the
public functions' fallback path and records into it, and the no-op
implementations accept arbitrary attribute objects without allocating. The
named meter (`@suaveplan/docs`) lets collectors apply package-level
sampling and retention without affecting unrelated subsystems.

View File

@@ -0,0 +1,88 @@
# parseDoc
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`parseDoc` turns the raw text of a single markdown file into a structured
`DocFile` by composing `gray-matter` (frontmatter), `unified` + `remark-parse`
+ `remark-gfm` (mdast), and `unist-util-visit` (typed traversal). It is the
atomic unit used both internally by `discoverDocs` and externally by callers
that already have markdown in memory (e.g. consumers that read from a content
DB rather than the filesystem). Every traversal is fully typed via
`@types/mdast` — there are no `any` casts and no unchecked field accesses.
## Features
- Stable 16-character hex id derived from `sha256(relativePath)`.
- Title precedence: `frontmatter.title` (string) → first H1 → filename
without `.md` extension.
- Typed link extraction via `unist-util-visit<Link>`.
- Full `DocCategory` derivation across the eighteen suaveplan super-categories
plus `"root"`.
- Package-name derivation across `packages/<category>/<plugin>/<pkg>/…`,
`apps/<name>/…`, and root-level files.
- Records `docs_parse_duration_ms` histogram + `docs_parse_error_total`
counter on parser failure.
- Wraps every thrown cause into `DocsError(DOCS.PARSE_FAILED)`.
## Usage: Basic
```ts
import { parseDoc } from "@suaveplan/docs";
const doc = parseDoc(
"/repo/packages/devtools/docs/docs/README.md",
"packages/devtools/docs/docs/README.md",
"# @suaveplan/docs\n\nMarkdown toolkit."
);
console.log(doc.title); // "@suaveplan/docs"
console.log(doc.category); // "devtools"
console.log(doc.packageName); // "@suaveplan/docs"
```
## Usage: Advanced
```ts
import { createDocsObservability, parseDoc } from "@suaveplan/docs";
const obs = createDocsObservability();
const raw = '---\norder: 1\ntags: [auth]\n---\n# Auth\n\n[X](./src/x.md)';
const doc = parseDoc(
"/repo/packages/identity/auth/auth/README.md",
"packages/identity/auth/auth/README.md",
raw,
obs
);
console.log(doc.frontmatter); // { order: 1, tags: ["auth"] }
console.log(doc.links); // [{ text: "X", href: "./src/x.md" }]
console.log(doc.content === raw); // true — raw content is preserved verbatim
```
## API Reference
### `parseDoc(absolutePath, relativePath, rawContent, observability?): DocFile`
- **`absolutePath`** — absolute filesystem path; used for `doc.path` and
basename / `isReadme` checks. Not read from disk.
- **`relativePath`** — path relative to the project root; used for id,
category, and package-name derivation.
- **`rawContent`** — raw file content including frontmatter delimiters.
- **`observability`** — optional `DocsObservability`. Defaults to no-op.
- **Returns** — `DocFile` with every field populated.
- **Throws** — `DocsError(DOCS.PARSE_FAILED)` on malformed frontmatter or
mdast parser failure.
## Implementation Notes
The mdast traversal uses `unist-util-visit<Heading>` and `<Link>` for full
type safety; `Text` nodes are narrowed by `child.type === "text"` rather than
cast. Frontmatter `title` overrides the H1 only when it is a string (numeric
or null frontmatter is treated as if absent). The category lookup is an
`O(1)` set membership against `suaveplan_CATEGORIES`, so unknown second-segment
values like `legacy/` fall through to `"root"` rather than introducing a
new category implicitly. `performance.now()` differences are recorded on
both the success and failure paths so the histogram always reflects work
done.

View File

@@ -0,0 +1,91 @@
# searchDocs
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`searchDocs` answers a free-text query against a `DocFile[]` and returns
ranked hits with snippet context. It is intentionally a synchronous in-memory
search rather than an indexed full-text engine: Suaveplan
typically holds a few thousand docs, the search input is small, and the
caller's process already has the parsed docs in memory after `discoverDocs`.
For larger corpora, callers are expected to compose this with their own
indexer; `searchDocs` is the in-memory baseline.
## Features
- Scoring layered across title, package name, and content matches.
- exact title match: `+100`
- title starts with query: `+50`
- title contains query: `+20`
- package name contains query: `+15`
- content match: `+1` per occurrence, capped at 5.
- Case-insensitive matching across all fields.
- Snippet context: up to five content snippets per doc, ±40 chars around
each match, newlines collapsed.
- Empty / whitespace-only queries return `[]` (not an error).
- Non-string queries throw `DocsError(DOCS.SEARCH_INVALID_QUERY)`.
- `limit` defaults to 20; pass any positive integer to truncate.
- Records `docs_search_total` counter and `docs_search_duration_ms`
histogram on every call.
## Usage: Basic
```ts
import { searchDocs } from "@suaveplan/docs";
const hits = searchDocs(docs, "authentication");
for (const { doc, score, matches } of hits) {
console.log(`${doc.title} (${score})`);
for (const m of matches) console.log(` ${m}`);
}
```
## Usage: Advanced
```ts
import {
createDocsObservability,
discoverDocs,
searchDocs,
} from "@suaveplan/docs";
const observability = createDocsObservability();
const docs = await discoverDocs({ rootDir: "/repo" }, observability);
const top5 = searchDocs(docs, "WebAuthn", 5, observability);
const exactPackage = searchDocs(
docs,
"@suaveplan/webauthn",
1,
observability
);
```
## API Reference
### `searchDocs(docs, query, limit?, observability?): SearchResult[]`
- **`docs`** — `DocFile[]` to search.
- **`query`** — search string. Whitespace-trimmed before matching.
- **`limit`** — maximum results (default 20).
- **`observability`** — optional `DocsObservability` bundle.
- **Returns** — `SearchResult[]` sorted by descending `score`. Each result
carries the original `DocFile`, a `matches: string[]` of context snippets,
and the numeric `score`.
- **Throws** — `DocsError(DOCS.SEARCH_INVALID_QUERY)` when `query` is not a
string.
## Implementation Notes
The content-match cap (5) is deliberate — without it, a long doc that
mentions the query 50 times would dominate the result set and crowd out
legitimately more relevant short docs. The `+15` package-name bonus is added
once per doc even when the title also matched, but the snippet array is
deduplicated to avoid showing the package name twice. The snippet radius of
40 characters is large enough to capture surrounding sentence context yet
short enough that the result fits comfortably in a terminal column. Scoring
is monotonic: any doc with a match scores at least `+1`, so the empty-array
fast path in the loop is safe.

View File

@@ -0,0 +1,107 @@
# docs.types
## Purpose
The `docs.types` module owns every public type emitted by `@suaveplan/docs`
plus the Zod schema that guards the `discoverDocs` boundary. It is the
shared vocabulary used by the parser, graph builder, hierarchy walker, and
search engine — they all import their input / output shapes from this one
file. Keeping the types here (rather than scattered next to each
implementation) keeps the public surface auditable in a single place and
guarantees that runtime callers can `import type` exactly what they need
without pulling in implementation code.
## Features
- `DocFile`, `DocLink`, `DocNode`, `DocGraph`, `DocHierarchy`, `OrphanInfo`,
`SearchResult`, `DiscoverOptions`, `BuildHierarchyOptions` — the full
public shape returned by every package function.
- `DocCategory` — closed union of the eighteen suaveplan super-categories
plus `"root"`, mirroring `CLAUDE.md` / `scripts/folder-reorg/assignment.yml`.
- `suaveplan_CATEGORIES` — runtime array of the same vocabulary so callers
can enumerate, validate, and filter without parsing the type.
- `DiscoverOptionsSchema` — Zod schema enforcing `rootDir: string (non-empty)`,
optional `ignore: string[]`, optional `includeApps: boolean`, optional
`signal: AbortSignal`. Used by `discoverDocs` to reject malformed
options with `DocsError(DOCS.INVALID_OPTIONS)`.
## Usage: Basic
```ts
import type { DiscoverOptions, DocFile } from "@suaveplan/docs";
const opts: DiscoverOptions = { rootDir: "/repo" };
const docs: DocFile[] = await discoverDocs(opts);
```
## Usage: Advanced
```ts
import {
DiscoverOptionsSchema,
type DocCategory,
suaveplan_CATEGORIES,
} from "@suaveplan/docs";
// Validate options without invoking discovery — useful for ahead-of-time
// CLI argument parsing.
const parsed = DiscoverOptionsSchema.safeParse({ rootDir: "/repo" });
if (!parsed.success) throw new Error(parsed.error.message);
// Iterate every recognised category to render a category-coloured legend.
for (const cat of suaveplan_CATEGORIES) {
const typed: DocCategory = cat;
console.log(typed);
}
```
## API Reference
### `DocFile`
Parsed markdown file with `id`, `path`, `relativePath`, `packageName`,
`category`, `filename`, `title`, `content`, `links`, `isReadme`, and
`frontmatter`.
### `DocLink`
A single markdown link with `text`, `href`, and optional `resolvedId`.
### `DocGraph`
Bidirectional reference graph with `nodes`, `references`, `referencedBy`,
`orphans`, and `roots` fields.
### `DocNode` / `DocHierarchy`
Hierarchy tree node + container shape.
### `DiscoverOptions` / `DiscoverOptionsSchema`
Parameter bag for `discoverDocs`; the schema mirrors the type and runs at
the package boundary.
### `BuildHierarchyOptions`
`{ maxDepth?: number }` — `buildHierarchy` descent cap (default 8).
### `DocCategory` / `suaveplan_CATEGORIES`
Closed union of eighteen suaveplan categories plus `"root"`; the runtime
array is iterated by the parser's category-deriver.
### `SearchResult`
`{ doc, matches, score }` shape returned by `searchDocs`.
## Implementation Notes
`DocCategory` is intentionally a closed union — adding a new suaveplan
category requires an spec change because every consumer that exhaustive
-switches on it would silently break. `suaveplan_CATEGORIES` is exported as a
`readonly` const tuple so the union type and the runtime list cannot drift.
`DiscoverOptionsSchema` is the canonical input contract; its inferred shape
is structurally identical to `DiscoverOptions`, but the runtime schema is
the authority that `discoverDocs` actually runs. Callers building custom
discovery wrappers should re-validate through this schema rather than
trusting the static type.

View File

@@ -0,0 +1,133 @@
# @suaveplan/pdf - Server-tier PDF 1.4 generation toolkit (Chunks A–D): typed PDF error registry, Zod boundary schemas, OpenTelemetry counters + histograms, unit conversion, hex/RGB colour utilities, page-size + margin types, hand-rolled PDF 1.4 binary writer (with FlateDecode + AbortSignal + injectable Clock), chainable content-stream encoder, document elements (text / table / image / divider) with Zod boundaries and AbortSignal propagation, style cascade with default theme, block-flow layout engine with header/footer slots, fluent DocumentBuilder, and orchestrating renderDocument entry point round-trippable via pdf-parse.
Server-tier PDF 1.4 generation toolkit. **Chunk A foundation** — pure
utilities + cross-cutting wiring (typed error registry, Zod boundary
schemas, OpenTelemetry counters + histograms, unit + colour utilities,
page-size + margin types). The low-level writer, element primitives,
layout engine, document builder, renderer, templates, and React preview
component land in subsequent chunks (B–E). See
[`enterprise-pdf/tasks.md`](../../../../docs/design/enterprise-pdf/tasks.md)
for the chunk roadmap.
<!-- begin:auto-docs -->
## Documentation tree
- [colors](./src/colors/colors.md) — Colour utilities for `@suaveplan/pdf`. The PDF specification stores colours as three floats in the `0`-`1` range (consu…
- [colors](./src/colors/colors.md) — Colour utilities for `@suaveplan/pdf`. The PDF specification stores colours as three floats in the `0`-`1` range (consu…
- [content-stream](./src/content-stream/content-stream.md) — `ContentStreamEncoder` is the chainable builder for PDF 1.4 content streams used by every page emitted by `@suaveplan/p…
- [content-stream](./src/content-stream/content-stream.md) — `ContentStreamEncoder` is the chainable builder for PDF 1.4 content streams used by every page emitted by `@suaveplan/p…
- [divider-element](./src/divider-element/divider-element.md) — `DividerElement` renders a horizontal or vertical rule into a PDF content stream. It is the lightweight separator primi…
- [divider-element](./src/divider-element/divider-element.md) — `DividerElement` renders a horizontal or vertical rule into a PDF content stream. It is the lightweight separator primi…
- [document-builder](./src/document-builder/document-builder.md) — The `DocumentBuilder` provides a chainable, deterministic API for assembling a PDF document tree without touching the l…
- [document-builder](./src/document-builder/document-builder.md) — The `DocumentBuilder` provides a chainable, deterministic API for assembling a PDF document tree without touching the l…
- [error-codes](./src/error-codes/error-codes.md) — Declares the stable `PDF.*` error-code registry and the `PdfError` class used by every public function in `@suaveplan/p…
- [error-codes](./src/error-codes/error-codes.md) — Declares the stable `PDF.*` error-code registry and the `PdfError` class used by every public function in `@suaveplan/p…
- [image-element](./src/image-element/image-element.md) — `ImageElement` is the bitmap primitive of `@suaveplan/pdf`. It accepts raw JPEG or PNG bytes, decodes them at construct…
- [image-element](./src/image-element/image-element.md) — `ImageElement` is the bitmap primitive of `@suaveplan/pdf`. It accepts raw JPEG or PNG bytes, decodes them at construct…
- [image-registry](./src/image-registry/image-registry.md) — The `ImageRegistry` allocates and reuses PDF Image XObjects across the lifetime of a single `renderDocument` call. Each…
- [image-registry](./src/image-registry/image-registry.md) — The `ImageRegistry` allocates and reuses PDF Image XObjects across the lifetime of a single `renderDocument` call. Each…
- [layout-engine](./src/layout-engine/layout-engine.md) — The `LayoutEngine` owns the block-flow geometry the renderer walks to position each element on the page. It computes ef…
- [layout-engine](./src/layout-engine/layout-engine.md) — The `LayoutEngine` owns the block-flow geometry the renderer walks to position each element on the page. It computes ef…
- [pdf-writer](./src/pdf-writer/pdf-writer.md) — `PdfWriter` is the low-level binary writer that emits valid PDF 1.4 byte streams. It manages indirect-object allocation…
- [pdf-writer](./src/pdf-writer/pdf-writer.md) — `PdfWriter` is the low-level binary writer that emits valid PDF 1.4 byte streams. It manages indirect-object allocation…
- [render-pipeline](./src/render-pipeline/render-pipeline.md) — Internal pipeline helpers that the top-level `renderDocument` orchestrator delegates to. Splitting placement and slot-r…
- [render-pipeline](./src/render-pipeline/render-pipeline.md) — Internal pipeline helpers that the top-level `renderDocument` orchestrator delegates to. Splitting placement and slot-r…
- [renderer](./src/renderer/renderer.md) — `renderDocument` is the top-level orchestrator that turns a `Document` AST (produced by `DocumentBuilder`) into a real…
- [renderer](./src/renderer/renderer.md) — `renderDocument` is the top-level orchestrator that turns a `Document` AST (produced by `DocumentBuilder`) into a real…
- [schemas](./src/schemas/schemas.md) — Zod boundary schemas for the public surface of `@suaveplan/pdf`. Every value crossing the package boundary from untrust…
- [schemas](./src/schemas/schemas.md) — Zod boundary schemas for the public surface of `@suaveplan/pdf`. Every value crossing the package boundary from untrust…
- [style-resolver](./src/style-resolver/style-resolver.md) — The `StyleResolver` resolves a fully-populated `PdfStyle` from the package defaults, an active theme, an optional base…
- [style-resolver](./src/style-resolver/style-resolver.md) — The `StyleResolver` resolves a fully-populated `PdfStyle` from the package defaults, an active theme, an optional base…
- [table-element](./src/table-element/table-element.md) — `TableElement` is the tabular-data primitive of `@suaveplan/pdf`. It accepts a typed column definition + row array and…
- [table-element](./src/table-element/table-element.md) — `TableElement` is the tabular-data primitive of `@suaveplan/pdf`. It accepts a typed column definition + row array and…
- [telemetry](./src/telemetry/telemetry.md) — OpenTelemetry surface for `@suaveplan/pdf`. PDF generation is a load-bearing primitive in any system that emits invoice…
- [telemetry](./src/telemetry/telemetry.md) — OpenTelemetry surface for `@suaveplan/pdf`. PDF generation is a load-bearing primitive in any system that emits invoice…
- [invoice-template](./src/templates/invoice-template/invoice-template.md) — `createInvoiceTemplate(data, options?)` converts a typed `InvoiceData` payload into a renderer-ready `Document` for pro…
- [invoice-template](./src/templates/invoice-template/invoice-template.md) — `createInvoiceTemplate(data, options?)` converts a typed `InvoiceData` payload into a renderer-ready `Document` for pro…
- [receipt-template](./src/templates/receipt-template/receipt-template.md) — `createReceiptTemplate(data, options?)` converts a typed `ReceiptData` payload into a renderer-ready `Document` for com…
- [receipt-template](./src/templates/receipt-template/receipt-template.md) — `createReceiptTemplate(data, options?)` converts a typed `ReceiptData` payload into a renderer-ready `Document` for com…
- [report-template](./src/templates/report-template/report-template.md) — `createReportTemplate(data, options?)` converts a typed `ReportData` payload into a renderer-ready `Document` for multi…
- [report-template](./src/templates/report-template/report-template.md) — `createReportTemplate(data, options?)` converts a typed `ReportData` payload into a renderer-ready `Document` for multi…
- [text-element](./src/text-element/text-element.md) — `TextElement` is the flowed-text primitive of `@suaveplan/pdf`. It accepts a content string plus typography options (fo…
- [text-element](./src/text-element/text-element.md) — `TextElement` is the flowed-text primitive of `@suaveplan/pdf`. It accepts a content string plus typography options (fo…
- [types](./src/types/page-size.md) — Defines the page-size + page-config primitives used across every other module in `@suaveplan/pdf`. PDF measurements are…
- [page-size](./src/types/page-size.md) — Defines the page-size + page-config primitives used across every other module in `@suaveplan/pdf`. PDF measurements are…
- [units](./src/units/units.md) — Unit-conversion utilities for `@suaveplan/pdf`. PDF's native measurement is the **point** (`1pt = 1/72 inch`); every co…
- [units](./src/units/units.md) — Unit-conversion utilities for `@suaveplan/pdf`. PDF's native measurement is the **point** (`1pt = 1/72 inch`); every co…
<!-- end:auto-docs -->
## Install
```bash
bun add @suaveplan/pdf
```
## Surface (Chunk A)
| Subpath | Symbols |
| --- | --- |
| `@suaveplan/pdf/error-codes` | `PDF_CODES`, `PdfError`, `asPdfError`, `PdfErrorCode` |
| `@suaveplan/pdf/schemas` | `UnitSchema`, `HexColorSchema`, `RgbColorSchema`, `MarginsSchema`, `PageConfigSchema`, `DocumentMetadataSchema`, `parseOrThrow`, `parseHexColor` |
| `@suaveplan/pdf/telemetry` | `createPdfObservability`, six `record*` helpers, `PdfObservability` |
| `@suaveplan/pdf/units` | `Unit`, `toPoints`, `fromPoints`, `convert`, `parseUnit`, `formatUnit` |
| `@suaveplan/pdf/colors` | `RgbColor`, `PdfColor`, `hexToRgb`, `rgbToPdfColor`, `hexToPdfColor`, `rgbToHex`, `lighten`, `darken`, `COLORS`, `isTransparent` |
| `@suaveplan/pdf/types` | `PageSize`, `PAGE_DIMENSIONS`, `Margins`, `DEFAULT_MARGINS`, `DocumentMetadata`, `PageConfig`, `DEFAULT_PAGE_CONFIG`, `DocumentDefinition`, `PageDefinition`, `ElementNode` |
The package barrel `@suaveplan/pdf` re-exports every symbol above.
## Example
```ts
import {
convert,
hexToPdfColor,
parseOrThrow,
PageConfigSchema,
PAGE_DIMENSIONS,
PageSize,
PDF_CODES,
} from "@suaveplan/pdf";
const a4 = PAGE_DIMENSIONS[PageSize.A4];
const inchInMm = convert(1, "in", "mm"); // ≈ 25.4
const accent = hexToPdfColor("#3366cc"); // PDF-native 0-1 triple
const page = parseOrThrow(
PageConfigSchema,
requestBody.page,
PDF_CODES.INVALID_PAGE_CONFIG,
"bad page config"
);
```
## Dependencies
- `@suaveplan/error` — typed-error base.
- `@suaveplan/opentelemetry` — counter / histogram helpers.
- `@suaveplan/types` — shared `Clock` etc. for later chunks.
- `@opentelemetry/api` — meter / tracer global API.
- `zod` — boundary validation.
- `tslib` — runtime helpers.
No third-party PDF dependency. Chunk B will keep the hand-rolled PDF 1.4
writer the upstream package ships with — the spec is small enough that
shipping a custom 200 LOC implementation is preferable to pulling in
`pdfkit` (`~600 KB` unpacked, CommonJS-only, includes unrelated PNG and
font subsystems).
## Roadmap
- **Chunk B** — `pdf-writer/`, `content-stream/` (low-level binary writer + content stream encoder).
- **Chunk C** — `text-element/`, `table-element/`, `image-element/`, `divider-element/`.
- **Chunk D** — `layout-engine/`, `style-resolver/`, `document-builder/`, `renderer/`.
- **Chunk E** — `invoice-template/`, `receipt-template/`, `report-template/`, `pdf-preview/` (React).
## Quality gate
```bash
bunx turbo lint typecheck test build --filter=@suaveplan/pdf
bunx turbo test:coverage --filter=@suaveplan/pdf
```
100 / 100 / 100 / 100 Istanbul coverage on every Chunk A module.

View File

@@ -0,0 +1,93 @@
# `colors`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Colour utilities for `@suaveplan/pdf`. The PDF specification stores
colours as three floats in the `0`-`1` range (consumed by the `rg` and
`RG` operators in a content stream), whereas humans and design systems
use `0`-`255` RGB triples or `#rrggbb` hex strings. This module bridges
the three representations, exposes the named palette consumed by the
default theme, and provides theme-friendly `lighten` / `darken`
helpers — all with the same typed `PdfError(PDF_CODES.INVALID_HEX_COLOR)`
failure mode on bad input.
## Features
- `hexToRgb` / `rgbToHex` — bidirectional conversion between `#rrggbb`
and `{ r, g, b }`. Accepts 3-char shorthand (`#f00` → `{255, 0, 0}`).
- `rgbToPdfColor` / `hexToPdfColor` — convert to the `0`-`1` triples the
PDF writer's content-stream operators expect.
- `lighten` / `darken` — adjust a hex colour by a percentage with
clamp-safe semantics (`lighten("#000", 100) === "#ffffff"`).
- `COLORS` — named palette (`black`, `white`, `red`, `green`, `blue`,
`gray`, `lightGray`, `darkGray`, `transparent`).
- `isTransparent` — recognises the `"transparent"` and `"none"` keywords.
## Usage: Basic
```ts
import { hexToPdfColor, hexToRgb, rgbToHex } from "@suaveplan/pdf/colors";
const rgb = hexToRgb("#ffaa00"); // { r: 255, g: 170, b: 0 }
const hex = rgbToHex(rgb); // "#ffaa00"
const pdf = hexToPdfColor("#ffaa00"); // { r: 1, g: 0.667, b: 0 }
```
## Usage: Advanced
Build a tinted theme by composing `lighten` and `darken`:
```ts
import { COLORS, darken, isTransparent, lighten } from "@suaveplan/pdf/colors";
const accent = "#3366cc";
const theme = {
accent,
accentHover: lighten(accent, 10),
accentActive: darken(accent, 15),
surface: COLORS.white,
border: COLORS.lightGray,
};
if (!isTransparent(theme.surface)) {
// …emit a background fill
}
```
## API Reference
- `interface RgbColor { r, g, b: number }` — values in `0`-`255`.
- `interface PdfColor { r, g, b: number }` — values in `0`-`1`.
- `hexToRgb(hex: string): RgbColor` — accepts `#RGB`, `#RRGGBB`, `RGB`,
`RRGGBB`; throws `PdfError(PDF_CODES.INVALID_HEX_COLOR)` on bad input.
- `rgbToPdfColor(rgb: RgbColor): PdfColor` — clamps to `0`-`1`.
- `hexToPdfColor(hex: string): PdfColor` — composition of the above.
- `rgbToHex(rgb: RgbColor): string` — emits `#rrggbb` (lowercase, padded).
- `lighten(hex: string, amount: number): string` — `amount` in `0`-`100`,
clamped.
- `darken(hex: string, amount: number): string` — `amount` in `0`-`100`,
clamped.
- `COLORS` — frozen palette object.
- `isTransparent(color: string): boolean`.
## Implementation Notes
- The hex regex `^[0-9a-fA-F]{6}$` is applied after the 3-char expansion,
so `"#f00"` → `"ff0000"` is validated as a 6-char form.
- `rgbToHex` rounds with `Math.round`; `127.5` rounds to `128` per
IEEE banker's rounding only on `.5` exact, and `127.4 → 127`
(`0x7f`), `127.6 → 128` (`0x80`).
- `lighten` linearly interpolates each channel toward white
(`c + (255 - c) * f`); `darken` linearly interpolates toward black
(`c * (1 - f)`). Out-of-range `amount` is clamped to `[0, 100]` so
callers can pass user input directly.
- The named palette is `as const` so consumers get string-literal types
when destructuring (e.g. `COLORS.black`'s type is `"#000000"`, not
`string`).
- `"transparent"` and `"none"` are the only no-paint keywords; any other
string — empty or otherwise — is treated as a hex colour that must
parse, matching CSS-like semantics without supporting `rgba()`.

View File

@@ -0,0 +1,109 @@
# content-stream
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`ContentStreamEncoder` is the chainable builder for PDF 1.4 content
streams used by every page emitted by `@suaveplan/pdf`. It surfaces the
narrow operator subset the package's higher layers (`text-element`,
`image-element`, `table-element`, `divider-element`, and the
`renderer`) actually need — graphics state, path construction, fill +
stroke, colour, text mode, and XObject painting — and refuses to emit
out-of-order operators so structural mistakes surface as
`PdfError(PDF_CODES.RENDER_FAILED)` rather than silently producing PDFs
that render blank or crash readers.
The encoder formats numbers via the same `formatPdfNumber` helper
exposed by the writer (PDF 1.4 §7.3.3 — integers without decimals,
fractions to four decimals, `-0` collapsed to `0`) and escapes literal
strings per §7.3.4, so the produced operator sequence is always safe to
embed inside an `addStream` call. The encoder is strict by default;
consumers needing to splice prerecorded streams can opt-in to
`{ strict: false }` and use `appendRaw` to inject pre-encoded operators.
## Features
- Graphics state: `q`, `Q`, `cm`, `w`.
- Path construction + painting: `m`, `l`, `re`, `h`, `S`, `s`, `f`, `B`.
- Colour: `rg`, `RG` validated against the `[0, 1]` channel range.
- Text: `BT`, `ET`, `Tf`, `Tm`, `Td`, `TL`, `T*`, `Tj` with literal-string
escaping and text-mode invariants.
- XObjects: `Do` for image painting via the page's `/XObject` resource
dictionary.
- Strict-mode invariants — `showText`/`Tf`/`Td`/`T*`/`TL`/`Tm` outside
text mode, unmatched `Q`, unmatched `BT`, and `encode()` while still
inside `BT…ET` all raise `PdfError(PDF_CODES.RENDER_FAILED)`.
- `appendRaw` escape hatch for testing or hand-crafted operator
sequences when strict mode is disabled.
## Usage: Basic
```ts
import { ContentStreamEncoder } from "@suaveplan/pdf/content-stream";
const stream = new ContentStreamEncoder()
.beginText()
.setFont("F1", 12)
.moveTextCursor(72, 720)
.showText("Hello PDF")
.endText()
.encode();
```
## Usage: Advanced
```ts
import { ContentStreamEncoder } from "@suaveplan/pdf/content-stream";
import { hexToPdfColor } from "@suaveplan/pdf/colors";
const stream = new ContentStreamEncoder()
.saveGraphicsState()
.setFillColor(hexToPdfColor("#1e90ff"))
.rectangle(50, 50, 200, 100)
.fill()
.restoreGraphicsState()
.beginText()
.setFont("F1", 18)
.moveTextCursor(60, 120)
.showText("Boxed heading")
.endText()
.drawImage("Im0")
.encode();
```
## API Reference
- `new ContentStreamEncoder(options?)` — `options.strict` defaults to
`true`.
- Graphics state: `saveGraphicsState()`, `restoreGraphicsState()`,
`concatMatrix(a, b, c, d, e, f)`, `setLineWidth(w)`.
- Path: `moveTo(x, y)`, `lineTo(x, y)`, `rectangle(x, y, w, h)`,
`closePath()`, `stroke()`, `closeAndStroke()`, `fill()`,
`fillAndStroke()`.
- Colour: `setFillColor({ r, g, b })`, `setStrokeColor({ r, g, b })` —
each channel must be in `[0, 1]`.
- Text: `beginText()`, `endText()`, `setFont(name, size)`,
`setTextMatrix(a, b, c, d, e, f)`, `moveTextCursor(tx, ty)`,
`setLeading(leading)`, `nextLine()`, `showText(text)`.
- XObjects: `drawImage(resourceName)`.
- Output: `appendRaw(op)`, `encode(): string`, `length: number`.
## Implementation Notes
The encoder buffers operators in a `string[]` so `encode()` is a simple
`join("\n")`. Text mode is tracked via an `inText` flag toggled by
`beginText`/`endText`. `graphicsDepth` counts `q`/`Q` pairs so
`encode()` can reject unmatched saves; this catches the most common
mistake in element renderers (forgetting to restore after a clipped
draw).
`setFillColor` and `setStrokeColor` validate channel ranges inline
(rather than delegating to a Zod schema) so the typed error chain
carries the right `INVALID_RGB` code without an extra import cycle
between the colour module and the content-stream module. The
`appendRaw` escape hatch deliberately does not validate input; it
exists only for tests and for stitching together prerecorded streams
where the caller already owns escaping.

View File

@@ -0,0 +1,88 @@
# divider-element
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`DividerElement` renders a horizontal or vertical rule into a PDF content
stream. It is the lightweight separator primitive used by templates
(invoice, receipt, report) and the layout engine to break content into
visual sections without resorting to background colours or raw graphics
operators. Stroke colour, thickness, dash pattern, and surrounding
spacing are all configurable; defaults give an unobtrusive 1pt
`#cccccc` line.
The element validates its options through Zod, so passing a negative
thickness, an unknown orientation, or a non-string colour raises a typed
`PDF.INVALID_ELEMENT` error rather than silently rendering garbage.
## Features
- Horizontal and vertical orientation.
- Configurable thickness, stroke colour, dash pattern.
- Optional `length` override (defaults to the available width / height).
- `spacingAbove` / `spacingBelow` for vertical flow accounting in the
layout engine.
- `measure(maxWidth, maxHeight?)` returns `{ width, height }` for layout.
- `render(context)` emits a `q…Q`-clean content-stream snippet.
- `AbortSignal` propagation — aborted contexts raise `PDF.ABORTED`.
- Skips the stroke-colour operator entirely for `transparent` / `none`.
## Usage: Basic
```ts
import { DividerElement, PdfWriter } from "@suaveplan/pdf";
const writer = new PdfWriter();
const divider = new DividerElement();
const out = divider.render({ x: 72, y: 720, maxWidth: 460 });
const streamId = writer.addStream(out.stream);
writer.addPage({ width: 612, height: 792, contentStreamId: streamId });
const bytes = writer.generate();
```
## Usage: Advanced
```ts
import { DividerElement, computeDividerSpacing } from "@suaveplan/pdf";
const divider = new DividerElement({
orientation: "horizontal",
thickness: 2,
color: "#0f3460",
dashPattern: [4, 2],
spacingAbove: 12,
spacingBelow: 12,
length: 200,
});
const flow = computeDividerSpacing(2, 12, 12);
// Lays out divider centred in a 460pt column with 12pt above + below.
const x = 72 + (460 - 200) / 2;
const result = divider.render({ x, y: cursorY - flow.offsetY, maxWidth: 460 });
cursorY -= flow.totalHeight;
```
## API Reference
- `class DividerElement(options)` — validates `DividerElementOptions`
and exposes `measure(maxWidth, maxHeight?)` + `render(context)`.
- `DividerElementOptionsSchema` — Zod schema used at construction.
- `DIVIDER_DEFAULTS` — fallback values (`thickness: 1`,
`color: "#cccccc"`, `orientation: "horizontal"`).
- `computeDividerSpacing(thickness, spacingAbove?, spacingBelow?)` —
flow-accounting helper used by the layout engine and templates.
## Implementation Notes
Rendering uses `ContentStreamEncoder.moveTo` + `lineTo` + `stroke` rather
than the rectangle operator so dash patterns render correctly across the
full path. Dash patterns go through `appendRaw` because the
`ContentStreamEncoder` chunk-B surface intentionally does not expose
every PDF operator — adding `setDashPattern` is a Chunk-D concern. The
horizontal divider offsets `y` by `spacingAbove` so calls that pass the
top of the flow region get a visually balanced rule; the vertical
divider grows downward from `y`. `AbortSignal` is checked at the top of
`render()` so the element participates in cooperative cancellation
alongside `PdfWriter.generate`.

View File

@@ -0,0 +1,120 @@
# document-builder
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The `DocumentBuilder` provides a chainable, deterministic API for
assembling a PDF document tree without touching the lower-level
writer / layout machinery. It eagerly constructs the underlying
element instances (running each element's Zod boundary validation at
the call site) and stores them in a `Document` AST that the renderer
walks node-by-node. Headers, footers, metadata, per-page configuration
overrides, and arbitrary typed user data are all first-class.
## Features
- Fluent metadata setters: `title`, `author`, `subject`, `keywords`,
`creator`, `creationDate`.
- Element shortcuts: `addText`, `addTable`, `addImage`, `addDivider`,
each accepting an optional per-node `Partial<PdfStyle>` override.
- `newPage(config?)` flushes the current logical page and optionally
declares a per-page `PageConfig` override (e.g. swap orientation
for a wide table page).
- `setHeader(nodes, height)` / `setFooter(nodes, height)` register
repeating page furniture; the renderer wires the height into the
layout engine's slot reservation.
- `withData(data)` carries a typed payload through to the resulting
`Document<TData>` for downstream consumers (e.g. invoice line items
driving telemetry tags).
- `build()` stamps a default `creationDate` and `creator` and refuses
to emit an empty document via `PDF.INVALID_DOCUMENT`.
## Usage: Basic
```ts
import { createDocument, PageSize } from "@suaveplan/pdf";
const doc = createDocument()
.title("Quarterly Report")
.addText("Executive Summary", { fontSize: 18, fontWeight: "bold" })
.addText("Revenue increased 14% year-over-year.")
.build();
```
## Usage: Advanced
```ts
import { createDocument, DEFAULT_PAGE_CONFIG, PageSize } from "@suaveplan/pdf";
interface InvoiceData {
invoiceNumber: string;
total: number;
}
const doc = createDocument<InvoiceData>()
.title("Invoice INV-2026-00128")
.author("Suaveplan Billing")
.keywords(["invoice", "billing"])
.pageConfig({ ...DEFAULT_PAGE_CONFIG, size: PageSize.LETTER })
.withData({ invoiceNumber: "INV-2026-00128", total: 12480.55 })
.setHeader(
[
{
type: "text",
element: new TextElement({
content: "Suaveplan Billing",
fontWeight: "bold",
}),
},
],
48
)
.addText("Bill to: Acme Corp")
.addDivider({ thickness: 1, spacingAbove: 8, spacingBelow: 8 })
.addTable(
[
{ key: "sku", header: "SKU" },
{ key: "qty", header: "Qty", align: "right" },
{ key: "price", header: "Price", align: "right" },
],
[
{ sku: "A-1", qty: 2, price: "$120.00" },
{ sku: "B-2", qty: 1, price: "$240.55" },
],
{ border: true, repeatHeader: true }
)
.newPage()
.addText("Page 2: payment terms…")
.build();
```
## API Reference
- `class DocumentBuilder<TData>` —
`title` / `author` / `subject` / `keywords` / `creator` /
`creationDate` / `pageConfig` / `withData` /
`addText(content, options?, style?)` /
`addTable(columns, rows, options?, style?)` /
`addImage(options, style?)` / `addDivider(options?, style?)` /
`newPage(config?)` / `setHeader(nodes, height)` /
`setFooter(nodes, height)` / `build()`.
- `createDocument<TData>()` → `DocumentBuilder<TData>`.
- Types: `Document<TData>`, `DocumentNode`, `TextNode`, `TableNode`,
`ImageNode`, `DividerNode`, `BuiltPageDefinition`,
`DocumentNodeType`.
## Implementation Notes
The builder stores nodes in a mutable per-page buffer; `newPage` /
`build` flush this buffer into the page list via the private
`flushPage` helper which also drops the per-page config override. The
element constructors invoked by `addXxx` perform the Zod boundary
validation eagerly so malformed input fails at the call site rather
than inside the renderer. `setHeader` / `setFooter` clone their input
arrays to guarantee post-call mutation by the caller does not bleed
into the built document. `build()` defensively copies pages, header,
and footer arrays so the returned `Document` is structurally
isolated from any subsequent mutation of the builder.

View File

@@ -0,0 +1,106 @@
# `error-codes`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Declares the stable `PDF.*` error-code registry and the `PdfError` class
used by every public function in `@suaveplan/pdf`. Every failure path
the package can take — from a malformed hex colour to a writer that has
been disposed mid-render — surfaces one of these codes so callers can
classify failures programmatically with `instanceof PdfError` plus a
discriminated switch on `err.code`, instead of brittle message-string
parsing.
## Features
- `PDF_CODES` constant built via `defineCodes("PDF", […])` so every
code is namespaced (`"PDF.INVALID_UNIT"`, `"PDF.RENDER_FAILED"`, …).
- Thirteen stable codes covering every Chunk A–E failure path. The full
union is declared up front so subsequent chunks never widen
`PdfErrorCode` and break consumers.
- `PdfError` class extending `SuaveplanError<PdfErrorCode>` with the
standard `code`, `cause`, `context`, and `timestamp` surface.
- `asPdfError(code, cause, fallback)` lifts unknown thrown values into a
`PdfError`, preserving the original `cause` and adopting its message
when present.
## Usage: Basic
```ts
import { PDF_CODES, PdfError } from "@suaveplan/pdf/error-codes";
if (!isValidHex(input)) {
throw new PdfError(
PDF_CODES.INVALID_HEX_COLOR,
`bad hex colour: ${input}`
);
}
```
## Usage: Advanced
Wrap third-party throws at the package boundary so the typed surface is
preserved without losing the original cause:
```ts
import { asPdfError, PDF_CODES, PdfError } from "@suaveplan/pdf/error-codes";
try {
await externalRenderHook(doc);
} catch (cause) {
throw asPdfError(PDF_CODES.RENDER_FAILED, cause, "render hook failed");
}
// Downstream discriminates on err.code:
try {
await render(doc);
} catch (err) {
if (err instanceof PdfError) {
switch (err.code) {
case PDF_CODES.LAYOUT_OVERFLOW:
// …split into multiple pages
break;
case PDF_CODES.IMAGE_DECODE_FAILED:
// …fall back to a placeholder asset
break;
}
}
}
```
## API Reference
- `PDF_CODES` — frozen object whose keys are the bare names
(`INVALID_UNIT`) and whose values are the namespaced forms
(`"PDF.INVALID_UNIT"`). Members: `INVALID_UNIT`, `INVALID_HEX_COLOR`,
`INVALID_RGB`, `INVALID_PAGE_CONFIG`, `INVALID_DOCUMENT`,
`INVALID_ELEMENT`, `INVALID_TEMPLATE_INPUT`, `RENDER_FAILED`,
`LAYOUT_OVERFLOW`, `IMAGE_DECODE_FAILED`, `FONT_UNKNOWN`,
`WRITER_DISPOSED`, `ABORTED`.
- `type PdfErrorCode` — union of `PDF_CODES[keyof typeof PDF_CODES]`.
- `class PdfError extends SuaveplanError<PdfErrorCode>` — sets
`name === "PdfError"` and forwards `code`, `message`, and `options`
to the base.
- `asPdfError(code, cause, fallback): PdfError` — lifts arbitrary
thrown values into a `PdfError`, attaching the original (or a wrapped
`Error(String(cause))`) as `cause`.
## Implementation Notes
- The code registry is declared with `as const` so the union is exact
rather than `string`.
- `defineCodes` is imported directly from `@suaveplan/error`; this
package never re-exports it (rule 4: no convenience re-exports of
external deps).
- `asPdfError` always returns a `PdfError` whose `cause` is an `Error`
instance, even when the original thrown value was a string or a
plain object — preserves the invariant that downstream tracing and
serialization can rely on `.message` and `.stack`.
- The registry is intentionally over-sized for Chunk A (only the unit /
colour / Zod-boundary codes are reachable here); the unreachable
codes are exercised in their own modules in Chunks B–E. The test
suite asserts every code is registered so adding a new code without
exercising it is impossible.

View File

@@ -0,0 +1,108 @@
# image-element
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`ImageElement` is the bitmap primitive of `@suaveplan/pdf`. It accepts
raw JPEG or PNG bytes, decodes them at construction time, and emits a
content-stream snippet that paints the registered XObject at the chosen
position + size. JPEGs pass through unchanged because PDF supports
`DCTDecode` natively; PNGs are decoded via `pngjs` to RGBA pixels, then
the RGB plane is embedded as a `FlateDecode` stream and the alpha plane
(when present) is published as an `SMask` for translucent compositing.
The element is the first dependency on a third-party decoder (`pngjs`)
and gates Chunk D's renderer, which is responsible for wiring the
decoded data into XObject indirect objects in the page-resources
dictionary.
## Features
- JPEG header parsing (SOF0..SOF15 walk) extracts width / height without
decoding pixels.
- PNG decoding via `pngjs` synchronous reader yields RGBA8888 buffers.
- Alpha plane is separated into a dedicated `SMask` payload only when
any pixel is non-opaque.
- `detectImageFormat(bytes)` — magic-byte sniffer (PNG signature + JPEG
SOI/APP0).
- `decodeImage(bytes)` — produces a `DecodedImage` ready for the writer
to embed.
- `scaleToFit` — proportional scaling that never upscales.
- `validateImageProps` — typed errors on non-positive dimensions.
- `AbortSignal` propagation on `render()`.
## Usage: Basic
```ts
import { ImageElement } from "@suaveplan/pdf";
import { readFileSync } from "node:fs";
const bytes = readFileSync("./logo.png");
const image = new ImageElement({
src: new Uint8Array(bytes),
width: 120,
height: 60,
});
const out = image.render({
x: 72,
y: 770,
maxWidth: 460,
fontResourceName: "Im0",
});
```
## Usage: Advanced
```ts
import {
decodeImage,
detectImageFormat,
ImageElement,
scaleToFit,
} from "@suaveplan/pdf";
const format = detectImageFormat(bytes);
if (!format) throw new Error("unknown format");
const decoded = decodeImage(bytes);
const fitted = scaleToFit(decoded.width, decoded.height, 460, 600);
const image = new ImageElement({
src: bytes,
width: fitted.width,
height: fitted.height,
format,
alt: "Company logo",
});
// Pass `image.decoded` to the renderer (Chunk D) which will register an
// XObject indirect object on the writer and populate the page-resources
// /XObject dictionary with its resource name.
```
## API Reference
- `class ImageElement(options)` — validates `ImageElementOptions`,
decodes the image at construction time, exposes `measure()` +
`render(context)` + `decoded`.
- `ImageElementOptionsSchema` — Zod schema used at construction.
- `decodeImage(bytes)` — returns a `DecodedImage`.
- `detectImageFormat(bytes)` — magic-byte sniffer.
- `scaleToFit(w, h, maxW, maxH)` — aspect-preserving fit.
- `validateImageProps(props)` — throws `PDF.INVALID_ELEMENT` on bad
dimensions.
## Implementation Notes
`pngjs.PNG.sync.read` allocates a single RGBA buffer; the decoder
splits it into three-byte RGB pixels + a separate alpha byte plane so
the writer can publish them as a primary `FlateDecode`-compressed
stream and an optional `SMask`. The alpha plane is only retained when
at least one pixel is non-opaque — fully opaque PNGs decode to a
zero-allocation single stream. JPEGs are passed through verbatim
because PDF readers handle `DCTDecode` natively; the SOF-walk only
reads the header bytes and never decodes the DCT coefficients. The
render method writes the `cm` transform such that the image's
bottom-left corner lands at `(x, y - height)`, matching the
PDF coordinate origin (bottom-left of the page). `AbortSignal` is
checked at the top of `render()`.

View File

@@ -0,0 +1,81 @@
# image-registry
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The `ImageRegistry` allocates and reuses PDF Image XObjects across the
lifetime of a single `renderDocument` call. Each unique `ImageElement`
instance maps to exactly one PDF indirect object, regardless of how
many pages reference it. Per-page resource maps share the underlying
object by id so a multi-page document with a repeating logo never
allocates a copy per page. The module also owns the lower-level
`registerImageXObject` helper that builds the canonical `/XObject
/Image` dictionary, choosing `/DCTDecode` for JPEGs and `/FlateDecode`
for decoded PNG RGB planes.
## Features
- One PDF object per `ImageElement` (identity-keyed, not src-hash).
- Per-page `pageXObjects` map populated as a side-effect of `ensure`.
- JPEG passthrough via `/DCTDecode` (no recompression).
- PNG RGB plane re-emission via `/FlateDecode`.
- `DeviceRGB` / `DeviceGray` colour-space selection follows the
decoder output.
- `BitsPerComponent` carried straight from the decoded image.
## Usage: Basic
```ts
import { ImageRegistry } from "@suaveplan/pdf/image-registry";
import { ImageElement, PdfWriter } from "@suaveplan/pdf";
const writer = new PdfWriter();
const registry = new ImageRegistry(writer);
const pageMap = new Map<string, number>();
const image = new ImageElement({ src: pngBytes, width: 100, height: 50 });
const resourceName = registry.ensure(image, pageMap);
// resourceName === "Im<id>"; pageMap now contains the entry.
```
## Usage: Advanced
```ts
import { ImageRegistry, registerImageXObject } from "@suaveplan/pdf/image-registry";
import { ImageElement, PdfWriter } from "@suaveplan/pdf";
const writer = new PdfWriter();
const registry = new ImageRegistry(writer);
const logo = new ImageElement({ src: pngBytes, width: 80, height: 24 });
// Share `logo` across three pages — only one XObject is allocated.
for (const pageMap of pageMaps) {
registry.ensure(logo, pageMap);
}
// Build a JPEG XObject directly (e.g. from a pre-decoded blob).
const jpegId = registerImageXObject(writer, decodedJpeg);
```
## API Reference
- `class ImageRegistry` — constructed with a `PdfWriter`. Exposes
`ensure(element, pageXObjects)` which returns the `Im<id>` resource
name and side-effects the supplied per-page map.
- `registerImageXObject(writer, decoded)` → `number` — low-level
helper used by `ImageRegistry.ensure`.
## Implementation Notes
The registry keys by `ImageElement` instance identity rather than
content hash. This is intentional: callers that share the same
element across pages benefit from the cache automatically, while
callers that construct two `ImageElement`s with identical bytes pay
for two PDF objects — the safe assumption when the bytes themselves
might differ after `decodeImage` runs (e.g. PNG alpha extraction).
`registerImageXObject` packs the dictionary entries in a fixed order
(`/Type`, `/Subtype`, `/Width`, `/Height`, `/ColorSpace`,
`/BitsPerComponent`, optional `/Filter`) so equal inputs produce
byte-identical objects, preserving the writer's xref-byte-offset
invariant. JPEG bytes are passed through unchanged via `"None"`
because the JPEG bitstream itself is what `/DCTDecode` consumes.

View File

@@ -0,0 +1,97 @@
# layout-engine
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The `LayoutEngine` owns the block-flow geometry the renderer walks to
position each element on the page. It computes effective page
dimensions (accounting for orientation), the inner content area
(margins minus header and footer slot heights), and the per-page
cursor state. It also detects when an element does not fit so the
renderer can flush the current page and start a fresh one. The engine
never emits PDF operators directly — that remains the renderer's
responsibility — keeping layout decisions deterministic and side-
effect-free.
## Features
- Cursor model with `x`, `y`, `availableWidth`, `availableHeight`,
`pageIndex` tracking the in-flight flow position.
- `getPageDimensions` / `getContentArea` swap dimensions for landscape
pages and respect the configured `headerHeight` + `footerHeight`.
- `advance` returns an `AdvanceResult` (`fit`, `remaining`) so the
renderer can branch without re-querying the cursor.
- `nextPage` resets the cursor to the top of a fresh page and bumps
`pageIndex`.
- `getHeaderArea` / `getFooterArea` expose the reserved slot
rectangles for the renderer to draw repeating page furniture.
- `computeTextHeight` mirrors `TextElement`'s 0.5em-per-glyph
approximation so layout and measurement remain consistent.
- AbortSignal-aware: every flow operation throws `PDF.ABORTED` if the
supplied signal trips.
## Usage: Basic
```ts
import { LayoutEngine, DEFAULT_PAGE_CONFIG } from "@suaveplan/pdf";
const layout = new LayoutEngine({ defaultPage: DEFAULT_PAGE_CONFIG });
const cursor = layout.createCursor();
const fit = layout.advance(cursor, 100);
if (!fit.fit) {
layout.nextPage(cursor);
}
```
## Usage: Advanced
```ts
import { LayoutEngine, DEFAULT_PAGE_CONFIG, PageSize } from "@suaveplan/pdf";
const controller = new AbortController();
const layout = new LayoutEngine({
defaultPage: { ...DEFAULT_PAGE_CONFIG, size: PageSize.LETTER },
headerHeight: 48,
footerHeight: 36,
signal: controller.signal,
});
const header = layout.getHeaderArea();
const footer = layout.getFooterArea();
const cursor = layout.createCursor();
// Reserved bands keep flow content away from header / footer:
// cursor.y starts at (pageHeight - margin.top - headerHeight)
```
## API Reference
- `class LayoutEngine<TConfig extends PageConfig>` —
`getSlotMetrics()`, `getPageDimensions(config?)`,
`getContentArea(config?)`, `getHeaderArea(config?)`,
`getFooterArea(config?)`, `createCursor(config?)`,
`advance(cursor, height, config?)`, `nextPage(cursor, config?)`,
`computeTextHeight(text, style, maxWidth)`,
`applyMargin(box, margin)`.
- Constructor options: `defaultPage`, `headerHeight`, `footerHeight`,
`signal`.
- Types: `LayoutBox`, `LayoutCursor`, `LayoutEngineOptions`,
`PageSlotMetrics`, `AdvanceResult`.
## Implementation Notes
The cursor stores `y` in PDF user-space coordinates (origin
bottom-left). `createCursor` therefore initialises `y` to
`pageHeight - margins.top - headerHeight` so the top of the content
area maps to the largest `y` value. `advance` decreases `y` and
`availableHeight` in lock-step; the fit check compares the post-
advance `y` against the bottom edge of the content area (computed as
`pageHeight - top - innerHeight`). The header / footer rectangles are
reported in the same coordinate system to keep renderer arithmetic
uniform. `applyMargin` reuses `normalizeSpacing` from the style
resolver so `SpacingValue` inputs (scalar or per-side) both work.
Validation of negative margins or oversized header/footer values is
performed eagerly in the constructor and on `getContentArea` so
malformed inputs surface as typed `PDF.LAYOUT_OVERFLOW` /
`PDF.INVALID_PAGE_CONFIG` rather than producing nonsense coordinates.

View File

@@ -0,0 +1,143 @@
# pdf-writer
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`PdfWriter` is the low-level binary writer that emits valid PDF 1.4 byte
streams. It manages indirect-object allocation, the font registry for
the 14 standard fonts, stream-object assembly (raw + FlateDecode), the
cross-reference table, the trailer dictionary, the `/Info` block, and
the canonical header + binary-marker + `%%EOF` framing.
This module sits at the bottom of the package. Every higher-level layer
(`content-stream` operator sequences, element renderers, the document
builder, and the templates) eventually flows through it; the only direct
external API is `addObject` / `addStream` / `addPage` / `generate`.
Instances are single-use: after `generate()` returns or `dispose()` is
called, further writes raise `PdfError(PDF_CODES.WRITER_DISPOSED)`.
## Features
- PDF 1.4 framing: header, binary-marker comment, xref, trailer
(`/Size`, `/Root`, `/Info`), `startxref`, `%%EOF`.
- Eight PDF object types via the `PdfValue` discriminated union: `null`,
`bool`, `number`, literal string, hex string, name, array, dictionary,
and indirect reference.
- Compression hook — `addStream(data, "FlateDecode")` deflates the
payload via `node:zlib` and tags the dictionary with
`/Filter /FlateDecode`.
- The 14 standard fonts registered via `registerFont(name)`; idempotent
on identical names; rejects unknown fonts with
`PdfError(PDF_CODES.FONT_UNKNOWN)`.
- Injectable `Clock` (from `@suaveplan/types`) drives `/CreationDate`
for deterministic test output.
- `AbortSignal`-aware `generate()` — abort mid-write throws
`PdfError(PDF_CODES.ABORTED)` and returns no partial bytes.
- `dispose()` lifecycle guard — all subsequent writes raise
`PdfError(PDF_CODES.WRITER_DISPOSED)`.
- Byte-accurate xref offsets — each entry points at the literal
`<id> 0 obj` opener in the emitted stream.
## Usage: Basic
```ts
import { PdfWriter } from "@suaveplan/pdf/pdf-writer";
const writer = new PdfWriter();
const fontResource = writer.registerFont("Helvetica");
const stream = writer.addStream(`BT ${fontResource} 12 Tf (Hi) Tj ET`);
writer.addPage({ width: 612, height: 792, contentStreamId: stream });
const bytes = writer.generate({ Title: "Hello" });
```
## Usage: Advanced
```ts
import { PdfWriter } from "@suaveplan/pdf/pdf-writer";
import { ContentStreamEncoder } from "@suaveplan/pdf/content-stream";
const controller = new AbortController();
const writer = new PdfWriter({
clock: () => Date.UTC(2026, 0, 1),
signal: controller.signal,
producer: "@my-org/reporting/1.0",
});
const fontResource = writer.registerFont("Times-Bold");
const imageId = writer.addStream(jpegBytes, "None", [
["/Type", { kind: "name", value: "/XObject" }],
["/Subtype", { kind: "name", value: "/Image" }],
]);
const stream = writer.addStream(
new ContentStreamEncoder()
.beginText()
.setFont(fontResource, 24)
.moveTextCursor(72, 720)
.showText("Annual Report")
.endText()
.drawImage("Im0")
.encode(),
"FlateDecode",
);
writer.addPage({
width: 612,
height: 792,
contentStreamId: stream,
xobjectResources: new Map([["Im0", imageId]]),
});
const bytes = writer.generate({
Title: "Annual Report",
Author: "Suaveplan",
});
```
## API Reference
- `new PdfWriter(options?)` — `options.clock` defaults to `Date.now`;
`options.signal` is an optional `AbortSignal`; `options.producer`
defaults to `@suaveplan/pdf`.
- `allocateId(): number` — reserve the next indirect-object id.
- `addObject(value: PdfValue, id?: number): number` — write any PDF
value as an indirect object.
- `addStream(data, filter?, extraDictEntries?)` — emit a stream
object; `filter` defaults to `"None"` and may be `"FlateDecode"`.
- `registerFont(name): string` — register one of the 14 standard fonts;
returns the resource name (`F1`, `F2`, …).
- `addPage(input): number` — append a page object referencing a content
stream + resource dictionaries.
- `generate(metadata?): Uint8Array` — assemble the complete document
and return the bytes.
- `dispose()` — mark the writer as unusable.
- Helpers: `encodePdfValue(value)`, `escapePdfLiteralString(s)`,
`formatPdfNumber(n)`, `formatPdfDate(epochMs)`, `STANDARD_FONTS`.
## Implementation Notes
The writer keeps each indirect object as a `{ id, body }` record (and
attaches `streamBytes` + `filter` for stream objects) inside a
`Map<number, …>`. Serialisation iterates the map in ascending id order,
records each object's byte offset, then assembles the xref table from
the recorded offsets. The xref table always contains every id from
`0` to `max(id)`; ids that were allocated but never written are
emitted with offset `0` (which still satisfies the structural
constraint imposed by readers that index the table by id, as the
catalog and pages dictionary fill those slots before `generate`).
`AbortSignal` checks bracket the serialisation pass on either side of
the per-object loop. The signal is also checked before the
`/Pages`/`/Info`/`/Catalog` writes so an early abort cannot leak a
partial document. `generate()` flips the `disposed` flag on the way out
so a second call surfaces immediately as `WRITER_DISPOSED` — there is
no internal reset path because PDFs are write-once artifacts.
The `Producer` field of `/Info` is always emitted (PDF 1.4 readers
treat it as informational but pdf-parse and other audit tools surface
it directly), so test fixtures that compare against `/Info` payloads
need to either accept the default `@suaveplan/pdf` value or override it
via the constructor option.

View File

@@ -0,0 +1,90 @@
# render-pipeline
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Internal pipeline helpers that the top-level `renderDocument`
orchestrator delegates to. Splitting placement and slot-rendering out
of the orchestrator keeps each file under the 300-line hard limit
and lets callers test the per-node placement protocol in isolation
from the page-emit loop. The pipeline is stateless across calls — every
operation receives a `PipelineState` bundle (writer, layout engine,
style resolver, image registry, abort signal, page counter) and
mutates only the writer and the page counter.
## Features
- `placeNode` dispatches on the discriminated `DocumentNode` union
and returns either a content-stream snippet or the `"page-break"`
sentinel that signals the orchestrator to flush + restart.
- Tables emit a `continuation` descriptor (`{ table, rowStart }`)
when they partially fit, so the orchestrator can resume on the
next page without re-measuring the full table.
- `renderTableSlice` swallows `PDF.LAYOUT_OVERFLOW` from
`TableElement.renderPage` and surfaces it as a `rowEnd === rowStart`
no-progress result.
- `renderSlot` flows header / footer nodes top-to-bottom inside the
reserved slot rectangle.
- `pickFontResource` re-uses the writer's idempotent font registry so
one `F<id>` survives the whole render.
## Usage: Basic
```ts
import { placeNode, renderSlot, type PipelineState } from "@suaveplan/pdf";
const state: PipelineState = { writer, layout, styles, images, signal: undefined, pageCount: 0 };
const result = placeNode(state, node, cursor, undefined, new Map());
if (result === "page-break") {
// orchestrator: flush and retry
}
```
## Usage: Advanced
```ts
import { placeNode, renderTableSlice, renderSlot } from "@suaveplan/pdf";
// Slice rendering protocol — orchestrator advances rowStart between calls.
let rowStart = 0;
while (rowStart < tableElement.options.rows.length) {
const slice = renderTableSlice(state, tableElement, rowStart, cursor, config);
if (slice.rowEnd === rowStart) {
// No progress — caller must flush + create a fresh cursor.
break;
}
rowStart = slice.rowEnd;
// ... write slice.stream into the per-page buffer ...
}
```
## API Reference
- `placeNode(state, node, cursor, config?, pageXObjects)` →
`PlacementResult`.
- `renderTableSlice(state, table, rowStart, cursor, config?)` →
`{ stream, rowEnd }`.
- `renderSlot(state, nodes?, height?, slot, config?, pageXObjects)` →
`string[]`.
- `pickFontResource(state, style)` → `"F<id>"`.
- Types: `PipelineState`, `PlacementResult`.
## Implementation Notes
`placeNode` always measures first, then conditionally advances the
cursor. Text and divider elements pass the pre-advance `cursor.y` as
the render `y` (PDF's bottom-up coordinate system means the top of
the element lives at the larger `y`, and the element's `.render()`
contract places baselines at `y - i·lineHeight`). Images measure
once and ignore `maxWidth` because their bounds are fixed at
construction. Tables short-circuit through `TableElement.render`
when the whole element fits and only fall back to the slice
protocol when pagination is required. The slot renderer flows
nodes downward inside the reserved rectangle, which means a footer
slot with two stacked text lines places the first line at the slot
top and the second below — matching the visual expectation that
flow content reads top-to-bottom regardless of PDF's bottom-up
geometry.

View File

@@ -0,0 +1,120 @@
# renderer
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`renderDocument` is the top-level orchestrator that turns a
`Document` AST (produced by `DocumentBuilder`) into a real PDF 1.4
binary. It wires together the chunk B `PdfWriter`, the chunk D
`LayoutEngine` and `StyleResolver`, and every chunk C element's
`measure()` + `render()` pair into a deterministic page-by-page flow
loop. Image XObjects are registered lazily, font resources are
re-used across pages, tables split across pages via
`TableElement.renderPage`, and headers / footers are stamped on every
emitted physical page. All output is verifiable end-to-end via
`pdf-parse`.
## Features
- Single-call entry point: `renderDocument(doc, options?)` returns
`{ bytes, pageCount, durationMs }`.
- Automatic page break: when an element's measured height exceeds
the cursor's remaining vertical space, the renderer flushes the
current page and retries on a fresh one.
- Table pagination via `TableElement.renderPage(context, rowStart)`
with optional header repeat.
- Image XObject registration is per-document with per-page
inclusion: a shared image referenced on three pages allocates one
XObject and three `Im<id>` entries in the page `/Resources` dict.
- Header / footer slots reserve vertical space up-front so flow
content never collides with repeating page furniture.
- AbortSignal honoured between every node placement and every page
flush.
- Six OpenTelemetry instruments recorded per call: documents-
rendered counter (tagged success), pages-emitted counter, render-
errors counter (tagged `PDF.*` code), validation-failures counter,
render-duration histogram, document-size histogram.
- Injectable `Clock` for deterministic `/CreationDate`, injectable
`producer` string for the `/Info` dictionary, injectable
`observability` bundle or factory options.
## Usage: Basic
```ts
import { createDocument, renderDocument } from "@suaveplan/pdf";
const doc = createDocument()
.title("Hello")
.addText("Hello, world.")
.build();
const { bytes, pageCount } = renderDocument(doc);
// bytes is a real PDF 1.4 Uint8Array; pageCount === 1.
```
## Usage: Advanced
```ts
import {
createDocument,
DEFAULT_THEME,
renderDocument,
} from "@suaveplan/pdf";
const controller = new AbortController();
const clock = { now: () => 1700000000000 };
const doc = createDocument()
.title("Annual Report 2026")
.author("Suaveplan Reporting")
.addText("Section 1: Overview", { fontWeight: "bold", fontSize: 18 })
.addDivider({ spacingAbove: 4, spacingBelow: 12 })
.addTable(
[
{ key: "metric", header: "Metric" },
{ key: "value", header: "Value", align: "right" },
],
Array.from({ length: 80 }, (_, i) => ({
metric: `Row ${i + 1}`,
value: `${i * 17}`,
})),
{ border: true, repeatHeader: true }
)
.build();
const result = renderDocument(doc, {
theme: { ...DEFAULT_THEME, colors: { ...DEFAULT_THEME.colors, text: "#1f2937" } },
signal: controller.signal,
clock,
producer: "Suaveplan Reporting/1.0",
});
// result.pageCount > 1 because the table paginates with header repeat.
```
## API Reference
- `renderDocument<TData>(doc, options?)` → `RenderResult`.
- Options: `theme?`, `signal?`, `clock?`, `producer?`,
`observability?`, `observabilityOptions?`.
- Result: `{ bytes: Uint8Array, pageCount: number, durationMs: number }`.
## Implementation Notes
The renderer maintains a `RenderState` carrying the active writer,
layout engine, style resolver, image registry, and abort signal.
Each logical `Document.page` enters `renderLogicalPage`, which sets
up a `cursor`, calls `beginPage()` to seed the per-page stream
buffer with header + footer fragments, then walks the node list.
Text / divider / image nodes measure first, advance the cursor, and
render at the pre-advance `y` (PDF's bottom-up coordinates mean the
top of an element lives at the larger `y`). Tables that fit in the
remaining height render in one pass; tables that overflow call
`TableElement.renderPage(context, rowStart)` to emit as many rows as
fit, then carry the remaining `rowStart` into the next page via a
`tableContinuation` slot — this is the same pagination protocol
chunk C declared. Image XObjects are registered once per
`ImageElement` instance and reused across pages. Telemetry is
recorded in both the success and failure branches of the top-level
try/catch so duration is never lost.

View File

@@ -0,0 +1,104 @@
# `schemas`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Zod boundary schemas for the public surface of `@suaveplan/pdf`. Every
value crossing the package boundary from untrusted input (HTTP request
body, config file, template constructor) is parsed through one of the
schemas declared here before reaching the renderer. Schema-validation
failures are lifted into typed `PdfError` instances via the
`parseOrThrow` helper, so consumers can rely on `instanceof PdfError`
without losing the field-level diagnostic detail that Zod produces.
## Features
- `UnitSchema` — five-valued enum (`pt`, `mm`, `cm`, `in`, `px`).
- `HexColorSchema` — accepts `#RGB`, `#RRGGBB`, and bare 3 / 6-char
hex strings.
- `RgbColorSchema` — `{ r, g, b }` with each channel in `0`-`255`.
- `MarginsSchema` — `{ top, right, bottom, left }` non-negative points.
- `PageConfigSchema` — `{ size, orientation, margins }` covering every
supported page-size preset.
- `DocumentMetadataSchema` — all-optional fields matching the PDF
`/Info` dictionary surface.
- `parseOrThrow(schema, value, code, message)` — universal helper that
converts Zod parse failures into `PdfError` with the supplied code
and exposes the Zod issue paths on `err.context.path`.
- `parseHexColor(value)` — convenience that wraps `HexColorSchema` with
`PDF_CODES.INVALID_HEX_COLOR`.
## Usage: Basic
```ts
import {
HexColorSchema,
PageConfigSchema,
parseHexColor,
} from "@suaveplan/pdf/schemas";
const hex = parseHexColor(req.body.brandColor); // throws PdfError on bad input
const page = PageConfigSchema.parse(req.body.page);
```
## Usage: Advanced
`parseOrThrow` lets you reuse any Zod schema (not just those declared
here) with the package's typed error contract:
```ts
import z from "zod";
import { PDF_CODES } from "@suaveplan/pdf/error-codes";
import { parseOrThrow } from "@suaveplan/pdf/schemas";
const InvoiceInputSchema = z.object({
number: z.string().min(1),
total: z.number().nonnegative(),
});
const input = parseOrThrow(
InvoiceInputSchema,
requestBody,
PDF_CODES.INVALID_TEMPLATE_INPUT,
"invalid invoice input"
);
```
The thrown `PdfError`'s `context.path` is an array of field paths from
the Zod issue list — perfect for surfacing field-level validation
diagnostics to upstream HTTP responses without leaking implementation
details.
## API Reference
- `UnitSchema: z.ZodEnum<["pt", "mm", "cm", "in", "px"]>`.
- `HexColorSchema: z.ZodString` — regex
`^#?(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$`.
- `RgbColorSchema: z.ZodObject<{ r, g, b }>`.
- `MarginsSchema: z.ZodObject<{ top, right, bottom, left }>`.
- `PageConfigSchema: z.ZodObject<{ size, orientation, margins }>`.
- `DocumentMetadataSchema: z.ZodObject<{ title?, author?, subject?, keywords?, creator?, creationDate? }>`.
- `parseOrThrow<T extends z.ZodTypeAny>(schema: T, value: unknown, code: PdfErrorCode, message: string): z.infer<T>`.
- `parseHexColor(value: unknown): string` — convenience wrapper.
## Implementation Notes
- Schemas declare only the runtime shape; the corresponding compile-time
types live in `src/types/page-size.ts` for `PageConfig` /
`DocumentMetadata` / `Margins` and in `src/colors/colors.ts` for
`RgbColor`. This split mirrors the docs / sandbox / contacts pattern
where the canonical type is defined once and the schema is a runtime
echo.
- `parseOrThrow` reads `result.error.issues` directly (Zod 3+) rather
than calling `.format()`, because we need the raw path arrays to
populate `err.context.path` for downstream HTTP error responses.
- The `cause` attached to the thrown `PdfError` is a fresh `Error`
whose `.message` is the `;`-joined Zod issue summary; the original
Zod `ZodError` is discarded to keep the cause chain serialisable via
the standard `SuaveplanError` wire format.
- `HexColorSchema` deliberately rejects `4` and `5`-char forms — only
`3` and `6` are valid in CSS / PDF contexts.
- `import z from "zod"` is the only Bun-safe form per repo CLAUDE.md.

View File

@@ -0,0 +1,86 @@
# style-resolver
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The `StyleResolver` resolves a fully-populated `PdfStyle` from the
package defaults, an active theme, an optional base layer, and any
caller-supplied overrides. It is the single source of truth for the
style cascade applied to every rendered element. A second concern of
this module is normalising `SpacingValue` inputs (scalar or per-side
object) into the four-sided `ResolvedSpacing` shape the layout engine
expects, so downstream consumers never branch on the input shape.
## Features
- Deterministic four-layer cascade: `DEFAULT_STYLE` → theme defaults
→ optional base → caller overrides.
- `mergeStyles` skips `undefined` values so narrow patches do not zero
out preceding layers.
- `mergeTheme` performs slot-by-slot deep merging (colours,
typography, spacing) including the `headingSizes` sub-object.
- `withTheme` returns a child resolver — the parent is not mutated.
- `normalizeSpacing` raises `PDF.INVALID_ELEMENT` on non-finite input.
- `DEFAULT_THEME` matches the historical main-branch palette so
templates render identically across both packages.
## Usage: Basic
```ts
import { StyleResolver } from "@suaveplan/pdf";
const styles = new StyleResolver();
const heading = styles.resolve({ fontSize: 24, fontWeight: "bold" });
// heading.color === "#000000" (DEFAULT_THEME palette)
// heading.fontFamily === "Helvetica"
```
## Usage: Advanced
```ts
import { StyleResolver, DEFAULT_THEME, normalizeSpacing } from "@suaveplan/pdf";
const branded = new StyleResolver({
...DEFAULT_THEME,
colors: { ...DEFAULT_THEME.colors, text: "#222244" },
});
const localised = branded.withTheme({
typography: { ...DEFAULT_THEME.typography, baseFontSize: 11 },
});
const cellStyle = localised.resolveWith(
{ padding: { top: 4, right: 8, bottom: 4, left: 8 } },
{ color: "#ffffff", backgroundColor: "#0f3460" }
);
const padding = normalizeSpacing(cellStyle.padding);
// padding = { top: 4, right: 8, bottom: 4, left: 8 }
```
## API Reference
- `class StyleResolver<TTheme extends PdfTheme>` — `getTheme()`,
`resolve(overrides?)`, `resolveWith(base, overrides?)`,
`withTheme(overrides)`.
- `mergeStyles(...layers)` → `PdfStyle`.
- `mergeTheme(base, overrides)` → `PdfTheme`.
- `normalizeSpacing(value)` → `{ top, right, bottom, left }`.
- Constants: `DEFAULT_STYLE`, `DEFAULT_THEME`.
- Types: `PdfStyle`, `PdfTheme`, `BorderStyle`, `SpacingValue`,
`ResolvedSpacing`, `ThemeColors`, `ThemeTypography`, `ThemeSpacing`.
## Implementation Notes
The cascade is implemented as a single left-to-right reduction over
the supplied layers. Each layer is iterated with `Object.entries` and
any `undefined` value is skipped — empirically this is the
least-surprising behaviour for fluent callers that emit partial
patches. `mergeTheme` is deliberately written as a literal-spread
expression so a missing override slot falls back to the base verbatim
without triggering a deep copy. `normalizeSpacing` validates every
component with `Number.isFinite` so callers that pipe user input
through the resolver receive a typed `PdfError` rather than producing
`NaN`-poisoned layout coordinates downstream. The default theme
palette and typography mirror the main-branch package byte-for-byte
to keep template output stable across the migration boundary.

View File

@@ -0,0 +1,124 @@
# table-element
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`TableElement` is the tabular-data primitive of `@suaveplan/pdf`. It
accepts a typed column definition + row array and renders a content
stream containing a header row, alternating row backgrounds, per-cell
alignment, optional borders, and cross-page pagination with header
repeat. The element is parameterised on the row type `TRow` so callers
get full TypeScript inference on the `formatter(value, row)` hook,
keeping the template layer (invoice, receipt, report) strongly typed.
The element validates options through Zod and consumes Chunk B's
`ContentStreamEncoder` for graphics-state-clean output. Pagination is
exposed via `renderPage(context, rowStart)` so the layout engine in
Chunk D can iterate page boundaries without re-measuring the entire
table.
## Features
- Per-column `width`, `align`, `header`, optional `formatter(value, row)`.
- Auto-distribution of remaining width across columns without `width`.
- Header repeat across paginated pages (toggleable).
- Alternating row backgrounds.
- Cell text wrapping using the text-element wrap helper (single line
shown by default; multi-line cells are a Chunk-D enhancement).
- Optional outer + inter-column border strokes.
- `LAYOUT_OVERFLOW` typed error when the available height cannot even
hold the header.
- `AbortSignal` propagation on `render` / `renderPage`.
## Usage: Basic
```ts
import { PdfWriter, TableElement } from "@suaveplan/pdf";
const writer = new PdfWriter();
const fontName = writer.registerFont("Helvetica");
const table = new TableElement({
columns: [
{ key: "name", header: "Name", width: 200 },
{ key: "qty", header: "Qty", width: 60, align: "right" },
{ key: "total", header: "Total", align: "right" },
],
rows: [
{ name: "Widget", qty: 2, total: 10 },
{ name: "Gizmo", qty: 1, total: 5 },
],
});
const out = table.render({
x: 72,
y: 770,
maxWidth: 460,
fontResourceName: fontName,
});
```
## Usage: Advanced
```ts
import { TableElement } from "@suaveplan/pdf";
interface Row { sku: string; price: number; qty: number; }
const table = new TableElement<Row>({
columns: [
{ key: "sku", header: "SKU", width: 120 },
{
key: "price",
header: "Price",
width: 80,
align: "right",
formatter: (v) => `$${(Number(v) ?? 0).toFixed(2)}`,
},
{ key: "qty", header: "Qty", align: "center" },
],
rows: largeRowSet,
headerBackground: "#0f3460",
headerColor: "#ffffff",
alternateRowBackground: "#f7f7f7",
border: true,
borderColor: "#cccccc",
cellPadding: 6,
fontSize: 9,
});
// Paginate with header repeat across multiple pages.
let cursor = 0;
while (cursor < largeRowSet.length) {
const page = table.renderPage(
{ x: 72, y: 770, maxWidth: 460, maxHeight: 680, fontResourceName: "F1" },
cursor
);
if (page.rowEnd === cursor) break; // nothing fitted — guard
cursor = page.rowEnd;
// Append `page.stream` to a new page in the writer.
}
```
## API Reference
- `class TableElement<TRow>(options)` — validates `TableElementOptions`
and exposes `measure(maxWidth)`, `render(context)`, and
`renderPage(context, rowStart)`.
- `TableElementOptionsSchema` — Zod schema used at construction.
- `computeColumnWidths(columns, maxWidth)` — width distribution helper.
## Implementation Notes
Header repeat is controlled by `repeatHeader` (default `true`) and
applies to every `renderPage` call where `rowStart > 0` — set `false`
when emitting continuation pages that already have their own header.
Cell text is wrapped with `wrapText` from `text-element` but currently
displays only the first line; multi-line cells require row-height
expansion which lands in Chunk D's layout engine alongside row
splitting. The border stroke draws an outer rectangle plus inter-column
verticals via raw `S` operators — no horizontal row dividers by design
(callers wanting full grids should set `border: true` and then layer a
`DividerElement` per row in the layout engine). `AbortSignal` is
checked at the top of `renderPage` so cancelled renders bail out
before any pixel work begins.

View File

@@ -0,0 +1,104 @@
# `telemetry`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
OpenTelemetry surface for `@suaveplan/pdf`. PDF generation is a
load-bearing primitive in any system that emits invoices, receipts, or
financial reports, so the package needs first-class observability —
counters for throughput / failure-rate, histograms for latency and
output size, all tagged with stable `PDF.*` codes. The
`createPdfObservability` factory constructs the bundle once at module
load and binds to the global no-op meter / tracer when no telemetry
pipeline is wired up, so the recording helpers are always safe to call
from the hot path.
## Features
- Six instruments on the `@suaveplan/pdf` meter:
- `pdf_documents_rendered_total` — counter tagged `success`.
- `pdf_pages_emitted_total` — counter.
- `pdf_render_errors_total` — counter tagged `code`.
- `pdf_validation_failures_total` — counter tagged `code`.
- `pdf_render_duration_ms` — histogram (ms).
- `pdf_document_size_bytes` — histogram (bytes).
- `createPdfObservability(opts?)` factory accepting either an explicit
bundle (`opts.observability`), an explicit meter + tracer pair, or
nothing (binds to the no-op providers).
- Six matching `record*` helpers so call sites do not have to know the
attribute keys.
## Usage: Basic
```ts
import { createPdfObservability, recordDocumentRendered } from "@suaveplan/pdf/telemetry";
const obs = createPdfObservability();
recordDocumentRendered(obs, { success: true });
```
## Usage: Advanced
In tests, supply a meter from a `MeterProvider` configured with a
metric reader to assert exact recorded values:
```ts
import { MeterProvider } from "@opentelemetry/sdk-metrics";
import {
createPdfObservability,
recordRenderDuration,
recordRenderError,
} from "@suaveplan/pdf/telemetry";
const provider = new MeterProvider();
const meter = provider.getMeter("test");
const obs = createPdfObservability({ meter });
recordRenderError(obs, { code: "PDF.RENDER_FAILED" });
recordRenderDuration(obs, 42);
```
In production, install the OTLP / Prometheus exporter once at process
start-up; every call site in `@suaveplan/pdf` will then surface metrics
to your collector automatically because `createPdfObservability()` (no
args) reads from the global `metrics`/`trace` API providers.
## API Reference
- `createPdfObservability(opts?: PdfObservabilityOptions): PdfObservability`
— factory returning `{ meter, tracer, counters, histograms }`.
- `recordDocumentRendered(obs, { success })` — adds `1` to the
documents-rendered counter tagged with `success` as the string
`"true"` / `"false"`.
- `recordPagesEmitted(obs, count)` — adds `count` to the pages-emitted
counter.
- `recordRenderError(obs, { code })` — adds `1` to the render-errors
counter tagged with the `PDF.*` code.
- `recordValidationFailure(obs, { code })` — adds `1` to the
validation-failures counter tagged with the `PDF.*` code.
- `recordRenderDuration(obs, ms)` — records a sample on the
render-duration histogram.
- `recordDocumentSize(obs, bytes)` — records a sample on the
document-size histogram.
## Implementation Notes
- `createCounter` / `createHistogram` come from
`@suaveplan/opentelemetry`; they wrap the standard
`Meter.createCounter` / `Meter.createHistogram` so the no-op meter is
picked up automatically when no `MeterProvider` is installed.
- Histogram units follow the OpenTelemetry convention: `"ms"` for
durations, `"By"` for byte sizes.
- Attribute values are stringified at the call-site (`success` becomes
`"true"` / `"false"`) because OTel SDKs reject boolean attribute
values in some backends; sticking to strings is the portable form.
- `createPdfObservability({ observability })` short-circuits because
call sites embedded inside a larger service want to pass through the
bundle their caller already built, avoiding duplicate counter
registration warnings on the OTel SDK side.
- Each record helper is its own export — colocating them with the
bundle factory lets call sites tree-shake unused recorders without
pulling the entire factory.

View File

@@ -0,0 +1,142 @@
# `invoice-template`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`createInvoiceTemplate(data, options?)` converts a typed `InvoiceData`
payload into a renderer-ready `Document` for professional A4 invoices.
The factory eagerly validates the payload through `InvoiceDataSchema`,
then walks every layout decision (the `INVOICE` headline, invoice
number, `From` / `Bill To` blocks, issue + due dates, line-item table,
totals ladder, and optional notes) via the chunk D `DocumentBuilder`,
producing a `Document<InvoiceData>` that `renderDocument` flattens to
PDF 1.4 bytes.
## Features
- Eager Zod validation with `PdfError.INVALID_TEMPLATE_INPUT`
- Subtotal / tax / discount / total aggregation via
`computeInvoiceTotals` (tax and discount rates are per-item
percentages applied to that item's line total)
- Configurable tax label (`data.taxLabel`) defaulting to `"Tax"`
- Currency symbol formatting for USD, EUR, GBP, JPY, AUD, CAD; unknown
codes fall back to a prefixed code (e.g. `"CHF 12.00"`)
- A4 page size default, overridable via `options.page`
- Optional repeating header / footer slots (`header` + `headerHeight`)
- Theme cascade — overrides `DEFAULT_THEME` colour palette; the
template colours match the upstream invoice visual layout
- Optional notes block rendered after a divider
## Usage: Basic
```ts
import { createInvoiceTemplate, renderDocument } from "@suaveplan/pdf";
const { bytes } = renderDocument(
createInvoiceTemplate({
invoiceNumber: "INV-1",
issueDate: new Date(),
dueDate: new Date(),
from: {
name: "Suaveplan Ltd",
line1: "1 Atlas Way",
city: "London",
postalCode: "EC1A 1AA",
country: "UK",
},
to: {
name: "Acme Co",
line1: "100 Market St",
city: "San Francisco",
state: "CA",
postalCode: "94103",
country: "USA",
},
items: [
{ description: "Consulting", quantity: 10, unitPrice: 200 },
],
})
);
```
## Usage: Advanced
```ts
import {
createInvoiceTemplate,
DEFAULT_THEME,
renderDocument,
TextElement,
} from "@suaveplan/pdf";
const headerNode = {
type: "text" as const,
element: new TextElement({ content: "SUAVEPLAN LTD" }),
};
const doc = createInvoiceTemplate(
{
invoiceNumber: "INV-42",
issueDate: new Date("2026-05-01"),
dueDate: new Date("2026-06-01"),
from: {
name: "Suaveplan Ltd",
line1: "1 Atlas Way",
city: "London",
postalCode: "EC1A 1AA",
country: "UK",
},
to: {
name: "Acme Co",
line1: "100 Market St",
city: "San Francisco",
state: "CA",
postalCode: "94103",
country: "USA",
},
items: [
{ description: "Service", quantity: 1, unitPrice: 100, taxRate: 20 },
],
currency: "EUR",
taxLabel: "VAT",
notes: "Pay-by-the-due-date.",
},
{
theme: { ...DEFAULT_THEME, name: "brand" },
header: [headerNode],
headerHeight: 28,
}
);
const { bytes, pageCount } = renderDocument(doc);
```
## API Reference
- `createInvoiceTemplate(data, options?) => Document<InvoiceData>` —
main factory.
- `InvoiceDataSchema` — Zod schema for the input payload.
- `computeInvoiceTotals(items)` — pure helper returning
`{ subtotal, tax, discount, total }`.
- `formatInvoiceAmount(amount, currency)` — symbol-prefixed two-decimal
string.
- `InvoiceData`, `InvoiceAddress`, `InvoiceLineItem`,
`InvoiceTemplateOptions`, `InvoiceDocument`, `InvoiceTotals` — public
types.
## Implementation Notes
The factory does not run `renderDocument` itself — it returns a
`Document<InvoiceData>` so callers control telemetry, abort signals,
and clock injection. Invalid payloads surface as `PdfError` with the
failing issue paths in `context.path`, enabling structured logging
downstream. The line-item table uses fixed column widths (`250`, `60`,
`100`, `100`) to fit the A4 default; supplying `options.page` with
`LETTER` keeps the layout visually consistent because the table
element preserves explicit column widths under auto-distribution. The
totals ladder always emits `Subtotal` and `Total`; the `Tax` and
`Discount` rows appear only when their aggregates are non-zero, so
zero-tax invoices stay visually clean.

View File

@@ -0,0 +1,122 @@
# `receipt-template`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`createReceiptTemplate(data, options?)` converts a typed `ReceiptData`
payload into a renderer-ready `Document` for compact A5 receipts that
mirror the layout of point-of-sale printers and emailed receipts. The
factory eagerly validates the payload through `ReceiptDataSchema`, then
walks every layout decision (merchant block, receipt number + date,
line-item table, totals ladder, optional notes + payment-method
sentinels, and the closing message) via the chunk D `DocumentBuilder`,
producing a `Document<ReceiptData>` that `renderDocument` flattens to
PDF 1.4 bytes.
## Features
- Eager Zod validation at the boundary with `PdfError.INVALID_TEMPLATE_INPUT`
- Subtotal / tax / discount / total aggregation via `computeReceiptTotals`
- Currency symbol formatting for USD, EUR, GBP, JPY, AUD, CAD; unknown
codes fall back to a prefixed code (e.g. `"CHF 12.00"`)
- A5 page size default with a 36-point margin block, overridable via
`options.page`
- Optional repeating header / footer slots (`header` + `headerHeight`)
- Theme cascade — overrides `DEFAULT_THEME` colour palette without
re-defining the entire theme
- Closing message customisable via `data.footerMessage`; falls back to
`"Thank you!"`
## Usage: Basic
```ts
import { createReceiptTemplate } from "@suaveplan/pdf";
import { renderDocument } from "@suaveplan/pdf";
const { bytes } = renderDocument(
createReceiptTemplate({
receiptNumber: "RCPT-1",
date: new Date(),
merchant: {
name: "Suaveplan Cafe",
line1: "1 Bean Lane",
city: "Pourtown",
state: "CA",
postalCode: "90210",
},
items: [
{ description: "Espresso", quantity: 2, unitPrice: 3.5 },
],
})
);
```
## Usage: Advanced
```ts
import { createReceiptTemplate, DEFAULT_THEME } from "@suaveplan/pdf";
import { renderDocument, TextElement } from "@suaveplan/pdf";
const headerNode = {
type: "text" as const,
element: new TextElement({ content: "SUAVEPLAN CAFE" }),
};
const doc = createReceiptTemplate(
{
receiptNumber: "RCPT-42",
date: new Date(),
merchant: {
name: "Suaveplan Cafe",
line1: "1 Bean Lane",
city: "Pourtown",
state: "CA",
postalCode: "90210",
},
items: [
{ description: "Latte", quantity: 1, unitPrice: 4.5, taxRate: 8.5 },
],
paymentMethod: "Visa **** 4242",
notes: "Loyalty: 25 pts",
currency: "EUR",
footerMessage: "Come again!",
},
{
theme: { ...DEFAULT_THEME, name: "merchant" },
header: [headerNode],
headerHeight: 28,
}
);
const { bytes, pageCount } = renderDocument(doc);
```
## API Reference
- `createReceiptTemplate(data, options?) => Document<ReceiptData>` — main
factory.
- `ReceiptDataSchema` — Zod schema for the input payload.
- `computeReceiptTotals(items)` — pure helper returning
`{ subtotal, tax, discount, total }`.
- `formatReceiptAmount(amount, currency)` — symbol-prefixed two-decimal
string.
- `ReceiptData`, `ReceiptMerchant`, `ReceiptLineItem`,
`ReceiptTemplateOptions`, `ReceiptDocument`, `ReceiptTotals` — public
types.
## Implementation Notes
The factory does not run `renderDocument` itself — it returns a
`Document<ReceiptData>` so callers control telemetry, abort signals, and
clock injection. Invalid payloads surface as `PdfError` with the failing
issue paths in `context.path`, enabling structured logging downstream.
Currency symbols are intentionally simple (no `Intl.NumberFormat`
dependency) so server-tier renderers remain locale-free; consumers that
need full ICU formatting should pre-format their values before passing
them in. The line-item table uses fixed column widths (`160`, `50`,
`80`) to fit the A5 default; supplying `options.page` with `LETTER` or
`A4` keeps the table visually consistent because the table element's
auto-distribution preserves the explicit widths.

View File

@@ -0,0 +1,119 @@
# `report-template`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`createReportTemplate(data, options?)` converts a typed `ReportData`
payload into a renderer-ready `Document` for multi-section reports —
compliance exports, dashboard snapshots, weekly digests, and the
analytics-style documents that pair a title block with one or more
section heading + body + optional data-table units. The factory
eagerly validates the payload through `ReportDataSchema`, then walks
every layout decision via the chunk D `DocumentBuilder`, producing a
`Document<ReportData>` that `renderDocument` flattens to PDF 1.4 bytes.
## Features
- Eager Zod validation with `PdfError.INVALID_TEMPLATE_INPUT`
- Title block: title (h1 + primary colour), optional subtitle, optional
author + ISO date meta line
- One section per entry: `h2` heading + body paragraph + optional data
table (renders only when both `data` and `columns` are present)
- Alternating row background + secondary-coloured table header drawn
from the active theme
- Optional summary tail rendered after a final divider
- Optional repeating header / footer slots (`header` + `headerHeight`)
- Theme cascade — `DEFAULT_THEME` colour palette is overridable; the
factory wires the theme through every emitted node
## Usage: Basic
```ts
import { createReportTemplate, renderDocument } from "@suaveplan/pdf";
const { bytes } = renderDocument(
createReportTemplate({
title: "Weekly Health",
date: new Date(),
sections: [
{
title: "Uptime",
content: "All services held > 99.9% over the week.",
},
],
})
);
```
## Usage: Advanced
```ts
import {
createReportTemplate,
DEFAULT_THEME,
renderDocument,
TextElement,
} from "@suaveplan/pdf";
const headerNode = {
type: "text" as const,
element: new TextElement({ content: "Q1 REPORT" }),
};
const doc = createReportTemplate(
{
title: "Quarterly Review",
subtitle: "Q1 2026",
author: "Auditor Bot",
date: new Date("2026-04-01"),
sections: [
{ title: "Overview", content: "..." },
{
title: "Numbers",
content: "Per-region revenue:",
data: [
{ region: "AMER", revenue: 100 },
{ region: "EMEA", revenue: 80 },
],
columns: [
{ key: "region", header: "Region" },
{ key: "revenue", header: "Revenue", align: "right" },
],
},
],
summary: "Targets met.",
},
{
theme: { ...DEFAULT_THEME, name: "audit" },
header: [headerNode],
headerHeight: 30,
}
);
const { bytes, pageCount } = renderDocument(doc);
```
## API Reference
- `createReportTemplate(data, options?) => Document<ReportData>` — main
factory.
- `ReportDataSchema` — Zod schema for the input payload.
- `ReportData`, `ReportSection`, `ReportColumn`, `ReportTemplateOptions`,
`ReportDocument` — public types.
## Implementation Notes
The factory deliberately runs every emission through `DocumentBuilder`
so the chunk D layout engine handles pagination, header/footer slot
reservation, and table-row pagination uniformly. A section with `data`
but no `columns` (or vice versa) silently skips the table — the missing
half is treated as a no-op rather than a validation error, because the
caller's intent (heading + body only) is unambiguous. The factory does
not run `renderDocument` itself — it returns a `Document<ReportData>`
so callers control telemetry, abort signals, and clock injection. The
`Intl.*`-free ISO date string keeps the renderer locale-independent;
consumers that need a localised header should pre-format their dates
into `data.subtitle` or render a custom header slot.

View File

@@ -0,0 +1,113 @@
# text-element
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`TextElement` is the flowed-text primitive of `@suaveplan/pdf`. It accepts
a content string plus typography options (font family, weight, style, size,
line height, alignment, colour, kerning, line cap) and produces a PDF
content-stream snippet that can be embedded in a page by `PdfWriter`. The
element wraps long content to fit a column, supports left / center / right /
justify alignment, exposes both `measure()` (for layout) and `render()` (for
output), and validates every option through a Zod schema so consumers
receive typed `PDF.INVALID_ELEMENT` errors instead of runtime crashes.
This is the first true rendering primitive that consumes Chunk B's
`ContentStreamEncoder` + `PdfWriter` font registry. Chunks D and E (layout
engine + templates) compose `TextElement` instances; the layout engine calls
`measure()` to decide page breaks and `render()` once a position has been
chosen.
## Features
- Standard-14 PDF font selection via `(family, weight, style)` triple.
- Word-wrap with average-glyph-width approximation, hard-split for
oversized words, paragraph-aware newline handling.
- `measure(maxWidth)` returns `{ width, height, lineCount }` for layout.
- `render(context)` emits a graphics-state-clean snippet with `q…Q`,
fill-colour application, `BT/ET` text-mode, multi-line cursor moves,
optional character-spacing (kerning).
- Alignment: `left`, `center`, `right`, `justify` (currently maps to left
for the X coordinate — full justification lands in the layout engine).
- `maxLines` cap + `overflowed` flag for callers that must enforce a
ceiling.
- `AbortSignal` propagation — aborted contexts raise `PDF.ABORTED`.
- Skips the fill-colour operator entirely for `transparent` / `none`.
## Usage: Basic
```ts
import { PdfWriter, TextElement } from "@suaveplan/pdf";
const writer = new PdfWriter();
const fontName = writer.registerFont("Helvetica");
const text = new TextElement({ content: "Hello PDF", fontSize: 14 });
const out = text.render({
x: 72,
y: 770,
maxWidth: 460,
fontResourceName: fontName,
});
const streamId = writer.addStream(out.stream);
writer.addPage({ width: 612, height: 792, contentStreamId: streamId });
const bytes = writer.generate({ Title: "Hello" });
```
## Usage: Advanced
```ts
import { TextElement, wrapText } from "@suaveplan/pdf";
// Pre-measure for a multi-column layout.
const text = new TextElement({
content: "Aenean commodo ligula eget dolor. Aenean massa.",
fontSize: 10,
fontFamily: "Times-Roman",
fontWeight: "bold",
fontStyle: "italic",
color: "#222244",
lineHeight: 1.35,
align: "right",
maxLines: 6,
kerning: 0.25,
});
const { lineCount, height } = text.measure(220);
const lines = wrapText(text.options.content, 10, 220);
// Choose page placement based on `lineCount` + `height`, then render.
const out = text.render({
x: 320,
y: 700,
maxWidth: 220,
fontResourceName: "F1",
});
if (out.overflowed) {
// Insert a continuation marker on the next page.
}
```
## API Reference
- `class TextElement(options)` — validates `TextElementOptions` and
exposes `measure(maxWidth)` + `render(context)` + `fontName()`.
- `TextElementOptionsSchema` — Zod schema for option validation.
- `wrapText(text, fontSize, maxWidth)` — word-wrap helper used by
`measure` / `render` and exported for layout-engine use.
- `measureLineWidth(line, fontSize)` — average-glyph width estimate.
- `resolveStandardFont(family, weight, style)` — resolves to one of the
14 standard PDF font names or throws `PDF.FONT_UNKNOWN`.
## Implementation Notes
The wrap algorithm uses a deterministic `0.5em` average-character width
because the standard 14 PDF fonts ship with every reader but do not embed
glyph metrics in the document; downstream chunks may swap in true font
metrics once subset embedding lands. Rendering goes through
`ContentStreamEncoder` (not raw string interpolation) so the text-mode
invariants are enforced and the resulting stream is guaranteed to balance
`BT`/`ET` + `q`/`Q`. The fill-colour application happens outside `BT/ET`
because PDF readers reject `rg` inside a text object on strict-mode
viewers. `AbortSignal` is checked at the top of `render()` so the element
can participate in cooperative cancellation alongside `PdfWriter.generate`.

View File

@@ -0,0 +1,86 @@
# `page-size`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Defines the page-size + page-config primitives used across every other
module in `@suaveplan/pdf`. PDF measurements are expressed in **points**
where one point equals one seventy-second of an inch (`1pt = 1/72 in`),
so a US Letter page is `612pt × 792pt` and an A4 page is
`595.28pt × 841.89pt`. The module is split out of `types/` because it
carries runtime exports (the `PageSize` enum and the `PAGE_DIMENSIONS`
lookup table) in addition to compile-time-only types, and it is imported
by the renderer + every template.
## Features
- `PageSize` enum with five presets: `A4`, `A3`, `A5`, `LETTER`, `LEGAL`.
- `PAGE_DIMENSIONS` constant — width × height in points for each preset.
- `PageOrientation` string union (`"portrait" | "landscape"`).
- `Margins` and `DEFAULT_MARGINS` (one-inch / 72pt all round).
- `DocumentMetadata`, `PageConfig`, `DEFAULT_PAGE_CONFIG`,
`DocumentDefinition`, `PageDefinition`, and the polymorphic
`ElementNode` discriminator type used by the future renderer.
## Usage: Basic
```ts
import {
DEFAULT_MARGINS,
PAGE_DIMENSIONS,
PageSize,
} from "@suaveplan/pdf/types";
const a4 = PAGE_DIMENSIONS[PageSize.A4];
console.log(`A4 is ${a4.width}pt × ${a4.height}pt`);
console.log(`Default margins: ${DEFAULT_MARGINS.top}pt`);
```
## Usage: Advanced
Custom page sizes are expressed as plain `PageConfig` literals — the
renderer treats anything matching the shape uniformly, so US `Tabloid`
(`792pt × 1224pt`) or a custom envelope is one structural step away
from the presets.
```ts
import type { PageConfig } from "@suaveplan/pdf/types";
import { PageSize } from "@suaveplan/pdf/types";
const tabloid: PageConfig = {
size: PageSize.LETTER, // treat as nearest preset for downstream code
orientation: "landscape",
margins: { top: 36, right: 36, bottom: 36, left: 36 },
};
```
## API Reference
- `enum PageSize { A4, A3, A5, LETTER, LEGAL }` — discriminator for the
built-in presets.
- `PAGE_DIMENSIONS: Record<PageSize, { width: number; height: number }>`
— width × height in points for each preset.
- `type PageOrientation = "portrait" | "landscape"`.
- `interface Margins { top, right, bottom, left: number }` (all in points).
- `const DEFAULT_MARGINS: Margins` — `{ 72, 72, 72, 72 }`.
- `interface DocumentMetadata { title?, author?, subject?, keywords?, creator?, creationDate? }`.
- `interface PageConfig { size, orientation, margins }`.
- `const DEFAULT_PAGE_CONFIG: PageConfig` — `A4` portrait with default margins.
- `interface DocumentDefinition<TData = unknown> { metadata, defaultPage, pages, data? }`.
- `interface PageDefinition { config?, elements }`.
- `interface ElementNode { type, props, children? }` — discriminated by `type`.
## Implementation Notes
- Dimensions are stored to two decimal places (the value at which ISO A
sizes round in points); the renderer uses `fmt(n)` to emit them with
at most four decimals which is the PDF reader's expected precision.
- `PAGE_DIMENSIONS` is typed `Record<PageSize, …>` and not `as const` so
the lookup is type-safe even when the consumer holds a `PageSize`
value from a string parse.
- `ElementNode` is intentionally permissive (`props: Record<string,
unknown>`) at the type layer; the concrete element shape is enforced
by Zod schemas at the public boundary in Chunk C.

View File

@@ -0,0 +1,91 @@
# `units`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Unit-conversion utilities for `@suaveplan/pdf`. PDF's native measurement
is the **point** (`1pt = 1/72 inch`); every coordinate, font-size, line
width, and margin passed to the writer must ultimately be in points.
This module lets callers express measurements in any of the five common
units (`pt`, `mm`, `cm`, `in`, `px`) and converts to and from points
deterministically. All failures surface as `PdfError(PDF_CODES.INVALID_UNIT)`
so consumers can discriminate programmatically.
## Features
- `toPoints(value, unit)` — convert any supported unit to points.
- `fromPoints(points, unit)` — inverse of the above.
- `convert(value, from, to)` — pairwise conversion between any two units;
identity short-circuits to avoid round-trip floating-point drift.
- `parseUnit(string)` — parse `"10mm"`, `"2.5in"`, `"72pt"`, `"100px"`,
plain `"42"`, with surrounding whitespace and negative values supported.
- `formatUnit(points, unit, decimals)` — format a point value with a
unit suffix and a configurable decimal-place count.
## Usage: Basic
```ts
import { convert, parseUnit, toPoints } from "@suaveplan/pdf/units";
const inchInPoints = toPoints(1, "in"); // 72
const mmFromInches = convert(1, "in", "mm"); // ≈ 25.4
const fromString = parseUnit("10mm"); // ≈ 28.346
```
## Usage: Advanced
`formatUnit` is useful for emitting human-readable margin / padding
strings in design-time debug output, while `parseUnit` is the canonical
entry point for accepting user-supplied measurements from a config
file or HTTP request body:
```ts
import { formatUnit, parseUnit } from "@suaveplan/pdf/units";
import { PDF_CODES, PdfError } from "@suaveplan/pdf/error-codes";
function loadMarginPoints(raw: string): number {
try {
return parseUnit(raw);
} catch (err) {
if (err instanceof PdfError && err.code === PDF_CODES.INVALID_UNIT) {
// …fall back to default
return 72;
}
throw err;
}
}
// Round-trip with controlled precision:
const display = formatUnit(parseUnit("1in"), "mm", 4); // "25.4000mm"
```
## API Reference
- `type Unit = "pt" | "mm" | "cm" | "in" | "px"` — the five supported
unit identifiers.
- `toPoints(value: number, unit?: Unit): number` — defaults to `"pt"`.
- `fromPoints(points: number, unit?: Unit): number` — defaults to `"pt"`.
- `convert(value: number, from: Unit, to: Unit): number`.
- `parseUnit(input: string): number` — returns points; throws `PdfError`
with `code === PDF_CODES.INVALID_UNIT` on bad input.
- `formatUnit(points: number, unit?: Unit, decimals?: number): string`
— defaults to `"pt"` and `2` decimals.
## Implementation Notes
- The conversion table is a `Record<Unit, number>`; the `factorFor`
helper validates the runtime input even when TypeScript would accept
it (defensive cast paths from JSON / form data).
- `convert(x, u, u)` short-circuits to avoid `(x * f) / f` floating-point
drift, which is observable for non-power-of-two conversions like
`convert(0.1, "in", "in")`.
- `parseUnit`'s regex `^(-?\d+(?:\.\d+)?)\s*(pt|mm|cm|in|px)?$` accepts
optional unit suffix, an optional sign, and optional whitespace
between the value and the suffix. Bare numbers (no suffix) are
treated as points to align with PDF's native measurement.
- No `Intl.NumberFormat` is used in `formatUnit` — the suffix is fixed
and `toFixed` is locale-free, which matters for downstream byte-level
PDF comparison testing.

View File

@@ -0,0 +1,69 @@
# @suaveplan/sitemap - Sitemap generation and parsing: sitemap.xml, sitemap index, image sitemaps, video sitemaps per sitemaps.org protocol
## Summary
Sitemap generation and parsing for the sitemaps.org XML protocol. Produces valid `sitemap.xml`, sitemap index documents, and parses existing sitemaps. Supports Google image and video extensions as well as hreflang alternate links.
<!-- begin:auto-docs -->
## Documentation tree
- [builder](./src/core/builder/sitemap-builder.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [sitemap-builder](./src/core/builder/sitemap-builder.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [index-builder](./src/core/index-builder/index-builder.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [index-builder](./src/core/index-builder/index-builder.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [parser](./src/core/parser/sitemap-parser.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [sitemap-parser](./src/core/parser/sitemap-parser.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [errors](./src/errors/sitemap-error.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [sitemap-error](./src/errors/sitemap-error.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [schemas](./src/schemas/sitemap.schema.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [sitemap.schema](./src/schemas/sitemap.schema.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [utils](./src/utils/url-utils.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [url-utils](./src/utils/url-utils.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
<!-- end:auto-docs --> - [sitemap-builder](./src/core/builder/sitemap-builder.md) — <!-- begin:auto-docs --> _(no submodules)_- [index-builder](./src/core/index-builder/index-builder.md) — <!-- begin:auto-docs --> _(no submodules)_- [index-builder](./src/core/index-builder/index-builder.md) — <!-- begin:auto-docs --> _(no submodules)_- [parser](./src/core/parser/sitemap-parser.md) — <!-- begin:auto-docs --> _(no submodules)_- [sitemap-parser](./src/core/parser/sitemap-parser.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/sitemap-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [sitemap-error](./src/errors/sitemap-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [schemas](./src/schemas/sitemap.schema.md) — <!-- begin:auto-docs --> _(no submodules)_- [sitemap.schema](./src/schemas/sitemap.schema.md) — <!-- begin:auto-docs --> _(no submodules)_- [utils](./src/utils/url-utils.md) — <!-- begin:auto-docs --> _(no submodules)_- [url-utils](./src/utils/url-utils.md) — <!-- begin:auto-docs --> _(no submodules)_- [sitemap-builder](./src/core/builder/sitemap-builder.md) — <!-- begin:auto-docs --> _(no submodules)_- [index-builder](./src/core/index-builder/index-builder.md) — <!-- begin:auto-docs --> _(no submodules)_- [index-builder](./src/core/index-builder/index-builder.md) — <!-- begin:auto-docs --> _(no submodules)_- [parser](./src/core/parser/sitemap-parser.md) — <!-- begin:auto-docs --> _(no submodules)_- [sitemap-parser](./src/core/parser/sitemap-parser.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/sitemap-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [sitemap-error](./src/errors/sitemap-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [schemas](./src/schemas/sitemap.schema.md) — <!-- begin:auto-docs --> _(no submodules)_- [sitemap.schema](./src/schemas/sitemap.schema.md) — <!-- begin:auto-docs --> _(no submodules)_- [utils](./src/utils/url-utils.md) — <!-- begin:auto-docs --> _(no submodules)_- [url-utils](./src/utils/url-utils.md) — <!-- begin:auto-docs --> _(no submodules)_- [sitemap-builder](./src/core/builder/sitemap-builder.md) — <!-- begin:auto-docs --> _(no submodules)_- [index-builder](./src/core/index-builder/index-builder.md) — <!-- begin:auto-docs --> _(no submodules)_- [index-builder](./src/core/index-builder/index-builder.md) — <!-- begin:auto-docs --> _(no submodules)_- [parser](./src/core/parser/sitemap-parser.md) — <!-- begin:auto-docs --> _(no submodules)_- [sitemap-parser](./src/core/parser/sitemap-parser.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/sitemap-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [sitemap-error](./src/errors/sitemap-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [schemas](./src/schemas/sitemap.schema.md) — <!-- begin:auto-docs --> _(no submodules)_- [sitemap.schema](./src/schemas/sitemap.schema.md) — <!-- begin:auto-docs --> _(no submodules)_- [utils](./src/utils/url-utils.md) — <!-- begin:auto-docs --> _(no submodules)_- [url-utils](./src/utils/url-utils.md) — <!-- begin:auto-docs --> _(no submodules)_- [sitemap-builder](./src/core/builder/sitemap-builder.md) — <!-- begin:auto-docs --> _(no submodules)_- [index-builder](./src/core/index-builder/index-builder.md) — <!-- begin:auto-docs --> _(no submodules)_ - [index-builder](./src/core/index-builder/index-builder.md) — <!-- begin:auto-docs --> _(no submodules)_- [parser](./src/core/parser/sitemap-parser.md) — <!-- begin:auto-docs --> _(no submodules)_ - [sitemap-parser](./src/core/parser/sitemap-parser.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/sitemap-error.md) — <!-- begin:auto-docs --> _(no submodules)_ - [sitemap-error](./src/errors/sitemap-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [schemas](./src/schemas/sitemap.schema.md) — <!-- begin:auto-docs --> _(no submodules)_ - [sitemap.schema](./src/schemas/sitemap.schema.md) — <!-- begin:auto-docs --> _(no submodules)_- [utils](./src/utils/url-utils.md) — <!-- begin:auto-docs --> _(no submodules)_ - [url-utils](./src/utils/url-utils.md) — <!-- begin:auto-docs --> _(no submodules)_
## Installation
```bash
bun add @suaveplan/sitemap
```
## Packages
| Module | Purpose |
|--------|---------|
| [sitemap-builder](src/core/builder/sitemap-builder.md) | Fluent builder for `<urlset>` sitemap documents |
| [index-builder](src/core/index-builder/index-builder.md) | Fluent builder for `<sitemapindex>` documents |
| [sitemap-parser](src/core/parser/sitemap-parser.md) | Parse sitemap and sitemap index XML strings |
| [url-utils](src/utils/url-utils.md) | URL validation, normalisation, XML escaping |
| [sitemap.schema](src/schemas/sitemap.schema.md) | Zod schemas for all wire-format types |
| [sitemap-error](src/errors/sitemap-error.md) | Error codes and `SitemapError` class |
## Quick Start
```ts
import { createSitemap, createSitemapIndex, parseSitemap } from "@suaveplan/sitemap";
// Build a sitemap
const xml = createSitemap({ pretty: true })
.add({ loc: "https://example.com/", changefreq: "weekly", priority: 1.0 })
.add({ loc: "https://example.com/about", lastmod: "2024-01-15" })
.build();
// Build a sitemap index
const indexXml = createSitemapIndex({ pretty: true })
.add("https://example.com/sitemap-blog.xml", "2024-06-01")
.add("https://example.com/sitemap-products.xml")
.build();
// Parse an existing sitemap
const urls = parseSitemap(xml);
console.log(urls[0].loc); // "https://example.com/"
```
## Subpath Exports
```ts
import { SitemapBuilder, createSitemap } from "@suaveplan/sitemap/builder";
import { SitemapIndexBuilder, createSitemapIndex } from "@suaveplan/sitemap/index-builder";
import { parseSitemap, parseSitemapIndex } from "@suaveplan/sitemap/parser";
import { SitemapError, SITEMAP_CODES } from "@suaveplan/sitemap/errors";
```

View File

@@ -0,0 +1,152 @@
# sitemap-builder
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Implements the fluent `SitemapBuilder` class and the `createSitemap` factory
function that together provide the primary interface for producing
sitemaps.org-conformant `<urlset>` XML strings. The builder accumulates
`SitemapUrl` entries, validates each location, enforces the 50 000 URL ceiling
(per the sitemaps.org protocol), and serialises the full document with optional
pretty-printing. Google image and video sitemap extensions are rendered
automatically when the corresponding fields are present on a `SitemapUrl`.
## Features
- Fluent `add(url)` / `addMany(urls[])` API returning `this` for chaining
- `maxUrls` guard (default 50 000) with `SIZE_EXCEEDED` error
- Automatic `xmlns:image`, `xmlns:video`, `xmlns:xhtml` namespace declarations
- Image extension: `loc`, `caption`, `title`, `geoLocation`, `license`
- Video extension: full sitemaps.org video schema including tags, ratings, subscription flags
- hreflang alternates via `<xhtml:link rel="alternate" …/>`
- Pretty-print mode with two-space indentation
- `toJSON()` snapshot, `clear()` reset, `count` getter
- OTel counter `sitemap.build.total{type="urlset"}` and histogram `sitemap.build.duration_ms`
## Usage: Basic Example
```ts
import { createSitemap } from "@suaveplan/sitemap/builder";
const xml = createSitemap({ pretty: true })
.add({ loc: "https://example.com/", changefreq: "weekly", priority: 1.0 })
.add({ loc: "https://example.com/about", lastmod: "2024-01-15" })
.build();
console.log(xml);
// <?xml version="1.0" encoding="UTF-8"?>
// <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" …>
// <url>
// <loc>https://example.com/</loc>
// …
// </url>
// </urlset>
```
## Usage: Advanced Example
```ts
import { createSitemap, SitemapBuilder } from "@suaveplan/sitemap/builder";
import type { SitemapUrl } from "@suaveplan/sitemap";
// Builder with a tight limit for a subsection sitemap
const builder: SitemapBuilder = new SitemapBuilder({ maxUrls: 1000, pretty: false });
const urls: SitemapUrl[] = [
{
loc: "https://example.com/gallery/photo-1",
images: [
{ loc: "https://cdn.example.com/photo-1.jpg", caption: "Sunset view", title: "Photo 1" },
],
},
{
loc: "https://example.com/videos/intro",
videos: [
{
thumbnailLoc: "https://cdn.example.com/intro-thumb.jpg",
title: "Intro video",
description: "A short introduction to our product",
duration: 120,
rating: 4.8,
familyFriendly: true,
tags: ["intro", "product"],
},
],
},
{
loc: "https://example.com/en/home",
alternates: [
{ hreflang: "fr", href: "https://example.com/fr/home" },
{ hreflang: "de", href: "https://example.com/de/home" },
],
},
];
builder.addMany(urls);
console.log(`Built ${builder.count} URLs`); // "Built 3 URLs"
const xml = builder.build();
builder.clear();
```
## API Reference
### `SitemapBuilderOptions`
| Field | Type | Default | Description |
|-----------|----------------------------|-----------|-----------------------------------------|
| `maxUrls` | `number` | `50000` | Maximum URL count; throws `SIZE_EXCEEDED` when exceeded |
| `xmlns` | `Record<string, string>` | `{}` | Extra namespace prefix → URI pairs |
| `pretty` | `boolean` | `false` | Indent XML output |
### `class SitemapBuilder`
#### `constructor(options?: SitemapBuilderOptions)`
Throws `SitemapError(INVALID_OPTIONS)` when `maxUrls < 1`.
#### `add(url: SitemapUrl): this`
Appends one URL entry. Throws `SitemapError(SIZE_EXCEEDED)` when at capacity.
Throws `SitemapError(INVALID_URL)` during `build()` if the URL is not a valid
absolute `http/https` URL.
#### `addMany(urls: SitemapUrl[]): this`
Calls `add()` for each entry in order.
#### `build(): string`
Returns the complete XML document string. Validates each URL's `loc` field;
throws `SitemapError(INVALID_URL)` on the first invalid entry. Emits OTel
metrics.
#### `clear(): this`
Empties the internal URL list. Options are preserved.
#### `get count: number`
Returns the current number of accumulated URLs.
#### `toJSON(): SitemapUrl[]`
Returns a shallow copy of the internal URL array.
### `createSitemap(options?: SitemapBuilderOptions): SitemapBuilder`
Convenience factory; equivalent to `new SitemapBuilder(options)`.
## Implementation Notes
Namespace declarations for image, video, and xhtml are unconditionally included
in the `<urlset>` element. This matches the practice of major sitemap generators
and avoids a two-pass scan of URL content to discover which namespaces are
needed. The overhead is a few bytes and eliminates the complexity of conditional
namespace injection.
XML serialisation is done via string concatenation rather than a DOM or
`XMLBuilder`, which keeps the dependency footprint minimal and the output
deterministic. All user-supplied string values are run through `escapeXml`
before inclusion in the output.

View File

@@ -0,0 +1,117 @@
# index-builder
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Provides the `SitemapIndexBuilder` class and `createSitemapIndex` factory for
producing `<sitemapindex>` XML documents per the sitemaps.org protocol. A
sitemap index is used when a site's content spans multiple individual sitemap
files (for example, one per content type or locale). This module handles
serialisation only; fetching or splitting URLs across child sitemaps is the
responsibility of the caller.
## Features
- Fluent `add(loc, lastmod?)` API returning `this` for chaining
- URL validation on each `add()` call — throws `INVALID_URL` for non-http(s) entries
- `count` getter reflecting the number of registered entries
- Optional pretty-print mode with two-space indentation
- OTel counter `sitemap.build.total{type="sitemapindex"}` and histogram `sitemap.build.duration_ms`
## Usage: Basic Example
```ts
import { createSitemapIndex } from "@suaveplan/sitemap/index-builder";
const xml = createSitemapIndex({ pretty: true })
.add("https://example.com/sitemap-blog.xml", "2024-06-01")
.add("https://example.com/sitemap-products.xml")
.build();
console.log(xml);
// <?xml version="1.0" encoding="UTF-8"?>
// <sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
//
// <sitemap>
// <loc>https://example.com/sitemap-blog.xml</loc>
// <lastmod>2024-06-01</lastmod>
// </sitemap>
//
// <sitemap>
// <loc>https://example.com/sitemap-products.xml</loc>
// </sitemap>
//
// </sitemapindex>
```
## Usage: Advanced Example
```ts
import { SitemapIndexBuilder } from "@suaveplan/sitemap/index-builder";
// Build an index from a database query result
interface SitemapRecord { url: string; updatedAt: Date }
function buildIndexFromRecords(records: SitemapRecord[]): string {
const builder = new SitemapIndexBuilder({ pretty: false });
for (const record of records) {
const isoDate = record.updatedAt.toISOString().split("T")[0];
builder.add(record.url, isoDate);
}
console.log(`Index contains ${builder.count} sitemaps`);
return builder.build();
}
const xml = buildIndexFromRecords([
{ url: "https://example.com/sitemap-en.xml", updatedAt: new Date("2024-01-10") },
{ url: "https://example.com/sitemap-fr.xml", updatedAt: new Date("2024-01-12") },
]);
```
## API Reference
### `SitemapIndexBuilderOptions`
| Field | Type | Default | Description |
|----------|-----------|---------|--------------------------|
| `pretty` | `boolean` | `false` | Indent XML output |
### `class SitemapIndexBuilder`
#### `constructor(options?: SitemapIndexBuilderOptions)`
Creates a new builder. All options are optional.
#### `add(loc: string, lastmod?: string): this`
Registers one sitemap entry. Throws `SitemapError(INVALID_URL)` when `loc` is
not a valid absolute `http/https` URL.
| Param | Type | Description |
|-----------|--------------------|-----------------------------------------|
| `loc` | `string` | Absolute URL of the child sitemap file |
| `lastmod` | `string` (optional) | ISO 8601 modification date |
#### `get count: number`
Returns the number of entries registered so far.
#### `build(): string`
Serialises all entries to a `<sitemapindex>` XML document. Emits OTel metrics.
### `createSitemapIndex(options?: SitemapIndexBuilderOptions): SitemapIndexBuilder`
Convenience factory equivalent to `new SitemapIndexBuilder(options)`.
## Implementation Notes
URL validation is performed eagerly in `add()` rather than lazily in `build()`
so errors surface at the point of insertion rather than at serialisation time.
This makes debugging large programmatically-generated indexes much easier. The
trade-off is a slightly higher per-call cost, which is negligible in practice
since sitemap indexes rarely exceed a few thousand entries.

View File

@@ -0,0 +1,127 @@
# sitemap-parser
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Implements `parseSitemap` and `parseSitemapIndex` — the two functions that
turn raw XML strings into typed `SitemapUrl[]` and `SitemapIndex` values.
The parser uses `fast-xml-parser` under the hood to handle the sitemaps.org
base format as well as Google's image and video extensions and hreflang
alternate links. It is intentionally read-only; no writing, validation, or
normalisation of the output values is performed — callers can pipe the output
through Zod schemas if they need strict validation.
## Features
- Parses standard `<urlset>` sitemaps (`loc`, `lastmod`, `changefreq`, `priority`)
- Image extension: `image:image` entries mapped to `SitemapImage` objects
- Video extension: full `video:video` entries including tags, ratings, and flags
- hreflang alternate links via `<xhtml:link rel="alternate" …/>`
- Parses `<sitemapindex>` documents into `SitemapIndex` objects
- Structured `SitemapError(PARSE_ERROR)` on malformed XML or wrong root element
- Graceful handling of missing optional fields (returns `undefined` rather than throwing)
## Usage: Basic Example
```ts
import { parseSitemap } from "@suaveplan/sitemap/parser";
const xml = `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url>
<loc>https://example.com/</loc>
<lastmod>2024-01-01</lastmod>
<changefreq>weekly</changefreq>
<priority>1.0</priority>
</url>
</urlset>`;
const urls = parseSitemap(xml);
console.log(urls[0].loc); // "https://example.com/"
console.log(urls[0].changefreq); // "weekly"
console.log(urls[0].priority); // 1
```
## Usage: Advanced Example
```ts
import { parseSitemap, parseSitemapIndex } from "@suaveplan/sitemap/parser";
// Parsing a rich sitemap with image and video extensions
const richXml = `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"
xmlns:image="http://www.google.com/schemas/sitemap-image/1.1"
xmlns:video="http://www.google.com/schemas/sitemap-video/1.1">
<url>
<loc>https://example.com/gallery</loc>
<image:image>
<image:loc>https://cdn.example.com/photo.jpg</image:loc>
<image:caption>Sunset view</image:caption>
</image:image>
<video:video>
<video:thumbnail_loc>https://cdn.example.com/thumb.jpg</video:thumbnail_loc>
<video:title>Product tour</video:title>
<video:description>A tour of our features</video:description>
<video:duration>180</video:duration>
<video:family_friendly>yes</video:family_friendly>
<video:tag>features</video:tag>
<video:tag>tour</video:tag>
</video:video>
</url>
</urlset>`;
const urls = parseSitemap(richXml);
console.log(urls[0].images?.[0].caption); // "Sunset view"
console.log(urls[0].videos?.[0].duration); // 180
console.log(urls[0].videos?.[0].tags); // ["features", "tour"]
// Parsing a sitemap index
const indexXml = `<?xml version="1.0"?>
<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<sitemap><loc>https://example.com/sitemap-1.xml</loc></sitemap>
</sitemapindex>`;
const index = parseSitemapIndex(indexXml);
console.log(index.sitemaps[0].loc); // "https://example.com/sitemap-1.xml"
```
## API Reference
### `parseSitemap(xml: string): SitemapUrl[]`
Parses a `<urlset>` sitemap XML string.
| Param | Type | Description |
|-------|----------|-------------------------|
| `xml` | `string` | Raw sitemap XML content |
**Returns:** `SitemapUrl[]` — may be empty for a sitemap with no `<url>` elements.
**Throws:** `SitemapError(PARSE_ERROR)` when XML is malformed or missing the `<urlset>` root.
### `parseSitemapIndex(xml: string): SitemapIndex`
Parses a `<sitemapindex>` document.
| Param | Type | Description |
|-------|----------|------------------------------|
| `xml` | `string` | Raw sitemap index XML content |
**Returns:** `SitemapIndex` with a `sitemaps` array (may be empty).
**Throws:** `SitemapError(PARSE_ERROR)` when XML is malformed or missing the `<sitemapindex>` root.
## Implementation Notes
`fast-xml-parser` is configured with `isArray` callbacks for `url`, `sitemap`,
`image:image`, `video:video`, `xhtml:link`, and `video:tag` so that single-element
collections are still returned as arrays. Without this, a sitemap with exactly
one URL would return an object rather than a one-element array, causing the
consumer to crash.
Boolean fields in video sitemaps (`family_friendly`, `requires_subscription`,
`live`) use "yes"/"no" string encoding per the sitemaps.org spec. The
`getBoolean` helper handles both "yes/no" and "true/false" forms for tolerance
of non-conformant producers.

View File

@@ -0,0 +1,103 @@
# sitemap-error
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Provides the stable error-code registry and typed `SitemapError` class for the
`@suaveplan/sitemap` package. Because `@suaveplan/sitemap` is a leaf package
(no `@suaveplan/error` dependency), this module re-implements a minimal
error substrate locally — keeping the package's dependency footprint to
`fast-xml-parser`, `zod`, `tslib`, and `@opentelemetry/api`.
## Features
- `SITEMAP_CODES` — four stable codes: `INVALID_URL`, `PARSE_ERROR`,
`SIZE_EXCEEDED`, `INVALID_OPTIONS`
- `SitemapCode` — union type inferred from the code registry
- `SitemapError` — extends `Error`, exposes a narrowly-typed `code` property,
restores the prototype chain in transpiled output
## Usage: Basic Example
```ts
import { SitemapError, SITEMAP_CODES } from "@suaveplan/sitemap/errors";
try {
throw new SitemapError(
"Sitemap exceeds 50 000 URL limit",
SITEMAP_CODES.SIZE_EXCEEDED,
);
} catch (err) {
if (err instanceof SitemapError) {
console.log(err.code); // "SITEMAP.SIZE_EXCEEDED"
console.log(err.name); // "SitemapError"
}
}
```
## Usage: Advanced Example
```ts
import { SitemapError, SITEMAP_CODES, type SitemapCode } from "@suaveplan/sitemap/errors";
function assertSitemapError(err: unknown, code: SitemapCode): asserts err is SitemapError {
if (!(err instanceof SitemapError) || err.code !== code) {
throw new TypeError(`Expected SitemapError with code ${code}`);
}
}
function processUrl(url: string): void {
if (!url.startsWith("http://") && !url.startsWith("https://")) {
throw new SitemapError(
`URL must use http(s) scheme: ${url}`,
SITEMAP_CODES.INVALID_URL,
);
}
}
try {
processUrl("ftp://example.com/");
} catch (err) {
assertSitemapError(err, SITEMAP_CODES.INVALID_URL);
console.error(err.message); // "URL must use http(s) scheme: ftp://example.com/"
}
```
## API Reference
### `SITEMAP_CODES`
`Record<string, string>` — const object mapping logical names to stable code strings.
| Key | Value |
|-------------------|--------------------------|
| `INVALID_URL` | `"SITEMAP.INVALID_URL"` |
| `PARSE_ERROR` | `"SITEMAP.PARSE_ERROR"` |
| `SIZE_EXCEEDED` | `"SITEMAP.SIZE_EXCEEDED"`|
| `INVALID_OPTIONS` | `"SITEMAP.INVALID_OPTIONS"` |
### `SitemapCode`
`type SitemapCode = "SITEMAP.INVALID_URL" | "SITEMAP.PARSE_ERROR" | "SITEMAP.SIZE_EXCEEDED" | "SITEMAP.INVALID_OPTIONS"`
### `SitemapError`
`class SitemapError extends Error`
**Constructor:** `new SitemapError(message: string, code: SitemapCode)`
| Param | Type | Description |
|-----------|---------------|-------------------------|
| `message` | `string` | Human-readable message |
| `code` | `SitemapCode` | Stable machine-readable code |
**Properties:**
- `code: SitemapCode` — the stable code passed at construction
- `name: string` — always `"SitemapError"`
## Implementation Notes
The prototype chain is explicitly restored with `Object.setPrototypeOf(this, new.target.prototype)` to ensure `instanceof` checks work correctly when the package is compiled to CommonJS or older ES targets. This is the standard pattern for extending built-in classes in TypeScript without `@suaveplan/error`'s base class.

View File

@@ -0,0 +1,113 @@
# sitemap.schema
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Defines Zod schemas for every wire-format type in `@suaveplan/sitemap`,
providing runtime validation, static type inference, and a single source of
truth for the shape of sitemap data structures. All schemas align with the
sitemaps.org protocol and Google's image/video extensions.
## Features
- `ChangeFrequencySchema` — validates the seven allowed change-frequency values
- `SitemapImageSchema` — image extension entry with optional caption, title, license, and geo-location
- `SitemapVideoSchema` — full video extension with duration, ratings, tags, and playback metadata
- `SitemapAlternateSchema` — hreflang alternate link pair
- `SitemapUrlSchema` — complete URL entry composing all extensions
- `SitemapIndexSchema` — sitemap index document with `loc` + optional `lastmod` entries
- Inferred TypeScript types exported for each schema
## Usage: Basic Example
```ts
import {
SitemapUrlSchema,
type SitemapUrl,
} from "@suaveplan/sitemap";
const raw = {
loc: "https://example.com/page",
changefreq: "weekly",
priority: 0.8,
};
const url: SitemapUrl = SitemapUrlSchema.parse(raw);
console.log(url.loc); // "https://example.com/page"
```
## Usage: Advanced Example
```ts
import {
SitemapUrlSchema,
SitemapImageSchema,
SitemapVideoSchema,
} from "@suaveplan/sitemap";
const richUrl = SitemapUrlSchema.parse({
loc: "https://example.com/media",
lastmod: "2024-06-01",
changefreq: "daily",
priority: 0.9,
images: [
SitemapImageSchema.parse({
loc: "https://cdn.example.com/hero.jpg",
caption: "Hero image",
title: "Main hero",
}),
],
videos: [
SitemapVideoSchema.parse({
thumbnailLoc: "https://cdn.example.com/thumb.jpg",
title: "Product demo",
description: "A walkthrough of key features",
duration: 180,
rating: 4.5,
familyFriendly: true,
tags: ["demo", "features"],
}),
],
});
```
## API Reference
### `ChangeFrequencySchema`
`z.ZodEnum` — one of `"always" | "hourly" | "daily" | "weekly" | "monthly" | "yearly" | "never"`.
### `SitemapImageSchema`
Object schema with fields: `loc` (required), `caption`, `title`, `license`, `geoLocation` (all optional strings).
### `SitemapVideoSchema`
Object schema with required fields `thumbnailLoc`, `title`, `description`; optional fields include `contentLoc`, `playerLoc`, `duration` (integer ≥ 0), `publicationDate`, `expirationDate`, `rating` (0–5), `viewCount`, `familyFriendly`, `requiresSubscription`, `live`, `tags`.
### `SitemapAlternateSchema`
Object schema with required `hreflang` and `href` string fields.
### `SitemapUrlSchema`
Object schema with required `loc`; optional `lastmod`, `changefreq`, `priority` (0–1), `images`, `videos`, `alternates`.
### `SitemapIndexSchema`
Object schema with `sitemaps` array, each entry having a required `loc` and optional `lastmod`.
### Inferred Types
- `ChangeFrequency` — union of the seven frequency strings
- `SitemapImage` — inferred from `SitemapImageSchema`
- `SitemapVideo` — inferred from `SitemapVideoSchema`
- `SitemapAlternate` — inferred from `SitemapAlternateSchema`
- `SitemapUrl` — inferred from `SitemapUrlSchema`
- `SitemapIndex` — inferred from `SitemapIndexSchema`
## Implementation Notes
All schemas use `.min(1)` on required string fields to prevent empty-string entries from passing validation silently. Numeric ranges (`priority: 0–1`, `rating: 0–5`, `duration ≥ 0`, `viewCount ≥ 0`) are enforced at the schema level rather than in business logic, keeping validation rules co-located with type definitions. The schemas intentionally do not validate URL format — that responsibility lies with `url-utils.ts` / `SitemapBuilder`.

View File

@@ -0,0 +1,97 @@
# url-utils
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Provides the four foundational URL and XML utility functions that the sitemap
builder and parser modules rely on. Keeping these helpers in a dedicated module
allows them to be independently tested and reused across all sitemap generation
paths without coupling them to any particular class or algorithm.
## Features
- `isValidUrl` — validates that a string is an absolute `http` or `https` URL using the platform `URL` constructor
- `normalizeUrl` — lowercases the scheme and host of any absolute URL for canonical sitemap output
- `escapeXml` — escapes the five XML-special characters (`&`, `<`, `>`, `"`, `'`)
- `formatPriority` — clamps a floating-point value to `[0.0, 1.0]` and renders it as a two-decimal-place string
## Usage: Basic Example
```ts
import {
isValidUrl,
escapeXml,
formatPriority,
} from "@suaveplan/sitemap";
console.log(isValidUrl("https://example.com/")); // true
console.log(isValidUrl("ftp://example.com/")); // false
console.log(escapeXml("a & b < c")); // "a &amp; b &lt; c"
console.log(formatPriority(0.75)); // "0.75"
```
## Usage: Advanced Example
```ts
import { isValidUrl, normalizeUrl, escapeXml, formatPriority } from "@suaveplan/sitemap";
// Normalise user-supplied URLs before XML serialisation
const userUrl = "HTTPS://Example.COM/About";
if (isValidUrl(userUrl)) {
const canonical = normalizeUrl(userUrl);
// "https://example.com/About"
const xml = `<loc>${escapeXml(canonical)}</loc>`;
// "<loc>https://example.com/About</loc>"
}
// Clamp out-of-range priorities gracefully
const userPriority = 1.5;
const safeAttr = `<priority>${formatPriority(userPriority)}</priority>`;
// "<priority>1.00</priority>"
```
## API Reference
### `isValidUrl(url: string): boolean`
Returns `true` when `url` parses as a valid absolute URL with `http:` or `https:` protocol. Returns `false` for any other protocol, relative URLs, or malformed strings.
**Throws:** never — all errors are caught internally.
### `normalizeUrl(url: string): string`
Lowercases the protocol and hostname of the parsed URL and returns the
reconstituted string. If the URL cannot be parsed, the original string is
returned unchanged.
**Throws:** never.
### `escapeXml(str: string): string`
Replaces `&` → `&amp;`, `<` → `&lt;`, `>` → `&gt;`, `"` → `&quot;`,
`'` → `&apos;`. Suitable for both element content and attribute values.
**Throws:** never.
### `formatPriority(priority: number): string`
Clamps `priority` to the closed interval `[0.0, 1.0]` using `Math.max` /
`Math.min`, then calls `.toFixed(2)` to produce a two-decimal-place string
(e.g. `"0.50"`).
**Throws:** never.
## Implementation Notes
`isValidUrl` and `normalizeUrl` deliberately delegate to the platform `URL`
constructor rather than attempting regex parsing. This gives accurate results
for IDNs, percent-encoded paths, and IPv6 host literals without maintaining a
custom parser. The `try/catch` wrappers ensure the helpers never throw
regardless of input.
`escapeXml` uses a sequence of `.replace()` calls rather than a single regex
with a callback to keep the implementation readable and free of closure
overhead for the common case.