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

51
docs/devtools/README.md Normal file
View File

@@ -0,0 +1,51 @@
# `devtools`
Developer tooling, documentation generators, Docker helpers, and build utilities.
<!-- begin:auto-docs -->
## Subcategories
3 subcategories, 21 packages.
### `docker`
Docker helpers and GPU runtime configuration.
| Package | Description |
|---|---|
| [`@suaveplan/docker-nvidia`](./docker-nvidia/README.md) | A collection of docker / docker-compose scripts configured for use with WSL / Nvidia |
### `docs`
Documentation utilities, SVG compression, PDF handling, and sitemap generation.
| 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… |
### `tooling`
CLI frameworks, installers, and build tooling.
| Package | Description |
|---|---|
| [`@suaveplan/cli`](./cli/README.md) | Enterprise-grade CLI framework for Node.js. Composable fluent API with subcommand routing, option parsing, OTel tracing, and a full set of terminal UI primitiv… |
| [`@suaveplan/electrobun`](./electrobun/README.md) | Electrobun desktop-app generator and orchestrator for the SuavePlan ecosystem. Scaffolds a fresh Electrobun project from a layered config + prompt flow, drives… |
| [`@suaveplan/install-agent-skills`](./install-agent-skills/README.md) | Installer plugin that bundles every Suaveplan skill (`SKILL.md` + assets) and installs them into a target project via `@suaveplan/installer`. |
| [`@suaveplan/installer`](./installer/README.md) | Plugin host for project boilerplating and asset installation. Pipelines plugins through `detect → prompts → plan → apply → postInstall` with transactional roll… |
| [`@suaveplan/javy-cli-darwin-arm64`](./javy-cli-darwin-arm64/README.md) | Platform wrapper for **Javy v8.1.1** on `darwin-arm64`. |
| [`@suaveplan/javy-cli-darwin-x64`](./javy-cli-darwin-x64/README.md) | Platform wrapper for **Javy v8.1.1** on `darwin-x64`. |
| [`@suaveplan/javy-cli-linux-arm64`](./javy-cli-linux-arm64/README.md) | Platform wrapper for **Javy v8.1.1** on `linux-arm64`. |
| [`@suaveplan/javy-cli-linux-x64`](./javy-cli-linux-x64/README.md) | Platform wrapper for **Javy v8.1.1** on `linux-x64`. |
| [`@suaveplan/javy-cli-win32-x64`](./javy-cli-win32-x64/README.md) | Platform wrapper for **Javy v8.1.1** on `win32-x64`. |
| [`@suaveplan/linter`](./linter/README.md) | Unified markdown + biome linting pipeline for Suaveplan: a remark-based markdown fixer covering MD012/022/025/029/031/032/035/036/040/044, an injectable `Comma… |
| [`@suaveplan/mcp`](./mcp/README.md) | Universal Model Context Protocol (MCP) server + client with a pluggable transport stack, JSON-RPC dispatcher, AbortSignal cancellation, progress notifications,… |
| [`@suaveplan/overview`](./overview/README.md) | Machine-readable Suaveplan project manifest: typed PackageRecord / SuavePlanManifest / WorkspaceGraph, Zod schemas, tier inference, filesystem discovery (server sub-path), graph utilities (topo, cycle detection, transitive closure, leaves, roots), plugin/category/tier roll-ups, six OVERVIEW.* error codes, three OTel counters + one histogram. |
| [`@suaveplan/profiler`](./profiler/README.md) | Universal runtime performance profiler. |
| [`@suaveplan/readline`](./readline/README.md) | AsyncGenerator line reader and interactive prompt interface for Node.js streams, file paths, and Web Streams. |
| [`@suaveplan/sandbox`](./sandbox/README.md) | Server-tier sandboxed code execution for `@suaveplan` plugin hosts and agent runtimes. Ships four `SandboxEngine` implementations (a `node:vm` default, real `n… |
| [`@suaveplan/test-vite`](./test-vite/README.md) | Vite + Vitest configuration factories and validators for Suaveplan: `createViteLibraryConfig`, `createViteAppConfig`, `createVitestConfig`, `createVitestBrowse… |
<!-- end:auto-docs -->

3
docs/devtools/_intro.md Normal file
View File

@@ -0,0 +1,3 @@
# `devtools`
Developer tooling, documentation generators, Docker helpers, and build utilities.

View File

@@ -0,0 +1,13 @@
# `docker`
Docker helpers and GPU runtime configuration.
<!-- begin:auto-docs -->
## Packages in `docker`
1 package.
| Package | Description |
|---|---|
| [`@suaveplan/docker-nvidia`](./docker-nvidia/README.md) | A collection of docker / docker-compose scripts configured for use with WSL / Nvidia |
<!-- end:auto-docs -->

View File

@@ -0,0 +1 @@
Docker helpers and GPU runtime configuration.

View File

@@ -0,0 +1,5 @@
# @suaveplan/docker-wsl - Docker Scripts for WSL2
## Summary
A collection of docker / docker-compose scripts configured for use with WSL / Nvidia

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.

View File

@@ -0,0 +1,28 @@
# `tooling`
CLI frameworks, installers, and build tooling.
<!-- begin:auto-docs -->
## Packages in `tooling`
16 packages.
| Package | Description |
|---|---|
| [`@suaveplan/cli`](./cli/README.md) | Enterprise-grade CLI framework for Node.js. Composable fluent API with subcommand routing, option parsing, OTel tracing, and a full set of terminal UI primitiv… |
| [`@suaveplan/electrobun`](./electrobun/README.md) | Electrobun desktop-app generator and orchestrator for the SuavePlan ecosystem. Scaffolds a fresh Electrobun project from a layered config + prompt flow, drives… |
| [`@suaveplan/install-agent-skills`](./install-agent-skills/README.md) | Installer plugin that bundles every Suaveplan skill (`SKILL.md` + assets) and installs them into a target project via `@suaveplan/installer`. |
| [`@suaveplan/installer`](./installer/README.md) | Plugin host for project boilerplating and asset installation. Pipelines plugins through `detect → prompts → plan → apply → postInstall` with transactional roll… |
| [`@suaveplan/javy-cli-darwin-arm64`](./javy-cli-darwin-arm64/README.md) | Platform wrapper for **Javy v8.1.1** on `darwin-arm64`. |
| [`@suaveplan/javy-cli-darwin-x64`](./javy-cli-darwin-x64/README.md) | Platform wrapper for **Javy v8.1.1** on `darwin-x64`. |
| [`@suaveplan/javy-cli-linux-arm64`](./javy-cli-linux-arm64/README.md) | Platform wrapper for **Javy v8.1.1** on `linux-arm64`. |
| [`@suaveplan/javy-cli-linux-x64`](./javy-cli-linux-x64/README.md) | Platform wrapper for **Javy v8.1.1** on `linux-x64`. |
| [`@suaveplan/javy-cli-win32-x64`](./javy-cli-win32-x64/README.md) | Platform wrapper for **Javy v8.1.1** on `win32-x64`. |
| [`@suaveplan/linter`](./linter/README.md) | Unified markdown + biome linting pipeline for Suaveplan: a remark-based markdown fixer covering MD012/022/025/029/031/032/035/036/040/044, an injectable `Comma… |
| [`@suaveplan/mcp`](./mcp/README.md) | Universal Model Context Protocol (MCP) server + client with a pluggable transport stack, JSON-RPC dispatcher, AbortSignal cancellation, progress notifications,… |
| [`@suaveplan/overview`](./overview/README.md) | Machine-readable Suaveplan project manifest: typed PackageRecord / SuavePlanManifest / WorkspaceGraph, Zod schemas, tier inference, filesystem discovery (server sub-path), graph utilities (topo, cycle detection, transitive closure, leaves, roots), plugin/category/tier roll-ups, six OVERVIEW.* error codes, three OTel counters + one histogram. |
| [`@suaveplan/profiler`](./profiler/README.md) | Universal runtime performance profiler. |
| [`@suaveplan/readline`](./readline/README.md) | AsyncGenerator line reader and interactive prompt interface for Node.js streams, file paths, and Web Streams. |
| [`@suaveplan/sandbox`](./sandbox/README.md) | Server-tier sandboxed code execution for `@suaveplan` plugin hosts and agent runtimes. Ships four `SandboxEngine` implementations (a `node:vm` default, real `n… |
| [`@suaveplan/test-vite`](./test-vite/README.md) | Vite + Vitest configuration factories and validators for Suaveplan: `createViteLibraryConfig`, `createViteAppConfig`, `createVitestConfig`, `createVitestBrowse… |
<!-- end:auto-docs -->

View File

@@ -0,0 +1 @@
CLI frameworks, installers, and build tooling.

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,120 @@
# app
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`App` is the top-level entry point for a `@suaveplan/cli` application. It wraps a root `Command` and adds application-level concerns: version printing, help generation, `onError` handling, OTel span instrumentation on `run()`, and `AbortSignal` support. The `cli()` factory function creates an `App` with a name and optional version string in a single expression.
`App` delegates all routing and parsing to the underlying `Command` hierarchy. Its primary responsibility is to intercept `--version`, `--help`, and abort signals before dispatching, and to wrap any thrown error with `CliError(CLI_CODES.APP.RUN_FAILED)` when an `onError` handler is registered.
## Features
- Fluent builder: `.description()`, `.option()`, `.argument()`, `.action()`, `.command()`, `.example()`
- `run(argv?, options?)` dispatches to the root command; defaults `argv` to `process.argv.slice(2)`
- `--version` / `-V` prints the version string (if set) and exits cleanly
- `--help` / `-h` prints `helpText()` and exits cleanly
- `options.signal` (AbortSignal) — throws `CliError(CLI_CODES.APP.ABORTED)` immediately when already aborted
- `onError` handler — catches thrown errors, wraps non-`CliError` in `CliError(CLI_CODES.APP.RUN_FAILED)`, and calls the handler instead of re-throwing
- OTel span on every `run()` call: span name `cli.app.run`, attribute `cli.app` set to the app name; `SpanStatusCode.ERROR` set on failure
- `cli(name, version?)` factory for single-expression app creation
## Usage: Basic Example
```ts
import { cli } from "@suaveplan/cli/app/app";
const app = cli("deploy", "1.0.0")
.description("Deployment toolkit");
app.command("up")
.description("Bring services up")
.action(async () => {
console.log("Services are up");
});
await app.run();
```
## Usage: Advanced Example
```ts
import { App } from "@suaveplan/cli/app/app";
const app = new App({
name: "myapp",
version: "2.1.0",
description: "My enterprise CLI",
writer: (text) => process.stdout.write(text + "\n"),
onError: (err) => {
console.error(`Error [${err.code}]: ${err.message}`);
process.exit(1);
},
});
app
.option("--verbose", "Enable verbose output")
.action(async (ctx) => {
if (ctx.options.verbose) console.log("Verbose mode on");
});
const controller = new AbortController();
process.on("SIGINT", () => controller.abort());
await app.run(process.argv.slice(2), { signal: controller.signal });
```
## API Reference
### `new App(config: AppConfig)`
Creates a new app. Config fields: `name` (required), `version`, `description`, `enableHelp` (default true), `enableVersion` (default true), `onError`, `writer`.
### `app.name: string`
The app's name as provided in config.
### `app.version: string | undefined`
The app's version string, or `undefined` if not set.
### `app.description(text: string): this`
Sets the app description shown in help text.
### `app.option(flags, description?, defaultValue?): this`
Registers a root-level option.
### `app.argument(spec, description?): this`
Registers a root-level positional argument.
### `app.action(fn: ActionFn): this`
Sets the root action handler.
### `app.command(name: string): Command`
Registers and returns a named sub-command.
### `app.example(text: string): this`
Adds an example to the help output.
### `app.helpText(): string`
Returns formatted multi-line help text for the root command.
### `app.run(argv?: string[], options?: { signal?: AbortSignal }): Promise<void>`
Executes the app. If `signal` is already aborted, throws `CliError(ABORTED)` immediately. Intercepts `--version` and `--help` before routing. Delegates to the root `Command.execute()`.
### `cli(name: string, version?: string): App`
Factory function. Creates an `App` with `name` and optional `version`.
## Implementation Notes
The `writer` config field injects the output sink, defaulting to `process.stdout.write`. This enables full testability without TTY or process coupling. The `onError` hook receives a `CliError` — if the thrown value was a plain `Error`, it is wrapped in `CliError(RUN_FAILED)` before being handed to the hook, so callers always receive a structured error with a stable code.

View File

@@ -0,0 +1,122 @@
# command
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`Command` is the core routing primitive for `@suaveplan/cli`. It models a single CLI command that can carry options, positional arguments, and an action handler, or act as a router that delegates to named sub-commands. Every `App` is backed by a root `Command`.
The class exposes a fluent builder API: chain `.description()`, `.option()`, `.argument()`, `.command()`, and `.action()` calls before calling `.execute()` at runtime. Sub-commands are registered via `.command(name)` which returns the child `Command`, enabling identical fluent chaining for nested hierarchies.
## Features
- Fluent builder: `.description()`, `.option()`, `.argument()`, `.action()`, `.command()`, `.alias()`, `.hidden()`, `.example()`
- Sub-command routing: first non-flag token in `argv` is matched against registered names and aliases
- Option parsing delegated to `parseArgs` — inherits full flag semantics (boolean, string, number, `--no-*`, short flags, `=` syntax, `--` rest)
- OTel span on every `execute()` call: span name `cli.command.execute`, attribute `cli.command` set to the full qualified name
- `CliError(CLI_CODES.COMMAND.NO_ACTION)` when no action is registered and no sub-command is found
- `CliError(CLI_CODES.COMMAND.UNKNOWN_SUBCOMMAND)` when an unknown first token is provided and sub-commands are registered
- Automatic `--help` / `-h` handling: prints `helpText()` and returns without error
- `helpText(parentName?)` generates a formatted usage block with options and sub-command listing
## Usage: Basic Example
```ts
import { Command } from "@suaveplan/cli/command/command";
const cmd = new Command("greet")
.description("Greet a user")
.option("--name <name>", "Name to greet", "World")
.action(async (ctx) => {
console.log(`Hello, ${ctx.options.name}!`);
});
await cmd.execute(["--name", "Alice"]);
// → Hello, Alice!
```
## Usage: Advanced Example
```ts
import { Command } from "@suaveplan/cli/command/command";
const root = new Command("deploy")
.description("Deployment toolkit");
root
.command("up")
.description("Bring services up")
.option("--env <env>", "Target environment", "staging")
.action(async (ctx) => {
console.log(`Deploying to ${ctx.options.env}`);
});
root
.command("down")
.alias("d")
.description("Tear services down")
.action(async () => {
console.log("Tearing down...");
});
// Route: "deploy up --env production"
await root.execute(["up", "--env", "production"]);
// Route by alias: "deploy d"
await root.execute(["d"]);
```
## API Reference
### `new Command(name: string)`
Creates a new command with the given name.
### `.description(text: string): this`
Sets the human-readable description shown in help text.
### `.option(flags: string, description?: string, defaultValue?: unknown): this`
Registers an option. `flags` follows `parseOptionFlags` syntax: `"--name <value>"`, `"-v, --verbose"`.
### `.argument(spec: string, description?: string): this`
Registers a positional argument. `spec` follows `parseArgumentSpec` syntax: `"<file>"`, `"[file]"`, `"<files...>"`.
### `.action(fn: ActionFn): this`
Sets the action handler. `fn` receives an `ActionContext` with `options`, `args` (positionals), `rest`, and `command` info.
### `.command(name: string): Command`
Registers a named sub-command and returns it for further chaining.
### `.alias(name: string): this`
Adds an alias for this command, recognised during routing.
### `.hidden(): this`
Marks the command as hidden — it is excluded from help text listings.
### `.example(text: string): this`
Adds an example line shown in help text.
### `.helpText(parentName?: string): string`
Returns formatted multi-line help text including usage, options, sub-commands, and examples.
### `.getParserConfig(): ParserConfig`
Returns the `ParserConfig` derived from registered options and arguments. Used internally by `execute()`.
### `.execute(argv: string[], parentName?: string): Promise<void>`
Parses `argv`, routes to sub-commands or calls the action. Wraps the execution in an OTel span. Throws `CliError` on routing or parse failures.
## Implementation Notes
Sub-command alias resolution scans all registered sub-commands for an `_aliases` entry matching the first token. An OTel span covers the full `execute()` lifecycle including sub-command delegation — the span status is set to `ERROR` if any exception propagates. The `--help` / `-h` shortcut is handled before sub-command routing and before the action call, so it always works even if no action is registered.

View File

@@ -0,0 +1,171 @@
# errors
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The `errors` module defines the complete error taxonomy for `@suaveplan/cli`. It
provides a stable, machine-readable code registry (`CLI_CODES`) and a strongly-typed
error class (`CliError`) so that every failure surface in the CLI framework is
discriminable at runtime without string-matching on messages. Callers can switch
exhaustively on `err.code`, log structured records with stable identifiers, and
forward typed errors across process boundaries without losing fidelity.
## Features
- **Three sub-namespaces** — `CLI.PARSE.*` covers argument/option parsing failures;
`CLI.COMMAND.*` covers command-dispatch failures; `CLI.APP.*` covers top-level
application lifecycle failures.
- **Nine stable error codes** — each code is a dot-delimited string constant produced
by `defineCodes()` from `@suaveplan/error`, guaranteeing uniqueness and structural
consistency across the codebase.
- **`CliCode` union type** — narrows the full set of valid codes for use in generic
`SuaveplanError<CliCode>` type parameters and exhaustive switch statements.
- **`CliError` class** — extends `SuaveplanError` with a public constructor, cause
chaining, and structured `context` attachment for diagnostic metadata.
- **Zero runtime overhead** — the `CLI_CODES` object is `as const`, so all string
values are inlined at the call site by the TypeScript compiler.
## Usage: Basic Example
```ts
import { CliError, CLI_CODES } from "@suaveplan/cli/errors/errors";
function assertKnownOption(flag: string, known: Set<string>): void {
if (!known.has(flag)) {
throw new CliError(
CLI_CODES.PARSE.UNKNOWN_OPTION,
"cli.parse.unknown_option"
);
}
}
```
## Usage: Advanced Example
```ts
import { CliError, CLI_CODES } from "@suaveplan/cli/errors/errors";
import { SuaveplanError } from "@suaveplan/error";
async function runApp(signal: AbortSignal): Promise<void> {
try {
await doWork(signal);
} catch (err) {
if (signal.aborted) {
throw new CliError(CLI_CODES.APP.ABORTED, "cli.app.aborted", {
cause: err instanceof Error ? err : undefined,
context: { reason: signal.reason },
});
}
throw new CliError(CLI_CODES.APP.RUN_FAILED, "cli.app.run_failed", {
cause: err instanceof Error ? err : undefined,
});
}
}
function handleError(err: unknown): void {
if (err instanceof CliError) {
// Narrow on the stable code — no message-string matching needed.
switch (err.code) {
case CLI_CODES.APP.ABORTED:
process.exitCode = 130; // standard SIGINT exit code
break;
case CLI_CODES.PARSE.UNKNOWN_OPTION:
console.error(`Unknown option: ${String(err.context?.["option"])}`);
process.exitCode = 1;
break;
default:
console.error(err.message);
process.exitCode = 1;
}
}
}
```
## API Reference
### `CLI_CODES`
```ts
const CLI_CODES: {
PARSE: {
UNKNOWN_OPTION: "CLI.PARSE.UNKNOWN_OPTION";
MISSING_VALUE: "CLI.PARSE.MISSING_VALUE";
INVALID_VALUE: "CLI.PARSE.INVALID_VALUE";
MISSING_ARGUMENT: "CLI.PARSE.MISSING_ARGUMENT";
MISSING_REQUIRED_OPTION: "CLI.PARSE.MISSING_REQUIRED_OPTION";
};
COMMAND: {
NO_ACTION: "CLI.COMMAND.NO_ACTION";
UNKNOWN_SUBCOMMAND: "CLI.COMMAND.UNKNOWN_SUBCOMMAND";
};
APP: {
RUN_FAILED: "CLI.APP.RUN_FAILED";
ABORTED: "CLI.APP.ABORTED";
};
}
```
Immutable registry of all error codes used by `@suaveplan/cli`. Each value is a
dot-delimited string in the form `<NAMESPACE>.<SUBCATEGORY>.<NAME>`.
### `CliCode`
```ts
type CliCode =
| "CLI.PARSE.UNKNOWN_OPTION"
| "CLI.PARSE.MISSING_VALUE"
| "CLI.PARSE.INVALID_VALUE"
| "CLI.PARSE.MISSING_ARGUMENT"
| "CLI.PARSE.MISSING_REQUIRED_OPTION"
| "CLI.COMMAND.NO_ACTION"
| "CLI.COMMAND.UNKNOWN_SUBCOMMAND"
| "CLI.APP.RUN_FAILED"
| "CLI.APP.ABORTED";
```
Union of all values in `CLI_CODES`. Use as the type parameter for
`SuaveplanError<CliCode>` or in exhaustive switch statements.
### `CliError`
```ts
class CliError extends SuaveplanError<CliCode> {
public constructor(
code: CliCode,
messageKey: string,
options?: SuaveplanErrorOptions
): CliError;
}
```
Typed error for all `@suaveplan/cli` failures.
- **`code`** — one of the `CliCode` values; accessible as `err.code` via the base
class.
- **`messageKey`** — i18n message key passed to the base class for message resolver
integration.
- **`options.cause`** — optional upstream `Error` for cause chaining; accessible as
`err.cause`.
- **`options.context`** — optional `Record<string, unknown>` with structured
diagnostic metadata; accessible as `err.context`.
Throws: nothing — this is an Error constructor.
## Implementation Notes
`CLI_CODES` is built from three separate `defineCodes()` calls rather than one flat
call so that each sub-namespace is isolated. This mirrors the pattern established in
`@suaveplan/readline` and keeps future code-set additions within a namespace
non-breaking for callers who already destructure `CLI_CODES.PARSE`.
The `CliError` constructor comment (`biome-ignore`) documents why the constructor is
not "useless" from Biome's perspective: the base `SuaveplanError` constructor is
`protected`, so widening it to `public` here is a meaningful override even though
the body only calls `super()`. This is the standard pattern across all
`@suaveplan/*` packages.
No runtime dependencies beyond `@suaveplan/error` are introduced. The `defineCodes`
helper produces plain string literals, so the entire module tree-shakes to near
zero bytes when errors are not thrown on a given code path.

View File

@@ -0,0 +1,93 @@
# output/format
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`format` provides a collection of pure string-formatting utilities for structured CLI output. All functions take plain data and return formatted strings — no side effects, no I/O. Callers are responsible for printing the result. This design makes every function trivially testable and composable.
The module covers the most common CLI output patterns: key-value pairs, bulleted lists, directory-style trees, inline JSON, multi-column alignment, section headings, horizontal dividers, and indented blocks.
## Features
- `keyValue(obj, opts?)` — formats a record as aligned `key: value` pairs
- `list(items, opts?)` — formats an array as a bulleted list
- `tree(node, opts?)` — renders a recursive tree with `├─` / `└─` connectors
- `json(value, opts?)` — `JSON.stringify` wrapper with configurable indent
- `columns(rows, separator?)` — right-pads cells so columns align across rows
- `heading(text, level?)` — box-style heading with `═` (level 1) or `─` (level 2) borders
- `divider(char?, width?)` — repeated character line
- `indent(text, spaces?)` — prefixes every line with spaces
## Usage: Basic Example
```ts
import { keyValue, list, heading } from "@suaveplan/cli/output/format/format";
console.log(heading("Build Report"));
console.log(keyValue({ status: "success", files: 42, duration: "1.2s" }));
console.log(list(["Compiled main.ts", "Emitted dist/", "Linked types"]));
```
## Usage: Advanced Example
```ts
import { tree, columns, json, indent } from "@suaveplan/cli/output/format/format";
const structure = {
label: "src/",
children: [
{ label: "app/", children: [{ label: "app.ts" }, { label: "app.test.ts" }] },
{ label: "utils/", children: [{ label: "format.ts" }] },
],
};
console.log(tree(structure));
const rows = [
["alice", "admin", "2024-01-01"],
["bob", "user", "2024-03-15"],
];
console.log(columns([["Name", "Role", "Joined"], ...rows]));
console.log(indent(json({ ok: true, count: 7 }), 4));
```
## API Reference
### `keyValue(obj: Record<string, unknown>, opts?: KeyValueOptions): string`
Formats each entry as `key: value`. Options: `separator` (default `": "`), `indent` (spaces before each line, default 0).
### `list(items: string[], opts?: ListOptions): string`
Formats each item with a bullet marker. Options: `bullet` (default `"•"`), `indent` (default 0).
### `tree(node: TreeNode, opts?: TreeOptions, _prefix?: string): string`
Renders a nested tree with connector lines. Options: `indent` (spaces per nesting level, default 2).
### `json(value: unknown, opts?: JsonOptions): string`
Returns `JSON.stringify(value, null, indent)`. Options: `indent` (default 2).
### `columns(rows: string[][], separator?: string): string`
Pads each cell in each column to the column's maximum width, then joins with `separator` (default `" "`). Returns empty string for empty input.
### `heading(text: string, level?: number): string`
Returns a three-line block: border, centered text, border. Level 1 uses `═`, level 2 uses `─`, others use `-`.
### `divider(char?: string, width?: number): string`
Returns `char.repeat(width)`. Defaults: `"─"` and `40`.
### `indent(text: string, spaces?: number): string`
Prepends `spaces` (default 2) spaces to every line of `text`.
## Implementation Notes
All functions are stateless and have no dependencies. `tree` recurses depth-first, tracking a prefix string for connector characters at each level. `columns` computes widths in a single pass over all rows, then renders in a second pass. The `_prefix` parameter of `tree` is internal — external callers should omit it.

View File

@@ -0,0 +1,81 @@
# output/table
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`table` renders structured data as a formatted ASCII table with header row, optional borders, and configurable column alignment. It computes column widths from the full data set — header and all rows — ensuring that every column is at least as wide as its widest cell. The result is a plain multi-line string ready for console output or piping.
The function is pure: it takes a `TableOptions` object and returns a `string`. No state, no I/O, no mutations.
## Features
- Automatic column width calculation: each column is as wide as its widest cell across all rows
- Three alignment modes per column: `"left"` (default), `"right"`, `"center"`
- Border mode (default on): header and data rows separated by `─┼─` divider
- Border-off mode: columns joined with two spaces
- Handles empty rows gracefully — returns header-only output
- Property-tested: column widths always `>=` max cell content width
## Usage: Basic Example
```ts
import { table } from "@suaveplan/cli/output/table/table";
const out = table({
headers: ["Name", "Role", "Since"],
rows: [
["Alice", "Admin", "2022"],
["Bob", "User", "2023"],
],
});
console.log(out);
// Name │ Role │ Since
// ──────┼───────┼──────
// Alice │ Admin │ 2022
// Bob │ User │ 2023
```
## Usage: Advanced Example
```ts
import { table } from "@suaveplan/cli/output/table/table";
const out = table({
headers: ["Package", "Version", "Size"],
rows: [
["@suaveplan/error", "0.1.0", "12 kB"],
["@suaveplan/cli", "0.1.0", "48 kB"],
],
alignment: ["left", "center", "right"],
border: true,
});
console.log(out);
```
## API Reference
### `table(opts: TableOptions): string`
Renders the table and returns the formatted string.
**`opts.headers: string[]`** — column header labels. Determines the number of columns.
**`opts.rows: string[][]`** — data rows. Each row is an array of string cells. Extra cells beyond `headers.length` are ignored; missing cells are treated as empty.
**`opts.alignment?: Alignment[]`** — per-column alignment (`"left"` | `"right"` | `"center"`). Defaults to `"left"` for any column not specified.
**`opts.border?: boolean`** — whether to render `│` column separators and `─┼─` divider line. Default `true`.
### `Alignment`
`"left" | "right" | "center"` — the alignment mode for a table column.
### `TableOptions`
The full options type. Corresponds to `TableOptionsSchema` for Zod validation.
## Implementation Notes
Column widths are computed with a single `Math.max` pass over `[headers, ...rows]`. Cells are padded with `String.prototype.padEnd` (left/center) or `String.prototype.padStart` (right via the `align` helper). The divider line joins `"─".repeat(width)` segments with `"─┼─"`. When `border` is false, the divider is omitted and segments are joined with `" "` instead of `" │ "`. Trailing whitespace on each row is trimmed to keep output clean.

View File

@@ -0,0 +1,92 @@
# parser
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The `parser` module turns a raw `argv` token array into a structured `ParsedArgs` object with typed options, named positional arguments, and a `rest` passthrough channel. It is the low-level argument-parsing engine that higher-level command definitions build on, designed to model the behaviour CLI users expect from mature tools like `git`, `npm`, and `cargo`.
The module is deliberately imperative and framework-free: no reflection, no decorators, no hidden state. Every behaviour — type coercion, `--no-*` negation, short-flag resolution, choice validation, required-option checking — is enforced in a single linear pass over `argv`, and every failure mode raises a `CliError` with a stable code drawn from `CLI_CODES.PARSE.*` so callers can branch on machine-readable codes instead of message strings.
## Features
- Boolean flags (`--verbose`) and negation (`--no-verbose`)
- String/number/array options via `--key value`, `--key=value`, or short-flag (`-v`) forms
- Short-flag mapping via `short: "v"` on the option config
- Passthrough `rest` channel after `--`
- Variadic positionals (`<files...>`) collected as arrays
- Required positionals and required options with dedicated error codes
- Choice validation with `INVALID_VALUE`
- `allowUnknown: true` to silently ignore unrecognised flags
- Defaults for options and positionals
- Helper parsers for flag specs (`parseOptionFlags`) and positional specs (`parseArgumentSpec`)
## Usage: Basic Example
```ts
import { parseArgs } from "@suaveplan/cli/parser";
const result = parseArgs(["--verbose", "src/file.ts"], {
options: { verbose: { type: "boolean" } },
arguments: [{ name: "file", required: true, variadic: false }],
});
console.log(result.options.verbose); // true
console.log(result.positionals.file); // "src/file.ts"
```
## Usage: Advanced Example
```ts
import { parseArgs, parseOptionFlags, parseArgumentSpec } from "@suaveplan/cli/parser";
const flag = parseOptionFlags("-o, --output <file>", "Output file");
const posArg = parseArgumentSpec("<sources...>", "Source files");
const result = parseArgs(
["-o", "out.txt", "--format=json", "a.ts", "b.ts", "--", "--not-a-flag"],
{
options: {
[flag.name]: flag.config,
format: { type: "string", choices: ["json", "text"], default: "text" },
retries: { type: "number", default: 3 },
},
arguments: [posArg],
}
);
// result.options.output === "out.txt"
// result.options.format === "json"
// result.positionals.sources === ["a.ts", "b.ts"]
// result.rest === ["--not-a-flag"]
```
## API Reference
### `parseArgs(argv, config?)`
- `argv: string[]` — raw CLI tokens (without the node/bun prefix)
- `config?: ParserConfig` — optional options/arguments definitions, `allowUnknown`, `stopEarly`
- Returns `ParsedArgs` with `options`, `positionals`, and `rest`
- Throws `CliError` with codes `UNKNOWN_OPTION`, `MISSING_VALUE`, `INVALID_VALUE`, `MISSING_ARGUMENT`, `MISSING_REQUIRED_OPTION`
### `parseOptionFlags(flags, description?, defaultValue?)`
- `flags: string` — commander-style spec, e.g. `"-v, --verbose"` or `"--output <file>"`
- Returns `{ name, config }` where `name` is the camelCased long-flag name
- Throws `CliError(UNKNOWN_OPTION)` if no long flag is provided
### `parseArgumentSpec(spec, description?)`
- `spec: string` — `"<name>"`, `"[name]"`, `"<name...>"`, or `"[name...]"`
- Returns an `ArgumentConfig` with `required`, `variadic`, `name`, and optional `description`
## Implementation Notes
- Single-pass state machine: tokens are consumed left-to-right with a small `restMode` flag that flips when `--` is encountered. There are no lookaheads beyond peeking at `argv[i + 1]` to decide whether a flag's value is the next token.
- `--no-*` handling strips the literal `--no-` prefix (not the camelCased form) and then camelCases the remainder, so `--no-dry-run` correctly maps to the `dryRun` option.
- Values are only consumed as the next token when that token does not start with `-`, preventing `--foo --bar` from stealing `--bar` as the value of `--foo`.
- `number` type uses `Number(raw)` and rejects `NaN` so strings like `"abc"` raise `INVALID_VALUE` rather than silently becoming `NaN`.
- All errors are `CliError` instances carrying `context` metadata (option name, offending value, allowed choices) so UIs and logs can render actionable diagnostics without re-parsing message strings.
- Schemas mirror the TypeScript types exactly and are exported separately so callers can validate untrusted `ParserConfig` inputs at the boundary without pulling Zod into hot paths.

View File

@@ -0,0 +1,92 @@
# progress
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`createProgress` renders a fixed-width ASCII progress bar for tracking long-running operations with a known total. It writes bar updates to any `WritableStreamLike` output — TTY or non-TTY — using in-place line clearing on TTY terminals and newline-separated lines on non-TTY streams (CI, pipes). The bar updates are driven by explicit `update(current)` calls from the caller; no internal timer is used.
The instance is `AbortSignal`-aware: aborting the signal silently makes all subsequent `update()` calls no-ops, allowing calling code to cancel a long operation without crashing.
## Features
- Fixed-width block bar with configurable `width` (default 40 characters) using `█` fill and `░` empty characters
- Percentage and `current/total` count displayed after the bar: `[████████████░░░░░░░░] 60% (60/100)`
- TTY mode: clears the current line and re-renders in-place via `clearLine` / `cursorTo`
- Non-TTY mode: writes each update on its own line for readable CI output
- `render()` returns the bar string without writing to the stream — useful for embedding in larger output
- `complete()` forces 100% and appends a newline; idempotent (safe to call multiple times)
- `AbortSignal` support: abort event listener marks the instance done; pre-fired signal respected via guard check in `update()`
## Usage: Basic Example
```ts
import { createProgress } from "@suaveplan/cli/progress/progress";
const p = createProgress({ total: 100 });
for (let i = 0; i <= 100; i++) {
p.update(i);
await sleep(20);
}
p.complete();
```
## Usage: Advanced Example
```ts
import { createProgress } from "@suaveplan/cli/progress/progress";
import { Writable } from "node:stream";
const controller = new AbortController();
const p = createProgress({
total: files.length,
width: 60,
signal: controller.signal,
});
let processed = 0;
for (const file of files) {
if (controller.signal.aborted) break;
await processFile(file);
p.update(++processed);
}
p.complete();
```
## API Reference
### `createProgress(opts: ProgressOptions): ProgressInstance`
Creates a progress bar instance. No output is written until `update()` or `complete()` is called.
**`opts.total: number`** — the maximum value (100%). Must be positive.
**`opts.width?: number`** — width of the bar in characters (default `40`).
**`opts.stream?: WritableStreamLike`** — output target (default `process.stdout`). TTY in-place rendering requires `clearLine` and `cursorTo` methods plus `isTTY === true`.
**`opts.signal?: AbortSignal`** — when aborted, future `update()` calls become no-ops and the bar stops advancing.
### `ProgressInstance`
**`update(current: number): void`** — updates the displayed progress to `current`. Values are clamped to `[0, total]`. No-op if `complete()` was already called or the signal was aborted.
**`complete(): void`** — writes a 100% bar with a trailing newline and marks the instance done. Idempotent.
**`render(): string`** — returns the current bar string without writing to the stream.
### `ProgressOptionsSchema`
Zod schema for `ProgressOptions` — validates `total` (positive number) and `width` (optional positive number). Excludes `stream` and `signal`.
### `WritableStreamLike`
Re-exported from `../spinner/spinner.types.js`. Any object with `write(text: string): void` satisfies this interface; `clearLine`, `cursorTo`, and `isTTY` are optional and enable TTY mode when present.
## Implementation Notes
Progress percentage is computed as `Math.min(1, current / total)` to handle over-total updates gracefully. The filled block count is `Math.round(pct * width)` — rounding avoids a full-bar display at 99% on small widths. The `complete()` guard (`done` flag) prevents double-writes when called multiple times. The `AbortSignal` listener uses `{ once: true }` so it self-removes after the first event.

View File

@@ -0,0 +1,95 @@
# prompt
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`prompt` provides a collection of pure, injectable interactive prompt functions for CLI applications: `text`, `password`, `confirm`, `select`, and `multiselect`. All five functions accept an `InputReader` as their last argument for testing and embedding — callers supply any object with `question(prompt): Promise<string>` and `close(): void`. In production, `createDefaultReader()` builds an `InputReader` backed by `@suaveplan/readline/interface`, which in turn reads from `process.stdin`.
Prompt functions are asynchronous, loop-safe, and validation-aware. They re-prompt on invalid input rather than throwing, keeping the user experience coherent across all terminal types.
## Features
- `text(opts, reader?)` — free-form text input with optional default and validation callback
- `password(opts, reader?)` — masked input (structurally identical to text; masking is delegated to the terminal emulator or TTY mode)
- `confirm(opts, reader?)` — yes/no boolean with optional default; accepts `y`, `yes`, `n`, `no` (case-insensitive)
- `select(opts, reader?)` — numbered single-choice list; re-prompts on out-of-range input
- `multiselect(opts, reader?)` — comma-separated multi-choice with configurable `min`/`max` and deduplication
- `createDefaultReader()` — async factory returning an `InputReader` wired to `process.stdin` via `@suaveplan/readline`
- All functions accept both a plain `string` message or a full options object for brevity
## Usage: Basic Example
```ts
import { confirm, select, text } from "@suaveplan/cli/prompt/prompt";
const name = await text("Your name?");
const confirmed = await confirm({ message: "Continue?", default: true });
const role = await select({ message: "Pick a role:", choices: ["admin", "user", "guest"] });
console.log(name, confirmed, role);
```
## Usage: Advanced Example
```ts
import { multiselect, text } from "@suaveplan/cli/prompt/prompt";
import type { InputReader } from "@suaveplan/cli/prompt/prompt";
// Inject a reader for testing
function mockReader(lines: string[]): InputReader {
let i = 0;
return { question: async () => lines[i++] ?? "", close: () => {} };
}
const username = await text(
{ message: "Username?", validate: (v) => v.length >= 3 || "at least 3 chars" },
mockReader(["ab", "alice"]),
);
const features = await multiselect(
{ message: "Enable features:", choices: ["logging", "metrics", "tracing"], min: 1 },
mockReader(["1,2"]),
);
console.log(username, features);
```
## API Reference
### `text(opts: TextPromptOptions | string, reader?: InputReader): Promise<string>`
Prompts for free-form text. When `opts` is a string it is used as the `message`. Returns the user's input or `opts.default` if the input is empty and a default is set. Re-prompts if `opts.validate` returns an error string.
### `password(opts: PasswordPromptOptions | string, reader?: InputReader): Promise<string>`
Identical interface to `text` but semantically for secret input. The caller is responsible for disabling echo on the actual terminal if needed.
### `confirm(opts: ConfirmPromptOptions | string, reader?: InputReader): Promise<boolean>`
Prompts for a yes/no answer. Accepts `y`, `yes`, `n`, `no` (case-insensitive). Returns `opts.default` when the user submits an empty line and a default is configured. Re-prompts on unrecognized input.
### `select<T>(opts: SelectPromptOptions<T>, reader?: InputReader): Promise<T>`
Displays a numbered list of choices and returns the `value` of the selected entry. Choices may be plain strings (label = value) or `SelectChoice<T>` objects. Re-prompts when the entered number is out of range.
### `multiselect<T>(opts: MultiSelectPromptOptions<T>, reader?: InputReader): Promise<T[]>`
Displays a numbered list and accepts comma-separated indices. Deduplicates repeated indices. Enforces `opts.min` / `opts.max` bounds and re-prompts when any index is invalid or the count is out of bounds.
### `createDefaultReader(): Promise<InputReader>`
Async factory that lazily imports `@suaveplan/readline/interface` and wires it to `process.stdin` / `process.stdout`. Returns an `InputReader`. The caller is responsible for calling `close()` when done.
### `InputReader`
Injectable interface: `{ question(prompt: string): Promise<string>; close(): void; }`. Pass a mock for tests.
### `TextPromptOptionsSchema`, `ConfirmPromptOptionsSchema`, `SelectChoiceSchema`
Zod schemas for the corresponding option types — useful for config-file driven prompts.
## Implementation Notes
All prompt functions manage the reader lifecycle when no reader is injected: they call `createDefaultReader()` in a `try/finally` block that calls `close()` after the prompt resolves. When a reader is injected, the caller owns the lifecycle. Validation callbacks return either `true` (pass) or an error string (fail); the error is written to `process.stderr` before re-prompting. The `multiselect` function considers any out-of-range index a hard error and re-prompts rather than silently discarding it.

View File

@@ -0,0 +1,99 @@
# spinner
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`createSpinner` renders an animated terminal spinner for long-running operations. It writes a spinning frame animation to any `WritableStreamLike` output — TTY or non-TTY — and exposes a chainable API to start, stop, succeed, fail, or warn when the operation resolves. The spinner integrates with `AbortSignal` so it can be cancelled programmatically without leaking timers.
The spinner is entirely synchronous after creation. No promises are used internally — the animation is driven by a `setInterval` timer that is always cancelled on `stop()`.
## Features
- Animated frame loop with configurable frames array and interval (default 80ms, 10-frame braille spinner)
- Chainable API: `start().succeed()`, `start().fail()`, `start().warn()`, `start().stop()`
- TTY mode: clears current line and re-renders in-place via `clearLine`/`cursorTo`
- Non-TTY mode (CI/pipes): writes each frame on its own line — no escape codes
- `AbortSignal` support: live abort event listener (`"abort"`) stops the spinner; pre-fired abort prevents `start()` entirely
- `succeed` / `fail` / `warn` write a terminal symbol (`✔` / `✖` / `⚠`) followed by the text
- `update(text)` changes the displayed text without restarting the animation
## Usage: Basic Example
```ts
import { createSpinner } from "@suaveplan/cli/spinner/spinner";
const sp = createSpinner({ text: "Fetching data…" });
sp.start();
await fetchData();
sp.succeed("Done!");
```
## Usage: Advanced Example
```ts
import { createSpinner } from "@suaveplan/cli/spinner/spinner";
const controller = new AbortController();
const sp = createSpinner({
text: "Building…",
frames: ["-", "\\", "|", "/"],
interval: 120,
signal: controller.signal,
});
sp.start();
setTimeout(() => controller.abort(), 5000); // cancel after 5 s
try {
await runBuild();
sp.succeed("Build complete");
} catch {
sp.fail("Build failed");
}
```
## API Reference
### `createSpinner(opts?: SpinnerOptions): SpinnerInstance`
Creates and returns a spinner instance. No animation starts until `start()` is called.
**`opts.text?: string`** — initial text displayed next to the spinner frame (default `""`).
**`opts.frames?: string[]`** — animation frame sequence (default: 10-step braille pattern).
**`opts.interval?: number`** — milliseconds between frame advances (default `80`).
**`opts.stream?: WritableStreamLike`** — output target (default `process.stdout`). Any object with a `write()` method is accepted; TTY-specific methods (`clearLine`, `cursorTo`) are optional.
**`opts.signal?: AbortSignal`** — when aborted, calls `stop()` immediately. If already aborted at construction time, `start()` becomes a no-op.
### `SpinnerInstance`
**`isSpinning: boolean`** — `true` between `start()` and `stop()`.
**`start(text?: string): SpinnerInstance`** — begins animation. If a `text` argument is provided, updates the displayed text before starting. No-op if already spinning or if the signal is pre-aborted.
**`stop(): SpinnerInstance`** — stops animation and clears the line (TTY only). No-op if not spinning.
**`succeed(text?: string): SpinnerInstance`** — calls `stop()` then writes `✔ <text>` (falls back to current text).
**`fail(text?: string): SpinnerInstance`** — calls `stop()` then writes `✖ <text>`.
**`warn(text?: string): SpinnerInstance`** — calls `stop()` then writes `⚠ <text>`.
**`update(text: string): SpinnerInstance`** — replaces the displayed text without interrupting animation.
### `SpinnerOptionsSchema`
Zod schema for `SpinnerOptions` — validates `text`, `frames`, and `interval` (excludes `stream` and `signal` which are not Zod-serialisable).
### `WritableStreamLike`
Minimal interface for the `stream` option: any object with `write(text: string): void`. The optional `clearLine`, `cursorTo`, and `isTTY` members enable TTY in-place rendering when present.
## Implementation Notes
The TTY branch detects `stream.isTTY === true` at construction time and uses `clearLine(0)` + `cursorTo(0)` + `write(frame + " " + text)` on each tick. The non-TTY branch appends a newline to each frame write so CI logs remain readable. The `setInterval` is stored and cleared in `stop()` to prevent timer leaks; calling `stop()` when not spinning is a safe no-op. The `AbortSignal` listener uses `{ once: true }` so it self-removes after the first abort event.

View File

@@ -0,0 +1,82 @@
# style
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`style` provides terminal ANSI colour and text-decoration utilities for `@suaveplan/cli`. It wraps text in ANSI escape sequences for modifiers (bold, dim, italic, underline, strikethrough) and colours (foreground and background). The module is colour-support-aware: when the terminal does not support colour, all styling is bypassed and plain text is returned unchanged.
The design uses a `Proxy`-based fluent builder that allows infinite chaining of style codes — `style.bold.red.underline("text")` — without allocating intermediate strings until the final call.
## Features
- 24 style codes: 6 modifiers, 9 foreground colours, 1 gray, 8 background colours
- `createStyler(enabled?)` — factory for a fresh styler instance, injectable colour flag for testing
- `style` — singleton styler wired to `isColorSupported()` at module load time
- `stripAnsi(text)` — removes all ANSI escape sequences from a string
- `isColorSupported()` — returns `true` when stdout is a TTY or `FORCE_COLOR` is set
- `getStyleNames()` — returns the list of all valid style code names
- Proxy-based chaining: `style.bold.red.underline("x")` composes all codes in a single call
- Disabled mode: `createStyler(false)` returns identity function — no allocation overhead
- Zero runtime dependencies outside the package
## Usage: Basic Example
```ts
import { style } from "@suaveplan/cli/style/style";
console.log(style.bold("Build complete"));
console.log(style.red("Error occurred"));
console.log(style.green.bold("✓ Tests passed"));
```
## Usage: Advanced Example
```ts
import { createStyler, isColorSupported, stripAnsi } from "@suaveplan/cli/style/style";
// Inject colour support for testing
const s = createStyler(process.env.CI !== "true");
function label(text: string, type: "info" | "warn" | "error"): string {
if (type === "error") return s.bold.red(`[ERROR] ${text}`);
if (type === "warn") return s.yellow(`[WARN] ${text}`);
return s.cyan(`[INFO] ${text}`);
}
console.log(label("Connection timed out", "error"));
// Strip for log files
const plain = stripAnsi(label("hello", "info"));
```
## API Reference
### `createStyler(enabled?: boolean): Styler`
Creates a new styler instance. `enabled` defaults to `isColorSupported()`. Returns a `Styler` (Proxy) that composes ANSI codes on chained property access and applies them on call.
### `style: Styler`
Pre-built singleton styler bound to the current terminal's colour support. Use this for production output.
### `stripAnsi(text: string): string`
Strips all ANSI escape sequences from `text`. Safe for log files and length calculations.
### `isColorSupported(): boolean`
Returns `true` when `process.stdout.isTTY` is true or `FORCE_COLOR` env var is set.
### `getStyleNames(): StyleCode[]`
Returns the array of all known style code names in order.
### `Styler`
A callable type with all style codes as chainable properties. Calling it with a string applies all accumulated codes and returns the styled string.
## Implementation Notes
The `Proxy` trap intercepts property access on the styler function itself, returning a new `Proxy`-wrapped function with the accessed code appended to the accumulated stack. This avoids the class-per-style-combination explosion of naive implementations. Opening and closing ANSI codes are concatenated from the stack at call time. The `reset` code (`\x1b[0m`) is handled like any other code — callers can chain `.reset` to reset all styles.

View File

@@ -0,0 +1,151 @@
# @suaveplan/electrobun - Electrobun desktop-app generator + orchestrator: scaffolds projects, generates icons, drives dev/build/package/sign commands via an injectable runner. Typed ELECTROBUN_CODES, Zod-validated options, OTel self-telemetry.
Electrobun desktop-app generator and orchestrator for the SuavePlan
ecosystem. Scaffolds a fresh Electrobun project from a layered config +
prompt flow, drives the upstream `electrobun` binary for dev / build /
package / sign, and emits structured reports + OpenTelemetry metrics for
every operation.
<!-- begin:auto-docs -->
## Documentation tree
- [bin](./src/bin/suaveplan-electrobun.md) — Single-file binary that wires `process.argv` into the pure `runCli` dispatcher and translates the resolved exit code in…
- [suaveplan-electrobun](./src/bin/suaveplan-electrobun.md) — Single-file binary that wires `process.argv` into the pure `runCli` dispatcher and translates the resolved exit code in…
- [cli](./src/cli/cli.md) — Owns the five command surfaces — `createCommand`, `devCommand`, `buildCommand`, `packageCommand`, `signCommand` — plus…
- [cli](./src/cli/cli.md) — Owns the five command surfaces — `createCommand`, `devCommand`, `buildCommand`, `packageCommand`, `signCommand` — plus…
- [config](./src/config/config.md) — `loadConfig(cliArgs, deps?)` builds an `ElectrobunConfig` by layering hard-coded defaults, an optional on-disk `.suavep…
- [config](./src/config/config.md) — `loadConfig(cliArgs, deps?)` builds an `ElectrobunConfig` by layering hard-coded defaults, an optional on-disk `.suavep…
- [error-codes](./src/error-codes/error-codes.md) — Owns the stable `ELECTROBUN.*` error-code namespace and the `ElectrobunError` class that every public entry point throw…
- [error-codes](./src/error-codes/error-codes.md) — Owns the stable `ELECTROBUN.*` error-code namespace and the `ElectrobunError` class that every public entry point throw…
- [fs](./src/fs/fs.md) — Owns the `FsAdapter` seam and the synchronous `ConfigDiscoveryAdapter` used by the layered-config loader. Every IO-touc…
- [fs](./src/fs/fs.md) — Owns the `FsAdapter` seam and the synchronous `ConfigDiscoveryAdapter` used by the layered-config loader. Every IO-touc…
- [generator](./src/generator/generator.md) — Owns the two-call surface that scaffolds an Electrobun project on disk. `collectFiles(options, overrides?)` is pure — i…
- [generator](./src/generator/generator.md) — Owns the two-call surface that scaffolds an Electrobun project on disk. `collectFiles(options, overrides?)` is pure — i…
- [icons](./src/icons/icons.md) — Wraps the upstream `iconz` library behind an `IconRendererAdapter` seam so the generator can run the icon-generation pi…
- [icons](./src/icons/icons.md) — Wraps the upstream `iconz` library behind an `IconRendererAdapter` seam so the generator can run the icon-generation pi…
- [prompts](./src/prompts/prompts.md) — Owns the interactive scaffolder that backs the CLI's `create` command. `runPrompts(config, outputDir, prompter)` walks…
- [prompts](./src/prompts/prompts.md) — Owns the interactive scaffolder that backs the CLI's `create` command. `runPrompts(config, outputDir, prompter)` walks…
- [runner](./src/runner/runner.md) — Owns the `CommandRunner` seam that every electrobun shell-out flows through, plus the `probeElectrobun` and `invokeElec…
- [runner](./src/runner/runner.md) — Owns the `CommandRunner` seam that every electrobun shell-out flows through, plus the `probeElectrobun` and `invokeElec…
- [schemas](./src/schemas/schemas.md) — Owns the Zod schemas that gate every public boundary of `@suaveplan/electrobun`. Each interface in `src/types/electrobu…
- [schemas](./src/schemas/schemas.md) — Owns the Zod schemas that gate every public boundary of `@suaveplan/electrobun`. Each interface in `src/types/electrobu…
- [telemetry](./src/telemetry/telemetry.md) — Owns the OpenTelemetry surface for `@suaveplan/electrobun`. Every public entry point that does observable work (project…
- [telemetry](./src/telemetry/telemetry.md) — Owns the OpenTelemetry surface for `@suaveplan/electrobun`. Every public entry point that does observable work (project…
- [templates](./src/templates/templates.md) — Owns every renderer that produces a project-level file in the scaffolded output: `electrobun.config.ts`, `package.json`…
- [features](./src/templates/features.md) — Owns every renderer that produces a per-feature file in the scaffolded output: `i18n`, `networking` (basic + full), `ap…
- [templates](./src/templates/templates.md) — Owns every renderer that produces a project-level file in the scaffolded output: `electrobun.config.ts`, `package.json`…
<!-- end:auto-docs -->
## Features
- **Five-command CLI**: `create`, `dev`, `build`, `package`, `sign`.
- **Layered config**: defaults + `.suaveplan-electrobun.json` + CLI args
via `@suaveplan/config`.
- **Injectable seams**: `FsAdapter`, `CommandRunner`,
`IconRendererAdapter`, `Prompter` — every IO path is testable without
`vi.mock`.
- **Zod-validated options** at every public boundary; bad input fails as
`ELECTROBUN.INVALID_OPTIONS`.
- **Eleven typed error codes** under the `ELECTROBUN.*` namespace.
- **OpenTelemetry**: five counters + two histograms on the
`@suaveplan/electrobun` meter.
- **iconz integration** for `.icns` / `.ico` / `.png` icon generation.
- **Auto-update bundling** via the `--update` flag on `build`.
- **Code-signing pipeline** via `sign --identity --entitlements
--notarize` (the wrapper shapes argv; signing certificates remain in
the user's keychain).
- **Eight feature renderers** (`i18n`, `networking` × basic/full, `api`,
`api-schemas`, `storage`, `auth`, `form-builder`, `r3f` with five
modules).
## Installation
```bash
bun add @suaveplan/electrobun
```
The package ships a `suaveplan-electrobun` binary:
```bash
suaveplan-electrobun --help
```
## Usage
### Scaffold a new project
```bash
suaveplan-electrobun create my-app --skip-prompts \
--name my-app --id com.example.my-app --icon ./assets/icon.png
```
### Interactive scaffold
```bash
suaveplan-electrobun create ./my-app
```
### Dev / build / package / sign
```bash
suaveplan-electrobun dev --cwd ./my-app
suaveplan-electrobun build --cwd ./my-app --target mac --update
suaveplan-electrobun package --cwd ./my-app --target mac
suaveplan-electrobun sign --cwd ./my-app \
--identity "Developer ID Application: ..." \
--entitlements ./entitlements.plist --notarize
```
### Programmatic use
```ts
import {
buildCommand,
createCommand,
generate,
runCli,
} from "@suaveplan/electrobun";
const report = await generate({
name: "my-app",
identifier: "com.example.my-app",
description: "A demo",
version: "0.1.0",
outputDir: "./my-app",
features: ["i18n", "api"],
networkingComplexity: "basic",
r3fModules: [],
});
```
## Subpath exports
| Subpath | Surface |
|---|---|
| `@suaveplan/electrobun` | Full barrel |
| `@suaveplan/electrobun/cli` | Five command functions + `runCli` |
| `@suaveplan/electrobun/config` | `loadConfig`, defaults |
| `@suaveplan/electrobun/error-codes` | `ELECTROBUN_CODES`, `ElectrobunError` |
| `@suaveplan/electrobun/fs` | `FsAdapter`, `nodeFsAdapter`, file helpers |
| `@suaveplan/electrobun/generator` | `collectFiles`, `generate` |
| `@suaveplan/electrobun/icons` | `generateIcons`, `iconzRendererAdapter` |
| `@suaveplan/electrobun/prompts` | `runPrompts` |
| `@suaveplan/electrobun/runner` | `CommandRunner`, `probeElectrobun`, `invokeElectrobun` |
| `@suaveplan/electrobun/schemas` | Zod schemas + `parseOrThrow` |
| `@suaveplan/electrobun/telemetry` | OTel counters + histograms |
| `@suaveplan/electrobun/templates` | Project + feature renderers |
| `@suaveplan/electrobun/types` | Shared types + `FEATURE_ORDER` |
## Error codes
The full `ELECTROBUN.*` registry:
`INVALID_OPTIONS`, `OUTPUT_NOT_EMPTY`, `CONFIG_PARSE_FAILED`,
`ICON_SOURCE_MISSING`, `ICON_RENDER_FAILED`, `TEMPLATE_RENDER_FAILED`,
`FS_WRITE_FAILED`, `BINARY_UNAVAILABLE`, `RUNNER_FAILED`,
`PROMPT_ABORTED`, `UNKNOWN_FLAG`.
## License
MIT — see the repository root.

View File

@@ -0,0 +1,65 @@
# Binary entry — `suaveplan-electrobun.ts`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Single-file binary that wires `process.argv` into the pure `runCli`
dispatcher and translates the resolved exit code into a real
`process.exit` call. All business logic lives in `src/cli/cli.ts`; this
file is the only place that talks to the process boundary, which makes
the rest of the package fully unit-testable.
The binary registers the `@suaveplan/cli` prompt primitives as the
`Prompter` so the interactive `create` flow works against a real TTY
when run by hand. Tests that exercise the CLI never load this file —
they call `runCli` directly with a scripted prompter and a recording
runner.
## Features
- One-line wiring from `process.argv` to `runCli`.
- Real TTY prompts via `@suaveplan/cli/prompt/prompt`.
- Exit code translation: `runCli` resolves `{ code }`, the binary calls
`process.exit(code)`.
- Unexpected exceptions print to stderr and exit with `code: 2`.
## Usage: Basic
```bash
suaveplan-electrobun create my-app --skip-prompts --name my-app
suaveplan-electrobun dev --cwd ./my-app
suaveplan-electrobun build --cwd ./my-app --target mac --update
suaveplan-electrobun package --cwd ./my-app --target mac
suaveplan-electrobun sign --cwd ./my-app --identity "Dev ID Application: …"
```
## Usage: Advanced
```bash
# Use the layered config file at $HOME/.suaveplan-electrobun.json
# combined with project-level overrides
suaveplan-electrobun create --name override-app --skip-prompts ./out
```
## API Reference
- The binary takes no programmatic API. It accepts the argv shape
documented in `src/cli/cli.ts::HELP_TEXT`.
## Implementation Notes
The binary is excluded from the coverage thresholds in
`vitest.config.ts` because its only logic is `runCli(...).then(...,
...)` — the unit tests for `runCli` cover every reachable branch, and
the only paths that are unique to the binary are the `process.exit`
calls themselves. Excluding the file from coverage keeps the gate
honest without forcing a contrived "spawn a subprocess and assert on
exit code" test that would not catch anything the unit tests miss.
The binary uses `process.argv.slice(2)` rather than `process.argv` so
the node executable path and script path are stripped before the
parser sees them. The dispatcher's parser treats every remaining token
as a user-supplied flag, subcommand, or positional argument.

View File

@@ -0,0 +1,100 @@
# CLI — `cli.ts`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Owns the five command surfaces — `createCommand`, `devCommand`,
`buildCommand`, `packageCommand`, `signCommand` — plus the
`runCli(argv, deps?)` dispatcher that the published binary delegates to.
Every command is a pure function: it validates its option struct against
the matching Zod schema, runs the orchestrated work, and returns a typed
report. `runCli` never calls `process.exit`; the only exit-call lives in
`src/bin/suaveplan-electrobun.ts`, so the entire CLI is unit-testable
without a child process.
`createCommand` is the scaffolder entry: it loads the layered config,
applies CLI overrides, optionally runs the prompt flow, then hands off
to `generate`. The other four commands are runner-only — they probe the
upstream `electrobun` binary, raise `ELECTROBUN.BINARY_UNAVAILABLE` when
missing, spawn the binary with the canonical argv, and return a
`CommandReport` carrying exit code, stdout, stderr, and duration.
## Features
- Five command functions, each with Zod-validated options and structured
reports.
- Pure `runCli(argv)` dispatcher with `--help` / `-h` short-circuit and
closed-set flag parsing.
- Long-flag `--key=value` and `--key value` forms supported uniformly.
- Unknown flags / subcommands raise `ELECTROBUN.UNKNOWN_FLAG`.
- Missing required values raise `ELECTROBUN.INVALID_OPTIONS`.
- `timeRunner` wrapper increments invocation + duration histograms per
call and increments failure counters on `ElectrobunError` paths.
## Usage: Basic
```ts
import { runCli } from "@suaveplan/electrobun/cli";
const { code } = await runCli(process.argv.slice(2));
process.exit(code);
```
## Usage: Advanced
```ts
import { buildCommand } from "@suaveplan/electrobun/cli";
import type { CommandRunner } from "@suaveplan/electrobun/types";
const customRunner: CommandRunner = async (input) => {
// ship the spawn to a remote CI worker
return await postToWorker(input);
};
const report = await buildCommand(
{ cwd: "/proj", target: "linux", update: true },
{ runner: customRunner }
);
```
## API Reference
- `createCommand(opts: CreateCommandOptions, deps: CommandDeps & {
prompter: Prompter }): Promise<GenerateReport>`.
- `devCommand(opts: DevCommandOptions, deps?: { runner? }):
Promise<CommandReport>`.
- `buildCommand(opts: BuildCommandOptions, deps?: { runner? }):
Promise<CommandReport>`.
- `packageCommand(opts: PackageCommandOptions, deps?: { runner? }):
Promise<CommandReport>`.
- `signCommand(opts: SignCommandOptions, deps?: { runner? }):
Promise<CommandReport>`.
- `runCli(argv: readonly string[], deps?: RunCliOptions): Promise<{
code: number }>`.
- `HELP_TEXT: string` — the full help banner.
## Implementation Notes
The arg parser uses a closed `ALL_KNOWN_FLAGS` set. New flags must be
added to the set before the parser will accept them; an unknown flag
fails the run with `ELECTROBUN.UNKNOWN_FLAG`, which keeps subtle typos
from being silently swallowed. The `--help` and `-h` short forms are
intercepted before subcommand dispatch so they work uniformly across
every subcommand.
`runCli` catches every `ElectrobunError` and folds it into `code: 1`
with a one-line stderr message. Any other thrown value re-propagates,
on the principle that the runtime should not eat unexpected exceptions
— if something other than a typed error escapes the call, the bin
wrapper surfaces it with `code: 2`.
The `timeRunner` helper increments `electrobun_runner_invocations_total`
on entry, records the duration histogram on every exit (success or
failure), and records `electrobun_runner_failures_total` when the thrown
`ElectrobunError` carries a numeric `details.exitCode`. The failure
counter labels the exit code with `-1` when no numeric exit-code is
available — for instance when the probe stage raises
`BINARY_UNAVAILABLE` — so dashboards can still attribute the failure
to the right command without inventing fake exit codes.

View File

@@ -0,0 +1,82 @@
# Config loader — `config.ts`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`loadConfig(cliArgs, deps?)` builds an `ElectrobunConfig` by layering
hard-coded defaults, an optional on-disk `.suaveplan-electrobun.json`,
and CLI argument overrides on top of one another. The loader sits on
`@suaveplan/config`'s five-layer pipeline (defaults / file / object /
env / CLI), wiring three of the five for the scaffolder's needs.
The `--name`, `--id`, `--icon`, and `--version` CLI flags map to the
`name`, `identifier`, `iconSource`, and `version` fields respectively.
Defaults fill `version="0.0.1"`, `features=[]`,
`networkingComplexity="basic"`, and `r3fModules=[]` so callers can rely
on the final shape having those fields populated even when no file or
flag supplies them.
The `ConfigDiscoveryAdapter` seam (defined in `src/fs/fs.ts`) controls
where the loader looks for the config file. Tests inject an in-memory
adapter; production code uses the default `nodeConfigDiscoveryAdapter`
that probes `process.cwd()` then `homedir()` via real `node:fs` calls.
## Features
- Three-layer build: defaults → file → CLI.
- Synchronous (no async lift required by callers).
- Wraps malformed JSON as `ELECTROBUN.CONFIG_PARSE_FAILED`.
- Pluggable file discovery through `ConfigDiscoveryAdapter`.
- Exported `CLI_MAPPING` constant so consumers can introspect the flag
set (used by the `--help` text builder).
## Usage: Basic
```ts
import { loadConfig } from "@suaveplan/electrobun/config";
const cfg = loadConfig(process.argv.slice(2));
console.log(cfg.name ?? "(no name set)");
```
## Usage: Advanced
```ts
import { loadConfig } from "@suaveplan/electrobun/config";
import type { ConfigDiscoveryAdapter } from "@suaveplan/electrobun/fs";
const inMemory: ConfigDiscoveryAdapter = {
cwd: () => "/proj",
homedir: () => "/home/user",
exists: (p) => p === "/proj/.suaveplan-electrobun.json",
readFile: () => JSON.stringify({ name: "ci-app", features: ["api"] }),
};
const cfg = loadConfig(["--icon", "/icons/app.png"], {
discovery: inMemory,
});
// cfg.name === "ci-app"; cfg.iconSource === "/icons/app.png"
```
## API Reference
- `loadConfig(cliArgs: readonly string[] = [], deps?: { discovery?:
ConfigDiscoveryAdapter }): ElectrobunConfig`.
- `HARDCODED_DEFAULTS: ElectrobunConfig` — frozen defaults applied at
the bottom of the layer stack.
- `CLI_MAPPING: Record<string, string>` — exact flag-to-field mapping
used by the CLI layer.
## Implementation Notes
`@suaveplan/config`'s `cliLayer` takes mapping keys without the leading
`--`; the constants here mirror that contract verbatim. `coerceValue`
inside the cli layer parses booleans, numbers, and JSON literals, so a
caller can pass `--features '["api","i18n"]'` and have the array land
on the right field without bespoke wiring.
The file layer is skipped entirely when `findConfigFile` returns
`undefined`. This keeps the layer stack short for the common "no config
file" case and avoids a no-op fileLayer reload subscription.

View File

@@ -0,0 +1,93 @@
# Error codes — `error-codes.ts`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Owns the stable `ELECTROBUN.*` error-code namespace and the
`ElectrobunError` class that every public entry point throws on failure.
The registry is produced by `@suaveplan/error`'s `defineCodes("ELECTROBUN",
[...])` so each code is a plain `ELECTROBUN.<REASON>` string that survives
serialisation across worker / RPC / log-shipping boundaries — consumers
discriminate on `error.code` instead of `instanceof`, which means
incidents land in a single switch statement regardless of where the
failure originated.
The eleven codes cover every observable failure path in the package:
boundary validation, output-directory collision, malformed config files,
missing or unrenderable icon sources, template renderer crashes, fs
write failures, an unavailable `electrobun` binary, a non-zero exit from
the orchestrated runner, a user-aborted prompt, and an unknown CLI flag
or subcommand. Each code is paired with at least one call site exercised
by the package's tests; the conformance gate fails the build if
any code becomes unreachable.
## Features
- `ELECTROBUN_CODES` frozen-string registry of eleven codes.
- `ElectrobunError` extending `SuaveplanError<ElectrobunCode>`.
- `details: Record<string, unknown>` payload preserved through
`SuaveplanError.context`.
- `cause` chaining for native error wrappers without losing the original
stack.
## Usage: Basic
```ts
import { ELECTROBUN_CODES, ElectrobunError } from "@suaveplan/electrobun/error-codes";
throw new ElectrobunError(
ELECTROBUN_CODES.INVALID_OPTIONS,
"name must be a non-empty string"
);
```
## Usage: Advanced
```ts
import { ELECTROBUN_CODES, ElectrobunError } from "@suaveplan/electrobun/error-codes";
try {
await runSomething();
} catch (cause) {
throw new ElectrobunError(
ELECTROBUN_CODES.RUNNER_FAILED,
"electrobun build exited 2",
{
cause,
details: {
command: "build",
exitCode: 2,
stderr: "missing entitlements",
},
}
);
}
```
## API Reference
- `ELECTROBUN_CODES: Readonly<{...}>` — frozen registry of code strings.
Every value is `"ELECTROBUN." + <REASON>`.
- `ElectrobunCode = typeof ELECTROBUN_CODES[keyof typeof
ELECTROBUN_CODES]` — the literal-union code type.
- `class ElectrobunError extends SuaveplanError<ElectrobunCode>` with
fields `code`, `message`, `details`, and `cause`.
- `ElectrobunErrorOptions = { cause?: unknown; details?: Record<string,
unknown> }`.
## Implementation Notes
The constructor forwards `details` through `SuaveplanError.context` so the
serialised wire format (the `error.toJSON()` shape produced by
`@suaveplan/error`) carries the diagnostic payload alongside the code.
Consumers reading the wire format pull `code` and `context` and never
need to deserialise an `Error` subclass.
`cause` is only set when the caller supplies it — passing `undefined`
elides the field rather than leaving an explicit `cause: undefined` that
would defeat downstream cause-chain walkers. The same pattern is used
across `@suaveplan/linter` and `@suaveplan/installer`; keeping the shape
identical reduces the cognitive load of moving between packages.

View File

@@ -0,0 +1,104 @@
# Filesystem adapters — `fs.ts`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Owns the `FsAdapter` seam and the synchronous `ConfigDiscoveryAdapter`
used by the layered-config loader. Every IO-touching helper in the
package routes through one of these contracts so tests can stand in a
fake adapter without resorting to `vi.mock`, and so production code can
swap in a real-fs adapter wired through a test container or a mounted
overlay when the situation calls for it.
The default `nodeFsAdapter` round-trips strings as UTF-8 through
`node:fs/promises`. The default `nodeConfigDiscoveryAdapter` uses
`node:fs`'s sync `existsSync` / `readFileSync` plus the `node:os`
`homedir` and `process.cwd()` calls — sync paths are limited to the
single one-off discovery pass during `loadConfig`, and every other write
path is async through the adapter seam.
## Features
- `nodeFsAdapter` — full `FsAdapter` implementation backed by
`node:fs/promises`.
- `ensureDirectory` / `directoryExists` / `directoryIsEmpty` — defensive
directory helpers that never throw on a missing parent.
- `writeFiles(outputDir, files, fs?)` — writes a generated file list,
creating parents along the way; wraps adapter throws as
`ELECTROBUN.FS_WRITE_FAILED`.
- `nodeConfigDiscoveryAdapter` + `findConfigFile(discovery?)` — synchronous
config-file discovery in `cwd` then `homedir`; wraps malformed JSON or
read failures as `ELECTROBUN.CONFIG_PARSE_FAILED`.
## Usage: Basic
```ts
import {
ensureDirectory,
nodeFsAdapter,
writeFiles,
} from "@suaveplan/electrobun/fs";
await ensureDirectory("/tmp/my-app");
await writeFiles(
"/tmp/my-app",
[{ path: "README.md", content: "# hi" }],
nodeFsAdapter
);
```
## Usage: Advanced
```ts
import type { FsAdapter } from "@suaveplan/electrobun/types";
import { writeFiles } from "@suaveplan/electrobun/fs";
const recording: Array<{ path: string; content: string }> = [];
const recorder: FsAdapter = {
readFile: async () => "",
writeFile: async (path, content) => {
recording.push({ path, content });
},
mkdir: async () => undefined,
stat: async () => ({ isDirectory: () => true }),
readdir: async () => [],
};
await writeFiles("/out", [{ path: "a.txt", content: "x" }], recorder);
// recording carries the spawn-equivalent log of every write
```
## API Reference
- `FsAdapter` — `readFile`, `writeFile`, `mkdir({ recursive })`, `stat`,
`readdir`.
- `nodeFsAdapter: FsAdapter`.
- `ensureDirectory(dir, fs?): Promise<void>`.
- `directoryExists(dir, fs?): Promise<boolean>`.
- `directoryIsEmpty(dir, fs?): Promise<boolean>` — `true` for missing
dirs as well.
- `writeFiles(outputDir, files, fs?): Promise<void>` — throws
`ElectrobunError(ELECTROBUN.FS_WRITE_FAILED)` on adapter failure.
- `ConfigDiscoveryAdapter` — `exists`, `readFile`, `cwd`, `homedir`.
- `nodeConfigDiscoveryAdapter: ConfigDiscoveryAdapter`.
- `findConfigFile(discovery?): ElectrobunConfig | undefined` — throws
`ElectrobunError(ELECTROBUN.CONFIG_PARSE_FAILED)` on malformed JSON or
unreadable file.
- `CONFIG_FILENAME = ".suaveplan-electrobun.json"`.
## Implementation Notes
`directoryIsEmpty` deliberately returns `true` for a missing directory
because the caller's intent is "can we scaffold into this path?" — a
non-existent path is just as scaffold-friendly as an empty one. This
mirrors the upstream behaviour and keeps the `create` command from
spuriously refusing to scaffold into a fresh directory.
`findConfigFile` is the only synchronous path in the package because it
runs once before the asynchronous CLI command dispatch; making it async
would force the entire CLI shim to be async without a benefit, and would
move the test boundary deeper into the call chain. The
`ConfigDiscoveryAdapter` seam keeps the test surface clean while
preserving the synchronous default.

View File

@@ -0,0 +1,96 @@
# Generator — `generator.ts`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Owns the two-call surface that scaffolds an Electrobun project on disk.
`collectFiles(options, overrides?)` is pure — it returns the
`GeneratedFile[]` list for a given `ProjectOptions` without touching the
filesystem. `generate(options, deps?)` writes the file list through the
`FsAdapter` seam, snapshots the `.suaveplan-electrobun.json` config, and
optionally runs the icon pipeline when `options.iconSource` is set.
The generator is the only place that knows the canonical file order: the
nine core files come first (`electrobun.config.ts`, `package.json`,
`tsconfig.json`, the main-process Bun entry, three mainview files, the
shared types module, the README), then one file per selected feature in
the `FEATURE_ORDER` (`i18n`, `networking`, `api`, `api-schemas`,
`storage`, `auth`, `form-builder`, `r3f`). Input order does not matter
— the iterator walks `FEATURE_ORDER` and includes the feature only when
it is present in the input set.
The `overrides` argument lets tests substitute a faulty renderer to
exercise the `ELECTROBUN.TEMPLATE_RENDER_FAILED` wrapper without forcing
the production renderer table to throw. Production callers never set
overrides.
## Features
- Deterministic file ordering across runs.
- Zod-validated input via `ProjectOptionsSchema`.
- Wraps every renderer throw as `ELECTROBUN.TEMPLATE_RENDER_FAILED`.
- Optional icon pipeline gated by `options.iconSource`.
- OTel: `recordProject(featuresCount)` increment +
`recordGenerateDuration(ms, featuresCount)` observation per call.
- `CollectOverrides` seam for test-only renderer substitution.
## Usage: Basic
```ts
import { generate } from "@suaveplan/electrobun/generator";
const report = await generate({
name: "my-app",
identifier: "com.example.my-app",
description: "demo",
version: "0.1.0",
outputDir: "/tmp/my-app",
features: ["i18n", "api"],
networkingComplexity: "basic",
r3fModules: [],
});
console.log(`Wrote ${report.filesCreated.length} files in ${report.durationMs}ms`);
```
## Usage: Advanced
```ts
import { collectFiles } from "@suaveplan/electrobun/generator";
import type { FsAdapter } from "@suaveplan/electrobun/types";
import { writeFiles } from "@suaveplan/electrobun/fs";
const fs: FsAdapter = /* in-memory adapter */;
const files = collectFiles(opts);
await writeFiles(opts.outputDir, files, fs);
```
## API Reference
- `collectFiles(options: ProjectOptions, overrides?: CollectOverrides):
GeneratedFile[]`.
- `generate(options: ProjectOptions, deps?: GenerateDeps):
Promise<GenerateReport>`.
- `GenerateDeps = { fs?, renderer?, fileExists?, now?, overrides? }`.
- `CollectOverrides = { renderProjectFile?, renderFeatureFile? }` — the
test seam.
## Implementation Notes
`collectFiles` validates with Zod first, then iterates the file list.
Each renderer is wrapped in a try/catch that converts native errors into
`ElectrobunError(ELECTROBUN.TEMPLATE_RENDER_FAILED)` carrying the
faulty path (or feature name) in `details`. The wrap is essential
because a renderer failure surfaces as a string-typed crash at the call
site that consumed `collectFiles`; the typed code lets the caller branch
on "template bug" vs "Zod bug" vs "fs bug" without parsing messages.
The icon-pipeline integration is gated by a truthy
`parsed.iconSource` rather than `!= undefined`, so an empty-string
`iconSource` (a common prompt default) skips icon generation entirely.
This matches the upstream behaviour: the prompt layer collapses an
empty answer to `undefined` before passing it down, but the generator
defends against the empty-string case too in case a config file
materialises one.

View File

@@ -0,0 +1,91 @@
# Icon pipeline — `icons.ts`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Wraps the upstream `iconz` library behind an `IconRendererAdapter` seam
so the generator can run the icon-generation pipeline with a real
renderer in production and with a deterministic fake in tests. The
adapter contract is intentionally narrow — `render(input)` takes a
single descriptor and returns a `{ files }` payload — so swapping in a
different implementation (for instance, a server-side image-magick
worker) costs one struct.
`generateIcons({ inputPath, outputDir }, deps?)` validates its input
against the Zod schema, probes for the source file's existence, calls
the renderer, and returns an `IconReport` carrying either the produced
file list or the error message that the renderer threw. The function
never throws on a renderer failure — it returns the report with
`generated: false` and an `error` string so callers can surface the
warning without aborting the rest of the scaffold. A missing source
file IS a hard failure (raises `ELECTROBUN.ICON_SOURCE_MISSING`)
because the caller's intent was specifically "generate icons from this
path", not "skip icons silently."
## Features
- `iconzRendererAdapter` default backed by lazy `iconz` import.
- Boundary validation against `GenerateIconsOptionsSchema`.
- Hard `ELECTROBUN.ICON_SOURCE_MISSING` on a missing source path.
- Soft `IconReport.error` wrapping for renderer throws.
- Canonical icon descriptor (icns 16/32/.../1024, ico 16/.../256, png
16/.../1024) — pinned across runs.
- OTel `recordIcons("generated"|"skipped"|"failed")` increments per
outcome.
## Usage: Basic
```ts
import { generateIcons } from "@suaveplan/electrobun/icons";
const report = await generateIcons({
inputPath: "./assets/source.png",
outputDir: "./out/my-app",
});
if (report.error) {
console.warn(report.error);
}
```
## Usage: Advanced
```ts
import type { IconRendererAdapter } from "@suaveplan/electrobun/types";
import { generateIcons } from "@suaveplan/electrobun/icons";
const recording: IconRendererAdapter = {
async render(input) {
console.log("would emit:", input.icons);
return { files: ["app.icns", "app.ico"] };
},
};
await generateIcons(
{ inputPath: "./a.png", outputDir: "./out" },
{ renderer: recording, fileExists: () => true }
);
```
## API Reference
- `generateIcons(options, deps?): Promise<IconReport>`.
- `GenerateIconsOptions = { inputPath, outputDir }`.
- `GenerateIconsDeps = { renderer?, fileExists? }`.
- `iconzRendererAdapter: IconRendererAdapter` — default `iconz`-backed
renderer.
## Implementation Notes
The renderer is loaded via dynamic `import("iconz")` rather than a
top-level static import so the cost is paid only on the first real
icon-render call. Test environments that always inject a fake adapter
never trigger the import, which keeps the test suite fast and free of
native-dependency side effects.
The `IconReport.files` array is built from the renderer's reply rather
than from a hard-coded list so an adapter that produces a different
file set (for example, a stripped-down adapter for CI) reports honestly.
The recorder fake in `icons.test.ts` returns a minimal list to keep the
assertions tight.

View File

@@ -0,0 +1,96 @@
# Prompts — `prompts.ts`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Owns the interactive scaffolder that backs the CLI's `create` command.
`runPrompts(config, outputDir, prompter)` walks the user through nine
questions (name, identifier, description, version, icon source,
features, networking complexity when networking is selected, R3F modules
when R3F is selected, and a final confirm) and returns a fully-populated
`ProjectOptions` ready to feed the generator.
Every interaction flows through the injectable `Prompter` contract; tests
drive the function with a scripted prompter that returns canned answers,
production consumes the `@suaveplan/cli` prompt façade. The
`Prompter.text`, `confirm`, `select<T>`, and `multiselect<T>` methods are
the four primitives every prompt rests on — keeping the surface narrow
means swapping in a non-TTY prompter (for example a GUI front-end)
costs four methods.
A negative `proceed` answer raises `ELECTROBUN.PROMPT_ABORTED` and bumps
the `electrobun_prompts_total{outcome="aborted"}` counter; a completed
run bumps the `outcome="completed"` slot.
## Features
- Closed `Prompter` contract for swapping front-ends.
- Defaults pulled from the supplied `ElectrobunConfig` (lifted from
`loadConfig`).
- `networkingComplexity` and `r3fModules` are conditional on the
matching feature being selected.
- `deriveIdentifier` falls back to `com.example.<slug>` when the user
declines to set an explicit identifier.
- OTel `recordPromptOutcome` per call.
## Usage: Basic
```ts
import { runPrompts } from "@suaveplan/electrobun/prompts";
import { confirm, multiselect, select, text } from "@suaveplan/cli/prompt/prompt";
import type { Prompter } from "@suaveplan/electrobun/types";
const cliPrompter: Prompter = {
text,
confirm,
select,
multiselect,
};
const opts = await runPrompts({}, undefined, cliPrompter);
```
## Usage: Advanced
```ts
import type { Prompter } from "@suaveplan/electrobun/types";
const recorded: string[] = [];
const dryPrompter: Prompter = {
async text({ message, default: d }) {
recorded.push(message);
return d ?? "";
},
async confirm({ default: d }) {
return d ?? true;
},
async select({ choices, default: d }) {
return (d ?? choices[0]!.value) as never;
},
async multiselect() {
return [] as never;
},
};
await runPrompts(cfg, "/tmp/x", dryPrompter);
```
## API Reference
- `runPrompts(config: ElectrobunConfig, outputDir: string | undefined,
prompter: Prompter): Promise<ProjectOptions>`.
## Implementation Notes
The feature-list and R3F-module choices are derived from
`FEATURE_LABELS` / `R3F_MODULE_LABELS` so the human-readable text stays
in lockstep with the closed `FeatureModule` / `R3fModule` union types.
A new feature joining the union appears automatically in the prompt
choices once the labels constant is updated.
`iconSource` is collapsed from an empty string to `undefined` before
being returned. This matches the generator's expectation that an
absent icon source means "skip the icon pipeline entirely"; without the
collapse, the generator would call into the icon module with an empty
path and then have to re-handle the empty case.

View File

@@ -0,0 +1,97 @@
# Command runner — `runner.ts`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Owns the `CommandRunner` seam that every electrobun shell-out flows
through, plus the `probeElectrobun` and `invokeElectrobun` helpers that
the four orchestration subcommands use. The seam exists so tests can
substitute a recording fake without `vi.mock`, and so consumers running
under sandboxed environments can plug in their own spawn primitive
(for example a remote-runner that ships the spawn off to a CI worker).
The default `nodeCommandRunner` wraps `node:child_process.spawn` directly;
it buffers stdout / stderr as UTF-8 strings, records the wall-clock
duration, and propagates an optional `AbortSignal` through a `SIGTERM`
kill on abort. The wrapper does not pass the command through a shell, so
argument-injection through unsanitised user input is structurally
impossible.
`probeElectrobun` runs `bunx electrobun --version` and reports
`{ available, version? }`. `invokeElectrobun` probes first, raises
`ELECTROBUN.BINARY_UNAVAILABLE` on miss, and otherwise spawns the
subcommand with the supplied argv; a non-zero exit lands as
`ELECTROBUN.RUNNER_FAILED` carrying `command`, `exitCode`, `stderr`, and
the original argv on `details`.
## Features
- `nodeCommandRunner` — default async runner backed by
`node:child_process.spawn`.
- `probeElectrobun(runner?, cwd?)` — boolean probe + optional version
string parsed from stdout / stderr.
- `invokeElectrobun({ runner, cwd, args, command, signal? })` —
probe-then-spawn orchestrator with full error classification.
## Usage: Basic
```ts
import { invokeElectrobun, nodeCommandRunner } from "@suaveplan/electrobun/runner";
const result = await invokeElectrobun({
runner: nodeCommandRunner,
cwd: "/tmp/my-app",
args: ["build", "--target", "mac"],
command: "build",
});
console.log(`build exited ${result.exitCode} in ${result.durationMs}ms`);
```
## Usage: Advanced
```ts
import type { CommandRunner } from "@suaveplan/electrobun/types";
import { invokeElectrobun } from "@suaveplan/electrobun/runner";
const recording: Array<{ command: string; args: readonly string[] }> = [];
const recorder: CommandRunner = async (input) => {
recording.push({ command: input.command, args: input.args });
return { exitCode: 0, stdout: "1.2.3", stderr: "", durationMs: 1 };
};
await invokeElectrobun({
runner: recorder,
cwd: "/tmp/x",
args: ["sign", "--identity", "Dev ID"],
command: "sign",
});
```
## API Reference
- `nodeCommandRunner: CommandRunner` — `spawn`-backed default.
- `probeElectrobun(runner?: CommandRunner, cwd?: string):
Promise<{ available: boolean; version?: string }>`.
- `invokeElectrobun(input: { runner, cwd, args, command, signal? }):
Promise<CommandResult>` — throws `ElectrobunError(BINARY_UNAVAILABLE
| RUNNER_FAILED)`.
## Implementation Notes
`spawn` is used rather than `exec` so the `command` and `args` arrays
never pass through a shell. The wrapper sets `stdio: ["ignore", "pipe",
"pipe"]` to detach stdin (the orchestrated commands run non-interactively)
and to surface stdout / stderr buffers for diagnostics.
The version regex `(\d+\.\d+\.\d+)` is intentionally permissive — it
covers `1.2.3`, `v1.2.3`, and `electrobun 1.2.3 (some flag)` outputs.
When the binary changes its `--version` format the probe still reports
`available: true` without a version, which is enough to clear the
`BINARY_UNAVAILABLE` gate.
`AbortSignal` is forwarded via an `addEventListener("abort", …,
{ once: true })` that issues `SIGTERM` to the child. The listener is
removed on `close` / `error` to avoid leaking memory when many
subprocesses share a single signal.

View File

@@ -0,0 +1,104 @@
# Schemas — `schemas.ts`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Owns the Zod schemas that gate every public boundary of
`@suaveplan/electrobun`. Each interface in `src/types/electrobun.types.ts`
has a matching schema here, and the `parseOrThrow` helper folds the
parse + throw pattern into one call so every entry point reads the same
way: validate, then operate on the inferred type. The schemas are the
runtime contract — when a downstream library passes a stale
`ProjectOptions` shape, the schema fails at the boundary rather than
silently producing a broken project.
The schemas use Zod 4's enum / object / readonly-array helpers to keep
the TypeScript inference cheap, and `parseOrThrow` always raises
`ElectrobunError(ELECTROBUN.INVALID_OPTIONS)` so consumers can branch on
`error.code === "ELECTROBUN.INVALID_OPTIONS"` regardless of which entry
point validated.
## Features
- Enum schemas (`FeatureModuleSchema`, `NetworkingComplexitySchema`,
`R3fModuleSchema`, `BuildTargetSchema`).
- Composite schemas (`ProjectOptionsSchema`, `ElectrobunConfigSchema`,
`GenerateIconsOptionsSchema`, `DevCommandOptionsSchema`,
`BuildCommandOptionsSchema`, `PackageCommandOptionsSchema`,
`SignCommandOptionsSchema`).
- `parseOrThrow(schema, value, context)` — single helper that converts a
Zod failure into an `ElectrobunError` carrying `details.issues`.
## Usage: Basic
```ts
import { ProjectOptionsSchema } from "@suaveplan/electrobun/schemas";
const parsed = ProjectOptionsSchema.safeParse(input);
if (!parsed.success) {
console.error(parsed.error.issues);
}
```
## Usage: Advanced
```ts
import {
ProjectOptionsSchema,
parseOrThrow,
} from "@suaveplan/electrobun/schemas";
import { ElectrobunError } from "@suaveplan/electrobun/error-codes";
try {
const validated = parseOrThrow(
ProjectOptionsSchema,
input,
"ProjectOptions"
);
await generate(validated);
} catch (err) {
if (err instanceof ElectrobunError) {
process.stderr.write(`${err.code}: ${err.message}\n`);
for (const issue of err.details["issues"] as Array<{
path: ReadonlyArray<string>;
message: string;
}>) {
process.stderr.write(` - ${issue.path.join(".")}: ${issue.message}\n`);
}
}
}
```
## API Reference
- `FeatureModuleSchema`, `NetworkingComplexitySchema`, `R3fModuleSchema`,
`BuildTargetSchema` — closed Zod enums.
- `ProjectOptionsSchema` — full `ProjectOptions` shape; rejects empty
`name`, `identifier`, `version`, `outputDir`.
- `ElectrobunConfigSchema` — partial config; every field optional.
- `GenerateIconsOptionsSchema` — `{ inputPath, outputDir }`.
- `DevCommandOptionsSchema`, `BuildCommandOptionsSchema`,
`PackageCommandOptionsSchema`, `SignCommandOptionsSchema` — per-command
option shapes.
- `parseOrThrow<T>(schema, value, context)` — returns the parsed value or
throws `ElectrobunError(ELECTROBUN.INVALID_OPTIONS, "${context}: invalid
options", { details: { context, issues } })`.
## Implementation Notes
`z.array(...).readonly()` produces a `readonly`-typed inference that
matches the `readonly`-array fields on `ProjectOptions`, so the consumer
gets a parsed object whose mutability matches the interface. This is
important because the generator iterates the feature list and would
otherwise need an explicit `Array.from(...)` to avoid a structural
mismatch.
`parseOrThrow` uses `safeParse` rather than `parse` so the Zod issue list
lands on `details.issues`. The thrown error carries the original `cause`
slot empty — the Zod issues are the cause, surfaced as structured data
rather than a child `Error`. Consumers who want to log a single line
should format `err.message`; consumers who want to drive remediation
should walk `details.issues`.

View File

@@ -0,0 +1,93 @@
# Telemetry — `telemetry.ts`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Owns the OpenTelemetry surface for `@suaveplan/electrobun`. Every public
entry point that does observable work (project scaffolding, icon
rendering, command orchestration, prompt completion) ends with a call
into one of the recorder helpers exported here. Tests register a real
in-memory `MeterProvider` in `tests/setup/polyfills.ts` before any source
module imports, so the global meter resolved on first use binds to it
and every increment / observation is captured for assertion.
When no provider is registered the OTel API returns no-op instruments,
so the helpers are safe to call from production code that opts out of
telemetry without a guard ladder. The instrument names follow the
`electrobun_*` snake-case convention that the rest of the tooling
family already uses, which keeps Prometheus / Loki dashboards consistent.
## Features
- Five counters (`projects_generated`, `icons_generated`,
`runner_invocations`, `runner_failures`, `prompts`).
- Two histograms (`generate_duration_ms`, `runner_duration_ms`).
- Eight recorder helpers, one per instrument plus the prompt outcome and
icon outcome convenience wrappers.
- Lazy-bound meter that picks up whichever `MeterProvider` was registered
globally at first use — no boot order constraints on the consumer.
## Usage: Basic
```ts
import { recordRunnerInvocation } from "@suaveplan/electrobun/telemetry";
recordRunnerInvocation("build");
```
## Usage: Advanced
```ts
import {
recordGenerateDuration,
recordRunnerDuration,
recordRunnerFailure,
} from "@suaveplan/electrobun/telemetry";
const started = performance.now();
try {
await runOrchestrator(opts);
recordRunnerDuration("build", performance.now() - started);
} catch (err) {
recordRunnerFailure("build", err?.details?.exitCode ?? 1);
throw err;
} finally {
recordGenerateDuration(performance.now() - started, opts.features.length);
}
```
## API Reference
- `recordProject(featuresCount: number): void` — `+1` on
`electrobun_projects_generated_total` with `features_count`.
- `recordIcons(outcome: "generated"|"skipped"|"failed"): void` — `+1` on
`electrobun_icons_generated_total`.
- `recordRunnerInvocation(command: RunnerCommand): void` — `+1` on
`electrobun_runner_invocations_total`.
- `recordRunnerFailure(command: RunnerCommand, exitCode: number): void`
— `+1` on `electrobun_runner_failures_total` with `command` and
`exit_code` labels.
- `recordPromptOutcome(outcome: "completed"|"aborted"): void` — `+1` on
`electrobun_prompts_total`.
- `recordGenerateDuration(ms: number, featuresCount: number): void` —
observation on `electrobun_generate_duration_ms` with `features_count`.
- `recordRunnerDuration(command: RunnerCommand, ms: number): void` —
observation on `electrobun_runner_duration_ms` with `command`.
## Implementation Notes
The label keys are pinned (`features_count`, `outcome`, `command`,
`exit_code`) and intentionally use snake_case so they line up with the
OpenTelemetry semantic-conventions style for tooling instruments. The
recorder helpers stringify numeric labels because the `MeterProvider`
attribute API accepts strings — letting callers pass numbers would force
every call site to remember the coercion.
The lazy `meter` binding means the package can be imported into a fresh
process and used immediately; if the consumer later registers a
`MeterProvider`, the next call picks up the new instruments without a
re-import. This matches the lifecycle of the `@suaveplan/opentelemetry`
SDK factory, which wires the provider once at process start.

View File

@@ -0,0 +1,85 @@
# Feature templates — `features.ts`
## Purpose
Owns every renderer that produces a per-feature file in the scaffolded
output: `i18n`, `networking` (basic + full), `api`, `api-schemas`,
`storage`, `auth`, `form-builder`, and `r3f`. The renderers are pure
functions over `ProjectOptions`; the `r3f` renderer additionally branches
on `options.r3fModules` to wire camera / physics / particles / character
/ pathfinding subsystems into the emitted scene.
The companion `templates.ts` module covers the project-level renderers;
the two together replace the upstream `src/templates/*.ts` and
`src/templates/features/*.ts` blob set. `renderFeature(feature,
options)` is the single dispatch entry the generator uses; individual
`render*Feature` helpers are exported for tools that want to render a
single feature in isolation (for example, a "show me the API skeleton"
preview pane in an IDE extension).
## Features
- Eight per-feature renderers — every closed `FeatureModule` value has a
dedicated implementation.
- `renderNetworkFeature` vs `renderNetworkFullFeature` selected by
`options.networkingComplexity`.
- `renderR3fFeature` composes the camera / physics / particles imports
+ scene fragments based on `options.r3fModules`.
- `featurePath(feature)` returns the canonical output path for each
feature (the generator uses this to assemble the file list).
## Usage: Basic
```ts
import { renderFeature, featurePath } from "@suaveplan/electrobun/templates";
const content = renderFeature("i18n", opts);
const path = featurePath("i18n");
// path === "src/features/i18n.ts"
```
## Usage: Advanced
```ts
import {
featurePath,
renderFeature,
} from "@suaveplan/electrobun/templates";
const featureFiles = opts.features.map((f) => ({
path: featurePath(f),
content: renderFeature(f, opts),
}));
```
## API Reference
- `renderI18nFeature()`, `renderNetworkFeature()`,
`renderNetworkFullFeature()`, `renderApiFeature()`,
`renderApiSchemasFeature()`, `renderStorageFeature()`,
`renderAuthFeature()`, `renderFormBuilderFeature()`,
`renderR3fFeature(options)`.
- `renderFeature(feature: FeatureModule, options: ProjectOptions):
string` — dispatch table.
- `featurePath(feature: FeatureModule): string`.
## Implementation Notes
The `r3f` renderer is the only one whose output is materially shaped by
`ProjectOptions` beyond the basic "the feature is enabled" toggle. The
imports and scene fragments are assembled by joining arrays of strings,
which makes the relative ordering deterministic and the diff-noise low
when a new R3F module is added: the test for "r3f renderer emits the
requested modules" pins the order so a refactor cannot silently
re-shuffle imports.
The `networking` renderer dispatches on `options.networkingComplexity`
rather than `options.networking` because the upstream contract used
`"basic"` / `"full"` to gate the full mesh layer. Keeping the same
contract means downstream electrobun projects already on the
`networkingComplexity` shape do not need to migrate.
`featurePath` lives here rather than in the generator because the
mapping is per-feature; the generator consumes it as a thin lookup. If
a future feature lands with a non-default output path, the change is
local to `features.ts`.

View File

@@ -0,0 +1,107 @@
# Project templates — `templates.ts`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Owns every renderer that produces a project-level file in the scaffolded
output: `electrobun.config.ts`, `package.json`, `tsconfig.json`,
`README.md`, the main-process Bun entry, the mainview HTML / CSS / TSX,
and the shared types module. The renderers are pure functions over
`ProjectOptions` — given the same input they return byte-identical
strings, which is the property the generator's snapshot tests rely on.
The companion `features.ts` module covers the per-feature renderers; the
two files together replace the upstream `src/templates/*.ts` blob set.
Splitting project files from features keeps the dispatch table for each
small enough to inline at the call site, and keeps the test surface
shallow.
`renderProjectFile(kind, options)` is the single entry point the
generator uses. Direct exports of each `render*` helper are preserved
for consumers that want to drop a single template into a different
toolchain (for example, a `--scaffold-only` CI script that emits
`package.json` without the rest of the project).
## Features
- Nine project renderers covering every file the scaffolder writes.
- `renderProjectFile(kind, options)` dispatch table for the generator.
- `FEATURE_LABELS` / `R3F_MODULE_LABELS` constants used by the prompt
layer and by `renderReadme`.
- Deterministic output for a given `ProjectOptions` (byte-stable across
runs).
## Usage: Basic
```ts
import { renderPackageJson } from "@suaveplan/electrobun/templates";
const pkgJson = renderPackageJson({
name: "my-app",
identifier: "com.example.my-app",
description: "demo",
version: "0.1.0",
outputDir: "/tmp/x",
features: ["api"],
networkingComplexity: "basic",
r3fModules: [],
});
```
## Usage: Advanced
```ts
import { renderProjectFile } from "@suaveplan/electrobun/templates";
import { writeFiles } from "@suaveplan/electrobun/fs";
const kinds = [
"electrobun-config",
"package-json",
"tsconfig",
"main-process",
"mainview-html",
"mainview-css",
"mainview-entry",
"shared-types",
"readme",
] as const;
const opts = /* ... */;
const files = kinds.map((kind) => ({
path: pathFor(kind),
content: renderProjectFile(kind, opts),
}));
await writeFiles(opts.outputDir, files);
```
## API Reference
- `renderElectrobunConfig(options)`, `renderMainProcess(options)`,
`renderMainviewHtml(options)`, `renderMainviewCss()`,
`renderMainviewEntry(options)`, `renderSharedTypes(options)`,
`renderTsconfig()`, `renderReadme(options)`, `renderPackageJson(options)`.
- `renderProjectFile(kind: ProjectFileKind, options)` — dispatch table.
- `FEATURE_LABELS: Record<FeatureModule, string>`.
- `R3F_MODULE_LABELS: Record<R3fModule, string>`.
## Implementation Notes
Every renderer is total over its input — there is no fallback branch
that emits a placeholder. Bad `ProjectOptions` should be rejected by the
Zod schema in `src/schemas/schemas.ts` before reaching a renderer; if
the schema misses something the renderer will produce a string with
unexpected `undefined` interpolations, which the unit tests catch.
`renderPackageJson` keeps the dependency map in a per-feature table
(`FEATURE_DEPS`) so the renderer body stays small and the table can
diff cleanly when the suaveplan dep set evolves. R3F module dependencies
live in a parallel `R3F_MODULE_DEPS` table for the same reason.
`renderMainviewEntry` builds the i18n greeting via a literal template
fragment rather than a string-interpolation `${...}` so the source
emitted into the scaffolded project preserves the surrounding `${...}`
braces. The build step never touches these strings — they are emitted
verbatim — so the generated `.tsx` file compiles cleanly inside the
downstream project.

View File

@@ -0,0 +1,115 @@
# Types — `electrobun.types.ts`
## Purpose
Centralises every public interface used across the `@suaveplan/electrobun`
surface. The file is type-only — no runtime emit beyond the
`FEATURE_ORDER` `readonly` array that defines the canonical feature
render order. Keeping every shape in a single module means downstream
consumers (CI scripts, IDE extensions, the binary entry) only ever import
one symbol per concept instead of chasing them across feature folders.
The Zod schemas in `src/schemas/schemas.ts` mirror these shapes one-to-one
so the type system and the runtime validator never drift. Every public
function in the package parses its options against the schema and then
operates on the inferred TypeScript type.
## Features
- `ProjectOptions` / `ElectrobunConfig` — the user-facing scaffolder shape.
- `FeatureModule`, `NetworkingComplexity`, `R3fModule`, `BuildTarget` —
closed union types that mirror the upstream CLI choices.
- `FsAdapter`, `CommandRunner`, `IconRendererAdapter`, `Prompter` — the four
seams every IO-touching helper accepts.
- `*CommandOptions` (`Dev`, `Build`, `Package`, `Sign`) + `CommandReport`
for the four orchestration subcommands.
- `IconReport`, `GenerateReport` — structured returns for the generator
and icon pipeline.
- `CommandDeps` — the dependency bag accepted by `runCli` and every
subcommand.
## Usage: Basic
```ts
import type { ProjectOptions } from "@suaveplan/electrobun/types";
const opts: ProjectOptions = {
name: "my-app",
identifier: "com.example.my-app",
description: "A demo Electrobun app",
version: "0.1.0",
outputDir: "/tmp/my-app",
features: ["i18n", "api"],
networkingComplexity: "basic",
r3fModules: [],
};
```
## Usage: Advanced
```ts
import type {
CommandRunner,
FsAdapter,
Prompter,
} from "@suaveplan/electrobun/types";
const recordingRunner: CommandRunner = async (input) => ({
exitCode: 0,
stdout: "",
stderr: "",
durationMs: 1,
});
const fakeFs: FsAdapter = {
readFile: async () => "",
writeFile: async () => undefined,
mkdir: async () => undefined,
stat: async () => ({ isDirectory: () => true }),
readdir: async () => [],
};
const promptScript: Prompter = {
text: async ({ default: d }) => d ?? "",
confirm: async () => true,
select: async ({ default: d, choices }) =>
(d ?? choices[0]!.value) as never,
multiselect: async () => [] as never,
};
```
## API Reference
- `ProjectOptions` — required fields: `name`, `identifier`, `description`,
`version`, `outputDir`, `features`, `networkingComplexity`, `r3fModules`.
Optional: `iconSource`.
- `ElectrobunConfig` — every `ProjectOptions` field is optional; used as the
config-file shape.
- `GeneratedFile = { path: string; content: string }`.
- `FsAdapter` — `readFile`, `writeFile`, `mkdir({ recursive })`, `stat`,
`readdir` (all `Promise<…>`).
- `CommandInput = { command, args, cwd?, env?, signal? }`.
- `CommandResult = { exitCode, stdout, stderr, durationMs }`.
- `CommandRunner = (input) => Promise<CommandResult>`.
- `Prompter.text / confirm / select<T> / multiselect<T>`.
- `IconRendererInput.icons` carries `{ icns, ico, png }` descriptors.
- `IconReport = { generated, files, error? }`.
- `GenerateReport = { outputDir, filesCreated, durationMs, icons }`.
- `CommandReport = { command, exitCode, durationMs, stdout, stderr }`.
- `FEATURE_ORDER` — the canonical eight-feature render order.
## Implementation Notes
`FEATURE_ORDER` is a `readonly` array tuple so the generator can iterate
it without sorting on every call. The runtime contract is "if the consumer
asks for `r3f` before `i18n`, we render `i18n` first regardless." Keeping
the order in a single exported constant is the gate against drift between
the upstream CLI and the renderer ordering.
`FsAdapter.stat` returns `{ isDirectory(): boolean }` to match the
`node:fs.Stats` shape without forcing the consumer to import the whole
`Stats` class. The fakes used in tests return inline objects implementing
just this method, which keeps tests free of `Stats` instantiation.
Every option that the CLI exposes has both a `*Options` interface and a
matching Zod schema in `src/schemas/schemas.ts`; consumers should prefer
the schema for runtime validation and the interface for compile-time
typing.

View File

@@ -0,0 +1,94 @@
# @suaveplan/install-agent-skills - Suaveplan installer plugin: bundles the agent-skills set and installs them into a target project via @suaveplan/installer.
## Summary
Installer plugin that bundles every Suaveplan skill (`SKILL.md` + assets)
and installs them into a target project via `@suaveplan/installer`.
<!-- begin:auto-docs -->
## Documentation tree
- [apply](./src/apply/apply.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [apply](./src/apply/apply.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [detect](./src/detect/detect.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [detect](./src/detect/detect.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [plan](./src/plan/plan.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [plan](./src/plan/plan.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [plugin](./src/plugin/plugin.md) — Composes the agent-skills installer plugin from its constituent stages (`detect`, `prompts`, `plan`, `apply`, `postInst…
- [plugin](./src/plugin/plugin.md) — Composes the agent-skills installer plugin from its constituent stages (`detect`, `prompts`, `plan`, `apply`, `postInst…
- [post-install](./src/post-install/post-install.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [post-install](./src/post-install/post-install.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [prompts](./src/prompts/prompts.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [prompts](./src/prompts/prompts.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [error-codes](./src/error-codes.md) — Defines the canonical error-code registry for `@suaveplan/install-agent-skills`. Every plugin-side failure path (invali…
- [manifest-loader](./src/manifest-loader.md) — Loads and validates the bundled `assets/skills/manifest.json` produced by `scripts/build-skills-bundle.ts`. The loader…
<!-- end:auto-docs --> - [apply](./src/apply/apply.md) — <!-- begin:auto-docs --> _(no submodules)_- [detect](./src/detect/detect.md) — <!-- begin:auto-docs --> _(no submodules)_- [detect](./src/detect/detect.md) — <!-- begin:auto-docs --> _(no submodules)_- [plan](./src/plan/plan.md) — <!-- begin:auto-docs --> _(no submodules)_- [plan](./src/plan/plan.md) — <!-- begin:auto-docs --> _(no submodules)_- [post-install](./src/post-install/post-install.md) — <!-- begin:auto-docs --> _(no submodules)_- [post-install](./src/post-install/post-install.md) — <!-- begin:auto-docs --> _(no submodules)_- [prompts](./src/prompts/prompts.md) — <!-- begin:auto-docs --> _(no submodules)_- [prompts](./src/prompts/prompts.md) — <!-- begin:auto-docs --> _(no submodules)_- [error-codes](./src/error-codes.md) — Defines the canonical error-code registry for `@suaveplan/install-agent-skills`. Every plugin-side failure path (invali…
- [manifest-loader](./src/manifest-loader.md) — Loads and validates the bundled `assets/skills/manifest.json` produced by `scripts/build-skills-bundle.ts`. The loader…
- [plugin](./src/plugin.md) — Composes the agent-skills installer plugin from its constituent stages (`detect`, `prompts`, `plan`, `apply`, `postInst…
- [apply](./src/apply/apply.md) — <!-- begin:auto-docs --> _(no submodules)_- [detect](./src/detect/detect.md) — <!-- begin:auto-docs --> _(no submodules)_- [detect](./src/detect/detect.md) — <!-- begin:auto-docs --> _(no submodules)_- [plan](./src/plan/plan.md) — <!-- begin:auto-docs --> _(no submodules)_- [plan](./src/plan/plan.md) — <!-- begin:auto-docs --> _(no submodules)_- [post-install](./src/post-install/post-install.md) — <!-- begin:auto-docs --> _(no submodules)_- [post-install](./src/post-install/post-install.md) — <!-- begin:auto-docs --> _(no submodules)_- [prompts](./src/prompts/prompts.md) — <!-- begin:auto-docs --> _(no submodules)_- [prompts](./src/prompts/prompts.md) — <!-- begin:auto-docs --> _(no submodules)_- [error-codes](./src/error-codes.md) — Defines the canonical error-code registry for `@suaveplan/install-agent-skills`. Every plugin-side failure path (invali…
- [manifest-loader](./src/manifest-loader.md) — Loads and validates the bundled `assets/skills/manifest.json` produced by `scripts/build-skills-bundle.ts`. The loader…
- [plugin](./src/plugin.md) — Composes the agent-skills installer plugin from its constituent stages (`detect`, `prompts`, `plan`, `apply`, `postInst…
- [apply](./src/apply/apply.md) — <!-- begin:auto-docs --> _(no submodules)_- [detect](./src/detect/detect.md) — <!-- begin:auto-docs --> _(no submodules)_- [detect](./src/detect/detect.md) — <!-- begin:auto-docs --> _(no submodules)_- [plan](./src/plan/plan.md) — <!-- begin:auto-docs --> _(no submodules)_- [plan](./src/plan/plan.md) — <!-- begin:auto-docs --> _(no submodules)_- [post-install](./src/post-install/post-install.md) — <!-- begin:auto-docs --> _(no submodules)_- [post-install](./src/post-install/post-install.md) — <!-- begin:auto-docs --> _(no submodules)_- [prompts](./src/prompts/prompts.md) — <!-- begin:auto-docs --> _(no submodules)_- [prompts](./src/prompts/prompts.md) — <!-- begin:auto-docs --> _(no submodules)_- [error-codes](./src/error-codes.md) — Defines the canonical error-code registry for `@suaveplan/install-agent-skills`. Every plugin-side failure path (invali…
- [manifest-loader](./src/manifest-loader.md) — Loads and validates the bundled `assets/skills/manifest.json` produced by `scripts/build-skills-bundle.ts`. The loader…
- [plugin](./src/plugin.md) — Composes the agent-skills installer plugin from its constituent stages (`detect`, `prompts`, `plan`, `apply`, `postInst…
- [apply](./src/apply/apply.md) — <!-- begin:auto-docs --> _(no submodules)_- [detect](./src/detect/detect.md) — <!-- begin:auto-docs --> _(no submodules)_ - [detect](./src/detect/detect.md) — <!-- begin:auto-docs --> _(no submodules)_- [plan](./src/plan/plan.md) — <!-- begin:auto-docs --> _(no submodules)_ - [plan](./src/plan/plan.md) — <!-- begin:auto-docs --> _(no submodules)_- [post-install](./src/post-install/post-install.md) — <!-- begin:auto-docs --> _(no submodules)_ - [post-install](./src/post-install/post-install.md) — <!-- begin:auto-docs --> _(no submodules)_- [prompts](./src/prompts/prompts.md) — <!-- begin:auto-docs --> _(no submodules)_ - [prompts](./src/prompts/prompts.md) — <!-- begin:auto-docs --> _(no submodules)_- [error-codes](./src/error-codes.md) — Defines the canonical error-code registry for `@suaveplan/install-agent-skills`. Every plugin-side failure path (invali…
- [manifest-loader](./src/manifest-loader.md) — Loads and validates the bundled `assets/skills/manifest.json` produced by `scripts/build-skills-bundle.ts`. The loader…
- [plugin](./src/plugin.md) — Composes the agent-skills installer plugin from its constituent stages (`detect`, `prompts`, `plan`, `apply`, `postInst…
## Quickstart
```bash
bun add -D @suaveplan/install-agent-skills
npx @suaveplan/installer add agent-skills
```
## Filtered installs
```bash
# Only a curated subset
npx @suaveplan/installer add agent-skills --only crypto,events,logger
# Everything except legacy skills
npx @suaveplan/installer add agent-skills --exclude legacy
# Force overwrite, non-interactive
npx @suaveplan/installer add agent-skills --force --no-interactive
```
## Programmatic use
```ts
import { agentSkillsPlugin } from "@suaveplan/install-agent-skills";
import { Host, DiskFs } from "@suaveplan/installer";
const fs = new DiskFs({ cwd: process.cwd() });
const host = new Host(ctx, fs);
const result = await host.run(agentSkillsPlugin, {
answers: { targetDir: ".suaveplan/skills" },
});
console.log(`installed ${result.handle?.installed} skills`);
```
## Bundle build
The plugin ships its skill set inside `assets/skills/`. To rebuild the
bundle from the upstream `suaveplan-skills` marketplace:
```bash
bun run build:bundle
```
By default the script reads from `../../../../suaveplan-skills/plugins`;
override via `SUAVEPLAN_SKILLS_ROOT=/path/to/skills bun run build:bundle`.
## Module documentation
Every implementation file ships with a sibling `.md` covering Purpose,
Features, Usage, API Reference, and Implementation Notes. See
`src/plan/plan.md`, `src/manifest-loader.md`, `src/plugin.md` for the
load-bearing modules.
## License
MIT

View File

@@ -0,0 +1,46 @@
---
name: ab-testing
description: Use when implementing or debugging code that uses @suaveplan/ab-testing: Enterprise A/B testing and experimentation: deterministic FNV-1a bucketing, targeting rules, variant assignment, conversion tracking, and OTel instrumentation.
---
# @suaveplan/ab-testing
Enterprise A/B testing and experimentation engine with deterministic FNV-1a bucketing, targeting rules, variant assignment, conversion tracking, result aggregation, and OpenTelemetry instrumentation.
## When to use this skill
- Implementing or debugging code that imports `@suaveplan/ab-testing`.
- The task touches: see package README.
- You need API guidance for this package's exports.
## Quick reference
```ts
import { /* see API Reference below */ } from "@suaveplan/ab-testing";
```
## Dependencies
Workspace dependencies: `@suaveplan/error`, `@suaveplan/events`, `@suaveplan/feature-flags`, `@suaveplan/logger`, `@suaveplan/types`
## Where to read more
This skill is intentionally short. The package's own documentation is the authoritative source — read these in order:
1. **`packages/platform/ab-testing/README.md`** — overview, install, quick-start, module index
2. **`packages/platform/ab-testing/src/**/*.md`** — co-located docs per implementation file (Purpose, Features, Basic + Advanced examples, full API reference, Implementation notes — all packages ship ≥200 words per module)
3. **`packages/platform/ab-testing/package.json`** `exports` map — every subpath you can import from
When the user asks an API question, read the relevant co-located `.md` first; do not answer from training-data memory.
## See also
- `@suaveplan/error`
- `@suaveplan/events`
- `@suaveplan/feature-flags`
- `@suaveplan/logger`
- `@suaveplan/types`
---
_This SKILL.md is auto-generated by `suaveplan-skills/scripts/generate-skills.ts` from the package's README. Do not edit by hand — re-run the generator after the README changes._

View File

@@ -0,0 +1,42 @@
---
name: api-schemas
description: Use when implementing or debugging code that uses @suaveplan/api-schemas: Shared Zod schemas describing Suaveplan API request/response shapes — single source of truth for server plugins, React data hooks, and generated OpenAPI documents.
---
# @suaveplan/api-schemas
Shared Zod schemas describing Suaveplan API request/response shapes. The single source of truth for server plugins, React data hooks, and generated OpenAPI documents.
## When to use this skill
- Implementing or debugging code that imports `@suaveplan/api-schemas`.
- The task touches: see package README.
- You need API guidance for this package's exports.
## Quick reference
```ts
import { /* see API Reference below */ } from "@suaveplan/api-schemas";
```
## Dependencies
Workspace dependencies: _none_
## Where to read more
This skill is intentionally short. The package's own documentation is the authoritative source — read these in order:
1. **`packages/api/api-schemas/README.md`** — overview, install, quick-start, module index
2. **`packages/api/api-schemas/src/**/*.md`** — co-located docs per implementation file (Purpose, Features, Basic + Advanced examples, full API reference, Implementation notes — all packages ship ≥200 words per module)
3. **`packages/api/api-schemas/package.json`** `exports` map — every subpath you can import from
When the user asks an API question, read the relevant co-located `.md` first; do not answer from training-data memory.
## See also
- The marketplace root: `../../../README.md`
---
_This SKILL.md is auto-generated by `suaveplan-skills/scripts/generate-skills.ts` from the package's README. Do not edit by hand — re-run the generator after the README changes._

View File

@@ -0,0 +1,48 @@
---
name: audit-logging
description: Use when implementing or debugging code that uses @suaveplan/audit-logging: Tamper-proof, GDPR-compliant audit logging: cryptographic chain-integrity, pluggable storage, retention policy, GDPR export, and a rich event taxonomy.
---
# @suaveplan/audit-logging
Tamper-proof, GDPR-compliant audit logging for Suaveplan. Provides a cryptographic chain-integrity engine, pluggable storage adapters, GDPR data export, configurable retention policies, and a rich event taxonomy spanning auth, authorization, data access, system, and GDPR events.
## When to use this skill
- Implementing or debugging code that imports `@suaveplan/audit-logging`.
- The task touches: see package README.
- You need API guidance for this package's exports.
## Quick reference
```ts
import { /* see API Reference below */ } from "@suaveplan/audit-logging";
```
## Dependencies
Workspace dependencies: `@suaveplan/crypto`, `@suaveplan/crypto-hash`, `@suaveplan/error`, `@suaveplan/events`, `@suaveplan/logger`, `@suaveplan/serialize`, `@suaveplan/types`
## Where to read more
This skill is intentionally short. The package's own documentation is the authoritative source — read these in order:
1. **`packages/platform/audit-logging/README.md`** — overview, install, quick-start, module index
2. **`packages/platform/audit-logging/src/**/*.md`** — co-located docs per implementation file (Purpose, Features, Basic + Advanced examples, full API reference, Implementation notes — all packages ship ≥200 words per module)
3. **`packages/platform/audit-logging/package.json`** `exports` map — every subpath you can import from
When the user asks an API question, read the relevant co-located `.md` first; do not answer from training-data memory.
## See also
- `@suaveplan/crypto`
- `@suaveplan/crypto-hash`
- `@suaveplan/error`
- `@suaveplan/events`
- `@suaveplan/logger`
- `@suaveplan/serialize`
- `@suaveplan/types`
---
_This SKILL.md is auto-generated by `suaveplan-skills/scripts/generate-skills.ts` from the package's README. Do not edit by hand — re-run the generator after the README changes._

View File

@@ -0,0 +1,47 @@
---
name: auth
description: Use when implementing or debugging code that uses @suaveplan/auth: Provider-agnostic authentication: JWT lifecycle, API key management, session management, and request middleware with pluggable storage adapters.
---
# @suaveplan/auth
Provider-agnostic authentication library for Suaveplan. Ships JWT token lifecycle, API key management, session management, and framework-agnostic middleware — all backed by a pluggable `AuthAdapter` interface.
## When to use this skill
- Implementing or debugging code that imports `@suaveplan/auth`.
- The task touches: see package README.
- You need API guidance for this package's exports.
## Quick reference
```ts
import { /* see API Reference below */ } from "@suaveplan/auth";
```
## Dependencies
Workspace dependencies: `@suaveplan/crypto`, `@suaveplan/crypto-keys`, `@suaveplan/error`, `@suaveplan/logger`, `@suaveplan/retry`, `@suaveplan/types`
## Where to read more
This skill is intentionally short. The package's own documentation is the authoritative source — read these in order:
1. **`packages/auth/auth/README.md`** — overview, install, quick-start, module index
2. **`packages/auth/auth/src/**/*.md`** — co-located docs per implementation file (Purpose, Features, Basic + Advanced examples, full API reference, Implementation notes — all packages ship ≥200 words per module)
3. **`packages/auth/auth/package.json`** `exports` map — every subpath you can import from
When the user asks an API question, read the relevant co-located `.md` first; do not answer from training-data memory.
## See also
- `@suaveplan/crypto`
- `@suaveplan/crypto-keys`
- `@suaveplan/error`
- `@suaveplan/logger`
- `@suaveplan/retry`
- `@suaveplan/types`
---
_This SKILL.md is auto-generated by `suaveplan-skills/scripts/generate-skills.ts` from the package's README. Do not edit by hand — re-run the generator after the README changes._

View File

@@ -0,0 +1,42 @@
---
name: barcode
description: Use when implementing or debugging code that uses @suaveplan/barcode: Barcode generation and parsing for common 1D symbologies: Code 128, Code 39, EAN-13, EAN-8, UPC-A, UPC-E, ITF.
---
# @suaveplan/barcode
Runtime-agnostic 1D barcode generation for seven symbologies — Code 128B, Code 39, EAN-13, EAN-8, UPC-A, UPC-E, and ITF. Outputs inline SVG strings and binary bar-pattern arrays with no canvas, no DOM, and no Node.js built-ins required, making it safe to use in Bun, Deno, edge runtimes, and browsers alike. Includes check-digit calculation, barcode validation, and human-readable text rendering. Use this package to generate product labels, shipping labels (ITF/GTIN-14), retail barcodes (EAN/UPC), and document barcodes (Code 128). For 2D barcodes (QR codes) use `@suaveplan/qrcode`. This package does not support barcode scanning/decoding from images — it is generation-only.
## When to use this skill
- Implementing or debugging code that imports `@suaveplan/barcode`.
- The task touches: see package README.
- You need API guidance for this package's exports.
## Quick reference
```ts
import { /* see API Reference below */ } from "@suaveplan/barcode";
```
## Dependencies
Workspace dependencies: _none_
## Where to read more
This skill is intentionally short. The package's own documentation is the authoritative source — read these in order:
1. **`packages/foundation/barcode/README.md`** — overview, install, quick-start, module index
2. **`packages/foundation/barcode/src/**/*.md`** — co-located docs per implementation file (Purpose, Features, Basic + Advanced examples, full API reference, Implementation notes — all packages ship ≥200 words per module)
3. **`packages/foundation/barcode/package.json`** `exports` map — every subpath you can import from
When the user asks an API question, read the relevant co-located `.md` first; do not answer from training-data memory.
## See also
- The marketplace root: `../../../README.md`
---
_This SKILL.md is auto-generated by `suaveplan-skills/scripts/generate-skills.ts` from the package's README. Do not edit by hand — re-run the generator after the README changes._

View File

@@ -0,0 +1,43 @@
---
name: bitpacker
description: Use when implementing or debugging code that uses @suaveplan/bitpacker: Bit-level binary packing and unpacking: arbitrary-width integers, booleans, fixed-point, bit-field schemas, and streaming read/write cursors.
---
# @suaveplan/bitpacker
Bit-level binary packing and unpacking for TypeScript. Designed for network protocols, game-state serialisation, and compact binary formats. Pure TypeScript, zero runtime deps beyond `@suaveplan/error`.
## When to use this skill
- Implementing or debugging code that imports `@suaveplan/bitpacker`.
- The task touches: see package README.
- You need API guidance for this package's exports.
## Quick reference
```ts
import { /* see API Reference below */ } from "@suaveplan/bitpacker";
```
## Dependencies
Workspace dependencies: `@suaveplan/error`, `@suaveplan/types`
## Where to read more
This skill is intentionally short. The package's own documentation is the authoritative source — read these in order:
1. **`packages/bitpacker/bitpacker/README.md`** — overview, install, quick-start, module index
2. **`packages/bitpacker/bitpacker/src/**/*.md`** — co-located docs per implementation file (Purpose, Features, Basic + Advanced examples, full API reference, Implementation notes — all packages ship ≥200 words per module)
3. **`packages/bitpacker/bitpacker/package.json`** `exports` map — every subpath you can import from
When the user asks an API question, read the relevant co-located `.md` first; do not answer from training-data memory.
## See also
- `@suaveplan/error`
- `@suaveplan/types`
---
_This SKILL.md is auto-generated by `suaveplan-skills/scripts/generate-skills.ts` from the package's README. Do not edit by hand — re-run the generator after the README changes._

View File

@@ -0,0 +1,43 @@
---
name: bytes
description: Use when implementing or debugging code that uses @suaveplan/bytes: Low-level byte utilities: hex/base64/utf8 encoding, Uint8Array helpers, UUID conversion.
---
# @suaveplan/bytes
Low-level byte utilities for Suaveplan: lossless hex/base64/UTF-8 encoding, `Uint8Array` manipulation, and UUID conversion. No native addons; depends only on platform globals (`TextEncoder`, `atob`/`btoa`, `crypto.randomUUID`) and `@suaveplan/error` for typed errors.
## When to use this skill
- Implementing or debugging code that imports `@suaveplan/bytes`.
- The task touches: see package README.
- You need API guidance for this package's exports.
## Quick reference
```ts
import { /* see API Reference below */ } from "@suaveplan/bytes";
```
## Dependencies
Workspace dependencies: `@suaveplan/error`, `@suaveplan/types`
## Where to read more
This skill is intentionally short. The package's own documentation is the authoritative source — read these in order:
1. **`packages/foundation/bytes/README.md`** — overview, install, quick-start, module index
2. **`packages/foundation/bytes/src/**/*.md`** — co-located docs per implementation file (Purpose, Features, Basic + Advanced examples, full API reference, Implementation notes — all packages ship ≥200 words per module)
3. **`packages/foundation/bytes/package.json`** `exports` map — every subpath you can import from
When the user asks an API question, read the relevant co-located `.md` first; do not answer from training-data memory.
## See also
- `@suaveplan/error`
- `@suaveplan/types`
---
_This SKILL.md is auto-generated by `suaveplan-skills/scripts/generate-skills.ts` from the package's README. Do not edit by hand — re-run the generator after the README changes._

View File

@@ -0,0 +1,44 @@
---
name: cache
description: Use when implementing or debugging code that uses @suaveplan/cache: Type-safe caching abstraction: LRU/LFU/FIFO/TTL MemoryCache, NamespacedCache, CacheFactory, stale-while-revalidate, multi-get/set, lifecycle events, injectable clock, OTel instrumentation, and structured CACHE.
---
# @suaveplan/cache
Type-safe caching abstraction for Suaveplan. Provides `MemoryCache` with LRU/LFU/FIFO/TTL eviction, `NamespacedCache` for shared-storage isolation, a `CacheFactory` with module-augmentation-based provider registry, stale-while-revalidate, multi-get/set, injectable clock and event bus, OTel instrumentation, and structured `CACHE.*` error codes.
## When to use this skill
- Implementing or debugging code that imports `@suaveplan/cache`.
- The task touches: see package README.
- You need API guidance for this package's exports.
## Quick reference
```ts
import { /* see API Reference below */ } from "@suaveplan/cache";
```
## Dependencies
Workspace dependencies: `@suaveplan/error`, `@suaveplan/events`, `@suaveplan/types`
## Where to read more
This skill is intentionally short. The package's own documentation is the authoritative source — read these in order:
1. **`packages/database/cache/README.md`** — overview, install, quick-start, module index
2. **`packages/database/cache/src/**/*.md`** — co-located docs per implementation file (Purpose, Features, Basic + Advanced examples, full API reference, Implementation notes — all packages ship ≥200 words per module)
3. **`packages/database/cache/package.json`** `exports` map — every subpath you can import from
When the user asks an API question, read the relevant co-located `.md` first; do not answer from training-data memory.
## See also
- `@suaveplan/error`
- `@suaveplan/events`
- `@suaveplan/types`
---
_This SKILL.md is auto-generated by `suaveplan-skills/scripts/generate-skills.ts` from the package's README. Do not edit by hand — re-run the generator after the README changes._

View File

@@ -0,0 +1,44 @@
---
name: cli
description: Use when implementing or debugging code that uses @suaveplan/cli: Zero-dependency CLI toolkit: argument parser, command router, ANSI styling, output formatting, spinner, progress bar, interactive prompts.
---
# @suaveplan/cli
Enterprise-grade CLI framework for Node.js. Composable fluent API with subcommand routing, option parsing, OTel tracing, and a full set of terminal UI primitives.
## When to use this skill
- Implementing or debugging code that imports `@suaveplan/cli`.
- The task touches: see package README.
- You need API guidance for this package's exports.
## Quick reference
```ts
import { /* see API Reference below */ } from "@suaveplan/cli";
```
## Dependencies
Workspace dependencies: `@suaveplan/error`, `@suaveplan/readline`, `@suaveplan/types`
## Where to read more
This skill is intentionally short. The package's own documentation is the authoritative source — read these in order:
1. **`packages/tooling/cli/README.md`** — overview, install, quick-start, module index
2. **`packages/tooling/cli/src/**/*.md`** — co-located docs per implementation file (Purpose, Features, Basic + Advanced examples, full API reference, Implementation notes — all packages ship ≥200 words per module)
3. **`packages/tooling/cli/package.json`** `exports` map — every subpath you can import from
When the user asks an API question, read the relevant co-located `.md` first; do not answer from training-data memory.
## See also
- `@suaveplan/error`
- `@suaveplan/readline`
- `@suaveplan/types`
---
_This SKILL.md is auto-generated by `suaveplan-skills/scripts/generate-skills.ts` from the package's README. Do not edit by hand — re-run the generator after the README changes._

View File

@@ -0,0 +1,43 @@
---
name: compress-image
description: Use when implementing or debugging code that uses @suaveplan/compress-image: Runtime-agnostic image compression utilities: resize, quality reduction, format conversion, and metadata stripping via pluggable adapters.
---
# @suaveplan/compress-image
Runtime-agnostic image compression utilities with pluggable adapters for Node.js (Sharp) and testing (MemoryAdapter). Supports resize, quality reduction, format conversion (JPEG, PNG, WebP, AVIF), and metadata stripping.
## When to use this skill
- Implementing or debugging code that imports `@suaveplan/compress-image`.
- The task touches: see package README.
- You need API guidance for this package's exports.
## Quick reference
```ts
import { /* see API Reference below */ } from "@suaveplan/compress-image";
```
## Dependencies
Workspace dependencies: `@suaveplan/error`, `@suaveplan/types`
## Where to read more
This skill is intentionally short. The package's own documentation is the authoritative source — read these in order:
1. **`packages/image/compress-image/README.md`** — overview, install, quick-start, module index
2. **`packages/image/compress-image/src/**/*.md`** — co-located docs per implementation file (Purpose, Features, Basic + Advanced examples, full API reference, Implementation notes — all packages ship ≥200 words per module)
3. **`packages/image/compress-image/package.json`** `exports` map — every subpath you can import from
When the user asks an API question, read the relevant co-located `.md` first; do not answer from training-data memory.
## See also
- `@suaveplan/error`
- `@suaveplan/types`
---
_This SKILL.md is auto-generated by `suaveplan-skills/scripts/generate-skills.ts` from the package's README. Do not edit by hand — re-run the generator after the README changes._

View File

@@ -0,0 +1,43 @@
---
name: compress-svg
description: Use when implementing or debugging code that uses @suaveplan/compress-svg: SVG optimisation and minification: whitespace removal, redundant attribute stripping, path data precision reduction, element collapsing, ID mangling, and SVGO-compatible plugin pipeline.
---
# @suaveplan/compress-svg
SVG optimisation and minification for TypeScript. Pure TypeScript — no DOMParser, no external SVG library. Runs in Bun, Node.js, browsers, and edge runtimes.
## When to use this skill
- Implementing or debugging code that imports `@suaveplan/compress-svg`.
- The task touches: see package README.
- You need API guidance for this package's exports.
## Quick reference
```ts
import { /* see API Reference below */ } from "@suaveplan/compress-svg";
```
## Dependencies
Workspace dependencies: `@suaveplan/error`, `@suaveplan/types`
## Where to read more
This skill is intentionally short. The package's own documentation is the authoritative source — read these in order:
1. **`packages/docs/compress-svg/README.md`** — overview, install, quick-start, module index
2. **`packages/docs/compress-svg/src/**/*.md`** — co-located docs per implementation file (Purpose, Features, Basic + Advanced examples, full API reference, Implementation notes — all packages ship ≥200 words per module)
3. **`packages/docs/compress-svg/package.json`** `exports` map — every subpath you can import from
When the user asks an API question, read the relevant co-located `.md` first; do not answer from training-data memory.
## See also
- `@suaveplan/error`
- `@suaveplan/types`
---
_This SKILL.md is auto-generated by `suaveplan-skills/scripts/generate-skills.ts` from the package's README. Do not edit by hand — re-run the generator after the README changes._

View File

@@ -0,0 +1,45 @@
---
name: config
description: Use when implementing or debugging code that uses @suaveplan/config: Layered configuration management: defaults, file, object, env, CLI layers merged in priority order with deep-merge, Zod validation, OTel instrumentation, and event-bus watch support.
---
# @suaveplan/config
Layered configuration management for Suaveplan. Resolves an ordered set of typed layers (defaults, file, object, env, CLI) in priority order, deep-merges the results, applies per-environment overrides, validates via a caller-supplied Zod schema or custom function, and exposes an immutable `ConfigStore` with live reload and event-bus change propagation.
## When to use this skill
- Implementing or debugging code that imports `@suaveplan/config`.
- The task touches: see package README.
- You need API guidance for this package's exports.
## Quick reference
```ts
import { /* see API Reference below */ } from "@suaveplan/config";
```
## Dependencies
Workspace dependencies: `@suaveplan/env`, `@suaveplan/error`, `@suaveplan/events`, `@suaveplan/types`
## Where to read more
This skill is intentionally short. The package's own documentation is the authoritative source — read these in order:
1. **`packages/foundation/config/README.md`** — overview, install, quick-start, module index
2. **`packages/foundation/config/src/**/*.md`** — co-located docs per implementation file (Purpose, Features, Basic + Advanced examples, full API reference, Implementation notes — all packages ship ≥200 words per module)
3. **`packages/foundation/config/package.json`** `exports` map — every subpath you can import from
When the user asks an API question, read the relevant co-located `.md` first; do not answer from training-data memory.
## See also
- `@suaveplan/env`
- `@suaveplan/error`
- `@suaveplan/events`
- `@suaveplan/types`
---
_This SKILL.md is auto-generated by `suaveplan-skills/scripts/generate-skills.ts` from the package's README. Do not edit by hand — re-run the generator after the README changes._

View File

@@ -0,0 +1,45 @@
---
name: crdt
description: Use when implementing or debugging code that uses @suaveplan/crdt: Conflict-free replicated data types: G-Counter, PN-Counter, OR-Set, LWW-Register, MV-Register, 2P-Set, RGA, Vector Clock, and Sync Manager.
---
# @suaveplan/crdt
Conflict-free Replicated Data Types (CRDTs) for distributed systems. Provides eight composable CRDT primitives plus a vector-clock and a sync manager that coordinate delta exchange across heterogeneous replicas.
## When to use this skill
- Implementing or debugging code that imports `@suaveplan/crdt`.
- The task touches: see package README.
- You need API guidance for this package's exports.
## Quick reference
```ts
import { /* see API Reference below */ } from "@suaveplan/crdt";
```
## Dependencies
Workspace dependencies: `@suaveplan/diff`, `@suaveplan/error`, `@suaveplan/events`, `@suaveplan/types`
## Where to read more
This skill is intentionally short. The package's own documentation is the authoritative source — read these in order:
1. **`packages/foundation/crdt/README.md`** — overview, install, quick-start, module index
2. **`packages/foundation/crdt/src/**/*.md`** — co-located docs per implementation file (Purpose, Features, Basic + Advanced examples, full API reference, Implementation notes — all packages ship ≥200 words per module)
3. **`packages/foundation/crdt/package.json`** `exports` map — every subpath you can import from
When the user asks an API question, read the relevant co-located `.md` first; do not answer from training-data memory.
## See also
- `@suaveplan/diff`
- `@suaveplan/error`
- `@suaveplan/events`
- `@suaveplan/types`
---
_This SKILL.md is auto-generated by `suaveplan-skills/scripts/generate-skills.ts` from the package's README. Do not edit by hand — re-run the generator after the README changes._

Some files were not shown because too many files have changed in this diff Show More