first commit
This commit is contained in:
51
docs/devtools/README.md
Normal file
51
docs/devtools/README.md
Normal 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
3
docs/devtools/_intro.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# `devtools`
|
||||
|
||||
Developer tooling, documentation generators, Docker helpers, and build utilities.
|
||||
13
docs/devtools/docker/README.md
Normal file
13
docs/devtools/docker/README.md
Normal 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 -->
|
||||
1
docs/devtools/docker/_intro.md
Normal file
1
docs/devtools/docker/_intro.md
Normal file
@@ -0,0 +1 @@
|
||||
Docker helpers and GPU runtime configuration.
|
||||
5
docs/devtools/docker/docker-nvidia/README.md
Normal file
5
docs/devtools/docker/docker-nvidia/README.md
Normal 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
|
||||
16
docs/devtools/docs/README.md
Normal file
16
docs/devtools/docs/README.md
Normal 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 -->
|
||||
1
docs/devtools/docs/_intro.md
Normal file
1
docs/devtools/docs/_intro.md
Normal file
@@ -0,0 +1 @@
|
||||
Documentation utilities, SVG compression, PDF handling, and sitemap generation.
|
||||
116
docs/devtools/docs/compress-svg/README.md
Normal file
116
docs/devtools/docs/compress-svg/README.md
Normal file
File diff suppressed because one or more lines are too long
@@ -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.
|
||||
87
docs/devtools/docs/compress-svg/src/parser/svg/svg.md
Normal file
87
docs/devtools/docs/compress-svg/src/parser/svg/svg.md
Normal 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 (`&`, `<`, `>`, `"`, `'`, 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.
|
||||
@@ -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).
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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).
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
102
docs/devtools/docs/compress-svg/src/types/common.md
Normal file
102
docs/devtools/docs/compress-svg/src/types/common.md
Normal 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.
|
||||
76
docs/devtools/docs/docs/README.md
Normal file
76
docs/devtools/docs/docs/README.md
Normal 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.
|
||||
|
||||
86
docs/devtools/docs/docs/src/discovery/discover.md
Normal file
86
docs/devtools/docs/docs/src/discovery/discover.md
Normal 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.
|
||||
102
docs/devtools/docs/docs/src/errors/errors.md
Normal file
102
docs/devtools/docs/docs/src/errors/errors.md
Normal 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.
|
||||
78
docs/devtools/docs/docs/src/graph/build-graph.md
Normal file
78
docs/devtools/docs/docs/src/graph/build-graph.md
Normal 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.
|
||||
82
docs/devtools/docs/docs/src/graph/find-orphans.md
Normal file
82
docs/devtools/docs/docs/src/graph/find-orphans.md
Normal 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.
|
||||
88
docs/devtools/docs/docs/src/hierarchy/build-hierarchy.md
Normal file
88
docs/devtools/docs/docs/src/hierarchy/build-hierarchy.md
Normal 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.
|
||||
89
docs/devtools/docs/docs/src/observability/observability.md
Normal file
89
docs/devtools/docs/docs/src/observability/observability.md
Normal 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.
|
||||
88
docs/devtools/docs/docs/src/parser/parse.md
Normal file
88
docs/devtools/docs/docs/src/parser/parse.md
Normal 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.
|
||||
91
docs/devtools/docs/docs/src/search/search.md
Normal file
91
docs/devtools/docs/docs/src/search/search.md
Normal 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.
|
||||
107
docs/devtools/docs/docs/src/types/docs.types.md
Normal file
107
docs/devtools/docs/docs/src/types/docs.types.md
Normal 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.
|
||||
133
docs/devtools/docs/pdf/README.md
Normal file
133
docs/devtools/docs/pdf/README.md
Normal 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.
|
||||
|
||||
93
docs/devtools/docs/pdf/src/colors/colors.md
Normal file
93
docs/devtools/docs/pdf/src/colors/colors.md
Normal 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()`.
|
||||
109
docs/devtools/docs/pdf/src/content-stream/content-stream.md
Normal file
109
docs/devtools/docs/pdf/src/content-stream/content-stream.md
Normal 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.
|
||||
@@ -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`.
|
||||
120
docs/devtools/docs/pdf/src/document-builder/document-builder.md
Normal file
120
docs/devtools/docs/pdf/src/document-builder/document-builder.md
Normal 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.
|
||||
106
docs/devtools/docs/pdf/src/error-codes/error-codes.md
Normal file
106
docs/devtools/docs/pdf/src/error-codes/error-codes.md
Normal 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.
|
||||
108
docs/devtools/docs/pdf/src/image-element/image-element.md
Normal file
108
docs/devtools/docs/pdf/src/image-element/image-element.md
Normal 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()`.
|
||||
81
docs/devtools/docs/pdf/src/image-registry/image-registry.md
Normal file
81
docs/devtools/docs/pdf/src/image-registry/image-registry.md
Normal 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.
|
||||
97
docs/devtools/docs/pdf/src/layout-engine/layout-engine.md
Normal file
97
docs/devtools/docs/pdf/src/layout-engine/layout-engine.md
Normal 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.
|
||||
143
docs/devtools/docs/pdf/src/pdf-writer/pdf-writer.md
Normal file
143
docs/devtools/docs/pdf/src/pdf-writer/pdf-writer.md
Normal 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.
|
||||
@@ -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.
|
||||
120
docs/devtools/docs/pdf/src/renderer/renderer.md
Normal file
120
docs/devtools/docs/pdf/src/renderer/renderer.md
Normal 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.
|
||||
104
docs/devtools/docs/pdf/src/schemas/schemas.md
Normal file
104
docs/devtools/docs/pdf/src/schemas/schemas.md
Normal 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.
|
||||
86
docs/devtools/docs/pdf/src/style-resolver/style-resolver.md
Normal file
86
docs/devtools/docs/pdf/src/style-resolver/style-resolver.md
Normal 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.
|
||||
124
docs/devtools/docs/pdf/src/table-element/table-element.md
Normal file
124
docs/devtools/docs/pdf/src/table-element/table-element.md
Normal 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.
|
||||
104
docs/devtools/docs/pdf/src/telemetry/telemetry.md
Normal file
104
docs/devtools/docs/pdf/src/telemetry/telemetry.md
Normal 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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
113
docs/devtools/docs/pdf/src/text-element/text-element.md
Normal file
113
docs/devtools/docs/pdf/src/text-element/text-element.md
Normal 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`.
|
||||
86
docs/devtools/docs/pdf/src/types/page-size.md
Normal file
86
docs/devtools/docs/pdf/src/types/page-size.md
Normal 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.
|
||||
91
docs/devtools/docs/pdf/src/units/units.md
Normal file
91
docs/devtools/docs/pdf/src/units/units.md
Normal 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.
|
||||
69
docs/devtools/docs/sitemap/README.md
Normal file
69
docs/devtools/docs/sitemap/README.md
Normal 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";
|
||||
```
|
||||
152
docs/devtools/docs/sitemap/src/core/builder/sitemap-builder.md
Normal file
152
docs/devtools/docs/sitemap/src/core/builder/sitemap-builder.md
Normal 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.
|
||||
@@ -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.
|
||||
127
docs/devtools/docs/sitemap/src/core/parser/sitemap-parser.md
Normal file
127
docs/devtools/docs/sitemap/src/core/parser/sitemap-parser.md
Normal 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.
|
||||
103
docs/devtools/docs/sitemap/src/errors/sitemap-error.md
Normal file
103
docs/devtools/docs/sitemap/src/errors/sitemap-error.md
Normal 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.
|
||||
113
docs/devtools/docs/sitemap/src/schemas/sitemap.schema.md
Normal file
113
docs/devtools/docs/sitemap/src/schemas/sitemap.schema.md
Normal 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`.
|
||||
97
docs/devtools/docs/sitemap/src/utils/url-utils.md
Normal file
97
docs/devtools/docs/sitemap/src/utils/url-utils.md
Normal 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 & b < 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 `&` → `&`, `<` → `<`, `>` → `>`, `"` → `"`,
|
||||
`'` → `'`. 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.
|
||||
28
docs/devtools/tooling/README.md
Normal file
28
docs/devtools/tooling/README.md
Normal 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 -->
|
||||
1
docs/devtools/tooling/_intro.md
Normal file
1
docs/devtools/tooling/_intro.md
Normal file
@@ -0,0 +1 @@
|
||||
CLI frameworks, installers, and build tooling.
|
||||
202
docs/devtools/tooling/cli/README.md
Normal file
202
docs/devtools/tooling/cli/README.md
Normal file
File diff suppressed because one or more lines are too long
120
docs/devtools/tooling/cli/src/app/app.md
Normal file
120
docs/devtools/tooling/cli/src/app/app.md
Normal 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.
|
||||
122
docs/devtools/tooling/cli/src/command/command.md
Normal file
122
docs/devtools/tooling/cli/src/command/command.md
Normal 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.
|
||||
171
docs/devtools/tooling/cli/src/errors/errors.md
Normal file
171
docs/devtools/tooling/cli/src/errors/errors.md
Normal 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.
|
||||
93
docs/devtools/tooling/cli/src/output/format/format.md
Normal file
93
docs/devtools/tooling/cli/src/output/format/format.md
Normal 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.
|
||||
81
docs/devtools/tooling/cli/src/output/table/table.md
Normal file
81
docs/devtools/tooling/cli/src/output/table/table.md
Normal 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.
|
||||
92
docs/devtools/tooling/cli/src/parser/parser.md
Normal file
92
docs/devtools/tooling/cli/src/parser/parser.md
Normal 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.
|
||||
92
docs/devtools/tooling/cli/src/progress/progress.md
Normal file
92
docs/devtools/tooling/cli/src/progress/progress.md
Normal 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.
|
||||
95
docs/devtools/tooling/cli/src/prompt/prompt.md
Normal file
95
docs/devtools/tooling/cli/src/prompt/prompt.md
Normal 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.
|
||||
99
docs/devtools/tooling/cli/src/spinner/spinner.md
Normal file
99
docs/devtools/tooling/cli/src/spinner/spinner.md
Normal 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.
|
||||
82
docs/devtools/tooling/cli/src/style/style.md
Normal file
82
docs/devtools/tooling/cli/src/style/style.md
Normal 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.
|
||||
151
docs/devtools/tooling/electrobun/README.md
Normal file
151
docs/devtools/tooling/electrobun/README.md
Normal 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.
|
||||
|
||||
@@ -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.
|
||||
100
docs/devtools/tooling/electrobun/src/cli/cli.md
Normal file
100
docs/devtools/tooling/electrobun/src/cli/cli.md
Normal 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.
|
||||
82
docs/devtools/tooling/electrobun/src/config/config.md
Normal file
82
docs/devtools/tooling/electrobun/src/config/config.md
Normal 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.
|
||||
@@ -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.
|
||||
104
docs/devtools/tooling/electrobun/src/fs/fs.md
Normal file
104
docs/devtools/tooling/electrobun/src/fs/fs.md
Normal 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.
|
||||
96
docs/devtools/tooling/electrobun/src/generator/generator.md
Normal file
96
docs/devtools/tooling/electrobun/src/generator/generator.md
Normal 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.
|
||||
91
docs/devtools/tooling/electrobun/src/icons/icons.md
Normal file
91
docs/devtools/tooling/electrobun/src/icons/icons.md
Normal 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.
|
||||
96
docs/devtools/tooling/electrobun/src/prompts/prompts.md
Normal file
96
docs/devtools/tooling/electrobun/src/prompts/prompts.md
Normal 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.
|
||||
97
docs/devtools/tooling/electrobun/src/runner/runner.md
Normal file
97
docs/devtools/tooling/electrobun/src/runner/runner.md
Normal 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.
|
||||
104
docs/devtools/tooling/electrobun/src/schemas/schemas.md
Normal file
104
docs/devtools/tooling/electrobun/src/schemas/schemas.md
Normal 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`.
|
||||
93
docs/devtools/tooling/electrobun/src/telemetry/telemetry.md
Normal file
93
docs/devtools/tooling/electrobun/src/telemetry/telemetry.md
Normal 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.
|
||||
85
docs/devtools/tooling/electrobun/src/templates/features.md
Normal file
85
docs/devtools/tooling/electrobun/src/templates/features.md
Normal 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`.
|
||||
107
docs/devtools/tooling/electrobun/src/templates/templates.md
Normal file
107
docs/devtools/tooling/electrobun/src/templates/templates.md
Normal 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.
|
||||
115
docs/devtools/tooling/electrobun/src/types/electrobun.types.md
Normal file
115
docs/devtools/tooling/electrobun/src/types/electrobun.types.md
Normal 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.
|
||||
94
docs/devtools/tooling/install-agent-skills/README.md
Normal file
94
docs/devtools/tooling/install-agent-skills/README.md
Normal 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
|
||||
|
||||
@@ -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._
|
||||
@@ -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._
|
||||
@@ -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._
|
||||
@@ -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._
|
||||
@@ -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._
|
||||
@@ -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._
|
||||
@@ -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._
|
||||
44
docs/devtools/tooling/install-agent-skills/assets/skills/cache/SKILL.md
vendored
Normal file
44
docs/devtools/tooling/install-agent-skills/assets/skills/cache/SKILL.md
vendored
Normal 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._
|
||||
@@ -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._
|
||||
@@ -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._
|
||||
@@ -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._
|
||||
@@ -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._
|
||||
@@ -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
Reference in New Issue
Block a user