app/lib/carrel/content-kinds.server.ts
131 lines and 17 functions in DrDustinEdwards/dustinedwards-info, at commit 1590ba4 on 2026-10-24. This file on GitHub.
Functions
| Function | Lines | Complexity | Covered | CRAP | Mutation |
|---|---|---|---|---|---|
| contentRegistry | 52 to 149 | 2 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| <anonymous:53> | 53 to 53 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| route | 57 to 61 | 2 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| encoded | 63 to 66 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| owner | 69 to 73 | 2 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| list | 76 to 89 | 4 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| <anonymous:78> | 78 to 79 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| <anonymous:79> | 79 to 79 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| get | 91 to 96 | 3 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| saveDraft | 98 to 101 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| publish | 103 to 106 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| schedule | 108 to 112 | 2 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| unpublish | 114 to 117 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| revisions | 119 to 122 | 2 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| revisionSource | 124 to 127 | 2 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| delete | 129 to 136 | 3 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| render | 141 to 147 | 5 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
Imports
It imports app/lib/carrel/content-id.mjs. Imported by app/lib/carrel/cv-handler.server.ts, app/lib/carrel/dictionary-handler.server.ts, app/lib/carrel/document-handler.server.ts, app/lib/carrel/page-handler.server.ts, app/lib/carrel/phage-handler.server.ts, app/lib/carrel/procedure-handler.server.ts, app/lib/carrel/publication-handler.server.ts, app/lib/carrel/registry-handler.server.ts, app/lib/carrel/roster-handler.server.ts, app/lib/carrel/site-adapter.server.ts.
Source
// The kind registry behind Carrel's /content group. site-api's contract has one id space and a free-form
// `kind` string on a summary, so each content kind (post, publication, page, cv) is a handler that works in
// its own slugs, and this module is the only place that turns a slug into a contract id and back.
import { NotFoundError, RefusedError } from "@dustinedwards/site-api";
import type {
ContentAdapter,
ContentDoc,
ContentSummary,
ListQuery,
PreviewInput,
PublishInput,
Revision,
SaveDraftInput,
ScheduleInput,
UnpublishInput,
WriteResult,
} from "@dustinedwards/site-api";
import { DEFAULT_KIND, decodeContentId, encodeContentId } from "./content-id.mjs";
/**
* One kind's side of the content group. Every id a handler takes or returns is its own slug; the
* registry encodes it on the way out.
*/
export interface ContentKindHandler {
kind: string;
/** Every item of the kind matching q and status; the registry merges kinds and pages the result. */
list(query: Pick<ListQuery, "q" | "status">): Promise<ContentSummary[]>;
get(slug: string): Promise<ContentDoc | null>;
saveDraft(slug: string, input: SaveDraftInput): Promise<WriteResult>;
publish(slug: string, input: PublishInput): Promise<WriteResult>;
schedule?(slug: string, input: ScheduleInput): Promise<WriteResult>;
unpublish(slug: string, input: UnpublishInput): Promise<WriteResult>;
revisions(slug: string): Promise<Revision[] | null>;
revisionSource(slug: string, version: string): Promise<string | null>;
/**
* Deletes the item and whatever must follow it as one unit (v0.3.0). Throws NotFoundError for a missing
* item, VersionConflictError for a stale version and RefusedError when the site's rules refuse. A kind
* without it cannot be deleted from Carrel.
*/
delete?(slug: string, input: { expectedVersion: string; changeId: string }): Promise<void>;
/** The page the item would publish; `slug` is null for an unsaved new item. */
preview?(slug: string | null, input: PreviewInput): Promise<string>;
}
export type ContentRegistry = {
adapter: ContentAdapter;
render(input: PreviewInput): Promise<string>;
};
export function contentRegistry(handlers: ContentKindHandler[]): ContentRegistry {
const byKind = new Map(handlers.map((h) => [h.kind, h]));
if (byKind.size !== handlers.length) throw new Error("two content kind handlers share a kind");
/** The handler and slug for an id, or null when no registered kind owns it. */
const route = (id: string) => {
const { kind, slug } = decodeContentId(id);
const handler = byKind.get(kind);
return handler ? { handler, slug } : null;
};
const encoded = <T extends { id: string }>(kind: string, value: T): T => ({
...value,
id: encodeContentId(kind, value.id),
});
/** A write for an id no kind owns is a missing id, which the contract answers as a null version. */
const owner = (id: string) => {
const found = route(id);
if (!found) throw new RefusedError(`No content kind owns the id ${id}.`);
return found;
};
const adapter: ContentAdapter = {
async list(query) {
const lists = await Promise.all(
handlers.map(async (h) =>
(await h.list({ q: query.q, status: query.status })).map((s) => encoded(h.kind, s)),
),
);
const matching = lists.flat();
const start = query.cursor && /^\d+$/.test(query.cursor) ? Number(query.cursor) : 0;
const end = start + query.limit;
return {
items: matching.slice(start, end),
nextCursor: end < matching.length ? String(end) : null,
};
},
async get(id) {
const found = route(id);
if (!found) return null;
const doc = await found.handler.get(found.slug);
return doc ? encoded(found.handler.kind, doc) : null;
},
async saveDraft(id, input) {
const { handler, slug } = owner(id);
return encoded(handler.kind, await handler.saveDraft(slug, input));
},
async publish(id, input) {
const { handler, slug } = owner(id);
return encoded(handler.kind, await handler.publish(slug, input));
},
async schedule(id, input) {
const { handler, slug } = owner(id);
if (!handler.schedule) throw new RefusedError(`A ${handler.kind} cannot be scheduled; publish it now.`);
return encoded(handler.kind, await handler.schedule(slug, input));
},
async unpublish(id, input) {
const { handler, slug } = owner(id);
return encoded(handler.kind, await handler.unpublish(slug, input));
},
async revisions(id) {
const found = route(id);
return found ? found.handler.revisions(found.slug) : null;
},
async revisionSource(id, version) {
const found = route(id);
return found ? found.handler.revisionSource(found.slug, version) : null;
},
async delete(id, input) {
const found = route(id);
if (!found) throw new NotFoundError(`No content kind owns the id ${id}.`);
if (!found.handler.delete) {
throw new RefusedError(`A ${found.handler.kind} cannot be deleted from Carrel.`);
}
await found.handler.delete(found.slug, input);
},
};
return {
adapter,
async render(input) {
// No id is a new, unsaved item, and only a post can be previewed without one.
const found = input.id === undefined ? null : route(input.id);
const target = input.id === undefined ? byKind.get(DEFAULT_KIND) : found?.handler;
if (!target?.preview) throw new RefusedError("This kind of content has no preview.");
return target.preview(found ? found.slug : null, input);
},
};
}