From cfa0ea3b4f77bd70dd2bc61d3bb98426421a960d Mon Sep 17 00:00:00 2001 From: Ewan Croft Date: Fri, 17 Jul 2026 05:44:39 +0100 Subject: [PATCH] Propagate site info record metadata --- package.json | 3 +- src/lib/components/Footer.svelte | 26 +- src/lib/components/SiteHead.svelte | 18 +- src/lib/services/atproto/siteInfo.test.ts | 57 +++++ src/lib/services/atproto/siteInfo.ts | 275 ++++++++++++++++++++++ src/lib/styles/pages.css | 8 + src/routes/+layout.server.ts | 8 + src/routes/+layout.svelte | 4 +- src/routes/site/meta/+page.server.ts | 17 -- src/routes/site/meta/+page.svelte | 189 ++++++++++----- 10 files changed, 520 insertions(+), 85 deletions(-) create mode 100644 src/lib/services/atproto/siteInfo.test.ts create mode 100644 src/lib/services/atproto/siteInfo.ts create mode 100644 src/routes/+layout.server.ts delete mode 100644 src/routes/site/meta/+page.server.ts diff --git a/package.json b/package.json index 0a7468e..2dbf984 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,8 @@ "preview": "vite preview", "prepare": "svelte-kit sync || echo ''", "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", - "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch" + "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", + "test:site-info": "node --experimental-strip-types --test src/lib/services/atproto/siteInfo.test.ts" }, "devDependencies": { "@lucide/svelte": "latest", diff --git a/src/lib/components/Footer.svelte b/src/lib/components/Footer.svelte index ad78dab..9644500 100644 --- a/src/lib/components/Footer.svelte +++ b/src/lib/components/Footer.svelte @@ -21,6 +21,9 @@ import Samhain from '$lib/components/icons/sabbats/Samhain.svelte'; import Yule from '$lib/components/icons/sabbats/Yule.svelte'; import { PUBLIC_ATPROTO_DID } from '$env/static/public'; + import type { NormalizedSiteInfo } from '$lib/services/atproto/siteInfo'; + + let { siteInfo = null }: { siteInfo?: NormalizedSiteInfo | null } = $props(); const SabbatIcons: Record = { Beltane, @@ -37,6 +40,17 @@ let showModal = $state(false); let SabbatIcon = $derived(currentSabbat ? SabbatIcons[currentSabbat.name] : null); + const currentYear = new Date().getFullYear(); + let birthYear = $derived(siteInfo?.additionalInfo?.websiteBirthYear); + let copyrightYears = $derived( + birthYear && birthYear < currentYear ? `${birthYear}–${currentYear}` : `${currentYear}` + ); + let primaryRepository = $derived( + siteInfo?.openSourceInfo?.repositories.find((repository) => repository.type === 'primary') + ?? siteInfo?.openSourceInfo?.repositories[0] + ); + let projectLicense = $derived(siteInfo?.openSourceInfo?.license); + let contactEmail = $derived(siteInfo?.additionalInfo?.contact?.email ?? 'contact@ewancroft.uk'); // Easter egg #4 — Mōnandæg footer label (client-side, respects local timezone) @@ -73,13 +87,19 @@ diff --git a/src/lib/components/SiteHead.svelte b/src/lib/components/SiteHead.svelte index 95ae3a1..0d64611 100644 --- a/src/lib/components/SiteHead.svelte +++ b/src/lib/components/SiteHead.svelte @@ -2,6 +2,7 @@ import { page } from '$app/state'; import { SITE } from '$lib/config'; import { PUBLIC_ATPROTO_DID, PUBLIC_LEAFLET_BLOG_PUBLICATION } from '$env/static/public'; + import type { NormalizedSiteInfo } from '$lib/services/atproto/siteInfo'; let { title, @@ -38,9 +39,21 @@ documentRkey?: string; } = $props(); + const siteInfo = $derived(page.data.siteInfo as NormalizedSiteInfo | null | undefined); const fullTitle = $derived(title ? `${title} — ${SITE.title}` : SITE.ogTitle); - const fullDescription = $derived(description ?? SITE.description); + const fullDescription = $derived( + description ?? siteInfo?.additionalInfo?.purpose ?? SITE.description + ); const canonicalUrl = $derived(new URL(page.url.pathname, page.url.origin).href); + const projectLicense = $derived(siteInfo?.openSourceInfo?.license); + const pageLicense = $derived.by(() => { + if (page.url.pathname.startsWith('/blog')) { + return siteInfo?.additionalInfo?.sectionLicense.find( + (license) => license.section?.toLowerCase() === 'blog' + ) ?? projectLicense; + } + return projectLicense; + }); // Standard.site discovery and verification // Publication hint is for the site-wide discovery, typically root and blog index. @@ -79,6 +92,9 @@ {fullTitle} + {#if pageLicense?.url} + + {/if} {#if publicationAtUri} diff --git a/src/lib/services/atproto/siteInfo.test.ts b/src/lib/services/atproto/siteInfo.test.ts new file mode 100644 index 0000000..80010fc --- /dev/null +++ b/src/lib/services/atproto/siteInfo.test.ts @@ -0,0 +1,57 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { normalizeSiteInfo } from "./siteInfo.ts"; + +test("normalizes the original nested additionalInfo record shape", () => { + const info = normalizeSiteInfo({ + additionalInfo: { + analytics: { services: [], cookiePolicy: "No analytics." }, + properties: { + purpose: "A personal website.", + websiteBirthYear: 2023, + sectionLicense: [ + { + section: "blog", + name: "CC BY 4.0", + url: "https://creativecommons.org/licenses/by/4.0/", + }, + ], + }, + }, + }); + + assert.equal(info?.additionalInfo?.purpose, "A personal website."); + assert.equal(info?.additionalInfo?.websiteBirthYear, 2023); + assert.equal(info?.additionalInfo?.sectionLicense[0]?.section, "blog"); + assert.equal(info?.additionalInfo?.analytics?.cookiePolicy, "No analytics."); +}); + +test("accepts flattened additionalInfo fields", () => { + const info = normalizeSiteInfo({ + additionalInfo: { + purpose: "A standards-based website.", + websiteBirthYear: 2023, + }, + }); + + assert.equal(info?.additionalInfo?.purpose, "A standards-based website."); + assert.equal(info?.additionalInfo?.websiteBirthYear, 2023); +}); + +test("drops unsafe record URLs before they reach link attributes", () => { + const info = normalizeSiteInfo({ + openSourceInfo: { + license: { name: "Unsafe", url: "javascript:alert(1)" }, + repositories: [ + { url: "javascript:alert(1)", type: "primary" }, + { url: "https://git.croft.click/ewan/website", type: "mirror" }, + ], + }, + }); + + assert.equal(info?.openSourceInfo?.license?.url, undefined); + assert.deepEqual( + info?.openSourceInfo?.repositories.map((repository) => repository.url), + ["https://git.croft.click/ewan/website"], + ); +}); diff --git a/src/lib/services/atproto/siteInfo.ts b/src/lib/services/atproto/siteInfo.ts new file mode 100644 index 0000000..24f6bec --- /dev/null +++ b/src/lib/services/atproto/siteInfo.ts @@ -0,0 +1,275 @@ +export interface SiteInfoLink { + name?: string; + url?: string; +} + +export interface SiteInfoCredit extends SiteInfoLink { + type: string; + author?: string; + section?: string; + description?: string; + license?: SiteInfoLink; +} + +export interface SiteInfoTechnology extends SiteInfoLink { + name: string; + description?: string; + section?: string; +} + +export interface SiteInfoRepository { + url: string; + type?: string; + platform?: string; + description?: string; +} + +export interface SiteInfoRelatedService extends SiteInfoLink { + name: string; + section?: string; + relationship?: string; + description?: string; +} + +export interface NormalizedSiteInfo { + credits: SiteInfoCredit[]; + technologyStack: SiteInfoTechnology[]; + privacyStatement?: string; + openSourceInfo?: { + description?: string; + license?: SiteInfoLink; + repositories: SiteInfoRepository[]; + relatedServices: SiteInfoRelatedService[]; + }; + additionalInfo?: { + purpose?: string; + websiteBirthYear?: number; + sectionLicense: Array; + analytics?: { + services: string[]; + cookiePolicy?: string; + }; + deployment?: { + platform?: string; + cdn?: string; + customDomain?: boolean; + }; + contact?: { + email?: string; + social: Array<{ platform: string; url: string; handle?: string }>; + }; + }; +} + +type UnknownRecord = Record; + +function record(value: unknown): UnknownRecord | undefined { + return value !== null && typeof value === "object" && !Array.isArray(value) + ? (value as UnknownRecord) + : undefined; +} + +function text(value: unknown, maxLength = 5000): string | undefined { + if (typeof value !== "string") return undefined; + const trimmed = value.trim(); + return trimmed ? trimmed.slice(0, maxLength) : undefined; +} + +function webUrl(value: unknown): string | undefined { + const raw = text(value, 2048); + if (!raw) return undefined; + + try { + const url = new URL(raw); + return url.protocol === "https:" ? url.href : undefined; + } catch { + return undefined; + } +} + +function email(value: unknown): string | undefined { + const candidate = text(value, 254); + return candidate && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(candidate) + ? candidate + : undefined; +} + +function link(value: unknown): SiteInfoLink | undefined { + const item = record(value); + if (!item) return undefined; + const normalized = { name: text(item.name, 1000), url: webUrl(item.url) }; + return normalized.name || normalized.url ? normalized : undefined; +} + +function list(value: unknown): unknown[] { + return Array.isArray(value) ? value : []; +} + +/** + * Treat the repository record as untrusted input and expose one stable shape to + * Svelte components. The compatibility lookup for `additionalInfo.properties` + * supports records created from the original draft schema as well as the + * flattened shape used by @ewanc26/atproto's public type. + */ +export function normalizeSiteInfo(value: unknown): NormalizedSiteInfo | null { + const input = record(value); + if (!input) return null; + + const additional = record(input.additionalInfo); + const legacyProperties = record(additional?.properties); + const property = (name: string) => + additional?.[name] ?? legacyProperties?.[name]; + + const credits = list(input.credits) + .flatMap((value): SiteInfoCredit[] => { + const item = record(value); + const name = text(item?.name, 1000); + if (!item || !name) return []; + + return [ + { + name, + type: text(item.type, 500) ?? "resource", + url: webUrl(item.url), + author: text(item.author, 1000), + section: text(item.section, 1000), + description: text(item.description), + license: link(item.license), + }, + ]; + }) + .slice(0, 50); + + const technologyStack = list(input.technologyStack) + .flatMap((value): SiteInfoTechnology[] => { + const item = record(value); + const name = text(item?.name, 1000); + if (!item || !name) return []; + return [ + { + name, + url: webUrl(item.url), + description: text(item.description), + section: text(item.section, 1000), + }, + ]; + }) + .slice(0, 50); + + const openSource = record(input.openSourceInfo); + const repositories = list(openSource?.repositories) + .flatMap((value): SiteInfoRepository[] => { + const item = record(value); + const url = webUrl(item?.url); + if (!item || !url) return []; + return [ + { + url, + type: text(item.type, 500), + platform: text(item.platform, 500), + description: text(item.description, 2000), + }, + ]; + }) + .slice(0, 20); + + const relatedServices = list(openSource?.relatedServices) + .flatMap((value): SiteInfoRelatedService[] => { + const item = record(value); + const name = text(item?.name, 1000); + if (!item || !name) return []; + return [ + { + name, + url: webUrl(item.url), + section: text(item.section, 1000), + relationship: text(item.relationship, 1000), + description: text(item.description), + }, + ]; + }) + .slice(0, 20); + + const sectionLicense = list(property("sectionLicense")) + .flatMap((value): Array => { + const item = record(value); + if (!item) return []; + const normalized = { + name: text(item.name, 1000), + url: webUrl(item.url), + section: text(item.section, 1000), + }; + return normalized.name || normalized.url ? [normalized] : []; + }) + .slice(0, 20); + + const analytics = record(additional?.analytics); + const deployment = record(additional?.deployment); + const contact = record(additional?.contact); + const social = list(contact?.social) + .flatMap( + (value): Array<{ platform: string; url: string; handle?: string }> => { + const item = record(value); + const platform = text(item?.platform, 500); + const url = webUrl(item?.url); + return item && platform && url + ? [{ platform, url, handle: text(item.handle, 1000) }] + : []; + }, + ) + .slice(0, 20); + + const yearValue = property("websiteBirthYear"); + const websiteBirthYear = + typeof yearValue === "number" && + Number.isInteger(yearValue) && + yearValue >= 1990 && + yearValue <= new Date().getFullYear() + 1 + ? yearValue + : undefined; + + return { + credits, + technologyStack, + privacyStatement: text(input.privacyStatement, 50000), + openSourceInfo: openSource + ? { + description: text(openSource.description, 20000), + license: link(openSource.license), + repositories, + relatedServices, + } + : undefined, + additionalInfo: additional + ? { + purpose: text(property("purpose"), 10000), + websiteBirthYear, + sectionLicense, + analytics: analytics + ? { + services: list(analytics.services) + .flatMap((service) => text(service, 1000) ?? []) + .slice(0, 10), + cookiePolicy: text(analytics.cookiePolicy, 10000), + } + : undefined, + deployment: deployment + ? { + platform: text(deployment.platform, 1000), + cdn: text(deployment.cdn, 1000), + customDomain: + typeof deployment.customDomain === "boolean" + ? deployment.customDomain + : undefined, + } + : undefined, + contact: contact + ? { + email: email(contact.email), + social, + } + : undefined, + } + : undefined, + }; +} diff --git a/src/lib/styles/pages.css b/src/lib/styles/pages.css index 9f30177..b298a29 100644 --- a/src/lib/styles/pages.css +++ b/src/lib/styles/pages.css @@ -1013,6 +1013,14 @@ .meta-card-link:hover { color: var(--color-primary-500); } + .meta-inline { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: var(--space-sm); + margin: 0 0 var(--space-sm); + font-size: var(--text-sm); + } .meta-card-desc { margin: var(--space-2xs) 0 0; font-size: var(--text-sm); diff --git a/src/routes/+layout.server.ts b/src/routes/+layout.server.ts new file mode 100644 index 0000000..f43c23d --- /dev/null +++ b/src/routes/+layout.server.ts @@ -0,0 +1,8 @@ +import type { LayoutServerLoad } from "./$types"; +import { fetchSiteInfo } from "$lib/services/atproto/fetch"; +import { normalizeSiteInfo } from "$lib/services/atproto/siteInfo"; + +export const load: LayoutServerLoad = async ({ fetch }) => { + const siteInfo = normalizeSiteInfo(await fetchSiteInfo(fetch)); + return { siteInfo }; +}; diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 67666f8..21c2161 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -14,7 +14,7 @@ import BlogArchiveEggs from '$lib/components/ostara-eggs/BlogArchiveEggs.svelte'; import './layout.css'; - let { children } = $props(); + let { data, children } = $props(); onMount(() => { const handleScroll = () => { @@ -52,4 +52,4 @@ {@render children()} -