PR 2373 · Apply Studio SEO settings
Closes the gap between SEO values saved in Studio and the HTML emitted by Fimo's Astro and SvelteKit starters.
Outcome
PR 2373 now gives Astro and SvelteKit a small, explicit way to turn Studio SEO settings into HTML. The application chooses the route, loads its content, and applies its own policy. Fimo converts the resulting values into tag attributes. The framework renders them.
This replaces the earlier draft's combined buildFimoHead helper. The public addition is toSeoHead in fimo/seo, used after the existing resolveSeoForPath. It follows the same ownership principle as the merged Next.js converters, with native tags as its output instead of Next metadata objects.
The PR remains draft, not released. Current proof includes 67 focused tests, three generated-framework integration tests and a SvelteKit browser navigation check. Existing applications still need to adopt the wiring; upgrading a dependency does not rewrite their SEO code.
The contract
Resolve → app policy → convert → render
The old helper combined route selection, defaults, overrides and HTML conversion. Separating those decisions makes custom routing and content possible without asking a utility to understand every application. There is no generic merge wrapper and no automatic pathname discovery.
Application inputs
Pure operations
Framework
Application composition exampleimport { resolveSeoForPath, toSeoHead } from 'fimo/seo';
import config from '../../.fimo/config.json';
const resolved = resolveSeoForPath(pathname, config, { article });
const seo = { ...resolved, title: String(article.title) };
const head = toSeoHead(seo, { canonicalUrl });- Both functions are pure and bundle-safe. Import paths for config depend on the application's actual file tree.
What Fimo supplies
Ten fields become metadata; the app still owns richer SEO
The converter accepts the existing FimoSEO type. It does not introduce a second settings schema. Most fields map directly to tags. The important exception is url, which is a site base, not the canonical URL of every page.
- title
- title, og:title, twitter:title
- description
- description and social descriptions
- image
- og:image and twitter:image
- siteName
- og:site_name
- locale
- og:locale
- twitterSite
- twitter:site
- favicon
- icon and apple-touch-icon links
- robots
- robots meta
- themeColor
- theme-color meta
- url
- Input for the app's URL policy only
- canonicalUrl option
- canonical link and og:url
Default converter output, abbreviated{
title: 'An article',
meta: [
{ property: 'og:type', content: 'website' },
{ property: 'og:title', content: 'An article' },
{ name: 'twitter:card', content: 'summary_large_image' },
{ name: 'twitter:title', content: 'An article' },
],
links: []
}- No canonical is inferred from seo.url. JSON-LD, alternate languages, article metadata and custom card types remain in the app's head code. The converter is optional if that code already handles Fimo's values.
Astro
Content pages resolve once, then pass values to Base
A layout knows the pathname, but not necessarily the article the page loaded. The starter now resolves project SEO after the content read and passes it into Base. Its route config uses {{project.title}} and {{project.summary}}. Base handles the native tags and retains title/description props for existing callers.
Actual starter pattern, excerptconst seo = resolveSeoForPath(Astro.url.pathname, config, { project });
---
<Base lang={locale} seo={seo} title={seo?.title ?? String(project.title)}>
<!-- Existing page content -->
</Base>Base.astro, conversion and rendering excerptsconst head = toSeoHead({
...seo,
...(title !== undefined ? { title } : {}),
...(description !== undefined ? { description } : {}),
}, { canonicalUrl });
---
<title>{head.title ?? 'Fimo + Astro'}</title>
{head.meta.map((attributes) => <meta {...attributes} />)}
{head.links.map((attributes) => <link {...attributes} />)}- Base resolves a default when no seo prop is supplied. A supplied final object is not merged again with global settings. Its canonicalUrl prop also accepts null to suppress a canonical explicitly.
HTML asserted by the Astro integration<title>Native Astro project</title>
<meta name="description" content="Localized project summary">
<meta property="og:title" content="Native Astro project">
<meta property="og:description" content="Localized project summary">
<meta name="twitter:title" content="Native Astro project">- These values come from the generated content fixture, not hardcoded metadata in the page layout.
SvelteKit
One head follows the final page data through navigation
A child title alone cannot keep a root Open Graph title in sync. The revised starter returns seoHead from its root server load. A content page can return a complete replacement under the same key. The root reads page.data, which includes the leaf's final data, and owns all the resulting tags.
Default-only pages inherit the root result. Content pages resolve their actual pathname and loaded entry. The app can return an empty converted head to clear Fimo tags, rather than merge defaults back in.
Content page load, composition exampleconst article = await loadArticle(event.params.slug);
const resolved = resolveSeoForPath(event.url.pathname, config, { article });
const seo = { ...resolved, title: String(article.title) };
return {
article: serializeArticle(article),
seoHead: toSeoHead(seo, { canonicalUrl: canonicalFor(article) }),
};- loadArticle, serializeArticle and canonicalFor stand for existing application code. Use the generated content module's serializer for tracked content at the Svelte load boundary. Do not create extra loaders solely to copy this example.
Root layout, implemented rendering patternimport { page } from '$app/state';
const head = $derived(page.data.seoHead ?? { meta: [], links: [] });
<svelte:head>
<title>{head.title ?? 'Fimo + SvelteKit'}</title>
{#each head.meta as attributes}<meta {...attributes} />{/each}
{#each head.links as attributes}<link {...attributes} />{/each}
</svelte:head>- Imports and the derived value belong inside the existing script block. Keep the provider and children rendering. Do not add a competing page head for the same tags.
Existing applications
Choose precedence before conversion, without taking over the app
An app's article title may override a Studio default; a private page may remove its share image. Those are explicit field choices. The converter never restores a removed field or silently changes the routing, caching or canonical policy.
A mature application may already use a head component or SEO library. Feed the resolved values into that component instead. There is no requirement to replace it with Fimo's renderer.
App-controlled removal, based on the tested article caseconst seo = {
...resolved,
robots: 'noindex',
image: undefined,
favicon: '',
};
const head = toSeoHead(seo, {
canonicalUrl: 'https://sveltekit.example/stories/article/',
});- An explicit undefined removes a field. Use a conditional spread when an absent optional override should retain the default. The canonical is passed through unchanged, including its path and trailing slash.
Extending tags beyond FimoSEO, application exampleconst head = toSeoHead(seo, { canonicalUrl });
const meta = head.meta.filter(
tag => !('property' in tag && tag.property === 'og:type')
);
meta.push({ property: 'og:type', content: 'article' });
// Render meta once, with the app's existing JSON-LD and alternate links.- Replace a matching tag instead of emitting duplicates. The converter's built-in website/card defaults are convenience, not a universal SEO policy.
Adoption and freshness
Skills teach one contract and point to the right framework recipe
The shared frameworks/seo-head reference names every field, removal semantics, canonical ownership and the single-head rule. Astro and SvelteKit references supply their own recipes. The main coding skill routes new content-page work there, and migration guidance tells agents to preserve the existing head owner.
These instructions make the pattern discoverable, but are not proof that every model will follow it. This revision has no independent coding-agent adoption run. The executable tests defend output and navigation contracts.
fimo-code/SKILL.md · discovery hookAstro/SvelteKit SEO or a new content route → the framework reference,
then references/frameworks/seo-head.md- New startersThe SEO wiring is included. Content routes must pass the data their SEO templates reference.
- Existing sitesUpdate Fimo, inspect the current head code, then adopt the values without duplicating tags. Package update alone is insufficient.
- Config freshnessImported JSON is a build snapshot. Sync and rebuild/redeploy after Studio config changes.
- Content freshnessSSR, prerendering and the app's cache policy determine when entry changes appear.
- Custom routingThe app owns locale/domain/base-path selection. The route catalog does not replace its router.
Evidence
The current revision has output and navigation proof, not production acceptance
The implementation was tested on an isolated Linux Dev Remote box. Initial fixture runs needed the companion packages and publish-time route bundle built first. The later runs passed without substituting a released CLI. Astro's current starter resolved to 7.3.1; the content fixture uses 7.1.6. SvelteKit uses 2.70.2 with Svelte 5.56.8.
Converter 7, resolver 11, server 3, template contracts 22, skill references 24.
Astro 2/2 and SvelteKit 1/1. Builds, typechecks and static/server HTML checks passed, including content metadata and app policy.
Default → article → Back. A window marker proves no full-page reload. Exact titles, social tags, image removal, robots and canonical assertions passed. Desktop/mobile fixture screenshots inspected; no overflow.
CLI and companion builds, CLI test typecheck, publish dry-run, lint, formatting, diff and pre-push i18n checks passed.
Fresh CI was not waited on. This revision has no hosted-site deployment, production crawler acceptance or independent coding-agent adoption receipt. No Lighthouse comparison is claimed.