app/lib/carrel/page-handler.server.ts
131 lines and 18 functions in DrDustinEdwards/dustinedwards-info, at commit 1590ba4 on 2026-10-24. This file on GitHub.
Functions
| Function | Lines | Complexity | Covered | CRAP | Mutation |
|---|---|---|---|---|---|
| isDraft | 24 to 24 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| statusOf | 25 to 25 | 2 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| pageHandler | 27 to 149 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| registered | 29 to 32 | 2 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| target | 35 to 44 | 2 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| write | 47 to 62 | 4 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| existing | 65 to 69 | 2 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| withDraft | 72 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 | 78 to 96 | 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:80> | 80 to 92 | 3 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| <anonymous:94> | 94 to 94 | 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:95> | 95 to 95 | 3 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| get | 98 to 118 | 6 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| saveDraft | 122 to 129 | 6 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| publish | 131 to 134 | 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 | 136 to 139 | 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 | 141 to 143 | 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 | 145 to 147 | 2 | 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/db/pages.ts, app/lib/carrel/content-kinds.server.ts, app/lib/carrel/errors.server.ts, app/lib/carrel/front-matter-lines.ts, app/lib/editor/github.server.ts, app/lib/pages/compile.mjs, app/lib/pages/save.server.ts. Imported by app/lib/carrel/site-adapter.server.ts.
Source
// The "page" kind of Carrel's content group (docs/PAGES.md): the Research, Teaching and Software prose
// pages. The id is `page.<slug>`, the slug being the file key (`research-phages` for /research/phages).
// Every write is savePage, the one page save, so a page saved through Carrel is validated by the code
// CI runs, committed, written to D1 and live on the next request with no deploy. A page cannot be
// created here: its address is structure and lives in CONTENT_PAGE_PATHS, so an id no registered path
// names is refused, and a save that names a missing file is a conflict like a post's.
import { RefusedError, VersionConflictError } from "@dustinedwards/site-api";
import type { ContentStatus, ContentSummary, WriteResult } from "@dustinedwards/site-api";
import matter from "gray-matter";
import { listPageRows } from "~/db/pages";
import type { ContentKindHandler } from "~/lib/carrel/content-kinds.server";
import { asSiteApiError } from "~/lib/carrel/errors.server";
import { withPublication } from "~/lib/carrel/front-matter-lines";
import { fileRevisions, fileRevisionSource, readFile } from "~/lib/editor/github.server";
import { PAGES_DIR, pagePathForSlug, pageSourcePath } from "~/lib/pages/compile.mjs";
import { savePage } from "~/lib/pages/save.server";
type PageEnv = Env & { GITHUB_TOKEN?: string };
/** A revision's version is a commit sha; an item's own version is the blob sha of its file. */
const isDraft = (raw: string) => matter(raw).data.draft === true;
const statusOf = (raw: string): ContentStatus => (isDraft(raw) ? "draft" : "published");
export function pageHandler(env: PageEnv): ContentKindHandler {
/** The path and file of a registered slug, or null: an id no registered path names owns nothing. */
function registered(slug: string) {
const path = pagePathForSlug(slug);
return path ? { path, file: pageSourcePath(slug) } : null;
}
/** The same, for a write: a missing registration is a refusal that says why. */
function target(slug: string) {
const found = registered(slug);
if (!found) {
throw new RefusedError(
`"${slug}" names no registered page. A save cannot create a page: its address is added to ` +
`CONTENT_PAGE_PATHS (app/lib/content-pages.mjs) in code first. The files are in ${PAGES_DIR}/.`,
);
}
return found;
}
/** One write through savePage as the Carrel actor, answered in the package's terms. */
async function write(slug: string, changeId: string, raw: string, expectedVersion: string | null): Promise<WriteResult> {
const { path } = target(slug);
try {
const saved = await savePage(env, {
path,
raw,
expectedBlobSha: expectedVersion ?? undefined,
isNew: false,
actor: { kind: "carrel", changeId },
});
return { id: slug, version: saved.sourceBlobSha, status: saved.draft ? "draft" : "published", changeId };
} catch (error) {
// A PageInvalid carries every message the validator gave; asSiteApiError passes them on.
return asSiteApiError(env, error, target(slug).file);
}
}
/** The stored file for a write that names a version; a version for a missing file is a conflict. */
async function existing(slug: string): Promise<string> {
const file = await readFile(env, target(slug).file);
if (!file) throw new VersionConflictError(null);
return file.content;
}
/** The source with its draft flag set to `draft`, touching no line when it already says so. */
const withDraft = (raw: string, draft: boolean) =>
isDraft(raw) === draft ? raw : withPublication(raw, { draft });
return {
kind: "page",
async list(query) {
const needle = query.q?.toLowerCase();
const summaries: ContentSummary[] = (await listPageRows(env)).map((row) => {
const status: ContentStatus = row.draft ? "draft" : "published";
return {
id: row.slug,
kind: "page",
title: row.title,
status,
path: row.draft ? null : row.path,
publishAt: null,
publishedAt: null,
updatedAt: row.syncedAt.toISOString(),
};
});
return summaries
.filter((s) => !query.status || s.status === query.status)
.filter((s) => !needle || s.title.toLowerCase().includes(needle) || s.id.includes(needle));
},
async get(slug) {
const found = registered(slug);
if (!found) return null;
const file = await readFile(env, found.file);
if (!file) return null;
const row = await env.DB.prepare(`SELECT synced_at FROM pages WHERE slug = ?1`).bind(slug).first<{ synced_at: number }>();
const fields = matter(file.content).data;
return {
id: slug,
kind: "page",
title: String(fields.title ?? slug),
status: statusOf(file.content),
path: isDraft(file.content) ? null : found.path,
publishAt: null,
publishedAt: null,
updatedAt: row ? new Date(row.synced_at * 1000).toISOString() : null,
format: "markdown",
source: file.content,
version: file.sha,
};
},
// Keeps the stored status, as a post does: a save to a live page edits it live. A page always has its
// file, so a save that names no version is a conflict.
async saveDraft(slug, input) {
const found = target(slug);
const file = await readFile(env, found.file);
if (input.expectedVersion === null && file) throw new VersionConflictError(file.sha);
if (input.expectedVersion !== null && !file) throw new VersionConflictError(null);
if (!file) throw new RefusedError(`The file for ${found.path} is missing from the repository, so it cannot be saved.`);
return write(slug, input.changeId, withDraft(input.source, isDraft(file.content)), input.expectedVersion);
},
async publish(slug, input) {
const stored = await existing(slug);
return write(slug, input.changeId, withDraft(input.source ?? stored, false), input.expectedVersion);
},
async unpublish(slug, input) {
const stored = await existing(slug);
return write(slug, input.changeId, withDraft(stored, true), input.expectedVersion);
},
revisions(slug) {
return fileRevisions(env, registered(slug)?.file ?? null);
},
revisionSource(slug, version) {
return fileRevisionSource(env, registered(slug)?.file ?? null, version);
},
};
}