Skip to the content

Capsid menu

src/testing.ts

454 lines and 68 functions in DrDustinEdwards/site-api, at commit d3b340f on 2026-10-24. This file on GitHub.

Functions

The functions of testing.ts
FunctionLinesComplexityCoveredCRAPMutation
withTags72 to 752100%OkCRAP 2Ok100%
summaryOf78 to 811100%OkCRAP 1Ok100%
titleOf83 to 883100%OkCRAP 3Watch61.5%
memoryAdapter90 to 48314100%WatchCRAP 14Ok100%
<anonymous:104>104 to 1041100%OkCRAP 1Ok100%
expect108 to 1133100%OkCRAP 3Ok100%
commit115 to 14310100%WatchCRAP 10Ok85.3%
existing145 to 149275%OkCRAP 2.1Watch75%
nextMediaVersion156 to 1561100%OkCRAP 1Watch66.7%
heldMedia160 to 1654100%OkCRAP 4Ok80%
touchMedia168 to 1721100%OkCRAP 1Ok100%
unattached176 to 1761100%OkCRAP 1Ok100%
<anonymous:177>177 to 1771100%OkCRAP 1Ok83.3%
large178 to 1781100%OkCRAP 1Ok80%
usesOf182 to 1922100%OkCRAP 2Ok100%
<anonymous:187>187 to 1892100%OkCRAP 2Ok100%
list200 to 22910100%WatchCRAP 10Ok94.9%
<anonymous:204>204 to 2041100%OkCRAP 1Ok100%
<anonymous:205>205 to 2052100%OkCRAP 2Ok100%
<anonymous:206>206 to 2063100%OkCRAP 3Ok85.7%
<anonymous:207>207 to 2073100%OkCRAP 3Watch72.7%
<anonymous:207>207 to 2071100%OkCRAP 1No data: no mutants in this code
<anonymous:208>208 to 2083100%OkCRAP 3Ok100%
key213 to 2135100%OkCRAP 5Watch61.5%
<anonymous:215>215 to 2194100%OkCRAP 4Ok83.3%
get230 to 2332100%OkCRAP 2Ok100%
upload234 to 2524100%OkCRAP 4Watch75%
delete253 to 2593100%OkCRAP 3Ok88.9%
setAlt262 to 2641100%OkCRAP 1Ok100%
setTags265 to 2671100%OkCRAP 1Ok100%
trash268 to 2701100%OkCRAP 1Ok100%
restore271 to 2731100%OkCRAP 1Ok100%
receiveMention288 to 3079100%WatchCRAP 9Critical52.4%
heldMention309 to 3143100%OkCRAP 3Ok87.5%
expiring316 to 3231100%OkCRAP 1Ok100%
past318 to 3211100%OkCRAP 1Ok100%
<anonymous:320>320 to 3202100%OkCRAP 2Ok90.9%
list329 to 34810100%WatchCRAP 10Ok97%
<anonymous:333>333 to 3332100%OkCRAP 2Ok100%
<anonymous:334>334 to 3344100%OkCRAP 4Ok84.6%
<anonymous:334>334 to 3341100%OkCRAP 1No data: no mutants in this code
<anonymous:335>335 to 3353100%OkCRAP 3Ok100%
<anonymous:336>336 to 3362100%OkCRAP 2Ok100%
decide349 to 3633100%OkCRAP 3Ok94.1%
reset364 to 3733100%OkCRAP 3Ok83.3%
delete374 to 3791100%OkCRAP 1Ok100%
sweep380 to 3842100%OkCRAP 2Ok100%
list402 to 4248100%WatchCRAP 8Ok82.1%
<anonymous:405>405 to 4051100%OkCRAP 1Ok100%
<anonymous:406>406 to 4062100%OkCRAP 2Ok100%
<anonymous:407>407 to 4073100%OkCRAP 3Watch77.8%
<anonymous:408>408 to 4081100%OkCRAP 1Critical0%
key412 to 4126100%WatchCRAP 6Critical26.7%
<anonymous:413>413 to 4133100%OkCRAP 3Ok90.9%
get425 to 4282100%OkCRAP 2Ok100%
saveDraft429 to 4332100%OkCRAP 2Watch63.6%
publish434 to 4371100%OkCRAP 1Watch75%
schedule438 to 4411100%OkCRAP 1Watch75%
unpublish442 to 4451100%OkCRAP 1Watch75%
revisions446 to 4482100%OkCRAP 2Ok100%
<anonymous:447>447 to 4471100%OkCRAP 1Watch66.7%
revisionSource449 to 4512100%OkCRAP 2Ok100%
<anonymous:450>450 to 4501100%OkCRAP 1Ok100%
setTags455 to 4623100%OkCRAP 3Ok81.8%
delete467 to 4722100%OkCRAP 2Ok100%
render476 to 4802100%OkCRAP 2Critical50%
escape478 to 4781100%OkCRAP 1Ok100%
<anonymous:478>478 to 4781100%OkCRAP 1No data: no mutants in this code

Imports

It imports src/adapter.ts, src/contract.ts, src/frontmatter.ts. No module of this repo imports it.

Source

// A reference adapter held in memory: what a conforming site does, small enough to read in one
// sitting. Carrel's tests run against it, and the conformance suite is proven on it.

import { MediaInUseError, NotFoundError, RefusedError, VersionConflictError, type MediaAdapter, type MentionsAdapter, type SiteAdapter } from "./adapter.js";
import { joinSource, setTags as writeTags, splitSource, tagsOf } from "./frontmatter.js";
import { LARGE_MEDIA_BYTES, MEDIA_LENSES } from "./contract.js";
import type { ContentDoc, ContentStatus, ContentSummary, MediaLens, MediaItem, MediaUploadLimits, MediaUse, MentionItem, MentionStatus, Revision, SiteInfo, WriteResult } from "./contract.js";

interface Stored {
  doc: ContentDoc;
  history: Array<{ revision: Revision; source: string }>;
}

export interface MemoryAdapterOptions {
  site?: SiteInfo;
  now?: () => Date;
  /** The media group's limits, or false for a site with no media manager (a v0.1.0 site). */
  media?: MediaUploadLimits | false;
  /** false for a site whose adapter has no content delete (its route then answers 501). On by default. */
  contentDelete?: boolean;
  /** false for a site that keeps no post tags through the API (a v0.5.0 site): the tags route then answers 501. On by default. */
  contentTags?: boolean;
  /** false for a media manager with no alt, tag or trash writes (a v0.2.0 site): those routes then answer 501. On by default. */
  mediaWrites?: boolean;
  /** false for a media manager with no lenses and no sort (a v0.5.0 site): a lens then answers 501. On by default. */
  mediaLenses?: boolean;
  /** false for a mentions group that ignores q and targetId (a v0.5.0 site) and so echoes no `filtered`. On by default. */
  mentionFilters?: boolean;
  /** false for a site that does not receive webmentions (its routes then answer 501). On by default. */
  mentions?: boolean;
  /** false for a mentions group with no reset (a v0.5.0 site): a reset then answers 501. On by default. */
  mentionReset?: boolean;
}

/** What the reference adapter's sweep keeps, like dustinedwards.info: failed rows 30 days, rejected rows 90. */
const MEMORY_MENTION_RETENTION_DAYS = { failed: 30, rejected: 90 } as const;

/** What the endpoint writes when a stranger sends a mention, for tests to seed the queue with. */
export interface ReceivedMention {
  sourceUrl: string;
  targetId: string;
  status?: MentionStatus;
  authorName?: string | null;
  authorUrl?: string | null;
  excerpt?: string | null;
  failureReason?: string | null;
  receivedAt?: Date;
}

/** What the reference adapter accepts, like dustinedwards.info's own list, at a test-sized limit. */
export const MEMORY_MEDIA_LIMITS: MediaUploadLimits = {
  maxBytes: 5 * 1024 * 1024,
  types: ["image/png", "image/jpeg", "image/gif", "image/webp", "image/avif", "image/svg+xml"],
};

const EXTENSIONS: Record<string, string> = {
  "image/png": "png",
  "image/jpeg": "jpg",
  "image/gif": "gif",
  "image/webp": "webp",
  "image/avif": "avif",
  "image/svg+xml": "svg",
  "application/pdf": "pdf",
};

interface StoredMedia {
  item: MediaItem;
  bytes: Uint8Array;
}

/** The item with its tags (v0.6.0), where its frontmatter can hold them. */
function withTags(doc: ContentDoc): ContentDoc {
  const front = splitSource(doc.source).front;
  return front !== null ? { ...doc, tags: tagsOf(front) } : doc;
}

/** A list row: the item without its source, with its version and tags. */
function summaryOf(doc: ContentDoc): ContentSummary {
  const { source: _s, format: _f, ...summary } = withTags(doc);
  return summary;
}

function titleOf(source: string, id: string): string {
  const front = source.match(/^---\r?\n[\s\S]*?^title:\s*["']?(.+?)["']?\s*$/m);
  if (front) return front[1]!;
  const heading = source.match(/^#\s+(.+)$/m);
  return heading ? heading[1]!.trim() : id;
}

export function memoryAdapter(
  options: MemoryAdapterOptions = {},
): SiteAdapter & {
  store: Map<string, Stored>;
  mediaStore: Map<string, StoredMedia>;
  deleted: string[];
  deletedContent: string[];
  mentionStore: Map<string, MentionItem>;
  /** Targets whose cached page the adapter purged, one entry per write that purges. */
  purged: string[];
  /** Adds a mention as the site's endpoint would, and returns its id. */
  receiveMention(fields: ReceivedMention): string;
} {
  const site = options.site ?? { id: "memory", name: "Memory site", origin: "https://memory.example" };
  const now = options.now ?? (() => new Date());
  const store = new Map<string, Stored>();
  let counter = 0;

  function expect(id: string, expectedVersion: string | null): Stored | undefined {
    const current = store.get(id);
    const held = current?.doc.version ?? null;
    if (held !== expectedVersion) throw new VersionConflictError(held);
    return current;
  }

  function commit(
    id: string,
    previous: Stored | undefined,
    changes: { source?: string; status: ContentStatus; publishAt?: string | null },
    changeId: string,
    message: string,
  ): WriteResult {
    const at = now().toISOString();
    const version = `v${++counter}`;
    const source = changes.source ?? previous?.doc.source ?? "";
    const wasPublished = previous?.doc.publishedAt ?? null;
    const doc: ContentDoc = {
      id,
      kind: "post",
      title: titleOf(source, id),
      status: changes.status,
      path: changes.status === "published" || wasPublished ? `/blog/${id}` : null,
      publishAt: changes.publishAt ?? null,
      publishedAt: changes.status === "published" ? (wasPublished ?? at) : wasPublished,
      updatedAt: at,
      format: "markdown",
      source,
      version,
    };
    const history = previous?.history ?? [];
    history.unshift({ revision: { version, at, author: "carrel", message: `${message} [${changeId}]` }, source });
    store.set(id, { doc, history });
    return { id, version, status: doc.status, changeId };
  }

  function existing(id: string, expectedVersion: string): Stored {
    const current = expect(id, expectedVersion);
    if (!current) throw new NotFoundError();
    return current;
  }

  const mediaStore = new Map<string, StoredMedia>();
  const deleted: string[] = [];
  const deletedContent: string[] = [];
  let mediaCounter = 0;
  let mediaVersion = 0;
  const nextMediaVersion = () => `m${++mediaVersion}`;
  const mediaWrites = options.mediaWrites !== false;

  /** The stored file, or NotFoundError; a stale expectedVersion is VersionConflictError. */
  function heldMedia(id: string, expectedVersion: string): StoredMedia {
    const stored = mediaStore.get(id);
    if (!stored) throw new NotFoundError("No such media.");
    if (stored.item.version !== expectedVersion) throw new VersionConflictError(stored.item.version ?? null, "The file changed since it was loaded.");
    return stored;
  }

  /** Every metadata write moves the version, so alt, tags and trash each count as a change. */
  function touchMedia(stored: StoredMedia, changes: Partial<MediaItem>): { version: string } {
    const version = nextMediaVersion();
    stored.item = { ...stored.item, ...changes, version };
    return { version };
  }

  /** The lenses, as the site would answer them: nothing uses it, it has no alt text, it is over the size line. */
  const LENS: Record<MediaLens, (i: MediaItem) => boolean> = {
    unattached: (i) => usesOf(i.id).length === 0,
    "no-alt": (i) => i.alt.trim() === "",
    large: (i) => i.bytes > LARGE_MEDIA_BYTES,
  };

  /** The site's reference check: every post whose source carries the file's URL, as the real site scans. */
  function usesOf(id: string): MediaUse[] {
    const url = `/media/${id}`;
    const uses: MediaUse[] = [];
    for (const { doc } of store.values()) {
      const lines = doc.source.split("\n");
      lines.forEach((line, i) => {
        if (line.includes(url)) uses.push({ type: "post", id: doc.id, title: doc.title, detail: `line ${i + 1}` });
      });
    }
    return uses;
  }

  const mediaAdapter: MediaAdapter | undefined =
    options.media === false
      ? undefined
      : {
          limits: options.media ?? MEMORY_MEDIA_LIMITS,
          ...(options.mediaLenses === false ? {} : { lenses: MEDIA_LENSES }),
          async list(query) {
            const needle = query.q?.toLowerCase();
            const lensed = options.mediaLenses !== false;
            const all = [...mediaStore.values()]
              .map((m) => m.item)
              .filter((i) => (query.trashed === "only" ? Boolean(i.trashedAt) : !i.trashedAt))
              .filter((i) => !query.tag || (i.tags ?? []).includes(query.tag))
              .filter((i) => !needle || [i.id, i.filename ?? "", i.alt].some((t) => t.toLowerCase().includes(needle)))
              .filter((i) => !lensed || !query.lens || LENS[query.lens](i))
              .reverse();
            // v0.6.0: sorted as asked, newest, largest or A to Z first unless dir says otherwise.
            const sorted = lensed && query.sort ? { sort: query.sort, dir: query.dir ?? (query.sort === "name" ? "asc" : "desc") } : undefined;
            if (sorted) {
              const key = (i: MediaItem): string | number => (sorted.sort === "size" ? i.bytes : sorted.sort === "name" ? (i.filename ?? i.id).toLowerCase() : (i.uploadedAt ?? ""));
              const order = [...all];
              all.sort((a, b) => {
                const [x, y] = [key(a), key(b)];
                const by = typeof x === "number" ? x - (y as number) : x.localeCompare(y as string);
                return (sorted.dir === "asc" ? 1 : -1) * by || order.indexOf(a) - order.indexOf(b);
              });
            }
            const start = query.cursor ? Number(query.cursor) : 0;
            const page = all.slice(start, start + query.limit);
            return {
              items: page,
              nextCursor: start + query.limit < all.length ? String(start + query.limit) : null,
              ...(lensed ? { total: all.length } : {}),
              ...(sorted ? { sorted } : {}),
            };
          },
          async get(id) {
            const stored = mediaStore.get(id);
            return stored ? { ...stored.item, usedBy: usesOf(id) } : null;
          },
          async upload(input) {
            const base = input.filename.replace(/\.[^.]*$/, "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "file";
            const id = `uploads/${++mediaCounter}-${base}.${EXTENSIONS[input.contentType] ?? "bin"}`;
            const item: MediaItem = {
              id,
              url: `/media/${id}`,
              filename: input.filename,
              contentType: input.contentType,
              bytes: input.bytes.byteLength,
              width: null,
              height: null,
              alt: input.alt,
              uploadedAt: now().toISOString(),
              deletable: true,
              ...(mediaWrites ? { version: nextMediaVersion(), tags: [], trashedAt: null } : {}),
            };
            mediaStore.set(id, { item, bytes: input.bytes });
            return item;
          },
          async delete(id) {
            if (!mediaStore.has(id)) throw new NotFoundError("No such media.");
            const uses = usesOf(id);
            if (uses.length > 0) throw new MediaInUseError(uses);
            mediaStore.delete(id);
            deleted.push(id);
          },
          ...(mediaWrites
            ? {
                async setAlt(id: string, input: { alt: string; expectedVersion: string; changeId: string }) {
                  return touchMedia(heldMedia(id, input.expectedVersion), { alt: input.alt });
                },
                async setTags(id: string, input: { tags: string[]; expectedVersion: string; changeId: string }) {
                  return touchMedia(heldMedia(id, input.expectedVersion), { tags: input.tags });
                },
                async trash(id: string, input: { expectedVersion: string; changeId: string }) {
                  return touchMedia(heldMedia(id, input.expectedVersion), { trashedAt: now().toISOString() });
                },
                async restore(id: string, input: { expectedVersion: string; changeId: string }) {
                  return touchMedia(heldMedia(id, input.expectedVersion), { trashedAt: null });
                },
              }
            : {}),
        };

  // ---------- mentions: what dustinedwards.info does, in memory

  const mentionStore = new Map<string, MentionItem>();
  const purged: string[] = [];
  let mentionCounter = 0;
  let mentionVersion = 0;
  const DAY = 24 * 60 * 60 * 1000;
  /** Same rule as the site: only a verified mention (pending, approved, rejected) takes a decision. */
  const DECIDABLE: MentionStatus[] = ["pending", "approved", "rejected"];

  function receiveMention(fields: ReceivedMention): string {
    const id = String(++mentionCounter);
    const status = fields.status ?? "pending";
    const receivedAt = (fields.receivedAt ?? now()).toISOString();
    mentionStore.set(id, {
      id,
      status,
      sourceUrl: fields.sourceUrl,
      targetId: fields.targetId,
      authorName: fields.authorName ?? null,
      authorUrl: fields.authorUrl ?? null,
      excerpt: fields.excerpt ?? null,
      failureReason: fields.failureReason ?? (status === "failed" ? "no-link" : null),
      receivedAt,
      verifiedAt: status === "unverified" ? null : receivedAt,
      decidedAt: null,
      version: `m${++mentionVersion}`,
    });
    return id;
  }

  function heldMention(id: string, expectedVersion: string): MentionItem {
    const current = mentionStore.get(id);
    if (!current) throw new NotFoundError("No such mention.");
    if (current.version !== expectedVersion) throw new VersionConflictError(current.version);
    return current;
  }

  function expiring(): { failed: MentionItem[]; rejected: MentionItem[] } {
    const at = now().getTime();
    const past = (status: "failed" | "rejected") =>
      [...mentionStore.values()].filter(
        (m) => m.status === status && Date.parse(m.receivedAt) < at - MEMORY_MENTION_RETENTION_DAYS[status] * DAY,
      );
    return { failed: past("failed"), rejected: past("rejected") };
  }

  const mentionsAdapter: MentionsAdapter | undefined =
    options.mentions === false
      ? undefined
      : {
          async list(query) {
            const filters = options.mentionFilters !== false;
            const needle = filters ? query.q?.toLowerCase() : undefined;
            const all = [...mentionStore.values()]
              .filter((m) => !query.status || m.status === query.status)
              .filter((m) => !needle || [m.sourceUrl, m.authorName ?? "", m.excerpt ?? ""].some((t) => t.toLowerCase().includes(needle)))
              .filter((m) => !filters || !query.targetId || m.targetId === query.targetId)
              .sort((a, b) => Date.parse(b.receivedAt) - Date.parse(a.receivedAt) || Number(b.id) - Number(a.id));
            const start = query.cursor ? Number(query.cursor) : 0;
            const counts = { unverified: 0, pending: 0, approved: 0, rejected: 0, failed: 0 };
            for (const m of mentionStore.values()) counts[m.status]++;
            const soon = expiring();
            return {
              items: all.slice(start, start + query.limit),
              nextCursor: start + query.limit < all.length ? String(start + query.limit) : null,
              counts,
              expiring: { failed: soon.failed.length, rejected: soon.rejected.length },
              ...(filters && (query.q || query.targetId) ? { filtered: { ...(query.q ? { q: query.q } : {}), ...(query.targetId ? { targetId: query.targetId } : {}) } } : {}),
            };
          },
          async decide(id, input) {
            const current = heldMention(id, input.expectedVersion);
            if (!DECIDABLE.includes(current.status)) {
              throw new RefusedError(`A ${current.status} mention cannot be decided: only a verified mention can.`);
            }
            const next: MentionItem = {
              ...current,
              status: input.decision === "approve" ? "approved" : "rejected",
              decidedAt: now().toISOString(),
              version: `m${++mentionVersion}`,
            };
            mentionStore.set(id, next);
            purged.push(current.targetId);
            return { status: next.status, version: next.version, purged: true };
          },
          async reset(id, input) {
            const current = heldMention(id, input.expectedVersion);
            if (current.status !== "approved" && current.status !== "rejected") {
              throw new RefusedError(`A ${current.status} mention has no decision to take back.`);
            }
            const next: MentionItem = { ...current, status: "pending", decidedAt: null, version: `m${++mentionVersion}` };
            mentionStore.set(id, next);
            purged.push(current.targetId);
            return { status: next.status, version: next.version, purged: true };
          },
          async delete(id, input) {
            const current = heldMention(id, input.expectedVersion);
            mentionStore.delete(id);
            purged.push(current.targetId);
            return { purged: true };
          },
          async sweep() {
            const gone = expiring();
            for (const m of [...gone.failed, ...gone.rejected]) mentionStore.delete(m.id);
            return { failed: gone.failed.length, rejected: gone.rejected.length };
          },
        };

  // A v0.5.0 mentions group: no way back to pending.
  if (mentionsAdapter && options.mentionReset === false) delete mentionsAdapter.reset;

  return {
    site,
    mentionStore,
    purged,
    receiveMention,
    ...(mentionsAdapter ? { mentions: mentionsAdapter } : {}),
    store,
    mediaStore,
    deleted,
    deletedContent,
    ...(mediaAdapter ? { media: mediaAdapter } : {}),
    content: {
      async list(query) {
        const needle = query.q?.toLowerCase();
        const all = [...store.values()]
          .map((s) => s.doc)
          .filter((d) => !query.status || d.status === query.status)
          .filter((d) => !needle || d.title.toLowerCase().includes(needle) || d.source.toLowerCase().includes(needle))
          .sort((a, b) => a.id.localeCompare(b.id));
        // v0.6.0: sorted as asked, newest first for the dates and A to Z for titles unless dir says otherwise.
        const sorted = query.sort ? { sort: query.sort, dir: query.dir ?? (query.sort === "title" ? "asc" : "desc") } : undefined;
        if (sorted) {
          const key = (d: ContentDoc) => (sorted.sort === "title" ? d.title.toLowerCase() : sorted.sort === "published" ? (d.publishedAt ?? d.publishAt ?? "") : (d.updatedAt ?? ""));
          all.sort((a, b) => (sorted.dir === "asc" ? 1 : -1) * key(a).localeCompare(key(b)) || a.id.localeCompare(b.id));
        }
        const start = query.cursor ? Number(query.cursor) : 0;
        const page = all.slice(start, start + query.limit);
        const next = start + query.limit < all.length ? String(start + query.limit) : null;
        return {
          items: page.map(summaryOf),
          nextCursor: next,
          total: all.length,
          ...(sorted ? { sorted } : {}),
        };
      },
      async get(id) {
        const doc = store.get(id)?.doc;
        return doc ? withTags(doc) : null;
      },
      async saveDraft(id, input) {
        const previous = expect(id, input.expectedVersion);
        const status = previous?.doc.status === "published" ? "published" : "draft";
        return commit(id, previous, { source: input.source, status, publishAt: previous?.doc.publishAt }, input.changeId, "Save");
      },
      async publish(id, input) {
        const previous = existing(id, input.expectedVersion);
        return commit(id, previous, { source: input.source, status: "published" }, input.changeId, "Publish");
      },
      async schedule(id, input) {
        const previous = existing(id, input.expectedVersion);
        return commit(id, previous, { source: input.source, status: "scheduled", publishAt: input.publishAt }, input.changeId, "Schedule");
      },
      async unpublish(id, input) {
        const previous = existing(id, input.expectedVersion);
        return commit(id, previous, { status: "draft" }, input.changeId, "Unpublish");
      },
      async revisions(id) {
        return store.get(id)?.history.map((h) => h.revision) ?? null;
      },
      async revisionSource(id, version) {
        return store.get(id)?.history.find((h) => h.revision.version === version)?.source ?? null;
      },
      ...(options.contentTags === false
        ? {}
        : {
            async setTags(id: string, input: { tags: string[]; expectedVersion: string; changeId: string }) {
              if (!store.has(id)) throw new NotFoundError();
              const previous = existing(id, input.expectedVersion);
              const parts = splitSource(previous.doc.source);
              if (parts.front === null) throw new RefusedError("This post has no frontmatter, so it has nowhere to keep tags.");
              const source = joinSource({ ...parts, front: writeTags(parts.front, input.tags) });
              return commit(id, previous, { source, status: previous.doc.status, publishAt: previous.doc.publishAt }, input.changeId, "Tags");
            },
          }),
      ...(options.contentDelete === false
        ? {}
        : {
            async delete(id: string, input: { expectedVersion: string; changeId: string }) {
              if (!store.has(id)) throw new NotFoundError();
              expect(id, input.expectedVersion);
              store.delete(id);
              deletedContent.push(id);
            },
          }),
    },
    preview: {
      async render(input) {
        const title = titleOf(input.source, input.id ?? "preview");
        const escape = (s: string) => s.replace(/[&<>"]/g, (c) => `&#${c.charCodeAt(0)};`);
        return `<!doctype html><html><head><title>${escape(title)}</title></head><body><pre>${escape(input.source)}</pre></body></html>`;
      },
    },
  };
}