SEO & Structured Data
TVfolio’s <head> is owned, not vendored. src/layouts/BaseHead.astro emits every meta, Open Graph, Twitter and structured-data tag with native tags and no SEO dependency. There is no astro-seo, no robots plugin, no schema package, no RSS package — @astrojs/sitemap is the only integration involved, and it only generates the sitemap.
What BaseHead emits
Every page passes title and description through BaseLayout to BaseHead, and gets:
<title>,<meta name="description">and a canonical link<meta name="robots" content="noindex, nofollow">when the page passesnoindex- Open Graph —
og:type,title,description,url,site_name,locale, andog:imagewith real width and height - Twitter —
summary_large_image, title, description, image and alt, plustwitter:creatoronly when a handle is configured article:published_time,article:modified_timeandarticle:authoron article pages- a JSON-LD
@graph - both favicons, the sitemap link and the RSS link
- the inline pre-paint theme script, and
<ClientRouter />when view transitions are on
Canonical and og:url cannot disagree, because both derive from one expression:
const canonicalURL = new URL(Astro.url.pathname, Astro.site);
Nothing elsewhere reconstructs a URL by hand. Where a page needs the canonical for its own schema @id — the blog post and the CV both do — it writes the same expression deliberately, so the three cannot describe different URLs.
og:image dimensions are real, not assumed. When a page passes a bundled ImageMetadata, its actual width and height are emitted; only the site-wide default falls back to the 1200×630 convention. That is why hero is required in the blog schema: it lets the image be passed unconditionally, so every post has a correct social card.
og:locale is converted, not copied. OG wants language_TERRITORY; siteLocale holds BCP-47, so en-US becomes en_US with a one-line replace.
The deliberate absence of a font preload
Where a preload would normally sit, BaseHead carries a comment instead. Inter backs --font-sans, which is on <body> — but the whole design renders inside the tube, where every string uses one of the three mono stacks. Measured on a built page: 134 rendered text elements, none of them Inter.
A preload is a high-priority fetch, so preloading 48 KB that paints nothing competes with the real LCP. The @font-face stays, so any content that does use the sans stack still gets Inter — fetched when needed. Install one of the three mono faces and preload that instead. Typography has the details.
The JSON-LD layer
src/js/schema.ts holds dependency-free builders. Each returns a plain node; getSiteSchema composes the site-level graph every page emits, and BaseHead merges any page-specific nodes into one @graph and serializes once.
| Builder | Used by |
|---|---|
getOrganizationSchema / getWebSiteSchema |
automatic, on every page via getSiteSchema |
getArticleSchema |
blog posts — emits BlogPosting |
getCreativeWorkSchema |
project pages |
getPersonSchema |
/about/, /cv/, /contact/ |
getBreadcrumbSchema |
available for detail pages |
Using one is a prop:
---
const article = getArticleSchema({
headline: title,
description,
url: canonical,
image: new URL(heroSrc.src, Astro.site).href,
datePublished: pubDate.toISOString(),
authorName: author.data.name,
inLanguage: siteLocale,
publisherId: organizationId(canonical),
});
---
<BaseLayout schema={[article]} article={{ published: pubDate, author: author.data.name }} …>
Note the two separate props. schema carries JSON-LD nodes; article drives og:type="article" and the article:* meta tags. They are different consumers, and the theme keeps the mapping explicit rather than inferring one from the other.
schema is always an array, never Node | Node[]. The comment states the trade honestly: a union buys one call site a saved pair of brackets and costs BaseHead a normalizing ternary on every render.
organizationId
export function organizationId(siteUrl: string): string {
return `${new URL(siteUrl).origin}/#organization`;
}
A stable @id derived from any absolute site URL, so page-level nodes can cross-reference the site’s Organization inside a @graph — publisherId on an article, worksForId on a Person. That is what turns a pile of separate nodes into a connected graph.
serializeJsonLd
Escapes < so the JSON is safe to inline in a <script> without a </script> breakout. The tag is is:inline because it is a raw data block, not a script for Astro to process.
The rule that keeps schema honest
Stated on the CV page, and it is the one to carry into your own edits:
Schema must never state what the markup does not.
The CV emits a Person node with no image field, because the CV frame draws no portrait. It would have been trivial to add one. The theme’s position is that structured data describes the page, not what you wish were on it.
sameAs is a claim, not a link list
siteData.sameAs ships empty, and it is deliberately not derived from socials. sameAs asserts “these URLs are the same entity as this site” — so emitting https://x.com would tell crawlers this site is X.
Once socials holds real profile URLs, sameAs becomes Object.values(socials), plus any profile with no cap on the cabinet. Until then, empty is correct. siteData.json.ts has a production gate that fails a build still carrying platform homepages.
The crawl endpoints
/robots.txt is an endpoint rather than a static file so its Sitemap: line resolves against site and can never name the wrong host.
/llms.txt is a curated content map for AI retrieval systems, following llmstxt.org. What matters is what it deliberately leaves out:
Individual bulletins and transmissions are deliberately absent, because
/blog/and/work/already lead a crawler to all of them and the sitemap already enumerates them.
It answers “what is this site and where does one start”. Add a line when a new section lands, not a new entry. It is an editorial map, not a second sitemap — and a llms.txt that duplicates the sitemap is one that has misunderstood the format.
The sitemap is @astrojs/sitemap with one filter:
sitemap({ filter: (page) => !page.includes("/examples/") && !page.includes("/404/") })
Both are marked noindex in the markup, so listing either would contradict the page itself. A clean build produces 22 URLs.
The pre-paint theme script
Inline in <head>, and it must stay inline — moving it to a bundled <script> reintroduces a flash of the wrong theme.
function savedTheme() {
try { return localStorage.getItem("colorTheme"); } catch { return null; }
}
The try/catch is not defensive noise. localStorage throws in a sandboxed iframe or when site data is blocked, and this runs pre-paint at the top level — an unguarded throw would also skip the listeners below it. It degrades to the device preference instead.
The logic: a saved pick wins, otherwise the site follows the device. It re-runs on astro:after-swap for view transitions, and follows live OS colour-scheme changes — but only while the user has not pinned a choice.
The one value that poisons six things
site in astro.config.mjs feeds canonical URLs, Open Graph, JSON-LD, the sitemap, robots.txt and llms.txt. None of them looks visibly broken in review when it is wrong.
That is why it is gated rather than documented: a production deploy with SITE_URL unset or still on example.com throws. See Deployment.