Skip to the content

Capsid menu

app/lib/sites.server.ts

155 lines and 29 functions in DrDustinEdwards/carrel, at commit d5d5e82 on 2026-10-24. This file on GitHub.

Functions

The functions of sites.server.ts
FunctionLinesComplexityCoveredCRAPMutation
origin32 to 321100%OkCRAP 1No data: mutation testing has not run on this repo yet
key33 to 331100%OkCRAP 1No data: mutation testing has not run on this repo yet
newSource37 to 3810%OkCRAP 2No data: mutation testing has not run on this repo yet
searchConsoleProperty39 to 392100%OkCRAP 2No data: mutation testing has not run on this repo yet
origin43 to 431100%OkCRAP 1No data: mutation testing has not run on this repo yet
key44 to 441100%OkCRAP 1No data: mutation testing has not run on this repo yet
newSource49 to 4910%OkCRAP 2No data: mutation testing has not run on this repo yet
searchConsoleProperty50 to 5020%WatchCRAP 6No data: mutation testing has not run on this repo yet
origin54 to 541100%OkCRAP 1No data: mutation testing has not run on this repo yet
key55 to 551100%OkCRAP 1No data: mutation testing has not run on this repo yet
newSource60 to 6110%OkCRAP 2No data: mutation testing has not run on this repo yet
searchConsoleProperty62 to 6220%WatchCRAP 6No data: mutation testing has not run on this repo yet
origin66 to 661100%OkCRAP 1No data: mutation testing has not run on this repo yet
key67 to 671100%OkCRAP 1No data: mutation testing has not run on this repo yet
newSource72 to 7310%OkCRAP 2No data: mutation testing has not run on this repo yet
searchConsoleProperty74 to 7420%WatchCRAP 6No data: mutation testing has not run on this repo yet
origin78 to 781100%OkCRAP 1No data: mutation testing has not run on this repo yet
key79 to 791100%OkCRAP 1No data: mutation testing has not run on this repo yet
searchConsoleProperty82 to 8220%WatchCRAP 6No data: mutation testing has not run on this repo yet
origin86 to 861100%OkCRAP 1No data: mutation testing has not run on this repo yet
key87 to 871100%OkCRAP 1No data: mutation testing has not run on this repo yet
searchConsoleProperty90 to 9020%WatchCRAP 6No data: mutation testing has not run on this repo yet
isSiteId96 to 982100%OkCRAP 2No data: mutation testing has not run on this repo yet
siteEntry100 to 1021100%OkCRAP 1No data: mutation testing has not run on this repo yet
siteConnection113 to 131792%WatchCRAP 7No data: mutation testing has not run on this repo yet
absoluteMediaUrl138 to 1402100%OkCRAP 2No data: mutation testing has not run on this repo yet
siteOrigins143 to 1545100%OkCRAP 5No data: mutation testing has not run on this repo yet
constructor157 to 1601100%OkCRAP 1No data: mutation testing has not run on this repo yet
siteClient164 to 1683100%OkCRAP 3No data: mutation testing has not run on this repo yet

Imports

It imports app/lib/timed-fetch.server.ts. Imported by app/lib/ai.server.ts, app/lib/bulk.server.ts, app/lib/content.server.ts, app/lib/google/refresh.server.ts, app/lib/google/search-console.server.ts, app/lib/health.server.ts, app/lib/history.server.ts, app/lib/index.server.ts, app/lib/legal.server.ts, app/lib/mcp/tools.ts, app/lib/media.server.ts, app/lib/mentions.server.ts, app/lib/projects.server.ts, app/lib/refresh.server.ts, app/lib/voice.server.ts, app/routes/ai-draft.preview.ts, app/routes/editor.tsx, app/routes/history.tsx, app/routes/legal.tsx, app/routes/media.api.ts, app/routes/media.bulk.ts, app/routes/media.tsx, app/routes/mentions.tsx, app/routes/preview.ts, app/routes/project.flags.tsx, app/routes/project.new.tsx, app/routes/project.tsx, app/routes/project.voice.tsx, app/routes/unpublish.tsx, workers/app.ts.

Source

// The sites Carrel reaches through site-api, and the client for one. A site's origin is config and
// its key is a secret; Carrel holds nothing else about a site and never reads its repository.

import { createSiteClient, type SiteClient } from "@dustinedwards/site-api/client";
import { MIN_KEY_LENGTH } from "@dustinedwards/site-api";
import { timedFetch } from "~/lib/timed-fetch.server";

export type SiteId = "dustinedwards" | "germomics" | "foxhound" | "foxing" | "txasm" | "julieedwards";

type SiteEntry = {
  name: string;
  origin: (env: Env) => string | undefined;
  key: (env: Env) => string | undefined;
  keyName: string;
  /**
   * The source a new item starts from, in the site's own frontmatter. The site validates it on save.
   * Absent for a site with a fixed set of items, which refuses a new id: Carrel then offers no New post.
   */
  newSource?: (slug: string, today: string) => string;
  /**
   * Items other code on the site depends on staying public. dustinedwards.info's terms are the license
   * named by the Dataset JSON-LD and the Link header on every download, so Carrel never takes them down.
   */
  keepPublic: readonly string[];
  /** The Search Console property the service account reads for the site's pages. */
  searchConsoleProperty: (env: Env) => string;
};

const SITES: Record<SiteId, SiteEntry> = {
  dustinedwards: {
    name: "dustinedwards.info",
    origin: (env) => env.SITE_DUSTINEDWARDS_ORIGIN,
    key: (env) => env.SITE_DUSTINEDWARDS_KEY,
    keyName: "SITE_DUSTINEDWARDS_KEY",
    keepPublic: ["page.terms"],
    // The shape of content/posts/*.md on the site: draft until the Owner publishes.
    newSource: (slug, today) =>
      ["---", 'title: ""', `slug: ${slug}`, 'description: ""', `date: ${today}`, "tags: []", "draft: true", "---", "", ""].join("\n"),
    searchConsoleProperty: (env) => env.SEARCH_CONSOLE_PROPERTY_DUSTINEDWARDS?.trim() || "sc-domain:dustinedwards.info",
  },
  germomics: {
    name: "Germomics",
    origin: (env) => env.SITE_GERMOMICS_ORIGIN,
    key: (env) => env.SITE_GERMOMICS_KEY,
    keyName: "SITE_GERMOMICS_KEY",
    keepPublic: [],
    // The shape of germomics's app/lib/carrel/source.ts: JSON front matter, then the article body as
    // the site's own HTML. The id is the slug, and an item the site has not seen is created a draft.
    newSource: () => ["---", 'title: ""', 'excerpt: ""', "tags: []", "---", ""].join("\n"),
    searchConsoleProperty: (env) => (env as { SEARCH_CONSOLE_PROPERTY_GERMOMICS?: string }).SEARCH_CONSOLE_PROPERTY_GERMOMICS?.trim() || "sc-domain:germomics.com",
  },
  foxhound: {
    name: "Foxhound",
    origin: (env) => env.SITE_FOXHOUND_ORIGIN,
    key: (env) => env.SITE_FOXHOUND_KEY,
    keyName: "SITE_FOXHOUND_KEY",
    keepPublic: [],
    // The shape of foxhound's app/lib/carrel/source.ts: one JSON value per front matter line, then the
    // post's Markdown body. The slug is the address given here; the site refuses a later change to it.
    newSource: (slug) =>
      ["---", 'title: ""', `slug: ${JSON.stringify(slug)}`, 'excerpt: ""', 'author: "Foxhound"', "tags: []", "featured: false", "---", ""].join("\n"),
    searchConsoleProperty: (env) => (env as { SEARCH_CONSOLE_PROPERTY_FOXHOUND?: string }).SEARCH_CONSOLE_PROPERTY_FOXHOUND?.trim() || "sc-domain:foxhoundapp.com",
  },
  foxing: {
    name: "Foxing",
    origin: (env) => env.SITE_FOXING_ORIGIN,
    key: (env) => env.SITE_FOXING_KEY,
    keyName: "SITE_FOXING_KEY",
    keepPublic: [],
    // The shape of foxing's apps/web/app/lib/carrel/journal-source.ts for a Journal post: every field
    // as JSON, a blank line, then the Markdown body. The id is the slug; a new post is created a draft.
    newSource: () =>
      ["---", 'title: ""', "excerpt: null", "cover_image_url: null", "cover_image_alt: null", "tags: []", "featured: false", "---", "", ""].join("\n"),
    searchConsoleProperty: (env) => (env as { SEARCH_CONSOLE_PROPERTY_FOXING?: string }).SEARCH_CONSOLE_PROPERTY_FOXING?.trim() || "sc-domain:foxing.app",
  },
  txasm: {
    name: "Texas Branch ASM",
    origin: (env) => env.SITE_TXASM_ORIGIN,
    key: (env) => env.SITE_TXASM_KEY,
    keyName: "SITE_TXASM_KEY",
    keepPublic: ["home", "award-nominations"],
    searchConsoleProperty: (env) => env.SEARCH_CONSOLE_PROPERTY_TXASM?.trim() || "sc-domain:txasm.org",
  },
  julieedwards: {
    name: "julieedwards.info",
    origin: (env) => env.SITE_JULIEEDWARDS_ORIGIN,
    key: (env) => env.SITE_JULIEEDWARDS_KEY,
    keyName: "SITE_JULIEEDWARDS_KEY",
    keepPublic: ["profile", "priorities", "about", "experience", "contact", "site"],
    searchConsoleProperty: (env) => env.SEARCH_CONSOLE_PROPERTY_JULIEEDWARDS?.trim() || "sc-domain:julieedwards.info",
  },
};

export const SITE_IDS = Object.keys(SITES) as SiteId[];

export function isSiteId(value: string | null | undefined): value is SiteId {
  return typeof value === "string" && Object.hasOwn(SITES, value);
}

export function siteEntry(id: SiteId): SiteEntry {
  return SITES[id];
}

export type SiteConnection =
  | { state: "connected"; origin: string; key: string }
  | { state: "not-connected"; detail: string }
  | { state: "misconfigured"; detail: string };

/**
 * No key is "not connected": the site's stage has not arrived (setup step 10). A key without an
 * origin, a short key, or a malformed origin is misconfigured, which the health check reports.
 */
export function siteConnection(env: Env, id: SiteId): SiteConnection {
  const entry = SITES[id];
  const key = entry.key(env)?.trim() ?? "";
  const origin = entry.origin(env)?.trim() ?? "";
  if (!key) return { state: "not-connected", detail: `No ${entry.keyName} set.` };
  if (key.length < MIN_KEY_LENGTH) {
    return { state: "misconfigured", detail: `${entry.keyName} is shorter than ${MIN_KEY_LENGTH} characters.` };
  }
  let url: URL;
  try {
    url = new URL(origin);
  } catch {
    return { state: "misconfigured", detail: `The origin for ${entry.name} is missing or not a URL.` };
  }
  if (url.protocol !== "https:") {
    return { state: "misconfigured", detail: `The origin for ${entry.name} is not https.` };
  }
  return { state: "connected", origin: url.origin, key };
}

/**
 * Where a browser loads a site's file from. The site gives a path on itself (/media/...) or a full
 * https URL; a path is made absolute on the site's origin, since Carrel is a different host. The
 * Markdown keeps the site's own form, which is what the site serves and its reference check reads.
 */
export function absoluteMediaUrl(origin: string, url: string): string {
  return url.startsWith("/") ? `${origin.replace(/\/+$/, "")}${url}` : url;
}

/** Every configured site origin, for the image policy: a thumbnail loads from the site that serves it. */
export function siteOrigins(env: Env): string[] {
  const origins: string[] = [];
  for (const id of SITE_IDS) {
    try {
      const url = new URL(SITES[id].origin(env)?.trim() ?? "");
      if (url.protocol === "https:") origins.push(url.origin);
    } catch {
      // Not configured: nothing of it is loaded.
    }
  }
  return origins;
}

export class SiteNotConnected extends Error {
  constructor(readonly detail: string) {
    super(detail);
    this.name = "SiteNotConnected";
  }
}

/** Throws SiteNotConnected when the site cannot be reached, so callers show why instead of a 500. */
export function siteClient(env: Env, id: SiteId, fetcher?: typeof fetch): SiteClient {
  const connection = siteConnection(env, id);
  if (connection.state !== "connected") throw new SiteNotConnected(connection.detail);
  return createSiteClient({ baseUrl: connection.origin, key: connection.key, fetch: fetcher ?? timedFetch });
}