Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View File

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

View File

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

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,174 @@
{
"name": "@suaveplan/compress-svg",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "SVG optimisation and minification: whitespace removal, redundant attribute stripping, path data precision reduction, element collapsing, ID mangling, and SVGO-compatible plugin pipeline",
"license": "MIT",
"author": "Suaveplan Team",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
"./package.json": "./package.json",
".": {
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"require": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"./core/compress-svg": {
"import": {
"types": "./dist/core/compress-svg/index.d.ts",
"default": "./dist/core/compress-svg/index.js"
},
"require": {
"types": "./dist/core/compress-svg/index.d.ts",
"default": "./dist/core/compress-svg/index.js"
}
},
"./parser/svg": {
"import": {
"types": "./dist/parser/svg/index.d.ts",
"default": "./dist/parser/svg/index.js"
},
"require": {
"types": "./dist/parser/svg/index.d.ts",
"default": "./dist/parser/svg/index.js"
}
},
"./plugins/remove-comments": {
"import": {
"types": "./dist/plugins/remove-comments/index.d.ts",
"default": "./dist/plugins/remove-comments/index.js"
},
"require": {
"types": "./dist/plugins/remove-comments/index.d.ts",
"default": "./dist/plugins/remove-comments/index.js"
}
},
"./plugins/remove-metadata": {
"import": {
"types": "./dist/plugins/remove-metadata/index.d.ts",
"default": "./dist/plugins/remove-metadata/index.js"
},
"require": {
"types": "./dist/plugins/remove-metadata/index.d.ts",
"default": "./dist/plugins/remove-metadata/index.js"
}
},
"./plugins/remove-doctype": {
"import": {
"types": "./dist/plugins/remove-doctype/index.d.ts",
"default": "./dist/plugins/remove-doctype/index.js"
},
"require": {
"types": "./dist/plugins/remove-doctype/index.d.ts",
"default": "./dist/plugins/remove-doctype/index.js"
}
},
"./plugins/collapse-groups": {
"import": {
"types": "./dist/plugins/collapse-groups/index.d.ts",
"default": "./dist/plugins/collapse-groups/index.js"
},
"require": {
"types": "./dist/plugins/collapse-groups/index.d.ts",
"default": "./dist/plugins/collapse-groups/index.js"
}
},
"./plugins/cleanup-attrs": {
"import": {
"types": "./dist/plugins/cleanup-attrs/index.d.ts",
"default": "./dist/plugins/cleanup-attrs/index.js"
},
"require": {
"types": "./dist/plugins/cleanup-attrs/index.d.ts",
"default": "./dist/plugins/cleanup-attrs/index.js"
}
},
"./plugins/convert-path-data": {
"import": {
"types": "./dist/plugins/convert-path-data/index.d.ts",
"default": "./dist/plugins/convert-path-data/index.js"
},
"require": {
"types": "./dist/plugins/convert-path-data/index.d.ts",
"default": "./dist/plugins/convert-path-data/index.js"
}
},
"./plugins/convert-transforms": {
"import": {
"types": "./dist/plugins/convert-transforms/index.d.ts",
"default": "./dist/plugins/convert-transforms/index.js"
},
"require": {
"types": "./dist/plugins/convert-transforms/index.d.ts",
"default": "./dist/plugins/convert-transforms/index.js"
}
},
"./plugins/mangle-ids": {
"import": {
"types": "./dist/plugins/mangle-ids/index.d.ts",
"default": "./dist/plugins/mangle-ids/index.js"
},
"require": {
"types": "./dist/plugins/mangle-ids/index.d.ts",
"default": "./dist/plugins/mangle-ids/index.js"
}
},
"./presets": {
"import": {
"types": "./dist/presets/index.d.ts",
"default": "./dist/presets/index.js"
},
"require": {
"types": "./dist/presets/index.d.ts",
"default": "./dist/presets/index.js"
}
}
},
"scripts": {
"build": "vite build",
"test": "vitest run --passWithNoTests",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage --passWithNoTests",
"lint": "bunx biome check src/",
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
"format": "bunx biome format --write --max-diagnostics 500 src/",
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
},
"dependencies": {
"@opentelemetry/api": "catalog:observability",
"@suaveplan/error": "workspace:^",
"@suaveplan/types": "workspace:^",
"tslib": "catalog:build",
"zod": "catalog:forms"
},
"devDependencies": {
"@suaveplan/testing": "workspace:^",
"@types/node": "catalog:",
"@vitest/coverage-istanbul": "catalog:testing",
"fast-check": "catalog:testing",
"mitata": "catalog:testing",
"typescript": "catalog:",
"vite": "catalog:build",
"vite-plugin-dts": "catalog:build",
"vitest": "catalog:testing"
},
"publishConfig": {
"registry": "https://npmjs.fq.io/",
"access": "public"
},
"files": [
"dist",
"src",
"README.md"
]
}

View File

@@ -0,0 +1,20 @@
export const DEFAULT_FLOAT_PRECISION = 3;
export const DEFAULT_TRANSFORM_PRECISION = 5;
export const MAX_MULTIPASS_ITERATIONS = 10;
export const SVG_NAMESPACE = "http://www.w3.org/2000/svg";
export const XLINK_NAMESPACE = "http://www.w3.org/1999/xlink";
export const DEFAULT_ATTRS: Record<string, string> = {
fill: "black",
"fill-opacity": "1",
"stroke-opacity": "1",
"stroke-width": "1",
"stroke-linecap": "butt",
"stroke-linejoin": "miter",
"stroke-miterlimit": "4",
opacity: "1",
display: "inline",
visibility: "visible",
overflow: "visible",
};

View File

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

View File

@@ -0,0 +1,201 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
SVG_COMPRESS_CODES,
SvgCompressError,
} from "../../error-codes/error-codes.js";
import { parseSvg } from "../../parser/svg/svg.js";
import { convertPathData } from "../../plugins/convert-path-data/convert-path-data.js";
import {
PRESET_AGGRESSIVE,
PRESET_DEFAULT,
PRESET_SAFE,
} from "../../presets/index.js";
import { compressSvg } from "./compress-svg.js";
const BASIC_SVG =
'<svg xmlns="http://www.w3.org/2000/svg"><circle cx="50" cy="50" r="40"/></svg>';
describe("compressSvg", () => {
it("returns a CompressResult with data, sizes, ratio, passCount", {
timeout: 5000,
}, () => {
const result = compressSvg(BASIC_SVG);
expect(result.data).toBeTruthy();
expect(result.originalSize).toBe(
new TextEncoder().encode(BASIC_SVG).byteLength
);
expect(result.compressedSize).toBeGreaterThan(0);
expect(result.ratio).toBeGreaterThan(0);
expect(result.passCount).toBeGreaterThanOrEqual(1);
});
it("compressed output is valid SVG (parseable)", { timeout: 5000 }, () => {
const result = compressSvg(BASIC_SVG);
expect(() => parseSvg(result.data)).not.toThrow();
});
it("removes comments with PRESET_DEFAULT", { timeout: 5000 }, () => {
const svg = '<svg><!-- hello --><circle r="5"/></svg>';
const result = compressSvg(svg, { plugins: PRESET_DEFAULT });
expect(result.data).not.toContain("<!--");
});
it("removes metadata with PRESET_DEFAULT", { timeout: 5000 }, () => {
const svg = '<svg><metadata><rdf:RDF/></metadata><circle r="5"/></svg>';
const result = compressSvg(svg, { plugins: PRESET_DEFAULT });
expect(result.data).not.toContain("<metadata>");
});
it("removes doctype with PRESET_DEFAULT", { timeout: 5000 }, () => {
const svg =
'<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg><circle r="5"/></svg>';
const result = compressSvg(svg, { plugins: PRESET_DEFAULT });
expect(result.data).not.toContain("DOCTYPE");
});
it("collapses single-child groups with PRESET_DEFAULT", {
timeout: 5000,
}, () => {
const svg = '<svg><g><rect width="10" height="10"/></g></svg>';
const result = compressSvg(svg, { plugins: PRESET_DEFAULT });
expect(result.data).not.toContain("<g>");
expect(result.data).toContain("<rect");
});
it("uses PRESET_SAFE which does not include convertTransforms", {
timeout: 5000,
}, () => {
const names = PRESET_SAFE.map((p) => p.name);
expect(names).not.toContain("convertTransforms");
expect(names).not.toContain("mangleIds");
});
it("uses PRESET_AGGRESSIVE which includes mangleIds", {
timeout: 5000,
}, () => {
const names = PRESET_AGGRESSIVE.map((p) => p.name);
expect(names).toContain("mangleIds");
expect(names).toContain("convertTransforms");
});
it("multipass runs until stable", { timeout: 5000 }, () => {
const svg = "<svg><!-- a --><!-- b --><g><g><rect/></g></g></svg>";
const result = compressSvg(svg, {
multipass: true,
plugins: PRESET_DEFAULT,
});
expect(result.passCount).toBeGreaterThanOrEqual(1);
expect(result.data).not.toContain("<!--");
});
it("multipass: false runs exactly one pass", { timeout: 5000 }, () => {
const result = compressSvg(BASIC_SVG, { multipass: false });
expect(result.passCount).toBe(1);
});
it("custom plugin is called", { timeout: 5000 }, () => {
let called = false;
const plugin = {
name: "spy",
apply: (node: unknown) => {
called = true;
return node as never;
},
};
compressSvg(BASIC_SVG, { plugins: [plugin] });
expect(called).toBe(true);
});
it("ratio is compressedSize / originalSize", { timeout: 5000 }, () => {
const result = compressSvg(BASIC_SVG);
expect(result.ratio).toBeCloseTo(
result.compressedSize / result.originalSize,
5
);
});
it("throws PARSE_FAILED on invalid SVG", { timeout: 5000 }, () => {
expect(() => compressSvg("<not valid xml")).toThrow(SvgCompressError);
try {
compressSvg("<not valid xml");
} catch (e) {
expect((e as SvgCompressError).code).toBe(
SVG_COMPRESS_CODES.PARSE_FAILED
);
}
});
it("throws PLUGIN_FAILED when a plugin throws", { timeout: 5000 }, () => {
const badPlugin = {
name: "thrower",
apply: () => {
throw new Error("plugin crashed");
},
};
expect(() => compressSvg(BASIC_SVG, { plugins: [badPlugin] })).toThrow(
SvgCompressError
);
try {
compressSvg(BASIC_SVG, { plugins: [badPlugin] });
} catch (e) {
expect((e as SvgCompressError).code).toBe(
SVG_COMPRESS_CODES.PLUGIN_FAILED
);
}
});
it("uses floatPrecision option for path data", { timeout: 5000 }, () => {
const svg = '<svg><path d="M 1.123456 2.654321"/></svg>';
const result = compressSvg(svg, {
plugins: [convertPathData],
floatPrecision: 2,
});
expect(result.data).toContain("1.12");
});
it("returns ratio <= 1 for typical SVG with comments removed", {
timeout: 5000,
}, () => {
const svg =
'<svg xmlns="http://www.w3.org/2000/svg"><!-- lots of comments -->' +
"<!-- more text that makes it larger -->" +
'<circle cx="50" cy="50" r="40"/></svg>';
const result = compressSvg(svg, { plugins: PRESET_DEFAULT });
expect(result.ratio).toBeLessThanOrEqual(1);
});
it("throws PLUGIN_FAILED when a plugin throws a non-Error value", {
timeout: 5000,
}, () => {
const badPlugin = {
name: "strThrower",
apply: () => {
throw "string error";
},
};
try {
compressSvg(BASIC_SVG, { plugins: [badPlugin] });
} catch (e) {
expect((e as SvgCompressError).code).toBe(
SVG_COMPRESS_CODES.PLUGIN_FAILED
);
expect((e as SvgCompressError).message).toContain("string error");
}
});
it("throws INVALID_SVG when plugin removes the root element", {
timeout: 5000,
}, () => {
const removeAll = { name: "removeAll", apply: () => null };
expect(() => compressSvg(BASIC_SVG, { plugins: [removeAll] })).toThrow(
SvgCompressError
);
try {
compressSvg(BASIC_SVG, { plugins: [removeAll] });
} catch (e) {
expect((e as SvgCompressError).code).toBe(
SVG_COMPRESS_CODES.INVALID_SVG
);
}
});
});

View File

@@ -0,0 +1,122 @@
import metrics from "@opentelemetry/api";
import {
DEFAULT_FLOAT_PRECISION,
DEFAULT_TRANSFORM_PRECISION,
MAX_MULTIPASS_ITERATIONS,
} from "../../constants/index.js";
import {
SVG_COMPRESS_CODES,
SvgCompressError,
} from "../../error-codes/error-codes.js";
import { parseSvg, serializeSvg } from "../../parser/svg/svg.js";
import { PRESET_DEFAULT } from "../../presets/index.js";
import type {
CompressOptions,
CompressResult,
SvgNode,
SvgPlugin,
SvgPluginOptions,
} from "../../types/common.js";
const meter = metrics.metrics.getMeter("@suaveplan/compress-svg");
const compressTotal = meter.createCounter("svg_compress_total");
const compressDuration = meter.createHistogram("svg_compress_duration_ms");
const compressRatio = meter.createHistogram("svg_compress_ratio");
function applyPlugins(
node: SvgNode,
plugins: SvgPlugin[],
options: SvgPluginOptions
): SvgNode | null {
let current: SvgNode | null = node;
for (const plugin of plugins) {
if (current === null) return null;
try {
current = plugin.apply(current, options);
} catch (err) {
throw new SvgCompressError(
SVG_COMPRESS_CODES.PLUGIN_FAILED,
`Plugin "${plugin.name}" threw an error: ${err instanceof Error ? err.message : String(err)}`,
{ cause: err }
);
}
}
if (current === null) return null;
if (current.type === "element" && current.children) {
const newChildren: SvgNode[] = [];
for (const child of current.children) {
const result = applyPlugins(child, plugins, options);
if (result !== null) newChildren.push(result);
}
return { ...current, children: newChildren };
}
return current;
}
export function compressSvg(
svg: string,
options?: CompressOptions
): CompressResult {
const start = Date.now();
const plugins = options?.plugins ?? PRESET_DEFAULT;
const floatPrecision = options?.floatPrecision ?? DEFAULT_FLOAT_PRECISION;
const transformPrecision =
options?.transformPrecision ?? DEFAULT_TRANSFORM_PRECISION;
const multipass = options?.multipass !== false;
const encoder = new TextEncoder();
const originalSize = encoder.encode(svg).byteLength;
const pluginOptions: SvgPluginOptions = {
floatPrecision,
transformPrecision,
};
let current = parseSvg(svg);
let passCount = 0;
let serialized = "";
const maxPasses = multipass ? MAX_MULTIPASS_ITERATIONS : 1;
let prevSize = -1;
for (let pass = 0; pass < maxPasses; pass++) {
passCount++;
const result = applyPlugins(current, plugins, pluginOptions);
if (result === null) {
throw new SvgCompressError(
SVG_COMPRESS_CODES.INVALID_SVG,
"All nodes were removed by plugins"
);
}
current = result;
serialized = serializeSvg(current);
const size = encoder.encode(serialized).byteLength;
if (multipass && size === prevSize) break;
prevSize = size;
}
const compressedSize = encoder.encode(serialized).byteLength;
const ratio =
originalSize > 0
? compressedSize / originalSize
: /* istanbul ignore next */ 1;
const duration = Date.now() - start;
compressTotal.add(1);
compressDuration.record(duration);
compressRatio.record(ratio);
return {
data: serialized,
originalSize,
compressedSize,
ratio,
passCount,
};
}

View File

@@ -0,0 +1 @@
export type { CompressOptions, CompressResult } from "../../types/common.js";

View File

@@ -0,0 +1 @@
export { compressSvg } from "./compress-svg.js";

View File

@@ -0,0 +1,78 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { SVG_COMPRESS_CODES, SvgCompressError } from "./error-codes.js";
describe("SVG_COMPRESS_CODES", () => {
it("exposes all three codes", { timeout: 5000 }, () => {
expect(SVG_COMPRESS_CODES.PARSE_FAILED).toBe(
"SVG_COMPRESS.PARSE_FAILED"
);
expect(SVG_COMPRESS_CODES.INVALID_SVG).toBe("SVG_COMPRESS.INVALID_SVG");
expect(SVG_COMPRESS_CODES.PLUGIN_FAILED).toBe(
"SVG_COMPRESS.PLUGIN_FAILED"
);
});
it("is frozen", { timeout: 5000 }, () => {
expect(Object.isFrozen(SVG_COMPRESS_CODES)).toBe(true);
});
});
describe("SvgCompressError", () => {
it("has correct name", { timeout: 5000 }, () => {
const err = new SvgCompressError(
SVG_COMPRESS_CODES.PARSE_FAILED,
"test"
);
expect(err.name).toBe("SvgCompressError");
});
it("sets code and message", { timeout: 5000 }, () => {
const err = new SvgCompressError(
SVG_COMPRESS_CODES.PLUGIN_FAILED,
"plugin error"
);
expect(err.code).toBe(SVG_COMPRESS_CODES.PLUGIN_FAILED);
expect(err.message).toBe("plugin error");
});
it("is instanceof SvgCompressError and Error", { timeout: 5000 }, () => {
const err = new SvgCompressError(
SVG_COMPRESS_CODES.INVALID_SVG,
"bad svg"
);
expect(err instanceof SvgCompressError).toBe(true);
expect(err instanceof Error).toBe(true);
});
it("accepts cause as Error", { timeout: 5000 }, () => {
const cause = new Error("original");
const err = new SvgCompressError(
SVG_COMPRESS_CODES.PARSE_FAILED,
"wrapper",
{ cause }
);
expect(err.cause).toBe(cause);
});
it("ignores non-Error cause", { timeout: 5000 }, () => {
const err = new SvgCompressError(
SVG_COMPRESS_CODES.PARSE_FAILED,
"wrapper",
{
cause: "string cause",
}
);
expect(err.cause).toBeUndefined();
});
it("accepts context metadata", { timeout: 5000 }, () => {
const err = new SvgCompressError(
SVG_COMPRESS_CODES.PARSE_FAILED,
"test",
{
context: { line: 42 },
}
);
expect(err.context).toEqual({ line: 42 });
});
});

View File

@@ -0,0 +1,31 @@
import { defineCodes, SuaveplanError } from "@suaveplan/error";
export const SVG_COMPRESS_CODES = defineCodes("SVG_COMPRESS", [
"PARSE_FAILED",
"INVALID_SVG",
"PLUGIN_FAILED",
] as const);
export type SvgCompressErrorCode =
(typeof SVG_COMPRESS_CODES)[keyof typeof SVG_COMPRESS_CODES];
export class SvgCompressError extends SuaveplanError<SvgCompressErrorCode> {
public constructor(
code: SvgCompressErrorCode,
message: string,
options?: { cause?: unknown; context?: Record<string, unknown> }
) {
const superOptions: {
cause?: Error;
context?: Record<string, unknown>;
} = {};
if (options?.cause instanceof Error) {
superOptions.cause = options.cause;
}
if (options?.context !== undefined) {
superOptions.context = options.context;
}
super(code, message, superOptions);
this.name = "SvgCompressError";
}
}

View File

@@ -0,0 +1,5 @@
export type { SvgCompressErrorCode } from "./error-codes.js";
export {
SVG_COMPRESS_CODES,
SvgCompressError,
} from "./error-codes.js";

View File

@@ -0,0 +1,13 @@
export * from "./core/compress-svg/index.js";
export * from "./error-codes/index.js";
export { parseSvg, serializeSvg } from "./parser/svg/index.js";
export { cleanupAttrs } from "./plugins/cleanup-attrs/index.js";
export { collapseGroups } from "./plugins/collapse-groups/index.js";
export { convertPathData } from "./plugins/convert-path-data/index.js";
export { convertTransforms } from "./plugins/convert-transforms/index.js";
export { createMangleIds, mangleIds } from "./plugins/mangle-ids/index.js";
export { removeComments } from "./plugins/remove-comments/index.js";
export { removeDoctype } from "./plugins/remove-doctype/index.js";
export { removeMetadata } from "./plugins/remove-metadata/index.js";
export * from "./presets/index.js";
export * from "./types/common.js";

View File

@@ -0,0 +1 @@
export { parseSvg, serializeSvg } from "./svg.js";

View File

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

View File

@@ -0,0 +1,327 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
SVG_COMPRESS_CODES,
SvgCompressError,
} from "../../error-codes/error-codes.js";
import { parseSvg, serializeSvg } from "./svg.js";
describe("parseSvg", () => {
it("parses a minimal SVG element", { timeout: 5000 }, () => {
const node = parseSvg('<svg xmlns="http://www.w3.org/2000/svg"></svg>');
expect(node.type).toBe("element");
expect(node.name).toBe("svg");
expect(node.attributes).toEqual({
xmlns: "http://www.w3.org/2000/svg",
});
expect(node.children).toEqual([]);
});
it("parses self-closing elements", { timeout: 5000 }, () => {
const node = parseSvg('<svg><circle cx="50" cy="50" r="40"/></svg>');
expect(node.children).toHaveLength(1);
const circle = node.children![0]!;
expect(circle.type).toBe("element");
expect(circle.name).toBe("circle");
expect(circle.attributes).toEqual({ cx: "50", cy: "50", r: "40" });
expect(circle.children).toEqual([]);
});
it("parses nested elements", { timeout: 5000 }, () => {
const svg =
'<svg><g><rect x="0" y="0" width="100" height="100"/></g></svg>';
const node = parseSvg(svg);
const g = node.children![0]!;
expect(g.name).toBe("g");
const rect = g.children![0]!;
expect(rect.name).toBe("rect");
expect(rect.attributes?.x).toBe("0");
});
it("parses text nodes", { timeout: 5000 }, () => {
const node = parseSvg("<svg><text>Hello world</text></svg>");
const text = node.children![0]!;
expect(text.name).toBe("text");
const textChild = text.children![0]!;
expect(textChild.type).toBe("text");
expect(textChild.value).toBe("Hello world");
});
it("parses XML comments", { timeout: 5000 }, () => {
const node = parseSvg("<svg><!-- a comment --></svg>");
const comment = node.children![0]!;
expect(comment.type).toBe("comment");
expect(comment.value).toBe(" a comment ");
});
it("parses CDATA sections", { timeout: 5000 }, () => {
const node = parseSvg("<svg><![CDATA[raw content]]></svg>");
const cdata = node.children![0]!;
expect(cdata.type).toBe("cdata");
expect(cdata.value).toBe("raw content");
});
it("parses doctype declarations", { timeout: 5000 }, () => {
const input =
'<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg></svg>';
const node = parseSvg(input);
expect(node.type).toBe("element");
expect(node.name).toBe("svg");
});
it("parses XML declaration", { timeout: 5000 }, () => {
const node = parseSvg(
'<?xml version="1.0" encoding="UTF-8"?><svg></svg>'
);
expect(node.name).toBe("svg");
});
it("parses attributes with single quotes", { timeout: 5000 }, () => {
const node = parseSvg("<svg><rect x='10' y='20'/></svg>");
const rect = node.children![0]!;
expect(rect.attributes?.x).toBe("10");
expect(rect.attributes?.y).toBe("20");
});
it("parses attributes with no value (boolean)", { timeout: 5000 }, () => {
const node = parseSvg("<svg><use disabled/></svg>");
const use = node.children![0]!;
expect(use.attributes?.disabled).toBe("");
});
it("parses multiple children", { timeout: 5000 }, () => {
const node = parseSvg(
"<svg><circle r='5'/><rect width='10'/><path d='M0 0'/></svg>"
);
expect(node.children).toHaveLength(3);
});
it("parses whitespace-only text nodes", { timeout: 5000 }, () => {
const node = parseSvg("<svg>\n <circle r='5'/>\n</svg>");
const nonElement = node.children!.filter((c) => c.type !== "element");
expect(nonElement.every((n) => n.type === "text")).toBe(true);
});
it("handles deeply nested structure", { timeout: 5000 }, () => {
const svg = "<svg><g><g><g><rect/></g></g></g></svg>";
const node = parseSvg(svg);
const inner =
node.children![0]!.children![0]!.children![0]!.children![0]!;
expect(inner.name).toBe("rect");
});
it("throws PARSE_FAILED on unclosed tag", { timeout: 5000 }, () => {
expect(() => parseSvg("<svg><circle>")).toThrow(SvgCompressError);
try {
parseSvg("<svg><circle>");
} catch (e) {
expect((e as SvgCompressError).code).toBe(
SVG_COMPRESS_CODES.PARSE_FAILED
);
}
});
it("throws PARSE_FAILED on mismatched closing tag", {
timeout: 5000,
}, () => {
expect(() => parseSvg("<svg><g></h></svg>")).toThrow(SvgCompressError);
});
it("throws PARSE_FAILED on closing tag with empty stack", {
timeout: 5000,
}, () => {
expect(() => parseSvg("</svg>")).toThrow(SvgCompressError);
try {
parseSvg("</svg>");
} catch (e) {
expect((e as SvgCompressError).code).toBe(
SVG_COMPRESS_CODES.PARSE_FAILED
);
expect((e as SvgCompressError).message).toContain("none");
}
});
it("throws PARSE_FAILED on empty input", { timeout: 5000 }, () => {
expect(() => parseSvg("")).toThrow(SvgCompressError);
});
it("throws PARSE_FAILED on non-SVG input (no root element)", {
timeout: 5000,
}, () => {
expect(() => parseSvg("just text")).toThrow(SvgCompressError);
});
it("parses attributes with namespace prefixes", { timeout: 5000 }, () => {
const node = parseSvg(
'<svg xmlns:xlink="http://www.w3.org/1999/xlink"><use xlink:href="#id"/></svg>'
);
const use = node.children![0]!;
expect(use.attributes?.["xlink:href"]).toBe("#id");
});
it("parses attribute values with encoded entities", {
timeout: 5000,
}, () => {
const node = parseSvg('<svg title="a &amp; b"></svg>');
expect(node.attributes?.title).toBe("a & b");
});
it("parses multiple attributes on same element", { timeout: 5000 }, () => {
const node = parseSvg(
'<svg width="100" height="200" viewBox="0 0 100 200" xmlns="http://www.w3.org/2000/svg"></svg>'
);
expect(node.attributes?.width).toBe("100");
expect(node.attributes?.height).toBe("200");
expect(node.attributes?.viewBox).toBe("0 0 100 200");
});
it("throws PARSE_FAILED on unclosed processing instruction", {
timeout: 5000,
}, () => {
expect(() => parseSvg("<?xml version='1.0'<svg/>")).toThrow(
SvgCompressError
);
});
it("throws PARSE_FAILED on unclosed comment", { timeout: 5000 }, () => {
expect(() => parseSvg("<!-- unclosed comment")).toThrow(
SvgCompressError
);
});
it("throws PARSE_FAILED on unclosed CDATA", { timeout: 5000 }, () => {
expect(() => parseSvg("<svg><![CDATA[unclosed")).toThrow(
SvgCompressError
);
});
it("throws PARSE_FAILED on unclosed DOCTYPE", { timeout: 5000 }, () => {
expect(() => parseSvg("<!DOCTYPE svg unclosed")).toThrow(
SvgCompressError
);
});
it("throws PARSE_FAILED on unclosed closing tag", { timeout: 5000 }, () => {
expect(() => parseSvg("<svg></svg")).toThrow(SvgCompressError);
try {
parseSvg("<svg></svg");
} catch (e) {
expect((e as SvgCompressError).code).toBe(
SVG_COMPRESS_CODES.PARSE_FAILED
);
}
});
it("skips non-DOCTYPE declarations (e.g. <!ENTITY>)", {
timeout: 5000,
}, () => {
const node = parseSvg("<!ENTITY foo bar><svg/>");
expect(node.name).toBe("svg");
});
it("decodes hex character entities (&#xHH;)", { timeout: 5000 }, () => {
const node = parseSvg('<svg title="&#x41;&#x42;"/>');
expect(node.attributes?.title).toBe("AB");
});
it("decodes decimal character entities (&#DD;)", { timeout: 5000 }, () => {
const node = parseSvg('<svg title="&#65;&#66;"/>');
expect(node.attributes?.title).toBe("AB");
});
});
describe("serializeSvg", () => {
it("serializes a minimal SVG element", { timeout: 5000 }, () => {
const node = parseSvg('<svg xmlns="http://www.w3.org/2000/svg"></svg>');
const result = serializeSvg(node);
expect(result).toBe('<svg xmlns="http://www.w3.org/2000/svg"/>');
});
it("serializes self-closing elements with children empty", {
timeout: 5000,
}, () => {
const node = parseSvg('<svg><circle r="5"/></svg>');
const result = serializeSvg(node);
expect(result).toContain('<circle r="5"/>');
});
it("serializes text nodes", { timeout: 5000 }, () => {
const node = parseSvg("<svg><text>Hello</text></svg>");
const result = serializeSvg(node);
expect(result).toContain("<text>Hello</text>");
});
it("serializes comment nodes", { timeout: 5000 }, () => {
const node = parseSvg("<svg><!-- note --></svg>");
const result = serializeSvg(node);
expect(result).toContain("<!-- note -->");
});
it("serializes CDATA nodes", { timeout: 5000 }, () => {
const node = parseSvg("<svg><![CDATA[data]]></svg>");
const result = serializeSvg(node);
expect(result).toContain("<![CDATA[data]]>");
});
it("serializes elements with children (non-self-closing)", {
timeout: 5000,
}, () => {
const node = parseSvg("<svg><g><rect/></g></svg>");
const result = serializeSvg(node);
expect(result).toContain("<g><rect/></g>");
});
it("produces compact output (no extra whitespace)", {
timeout: 5000,
}, () => {
const _node = parseSvg("<svg>\n <rect/>\n</svg>");
const result = serializeSvg({
type: "element",
name: "svg",
attributes: {},
children: [
{ type: "element", name: "rect", attributes: {}, children: [] },
],
});
expect(result).toBe("<svg><rect/></svg>");
});
it("round-trips parse → serialize for complex SVG", {
timeout: 5000,
}, () => {
const input =
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><g id="layer1"><circle cx="50" cy="50" r="40" fill="red"/><text x="10" y="90">label</text></g></svg>';
const node = parseSvg(input);
const output = serializeSvg(node);
// Re-parse the output and check structure
const reparsed = parseSvg(output);
expect(reparsed.name).toBe("svg");
expect(reparsed.children).toHaveLength(1);
const g = reparsed.children![0]!;
expect(g.name).toBe("g");
expect(g.children).toHaveLength(2);
});
it("serializes a text-type node directly", { timeout: 5000 }, () => {
const textNode = { type: "text" as const, value: "hello" };
expect(serializeSvg(textNode)).toBe("hello");
});
it("serializes a doctype node", { timeout: 5000 }, () => {
const doctype = {
type: "doctype" as const,
value: 'svg PUBLIC "-//W3C//DTD SVG 1.1//EN"',
};
expect(serializeSvg(doctype)).toBe(
'<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN">'
);
});
it("omits undefined attributes", { timeout: 5000 }, () => {
const node = serializeSvg({
type: "element",
name: "svg",
children: [],
});
expect(node).toBe("<svg/>");
});
});

View File

@@ -0,0 +1,254 @@
import {
SVG_COMPRESS_CODES,
SvgCompressError,
} from "../../error-codes/error-codes.js";
import type { SvgNode } from "../../types/common.js";
const HTML_ENTITIES: Record<string, string> = {
amp: "&",
lt: "<",
gt: ">",
quot: '"',
apos: "'",
nbsp: " ",
};
function decodeEntities(s: string): string {
return s.replace(/&([^;]+);/g, (_match, name: string) => {
if (name.startsWith("#x")) {
return String.fromCodePoint(Number.parseInt(name.slice(2), 16));
}
if (name.startsWith("#")) {
return String.fromCodePoint(Number.parseInt(name.slice(1), 10));
}
return HTML_ENTITIES[name] ?? /* istanbul ignore next */ `&${name};`;
});
}
function parseAttrs(raw: string): Record<string, string> {
const attrs: Record<string, string> = {};
const re =
/([^\s=/"'<>]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'`<>=]*)))?/g;
let m: RegExpExecArray | null;
while ((m = re.exec(raw)) !== null) {
const name = m[1]!;
const val = m[2] ?? m[3] ?? m[4] ?? "";
attrs[name] = decodeEntities(val);
}
return attrs;
}
export function parseSvg(svg: string): SvgNode {
if (!svg.trim()) {
throw new SvgCompressError(
SVG_COMPRESS_CODES.PARSE_FAILED,
"Empty input string"
);
}
const stack: SvgNode[] = [];
let pos = 0;
let root: SvgNode | null = null;
function pushNode(node: SvgNode): void {
const parent = stack[stack.length - 1];
if (parent) {
(parent.children ??= []).push(node);
} else if (!root) {
root = node;
}
}
while (pos < svg.length) {
if (svg[pos] !== "<") {
const end = svg.indexOf("<", pos);
const text = end === -1 ? svg.slice(pos) : svg.slice(pos, end);
const parent = stack[stack.length - 1];
if (parent) {
(parent.children ??= []).push({ type: "text", value: text });
}
pos = end === -1 ? svg.length : end;
continue;
}
// XML/processing instruction: <?...?>
if (svg.startsWith("<?", pos)) {
const end = svg.indexOf("?>", pos + 2);
if (end === -1) {
throw new SvgCompressError(
SVG_COMPRESS_CODES.PARSE_FAILED,
"Unclosed processing instruction"
);
}
pos = end + 2;
continue;
}
// Comment: <!--...-->
if (svg.startsWith("<!--", pos)) {
const end = svg.indexOf("-->", pos + 4);
if (end === -1) {
throw new SvgCompressError(
SVG_COMPRESS_CODES.PARSE_FAILED,
"Unclosed comment"
);
}
const value = svg.slice(pos + 4, end);
pushNode({ type: "comment", value });
pos = end + 3;
continue;
}
// CDATA: <![CDATA[...]]>
if (svg.startsWith("<![CDATA[", pos)) {
const end = svg.indexOf("]]>", pos + 9);
if (end === -1) {
throw new SvgCompressError(
SVG_COMPRESS_CODES.PARSE_FAILED,
"Unclosed CDATA section"
);
}
const value = svg.slice(pos + 9, end);
pushNode({ type: "cdata", value });
pos = end + 3;
continue;
}
// DOCTYPE: <!...>
if (svg.startsWith("<!", pos)) {
const end = svg.indexOf(">", pos + 2);
if (end === -1) {
throw new SvgCompressError(
SVG_COMPRESS_CODES.PARSE_FAILED,
"Unclosed DOCTYPE"
);
}
const inner = svg.slice(pos + 2, end).trim();
if (inner.toUpperCase().startsWith("DOCTYPE")) {
const value = inner.slice(7).trim();
pushNode({ type: "doctype", value });
}
pos = end + 1;
continue;
}
// Closing tag: </name>
if (svg.startsWith("</", pos)) {
const end = svg.indexOf(">", pos + 2);
if (end === -1) {
throw new SvgCompressError(
SVG_COMPRESS_CODES.PARSE_FAILED,
"Unclosed closing tag"
);
}
const name = svg.slice(pos + 2, end).trim();
const top = stack[stack.length - 1];
if (!top || top.name !== name) {
throw new SvgCompressError(
SVG_COMPRESS_CODES.PARSE_FAILED,
`Mismatched closing tag: </${name}>, expected </${top?.name ?? "none"}>`
);
}
const closed = stack.pop()!;
if (stack.length === 0) {
root = closed;
}
pos = end + 1;
continue;
}
// Opening tag: <name attrs> or <name attrs/>
const tagEnd = findTagEnd(svg, pos + 1);
if (tagEnd === -1) {
throw new SvgCompressError(
SVG_COMPRESS_CODES.PARSE_FAILED,
"Unclosed opening tag"
);
}
const tagContent = svg.slice(pos + 1, tagEnd);
const selfClosing = tagContent.endsWith("/");
const inner = selfClosing ? tagContent.slice(0, -1) : tagContent;
const spaceIdx = inner.search(/[\s]/);
const name =
spaceIdx === -1 ? inner.trim() : inner.slice(0, spaceIdx).trim();
const attrStr = spaceIdx === -1 ? "" : inner.slice(spaceIdx + 1);
const attributes = parseAttrs(attrStr);
const node: SvgNode = {
type: "element",
name,
attributes,
children: [],
};
if (selfClosing) {
pushNode(node);
} else {
const parent = stack[stack.length - 1];
if (parent) {
(parent.children ??= []).push(node);
}
stack.push(node);
}
pos = tagEnd + 1;
}
if (stack.length > 0) {
throw new SvgCompressError(
SVG_COMPRESS_CODES.PARSE_FAILED,
`Unclosed elements: ${stack.map((n) => n.name).join(", ")}`
);
}
if (!root) {
throw new SvgCompressError(
SVG_COMPRESS_CODES.PARSE_FAILED,
"No root element found"
);
}
return root;
}
function findTagEnd(svg: string, start: number): number {
let i = start;
let inSingle = false;
let inDouble = false;
while (i < svg.length) {
const ch = svg[i];
if (ch === '"' && !inSingle) inDouble = !inDouble;
else if (ch === "'" && !inDouble) inSingle = !inSingle;
else if (ch === ">" && !inSingle && !inDouble) return i;
i++;
}
return -1;
}
export function serializeSvg(node: SvgNode): string {
switch (node.type) {
case "text":
return node.value ?? /* istanbul ignore next */ "";
case "comment":
return `<!--${node.value ?? /* istanbul ignore next */ ""}-->`;
case "cdata":
return `<![CDATA[${node.value ?? /* istanbul ignore next */ ""}]]>`;
case "doctype":
return `<!DOCTYPE ${node.value ?? /* istanbul ignore next */ ""}>`;
case "element": {
const name = node.name ?? /* istanbul ignore next */ "unknown";
const attrs = node.attributes
? Object.entries(node.attributes)
.map(([k, v]) => `${k}="${v}"`)
.join(" ")
: "";
const attrStr = attrs ? ` ${attrs}` : "";
const children = node.children ?? /* istanbul ignore next */ [];
if (children.length === 0) {
return `<${name}${attrStr}/>`;
}
const childStr = children.map(serializeSvg).join("");
return `<${name}${attrStr}>${childStr}</${name}>`;
}
}
}

View File

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

View File

@@ -0,0 +1,110 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { cleanupAttrs } from "./cleanup-attrs.js";
const opts = { floatPrecision: 3, transformPrecision: 5 };
describe("cleanupAttrs plugin", () => {
it("removes fill='black' (SVG default)", { timeout: 5000 }, () => {
const node = {
type: "element" as const,
name: "rect",
attributes: { fill: "black", x: "10" },
children: [],
};
const result = cleanupAttrs.apply(node, opts);
expect(result?.attributes?.fill).toBeUndefined();
expect(result?.attributes?.x).toBe("10");
});
it("removes fill-opacity='1' (SVG default)", { timeout: 5000 }, () => {
const node = {
type: "element" as const,
name: "rect",
attributes: { "fill-opacity": "1" },
children: [],
};
const result = cleanupAttrs.apply(node, opts);
expect(result?.attributes?.["fill-opacity"]).toBeUndefined();
});
it("removes opacity='1' (SVG default)", { timeout: 5000 }, () => {
const node = {
type: "element" as const,
name: "rect",
attributes: { opacity: "1" },
children: [],
};
const result = cleanupAttrs.apply(node, opts);
expect(result?.attributes?.opacity).toBeUndefined();
});
it("keeps non-default attribute values", { timeout: 5000 }, () => {
const node = {
type: "element" as const,
name: "rect",
attributes: { fill: "red" },
children: [],
};
const result = cleanupAttrs.apply(node, opts);
expect(result?.attributes?.fill).toBe("red");
});
it("keeps fill='none'", { timeout: 5000 }, () => {
const node = {
type: "element" as const,
name: "rect",
attributes: { fill: "none" },
children: [],
};
const result = cleanupAttrs.apply(node, opts);
expect(result?.attributes?.fill).toBe("none");
});
it("passes through non-element nodes", { timeout: 5000 }, () => {
const node = { type: "text" as const, value: "hello" };
expect(cleanupAttrs.apply(node, opts)).toBe(node);
});
it("trims whitespace from attribute values", { timeout: 5000 }, () => {
const node = {
type: "element" as const,
name: "rect",
attributes: { x: " 10 " },
children: [],
};
const result = cleanupAttrs.apply(node, opts);
expect(result?.attributes?.x).toBe("10");
});
it("removes empty string attributes", { timeout: 5000 }, () => {
const node = {
type: "element" as const,
name: "rect",
attributes: { style: "" },
children: [],
};
const result = cleanupAttrs.apply(node, opts);
expect(result?.attributes?.style).toBeUndefined();
});
it("handles element with no attributes", { timeout: 5000 }, () => {
const node = {
type: "element" as const,
name: "rect",
attributes: {},
children: [],
};
const result = cleanupAttrs.apply(node, opts);
expect(result?.attributes).toEqual({});
});
it("handles element with undefined attributes", { timeout: 5000 }, () => {
const node = { type: "element" as const, name: "rect", children: [] };
const result = cleanupAttrs.apply(node, opts);
expect(result?.attributes).toEqual({});
});
it("has correct plugin name", { timeout: 5000 }, () => {
expect(cleanupAttrs.name).toBe("cleanupAttrs");
});
});

View File

@@ -0,0 +1,24 @@
import { DEFAULT_ATTRS } from "../../constants/index.js";
import type {
SvgNode,
SvgPlugin,
SvgPluginOptions,
} from "../../types/common.js";
export const cleanupAttrs: SvgPlugin = {
name: "cleanupAttrs",
apply(node: SvgNode, _options: SvgPluginOptions): SvgNode | null {
if (node.type !== "element") return node;
const raw = node.attributes ?? {};
const cleaned: Record<string, string> = {};
for (const [key, val] of Object.entries(raw)) {
const trimmed = val.trim();
if (trimmed === "") continue;
if (DEFAULT_ATTRS[key] === trimmed) continue;
cleaned[key] = trimmed;
}
return { ...node, attributes: cleaned };
},
};

View File

@@ -0,0 +1 @@
export { cleanupAttrs } from "./cleanup-attrs.js";

View File

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

View File

@@ -0,0 +1,193 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { collapseGroups } from "./collapse-groups.js";
const opts = { floatPrecision: 3, transformPrecision: 5 };
describe("collapseGroups plugin", () => {
it("collapses a single-child group by hoisting the child", {
timeout: 5000,
}, () => {
const child = {
type: "element" as const,
name: "rect",
attributes: { fill: "red" },
children: [],
};
const group = {
type: "element" as const,
name: "g",
attributes: {},
children: [child],
};
const result = collapseGroups.apply(group, opts);
expect(result).not.toBeNull();
expect(result?.name).toBe("rect");
});
it("merges group attributes onto child when child has no conflicting attrs", {
timeout: 5000,
}, () => {
const child = {
type: "element" as const,
name: "rect",
attributes: { fill: "red" },
children: [],
};
const group = {
type: "element" as const,
name: "g",
attributes: { opacity: "0.5" },
children: [child],
};
const result = collapseGroups.apply(group, opts);
expect(result?.attributes?.opacity).toBe("0.5");
expect(result?.attributes?.fill).toBe("red");
});
it("does not collapse a group with multiple children", {
timeout: 5000,
}, () => {
const group = {
type: "element" as const,
name: "g",
attributes: {},
children: [
{
type: "element" as const,
name: "rect",
attributes: {},
children: [],
},
{
type: "element" as const,
name: "circle",
attributes: {},
children: [],
},
],
};
const result = collapseGroups.apply(group, opts);
expect(result?.name).toBe("g");
expect(result?.children).toHaveLength(2);
});
it("does not collapse a group with zero children", {
timeout: 5000,
}, () => {
const group = {
type: "element" as const,
name: "g",
attributes: {},
children: [],
};
const result = collapseGroups.apply(group, opts);
expect(result?.name).toBe("g");
});
it("does not collapse non-group elements", { timeout: 5000 }, () => {
const node = {
type: "element" as const,
name: "svg",
attributes: {},
children: [],
};
const result = collapseGroups.apply(node, opts);
expect(result).toBe(node);
});
it("passes through non-element nodes", { timeout: 5000 }, () => {
const node = { type: "text" as const, value: "hello" };
expect(collapseGroups.apply(node, opts)).toBe(node);
});
it("does not collapse when child has conflicting attr", {
timeout: 5000,
}, () => {
const child = {
type: "element" as const,
name: "rect",
attributes: { opacity: "1" },
children: [],
};
const group = {
type: "element" as const,
name: "g",
attributes: { opacity: "0.5" },
children: [child],
};
const result = collapseGroups.apply(group, opts);
expect(result?.name).toBe("g");
});
it("does not collapse a group with id attr", { timeout: 5000 }, () => {
const child = {
type: "element" as const,
name: "rect",
attributes: {},
children: [],
};
const group = {
type: "element" as const,
name: "g",
attributes: { id: "layer1" },
children: [child],
};
const result = collapseGroups.apply(group, opts);
expect(result?.name).toBe("g");
});
it("does not collapse a group with undefined children", {
timeout: 5000,
}, () => {
const group = { type: "element" as const, name: "g", attributes: {} };
const result = collapseGroups.apply(group, opts);
expect(result?.name).toBe("g");
});
it("does not collapse group when single child is non-element", {
timeout: 5000,
}, () => {
const group = {
type: "element" as const,
name: "g",
attributes: {},
children: [{ type: "text" as const, value: "hello" }],
};
const result = collapseGroups.apply(group, opts);
expect(result?.name).toBe("g");
});
it("handles group with undefined attributes", { timeout: 5000 }, () => {
const child = {
type: "element" as const,
name: "rect",
attributes: {},
children: [],
};
const group = {
type: "element" as const,
name: "g",
children: [child],
};
const result = collapseGroups.apply(group, opts);
expect(result?.name).toBe("rect");
});
it("handles child element with undefined attributes", {
timeout: 5000,
}, () => {
const child = { type: "element" as const, name: "rect", children: [] };
const group = {
type: "element" as const,
name: "g",
attributes: {},
children: [child],
};
const result = collapseGroups.apply(group, opts);
expect(result?.name).toBe("rect");
});
it("has correct plugin name", { timeout: 5000 }, () => {
expect(collapseGroups.name).toBe("collapseGroups");
});
});

View File

@@ -0,0 +1,35 @@
import type {
SvgNode,
SvgPlugin,
SvgPluginOptions,
} from "../../types/common.js";
export const collapseGroups: SvgPlugin = {
name: "collapseGroups",
apply(node: SvgNode, _options: SvgPluginOptions): SvgNode | null {
if (node.type !== "element" || node.name !== "g") return node;
const children = node.children ?? [];
if (children.length !== 1) return node;
const child = children[0]!;
if (child.type !== "element") return node;
const groupAttrs = node.attributes ?? {};
// Never collapse if group has an id (may be referenced)
if ("id" in groupAttrs) return node;
const childAttrs = child.attributes ?? {};
// Check for conflicting attributes
for (const key of Object.keys(groupAttrs)) {
if (key in childAttrs) return node;
}
// Merge group attrs onto child
return {
...child,
attributes: { ...groupAttrs, ...childAttrs },
};
},
};

View File

@@ -0,0 +1 @@
export { collapseGroups } from "./collapse-groups.js";

View File

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

View File

@@ -0,0 +1,92 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { convertPathData } from "./convert-path-data.js";
const opts = { floatPrecision: 3, transformPrecision: 5 };
describe("convertPathData plugin", () => {
it("reduces decimal precision in path d attribute", {
timeout: 5000,
}, () => {
const node = {
type: "element" as const,
name: "path",
attributes: { d: "M 1.23456789 2.98765432 L 10 20" },
children: [],
};
const result = convertPathData.apply(node, opts);
expect(result?.attributes?.d).toBe("M 1.235 2.988 L 10 20");
});
it("respects floatPrecision option", { timeout: 5000 }, () => {
const node = {
type: "element" as const,
name: "path",
attributes: { d: "M 1.123456 2.654321" },
children: [],
};
const result = convertPathData.apply(node, {
floatPrecision: 2,
transformPrecision: 5,
});
expect(result?.attributes?.d).toBe("M 1.12 2.65");
});
it("does not modify non-path elements", { timeout: 5000 }, () => {
const node = {
type: "element" as const,
name: "rect",
attributes: { x: "1.23456789" },
children: [],
};
const result = convertPathData.apply(node, opts);
expect(result).toBe(node);
});
it("passes through non-element nodes", { timeout: 5000 }, () => {
const node = { type: "text" as const, value: "hello" };
expect(convertPathData.apply(node, opts)).toBe(node);
});
it("handles integer coordinates without modification", {
timeout: 5000,
}, () => {
const node = {
type: "element" as const,
name: "path",
attributes: { d: "M 10 20 L 30 40" },
children: [],
};
const result = convertPathData.apply(node, opts);
expect(result?.attributes?.d).toBe("M 10 20 L 30 40");
});
it("handles path with no d attribute", { timeout: 5000 }, () => {
const node = {
type: "element" as const,
name: "path",
attributes: {},
children: [],
};
const result = convertPathData.apply(node, opts);
expect(result?.attributes?.d).toBeUndefined();
});
it("handles curves (cubic bezier)", { timeout: 5000 }, () => {
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, opts);
expect(result?.attributes?.d).toBe(
"C 1.111 2.222 3.333 4.444 5.556 6.667"
);
});
it("has correct plugin name", { timeout: 5000 }, () => {
expect(convertPathData.name).toBe("convertPathData");
});
});

View File

@@ -0,0 +1,29 @@
import type {
SvgNode,
SvgPlugin,
SvgPluginOptions,
} from "../../types/common.js";
function roundNumbers(str: string, precision: number): string {
return str.replace(/-?[0-9]*\.?[0-9]+(?:[eE][+-]?[0-9]+)?/g, (num) => {
const n = Number.parseFloat(num);
const rounded = Number.parseFloat(n.toFixed(precision));
return String(rounded);
});
}
export const convertPathData: SvgPlugin = {
name: "convertPathData",
apply(node: SvgNode, options: SvgPluginOptions): SvgNode | null {
if (node.type !== "element" || node.name !== "path") return node;
const attrs = node.attributes;
if (!attrs?.["d"]) return node;
return {
...node,
attributes: {
...attrs,
d: roundNumbers(attrs["d"], options.floatPrecision),
},
};
},
};

View File

@@ -0,0 +1 @@
export { convertPathData } from "./convert-path-data.js";

View File

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

View File

@@ -0,0 +1,79 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { convertTransforms } from "./convert-transforms.js";
const opts = { floatPrecision: 3, transformPrecision: 5 };
describe("convertTransforms plugin", () => {
it("reduces decimal precision in transform attribute", {
timeout: 5000,
}, () => {
const node = {
type: "element" as const,
name: "rect",
attributes: { transform: "translate(1.123456789 2.987654321)" },
children: [],
};
const result = convertTransforms.apply(node, opts);
expect(result?.attributes?.transform).toBe(
"translate(1.12346 2.98765)"
);
});
it("respects transformPrecision option", { timeout: 5000 }, () => {
const node = {
type: "element" as const,
name: "rect",
attributes: { transform: "translate(1.123456 2.654321)" },
children: [],
};
const result = convertTransforms.apply(node, {
floatPrecision: 3,
transformPrecision: 2,
});
expect(result?.attributes?.transform).toBe("translate(1.12 2.65)");
});
it("passes through elements without transform attribute", {
timeout: 5000,
}, () => {
const node = {
type: "element" as const,
name: "rect",
attributes: { x: "10" },
children: [],
};
const result = convertTransforms.apply(node, opts);
expect(result).toBe(node);
});
it("handles integer transform values", { timeout: 5000 }, () => {
const node = {
type: "element" as const,
name: "rect",
attributes: { transform: "translate(10 20)" },
children: [],
};
const result = convertTransforms.apply(node, opts);
expect(result?.attributes?.transform).toBe("translate(10 20)");
});
it("handles scale transform", { timeout: 5000 }, () => {
const node = {
type: "element" as const,
name: "rect",
attributes: { transform: "scale(1.123456789)" },
children: [],
};
const result = convertTransforms.apply(node, opts);
expect(result?.attributes?.transform).toBe("scale(1.12346)");
});
it("passes through non-element nodes", { timeout: 5000 }, () => {
const node = { type: "text" as const, value: "hello" };
expect(convertTransforms.apply(node, opts)).toBe(node);
});
it("has correct plugin name", { timeout: 5000 }, () => {
expect(convertTransforms.name).toBe("convertTransforms");
});
});

View File

@@ -0,0 +1,32 @@
import type {
SvgNode,
SvgPlugin,
SvgPluginOptions,
} from "../../types/common.js";
function roundNumbers(str: string, precision: number): string {
return str.replace(/-?[0-9]*\.?[0-9]+(?:[eE][+-]?[0-9]+)?/g, (num) => {
const n = Number.parseFloat(num);
const rounded = Number.parseFloat(n.toFixed(precision));
return String(rounded);
});
}
export const convertTransforms: SvgPlugin = {
name: "convertTransforms",
apply(node: SvgNode, options: SvgPluginOptions): SvgNode | null {
if (node.type !== "element") return node;
const attrs = node.attributes;
if (!attrs?.["transform"]) return node;
return {
...node,
attributes: {
...attrs,
transform: roundNumbers(
attrs["transform"],
options.transformPrecision
),
},
};
},
};

View File

@@ -0,0 +1 @@
export { convertTransforms } from "./convert-transforms.js";

View File

@@ -0,0 +1 @@
export { createMangleIds, mangleIds } from "./mangle-ids.js";

View File

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

View File

@@ -0,0 +1,149 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { createMangleIds } from "./mangle-ids.js";
const opts = { floatPrecision: 3, transformPrecision: 5 };
describe("mangleIds plugin", () => {
it("replaces id attribute with a short sequential id", {
timeout: 5000,
}, () => {
const plugin = createMangleIds();
const node = {
type: "element" as const,
name: "rect",
attributes: { id: "longDescriptiveName" },
children: [],
};
const result = plugin.apply(node, opts);
expect(result?.attributes?.id).toBeDefined();
expect((result?.attributes?.id ?? "").length).toBeLessThan(
"longDescriptiveName".length
);
});
it("produces sequential ids for multiple nodes", { timeout: 5000 }, () => {
const plugin = createMangleIds();
const node1 = {
type: "element" as const,
name: "rect",
attributes: { id: "alpha" },
children: [],
};
const node2 = {
type: "element" as const,
name: "circle",
attributes: { id: "beta" },
children: [],
};
const r1 = plugin.apply(node1, opts);
const r2 = plugin.apply(node2, opts);
expect(r1?.attributes?.id).not.toBe(r2?.attributes?.id);
});
it("passes through nodes without id attribute", { timeout: 5000 }, () => {
const plugin = createMangleIds();
const node = {
type: "element" as const,
name: "rect",
attributes: { fill: "red" },
children: [],
};
const result = plugin.apply(node, opts);
expect(result?.attributes?.id).toBeUndefined();
});
it("passes through element nodes with no attributes field", {
timeout: 5000,
}, () => {
const plugin = createMangleIds();
const node = { type: "element" as const, name: "rect", children: [] };
expect(plugin.apply(node, opts)).toBe(node);
});
it("passes through non-element nodes", { timeout: 5000 }, () => {
const plugin = createMangleIds();
const node = { type: "text" as const, value: "hello" };
expect(plugin.apply(node, opts)).toBe(node);
});
it("has correct plugin name", { timeout: 5000 }, () => {
const plugin = createMangleIds();
expect(plugin.name).toBe("mangleIds");
});
it("rewrites href references to mangled ids", { timeout: 5000 }, () => {
const plugin = createMangleIds();
const def = {
type: "element" as const,
name: "path",
attributes: { id: "originalId" },
children: [],
};
const use = {
type: "element" as const,
name: "use",
attributes: { href: "#originalId" },
children: [],
};
const mangledDef = plugin.apply(def, opts);
const mangledUse = plugin.apply(use, opts);
const newId = mangledDef?.attributes?.id ?? "";
expect(mangledUse?.attributes?.href).toBe(`#${newId}`);
});
it("rewrites xlink:href references to mangled ids", {
timeout: 5000,
}, () => {
const plugin = createMangleIds();
const def = {
type: "element" as const,
name: "path",
attributes: { id: "myId" },
children: [],
};
const use = {
type: "element" as const,
name: "use",
attributes: { "xlink:href": "#myId" },
children: [],
};
const mangledDef = plugin.apply(def, opts);
const mangledUse = plugin.apply(use, opts);
const newId = mangledDef?.attributes?.id ?? "";
expect(mangledUse?.attributes?.["xlink:href"]).toBe(`#${newId}`);
});
it("does not rewrite xlink:href that does not start with #", {
timeout: 5000,
}, () => {
const plugin = createMangleIds();
const node = {
type: "element" as const,
name: "use",
attributes: { "xlink:href": "relative-url.svg" },
children: [],
};
const result = plugin.apply(node, opts);
expect(result?.attributes?.["xlink:href"]).toBe("relative-url.svg");
});
it("generates multi-character ids after 52 unique ids", {
timeout: 5000,
}, () => {
const plugin = createMangleIds();
const ids: string[] = [];
for (let i = 0; i < 54; i++) {
const node = {
type: "element" as const,
name: "rect",
attributes: { id: `original${i}` },
children: [],
};
const result = plugin.apply(node, opts);
ids.push(result?.attributes?.id ?? "");
}
// After 52 single-char IDs, next ones are multi-char
const multiChar = ids.slice(52);
expect(multiChar.every((id) => id.length > 1)).toBe(true);
});
});

View File

@@ -0,0 +1,61 @@
import type {
SvgNode,
SvgPlugin,
SvgPluginOptions,
} from "../../types/common.js";
const CHARS = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";
function toShortId(n: number): string {
if (n < CHARS.length) return CHARS[n]!;
return (
toShortId(Math.floor(n / CHARS.length) - 1) + CHARS[n % CHARS.length]!
);
}
export function createMangleIds(): SvgPlugin {
const idMap = new Map<string, string>();
let counter = 0;
function mangle(originalId: string): string {
const existing = idMap.get(originalId);
if (existing !== undefined) return existing;
const newId = toShortId(counter++);
idMap.set(originalId, newId);
return newId;
}
function rewriteRef(ref: string): string {
const originalId = ref.slice(1);
return `#${mangle(originalId)}`;
}
return {
name: "mangleIds",
apply(node: SvgNode, _options: SvgPluginOptions): SvgNode | null {
if (node.type !== "element") return node;
const attrs = node.attributes;
if (!attrs) return node;
let changed = false;
const next: Record<string, string> = { ...attrs };
if (next["id"]) {
next["id"] = mangle(next["id"]);
changed = true;
}
if (next["href"]?.startsWith("#")) {
next["href"] = rewriteRef(next["href"]);
changed = true;
}
if (next["xlink:href"]?.startsWith("#")) {
next["xlink:href"] = rewriteRef(next["xlink:href"]!);
changed = true;
}
return changed ? { ...node, attributes: next } : node;
},
};
}
export const mangleIds: SvgPlugin = createMangleIds();

View File

@@ -0,0 +1 @@
export { removeComments } from "./remove-comments.js";

View File

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

View File

@@ -0,0 +1,40 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { removeComments } from "./remove-comments.js";
const opts = { floatPrecision: 3, transformPrecision: 5 };
describe("removeComments plugin", () => {
it("removes comment nodes", { timeout: 5000 }, () => {
const node = { type: "comment" as const, value: " a comment " };
expect(removeComments.apply(node, opts)).toBeNull();
});
it("passes through element nodes unchanged", { timeout: 5000 }, () => {
const node = {
type: "element" as const,
name: "rect",
attributes: {},
children: [],
};
expect(removeComments.apply(node, opts)).toBe(node);
});
it("passes through text nodes unchanged", { timeout: 5000 }, () => {
const node = { type: "text" as const, value: "hello" };
expect(removeComments.apply(node, opts)).toBe(node);
});
it("passes through cdata nodes unchanged", { timeout: 5000 }, () => {
const node = { type: "cdata" as const, value: "data" };
expect(removeComments.apply(node, opts)).toBe(node);
});
it("passes through doctype nodes unchanged", { timeout: 5000 }, () => {
const node = { type: "doctype" as const, value: "svg" };
expect(removeComments.apply(node, opts)).toBe(node);
});
it("has correct plugin name", { timeout: 5000 }, () => {
expect(removeComments.name).toBe("removeComments");
});
});

View File

@@ -0,0 +1,12 @@
import type {
SvgNode,
SvgPlugin,
SvgPluginOptions,
} from "../../types/common.js";
export const removeComments: SvgPlugin = {
name: "removeComments",
apply(node: SvgNode, _options: SvgPluginOptions): SvgNode | null {
if (node.type === "comment") return null;
return node;
},
};

View File

@@ -0,0 +1 @@
export { removeDoctype } from "./remove-doctype.js";

View File

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

View File

@@ -0,0 +1,33 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { removeDoctype } from "./remove-doctype.js";
const opts = { floatPrecision: 3, transformPrecision: 5 };
describe("removeDoctype plugin", () => {
it("removes doctype nodes", { timeout: 5000 }, () => {
const node = {
type: "doctype" as const,
value: 'svg PUBLIC "-//W3C//DTD SVG 1.1//EN"',
};
expect(removeDoctype.apply(node, opts)).toBeNull();
});
it("passes through element nodes", { timeout: 5000 }, () => {
const node = {
type: "element" as const,
name: "svg",
attributes: {},
children: [],
};
expect(removeDoctype.apply(node, opts)).toBe(node);
});
it("passes through text nodes", { timeout: 5000 }, () => {
const node = { type: "text" as const, value: "hello" };
expect(removeDoctype.apply(node, opts)).toBe(node);
});
it("has correct plugin name", { timeout: 5000 }, () => {
expect(removeDoctype.name).toBe("removeDoctype");
});
});

View File

@@ -0,0 +1,12 @@
import type {
SvgNode,
SvgPlugin,
SvgPluginOptions,
} from "../../types/common.js";
export const removeDoctype: SvgPlugin = {
name: "removeDoctype",
apply(node: SvgNode, _options: SvgPluginOptions): SvgNode | null {
if (node.type === "doctype") return null;
return node;
},
};

View File

@@ -0,0 +1 @@
export { removeMetadata } from "./remove-metadata.js";

View File

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

View File

@@ -0,0 +1,35 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { removeMetadata } from "./remove-metadata.js";
const opts = { floatPrecision: 3, transformPrecision: 5 };
describe("removeMetadata plugin", () => {
it("removes metadata elements", { timeout: 5000 }, () => {
const node = {
type: "element" as const,
name: "metadata",
attributes: {},
children: [],
};
expect(removeMetadata.apply(node, opts)).toBeNull();
});
it("passes through non-metadata elements", { timeout: 5000 }, () => {
const node = {
type: "element" as const,
name: "svg",
attributes: {},
children: [],
};
expect(removeMetadata.apply(node, opts)).toBe(node);
});
it("passes through text nodes", { timeout: 5000 }, () => {
const node = { type: "text" as const, value: "hello" };
expect(removeMetadata.apply(node, opts)).toBe(node);
});
it("has correct plugin name", { timeout: 5000 }, () => {
expect(removeMetadata.name).toBe("removeMetadata");
});
});

View File

@@ -0,0 +1,12 @@
import type {
SvgNode,
SvgPlugin,
SvgPluginOptions,
} from "../../types/common.js";
export const removeMetadata: SvgPlugin = {
name: "removeMetadata",
apply(node: SvgNode, _options: SvgPluginOptions): SvgNode | null {
if (node.type === "element" && node.name === "metadata") return null;
return node;
},
};

View File

@@ -0,0 +1,37 @@
import { cleanupAttrs } from "../plugins/cleanup-attrs/index.js";
import { collapseGroups } from "../plugins/collapse-groups/index.js";
import { convertPathData } from "../plugins/convert-path-data/index.js";
import { convertTransforms } from "../plugins/convert-transforms/index.js";
import { mangleIds } from "../plugins/mangle-ids/index.js";
import { removeComments } from "../plugins/remove-comments/index.js";
import { removeDoctype } from "../plugins/remove-doctype/index.js";
import { removeMetadata } from "../plugins/remove-metadata/index.js";
import type { SvgPlugin } from "../types/common.js";
export const PRESET_DEFAULT: SvgPlugin[] = [
removeComments,
removeMetadata,
removeDoctype,
collapseGroups,
cleanupAttrs,
convertPathData,
];
export const PRESET_SAFE: SvgPlugin[] = [
removeComments,
removeMetadata,
removeDoctype,
collapseGroups,
cleanupAttrs,
];
export const PRESET_AGGRESSIVE: SvgPlugin[] = [
removeComments,
removeMetadata,
removeDoctype,
collapseGroups,
cleanupAttrs,
convertPathData,
convertTransforms,
mangleIds,
];

View File

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

View File

@@ -0,0 +1,77 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { CompressOptionsSchema, CompressResultSchema } from "./common.js";
describe("CompressOptionsSchema", () => {
it("accepts empty options object", { timeout: 5000 }, () => {
expect(() => CompressOptionsSchema.parse({})).not.toThrow();
});
it("accepts valid options", { timeout: 5000 }, () => {
const result = CompressOptionsSchema.parse({
floatPrecision: 3,
transformPrecision: 5,
multipass: true,
removeComments: false,
removeMetadata: true,
removeDoctype: true,
collapseGroups: false,
mergeStyles: true,
mangleIds: false,
});
expect(result.floatPrecision).toBe(3);
expect(result.multipass).toBe(true);
});
it("rejects unknown keys (strict)", { timeout: 5000 }, () => {
expect(() =>
CompressOptionsSchema.parse({ unknownKey: true })
).toThrow();
});
it("rejects out-of-range floatPrecision", { timeout: 5000 }, () => {
expect(() =>
CompressOptionsSchema.parse({ floatPrecision: 25 })
).toThrow();
expect(() =>
CompressOptionsSchema.parse({ floatPrecision: -1 })
).toThrow();
});
});
describe("CompressResultSchema", () => {
it("accepts valid result", { timeout: 5000 }, () => {
const result = CompressResultSchema.parse({
data: "<svg/>",
originalSize: 100,
compressedSize: 80,
ratio: 0.8,
passCount: 2,
});
expect(result.ratio).toBe(0.8);
expect(result.passCount).toBe(2);
});
it("rejects negative originalSize", { timeout: 5000 }, () => {
expect(() =>
CompressResultSchema.parse({
data: "<svg/>",
originalSize: -1,
compressedSize: 80,
ratio: 0.8,
passCount: 1,
})
).toThrow();
});
it("rejects zero passCount", { timeout: 5000 }, () => {
expect(() =>
CompressResultSchema.parse({
data: "<svg/>",
originalSize: 100,
compressedSize: 80,
ratio: 0.8,
passCount: 0,
})
).toThrow();
});
});

View File

@@ -0,0 +1,62 @@
import z from "zod";
export interface SvgNode {
type: "element" | "text" | "comment" | "cdata" | "doctype";
name?: string;
attributes?: Record<string, string>;
children?: SvgNode[];
value?: string;
}
export interface SvgPluginOptions {
floatPrecision: number;
transformPrecision: number;
}
export interface SvgPlugin {
name: string;
apply(node: SvgNode, options: SvgPluginOptions): SvgNode | null;
}
export interface CompressOptions {
plugins?: SvgPlugin[];
floatPrecision?: number;
transformPrecision?: number;
multipass?: boolean;
removeComments?: boolean;
removeMetadata?: boolean;
removeDoctype?: boolean;
collapseGroups?: boolean;
mergeStyles?: boolean;
mangleIds?: boolean;
}
export interface CompressResult {
data: string;
originalSize: number;
compressedSize: number;
ratio: number;
passCount: number;
}
export const CompressOptionsSchema = z
.object({
floatPrecision: z.number().int().min(0).max(20).optional(),
transformPrecision: z.number().int().min(0).max(20).optional(),
multipass: z.boolean().optional(),
removeComments: z.boolean().optional(),
removeMetadata: z.boolean().optional(),
removeDoctype: z.boolean().optional(),
collapseGroups: z.boolean().optional(),
mergeStyles: z.boolean().optional(),
mangleIds: z.boolean().optional(),
})
.strict();
export const CompressResultSchema = z.object({
data: z.string(),
originalSize: z.number().nonnegative(),
compressedSize: z.number().nonnegative(),
ratio: z.number().nonnegative(),
passCount: z.number().int().positive(),
});

View File

@@ -0,0 +1,130 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { compressSvg } from "../src/core/compress-svg/compress-svg.js";
import { parseSvg } from "../src/parser/svg/svg.js";
import {
PRESET_AGGRESSIVE,
PRESET_DEFAULT,
PRESET_SAFE,
} from "../src/presets/index.js";
const REAL_SVG_ICON = `<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<!-- This is an SVG icon -->
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 24 24" width="24" height="24" fill="black" opacity="1">
<metadata>
<dc:title xmlns:dc="http://purl.org/dc/elements/1.1/">Home Icon</dc:title>
</metadata>
<!-- house shape -->
<g id="layer1">
<g>
<path d="M 10.000000001 20.0000000009 L 14.000000001 20.0000000009 L 14.000000001 15.0000000009 L 10.000000001 15.0000000009 Z" fill="black" opacity="1"/>
</g>
</g>
<path d="M 12 3.0000000009 L 2.0000000001 12 L 4.0000000001 12 L 4.0000000001 21 L 10.000000001 21 L 10.000000001 15 L 14 15 L 14 21 L 20 21 L 20 12 L 22 12 Z" fill-opacity="1" stroke-opacity="1"/>
</svg>`;
const MINIMAL_SVG =
'<svg xmlns="http://www.w3.org/2000/svg"><rect width="100" height="100"/></svg>';
describe("integration: PRESET_DEFAULT", () => {
it("compresses real SVG icon and reduces size", { timeout: 5000 }, () => {
const result = compressSvg(REAL_SVG_ICON, { plugins: PRESET_DEFAULT });
expect(result.ratio).toBeLessThanOrEqual(1);
expect(result.data).not.toContain("<!--");
expect(result.data).not.toContain("DOCTYPE");
expect(result.data).not.toContain("<metadata>");
});
it("removes XML declaration", { timeout: 5000 }, () => {
const result = compressSvg(REAL_SVG_ICON, { plugins: PRESET_DEFAULT });
expect(result.data).not.toContain("<?xml");
});
it("output is parseable after compression", { timeout: 5000 }, () => {
const result = compressSvg(REAL_SVG_ICON, { plugins: PRESET_DEFAULT });
expect(() => parseSvg(result.data)).not.toThrow();
});
it("reduces float precision in path data", { timeout: 5000 }, () => {
const result = compressSvg(REAL_SVG_ICON, {
plugins: PRESET_DEFAULT,
floatPrecision: 1,
});
expect(result.data).not.toMatch(/\d\.\d{4,}/);
});
it("collapses single-child groups", { timeout: 5000 }, () => {
const svg = '<svg><g><rect width="10" height="10"/></g></svg>';
const result = compressSvg(svg, { plugins: PRESET_DEFAULT });
expect(result.data).not.toContain("<g>");
expect(result.data).toContain("<rect");
});
it("reports passCount >= 1", { timeout: 5000 }, () => {
const result = compressSvg(REAL_SVG_ICON, { plugins: PRESET_DEFAULT });
expect(result.passCount).toBeGreaterThanOrEqual(1);
});
});
describe("integration: PRESET_SAFE", () => {
it("compresses without converting transforms", { timeout: 5000 }, () => {
const svg =
'<svg><rect transform="translate(1.123456789 2.987654321)"/></svg>';
const defaultResult = compressSvg(svg, { plugins: PRESET_DEFAULT });
const safeResult = compressSvg(svg, { plugins: PRESET_SAFE });
// PRESET_SAFE does not include convertTransforms
expect(safeResult.data).toContain("1.123456789");
// PRESET_DEFAULT does not include convertTransforms either, so both same
expect(defaultResult.data).toContain("1.123456789");
});
it("still removes comments", { timeout: 5000 }, () => {
const svg = "<svg><!-- note --><rect/></svg>";
const result = compressSvg(svg, { plugins: PRESET_SAFE });
expect(result.data).not.toContain("<!--");
});
});
describe("integration: PRESET_AGGRESSIVE", () => {
it("mangles ids", { timeout: 5000 }, () => {
const svg = '<svg><rect id="veryLongDescriptiveName"/></svg>';
const result = compressSvg(svg, { plugins: PRESET_AGGRESSIVE });
expect(result.data).not.toContain("veryLongDescriptiveName");
expect(result.data).toContain("id=");
});
it("converts transforms", { timeout: 5000 }, () => {
const svg =
'<svg><rect transform="translate(1.123456789 2.987654321)"/></svg>';
const result = compressSvg(svg, {
plugins: PRESET_AGGRESSIVE,
transformPrecision: 2,
});
expect(result.data).toContain("translate(1.12 2.99)");
});
it("has smaller output than PRESET_SAFE for icon", {
timeout: 5000,
}, () => {
const safe = compressSvg(REAL_SVG_ICON, { plugins: PRESET_SAFE });
const aggressive = compressSvg(REAL_SVG_ICON, {
plugins: PRESET_AGGRESSIVE,
});
expect(aggressive.compressedSize).toBeLessThanOrEqual(
safe.compressedSize
);
});
});
describe("integration: minimal SVG", () => {
it("handles minimal SVG without error", { timeout: 5000 }, () => {
const result = compressSvg(MINIMAL_SVG);
expect(result.data).toBeTruthy();
expect(result.originalSize).toBeGreaterThan(0);
});
it("passCount is 1 when multipass=false", { timeout: 5000 }, () => {
const result = compressSvg(MINIMAL_SVG, { multipass: false });
expect(result.passCount).toBe(1);
});
});

View File

@@ -0,0 +1,140 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import * as fc from "fast-check";
import { compressSvg } from "../src/core/compress-svg/compress-svg.js";
import { parseSvg, serializeSvg } from "../src/parser/svg/svg.js";
import { PRESET_DEFAULT } from "../src/presets/index.js";
const SIMPLE_SVGS = [
'<svg xmlns="http://www.w3.org/2000/svg"><circle cx="50" cy="50" r="40"/></svg>',
'<svg><rect width="100" height="100"/></svg>',
'<svg><g><path d="M 10 20 L 30 40"/></g></svg>',
'<svg><text x="10" y="20">hello</text></svg>',
"<svg><g><g><rect/></g></g></svg>",
];
describe("property-based: compression idempotency", () => {
it("compress(compress(svg)) === compress(svg) for all sample SVGs", {
timeout: 30000,
}, () => {
fc.assert(
fc.property(fc.constantFrom(...SIMPLE_SVGS), (svg) => {
const first = compressSvg(svg, { plugins: PRESET_DEFAULT });
const second = compressSvg(first.data, {
plugins: PRESET_DEFAULT,
});
expect(second.data).toBe(first.data);
return true;
}),
{ numRuns: 10 }
);
});
});
describe("property-based: parse ↔ serialize round-trip", () => {
it("serializeSvg(parseSvg(svg)) is re-parseable for sample SVGs", {
timeout: 30000,
}, () => {
fc.assert(
fc.property(fc.constantFrom(...SIMPLE_SVGS), (svg) => {
const node = parseSvg(svg);
const serialized = serializeSvg(node);
const reparsed = parseSvg(serialized);
expect(reparsed.type).toBe("element");
return true;
}),
{ numRuns: 10 }
);
});
it("parse → serialize preserves element names", { timeout: 30000 }, () => {
fc.assert(
fc.property(fc.constantFrom(...SIMPLE_SVGS), (svg) => {
const node = parseSvg(svg);
const serialized = serializeSvg(node);
const reparsed = parseSvg(serialized);
expect(reparsed.name).toBe(node.name);
return true;
}),
{ numRuns: 10 }
);
});
});
describe("property-based: ratio invariants", () => {
it("ratio is always > 0 for valid SVG", { timeout: 30000 }, () => {
fc.assert(
fc.property(fc.constantFrom(...SIMPLE_SVGS), (svg) => {
const result = compressSvg(svg);
expect(result.ratio).toBeGreaterThan(0);
return true;
}),
{ numRuns: 10 }
);
});
it("originalSize matches byte length of input", { timeout: 30000 }, () => {
fc.assert(
fc.property(fc.constantFrom(...SIMPLE_SVGS), (svg) => {
const result = compressSvg(svg);
const expected = new TextEncoder().encode(svg).byteLength;
expect(result.originalSize).toBe(expected);
return true;
}),
{ numRuns: 10 }
);
});
it("compressedSize matches byte length of output", {
timeout: 30000,
}, () => {
fc.assert(
fc.property(fc.constantFrom(...SIMPLE_SVGS), (svg) => {
const result = compressSvg(svg);
const expected = new TextEncoder().encode(
result.data
).byteLength;
expect(result.compressedSize).toBe(expected);
return true;
}),
{ numRuns: 10 }
);
});
it("ratio equals compressedSize / originalSize", { timeout: 30000 }, () => {
fc.assert(
fc.property(fc.constantFrom(...SIMPLE_SVGS), (svg) => {
const result = compressSvg(svg);
expect(result.ratio).toBeCloseTo(
result.compressedSize / result.originalSize,
5
);
return true;
}),
{ numRuns: 10 }
);
});
});
describe("property-based: passCount invariants", () => {
it("passCount >= 1 for all valid SVGs", { timeout: 30000 }, () => {
fc.assert(
fc.property(fc.constantFrom(...SIMPLE_SVGS), (svg) => {
const result = compressSvg(svg);
expect(result.passCount).toBeGreaterThanOrEqual(1);
return true;
}),
{ numRuns: 10 }
);
});
it("passCount === 1 when multipass: false", { timeout: 30000 }, () => {
fc.assert(
fc.property(fc.constantFrom(...SIMPLE_SVGS), (svg) => {
const result = compressSvg(svg, { multipass: false });
expect(result.passCount).toBe(1);
return true;
}),
{ numRuns: 10 }
);
});
});

View File

@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"lib": ["ES2022"],
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.test.ts", "src/**/*.bench.ts", "dist", "node_modules"]
}

View File

@@ -0,0 +1,37 @@
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
export default defineConfig({
root: __dirname,
cacheDir: "../../../node_modules/.vite/packages/compress-svg",
plugins: [
dts({
entryRoot: "src",
tsconfigPath: "./tsconfig.lib.json",
insertTypesEntry: true,
}),
],
build: {
outDir: "./dist",
emptyOutDir: true,
reportCompressedSize: true,
sourcemap: true,
lib: { entry: "src/index.ts", formats: ["es"] },
rollupOptions: {
treeshake: true,
output: {
preserveModules: true,
preserveModulesRoot: "src",
entryFileNames: "[name].js",
},
external: [
"tslib",
"zod",
"@opentelemetry/api",
"@suaveplan/error",
"@suaveplan/types",
/^node:/,
],
},
},
});

View File

@@ -0,0 +1,31 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: false,
environment: "node",
pool: "forks",
include: ["src/**/*.test.ts", "tests/**/*.test.ts"],
exclude: ["src/__benchmarks__/**", "dist/**", "node_modules/**"],
coverage: {
provider: "istanbul",
reporter: ["text", "html", "lcov"],
include: ["src/**/*.ts"],
exclude: [
"src/**/*.test.ts",
"src/**/*.bench.ts",
"src/**/*.types.ts",
"src/**/index.ts",
"src/__benchmarks__/**",
"tests/**",
],
thresholds: {
statements: 100,
branches: 100,
functions: 100,
lines: 100,
},
},
testTimeout: 5000,
},
});

View File

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

View File

@@ -0,0 +1,124 @@
{
"name": "@suaveplan/docs",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "Server-tier markdown documentation toolkit: monorepo-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.",
"license": "MIT",
"author": "Suaveplan Team",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
"./package.json": "./package.json",
".": {
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"require": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"./discovery/discover": {
"import": {
"types": "./dist/discovery/discover.d.ts",
"default": "./dist/discovery/discover.js"
}
},
"./parser/parse": {
"import": {
"types": "./dist/parser/parse.d.ts",
"default": "./dist/parser/parse.js"
}
},
"./graph/build-graph": {
"import": {
"types": "./dist/graph/build-graph.d.ts",
"default": "./dist/graph/build-graph.js"
}
},
"./graph/find-orphans": {
"import": {
"types": "./dist/graph/find-orphans.d.ts",
"default": "./dist/graph/find-orphans.js"
}
},
"./hierarchy/build-hierarchy": {
"import": {
"types": "./dist/hierarchy/build-hierarchy.d.ts",
"default": "./dist/hierarchy/build-hierarchy.js"
}
},
"./search/search": {
"import": {
"types": "./dist/search/search.d.ts",
"default": "./dist/search/search.js"
}
},
"./errors/errors": {
"import": {
"types": "./dist/errors/errors.d.ts",
"default": "./dist/errors/errors.js"
}
},
"./observability/observability": {
"import": {
"types": "./dist/observability/observability.d.ts",
"default": "./dist/observability/observability.js"
}
},
"./types": {
"import": {
"types": "./dist/types/docs.types.d.ts",
"default": "./dist/types/docs.types.js"
}
}
},
"scripts": {
"build": "vite build",
"test": "vitest run --passWithNoTests",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage --passWithNoTests",
"lint": "bunx biome check src/",
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
"format": "bunx biome format --write --max-diagnostics 500 src/",
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
},
"dependencies": {
"@opentelemetry/api": "catalog:observability",
"@suaveplan/error": "workspace:^",
"@suaveplan/opentelemetry": "workspace:^",
"@suaveplan/types": "workspace:^",
"@types/mdast": "catalog:markdown",
"glob": "catalog:markdown",
"gray-matter": "catalog:markdown",
"remark-gfm": "catalog:markdown",
"remark-parse": "catalog:markdown",
"tslib": "catalog:build",
"unified": "catalog:markdown",
"unist-util-visit": "catalog:markdown",
"zod": "catalog:forms"
},
"devDependencies": {
"@suaveplan/testing": "workspace:^",
"@types/node": "catalog:",
"@vitest/coverage-istanbul": "catalog:testing",
"typescript": "catalog:",
"vite": "catalog:build",
"vite-plugin-dts": "catalog:build",
"vitest": "catalog:testing"
},
"publishConfig": {
"registry": "https://npmjs.fq.io/",
"access": "public"
},
"files": [
"dist",
"src",
"README.md"
]
}

View File

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

View File

@@ -0,0 +1,314 @@
import { chmod, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
afterAll,
afterEach,
beforeAll,
beforeEach,
describe,
expect,
it,
} from "@suaveplan/testing/runner";
import { DocsError } from "../errors/errors.js";
import { discoverDocs, wrapGlobError } from "./discover.js";
let tempDir: string;
beforeAll(async () => {
tempDir = await mkdtemp(join(tmpdir(), "suaveplan-docs-test-"));
await mkdir(join(tempDir, "packages/foundation/core/utils"), {
recursive: true,
});
await mkdir(join(tempDir, "packages/ui/react-ui/react-ui"), {
recursive: true,
});
await mkdir(join(tempDir, "packages/foundation/core/utils/src/string"), {
recursive: true,
});
await writeFile(join(tempDir, "README.md"), "# Genesis\n\nRoot readme.");
await writeFile(
join(tempDir, "packages/foundation/core/utils/README.md"),
"# @suaveplan/utils\n\nUtility functions.\n\n[String](./src/string/string.md)"
);
await writeFile(
join(tempDir, "packages/foundation/core/utils/src/string/string.md"),
"# String utilities\n\nHelpers for string manipulation."
);
await writeFile(
join(tempDir, "packages/ui/react-ui/react-ui/README.md"),
"# @suaveplan/react-ui\n\nUI components."
);
}, 30000);
afterAll(async () => {
await rm(tempDir, { recursive: true, force: true });
}, 30000);
describe("discoverDocs", () => {
it("finds all .md files in packages/", { timeout: 30000 }, async () => {
const docs = await discoverDocs({ rootDir: tempDir });
const paths = docs.map((d) => d.relativePath);
expect(paths).toContain("packages/foundation/core/utils/README.md");
expect(paths).toContain(
"packages/foundation/core/utils/src/string/string.md"
);
expect(paths).toContain("packages/ui/react-ui/react-ui/README.md");
});
it("includes root-level .md files", { timeout: 30000 }, async () => {
const docs = await discoverDocs({ rootDir: tempDir });
const paths = docs.map((d) => d.relativePath);
expect(paths).toContain("README.md");
});
it("returns sorted results", { timeout: 30000 }, async () => {
const docs = await discoverDocs({ rootDir: tempDir });
const paths = docs.map((d) => d.relativePath);
const sorted = [...paths].sort((a, b) => a.localeCompare(b));
expect(paths).toEqual(sorted);
});
it("derives category from genesis layout", { timeout: 30000 }, async () => {
const docs = await discoverDocs({ rootDir: tempDir });
const utils = docs.find(
(d) => d.relativePath === "packages/foundation/core/utils/README.md"
);
const ui = docs.find(
(d) => d.relativePath === "packages/ui/react-ui/react-ui/README.md"
);
expect(utils?.category).toBe("foundation");
expect(ui?.category).toBe("ui");
});
it("marks README.md files correctly", { timeout: 30000 }, async () => {
const docs = await discoverDocs({ rootDir: tempDir });
const readmes = docs.filter((d) => d.isReadme);
const nonReadmes = docs.filter((d) => !d.isReadme);
expect(readmes.length).toBeGreaterThan(0);
expect(nonReadmes.length).toBeGreaterThan(0);
for (const r of readmes) expect(r.filename).toBe("README.md");
});
it("rejects empty rootDir with INVALID_OPTIONS", {
timeout: 5000,
}, async () => {
await expect(discoverDocs({ rootDir: "" })).rejects.toBeInstanceOf(
DocsError
);
await expect(discoverDocs({ rootDir: "" })).rejects.toMatchObject({
code: "DOCS.INVALID_OPTIONS",
});
});
it("rejects non-object options via schema", {
timeout: 5000,
}, async () => {
await expect(
discoverDocs(null as unknown as { rootDir: string })
).rejects.toMatchObject({ code: "DOCS.INVALID_OPTIONS" });
});
it("throws ABORTED on pre-aborted signal", { timeout: 5000 }, async () => {
const controller = new AbortController();
controller.abort();
await expect(
discoverDocs({ rootDir: tempDir, signal: controller.signal })
).rejects.toMatchObject({ code: "DOCS.ABORTED" });
});
it("throws ABORTED when signal aborts during glob", {
timeout: 5000,
}, async () => {
// Race the abort against glob: schedule the abort on the
// current microtask queue so glob is already running when it
// fires. glob 11 rejects with AbortError when the signal
// aborts mid-walk.
const controller = new AbortController();
queueMicrotask(() => controller.abort());
await expect(
discoverDocs({ rootDir: tempDir, signal: controller.signal })
).rejects.toMatchObject({ code: "DOCS.ABORTED" });
});
it("respects additional ignore patterns", { timeout: 30000 }, async () => {
const docs = await discoverDocs({
rootDir: tempDir,
ignore: ["**/react-ui/**"],
});
const paths = docs.map((d) => d.relativePath);
expect(paths).not.toContain("packages/ui/react-ui/react-ui/README.md");
});
});
describe("wrapGlobError", () => {
it("wraps aborted signal as DOCS.ABORTED", { timeout: 5000 }, () => {
const controller = new AbortController();
controller.abort();
const err = wrapGlobError(
new Error("aborted by glob"),
"/repo",
controller.signal
);
expect(err).toBeInstanceOf(DocsError);
expect(err.code).toBe("DOCS.ABORTED");
});
it("wraps non-aborted throwable as DOCS.GLOB_FAILED", {
timeout: 5000,
}, () => {
const err = wrapGlobError(
new Error("internal glob bug"),
"/repo",
undefined
);
expect(err).toBeInstanceOf(DocsError);
expect(err.code).toBe("DOCS.GLOB_FAILED");
});
it("drops non-Error cause when wrapping ABORTED", { timeout: 5000 }, () => {
const controller = new AbortController();
controller.abort();
const err = wrapGlobError(
"string-throwable",
"/repo",
controller.signal
);
expect(err.code).toBe("DOCS.ABORTED");
expect(err.cause).toBeUndefined();
});
});
describe("discoverDocs large sweep", () => {
let largeDir: string;
beforeAll(async () => {
largeDir = await mkdtemp(join(tmpdir(), "suaveplan-docs-large-"));
await mkdir(join(largeDir, "packages/foundation/core/big"), {
recursive: true,
});
for (let i = 0; i < 70; i++) {
await writeFile(
join(
largeDir,
`packages/foundation/core/big/doc-${i.toString().padStart(3, "0")}.md`
),
`# Doc ${i}\n\nbody ${i}.`
);
}
}, 30000);
afterAll(async () => {
await rm(largeDir, { recursive: true, force: true });
}, 30000);
it("performs periodic abort checks every batch", {
timeout: 30000,
}, async () => {
// 70 files > one ABORT_CHECK_BATCH (64) — exercises the
// `processed % ABORT_CHECK_BATCH === 0` truthy branch.
const docs = await discoverDocs({ rootDir: largeDir });
expect(docs.length).toBeGreaterThanOrEqual(70);
});
});
describe("discoverDocs apps/", () => {
let appsDir: string;
beforeAll(async () => {
appsDir = await mkdtemp(join(tmpdir(), "suaveplan-docs-apps-test-"));
await mkdir(join(appsDir, "apps/landing"), { recursive: true });
await writeFile(
join(appsDir, "apps/landing/README.md"),
"# Landing app"
);
await mkdir(join(appsDir, "packages/foundation/core/x"), {
recursive: true,
});
await writeFile(
join(appsDir, "packages/foundation/core/x/README.md"),
"# X"
);
}, 30000);
afterAll(async () => {
await rm(appsDir, { recursive: true, force: true });
}, 30000);
it("excludes apps/ by default", { timeout: 30000 }, async () => {
const docs = await discoverDocs({ rootDir: appsDir });
const paths = docs.map((d) => d.relativePath);
expect(paths).not.toContain("apps/landing/README.md");
});
it("includes apps/ when includeApps: true", {
timeout: 30000,
}, async () => {
const docs = await discoverDocs({
rootDir: appsDir,
includeApps: true,
});
const paths = docs.map((d) => d.relativePath);
expect(paths).toContain("apps/landing/README.md");
});
});
describe("discoverDocs error surfacing", () => {
let errDir: string;
let unreadable: string;
beforeEach(async () => {
errDir = await mkdtemp(join(tmpdir(), "suaveplan-docs-err-test-"));
await mkdir(join(errDir, "packages/foundation/core/y"), {
recursive: true,
});
await writeFile(
join(errDir, "packages/foundation/core/y/README.md"),
"# Y"
);
unreadable = join(errDir, "packages/foundation/core/y/secret.md");
await writeFile(unreadable, "# secret");
// Strip read permission so node:fs/promises.readFile rejects.
await chmod(unreadable, 0o000);
});
afterEach(async () => {
try {
await chmod(unreadable, 0o644);
} catch {
// file may have been removed already
}
await rm(errDir, { recursive: true, force: true });
});
it("surfaces readFile failures via io_error counter", {
timeout: 30000,
}, async () => {
// Skip under root (which can read 0o000 files anyway).
if (process.getuid?.() === 0) return;
const docs = await discoverDocs({ rootDir: errDir });
const paths = docs.map((d) => d.relativePath);
// Sweep continues — README.md is still parsed.
expect(paths).toContain("packages/foundation/core/y/README.md");
// Unreadable file is dropped silently from the result.
expect(paths).not.toContain("packages/foundation/core/y/secret.md");
});
it("wraps parse failures via io_error counter", {
timeout: 30000,
}, async () => {
// Write a doc with frontmatter that gray-matter cannot parse so the
// parseDoc path inside discoverDocs throws a DocsError — the catch
// branch in discover.ts MUST surface it as io_error_total with the
// DocsError code, NOT the generic READ_FAILED fallback.
await writeFile(
join(errDir, "packages/foundation/core/y/bad.md"),
"---\ntitle: [unterminated\n# Doc\n"
);
const docs = await discoverDocs({ rootDir: errDir });
// Malformed file does NOT appear in result — branch covered.
expect(
docs.find((d) => d.relativePath.endsWith("bad.md"))
).toBeUndefined();
});
});

View File

@@ -0,0 +1,146 @@
import { readFile } from "node:fs/promises";
import { join } from "node:path";
import { glob } from "glob";
import { asDocsError, DOCS_CODES, DocsError } from "../errors/errors.js";
import { createDocsObservability } from "../observability/observability.js";
import type { DocsObservability } from "../observability/observability.types.js";
import { parseDoc } from "../parser/parse.js";
import {
type DiscoverOptions,
DiscoverOptionsSchema,
type DocFile,
} from "../types/docs.types.js";
const DEFAULT_IGNORE = [
"**/node_modules/**",
"**/dist/**",
"**/.git/**",
"**/coverage/**",
"**/.turbo/**",
];
const ABORT_CHECK_BATCH = 64;
/**
* Map an unknown throwable from `glob` to a typed {@link DocsError}.
* Exported so tests can exercise both branches without coupling to glob's
* internal throw conditions.
*/
export function wrapGlobError(
cause: unknown,
rootDir: string,
signal: AbortSignal | undefined
): DocsError {
if (signal?.aborted === true) {
return new DocsError(
DOCS_CODES.ABORTED,
"discoverDocs aborted via AbortSignal",
{ cause: cause instanceof Error ? cause : undefined }
);
}
return asDocsError(
DOCS_CODES.GLOB_FAILED,
cause,
`glob failed for rootDir ${rootDir}`
);
}
function assertNotAborted(signal: AbortSignal | undefined): void {
if (signal?.aborted === true) {
throw new DocsError(
DOCS_CODES.ABORTED,
"discoverDocs aborted via AbortSignal"
);
}
}
/**
* Discover every `.md` file under `options.rootDir`. Honours `AbortSignal`,
* Zod-validates options, surfaces per-file IO errors via the
* `docs_io_error_total` counter (without aborting the sweep), and records
* the `docs_discover_duration_ms` histogram + `docs_discovered_total`
* counter.
*/
export async function discoverDocs(
options: DiscoverOptions,
observability?: DocsObservability
): Promise<DocFile[]> {
const obs = observability ?? createDocsObservability();
const started = performance.now();
const parsed = DiscoverOptionsSchema.safeParse(options);
if (!parsed.success) {
obs.histograms.discoverDurationMs.record(performance.now() - started);
throw new DocsError(
DOCS_CODES.INVALID_OPTIONS,
`discoverDocs received invalid options: ${parsed.error.message}`,
{ cause: parsed.error }
);
}
const { rootDir, ignore = [], includeApps = false, signal } = parsed.data;
assertNotAborted(signal);
const patterns = ["packages/**/*.md", "*.md"];
if (includeApps) patterns.push("apps/**/*.md");
// glob 11 tolerates missing / unreadable directories by returning an
// empty array — IO errors at the directory level surface only through
// an `AbortSignal` abort, which glob throws as an `AbortError`.
let files: string[];
try {
const globOptions: {
cwd: string;
ignore: string[];
absolute: false;
nodir: true;
signal?: AbortSignal;
} = {
cwd: rootDir,
ignore: [...DEFAULT_IGNORE, ...ignore],
absolute: false,
nodir: true,
};
if (signal !== undefined) globOptions.signal = signal;
files = (await glob(patterns, globOptions)) as string[];
} catch (cause) {
obs.histograms.discoverDurationMs.record(performance.now() - started);
// The only path glob 11 reaches this catch through is signal abort.
// Any other throwable (internal glob bug, OOM, etc.) is repackaged
// as `DOCS.GLOB_FAILED` via `wrapGlobError` — exercised by the
// unit tests below.
throw wrapGlobError(cause, rootDir, signal);
}
assertNotAborted(signal);
files.sort();
const docs: DocFile[] = [];
let processed = 0;
for (const relativePath of files) {
const absolutePath = join(rootDir, relativePath);
try {
const content = await readFile(absolutePath, "utf-8");
const doc = parseDoc(absolutePath, relativePath, content, obs);
docs.push(doc);
obs.counters.discoveredTotal.add(1);
} catch (cause) {
if (cause instanceof DocsError) {
// parseDoc already incremented parseErrorTotal — surface
// anything that wasn't an IO read as a discovery-level IO
// error too so callers see a single aggregated counter
// when sweeping a large tree.
obs.counters.ioErrorTotal.add(1, { code: cause.code });
} else {
obs.counters.ioErrorTotal.add(1, {
code: DOCS_CODES.READ_FAILED,
});
}
}
processed++;
if (processed % ABORT_CHECK_BATCH === 0) assertNotAborted(signal);
}
docs.sort((a, b) => a.relativePath.localeCompare(b.relativePath));
obs.histograms.discoverDurationMs.record(performance.now() - started);
return docs;
}

View File

@@ -0,0 +1 @@
export { discoverDocs } from "./discover.js";

View File

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

View File

@@ -0,0 +1,88 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { asDocsError, DOCS_CODES, DocsError } from "./errors.js";
describe("DOCS_CODES", () => {
it("DOCS_CODES has all eight stable codes", { timeout: 5000 }, () => {
const codes = Object.keys(DOCS_CODES).sort();
expect(codes).toEqual(
[
"ABORTED",
"GLOB_FAILED",
"HIERARCHY_CYCLE_LIMIT",
"INVALID_OPTIONS",
"LINK_RESOLUTION_FAILED",
"PARSE_FAILED",
"READ_FAILED",
"SEARCH_INVALID_QUERY",
].sort()
);
expect(DOCS_CODES.INVALID_OPTIONS).toBe("DOCS.INVALID_OPTIONS");
expect(DOCS_CODES.READ_FAILED).toBe("DOCS.READ_FAILED");
expect(DOCS_CODES.PARSE_FAILED).toBe("DOCS.PARSE_FAILED");
expect(DOCS_CODES.GLOB_FAILED).toBe("DOCS.GLOB_FAILED");
expect(DOCS_CODES.ABORTED).toBe("DOCS.ABORTED");
expect(DOCS_CODES.LINK_RESOLUTION_FAILED).toBe(
"DOCS.LINK_RESOLUTION_FAILED"
);
expect(DOCS_CODES.HIERARCHY_CYCLE_LIMIT).toBe(
"DOCS.HIERARCHY_CYCLE_LIMIT"
);
expect(DOCS_CODES.SEARCH_INVALID_QUERY).toBe(
"DOCS.SEARCH_INVALID_QUERY"
);
});
});
describe("DocsError", () => {
it("DocsError preserves code and cause", { timeout: 5000 }, () => {
const native = new Error("disk read failure");
const err = new DocsError(DOCS_CODES.READ_FAILED, "wrapped", {
cause: native,
});
expect(err).toBeInstanceOf(DocsError);
expect(err).toBeInstanceOf(Error);
expect(err.code).toBe("DOCS.READ_FAILED");
expect(err.message).toBe("wrapped");
expect(err.cause).toBe(native);
expect(err.name).toBe("DocsError");
});
it("DocsError accepts every code in the registry", {
timeout: 5000,
}, () => {
for (const code of Object.values(DOCS_CODES)) {
const e = new DocsError(code, `msg-${code}`);
expect(e.code).toBe(code);
}
});
});
describe("asDocsError", () => {
it("wraps Error preserving message", { timeout: 5000 }, () => {
const cause = new Error("oh no");
const lifted = asDocsError(DOCS_CODES.PARSE_FAILED, cause, "fallback");
expect(lifted.code).toBe("DOCS.PARSE_FAILED");
expect(lifted.message).toBe("oh no");
expect(lifted.cause).toBe(cause);
});
it("wraps Error with empty message using fallback", {
timeout: 5000,
}, () => {
const cause = new Error("");
const lifted = asDocsError(DOCS_CODES.PARSE_FAILED, cause, "fallback");
expect(lifted.message).toBe("fallback");
});
it("wraps non-Error throwable using fallback", { timeout: 5000 }, () => {
const lifted = asDocsError(
DOCS_CODES.READ_FAILED,
"string-throwable",
"fallback"
);
expect(lifted.code).toBe("DOCS.READ_FAILED");
expect(lifted.message).toBe("fallback");
expect(lifted.cause).toBeInstanceOf(Error);
expect((lifted.cause as Error).message).toBe("string-throwable");
});
});

View File

@@ -0,0 +1,56 @@
import type { SuaveplanErrorOptions } from "@suaveplan/error";
import { defineCodes, SuaveplanError } from "@suaveplan/error";
/**
* Stable error-code registry for `@suaveplan/docs`. Every error path the
* package can take surfaces one of these codes so callers can classify
* failures programmatically without parsing message strings.
*/
export const DOCS_CODES = defineCodes("DOCS", [
"INVALID_OPTIONS",
"READ_FAILED",
"PARSE_FAILED",
"GLOB_FAILED",
"ABORTED",
"LINK_RESOLUTION_FAILED",
"HIERARCHY_CYCLE_LIMIT",
"SEARCH_INVALID_QUERY",
] as const);
/** Union of every error code emitted by `@suaveplan/docs`. */
export type DocsErrorCode = (typeof DOCS_CODES)[keyof typeof DOCS_CODES];
/**
* Typed error for `@suaveplan/docs`. Extends `SuaveplanError` so consumers get
* the standard `code`, `cause`, `context`, and `timestamp` surface; raise via
* `new DocsError(DOCS_CODES.INVALID_OPTIONS, "rootDir must be non-empty")`.
*/
export class DocsError extends SuaveplanError<DocsErrorCode> {
constructor(
code: DocsErrorCode,
message: string,
options?: SuaveplanErrorOptions
) {
super(code, message, options);
this.name = "DocsError";
}
}
/**
* Lift an unknown thrown value into a {@link DocsError}, preserving the
* original `cause` and adopting its message when present. Used at every
* `catch` site to maintain the typed error contract.
*/
export function asDocsError(
code: DocsErrorCode,
cause: unknown,
fallbackMessage: string
): DocsError {
const message =
cause instanceof Error && cause.message
? cause.message
: fallbackMessage;
const causeError =
cause instanceof Error ? cause : new Error(String(cause));
return new DocsError(code, message, { cause: causeError });
}

View File

@@ -0,0 +1,6 @@
export {
asDocsError,
DOCS_CODES,
DocsError,
type DocsErrorCode,
} from "./errors.js";

View File

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

View File

@@ -0,0 +1,179 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { parseDoc } from "../parser/parse.js";
import { buildGraph } from "./build-graph.js";
function makeDoc(relativePath: string, content: string) {
return parseDoc(`/repo/${relativePath}`, relativePath, content);
}
describe("buildGraph", () => {
it("indexes all docs by id", { timeout: 5000 }, () => {
const docs = [
makeDoc("packages/foundation/core/utils/README.md", "# Utils"),
makeDoc("packages/foundation/core/utils/src/string.md", "# String"),
];
const graph = buildGraph(docs);
expect(graph.nodes.size).toBe(2);
});
it("resolves relative links between docs", { timeout: 5000 }, () => {
const readme = makeDoc(
"packages/foundation/core/utils/README.md",
"# Utils\n\n[String](./src/string.md)"
);
const string = makeDoc(
"packages/foundation/core/utils/src/string.md",
"# String"
);
const graph = buildGraph([readme, string]);
expect(graph.references.get(readme.id)?.has(string.id)).toBe(true);
expect(graph.referencedBy.get(string.id)?.has(readme.id)).toBe(true);
});
it("resolves links by appending .md when missing", {
timeout: 5000,
}, () => {
const readme = makeDoc(
"packages/foundation/core/utils/README.md",
"# Utils\n\n[String](./src/string)"
);
const string = makeDoc(
"packages/foundation/core/utils/src/string.md",
"# String"
);
const graph = buildGraph([readme, string]);
expect(graph.references.get(readme.id)?.has(string.id)).toBe(true);
});
it("falls back to basename match when path differs", {
timeout: 5000,
}, () => {
const readme = makeDoc(
"packages/foundation/core/utils/README.md",
"# Utils\n\n[String](string.md)"
);
const string = makeDoc(
"packages/foundation/core/utils/src/string.md",
"# String"
);
const graph = buildGraph([readme, string]);
expect(graph.references.get(readme.id)?.has(string.id)).toBe(true);
});
it("rejects empty href after fragment strip", { timeout: 5000 }, () => {
const doc = makeDoc(
"packages/foundation/core/utils/README.md",
"# Utils\n\n[Hash only](#fragment-only)"
);
const graph = buildGraph([doc]);
expect(graph.references.get(doc.id)?.size).toBe(0);
});
it("does not create self-references", { timeout: 5000 }, () => {
const doc = makeDoc(
"packages/foundation/core/utils/README.md",
"# Utils\n\n[Self](./README.md)"
);
const graph = buildGraph([doc]);
expect(graph.references.get(doc.id)?.size).toBe(0);
});
it("ignores external links", { timeout: 5000 }, () => {
const doc = makeDoc(
"packages/foundation/core/utils/README.md",
"# Utils\n\n[External](https://example.com)"
);
const graph = buildGraph([doc]);
expect(graph.references.get(doc.id)?.size).toBe(0);
});
it("ignores mailto links", { timeout: 5000 }, () => {
const doc = makeDoc(
"packages/foundation/core/utils/README.md",
"# Utils\n\n[Mail](mailto:team@example.com)"
);
const graph = buildGraph([doc]);
expect(graph.references.get(doc.id)?.size).toBe(0);
});
it("ignores anchor-only links", { timeout: 5000 }, () => {
const doc = makeDoc(
"packages/foundation/core/utils/README.md",
"# Utils\n\n[Anchor](#section)"
);
const graph = buildGraph([doc]);
expect(graph.references.get(doc.id)?.size).toBe(0);
});
it("designates README files as roots", { timeout: 5000 }, () => {
const readme = makeDoc(
"packages/foundation/core/utils/README.md",
"# Utils"
);
const other = makeDoc(
"packages/foundation/core/utils/src/string.md",
"# String"
);
const graph = buildGraph([readme, other]);
expect(graph.roots).toHaveLength(1);
expect(graph.roots[0]?.id).toBe(readme.id);
});
it("identifies orphaned docs", { timeout: 5000 }, () => {
const readme = makeDoc(
"packages/foundation/core/utils/README.md",
"# Utils"
);
const orphan = makeDoc(
"packages/foundation/core/utils/src/orphan.md",
"# Orphan"
);
const graph = buildGraph([readme, orphan]);
expect(graph.orphans).toHaveLength(1);
expect(graph.orphans[0]?.id).toBe(orphan.id);
});
it("removes doc from orphans when linked", { timeout: 5000 }, () => {
const readme = makeDoc(
"packages/foundation/core/utils/README.md",
"# Utils\n\n[Orphan](./src/orphan.md)"
);
const orphan = makeDoc(
"packages/foundation/core/utils/src/orphan.md",
"# Orphan"
);
const graph = buildGraph([readme, orphan]);
expect(graph.orphans).toHaveLength(0);
});
it("does not flag root-category docs as orphans", {
timeout: 5000,
}, () => {
const rootDoc = makeDoc("CHANGELOG.md", "# Changelog");
const graph = buildGraph([rootDoc]);
expect(graph.orphans).toHaveLength(0);
});
it("handles links with fragment identifiers", { timeout: 5000 }, () => {
const readme = makeDoc(
"packages/foundation/core/utils/README.md",
"# Utils\n\n[String](./src/string.md#section)"
);
const string = makeDoc(
"packages/foundation/core/utils/src/string.md",
"# String"
);
const graph = buildGraph([readme, string]);
expect(graph.references.get(readme.id)?.has(string.id)).toBe(true);
});
it("leaves unresolved links with no resolvedId", { timeout: 5000 }, () => {
const readme = makeDoc(
"packages/foundation/core/utils/README.md",
"# Utils\n\n[Missing](./does/not/exist.md)"
);
const graph = buildGraph([readme]);
expect(readme.links[0]?.resolvedId).toBeUndefined();
expect(graph.references.get(readme.id)?.size).toBe(0);
});
});

View File

@@ -0,0 +1,88 @@
import { dirname, join, normalize } from "node:path";
import { createDocsObservability } from "../observability/observability.js";
import type { DocsObservability } from "../observability/observability.types.js";
import type { DocFile, DocGraph } from "../types/docs.types.js";
function resolveHref(
href: string,
docRelativePath: string,
pathIndex: Map<string, DocFile>
): string | undefined {
if (
href.startsWith("#") ||
href.startsWith("http") ||
href.startsWith("mailto:")
) {
return undefined;
}
// `String.split` always yields at least one element. The earlier
// `startsWith("#")` guard rejected the only input ("#fragment-only")
// whose first segment could be empty, so `hrefBase` is non-empty.
const hrefBase = href.split("#")[0] as string;
const docDir = dirname(docRelativePath);
const resolved = normalize(join(docDir, hrefBase)).replace(/\\/g, "/");
const direct = pathIndex.get(resolved);
if (direct !== undefined) return direct.id;
const withMd = resolved.endsWith(".md") ? resolved : `${resolved}.md`;
const md = pathIndex.get(withMd);
if (md !== undefined) return md.id;
for (const [path, doc] of pathIndex) {
if (path.endsWith(`/${hrefBase}`) || path === hrefBase) return doc.id;
}
return undefined;
}
/**
* Build a bidirectional reference graph from a list of {@link DocFile}s.
* READMEs are designated as roots. Orphans are non-README, non-root docs
* that no other doc links to. Records `docs_graph_build_duration_ms`.
*/
export function buildGraph(
docs: DocFile[],
observability?: DocsObservability
): DocGraph {
const obs = observability ?? createDocsObservability();
const started = performance.now();
const nodes = new Map<string, DocFile>();
const pathIndex = new Map<string, DocFile>();
const references = new Map<string, Set<string>>();
const referencedBy = new Map<string, Set<string>>();
for (const doc of docs) {
nodes.set(doc.id, doc);
pathIndex.set(doc.relativePath, doc);
references.set(doc.id, new Set());
referencedBy.set(doc.id, new Set());
}
for (const doc of docs) {
for (const link of doc.links) {
const resolvedId = resolveHref(
link.href,
doc.relativePath,
pathIndex
);
if (resolvedId !== undefined && resolvedId !== doc.id) {
link.resolvedId = resolvedId;
references.get(doc.id)?.add(resolvedId);
referencedBy.get(resolvedId)?.add(doc.id);
}
}
}
const roots = docs.filter((d) => d.isReadme);
const orphans = docs.filter(
(d) =>
!d.isReadme &&
referencedBy.get(d.id)?.size === 0 &&
d.category !== "root"
);
obs.histograms.graphBuildDurationMs.record(performance.now() - started);
return { nodes, references, referencedBy, orphans, roots };
}

View File

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

View File

@@ -0,0 +1,69 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { parseDoc } from "../parser/parse.js";
import { buildGraph } from "./build-graph.js";
import { findOrphans } from "./find-orphans.js";
function makeDoc(relativePath: string, content: string) {
return parseDoc(`/repo/${relativePath}`, relativePath, content);
}
describe("findOrphans", () => {
it("returns empty array when no orphans", { timeout: 5000 }, () => {
const readme = makeDoc(
"packages/foundation/core/utils/README.md",
"# Utils\n\n[String](./src/string.md)"
);
const string = makeDoc(
"packages/foundation/core/utils/src/string.md",
"# String"
);
const graph = buildGraph([readme, string]);
expect(findOrphans(graph)).toHaveLength(0);
});
it("returns orphans with suggested parent", { timeout: 5000 }, () => {
const readme = makeDoc(
"packages/foundation/core/utils/README.md",
"# Utils"
);
const orphan = makeDoc(
"packages/foundation/core/utils/src/orphan.md",
"# Orphan"
);
const graph = buildGraph([readme, orphan]);
const orphans = findOrphans(graph);
expect(orphans).toHaveLength(1);
expect(orphans[0]?.doc.id).toBe(orphan.id);
expect(orphans[0]?.suggestedParent?.id).toBe(readme.id);
});
it("returns orphan without suggestedParent when no README of same package", {
timeout: 5000,
}, () => {
const orphan = makeDoc(
"packages/devtools/docs/docs/src/orphan.md",
"# Orphan"
);
const graph = buildGraph([orphan]);
const orphans = findOrphans(graph);
expect(orphans).toHaveLength(1);
expect(orphans[0]?.suggestedParent).toBeUndefined();
});
it("sorts orphans by package name", { timeout: 5000 }, () => {
const readme1 = makeDoc(
"packages/foundation/core/aaa/README.md",
"# AAA"
);
const readme2 = makeDoc(
"packages/foundation/core/zzz/README.md",
"# ZZZ"
);
const orphan1 = makeDoc("packages/foundation/core/zzz/src/z.md", "# Z");
const orphan2 = makeDoc("packages/foundation/core/aaa/src/a.md", "# A");
const graph = buildGraph([readme1, readme2, orphan1, orphan2]);
const orphans = findOrphans(graph);
expect(orphans[0]?.doc.packageName).toBe("@suaveplan/aaa");
expect(orphans[1]?.doc.packageName).toBe("@suaveplan/zzz");
});
});

View File

@@ -0,0 +1,25 @@
import type { DocFile, DocGraph } from "../types/docs.types.js";
/** An orphaned document with an optional parent suggestion. */
export interface OrphanInfo {
doc: DocFile;
/** The README of the same package — natural place to add a reference. */
suggestedParent?: DocFile;
}
/**
* Return every orphan in `graph.orphans` paired with the README of the same
* package (when one exists) and sorted ascending by `packageName`.
*/
export function findOrphans(graph: DocGraph): OrphanInfo[] {
return graph.orphans
.map<OrphanInfo>((doc) => {
const suggestedParent = graph.roots.find(
(r) => r.packageName === doc.packageName && r.isReadme
);
return suggestedParent !== undefined
? { doc, suggestedParent }
: { doc };
})
.sort((a, b) => a.doc.packageName.localeCompare(b.doc.packageName));
}

View File

@@ -0,0 +1,2 @@
export { buildGraph } from "./build-graph.js";
export { findOrphans, type OrphanInfo } from "./find-orphans.js";

View File

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

View File

@@ -0,0 +1,242 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { buildGraph } from "../graph/build-graph.js";
import { parseDoc } from "../parser/parse.js";
import { buildHierarchy } from "./build-hierarchy.js";
function makeDoc(relativePath: string, content: string) {
return parseDoc(`/repo/${relativePath}`, relativePath, content);
}
describe("buildHierarchy", () => {
it("creates root nodes from README files", { timeout: 5000 }, () => {
const docs = [
makeDoc("packages/foundation/core/utils/README.md", "# Utils"),
makeDoc("packages/ui/react-ui/react-ui/README.md", "# React UI"),
];
const graph = buildGraph(docs);
const hierarchy = buildHierarchy(graph);
expect(hierarchy.roots).toHaveLength(2);
});
it("nests referenced docs as children", { timeout: 5000 }, () => {
const readme = makeDoc(
"packages/foundation/core/utils/README.md",
"# Utils\n\n[String](./src/string.md)"
);
const string = makeDoc(
"packages/foundation/core/utils/src/string.md",
"# String"
);
const graph = buildGraph([readme, string]);
const hierarchy = buildHierarchy(graph);
const root = hierarchy.roots.find((n) => n.doc.isReadme);
expect(root?.children).toHaveLength(1);
expect(root?.children[0]?.doc.filename).toBe("string.md");
});
it("groups roots by category", { timeout: 5000 }, () => {
const docs = [
makeDoc("packages/foundation/core/utils/README.md", "# Utils"),
makeDoc("packages/ui/react-ui/react-ui/README.md", "# UI"),
makeDoc("packages/api-stack/api/api/README.md", "# API"),
];
const graph = buildGraph(docs);
const hierarchy = buildHierarchy(graph);
expect(hierarchy.byCategory.foundation).toHaveLength(1);
expect(hierarchy.byCategory.ui).toHaveLength(1);
expect(hierarchy.byCategory["api-stack"]).toHaveLength(1);
});
it("exposes orphans from graph", { timeout: 5000 }, () => {
const readme = makeDoc(
"packages/foundation/core/utils/README.md",
"# Utils"
);
const orphan = makeDoc(
"packages/foundation/core/utils/src/orphan.md",
"# Orphan"
);
const graph = buildGraph([readme, orphan]);
const hierarchy = buildHierarchy(graph);
expect(hierarchy.orphans).toHaveLength(1);
expect(hierarchy.orphans[0]?.id).toBe(orphan.id);
});
it("handles circular references without infinite loop", {
timeout: 5000,
}, () => {
const readme = makeDoc(
"packages/foundation/core/utils/README.md",
"# Utils\n\n[Other](./src/other.md)"
);
const other = makeDoc(
"packages/foundation/core/utils/src/other.md",
"# Other\n\n[README](../README.md)"
);
const graph = buildGraph([readme, other]);
expect(() => buildHierarchy(graph)).not.toThrow();
const hierarchy = buildHierarchy(graph);
expect(hierarchy.roots).toHaveLength(1);
});
it("sorts roots within category alphabetically", {
timeout: 5000,
}, () => {
const docs = [
makeDoc("packages/foundation/core/zzz/README.md", "# ZZZ"),
makeDoc("packages/foundation/core/aaa/README.md", "# AAA"),
makeDoc("packages/foundation/core/mmm/README.md", "# MMM"),
];
const graph = buildGraph(docs);
const hierarchy = buildHierarchy(graph);
const names = hierarchy.byCategory.foundation?.map(
(n) => n.doc.packageName
);
expect(names).toEqual([
"@suaveplan/aaa",
"@suaveplan/mmm",
"@suaveplan/zzz",
]);
});
it("maxDepth 0 yields no children", { timeout: 5000 }, () => {
const readme = makeDoc(
"packages/foundation/core/utils/README.md",
"# Utils\n\n[String](./src/string.md)"
);
const string = makeDoc(
"packages/foundation/core/utils/src/string.md",
"# String"
);
const graph = buildGraph([readme, string]);
const hierarchy = buildHierarchy(graph, { maxDepth: 0 });
const root = hierarchy.roots[0];
expect(root?.children).toHaveLength(0);
});
it("sorts children with READMEs before non-READMEs by title", {
timeout: 5000,
}, () => {
// Link a non-README first then a README so the comparator sees
// (a=non-README, b=README) and returns `1` — exercising the
// second arm of the `a.doc.isReadme ? -1 : 1` ternary.
const readme = makeDoc(
"packages/foundation/core/utils/README.md",
"# Utils\n\n[Z](./src/zeta.md)\n\n[Sub](./sub/README.md)"
);
const sub = makeDoc(
"packages/foundation/core/utils/sub/README.md",
"# Sub Readme"
);
const zeta = makeDoc(
"packages/foundation/core/utils/src/zeta.md",
"# Zeta"
);
const graph = buildGraph([readme, sub, zeta]);
const hierarchy = buildHierarchy(graph);
const root = hierarchy.roots.find(
(n) =>
n.doc.relativePath ===
"packages/foundation/core/utils/README.md"
);
expect(root?.children[0]?.doc.isReadme).toBe(true);
expect(root?.children[1]?.doc.isReadme).toBe(false);
});
it("sorts children with mixed READMEs and non-READMEs across many", {
timeout: 5000,
}, () => {
// Three children with at least one README intermixed — the
// sort comparator must visit both (a=README, b=non-README) and
// (a=non-README, b=README) orderings.
const readme = makeDoc(
"packages/foundation/core/utils/README.md",
"# Utils\n\n[Z](./src/zeta.md)\n\n[Sub](./sub/README.md)\n\n[A](./src/alpha.md)"
);
const sub = makeDoc(
"packages/foundation/core/utils/sub/README.md",
"# Sub Readme"
);
const zeta = makeDoc(
"packages/foundation/core/utils/src/zeta.md",
"# Zeta"
);
const alpha = makeDoc(
"packages/foundation/core/utils/src/alpha.md",
"# Alpha"
);
const graph = buildGraph([readme, sub, zeta, alpha]);
const hierarchy = buildHierarchy(graph);
const root = hierarchy.roots.find(
(n) =>
n.doc.relativePath ===
"packages/foundation/core/utils/README.md"
);
expect(root?.children[0]?.doc.isReadme).toBe(true);
expect(root?.children[1]?.doc.title).toBe("Alpha");
expect(root?.children[2]?.doc.title).toBe("Zeta");
});
it("tolerates hand-rolled graph with missing references entry", {
timeout: 5000,
}, () => {
// Build a DocGraph manually that omits the references entry for
// the root. The `references.get(doc.id)` call returns undefined,
// and the inner loop falls back to `[]` — root has no children.
const readme = makeDoc(
"packages/foundation/core/utils/README.md",
"# Utils"
);
const hierarchy = buildHierarchy({
nodes: new Map([[readme.id, readme]]),
references: new Map(),
referencedBy: new Map(),
orphans: [],
roots: [readme],
});
expect(hierarchy.roots[0]?.children).toHaveLength(0);
});
it("tolerates hand-rolled graph referencing unknown ids", {
timeout: 5000,
}, () => {
// references points to an id not present in nodes — the falsy
// branch of `refDoc === undefined` fires.
const readme = makeDoc(
"packages/foundation/core/utils/README.md",
"# Utils"
);
const refs = new Map<string, Set<string>>();
refs.set(readme.id, new Set(["nonexistent-id"]));
const hierarchy = buildHierarchy({
nodes: new Map([[readme.id, readme]]),
references: refs,
referencedBy: new Map([[readme.id, new Set()]]),
orphans: [],
roots: [readme],
});
expect(hierarchy.roots[0]?.children).toHaveLength(0);
});
it("sorts same-kind children alphabetically by title", {
timeout: 5000,
}, () => {
const readme = makeDoc(
"packages/foundation/core/utils/README.md",
"# Utils\n\n[Zeta](./src/zeta.md)\n\n[Alpha](./src/alpha.md)"
);
const alpha = makeDoc(
"packages/foundation/core/utils/src/alpha.md",
"# Alpha"
);
const zeta = makeDoc(
"packages/foundation/core/utils/src/zeta.md",
"# Zeta"
);
const graph = buildGraph([readme, alpha, zeta]);
const hierarchy = buildHierarchy(graph);
const root = hierarchy.roots[0];
expect(root?.children[0]?.doc.title).toBe("Alpha");
expect(root?.children[1]?.doc.title).toBe("Zeta");
});
});

View File

@@ -0,0 +1,87 @@
import type {
BuildHierarchyOptions,
DocCategory,
DocFile,
DocGraph,
DocNode,
} from "../types/docs.types.js";
/** The full documentation hierarchy organised by roots and category. */
export interface DocHierarchy {
/** All README-rooted subtrees, one per README in the graph. */
roots: DocNode[];
/** Roots grouped by package category. */
byCategory: Partial<Record<DocCategory, DocNode[]>>;
/** All unreferenced docs (mirrors `graph.orphans`). */
orphans: DocFile[];
}
const DEFAULT_MAX_DEPTH = 8;
function buildNode(
doc: DocFile,
graph: DocGraph,
visited: Set<string>,
depth: number,
maxDepth: number
): DocNode {
visited.add(doc.id);
const children: DocNode[] = [];
// `buildGraph` invariant: `references` carries an entry for every id
// present in `nodes`, and every id inside a reference set is also in
// `nodes`. We rely on that here so the inner loop is straight-line.
if (depth < maxDepth) {
const refs = graph.references.get(doc.id);
for (const refId of refs ?? []) {
if (visited.has(refId)) continue;
const refDoc = graph.nodes.get(refId);
if (refDoc === undefined) continue;
children.push(
buildNode(refDoc, graph, visited, depth + 1, maxDepth)
);
}
}
children.sort((a, b) => {
if (a.doc.isReadme !== b.doc.isReadme) return a.doc.isReadme ? -1 : 1;
return a.doc.title.localeCompare(b.doc.title);
});
return { doc, children };
}
/**
* Build a hierarchy tree from a graph. Every README is a root; referenced
* docs become children (cycle-safe; descent stops at `maxDepth`, default 8).
*/
export function buildHierarchy(
graph: DocGraph,
options?: BuildHierarchyOptions
): DocHierarchy {
const maxDepth = options?.maxDepth ?? DEFAULT_MAX_DEPTH;
const roots: DocNode[] = [];
const byCategory: Partial<Record<DocCategory, DocNode[]>> = {};
const sortedRoots = [...graph.roots].sort((a, b) =>
a.packageName.localeCompare(b.packageName)
);
for (const rootDoc of sortedRoots) {
const visited = new Set<string>();
const node = buildNode(rootDoc, graph, visited, 0, maxDepth);
roots.push(node);
const cat = rootDoc.category;
const bucket = byCategory[cat] ?? [];
bucket.push(node);
byCategory[cat] = bucket;
}
for (const cat of Object.keys(byCategory) as DocCategory[]) {
byCategory[cat]?.sort((a, b) =>
a.doc.packageName.localeCompare(b.doc.packageName)
);
}
return { roots, byCategory, orphans: graph.orphans };
}

View File

@@ -0,0 +1 @@
export { buildHierarchy, type DocHierarchy } from "./build-hierarchy.js";

View File

@@ -0,0 +1,57 @@
/**
* `@suaveplan/docs` — server-tier markdown documentation toolkit. Monorepo-
* wide discovery, mdast-typed parsing, bidirectional reference graph,
* hierarchy builder, scored full-text search, typed `DocsError` registry,
* OpenTelemetry counters + histograms, Zod-validated `DiscoverOptions`,
* `AbortSignal` propagation.
*/
// Discovery
export { discoverDocs } from "./discovery/discover.js";
// Errors
export {
asDocsError,
DOCS_CODES,
DocsError,
type DocsErrorCode,
} from "./errors/errors.js";
// Graph
export { buildGraph } from "./graph/build-graph.js";
export { findOrphans, type OrphanInfo } from "./graph/find-orphans.js";
// Hierarchy
export {
buildHierarchy,
type DocHierarchy,
} from "./hierarchy/build-hierarchy.js";
// Observability
export { createDocsObservability } from "./observability/observability.js";
export type {
DocsCounters,
DocsHistograms,
DocsObservability,
DocsObservabilityOptions,
} from "./observability/observability.types.js";
// Parser
export { parseDoc } from "./parser/parse.js";
// Search
export { searchDocs } from "./search/search.js";
// Types
export {
type BuildHierarchyOptions,
type DiscoverOptions,
DiscoverOptionsSchema,
type DocCategory,
type DocFile,
type DocGraph,
type DocLink,
type DocNode,
GENESIS_CATEGORIES,
type SearchResult,
} from "./types/docs.types.js";

View File

@@ -0,0 +1,7 @@
export { createDocsObservability } from "./observability.js";
export type {
DocsCounters,
DocsHistograms,
DocsObservability,
DocsObservabilityOptions,
} from "./observability.types.js";

View File

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

View File

@@ -0,0 +1,44 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { createDocsObservability } from "./observability.js";
describe("createDocsObservability", () => {
it("createDocsObservability exposes four counters and four histograms", {
timeout: 5000,
}, () => {
const obs = createDocsObservability();
expect(obs.counters.discoveredTotal).toBeDefined();
expect(obs.counters.parseErrorTotal).toBeDefined();
expect(obs.counters.ioErrorTotal).toBeDefined();
expect(obs.counters.searchTotal).toBeDefined();
expect(obs.histograms.discoverDurationMs).toBeDefined();
expect(obs.histograms.parseDurationMs).toBeDefined();
expect(obs.histograms.graphBuildDurationMs).toBeDefined();
expect(obs.histograms.searchDurationMs).toBeDefined();
// Counters and histograms accept add / record without throwing.
obs.counters.discoveredTotal.add(1);
obs.counters.parseErrorTotal.add(1, { code: "DOCS.PARSE_FAILED" });
obs.counters.ioErrorTotal.add(1);
obs.counters.searchTotal.add(1);
obs.histograms.discoverDurationMs.record(1.5);
obs.histograms.parseDurationMs.record(0.5);
obs.histograms.graphBuildDurationMs.record(2.5);
obs.histograms.searchDurationMs.record(0.25);
});
it("returns supplied bundle when observability option is set", {
timeout: 5000,
}, () => {
const first = createDocsObservability();
const second = createDocsObservability({ observability: first });
expect(second).toBe(first);
});
it("accepts custom meter and tracer", { timeout: 5000 }, async () => {
const { metrics, trace } = await import("@opentelemetry/api");
const meter = metrics.getMeter("custom-meter");
const tracer = trace.getTracer("custom-tracer");
const obs = createDocsObservability({ meter, tracer });
expect(obs.meter).toBe(meter);
expect(obs.tracer).toBe(tracer);
});
});

View File

@@ -0,0 +1,79 @@
import { metrics, trace } from "@opentelemetry/api";
import { createCounter, createHistogram } from "@suaveplan/opentelemetry";
import type {
DocsObservability,
DocsObservabilityOptions,
Meter,
Tracer,
} from "./observability.types.js";
const METER_NAME = "@suaveplan/docs";
const TRACER_NAME = "@suaveplan/docs";
/**
* Build a {@link DocsObservability} bundle on the supplied meter and tracer,
* or on the no-op providers from `@opentelemetry/api` when none are supplied.
* Safe to call from every public entrypoint even when no telemetry pipeline
* is wired up.
*/
export function createDocsObservability(
opts?: DocsObservabilityOptions
): DocsObservability {
if (opts?.observability !== undefined) {
return opts.observability;
}
const meter: Meter = opts?.meter ?? metrics.getMeter(METER_NAME);
const tracer: Tracer = opts?.tracer ?? trace.getTracer(TRACER_NAME);
return {
meter,
tracer,
counters: {
discoveredTotal: createCounter(
meter,
"docs_discovered_total",
"Total docs successfully parsed across all discoverDocs calls."
),
parseErrorTotal: createCounter(
meter,
"docs_parse_error_total",
"Total parseDoc failures partitioned by code."
),
ioErrorTotal: createCounter(
meter,
"docs_io_error_total",
"Total readFile / glob failures during discovery."
),
searchTotal: createCounter(
meter,
"docs_search_total",
"Total searchDocs invocations."
),
},
histograms: {
discoverDurationMs: createHistogram(
meter,
"docs_discover_duration_ms",
"Wall-clock time of each discoverDocs call.",
"ms"
),
parseDurationMs: createHistogram(
meter,
"docs_parse_duration_ms",
"Wall-clock time of each parseDoc call.",
"ms"
),
graphBuildDurationMs: createHistogram(
meter,
"docs_graph_build_duration_ms",
"Wall-clock time of each buildGraph call.",
"ms"
),
searchDurationMs: createHistogram(
meter,
"docs_search_duration_ms",
"Wall-clock time of each searchDocs call.",
"ms"
),
},
};
}

View File

@@ -0,0 +1,45 @@
import type { Counter, Histogram, Meter, Tracer } from "@opentelemetry/api";
export type { Counter, Histogram, Meter, Tracer };
/** Counters emitted by `@suaveplan/docs`. */
export interface DocsCounters {
/** Total docs successfully parsed across all `discoverDocs` calls. */
readonly discoveredTotal: Counter;
/** Total `parseDoc` failures partitioned by `code`. */
readonly parseErrorTotal: Counter;
/** Total `readFile` / `glob` failures during discovery. */
readonly ioErrorTotal: Counter;
/** Total `searchDocs` invocations. */
readonly searchTotal: Counter;
}
/** Histograms emitted by `@suaveplan/docs`. */
export interface DocsHistograms {
/** Wall-clock time of each `discoverDocs` call (ms). */
readonly discoverDurationMs: Histogram;
/** Wall-clock time of each `parseDoc` call (ms). */
readonly parseDurationMs: Histogram;
/** Wall-clock time of each `buildGraph` call (ms). */
readonly graphBuildDurationMs: Histogram;
/** Wall-clock time of each `searchDocs` call (ms). */
readonly searchDurationMs: Histogram;
}
/** Bundle of meter + tracer + counters + histograms. */
export interface DocsObservability {
readonly meter: Meter;
readonly tracer: Tracer;
readonly counters: DocsCounters;
readonly histograms: DocsHistograms;
}
/** Options accepted by `createDocsObservability`. */
export interface DocsObservabilityOptions {
/** Pre-built bundle — if supplied, skips meter / tracer construction. */
observability?: DocsObservability;
/** Explicit meter; defaults to `metrics.getMeter("@suaveplan/docs")`. */
meter?: Meter;
/** Explicit tracer; defaults to `trace.getTracer("@suaveplan/docs")`. */
tracer?: Tracer;
}

View File

@@ -0,0 +1 @@
export { parseDoc } from "./parse.js";

View File

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

View File

@@ -0,0 +1,166 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { GENESIS_CATEGORIES } from "../types/docs.types.js";
import { parseDoc } from "./parse.js";
const BASE_PATH = "/repo/packages/devtools/docs/docs/README.md";
const BASE_REL = "packages/devtools/docs/docs/README.md";
describe("parseDoc", () => {
it("extracts title from first H1", { timeout: 5000 }, () => {
const doc = parseDoc(
BASE_PATH,
BASE_REL,
"# My Package\n\nSome content."
);
expect(doc.title).toBe("My Package");
});
it("falls back to filename when no H1", { timeout: 5000 }, () => {
const doc = parseDoc(BASE_PATH, BASE_REL, "## Not a title\n\nContent.");
expect(doc.title).toBe("README");
});
it("prefers frontmatter title over H1", { timeout: 5000 }, () => {
const content =
'---\ntitle: "Override Title"\n---\n# H1 Title\n\nContent.';
const doc = parseDoc(BASE_PATH, BASE_REL, content);
expect(doc.title).toBe("Override Title");
});
it("parses frontmatter", { timeout: 5000 }, () => {
const content = "---\norder: 1\ntags: [a, b]\n---\n# Doc\n\nContent.";
const doc = parseDoc(BASE_PATH, BASE_REL, content);
expect(doc.frontmatter.order).toBe(1);
expect(doc.frontmatter.tags).toEqual(["a", "b"]);
});
it("extracts links", { timeout: 5000 }, () => {
const content =
"# Doc\n\nSee [string utils](./src/string.md) and [external](https://example.com).";
const doc = parseDoc(BASE_PATH, BASE_REL, content);
expect(doc.links).toHaveLength(2);
expect(doc.links[0]?.href).toBe("./src/string.md");
expect(doc.links[0]?.text).toBe("string utils");
expect(doc.links[1]?.href).toBe("https://example.com");
});
it("extracts link with empty text when no text child", {
timeout: 5000,
}, () => {
// A link whose body is an image (non-text child) — text falls back to "".
const content = "# Doc\n\n[![alt](./img.png)](./target.md)";
const doc = parseDoc(BASE_PATH, BASE_REL, content);
const link = doc.links.find((l) => l.href === "./target.md");
expect(link).toBeDefined();
expect(link?.text).toBe("");
});
it("recognises every genesis category", { timeout: 5000 }, () => {
for (const cat of GENESIS_CATEGORIES) {
const rel = `packages/${cat}/plug/pkg/README.md`;
const doc = parseDoc(`/repo/${rel}`, rel, "# Test");
expect(doc.category).toBe(cat);
}
});
it("uses root for unknown packages/<x>/ category", {
timeout: 5000,
}, () => {
const rel = "packages/unknown-tier/plug/pkg/README.md";
const doc = parseDoc(`/repo/${rel}`, rel, "# Test");
expect(doc.category).toBe("root");
});
it("assigns root category for top-level files", { timeout: 5000 }, () => {
const doc = parseDoc("/repo/README.md", "README.md", "# Root");
expect(doc.category).toBe("root");
});
it("derives package name from genesis layout", { timeout: 5000 }, () => {
const doc = parseDoc(BASE_PATH, BASE_REL, "# Test");
expect(doc.packageName).toBe("@suaveplan/docs");
});
it("derives app: package name from apps/ paths", { timeout: 5000 }, () => {
const rel = "apps/landing/README.md";
const doc = parseDoc(`/repo/${rel}`, rel, "# Landing");
expect(doc.packageName).toBe("app:landing");
});
it("derives root package name for top-level files", {
timeout: 5000,
}, () => {
const doc = parseDoc("/repo/CHANGELOG.md", "CHANGELOG.md", "# Log");
expect(doc.packageName).toBe("root");
});
it("marks README.md as isReadme", { timeout: 5000 }, () => {
const doc = parseDoc(BASE_PATH, BASE_REL, "# Test");
expect(doc.isReadme).toBe(true);
});
it("marks non-README as not isReadme", { timeout: 5000 }, () => {
const rel = "packages/devtools/docs/docs/src/string.md";
const doc = parseDoc(`/repo/${rel}`, rel, "# String");
expect(doc.isReadme).toBe(false);
});
it("generates stable id from relativePath", { timeout: 5000 }, () => {
const d1 = parseDoc(BASE_PATH, BASE_REL, "# A");
const d2 = parseDoc(BASE_PATH, BASE_REL, "# B");
expect(d1.id).toBe(d2.id);
expect(d1.id).toHaveLength(16);
});
it("generates unique ids for different paths", { timeout: 5000 }, () => {
const d1 = parseDoc(BASE_PATH, BASE_REL, "# Test");
const rel2 = "packages/foundation/core/logger/README.md";
const d2 = parseDoc(`/repo/${rel2}`, rel2, "# Test");
expect(d1.id).not.toBe(d2.id);
});
it("handles GFM tables", { timeout: 5000 }, () => {
const content = "# Doc\n\n| A | B |\n|---|---|\n| 1 | 2 |";
const doc = parseDoc(BASE_PATH, BASE_REL, content);
expect(doc.title).toBe("Doc");
});
it("preserves raw content including frontmatter", { timeout: 5000 }, () => {
const raw = "---\ntitle: Test\n---\n# Title\n\nContent.";
const doc = parseDoc(BASE_PATH, BASE_REL, raw);
expect(doc.content).toBe(raw);
});
it("ignores non-string frontmatter.title and keeps H1", {
timeout: 5000,
}, () => {
const raw = "---\ntitle: 123\n---\n# Real Title\n";
const doc = parseDoc(BASE_PATH, BASE_REL, raw);
expect(doc.title).toBe("Real Title");
});
it("ignores deeper headings when picking title", {
timeout: 5000,
}, () => {
// Heading depth 2 alone should leave title at filename fallback.
const doc = parseDoc(BASE_PATH, BASE_REL, "## Sub heading only");
expect(doc.title).toBe("README");
});
it("ignores H1 without text child", { timeout: 5000 }, () => {
// A heading containing only an image (no text child) — fallback applies.
const doc = parseDoc(BASE_PATH, BASE_REL, "# ![alt](./img.png)\n");
expect(doc.title).toBe("README");
});
it("wraps gray-matter failure in DocsError", {
timeout: 5000,
}, async () => {
const { DocsError } = await import("../errors/errors.js");
// Unterminated YAML frontmatter forces gray-matter to throw.
const malformed = "---\ntitle: [unterminated\n# Doc\n";
expect(() => parseDoc(BASE_PATH, BASE_REL, malformed)).toThrow(
DocsError
);
});
});

View File

@@ -0,0 +1,125 @@
import { createHash } from "node:crypto";
import { basename } from "node:path";
import matter from "gray-matter";
import type { Heading, Link, Root, Text } from "mdast";
import remarkGfm from "remark-gfm";
import remarkParse from "remark-parse";
import { unified } from "unified";
import { visit } from "unist-util-visit";
import { asDocsError, DOCS_CODES } from "../errors/errors.js";
import { createDocsObservability } from "../observability/observability.js";
import type { DocsObservability } from "../observability/observability.types.js";
import {
type DocCategory,
type DocFile,
type DocLink,
GENESIS_CATEGORIES,
} from "../types/docs.types.js";
const CATEGORY_SET: ReadonlySet<string> = new Set<string>(GENESIS_CATEGORIES);
function deriveCategory(relativePath: string): DocCategory {
if (!relativePath.startsWith("packages/")) return "root";
const segment = relativePath.split("/")[1];
if (segment !== undefined && CATEGORY_SET.has(segment)) {
return segment as DocCategory;
}
return "root";
}
function derivePackageName(relativePath: string): string {
const pkgMatch = relativePath.match(/^packages\/[^/]+\/[^/]+\/([^/]+)\//);
if (pkgMatch?.[1] !== undefined) return `@suaveplan/${pkgMatch[1]}`;
const appMatch = relativePath.match(/^apps\/([^/]+)\//);
if (appMatch?.[1] !== undefined) return `app:${appMatch[1]}`;
return "root";
}
function extractTitle(
tree: Root,
frontmatter: Record<string, unknown>,
filename: string
): string {
let title = filename.replace(/\.md$/i, "");
let foundH1 = false;
visit(tree, "heading", (node: Heading) => {
if (foundH1 || node.depth !== 1) return;
const first = node.children[0];
if (first !== undefined && first.type === "text") {
title = (first as Text).value;
foundH1 = true;
}
});
const fmTitle = frontmatter["title"];
if (typeof fmTitle === "string") title = fmTitle;
return title;
}
function extractLinks(tree: Root): DocLink[] {
const links: DocLink[] = [];
visit(tree, "link", (node: Link) => {
const first = node.children[0];
const text =
first !== undefined && first.type === "text"
? (first as Text).value
: "";
links.push({ text, href: node.url });
});
return links;
}
/**
* Parse a markdown file into a structured {@link DocFile}. Extracts the title
* (frontmatter > first H1 > filename), every markdown link, and parsed
* frontmatter. Throws `DocsError(DOCS.PARSE_FAILED)` when `gray-matter` or
* `remark` raises on malformed input.
*/
export function parseDoc(
absolutePath: string,
relativePath: string,
rawContent: string,
observability?: DocsObservability
): DocFile {
const obs = observability ?? createDocsObservability();
const started = performance.now();
try {
const id = createHash("sha256")
.update(relativePath)
.digest("hex")
.slice(0, 16);
const filename = basename(absolutePath);
const parsed = matter(rawContent);
const frontmatter = parsed.data as Record<string, unknown>;
const content = parsed.content;
const processor = unified().use(remarkParse).use(remarkGfm);
const tree = processor.parse(content) as Root;
const title = extractTitle(tree, frontmatter, filename);
const links = extractLinks(tree);
const doc: DocFile = {
id,
path: absolutePath,
relativePath,
packageName: derivePackageName(relativePath),
category: deriveCategory(relativePath),
filename,
title,
content: rawContent,
links,
isReadme: filename === "README.md",
frontmatter,
};
obs.histograms.parseDurationMs.record(performance.now() - started);
return doc;
} catch (cause) {
obs.counters.parseErrorTotal.add(1, { code: DOCS_CODES.PARSE_FAILED });
obs.histograms.parseDurationMs.record(performance.now() - started);
throw asDocsError(
DOCS_CODES.PARSE_FAILED,
cause,
`parseDoc failed for ${relativePath}`
);
}
}

View File

@@ -0,0 +1 @@
export { searchDocs } from "./search.js";

View File

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

View File

@@ -0,0 +1,154 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { DocsError } from "../errors/errors.js";
import { parseDoc } from "../parser/parse.js";
import { searchDocs } from "./search.js";
function makeDoc(relativePath: string, content: string) {
return parseDoc(`/repo/${relativePath}`, relativePath, content);
}
const docs = [
makeDoc(
"packages/identity/auth/auth/README.md",
"# Authentication\n\nHandles auth tokens and sessions."
),
makeDoc(
"packages/foundation/core/utils/README.md",
"# Utils\n\nGeneral utility functions."
),
makeDoc(
"packages/ui/react-ui/react-auth/README.md",
"# React Auth\n\nAuthentication UI components."
),
makeDoc(
"packages/api-stack/api/api/README.md",
"# API Server\n\nHono-based REST API."
),
];
describe("searchDocs", () => {
it("returns empty array for empty query", { timeout: 5000 }, () => {
expect(searchDocs(docs, "")).toHaveLength(0);
expect(searchDocs(docs, " ")).toHaveLength(0);
});
it("finds docs by title", { timeout: 5000 }, () => {
const results = searchDocs(docs, "Utils");
expect(results.length).toBeGreaterThan(0);
expect(results[0]?.doc.title).toBe("Utils");
});
it("ranks exact title match highest", { timeout: 5000 }, () => {
const results = searchDocs(docs, "Utils");
const top = results[0];
expect(top?.score).toBeGreaterThanOrEqual(100);
});
it("ranks title-starts-with match above title-contains", {
timeout: 5000,
}, () => {
const results = searchDocs(docs, "Auth");
// Authentication starts with "Auth", React Auth merely contains it
// — both score 50 vs 20, plus pkg + content bonuses.
expect(results[0]?.doc.title.toLowerCase()).toMatch(/^auth/i);
});
it("is case-insensitive", { timeout: 5000 }, () => {
const lower = searchDocs(docs, "auth");
const upper = searchDocs(docs, "AUTH");
expect(lower.map((r) => r.doc.id)).toEqual(upper.map((r) => r.doc.id));
});
it("finds docs by package name", { timeout: 5000 }, () => {
const results = searchDocs(docs, "react-auth");
expect(
results.some((r) => r.doc.packageName === "@suaveplan/react-auth")
).toBe(true);
});
it("finds docs by content", { timeout: 5000 }, () => {
const results = searchDocs(docs, "Hono-based");
expect(
results.some((r) => r.doc.packageName === "@suaveplan/api")
).toBe(true);
});
it("returns snippets with match context", { timeout: 5000 }, () => {
const results = searchDocs(docs, "tokens");
const first = results[0];
expect(first?.matches.some((m) => m.includes("tokens"))).toBe(true);
});
it("respects limit parameter", { timeout: 5000 }, () => {
const results = searchDocs(docs, "a", 2);
expect(results.length).toBeLessThanOrEqual(2);
});
it("sorts by score descending", { timeout: 5000 }, () => {
const results = searchDocs(docs, "auth");
for (let i = 1; i < results.length; i++) {
const prev = results[i - 1];
const cur = results[i];
if (prev !== undefined && cur !== undefined) {
expect(prev.score).toBeGreaterThanOrEqual(cur.score);
}
}
});
it("returns no results for nonsense query", { timeout: 5000 }, () => {
const results = searchDocs(docs, "xyzzy_nonexistent_zzz");
expect(results).toHaveLength(0);
});
it("caps content matches at five per doc", { timeout: 5000 }, () => {
const noisy = makeDoc(
"packages/foundation/core/x/README.md",
"# X\n\nabc abc abc abc abc abc abc abc abc abc"
);
const results = searchDocs([noisy], "abc");
// Title is "X" (no match) — so score = 5 (cap) and matches has 5
// content snippets only.
const first = results[0];
expect(first?.score).toBe(5);
expect(first?.matches.filter((m) => m.startsWith("...")).length).toBe(
5
);
});
it("dedupes package-name match against title-already-matched", {
timeout: 5000,
}, () => {
const d = makeDoc(
"packages/foundation/core/auth/README.md",
"# Auth\n\nNo extra body"
);
const results = searchDocs([d], "auth");
const first = results[0];
expect(
first?.matches.filter((m) => m === "@suaveplan/auth")
).toHaveLength(1);
});
it("dedupes when title equals package name", { timeout: 5000 }, () => {
// Title equals packageName ("@suaveplan/auth"). Searching for the
// shared substring matches the title (pushes "@suaveplan/auth") and
// then the package-name branch must NOT push again.
const d = makeDoc(
"packages/identity/auth/auth/README.md",
"# @suaveplan/auth\n\nbody."
);
const results = searchDocs([d], "@suaveplan/auth");
const first = results[0];
expect(
first?.matches.filter((m) => m === "@suaveplan/auth")
).toHaveLength(1);
});
it("throws SEARCH_INVALID_QUERY on non-string query", {
timeout: 5000,
}, () => {
expect(() => searchDocs(docs, 42 as unknown as string)).toThrow(
DocsError
);
});
});

View File

@@ -0,0 +1,98 @@
import { DOCS_CODES, DocsError } from "../errors/errors.js";
import { createDocsObservability } from "../observability/observability.js";
import type { DocsObservability } from "../observability/observability.types.js";
import type { DocFile, SearchResult } from "../types/docs.types.js";
const DEFAULT_LIMIT = 20;
const MAX_CONTENT_MATCHES = 5;
const SNIPPET_RADIUS = 40;
/**
* Search `docs` for `query`. Scoring (higher = more relevant):
*
* - 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).
*
* Returns up to `limit` (default 20) hits sorted by score descending.
* Records `docs_search_total` and `docs_search_duration_ms`.
*/
export function searchDocs(
docs: DocFile[],
query: string,
limit: number = DEFAULT_LIMIT,
observability?: DocsObservability
): SearchResult[] {
if (typeof query !== "string") {
throw new DocsError(
DOCS_CODES.SEARCH_INVALID_QUERY,
`searchDocs query must be a string, received ${typeof query}`
);
}
const obs = observability ?? createDocsObservability();
const started = performance.now();
obs.counters.searchTotal.add(1);
const trimmed = query.trim();
if (trimmed === "") {
obs.histograms.searchDurationMs.record(performance.now() - started);
return [];
}
const q = trimmed.toLowerCase();
const results: SearchResult[] = [];
for (const doc of docs) {
const matches: string[] = [];
let score = 0;
const titleLower = doc.title.toLowerCase();
if (titleLower === q) {
score += 100;
matches.push(doc.title);
} else if (titleLower.startsWith(q)) {
score += 50;
matches.push(doc.title);
} else if (titleLower.includes(q)) {
score += 20;
matches.push(doc.title);
}
const pkgLower = doc.packageName.toLowerCase();
if (pkgLower.includes(q)) {
score += 15;
if (!matches.includes(doc.packageName)) {
matches.push(doc.packageName);
}
}
const contentLower = doc.content.toLowerCase();
let searchFrom = 0;
let matchCount = 0;
while (matchCount < MAX_CONTENT_MATCHES) {
const idx = contentLower.indexOf(q, searchFrom);
if (idx === -1) break;
score += 1;
const start = Math.max(0, idx - SNIPPET_RADIUS);
const end = Math.min(
doc.content.length,
idx + q.length + SNIPPET_RADIUS
);
const snippet = doc.content
.slice(start, end)
.replace(/\n+/g, " ")
.trim();
matches.push(`...${snippet}...`);
searchFrom = idx + 1;
matchCount++;
}
if (score > 0) results.push({ doc, matches, score });
}
const sorted = results.sort((a, b) => b.score - a.score).slice(0, limit);
obs.histograms.searchDurationMs.record(performance.now() - started);
return sorted;
}

View File

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

View File

@@ -0,0 +1,129 @@
import z from "zod";
/**
* Closed vocabulary of genesis super-categories plus `"root"` for top-level
* paths outside `packages/`. Kept in sync with `CLAUDE.md` and
* `scripts/folder-reorg/assignment.yml`.
*/
export const GENESIS_CATEGORIES = [
"foundation",
"persistence",
"networking",
"communication",
"api-stack",
"identity",
"security-stack",
"observability",
"media",
"ui",
"threejs",
"localization",
"geospatial",
"money",
"commerce",
"testkit",
"devtools",
"gaming",
] as const;
/** Union of every recognised genesis category plus `"root"`. */
export type DocCategory = (typeof GENESIS_CATEGORIES)[number] | "root";
/** A markdown link extracted from a parsed document. */
export interface DocLink {
/** Link display text — the first text child of the mdast `link` node. */
text: string;
/** Raw href from the markdown. */
href: string;
/** Resolved `DocFile.id` when the link points to another doc in the graph. */
resolvedId?: string;
}
/** A single markdown document discovered by `discoverDocs`. */
export interface DocFile {
/** Stable 16-char hex hash of `relativePath`. */
id: string;
/** Absolute filesystem path. */
path: string;
/** Path relative to the monorepo root. */
relativePath: string;
/** Package name — `@suaveplan/<pkg>`, `app:<name>`, or `"root"`. */
packageName: string;
/** Category derived from the path. */
category: DocCategory;
/** Basename — e.g. `"README.md"`. */
filename: string;
/** First H1 heading, frontmatter title, or filename without extension. */
title: string;
/** Raw markdown content including frontmatter. */
content: string;
/** All extracted links. */
links: DocLink[];
/** Whether the filename is `"README.md"`. */
isReadme: boolean;
/** Parsed frontmatter key-value pairs. */
frontmatter: Record<string, unknown>;
}
/** A node in the documentation hierarchy tree. */
export interface DocNode {
doc: DocFile;
children: DocNode[];
}
/** Bidirectional reference graph of every discovered doc. */
export interface DocGraph {
/** Every doc indexed by id. */
nodes: Map<string, DocFile>;
/** id → set of doc ids this doc links to. */
references: Map<string, Set<string>>;
/** id → set of doc ids that link to this doc. */
referencedBy: Map<string, Set<string>>;
/** Docs not referenced by any other doc (excluding READMEs and root tier). */
orphans: DocFile[];
/** README.md files — treated as hierarchy roots. */
roots: DocFile[];
}
/** Options accepted by `discoverDocs`. */
export interface DiscoverOptions {
/** SuavePlan root directory. */
rootDir: string;
/** Additional glob patterns to ignore (in addition to defaults). */
ignore?: string[];
/** Whether to include `apps/` (default `false`). */
includeApps?: boolean;
/** Optional `AbortSignal` for cancellable scans. */
signal?: AbortSignal;
}
/** Zod schema enforcing the `DiscoverOptions` boundary. */
export const DiscoverOptionsSchema = z.object({
rootDir: z.string().min(1),
ignore: z.array(z.string()).optional(),
includeApps: z.boolean().optional(),
signal: z
.custom<AbortSignal>(
(val) =>
typeof val === "object" &&
val !== null &&
"aborted" in (val as object),
{ message: "signal must be an AbortSignal" }
)
.optional(),
});
/** A scored search hit. */
export interface SearchResult {
doc: DocFile;
/** Snippets containing the query (or the matched title / package name). */
matches: string[];
/** Higher = more relevant. */
score: number;
}
/** Options for `buildHierarchy`. */
export interface BuildHierarchyOptions {
/** Maximum descent depth from any root (default 8). */
maxDepth?: number;
}

View File

@@ -0,0 +1,12 @@
export {
type BuildHierarchyOptions,
type DiscoverOptions,
DiscoverOptionsSchema,
type DocCategory,
type DocFile,
type DocGraph,
type DocLink,
type DocNode,
GENESIS_CATEGORIES,
type SearchResult,
} from "./docs.types.js";

View File

@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"lib": ["ES2022"],
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.test.ts", "dist", "node_modules"]
}

View File

@@ -0,0 +1,60 @@
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
export default defineConfig({
root: __dirname,
cacheDir: "../../../../node_modules/.vite/packages/docs",
plugins: [
dts({
entryRoot: "src",
tsconfigPath: "./tsconfig.lib.json",
insertTypesEntry: true,
}),
],
build: {
outDir: "./dist",
emptyOutDir: true,
reportCompressedSize: true,
sourcemap: true,
lib: {
entry: {
index: "src/index.ts",
"discovery/discover": "src/discovery/discover.ts",
"parser/parse": "src/parser/parse.ts",
"graph/build-graph": "src/graph/build-graph.ts",
"graph/find-orphans": "src/graph/find-orphans.ts",
"hierarchy/build-hierarchy":
"src/hierarchy/build-hierarchy.ts",
"search/search": "src/search/search.ts",
"errors/errors": "src/errors/errors.ts",
"observability/observability":
"src/observability/observability.ts",
"types/docs.types": "src/types/docs.types.ts",
},
formats: ["es"],
},
rollupOptions: {
treeshake: true,
output: {
preserveModules: true,
preserveModulesRoot: "src",
entryFileNames: "[name].js",
},
external: [
"@opentelemetry/api",
"@suaveplan/error",
"@suaveplan/opentelemetry",
"@suaveplan/types",
"glob",
"gray-matter",
"remark-gfm",
"remark-parse",
"tslib",
"unified",
"unist-util-visit",
"zod",
/^node:/,
],
},
},
});

View File

@@ -0,0 +1,6 @@
import { createTestConfig } from "@suaveplan/testing/config";
export default createTestConfig({
environment: "node",
testTimeout: 5000,
});

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