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
| Function | Lines | Complexity | Covered | CRAP | Mutation |
|---|---|---|---|---|---|
| origin | 32 to 32 | 1 | 100% | OkCRAP 1 | No data: mutation testing has not run on this repo yet |
| key | 33 to 33 | 1 | 100% | OkCRAP 1 | No data: mutation testing has not run on this repo yet |
| newSource | 37 to 38 | 1 | 0% | OkCRAP 2 | No data: mutation testing has not run on this repo yet |
| searchConsoleProperty | 39 to 39 | 2 | 100% | OkCRAP 2 | No data: mutation testing has not run on this repo yet |
| origin | 43 to 43 | 1 | 100% | OkCRAP 1 | No data: mutation testing has not run on this repo yet |
| key | 44 to 44 | 1 | 100% | OkCRAP 1 | No data: mutation testing has not run on this repo yet |
| newSource | 49 to 49 | 1 | 0% | OkCRAP 2 | No data: mutation testing has not run on this repo yet |
| searchConsoleProperty | 50 to 50 | 2 | 0% | WatchCRAP 6 | No data: mutation testing has not run on this repo yet |
| origin | 54 to 54 | 1 | 100% | OkCRAP 1 | No data: mutation testing has not run on this repo yet |
| key | 55 to 55 | 1 | 100% | OkCRAP 1 | No data: mutation testing has not run on this repo yet |
| newSource | 60 to 61 | 1 | 0% | OkCRAP 2 | No data: mutation testing has not run on this repo yet |
| searchConsoleProperty | 62 to 62 | 2 | 0% | WatchCRAP 6 | No data: mutation testing has not run on this repo yet |
| origin | 66 to 66 | 1 | 100% | OkCRAP 1 | No data: mutation testing has not run on this repo yet |
| key | 67 to 67 | 1 | 100% | OkCRAP 1 | No data: mutation testing has not run on this repo yet |
| newSource | 72 to 73 | 1 | 0% | OkCRAP 2 | No data: mutation testing has not run on this repo yet |
| searchConsoleProperty | 74 to 74 | 2 | 0% | WatchCRAP 6 | No data: mutation testing has not run on this repo yet |
| origin | 78 to 78 | 1 | 100% | OkCRAP 1 | No data: mutation testing has not run on this repo yet |
| key | 79 to 79 | 1 | 100% | OkCRAP 1 | No data: mutation testing has not run on this repo yet |
| searchConsoleProperty | 82 to 82 | 2 | 0% | WatchCRAP 6 | No data: mutation testing has not run on this repo yet |
| origin | 86 to 86 | 1 | 100% | OkCRAP 1 | No data: mutation testing has not run on this repo yet |
| key | 87 to 87 | 1 | 100% | OkCRAP 1 | No data: mutation testing has not run on this repo yet |
| searchConsoleProperty | 90 to 90 | 2 | 0% | WatchCRAP 6 | No data: mutation testing has not run on this repo yet |
| isSiteId | 96 to 98 | 2 | 100% | OkCRAP 2 | No data: mutation testing has not run on this repo yet |
| siteEntry | 100 to 102 | 1 | 100% | OkCRAP 1 | No data: mutation testing has not run on this repo yet |
| siteConnection | 113 to 131 | 7 | 92% | WatchCRAP 7 | No data: mutation testing has not run on this repo yet |
| absoluteMediaUrl | 138 to 140 | 2 | 100% | OkCRAP 2 | No data: mutation testing has not run on this repo yet |
| siteOrigins | 143 to 154 | 5 | 100% | OkCRAP 5 | No data: mutation testing has not run on this repo yet |
| constructor | 157 to 160 | 1 | 100% | OkCRAP 1 | No data: mutation testing has not run on this repo yet |
| siteClient | 164 to 168 | 3 | 100% | OkCRAP 3 | No 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 });
}