app/lib/carrel/publication-handler.server.ts
115 lines and 15 functions in DrDustinEdwards/dustinedwards-info, at commit 1590ba4 on 2026-10-24. This file on GitHub.
Functions
| Function | Lines | Complexity | Covered | CRAP | Mutation |
|---|---|---|---|---|---|
| timestampOf | 23 to 28 | 5 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| publicationHandler | 30 to 130 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| write | 32 to 45 | 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 | 48 to 52 | 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 | 57 to 77 | 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:60> | 60 to 73 | 5 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| <anonymous:75> | 75 to 75 | 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:76> | 76 to 76 | 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 | 79 to 99 | 8 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| <anonymous:85> | 85 to 85 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| saveDraft | 103 to 109 | 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 | 111 to 115 | 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 | 117 to 120 | 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 | 122 to 124 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| revisionSource | 126 to 128 | 1 | 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/publications.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/publications/parse.mjs, app/lib/publications/paths.mjs, app/lib/publications/save.server.ts, app/lib/publications/types.ts. Imported by app/lib/carrel/site-adapter.server.ts.
Source
// The publication kind of Carrel's /content group (docs/PUBLICATIONS.md): ids are `publication.<slug>` (the
// registry encodes them), and a write goes through savePublication, the one path a publication reaches the
// repository by. Nothing here writes around it, and there is no editor in the site's admin for this kind.
import { VersionConflictError } from "@dustinedwards/site-api";
import type { ContentStatus, ContentSummary, WriteResult } from "@dustinedwards/site-api";
import { listPublicationIdentities } from "~/db/publications";
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 { parsePublication, publicationPath } from "~/lib/publications/parse.mjs";
import { paperPath } from "~/lib/publications/paths.mjs";
import { fileIsDraft, savePublication } from "~/lib/publications/save.server";
import type { Publication } from "~/lib/publications/types";
type PublicationEnv = Env & { GITHUB_TOKEN?: string };
/** A revision's version is a commit sha; an item's own version is the blob sha of its file. */
/** The deposited date at its own precision, as the timestamp the contract wants (a partial date reads as its first day). */
function timestampOf(date: string | null): string | null {
if (!date) return null;
const full = date.length === 4 ? `${date}-01-01` : date.length === 7 ? `${date}-01` : date;
const at = Date.parse(`${full}T00:00:00.000Z`);
return Number.isNaN(at) ? null : new Date(at).toISOString();
}
export function publicationHandler(env: PublicationEnv): ContentKindHandler {
/** One write through savePublication as the Carrel actor, answered in the package's terms. */
async function write(slug: string, changeId: string, raw: string, expectedVersion: string | null): Promise<WriteResult> {
try {
const saved = await savePublication(env, {
slug,
raw,
expectedBlobSha: expectedVersion ?? undefined,
isNew: expectedVersion === null,
actor: { kind: "carrel", changeId },
});
return { id: slug, version: saved.sourceBlobSha, status: saved.draft ? "draft" : "published", changeId };
} catch (error) {
return asSiteApiError(env, error, publicationPath(slug));
}
}
/** The stored file for a write that names a version; a version for a missing id is a conflict. */
async function existing(slug: string): Promise<string> {
const file = await readFile(env, publicationPath(slug));
if (!file) throw new VersionConflictError(null);
return file.content;
}
return {
kind: "publication",
async list(query) {
const needle = query.q?.toLowerCase();
const rows = await listPublicationIdentities(env);
const summaries: ContentSummary[] = rows.map((row) => {
const record = JSON.parse(row.record) as Publication;
const status: ContentStatus = row.status === "draft" ? "draft" : "published";
return {
id: row.slug,
kind: "publication",
title: row.title,
status,
path: status === "draft" ? null : paperPath(row.slug),
publishAt: null,
publishedAt: status === "published" ? timestampOf(record.publishedDate) : null,
updatedAt: row.syncedAt ? row.syncedAt.toISOString() : null,
};
});
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 file = await readFile(env, publicationPath(slug));
if (!file) return null;
const rows = await listPublicationIdentities(env);
const { data } = parsePublication({ file: publicationPath(slug), raw: file.content });
const draft = data.draft === true;
const stored = rows.find((row) => row.slug === slug);
return {
id: slug,
kind: "publication",
title: typeof data.title === "string" ? data.title : slug,
status: draft ? "draft" : "published",
path: draft ? null : paperPath(slug),
publishAt: null,
publishedAt: draft ? null : timestampOf(typeof data.publishedDate === "string" ? data.publishedDate : null),
updatedAt: stored?.syncedAt ? stored.syncedAt.toISOString() : null,
format: "markdown",
source: file.content,
version: file.sha,
};
},
// Keeps the stored status, as a post does: a save to a live paper edits it live, and a new record
// starts as a draft. First publication is a separate call, which Carrel's key may make.
async saveDraft(slug, input) {
const file = await readFile(env, publicationPath(slug));
if (input.expectedVersion === null && file) throw new VersionConflictError(file.sha);
if (input.expectedVersion !== null && !file) throw new VersionConflictError(null);
const raw = withPublication(input.source, { draft: file ? fileIsDraft(file.content) : true });
return write(slug, input.changeId, raw, input.expectedVersion);
},
async publish(slug, input) {
// Read even when source is sent: a version for an id that does not exist is a conflict.
const stored = await existing(slug);
return write(slug, input.changeId, withPublication(input.source ?? stored, { draft: false }), input.expectedVersion);
},
async unpublish(slug, input) {
const source = await existing(slug);
return write(slug, input.changeId, withPublication(source, { draft: true }), input.expectedVersion);
},
revisions(slug) {
return fileRevisions(env, publicationPath(slug));
},
revisionSource(slug, version) {
return fileRevisionSource(env, publicationPath(slug), version);
},
};
}