first commit
This commit is contained in:
97
docs/api-stack/api/api-plugin-cms/README.md
Normal file
97
docs/api-stack/api/api-plugin-cms/README.md
Normal file
@@ -0,0 +1,97 @@
|
||||
# @suaveplan/api-plugin-cms - Headless CMS primitives for @suaveplan/api: in-memory CmsEngine over @suaveplan/events with content models, entries, status lifecycle, version history, slug uniqueness, Zod-validated plugin options, RFC 7807 problem+json renderer, and an OTel instrument bundle. Server-tier — Bun + Node.
|
||||
|
||||
Headless content-management primitives for `@suaveplan/api`. Ships an
|
||||
in-memory `CmsEngine` that manages flexible content models, content
|
||||
entries with a `draft → review → published → archived` status
|
||||
lifecycle, slug uniqueness within a model, and a per-entry version
|
||||
trail. The package factory wraps the engine in a typed
|
||||
`PluginDescriptor` consumable by `@suaveplan/api-plugin`'s
|
||||
`PluginRegistry`; the engine is exposed under the `cms` slot of the
|
||||
host `ServiceRegistry`.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Documentation tree
|
||||
|
||||
- [engine](./src/engine/cms-engine.md) — `CmsEngine` is the headless content-management core for `@suaveplan/api-plugin-cms`. It manages flexible content models…
|
||||
- [cms-engine](./src/engine/cms-engine.md) — `CmsEngine` is the headless content-management core for `@suaveplan/api-plugin-cms`. It manages flexible content models…
|
||||
- [error-codes](./src/error-codes/error-codes.md) — This module owns the stable error-code namespace `CMS.*` for the package and the concrete `CmsError` class that consume…
|
||||
- [error-codes](./src/error-codes/error-codes.md) — This module owns the stable error-code namespace `CMS.*` for the package and the concrete `CmsError` class that consume…
|
||||
- [otel](./src/otel/instruments.md) — The package emits seven OpenTelemetry counters on every production mutation so operators can dashboard CMS activity and…
|
||||
- [instruments](./src/otel/instruments.md) — The package emits seven OpenTelemetry counters on every production mutation so operators can dashboard CMS activity and…
|
||||
- [plugin](./src/plugin/plugin.md) — Wraps `CmsEngine` in a typed `PluginDescriptor` consumable by `@suaveplan/api-plugin`'s `PluginRegistry`. The factory m…
|
||||
- [plugin](./src/plugin/plugin.md) — Wraps `CmsEngine` in a typed `PluginDescriptor` consumable by `@suaveplan/api-plugin`'s `PluginRegistry`. The factory m…
|
||||
- [routes](./src/routes/problem-json.md) — Render `CmsError` (and arbitrary throwables) as RFC 7807 problem+json HTTP responses without requiring `@suaveplan/api-…
|
||||
- [problem-json](./src/routes/problem-json.md) — Render `CmsError` (and arbitrary throwables) as RFC 7807 problem+json HTTP responses without requiring `@suaveplan/api-…
|
||||
- [schemas](./src/schemas/options.md) — Zod schema validating the `CmsPluginOptions` bundle accepted by `createCmsPlugin(...)`. Boundary validation at the plug…
|
||||
- [options](./src/schemas/options.md) — Zod schema validating the `CmsPluginOptions` bundle accepted by `createCmsPlugin(...)`. Boundary validation at the plug…
|
||||
- [services](./src/services/registry-augmentation.md) — Declares the `cms` slot on the `@suaveplan/api-plugin/service-registry`'s `ServiceRegistry` interface via TypeScript mo…
|
||||
- [registry-augmentation](./src/services/registry-augmentation.md) — Declares the `cms` slot on the `@suaveplan/api-plugin/service-registry`'s `ServiceRegistry` interface via TypeScript mo…
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
bun add @suaveplan/api-plugin-cms
|
||||
```
|
||||
|
||||
## Quick start
|
||||
|
||||
```ts
|
||||
import { createCmsPlugin } from "@suaveplan/api-plugin-cms";
|
||||
import { createPluginRegistry } from "@suaveplan/api-plugin";
|
||||
|
||||
const registry = createPluginRegistry();
|
||||
registry.register(createCmsPlugin({}));
|
||||
await registry.initAll();
|
||||
|
||||
const cms = registry.context("cms").getService("cms");
|
||||
const model = cms?.createModel({
|
||||
name: "Blog post",
|
||||
slug: "post",
|
||||
contentType: "post",
|
||||
fields: [
|
||||
{ name: "title", type: "text", label: "Title", required: true },
|
||||
{ name: "body", type: "richtext", label: "Body" },
|
||||
],
|
||||
});
|
||||
const entry = cms?.createContent({
|
||||
modelId: model!.id,
|
||||
title: "Hello world",
|
||||
data: { title: "Hello world", body: "Welcome." },
|
||||
});
|
||||
cms?.transitionStatus(entry!.id, "published");
|
||||
```
|
||||
|
||||
## Rendering errors as RFC 7807 problem+json
|
||||
|
||||
Use `respondProblemJson(res, err)` from your route's error handler.
|
||||
The renderer maps every `CMS.*` code to the documented HTTP status
|
||||
(404 / 400 / 409 / 500).
|
||||
|
||||
```ts
|
||||
import { respondProblemJson } from "@suaveplan/api-plugin-cms";
|
||||
|
||||
app.onError((err, c) => respondProblemJson(c, err));
|
||||
```
|
||||
|
||||
## Modules
|
||||
|
||||
- [`engine/cms-engine`](./src/engine/cms-engine.md) — `CmsEngine`
|
||||
core (model + content CRUD, status FSM, versions).
|
||||
- [`error-codes/error-codes`](./src/error-codes/error-codes.md) —
|
||||
`CMS_CODES`, `CmsError`, factory helpers.
|
||||
- [`otel/instruments`](./src/otel/instruments.md) — OpenTelemetry
|
||||
counters.
|
||||
- [`plugin/plugin`](./src/plugin/plugin.md) — `createCmsPlugin`
|
||||
factory.
|
||||
- [`routes/problem-json`](./src/routes/problem-json.md) — RFC 7807
|
||||
renderer.
|
||||
- [`schemas/options`](./src/schemas/options.md) —
|
||||
`CmsPluginOptionsSchema`.
|
||||
- [`services/registry-augmentation`](./src/services/registry-augmentation.md)
|
||||
— `ServiceRegistry` augmentation for the `cms` slot.
|
||||
|
||||
## License
|
||||
|
||||
MIT — © Suaveplan Team
|
||||
|
||||
117
docs/api-stack/api/api-plugin-cms/src/engine/cms-engine.md
Normal file
117
docs/api-stack/api/api-plugin-cms/src/engine/cms-engine.md
Normal file
@@ -0,0 +1,117 @@
|
||||
# `CmsEngine`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`CmsEngine` is the headless content-management core for
|
||||
`@suaveplan/api-plugin-cms`. It manages flexible content models,
|
||||
content entries with a `draft → review → published → archived` status
|
||||
lifecycle, slug uniqueness within a model, and a per-entry version
|
||||
trail. The engine is in-memory by design — three `Map`s back its
|
||||
state — so it is suitable for development, testing, and small-scale
|
||||
single-process deployments. Production-scale persistence is a downstream
|
||||
concern; the engine ships a stable surface that a future Surreal-backed
|
||||
storage port can wrap without modifying any caller code.
|
||||
|
||||
State changes flow through a typed `EventBus<CmsEventMap>` and an
|
||||
OpenTelemetry counter bundle so observability is free at the call site.
|
||||
Two abstractions are injectable for determinism: `Clock` drives every
|
||||
timestamp and `IdGenerator` drives every persisted identifier. Both
|
||||
default to the upstream Suaveplan main behaviour, so test fixtures
|
||||
captured against main remain byte-reproducible against this port.
|
||||
|
||||
## Features
|
||||
|
||||
- Content-model CRUD (`createModel`, `getModel`, `listModels`,
|
||||
`deleteModel`) with referential-integrity guard against orphaning
|
||||
entries.
|
||||
- Content-entry CRUD (`createContent`, `getContent`, `queryContent`,
|
||||
`updateContent`, `deleteContent`) with `(modelId, slug)` uniqueness
|
||||
and `validateContentData` enforcement on create and update.
|
||||
- `queryContent` supports filter by `modelId`, `status`, `locale`,
|
||||
case-insensitive `search` over `title`/`slug`, sort by an arbitrary
|
||||
field with `asc`/`desc`, and `offset`/`limit` paging.
|
||||
- Status transitions consult `VALID_STATUS_TRANSITIONS` and emit one
|
||||
of `content.published` / `content.drafted` / `content.archived` /
|
||||
fallback `content.updated`.
|
||||
- Version history retained per entry; `getVersions` returns the full
|
||||
trail, deletion drops it.
|
||||
- OTel counters fire from every mutating method.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { CmsEngine } from "@suaveplan/api-plugin-cms";
|
||||
|
||||
const engine = new CmsEngine();
|
||||
const model = engine.createModel({
|
||||
name: "Blog post",
|
||||
slug: "post",
|
||||
contentType: "post",
|
||||
fields: [
|
||||
{ name: "title", type: "text", label: "Title", required: true },
|
||||
{ name: "body", type: "richtext", label: "Body" },
|
||||
],
|
||||
});
|
||||
const entry = engine.createContent({
|
||||
modelId: model.id,
|
||||
title: "Hello world",
|
||||
data: { title: "Hello world", body: "Welcome." },
|
||||
});
|
||||
engine.transitionStatus(entry.id, "published");
|
||||
console.log(engine.queryContent({ status: "published" }));
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Inject a `Clock` and `IdGenerator` for deterministic test fixtures, and
|
||||
subscribe to the typed event bus to fan-out into downstream systems
|
||||
(audit log, search indexer, webhook dispatcher):
|
||||
|
||||
```ts
|
||||
import { CmsEngine } from "@suaveplan/api-plugin-cms";
|
||||
import { EventBus } from "@suaveplan/events";
|
||||
|
||||
let n = 0;
|
||||
const engine = new CmsEngine({
|
||||
clock: () => 1_700_000_000_000,
|
||||
idGenerator: () => `ulid_${++n}`,
|
||||
events: new EventBus(),
|
||||
});
|
||||
engine.events.on("content.published", (entry) => {
|
||||
queueWebhook("content.published", entry);
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
| Member | Description |
|
||||
|---|---|
|
||||
| `new CmsEngine(options?)` | Construct the engine. Optional `clock`, `idGenerator`, `events`. |
|
||||
| `engine.events` | The underlying `EventBus<CmsEventMap>` — subscribe with `.on(...)`. |
|
||||
| `createModel(input)` | Register a new model. Emits `model.created`. |
|
||||
| `getModel(id)` | Look up by id. Throws `CMS.MODEL_NOT_FOUND` on miss. |
|
||||
| `listModels()` | Snapshot of every registered model. |
|
||||
| `deleteModel(id)` | Remove a model. Throws `CMS.MODEL_IN_USE` if entries exist. |
|
||||
| `createContent(input)` | Validate + persist + version + emit `content.created`. |
|
||||
| `getContent(id)` | Look up entry. Throws `CMS.CONTENT_NOT_FOUND` on miss. |
|
||||
| `queryContent(query?)` | Filter / sort / page entries. |
|
||||
| `updateContent(id, input)` | Merge data; re-validate; version; emit `content.updated`. |
|
||||
| `transitionStatus(id, target)` | Status FSM step. Throws `CMS.INVALID_TRANSITION` on illegal edge. |
|
||||
| `deleteContent(id)` | Drop entry + versions; emit `content.deleted`. |
|
||||
| `getVersions(entryId)` | Full version trail. |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The engine uses three `Map`s (`models`, `entries`, `versions`). The
|
||||
default `IdGenerator` produces `cms_<n>_<clock()>` strings — verbatim
|
||||
from upstream so byte-level test fixtures survive. The default `Clock`
|
||||
is `() => Date.now()`. `EventBus<CmsEventMap>` adapts to the
|
||||
single-payload suaveplan convention (`Record<string, unknown>`); upstream
|
||||
used tuple events, but the listener-side argument shape is identical.
|
||||
OTel counters are resolved lazily inside `getCmsInstruments()` so test
|
||||
harnesses installing a custom `MeterProvider` first capture every
|
||||
observation.
|
||||
@@ -0,0 +1,89 @@
|
||||
# Error codes — `@suaveplan/api-plugin-cms`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
This module owns the stable error-code namespace `CMS.*` for the
|
||||
package and the concrete `CmsError` class that consumers `instanceof`
|
||||
in their `catch` blocks. Every failure path raised by `CmsEngine` and
|
||||
`createCmsPlugin` carries one of these codes; the
|
||||
problem+json renderer maps them to HTTP statuses; the spec-conformance
|
||||
test asserts every code is raised from at least one production source
|
||||
file. Codes are minted via `defineCodes("CMS", [...])` so the literal
|
||||
union flows through TypeScript and `error.code` narrows without a cast.
|
||||
|
||||
## Features
|
||||
|
||||
- Seven stable codes: `CONTENT_NOT_FOUND`, `MODEL_NOT_FOUND`,
|
||||
`INVALID_TRANSITION`, `VALIDATION_FAILED`, `DUPLICATE_SLUG`,
|
||||
`MODEL_IN_USE`, `INVALID_INPUT`.
|
||||
- Six factory helpers (`contentNotFound`, `modelNotFound`,
|
||||
`invalidStatusTransition`, `validationFailed`, `duplicateSlug`,
|
||||
`modelInUse`) that build the right `CmsError` with the right
|
||||
`context` fields populated.
|
||||
- `CmsError` extends `SuaveplanError<CmsErrorCode>` so the `code` field
|
||||
narrows and the `cause` chain is preserved across factory wrapping.
|
||||
- A `cmsCode` mirror field types the same value with the narrowed
|
||||
`CmsErrorCode` (the inherited `code` is typed as `string` on the
|
||||
base class to keep the abstract surface stable).
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
CMS_CODES,
|
||||
CmsError,
|
||||
contentNotFound,
|
||||
} from "@suaveplan/api-plugin-cms";
|
||||
|
||||
try {
|
||||
engine.getContent("missing");
|
||||
} catch (err) {
|
||||
if (err instanceof CmsError && err.code === CMS_CODES.CONTENT_NOT_FOUND) {
|
||||
return notFound();
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Build a richer error site by chaining a `cause` and supplying extra
|
||||
context that the audit log can persist:
|
||||
|
||||
```ts
|
||||
import { CmsError, CMS_CODES } from "@suaveplan/api-plugin-cms";
|
||||
|
||||
throw new CmsError(
|
||||
CMS_CODES.VALIDATION_FAILED,
|
||||
"user-driven validation failed",
|
||||
{ cause: zodError, context: { tenantId, requestId } }
|
||||
);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
| Symbol | Description |
|
||||
|---|---|
|
||||
| `CMS_CODES` | Frozen map: `{ CONTENT_NOT_FOUND: "CMS.CONTENT_NOT_FOUND", ... }`. |
|
||||
| `CmsErrorCode` | Union literal type of the seven values. |
|
||||
| `class CmsError` | Concrete class; `instanceof` narrows `code` to `CmsErrorCode`. |
|
||||
| `contentNotFound(id)` | → `CmsError(CONTENT_NOT_FOUND, ..., { context: { entryId } })`. |
|
||||
| `modelNotFound(id)` | → `CmsError(MODEL_NOT_FOUND, ..., { context: { modelId } })`. |
|
||||
| `invalidStatusTransition(from, to)` | → `CmsError(INVALID_TRANSITION, ..., { context: { from, to } })`. |
|
||||
| `validationFailed(errors[])` | → `CmsError(VALIDATION_FAILED, ..., { context: { errors } })`. |
|
||||
| `duplicateSlug(slug, modelId)` | → `CmsError(DUPLICATE_SLUG, ..., { context: { slug, modelId } })`. |
|
||||
| `modelInUse(id)` | → `CmsError(MODEL_IN_USE, ..., { context: { modelId } })`. |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`defineCodes` is the canonical suaveplan pattern; it returns a frozen
|
||||
record where each key maps to a fully-qualified `<namespace>.<reason>`
|
||||
string. The constructor of `CmsError` keeps the inherited `code` field
|
||||
typed as `string` (limitation of `SuaveplanError`'s base generic) and
|
||||
mirrors the narrowed value into `cmsCode`. The `context` field on the
|
||||
base class is `readonly context?: ErrorContext` — optional — so all
|
||||
access goes through `err.context?.<field>`.
|
||||
77
docs/api-stack/api/api-plugin-cms/src/otel/instruments.md
Normal file
77
docs/api-stack/api/api-plugin-cms/src/otel/instruments.md
Normal file
@@ -0,0 +1,77 @@
|
||||
# OpenTelemetry instruments — `@suaveplan/api-plugin-cms`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
The package emits seven OpenTelemetry counters on every production
|
||||
mutation so operators can dashboard CMS activity and alert on drift
|
||||
without wiring custom hooks. Instruments are resolved lazily through
|
||||
`getCmsInstruments()` so test harnesses installing a custom
|
||||
`MeterProvider` before importing the engine capture every observation.
|
||||
The `resetCmsInstruments()` helper drops the cached bundle for tests
|
||||
that need a clean slate; it is exported for testing only.
|
||||
|
||||
## Features
|
||||
|
||||
- One counter per `CmsEngine` event: `cms.models_created_total`,
|
||||
`cms.content_created_total`, `cms.content_updated_total`,
|
||||
`cms.content_published_total`, `cms.content_archived_total`,
|
||||
`cms.content_deleted_total`.
|
||||
- One derived counter `cms.validation_failures_total` increments
|
||||
whenever `createContent` or `updateContent` raises
|
||||
`CMS.VALIDATION_FAILED`.
|
||||
- Lazy resolution honours the harness pattern: first import of the
|
||||
package does not touch `metrics.getMeter` — the meter binds on the
|
||||
first hot-path call.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { metrics } from "@opentelemetry/api";
|
||||
import { MeterProvider } from "@opentelemetry/sdk-metrics";
|
||||
|
||||
const provider = new MeterProvider({ readers: [...] });
|
||||
metrics.setGlobalMeterProvider(provider);
|
||||
|
||||
// First mutation binds the cached meter to `provider`.
|
||||
import { CmsEngine } from "@suaveplan/api-plugin-cms";
|
||||
new CmsEngine().createModel({ /* ... */ });
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
In tests, install the in-memory exporter from
|
||||
`tests/setup/polyfills.ts`, exercise the engine, then assert against
|
||||
the flushed metrics:
|
||||
|
||||
```ts
|
||||
import { flushTestMetrics } from "../../tests/setup/polyfills.js";
|
||||
import { resetCmsInstruments } from "@suaveplan/api-plugin-cms";
|
||||
|
||||
beforeEach(() => resetCmsInstruments());
|
||||
|
||||
it("publishing increments the counter", async () => {
|
||||
// ... engine.transitionStatus(..., "published")
|
||||
const metrics = await flushTestMetrics();
|
||||
// assert "cms.content_published_total" sum == 1
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
| Symbol | Description |
|
||||
|---|---|
|
||||
| `interface CmsInstruments` | Holds the seven counters + meter. |
|
||||
| `getCmsInstruments()` | Resolve (or build + cache) the bundle. |
|
||||
| `resetCmsInstruments()` | Test-only — clear the cached bundle. |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The meter is acquired via `metrics.getMeter("@suaveplan/api-plugin-cms")`
|
||||
on first call. Each counter is created with a human-readable
|
||||
`description`. Production paths increment via `instr.<name>.add(1)` so
|
||||
the wire emits a single delta per call; downstream pipelines aggregate
|
||||
to a histogram of activity per minute if needed.
|
||||
83
docs/api-stack/api/api-plugin-cms/src/plugin/plugin.md
Normal file
83
docs/api-stack/api/api-plugin-cms/src/plugin/plugin.md
Normal file
@@ -0,0 +1,83 @@
|
||||
# `createCmsPlugin`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Wraps `CmsEngine` in a typed `PluginDescriptor` consumable by
|
||||
`@suaveplan/api-plugin`'s `PluginRegistry`. The factory matches the
|
||||
suaveplan convention shipped by every other `api-plugin-*` package:
|
||||
options are Zod-validated at the boundary so misuse is caught before
|
||||
the plugin is registered; `onInit` constructs a `CmsEngine` and exposes
|
||||
it via the host `ServiceRegistry`'s `cms` slot; `onClose` drops the
|
||||
internal reference. Downstream plugins look the engine up via
|
||||
`ctx.getService("cms")` and the slot is type-safe thanks to a
|
||||
`declare module "@suaveplan/api-plugin/service-registry"` augmentation.
|
||||
|
||||
## Features
|
||||
|
||||
- Zod-validated boundary — invalid input raises
|
||||
`CmsError(CMS.INVALID_INPUT)` with the underlying `ZodError`
|
||||
attached as `cause`.
|
||||
- Caller-supplied `Clock` and `IdGenerator` flow through to the
|
||||
constructed `CmsEngine`, enabling deterministic test fixtures.
|
||||
- ServiceRegistry slot named `cms` (module-augmented at package load).
|
||||
- Lifecycle hooks (`onInit` / `onClose`) implement the canonical
|
||||
api-plugin lifecycle; the descriptor is `enabledByDefault`-compatible
|
||||
with `@suaveplan/api`'s `PluginRegistry`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { createCmsPlugin } from "@suaveplan/api-plugin-cms";
|
||||
import { createPluginRegistry } from "@suaveplan/api-plugin";
|
||||
|
||||
const registry = createPluginRegistry();
|
||||
registry.register(createCmsPlugin({}));
|
||||
await registry.initAll();
|
||||
const cms = registry.context("cms").getService("cms");
|
||||
cms?.createModel({ /* ... */ });
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Inject a deterministic clock + id generator (useful for snapshot
|
||||
tests of an entire request pipeline) and consume the registered engine
|
||||
from another plugin:
|
||||
|
||||
```ts
|
||||
import { createCmsPlugin } from "@suaveplan/api-plugin-cms";
|
||||
|
||||
let n = 0;
|
||||
const plugin = createCmsPlugin({
|
||||
clock: () => 1_700_000_000_000,
|
||||
idGenerator: () => `det_${++n}`,
|
||||
});
|
||||
|
||||
// In another plugin's onInit, after deps:
|
||||
const cms = ctx.getService("cms");
|
||||
cms?.events.on("content.published", broadcastWebhook);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
| Symbol | Description |
|
||||
|---|---|
|
||||
| `interface CmsPluginOptions` | `{ clock?, idGenerator? }` — caller input. |
|
||||
| `interface CmsPluginConfig` | Resolved post-validation shape. |
|
||||
| `createCmsPlugin(options?)` | Returns `PluginDescriptor<CmsPluginConfig>`. |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The factory Zod-parses options via `CmsPluginOptionsSchema`; both
|
||||
`clock` and `idGenerator` are typed as `unknown` at the schema level
|
||||
(Zod cannot usefully type arbitrary function signatures) and the
|
||||
runtime `typeof === "function"` guard inside `createCmsPlugin` decides
|
||||
whether to forward them to the engine. `onInit` mutates a closed-over
|
||||
`CmsPluginState` so `onClose` can drop the reference without leaking
|
||||
the engine instance into the registry. The `cms` slot is declared via
|
||||
module augmentation in `services/registry-augmentation.ts` and the
|
||||
barrel side-effect-imports that module so downstream type-only
|
||||
imports of `@suaveplan/api-plugin/service-registry` see the slot.
|
||||
90
docs/api-stack/api/api-plugin-cms/src/routes/problem-json.md
Normal file
90
docs/api-stack/api/api-plugin-cms/src/routes/problem-json.md
Normal file
@@ -0,0 +1,90 @@
|
||||
# RFC 7807 problem+json renderer
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Render `CmsError` (and arbitrary throwables) as RFC 7807 problem+json
|
||||
HTTP responses without requiring `@suaveplan/api-plugin-cms` to depend
|
||||
on any HTTP framework. The renderer accepts a small structural
|
||||
`HttpResponseLike` interface that is compatible with Hono, Fastify,
|
||||
Bun's stdlib `Response`, and any framework whose response object
|
||||
exposes `header(name, value)` plus `body(data, status)`. Status
|
||||
mapping is encoded inline as a `Record<string, number>` lookup table
|
||||
that the package-level spec-conformance test asserts is exhaustive
|
||||
over `CMS_CODES`.
|
||||
|
||||
## Features
|
||||
|
||||
- Pure `formatProblemJson(err)` returns the JSON body for logging or
|
||||
side-channel use.
|
||||
- `respondProblemJson(res, err)` writes header + body and returns the
|
||||
framework's `Response` for `return res.body(...)` style handlers.
|
||||
- `PROBLEM_JSON_CONTENT_TYPE` constant — `application/problem+json` per
|
||||
RFC 7807.
|
||||
- Local `ProblemDetails` interface matching the IETF spec, with an
|
||||
extension-index signature so callers can add fields per RFC §3.2.
|
||||
- Status table:
|
||||
- `CMS.CONTENT_NOT_FOUND` / `CMS.MODEL_NOT_FOUND` → 404
|
||||
- `CMS.INVALID_TRANSITION` / `CMS.DUPLICATE_SLUG` / `CMS.MODEL_IN_USE` → 409
|
||||
- `CMS.VALIDATION_FAILED` → 400
|
||||
- `CMS.INVALID_INPUT` → 500
|
||||
- Generic `SuaveplanError` instances render with HTTP 500 but with the
|
||||
original code so log scrapers still see structure.
|
||||
- Non-error throwables render `{ type: "about:blank", status: 500 }`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { Hono } from "hono";
|
||||
import { respondProblemJson } from "@suaveplan/api-plugin-cms";
|
||||
|
||||
const app = new Hono();
|
||||
app.onError((err, c) => respondProblemJson(c, err));
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Side-channel logging without writing to a response (e.g. forwarding
|
||||
the body to an audit pipeline):
|
||||
|
||||
```ts
|
||||
import { formatProblemJson } from "@suaveplan/api-plugin-cms";
|
||||
|
||||
try {
|
||||
engine.transitionStatus(id, target);
|
||||
} catch (err) {
|
||||
const body = formatProblemJson(err);
|
||||
await audit.log({
|
||||
type: "cms.transition.failed",
|
||||
severity: body.status >= 500 ? "error" : "warning",
|
||||
...body,
|
||||
});
|
||||
throw err;
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
| Symbol | Description |
|
||||
|---|---|
|
||||
| `PROBLEM_JSON_CONTENT_TYPE` | `"application/problem+json"`. |
|
||||
| `interface ProblemDetails` | RFC 7807 body shape with an extension index. |
|
||||
| `interface HttpResponseLike` | `{ header(n, v); body(d, s): Response }`. |
|
||||
| `respondProblemJson(res, err)` | Write header + body; return `Response`. |
|
||||
| `formatProblemJson(err)` | Pure variant. |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The status lookup is a `Readonly<Record<string, number>>` keyed by the
|
||||
fully-qualified `CMS.*` code. If a `CmsError` is constructed with a
|
||||
code not in the table (unreachable in normal use; possible only in
|
||||
tests that bypass `defineCodes`), the renderer falls back to HTTP 500.
|
||||
Generic `SuaveplanError` instances render with HTTP 500 + the
|
||||
original `code` field so the response is still grep-friendly. The
|
||||
`HttpResponseLike` shape was chosen over `Context` (Hono) to keep
|
||||
this package free of framework dependencies — see
|
||||
`@suaveplan/api-plugin-database`'s `routes/problem-json.ts` for the
|
||||
same pattern.
|
||||
69
docs/api-stack/api/api-plugin-cms/src/schemas/options.md
Normal file
69
docs/api-stack/api/api-plugin-cms/src/schemas/options.md
Normal file
@@ -0,0 +1,69 @@
|
||||
# `CmsPluginOptionsSchema`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Zod schema validating the `CmsPluginOptions` bundle accepted by
|
||||
`createCmsPlugin(...)`. Boundary validation at the plugin factory
|
||||
ensures that misuse fails before the plugin reaches the registry, with
|
||||
the underlying `ZodError` preserved as the `cause` chain on the
|
||||
resulting `CmsError(CMS.INVALID_INPUT)`. The schema is intentionally
|
||||
minimal — only `clock` and `idGenerator` are accepted today — and both
|
||||
fields are passed through as `unknown` because Zod cannot usefully
|
||||
type-check arbitrary function signatures. The runtime `typeof ===
|
||||
"function"` guard inside `createCmsPlugin` is the actual gate.
|
||||
|
||||
## Features
|
||||
|
||||
- Boundary validation for `CmsPluginOptions`.
|
||||
- `safeParse` style for opt-in error handling at the factory site.
|
||||
- `z.input` / `z.output` derived types for downstream consumers that
|
||||
want the pre-/post-validation shapes.
|
||||
- The schema invokes `.transform(...)` so omitted fields are stripped
|
||||
from the output rather than serialised as `undefined`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { CmsPluginOptionsSchema } from "@suaveplan/api-plugin-cms";
|
||||
|
||||
const result = CmsPluginOptionsSchema.safeParse(input);
|
||||
if (!result.success) throw new Error("bad options");
|
||||
const { clock, idGenerator } = result.data;
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Compose with a project-level config schema that also drives logging /
|
||||
telemetry parameters:
|
||||
|
||||
```ts
|
||||
import z from "zod";
|
||||
import { CmsPluginOptionsSchema } from "@suaveplan/api-plugin-cms";
|
||||
|
||||
const ProjectConfigSchema = z.object({
|
||||
cms: CmsPluginOptionsSchema,
|
||||
logLevel: z.enum(["debug", "info", "warn", "error"]).default("info"),
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
| Symbol | Description |
|
||||
|---|---|
|
||||
| `CmsPluginOptionsSchema` | Zod schema. |
|
||||
| `CmsPluginOptionsInput` | `z.input<typeof CmsPluginOptionsSchema>`. |
|
||||
| `CmsPluginOptionsResolved` | `z.output<typeof CmsPluginOptionsSchema>`. |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The schema is the only Zod surface exposed by the package, so the
|
||||
conformance script's "schemas at boundaries" check is satisfied by the
|
||||
single `.safeParse(...)` call inside `src/plugin/plugin.ts`. The
|
||||
`unknown` typing of `clock` / `idGenerator` is deliberate; Zod
|
||||
`z.function()` requires arg/return schemas at parse-time which would
|
||||
force callers to construct a Zod-wrapped function — a worse ergonomics
|
||||
trade than the inline `typeof` check inside the factory.
|
||||
@@ -0,0 +1,82 @@
|
||||
# `ServiceRegistry` augmentation — `cms` slot
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Declares the `cms` slot on the
|
||||
`@suaveplan/api-plugin/service-registry`'s `ServiceRegistry` interface
|
||||
via TypeScript module augmentation. The augmentation has no runtime
|
||||
output — it exists purely so consumers typed against the augmented
|
||||
registry see `ctx.getService("cms")` resolve to `CmsEngine |
|
||||
undefined` without any cast at the call site. The barrel
|
||||
side-effect-imports this module so the augmentation is observed as
|
||||
soon as any consumer imports `@suaveplan/api-plugin-cms`.
|
||||
|
||||
## Features
|
||||
|
||||
- Adds `cms: CmsEngine` to `ServiceRegistry`.
|
||||
- Zero runtime cost — the file emits no JavaScript.
|
||||
- Cumulative augmentation — TypeScript merges this declaration with
|
||||
every other plugin's slot declarations, so apps wiring `cms`,
|
||||
`auditService`, `db`, `searchIndexes`, etc. see one combined
|
||||
registry shape.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
Consumers do not import this module directly; the barrel does it on
|
||||
their behalf. The slot is then usable wherever a `PluginContext` is
|
||||
in scope:
|
||||
|
||||
```ts
|
||||
import { definePlugin } from "@suaveplan/api-plugin";
|
||||
|
||||
export const myPlugin = definePlugin({
|
||||
name: "my-plugin",
|
||||
version: "0.1.0",
|
||||
dependencies: ["cms"],
|
||||
onInit: (ctx) => {
|
||||
const cms = ctx.getService("cms"); // typed as CmsEngine | undefined
|
||||
cms?.events.on("content.published", (entry) => {
|
||||
/* ... */
|
||||
});
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
When stripping the package out at build time (e.g. for a browser
|
||||
bundle), make sure to also strip the side-effect import of this module
|
||||
from `src/index.ts`; otherwise the `declare module` block is harmless
|
||||
but the `import` statement may cause a tree-shake regression.
|
||||
|
||||
```ts
|
||||
// To consume the engine without the slot augmentation:
|
||||
import { CmsEngine } from "@suaveplan/api-plugin-cms/engine/cms-engine";
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
This module exports nothing at runtime. Its only effect is the
|
||||
ambient `ServiceRegistry` merge:
|
||||
|
||||
```ts
|
||||
declare module "@suaveplan/api-plugin/service-registry" {
|
||||
interface ServiceRegistry {
|
||||
cms: CmsEngine;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The augmentation file deliberately has no value-level imports — it
|
||||
imports `type CmsEngine` so the JavaScript output is empty after
|
||||
TypeScript erasure. Side-effect imports of empty files are still
|
||||
emitted; the bundler keeps them because they may have ambient
|
||||
side-effects (here, only typing). The barrel imports this module
|
||||
unconditionally so any caller of `@suaveplan/api-plugin-cms` gets the
|
||||
slot without an explicit reference.
|
||||
Reference in New Issue
Block a user