first commit
This commit is contained in:
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.
|
||||
Reference in New Issue
Block a user