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()}
-
+
diff --git a/src/routes/site/meta/+page.server.ts b/src/routes/site/meta/+page.server.ts
deleted file mode 100644
index 672b8d8..0000000
--- a/src/routes/site/meta/+page.server.ts
+++ /dev/null
@@ -1,17 +0,0 @@
-import type { PageServerLoad } from "./$types";
-import { fetchSiteInfo } from "@ewanc26/atproto";
-import { PUBLIC_ATPROTO_DID } from "$env/static/public";
-
-export const load: PageServerLoad = async ({ fetch }) => {
- let siteInfo = null;
- let error = null;
-
- try {
- siteInfo = await fetchSiteInfo(PUBLIC_ATPROTO_DID, fetch);
- } catch (err) {
- error =
- err instanceof Error ? err.message : "Failed to load site information";
- }
-
- return { siteInfo, error };
-};
diff --git a/src/routes/site/meta/+page.svelte b/src/routes/site/meta/+page.svelte
index b4893e9..eb5fca9 100644
--- a/src/routes/site/meta/+page.svelte
+++ b/src/routes/site/meta/+page.svelte
@@ -1,57 +1,28 @@
-
+
- {#if data.error}
- {data.error}
- {:else if info}
+ {#if info}
{#if info.additionalInfo?.purpose}
- [01]
+ {sectionNumber('purpose')}
Purpose
{info.additionalInfo.purpose}
@@ -91,32 +60,63 @@
{#if info.additionalInfo?.websiteBirthYear}
- [02]
+ {sectionNumber('history')}
History
This website was first launched in {info.additionalInfo.websiteBirthYear}.
{/if}
- {#if info.privacyStatement}
+ {#if info.privacyStatement || info.additionalInfo?.analytics}
- [03]
+ {sectionNumber('privacy')}
Privacy
- {info.privacyStatement}
+ {#if info.privacyStatement}{info.privacyStatement}
{/if}
+ {#if info.additionalInfo?.analytics}
+
+ {/if}
{/if}
{#if info.openSourceInfo}
- [04]
+ {sectionNumber('open-source')}
Open Source
{#if info.openSourceInfo.description}
{info.openSourceInfo.description}
{/if}
+ {#if info.openSourceInfo.license}
+
+ Project licence
+ {#if info.openSourceInfo.license.url}
+
+ {info.openSourceInfo.license.name ?? 'View licence'}
+
+
+ {:else}
+ {info.openSourceInfo.license.name}
+ {/if}
+
+ {/if}
{#if info.openSourceInfo.repositories?.length}
{#each info.openSourceInfo.repositories as repo}
@@ -143,13 +143,36 @@
{/each}
{/if}
+ {#if info.openSourceInfo.relatedServices.length}
+
+ {/if}
{/if}
{#if info.technologyStack?.length}
- [05]
+ {sectionNumber('tech-stack')}
Technology Stack
{#each groupBySection(info.technologyStack) as [section, techs]}
@@ -179,10 +202,43 @@
{/if}
+ {#if info.additionalInfo?.deployment || info.additionalInfo?.sectionLicense.length}
+
+
+ {sectionNumber('operations')}
+ Operations & licensing
+
+
+
+ {/if}
+
{#if info.credits?.length}
- [06]
+ {sectionNumber('credits')}
Credits
{#each groupBySection(info.credits) as [section, credits]}
@@ -201,10 +257,21 @@
{credit.name}
{/if}
- {#if credit.author}
- by {credit.author}
+ {#if credit.author}
+ by {credit.author}
+ {/if}
+ {#if credit.description}{credit.description}
{/if}
+
+
{/each}