app/lib/seo.ts
418 lines and 23 functions in DrDustinEdwards/dustinedwards-info, at commit 1590ba4 on 2026-10-24. This file on GitHub.
Functions
| Function | Lines | Complexity | Covered | CRAP | Mutation |
|---|---|---|---|---|---|
| postSocial | 50 to 63 | 7 | 7% | CriticalCRAP 46.3 | No data: no mutants in this code |
| pageMeta | 69 to 90 | 3 | 5% | WatchCRAP 10.8 | No data: no mutants in this code |
| truncateForSerp | 99 to 104 | 3 | 17% | WatchCRAP 8.2 | No data: no mutants in this code |
| personJsonLd | 107 to 109 | 1 | 33% | OkCRAP 1.3 | No data: no mutants in this code |
| profilePageJsonLd | 115 to 126 | 1 | 8% | OkCRAP 1.8 | No data: no mutants in this code |
| edgeCacheControl | 158 to 159 | 1 | 100% | OkCRAP 1 | No data: no mutants in this code |
| scheduledEdgeCacheControl | 174 to 182 | 2 | 100% | OkCRAP 2 | No data: no mutants in this code |
| cacheTags | 187 to 189 | 2 | 100% | OkCRAP 2 | No data: no mutants in this code |
| publicHtmlHeaders | 204 to 210 | 2 | 14% | OkCRAP 4.5 | No data: no mutants in this code |
| articleFacts | 232 to 240 | 2 | 0% | WatchCRAP 6 | No data: no mutants in this code |
| articleOpenGraph | 243 to 257 | 4 | 7% | WatchCRAP 17 | No data: no mutants in this code |
| articleJsonLd | 259 to 280 | 4 | 5% | WatchCRAP 17.9 | No data: no mutants in this code |
| breadcrumbJsonLd | 282 to 293 | 1 | 17% | OkCRAP 1.6 | No data: no mutants in this code |
| <anonymous:286> | 286 to 291 | 1 | 0% | OkCRAP 2 | No data: no mutants in this code |
| webSiteJsonLd | 295 to 303 | 1 | 11% | OkCRAP 1.7 | No data: no mutants in this code |
| photoObject | 338 to 341 | 1 | 100% | OkCRAP 1 | No data: no mutants in this code |
| personId | 372 to 374 | 1 | 100% | OkCRAP 1 | No data: no mutants in this code |
| personNode | 377 to 384 | 1 | 100% | OkCRAP 1 | No data: no mutants in this code |
| personFacts | 387 to 407 | 1 | 100% | OkCRAP 1 | No data: no mutants in this code |
| isSiteOwner | 411 to 413 | 1 | 100% | OkCRAP 1 | No data: no mutants in this code |
| publicationsJsonLd | 418 to 468 | 1 | 100% | OkCRAP 1 | No data: no mutants in this code |
| <anonymous:436> | 436 to 466 | 5 | 79% | WatchCRAP 5.2 | No data: no mutants in this code |
| <anonymous:443> | 443 to 444 | 2 | 100% | OkCRAP 2 | No data: no mutants in this code |
Imports
It imports app/lib/identity.generated.mjs, app/lib/license.mjs, app/lib/publications/article-json-ld.mjs, app/lib/publications/authors.mjs, app/lib/publications/entities.mjs, app/lib/publications/paths.mjs, app/lib/site-origin.mjs. Imported by app/components/admin/settings-drawer.tsx, app/components/admin/social-previews.tsx, app/components/home-intro.tsx, app/components/home-podcast.tsx, app/components/procedure.tsx, app/components/shell-footer.tsx, app/components/site-header.tsx, app/components/taxonomy-listing.tsx, app/lib/cache-purge.server.ts, app/lib/carrel/site-adapter.server.ts, app/lib/cv/pdf-html.mjs, app/lib/feed-response.ts, app/lib/footer.ts, app/lib/llms/save.server.ts, app/lib/markdown-twin.ts, app/lib/media/upload.server.ts, app/lib/nav.ts, app/lib/pages/page-json-ld.ts, app/lib/publications/sources.server.ts, app/lib/site-urls.server.ts, app/lib/webmention/advertise.ts, app/lib/webmention/verify.server.ts, app/root.tsx, app/routes/about.tsx, app/routes/accessibility.tsx, app/routes/admin.tsx, app/routes/blog.$slug.tsx, app/routes/blog.$slug[.md].ts, app/routes/blog._index.tsx, app/routes/blog.series.$series.tsx, app/routes/blog.tags.$tag.tsx, app/routes/colophon.tsx, app/routes/contact.tsx, app/routes/content-page.tsx, app/routes/content-page[.md].ts, app/routes/cv-pdf.ts, app/routes/cv.charts[.json].ts, app/routes/cv.tsx, app/routes/cv[.md].ts, app/routes/gone.tsx, app/routes/home.tsx, app/routes/kb-library.tsx, app/routes/kb-library[.csv].ts, app/routes/kb-library[.json].ts, app/routes/kb-library[.md].ts, app/routes/lab.kind.tsx, app/routes/lab.tsx, app/routes/lab[.md].ts, app/routes/llms-full[.txt].ts, app/routes/llms.ts, app/routes/media.$.ts, app/routes/phages.tsx, app/routes/preview.$token.tsx, app/routes/privacy.tsx, app/routes/procedure.sheet.tsx, app/routes/procedure.tsx, app/routes/procedure.version.sheet.tsx, app/routes/procedure.version.tsx, app/routes/procedure.version[.md].ts, app/routes/procedure[.md].ts, app/routes/protocols.tsx, app/routes/protocols[.csv].ts, app/routes/protocols[.json].ts, app/routes/protocols[.md].ts, app/routes/publications.$slug.tsx, app/routes/publications.$slug[.bib].ts, app/routes/publications.$slug[.md].ts, app/routes/publications.$slug[.ris].ts, app/routes/publications.tsx, app/routes/publications[.bib].ts, app/routes/publications[.json].ts, app/routes/publications[.ris].ts, app/routes/robots.ts, app/routes/search.tsx, app/routes/security-txt.ts, app/routes/site-map.tsx, app/routes/sitemap.ts, app/routes/software.tsx, app/routes/systems.tsx, app/routes/systems[.md].ts, app/routes/teaching.tsx, app/routes/terms.tsx, app/routes/webmention.ts, app/routes/workspace.tsx, workers/app.ts, workers/watchdog.ts.
Source
// Relative, not `~/`: tsconfig.node.json also compiles this file and has no path mapping.
import { IDENTITY } from "./identity.generated.mjs";
import { TERMS_PATH } from "./license.mjs";
import { canonicalAuthor } from "./publications/authors.mjs";
import { schemaTypeFor } from "./publications/article-json-ld.mjs";
import { decodeEntities } from "./publications/entities.mjs";
import { paperPath } from "./publications/paths.mjs";
import { SITE_ORIGIN } from "./site-origin.mjs";
export { SITE_ORIGIN };
/** The `license` of every Dataset: the site's terms page (app/lib/license.mjs). */
export const LICENSE_URL = `${SITE_ORIGIN}${TERMS_PATH}`;
const DEFAULT_OG_IMAGE = `${SITE_ORIGIN}/dustin-edwards-og-image.png`;
// The person, the role, the headship, the department and the university are the CV's: derived from
// content/cv/profile.md and appointments.md by app/lib/identity.mjs (written to identity.generated.mjs at build),
// so a CV change reaches the site settings, the titles, the About text and the structured data.
// `role` is the visible identity line; `jobTitle` is the Person record's, which also names the headship
// (Dustin, 2026-09-28). `affiliation` is structured data (worksFor), so it stays short and literal; `org` is the full line for people to read.
// The description is the name and the role only (Dustin, 2026-10-15: "It should be just 'Dr. Dustin Edwards,
// Professor and Virologist'"). "Dr." is the doctorate the CV states, so it follows the degree rather than being typed.
const HONORIFIC = /^(Ph\.?D|M\.?D|D\.?V\.?M|Ed\.?D|D\.?Sc)\.?$/i.test(IDENTITY.degree) ? "Dr. " : "";
export const SITE = {
name: IDENTITY.name,
role: IDENTITY.role,
jobTitle: IDENTITY.jobTitle,
affiliation: IDENTITY.affiliation,
// The university and its system, for the home hero and the About panel. `affiliation` stays the short form worksFor needs.
org: IDENTITY.org,
department: IDENTITY.department,
eyebrow: IDENTITY.role,
tagline: `${IDENTITY.role}, ${IDENTITY.department}, ${IDENTITY.affiliation}.`,
description: `${HONORIFIC}${IDENTITY.name}, ${IDENTITY.role}`,
} as const;
// Shared by the public route and the admin preview, so the preview cannot silently disagree.
type PostSocialFields = {
slug: string;
title: string;
description?: string | null;
ogTitle?: string | null;
ogDescription?: string | null;
coverImage?: string | null;
ogImage?: string | null;
};
export function postSocial(post: PostSocialFields) {
const canonical = `${SITE_ORIGIN}/writing/${post.slug}`;
const description = post.description ?? SITE.description;
const socialImage = post.coverImage ?? post.ogImage ?? null;
return {
canonical,
pageTitle: `${post.title} | ${SITE.name}`,
description,
socialTitle: post.ogTitle ?? post.title,
socialDescription: post.ogDescription ?? description,
image: socialImage ? `${SITE_ORIGIN}${socialImage}` : DEFAULT_OG_IMAGE,
usingDefaultImage: socialImage === null,
};
}
/**
* `summary_large_image` always: the image falls back to the site mark, and a `twitter:card` without
* an image renders as a bare link.
*/
export function pageMeta(page: {
title: string;
description: string;
path: string;
image?: string;
ogType?: "website" | "article";
}) {
const url = `${SITE_ORIGIN}${page.path}`;
const image = page.image ?? DEFAULT_OG_IMAGE;
return [
{ title: page.title },
{ name: "description", content: page.description },
{ tagName: "link", rel: "canonical", href: url },
{ property: "og:title", content: page.title },
{ property: "og:description", content: page.description },
{ property: "og:url", content: url },
{ property: "og:type", content: page.ogType ?? "website" },
{ property: "og:image", content: image },
{ name: "twitter:card", content: "summary_large_image" },
{ name: "twitter:image", content: image },
];
}
/**
* Google truncates by pixel width (about 600px title, 920px description), so these approximate it;
* the description field's own 160-character counter would show text the result clips.
*/
export const SERP_TITLE_LIMIT = 60;
export const SERP_DESCRIPTION_LIMIT = 155;
export function truncateForSerp(text: string, limit: number) {
if (text.length <= limit) return text;
const cut = text.slice(0, limit);
const lastSpace = cut.lastIndexOf(" ");
return `${(lastSpace > limit * 0.6 ? cut.slice(0, lastSpace) : cut).trimEnd()}...`;
}
/** The home page's Person: the same node as `personNode`, joined to it by the shared `@id`. */
export function personJsonLd(origin: string) {
return personNode(origin);
}
/**
* The About page is a ProfilePage about the same Person the home page describes: the shared `@id` is
* what makes the two one entity rather than two people with one name.
*/
export function profilePageJsonLd(origin: string, page: { path: string; description: string }) {
const { "@context": context, ...person } = personNode(origin);
return {
"@context": context,
"@type": "ProfilePage",
"@id": `${origin}${page.path}`,
url: `${origin}${page.path}`,
name: `About ${SITE.name}`,
description: page.description,
mainEntity: person,
};
}
/**
* Needs no `Vary`: the theme is part of the cache key in `workers/app.ts`. Never add
* must-revalidate, proxy-revalidate, no-cache or s-maxage: each disables stale-while-revalidate at
* Cloudflare (RFC 9111 4.2.4). Browser max-age stays 0 because purges cannot reach a browser.
*/
export const SHARED_CACHE_CONTROL = "public, max-age=0";
/** A response whose address changes when its bytes do (hashed media, uploads). */
export const IMMUTABLE_CACHE_CONTROL = "public, max-age=31536000, immutable";
/** The small text files crawlers read (robots.txt, security.txt, llms.txt). */
export const HOURLY_CACHE_CONTROL = "public, max-age=3600";
/**
* A day fresh is safe because every write purges by tag, every deploy starts cold, and a page that
* lists posts expires at the next scheduled publish_at. Never parse a lifetime back out of a string:
* a failed parse has to substitute a false lifetime.
*/
const EDGE_FRESH_SECONDS = 86_400;
const EDGE_STALE_SECONDS = 604_800;
// Short because the home page carries the latest Germomics episode, which no write purges.
export const HOME_EDGE_FRESH_SECONDS = 600;
/**
* A publish_at seconds away would otherwise send a max-age near zero, and every read until it passed
* would be a render. A minute is the most a scheduled post can lag its time for it.
*/
const SCHEDULED_FLOOR_SECONDS = 60;
const edgeCacheControl = (fresh: number) =>
`max-age=${fresh}, stale-while-revalidate=${EDGE_STALE_SECONDS}`;
/**
* Sent as `Cloudflare-CDN-Cache-Control`, not `s-maxage`: `s-maxage` implies proxy-revalidate, so
* Cloudflare refuses to serve stale and every read past the lifetime blocks on a render.
*/
export const EDGE_CACHE_CONTROL = edgeCacheControl(EDGE_FRESH_SECONDS);
/** Short because the colophon's site-health tile age is the watchdog's liveness. */
export const HEALTH_EDGE_CACHE_CONTROL = edgeCacheControl(600);
/**
* The edge policy for a page that lists posts. A scheduled post goes live with no write to purge on,
* so the page expires when the next one does; with nothing scheduled it is the plain policy.
*/
export function scheduledEdgeCacheControl(
now: Date,
nextPublishAt: Date | null,
fresh: number = EDGE_FRESH_SECONDS,
): string {
if (!nextPublishAt) return edgeCacheControl(fresh);
const untilPublish = Math.ceil((nextPublishAt.getTime() - now.getTime()) / 1000);
return edgeCacheControl(Math.min(fresh, Math.max(SCHEDULED_FLOOR_SECONDS, untilPublish)));
}
export const EDGE_CACHE_HEADER = "Cloudflare-CDN-Cache-Control";
// A misspelled tag is a purge that silently does nothing: `cache.purge` succeeds on an unmatched tag.
export function cacheTags(slug?: string): string {
return slug ? `post:${slug},posts` : "posts";
}
/**
* The tags of a document that lists both posts and procedures (the sitemap), in the comma-list
* `cacheTags()` uses. `purgePosts` and `purgeProcedures` each reach it. The literal is the one
* `PROCEDURES_CACHE_TAG` names (app/kb/procedures/route.ts); test/worker/cache-tags.test.ts pins both.
*/
export const POSTS_AND_PROCEDURES_CACHE_TAGS = `${cacheTags()},procedures`;
const PAGES_CACHE_TAG = "pages";
/**
* The Renderer stamps `EDGE_CACHE_CONTROL` beside `SHARED_CACHE_CONTROL`; pass `edge` only to depart
* from that default.
*/
export function publicHtmlHeaders(tag: string = PAGES_CACHE_TAG, edge?: string) {
return {
"Cache-Control": SHARED_CACHE_CONTROL,
"Cache-Tag": tag,
...(edge ? { [EDGE_CACHE_HEADER]: edge } : {}),
};
}
export const HTML_VARY_ACCEPT = "Accept";
/**
* Also applied by `workers/app.ts` to any response with no `Cache-Control`, and to cookie-bearing
* requests on the HTML routes.
*/
export const NO_STORE_CACHE_CONTROL = "private, no-store";
type ArticleSeo = {
slug: string;
title: string;
description: string | null;
publishAt: Date | null;
updatedAt: Date | null;
coverImage: string | null;
ogImage?: string | null;
tags: string[];
};
// Derived once so the JSON-LD and Open Graph outputs cannot drift apart.
function articleFacts(origin: string, post: ArticleSeo) {
return {
publishedTime: post.publishAt?.toISOString(),
modifiedTime: (post.updatedAt ?? post.publishAt)?.toISOString(),
authorName: SITE.name,
authorUrl: origin,
tags: post.tags,
};
}
// An absent date yields no property: an empty `article:published_time` claims there is no date.
export function articleOpenGraph(origin: string, post: ArticleSeo) {
const facts = articleFacts(origin, post);
const tags: Array<{ property: string; content: string }> = [];
if (facts.publishedTime) {
tags.push({ property: "article:published_time", content: facts.publishedTime });
}
if (facts.modifiedTime) {
tags.push({ property: "article:modified_time", content: facts.modifiedTime });
}
tags.push({ property: "article:author", content: facts.authorName });
for (const tag of facts.tags) {
tags.push({ property: "article:tag", content: tag });
}
return tags;
}
export function articleJsonLd(origin: string, post: ArticleSeo) {
const facts = articleFacts(origin, post);
return {
"@context": "https://schema.org",
"@type": "Article",
headline: post.title,
description: post.description ?? undefined,
datePublished: facts.publishedTime,
dateModified: facts.modifiedTime,
image: postSocial({
slug: post.slug,
title: post.title,
description: post.description,
coverImage: post.coverImage,
ogImage: post.ogImage ?? null,
}).image,
keywords: facts.tags.length > 0 ? facts.tags.join(", ") : undefined,
mainEntityOfPage: { "@type": "WebPage", "@id": `${origin}/writing/${post.slug}` },
author: { "@type": "Person", name: facts.authorName, url: facts.authorUrl },
publisher: { "@type": "Person", name: SITE.name, url: origin },
};
}
export function breadcrumbJsonLd(origin: string, trail: Array<[string, string]>) {
return {
"@context": "https://schema.org",
"@type": "BreadcrumbList",
itemListElement: trail.map(([name, path], i) => ({
"@type": "ListItem",
position: i + 1,
name,
item: `${origin}${path}`,
})),
};
}
export function webSiteJsonLd(origin: string) {
return {
"@context": "https://schema.org",
"@type": "WebSite",
name: SITE.name,
description: SITE.description,
url: origin,
};
}
export const PUBLICATIONS_URL = "/research/publications";
export const PUBLICATIONS_DESCRIPTION =
"Peer-reviewed work by Dustin Edwards on retroviruses, bacteriophage genomics, and science education, with full text hosted here.";
export const OWNER_ORCID = "https://orcid.org/0000-0001-6409-8041";
export const OWNER_SCHOLAR =
"https://scholar.google.com/citations?user=lfzCCXwAAAAJ";
export const OWNER_PUBMED =
"https://pubmed.ncbi.nlm.nih.gov/?term=Edwards+Dustin%5BAuthor%5D&sort=date";
export const OWNER_FACULTY_PAGE = "https://faculty.tarleton.edu/dcedwards/";
// Confirmed by Dustin, 2026-09-28 (#200); each ties to Tarleton.
const OWNER_SEA_PHAGES = "https://seaphages.org/faculty/318/";
const OWNER_SCOPUS = "https://www.scopus.com/authid/detail.uri?authorId=57204587587";
const OWNER_LOOP = "https://loop.frontiersin.org/people/2520780";
const OWNER_GITHUB = "https://github.com/DrDustinEdwards";
const OWNER_LINKEDIN = "https://www.linkedin.com/in/dustin-edwards-152235274/";
const OWNER_WIKIDATA = "https://www.wikidata.org/wiki/Q141630373";
// The headshot (Dustin, 2026-09-28), uploaded to MEDIA from Tarleton's file so nothing here links to
// another server. The square crop leads: search engines prefer it for a Person.
export const OWNER_PHOTO = {
key: "dustin-edwards-headshot-a4b25f04061613b9-1365x2048.jpg",
width: 1365,
height: 2048,
} as const;
const OWNER_PHOTO_SQUARE = {
key: "dustin-edwards-headshot-square-090e58f805bc9e9f-1365x1365.jpg",
width: 1365,
height: 1365,
} as const;
function photoObject(origin: string, photo: { key: string; width: number; height: number }) {
const url = `${origin}/media/${photo.key}`;
return { "@type": "ImageObject", url, contentUrl: url, width: photo.width, height: photo.height };
}
export const GERMOMICS_URL = "https://germomics.com/";
export const GERMOMICS_X_URL = "https://x.com/Germomics";
// The footer's `rel="me"` profiles, in order. `check:machine-readable` reads this list.
export const OWNER_PROFILES = [OWNER_SCHOLAR, OWNER_ORCID, OWNER_PUBMED] as const;
const OWNER_SAME_AS = [
...OWNER_PROFILES,
OWNER_FACULTY_PAGE,
OWNER_SEA_PHAGES,
OWNER_SCOPUS,
OWNER_LOOP,
OWNER_GITHUB,
OWNER_LINKEDIN,
OWNER_WIKIDATA,
GERMOMICS_URL,
GERMOMICS_X_URL,
];
// What he is known for.
const OWNER_KNOWS_ABOUT = [
"Virology",
"Bacteriophages",
"Phage genomics",
"Avian retroviruses",
"Retroviruses",
"Science education",
];
export function personId(origin: string) {
return `${origin}/#person`;
}
/** Not a duplicate of `personJsonLd`: this `@id` joins papers to a person. */
export function personNode(origin: string) {
return {
"@context": "https://schema.org",
"@type": "Person",
"@id": personId(origin),
...personFacts(origin),
};
}
/** What both Person nodes say about the owner, in their key order. No alternate names. */
function personFacts(origin: string) {
return {
name: SITE.name,
honorificSuffix: IDENTITY.degree,
jobTitle: SITE.jobTitle,
image: [photoObject(origin, OWNER_PHOTO_SQUARE), photoObject(origin, OWNER_PHOTO)],
description: SITE.description,
url: origin,
worksFor: {
"@type": "CollegeOrUniversity",
name: SITE.affiliation,
url: "https://www.tarleton.edu/",
department: {
"@type": "Organization",
name: SITE.department,
},
},
knowsAbout: OWNER_KNOWS_ABOUT,
sameAs: OWNER_SAME_AS,
};
}
// Registries spell his name several ways, so this reads the exact alias table the exports use: a
// surname-plus-initial rule claimed any "D* Edwards" co-author as the owner in JSON-LD.
export function isSiteOwner(name: string) {
return canonicalAuthor(name) === SITE.name;
}
// Only the owner's entry becomes an `@id` reference; replacing the whole array would drop co-authors.
// Every record has its own page (app/lib/publications/by-slug.ts), so each article takes that page as
// its `@id` and `url`, the same node the paper page describes in full; a hosted PDF is its encoding.
export function publicationsJsonLd(
origin: string,
items: {
title: string;
authors: string[];
year: number;
journal: string | null;
slug: string;
/** Null for a submitted manuscript, which has no DOI yet. */
doi: string | null;
pdfPath: string | null;
/** The curated type, so a chapter here is the same Chapter its own page describes. */
type?: string;
}[],
) {
const id = personId(origin);
return [
personNode(origin),
...items.map((p) => ({
"@context": "https://schema.org",
"@type": p.type ? schemaTypeFor(p.type) : "ScholarlyArticle",
"@id": origin + paperPath(p.slug),
// Decoding is safe here: `jsonLd()` escapes `<` and `>` on the way into the script element.
headline: decodeEntities(p.title),
name: decodeEntities(p.title),
author: p.authors.map((name) =>
isSiteOwner(name) ? { "@id": id } : { "@type": "Person", name: canonicalAuthor(name) },
),
datePublished: String(p.year),
...(p.journal
? { isPartOf: { "@type": "Periodical", name: decodeEntities(p.journal) } }
: {}),
...(p.doi
? {
identifier: { "@type": "PropertyValue", propertyID: "DOI", value: p.doi },
sameAs: `https://doi.org/${p.doi}`,
}
: {}),
url: origin + paperPath(p.slug),
...(p.pdfPath
? {
encoding: {
"@type": "MediaObject",
encodingFormat: "application/pdf",
contentUrl: origin + p.pdfPath,
},
}
: {}),
})),
];
}