diff --git a/apps/web/src/lib/components/theme/SiteArticle.svelte b/apps/web/src/lib/components/theme/SiteArticle.svelte
index 09189d0..7ee3275 100644
--- a/apps/web/src/lib/components/theme/SiteArticle.svelte
+++ b/apps/web/src/lib/components/theme/SiteArticle.svelte
@@ -1,5 +1,6 @@
@@ -9,9 +10,10 @@
{page.title}
{#if page.publishedAt || page.updatedAt}
- {#if page.publishedAt}Published {page.publishedAt.slice(0, 10)}{/if}
+ {#if page.publishedAt}Published
+ {/if}
{#if page.updatedAt}·Updated
- {page.updatedAt.slice(0, 10)}{/if}
+ {/if}
{/if}
diff --git a/apps/web/src/lib/components/theme/SiteLayout.svelte b/apps/web/src/lib/components/theme/SiteLayout.svelte
index 78492be..f17945a 100644
--- a/apps/web/src/lib/components/theme/SiteLayout.svelte
+++ b/apps/web/src/lib/components/theme/SiteLayout.svelte
@@ -7,8 +7,15 @@
base,
// The index leads with the name in its hero; repeating it here reads as a typo.
showWordmark = true,
+ currentPath,
children
- }: { site: SiteView; base: string; showWordmark?: boolean; children: Snippet } = $props();
+ }: {
+ site: SiteView;
+ base: string;
+ showWordmark?: boolean;
+ currentPath?: string;
+ children: Snippet;
+ } = $props();
// Colour roles set on the site record override the theme's own.
const overrides = $derived(
@@ -26,7 +33,15 @@
const nav = $derived(site.pages.filter((page) => page.listed));
+
+ {#if site.colors.background}
+
+ {@html ``}
+ {/if}
+
+
+
Skip to content
-
+
{@render children()}
@@ -56,8 +74,19 @@
diff --git a/apps/web/src/lib/date-format.ts b/apps/web/src/lib/date-format.ts
new file mode 100644
index 0000000..f47c71b
--- /dev/null
+++ b/apps/web/src/lib/date-format.ts
@@ -0,0 +1,15 @@
+/** Dates for display: "2026-08-23T…" → "Aug 23, 2026". */
+
+// UTC keeps server-rendered and hydrated output identical across timezones.
+const formatter = new Intl.DateTimeFormat('en-US', {
+ year: 'numeric',
+ month: 'short',
+ day: 'numeric',
+ timeZone: 'UTC'
+});
+
+/** Unparsable values pass through unchanged. */
+export function formatDate(value: string): string {
+ const date = new Date(value);
+ return Number.isNaN(date.getTime()) ? value : formatter.format(date);
+}
diff --git a/apps/web/src/lib/linkify.ts b/apps/web/src/lib/linkify.ts
new file mode 100644
index 0000000..83e4d13
--- /dev/null
+++ b/apps/web/src/lib/linkify.ts
@@ -0,0 +1,26 @@
+/**
+ * Splits plain text into segments with URLs linked. Used for profile bios,
+ * which carry no facets. Bare domains (`jzweifel.dev`) are linked as https.
+ */
+
+import type { TextSegment } from './site-view';
+
+// ponytail: naive bare-domain heuristic (any dotted word with a ≥2-letter final
+// label); swap in a TLD list if false positives show up in real bios.
+const URL_PATTERN = /https?:\/\/[^\s<>]+|(?]*)?/gi;
+const TRAILING_PUNCTUATION = /[.,;:!?)'"\]]+$/;
+
+export function linkify(text: string): TextSegment[] {
+ const segments: TextSegment[] = [];
+ let cursor = 0;
+ for (const match of text.matchAll(URL_PATTERN)) {
+ const candidate = match[0].replace(TRAILING_PUNCTUATION, '');
+ if (candidate === '') continue;
+ if (match.index > cursor) segments.push({ text: text.slice(cursor, match.index) });
+ const url = /^https?:\/\//i.test(candidate) ? candidate : `https://${candidate}`;
+ segments.push({ text: candidate, url });
+ cursor = match.index + candidate.length;
+ }
+ if (cursor < text.length) segments.push({ text: text.slice(cursor) });
+ return segments.length > 0 ? segments : [{ text }];
+}
diff --git a/apps/web/src/lib/server/atmosphere/atmosphere.test.ts b/apps/web/src/lib/server/atmosphere/atmosphere.test.ts
index 290e4d6..ca3be81 100644
--- a/apps/web/src/lib/server/atmosphere/atmosphere.test.ts
+++ b/apps/web/src/lib/server/atmosphere/atmosphere.test.ts
@@ -1,9 +1,9 @@
import { describe, expect, it } from 'vitest';
import { listAllRecords, blobUrl, type AtmosphereContext } from './xrpc';
import { handleFromDidDocument, pdsEndpoint } from './identity';
-import { fetchBlueskyPosts, fetchBlueskyProfile } from './bluesky';
+import { fetchBlueskyPosts, fetchBlueskyProfile, postPermalink, postSegments } from './bluesky';
import { fetchDocuments } from './standard-site';
-import { fetchSifaProfile, fetchSifaSkills } from './sifa';
+import { fetchSifaEducation, fetchSifaPositions, fetchSifaProfile, fetchSifaSkills } from './sifa';
import { detectSources } from './sources';
/**
@@ -127,6 +127,94 @@ describe('bluesky adapter', () => {
it('returns undefined when there is no profile record', async () => {
expect(await fetchBlueskyProfile(fakeCtx({}))).toBeUndefined();
});
+
+ it('derives a bsky.app permalink from the record URI', async () => {
+ const ctx = fakeCtx({
+ collections: { 'app.bsky.feed.post': [{ text: 'hi', createdAt: '2026-01-01T00:00:00Z' }] }
+ });
+ const posts = await fetchBlueskyPosts(ctx);
+ expect(posts[0].url).toBe('https://bsky.app/profile/did:plc:test123/post/rkey0');
+ });
+});
+
+describe('postSegments', () => {
+ it('splits at facet byte ranges, counting UTF-8 bytes not code units', () => {
+ // "☁️ " is 7 bytes; the link facet covers "example.com".
+ const text = '☁️ example.com!';
+ const segments = postSegments(text, {
+ facets: [
+ {
+ index: { byteStart: 7, byteEnd: 18 },
+ features: [{ $type: 'app.bsky.richtext.facet#link', uri: 'https://example.com' }]
+ }
+ ]
+ });
+ expect(segments).toEqual([
+ { text: '☁️ ' },
+ { text: 'example.com', url: 'https://example.com' },
+ { text: '!' }
+ ]);
+ });
+
+ it('links mentions and tags to bsky.app', () => {
+ const text = '@ann #sky';
+ const segments = postSegments(text, {
+ facets: [
+ {
+ index: { byteStart: 0, byteEnd: 4 },
+ features: [{ $type: 'app.bsky.richtext.facet#mention', did: 'did:plc:ann' }]
+ },
+ {
+ index: { byteStart: 5, byteEnd: 9 },
+ features: [{ $type: 'app.bsky.richtext.facet#tag', tag: 'sky' }]
+ }
+ ]
+ });
+ expect(segments.map((s) => s.url)).toEqual([
+ 'https://bsky.app/profile/did:plc:ann',
+ undefined,
+ 'https://bsky.app/hashtag/sky'
+ ]);
+ });
+
+ it('drops malformed, overlapping, and non-web facets', () => {
+ const text = 'abcdef';
+ const segments = postSegments(text, {
+ facets: [
+ {
+ index: { byteStart: 0, byteEnd: 3 },
+ features: [{ $type: 'app.bsky.richtext.facet#link', uri: 'javascript:alert(1)' }]
+ },
+ {
+ index: { byteStart: 1, byteEnd: 4 },
+ features: [{ $type: 'app.bsky.richtext.facet#link', uri: 'https://a.example' }]
+ },
+ {
+ index: { byteStart: 2, byteEnd: 99 },
+ features: [{ $type: 'app.bsky.richtext.facet#link', uri: 'https://b.example' }]
+ },
+ { index: 'nope', features: [] }
+ ]
+ });
+ expect(segments).toEqual([
+ { text: 'a' },
+ { text: 'bcd', url: 'https://a.example' },
+ { text: 'ef' }
+ ]);
+ });
+
+ it('returns the whole text when there are no facets', () => {
+ expect(postSegments('plain', {})).toEqual([{ text: 'plain' }]);
+ });
+});
+
+describe('postPermalink', () => {
+ it('rewrites an at:// post URI and rejects other shapes', () => {
+ expect(postPermalink('at://did:plc:x/app.bsky.feed.post/3kabc')).toBe(
+ 'https://bsky.app/profile/did:plc:x/post/3kabc'
+ );
+ expect(postPermalink('not-a-uri')).toBeUndefined();
+ });
});
describe('standard.site adapter', () => {
@@ -157,6 +245,24 @@ describe('standard.site adapter', () => {
expect(docs[0]).toMatchObject({ title: 'Legacy', legacy: true, publishedAt: '2026-03-01T00:00:00Z' });
});
+ it('derives a document URL from its publication URL and path', async () => {
+ const ctx = fakeCtx({
+ collections: {
+ 'site.standard.document': [
+ { title: 'Joined', site: 'at://did:plc:test123/site.standard.publication/rkey0', path: '/joined' },
+ { title: 'Own URL', site: 'at://did:plc:test123/site.standard.publication/rkey0', path: '/x', url: 'https://own.example/x' },
+ { title: 'No publication', path: '/orphan' }
+ ],
+ 'site.standard.publication': [{ name: 'Blog', url: 'https://blog.example/' }]
+ }
+ });
+ const docs = await fetchDocuments(ctx);
+ const byTitle = new Map(docs.map((d) => [d.title, d.url]));
+ expect(byTitle.get('Joined')).toBe('https://blog.example/joined');
+ expect(byTitle.get('Own URL')).toBe('https://own.example/x');
+ expect(byTitle.get('No publication')).toBeUndefined();
+ });
+
it('filters by publication when asked', async () => {
const ctx = fakeCtx({
collections: {
@@ -180,6 +286,29 @@ describe('sifa adapter', () => {
expect(await fetchSifaProfile(fakeCtx({}))).toBeUndefined();
});
+ it('reads dates from either startDate/endDate or sifa startedAt/endedAt', async () => {
+ const ctx = fakeCtx({
+ collections: {
+ 'id.sifa.profile.position': [
+ { title: 'Captain', company: 'Airship Co', startedAt: '2019-08' },
+ { title: 'Navigator', organization: 'Skyways', startDate: '2016', endDate: '2018' }
+ ],
+ 'id.sifa.profile.education': [
+ { institution: 'Sky Academy', degree: 'Meteorology', startedAt: '2006', endedAt: '2010' }
+ ]
+ }
+ });
+ const positions = await fetchSifaPositions(ctx);
+ expect(positions[0]).toMatchObject({ organization: 'Airship Co', startDate: '2019-08' });
+ expect(positions[1]).toMatchObject({ startDate: '2016', endDate: '2018' });
+ const education = await fetchSifaEducation(ctx);
+ expect(education[0]).toMatchObject({
+ school: 'Sky Academy',
+ startDate: '2006',
+ endDate: '2010'
+ });
+ });
+
it('skips malformed skill records instead of failing', async () => {
const ctx = fakeCtx({
collections: {
diff --git a/apps/web/src/lib/server/atmosphere/bluesky.ts b/apps/web/src/lib/server/atmosphere/bluesky.ts
index e72517d..dd39d1d 100644
--- a/apps/web/src/lib/server/atmosphere/bluesky.ts
+++ b/apps/web/src/lib/server/atmosphere/bluesky.ts
@@ -10,6 +10,7 @@ import {
listAllRecords,
str
} from './xrpc';
+import type { TextSegment } from '../../site-view';
export interface BlueskyProfile {
displayName?: string;
@@ -21,11 +22,81 @@ export interface BlueskyProfile {
export interface BlueskyPost {
uri: string;
text: string;
+ /** The text split at its facets, so links render as links. */
+ segments: TextSegment[];
+ /** bsky.app permalink. */
+ url?: string;
createdAt: string;
/** Post has an embed (image, external link, quote). */
hasEmbed: boolean;
}
+interface FacetLink {
+ byteStart: number;
+ byteEnd: number;
+ url: string;
+}
+
+function featureUrl(features: unknown[]): string | undefined {
+ for (const feature of features) {
+ if (feature === null || typeof feature !== 'object') continue;
+ const f = feature as Record;
+ switch (f.$type) {
+ case 'app.bsky.richtext.facet#link':
+ // Only web URLs become hrefs; anything else in a record stays text.
+ if (typeof f.uri === 'string' && /^https?:\/\//.test(f.uri)) return f.uri;
+ break;
+ case 'app.bsky.richtext.facet#mention':
+ if (typeof f.did === 'string') return `https://bsky.app/profile/${f.did}`;
+ break;
+ case 'app.bsky.richtext.facet#tag':
+ if (typeof f.tag === 'string') return `https://bsky.app/hashtag/${encodeURIComponent(f.tag)}`;
+ break;
+ }
+ }
+ return undefined;
+}
+
+function facetLinks(value: Record): FacetLink[] {
+ if (!Array.isArray(value.facets)) return [];
+ const links: FacetLink[] = [];
+ for (const facet of value.facets) {
+ if (facet === null || typeof facet !== 'object') continue;
+ const { index, features } = facet as Record;
+ if (index === null || typeof index !== 'object' || !Array.isArray(features)) continue;
+ const { byteStart, byteEnd } = index as Record;
+ if (typeof byteStart !== 'number' || typeof byteEnd !== 'number' || byteStart >= byteEnd)
+ continue;
+ const url = featureUrl(features);
+ if (url) links.push({ byteStart, byteEnd, url });
+ }
+ return links.sort((a, b) => a.byteStart - b.byteStart);
+}
+
+/** Facet ranges are byte offsets into the UTF-8 encoding of the text. */
+export function postSegments(text: string, value: Record): TextSegment[] {
+ const links = facetLinks(value);
+ if (links.length === 0) return [{ text }];
+ const bytes = new TextEncoder().encode(text);
+ const decoder = new TextDecoder();
+ const segments: TextSegment[] = [];
+ let cursor = 0;
+ for (const { byteStart, byteEnd, url } of links) {
+ // Overlapping or out-of-range facets are dropped, not clamped.
+ if (byteStart < cursor || byteEnd > bytes.length) continue;
+ if (byteStart > cursor) segments.push({ text: decoder.decode(bytes.subarray(cursor, byteStart)) });
+ segments.push({ text: decoder.decode(bytes.subarray(byteStart, byteEnd)), url });
+ cursor = byteEnd;
+ }
+ if (cursor < bytes.length) segments.push({ text: decoder.decode(bytes.subarray(cursor)) });
+ return segments;
+}
+
+export function postPermalink(uri: string): string | undefined {
+ const match = /^at:\/\/([^/]+)\/[^/]+\/([^/]+)$/.exec(uri);
+ return match ? `https://bsky.app/profile/${match[1]}/post/${match[2]}` : undefined;
+}
+
export async function fetchBlueskyProfile(
ctx: AtmosphereContext
): Promise {
@@ -57,6 +128,8 @@ export async function fetchBlueskyPosts(
posts.push({
uri: record.uri,
text,
+ segments: postSegments(text, record.value),
+ url: postPermalink(record.uri),
createdAt,
hasEmbed: record.value.embed !== undefined
});
diff --git a/apps/web/src/lib/server/atmosphere/sifa.ts b/apps/web/src/lib/server/atmosphere/sifa.ts
index b853c49..31902ad 100644
--- a/apps/web/src/lib/server/atmosphere/sifa.ts
+++ b/apps/web/src/lib/server/atmosphere/sifa.ts
@@ -54,8 +54,8 @@ export async function fetchSifaPositions(ctx: AtmosphereContext): Promise !d.url && d.publicationUri && d.path)) {
+ const publications = await fetchPublications(ctx);
+ const urls = new Map(publications.map((p) => [p.uri, p.url]));
+ docs = docs.map((d) => {
+ if (d.url || !d.publicationUri || !d.path) return d;
+ const base = urls.get(d.publicationUri);
+ return base ? { ...d, url: joinUrl(base, d.path) } : d;
+ });
+ }
+
if (options?.publicationUri) {
docs = docs.filter((d) => d.publicationUri === options.publicationUri);
}
docs.sort((a, b) => (b.publishedAt ?? '').localeCompare(a.publishedAt ?? ''));
return docs.slice(0, limit);
}
+
+function joinUrl(base: string, path: string): string {
+ return `${base.replace(/\/+$/, '')}/${path.replace(/^\/+/, '')}`;
+}
diff --git a/apps/web/src/lib/server/render/render.test.ts b/apps/web/src/lib/server/render/render.test.ts
index dcb956c..63b915a 100644
--- a/apps/web/src/lib/server/render/render.test.ts
+++ b/apps/web/src/lib/server/render/render.test.ts
@@ -134,11 +134,14 @@ describe('resolveSections', () => {
{ kind: 'hero', showAvatar: true },
{ kind: 'career' },
{ kind: 'writing' },
- { kind: 'posts' },
- { kind: 'pages' }
+ { kind: 'posts' }
]);
});
+ it('leaves pages out of the fallback, since the nav already lists them', () => {
+ expect(resolveSections(undefined, ['pages'])).toEqual([{ kind: 'hero', showAvatar: true }]);
+ });
+
it('falls back to a bare hero when no source has data', () => {
expect(resolveSections(undefined, [])).toEqual([{ kind: 'hero', showAvatar: true }]);
});
@@ -166,9 +169,9 @@ describe('resolveSections', () => {
it('falls back rather than rendering nothing when every entry is unknown', () => {
const sections = [{ $type: 'com.example.custom#weatherSection' }];
- expect(resolveSections(sections, ['pages'])).toEqual([
+ expect(resolveSections(sections, ['bluesky'])).toEqual([
{ kind: 'hero', showAvatar: true },
- { kind: 'pages' }
+ { kind: 'posts' }
]);
});
diff --git a/apps/web/src/lib/server/render/sections.ts b/apps/web/src/lib/server/render/sections.ts
index a9c6101..6171f2c 100644
--- a/apps/web/src/lib/server/render/sections.ts
+++ b/apps/web/src/lib/server/render/sections.ts
@@ -74,8 +74,10 @@ export function resolveSections(sections: unknown, sources: SourceId[]): Section
const specs = sections.map(toSpec).filter((s): s is SectionSpec => s !== undefined);
if (specs.length > 0) return specs;
}
+ // The masthead nav already lists pages; a default pages section would
+ // duplicate it. An explicit pagesSection in the site record still renders.
return [
{ kind: 'hero', showAvatar: true },
- ...sources.map((id) => ({ kind: SOURCE_SECTIONS[id] }))
+ ...sources.filter((id) => id !== 'pages').map((id) => ({ kind: SOURCE_SECTIONS[id] }))
];
}
diff --git a/apps/web/src/lib/server/render/site.ts b/apps/web/src/lib/server/render/site.ts
index 3e1fb16..c8cc8f3 100644
--- a/apps/web/src/lib/server/render/site.ts
+++ b/apps/web/src/lib/server/render/site.ts
@@ -132,8 +132,9 @@ export async function loadSiteView(
const name =
(typeof site?.name === 'string' && site.name) || profile?.displayName || `@${handle}`;
+ // The description feeds tags, so multi-line input collapses to one line.
const description =
- (typeof site?.description === 'string' && site.description) ||
+ (typeof site?.description === 'string' && site.description.replace(/\s+/g, ' ').trim()) ||
(profile?.description ? markdownExcerpt(profile.description) : undefined) ||
sifaProfile?.headline;
diff --git a/apps/web/src/lib/site-view.ts b/apps/web/src/lib/site-view.ts
index 695aec9..3a9e61e 100644
--- a/apps/web/src/lib/site-view.ts
+++ b/apps/web/src/lib/site-view.ts
@@ -31,9 +31,19 @@ export interface SifaProfileView {
summary?: string;
}
+/** A run of text, linked when `url` is present. */
+export interface TextSegment {
+ text: string;
+ url?: string;
+}
+
export interface PostView {
uri: string;
text: string;
+ /** The text split at its facets, so links render as links. */
+ segments: TextSegment[];
+ /** bsky.app permalink derived from the record URI. */
+ url?: string;
createdAt: string;
hasEmbed: boolean;
}
diff --git a/apps/web/src/lib/text-display.test.ts b/apps/web/src/lib/text-display.test.ts
new file mode 100644
index 0000000..c677914
--- /dev/null
+++ b/apps/web/src/lib/text-display.test.ts
@@ -0,0 +1,42 @@
+import { describe, expect, it } from 'vitest';
+import { linkify } from './linkify';
+import { formatDate } from './date-format';
+
+describe('linkify', () => {
+ it('links full URLs and bare domains, leaving other text alone', () => {
+ expect(linkify('see https://a.example/x and jzweifel.dev too')).toEqual([
+ { text: 'see ' },
+ { text: 'https://a.example/x', url: 'https://a.example/x' },
+ { text: ' and ' },
+ { text: 'jzweifel.dev', url: 'https://jzweifel.dev' },
+ { text: ' too' }
+ ]);
+ });
+
+ it('keeps trailing punctuation out of the link', () => {
+ expect(linkify('at jzweifel.dev.')).toEqual([
+ { text: 'at ' },
+ { text: 'jzweifel.dev', url: 'https://jzweifel.dev' },
+ { text: '.' }
+ ]);
+ });
+
+ it('does not treat email addresses as domains', () => {
+ expect(linkify('mail me: jacob@example.com')).toEqual([{ text: 'mail me: jacob@example.com' }]);
+ });
+
+ it('passes plain text through as one segment', () => {
+ expect(linkify('just words / no links')).toEqual([{ text: 'just words / no links' }]);
+ expect(linkify('')).toEqual([{ text: '' }]);
+ });
+});
+
+describe('formatDate', () => {
+ it('formats ISO timestamps for display in UTC', () => {
+ expect(formatDate('2026-08-23T22:15:00.000Z')).toBe('Aug 23, 2026');
+ });
+
+ it('passes unparsable values through', () => {
+ expect(formatDate('soon')).toBe('soon');
+ });
+});
diff --git a/apps/web/src/routes/+page.svelte b/apps/web/src/routes/+page.svelte
index 8f45c09..5611498 100644
--- a/apps/web/src/routes/+page.svelte
+++ b/apps/web/src/routes/+page.svelte
@@ -11,6 +11,12 @@
{data.site.name}
{#if data.site.description}
+
+ {/if}
+
+
+ {#if data.site.avatarUrl}
+
{/if}
{#if !data.site.discoverable}
diff --git a/apps/web/src/routes/[...path]/+page.server.ts b/apps/web/src/routes/[...path]/+page.server.ts
index 33df3e0..cf96529 100644
--- a/apps/web/src/routes/[...path]/+page.server.ts
+++ b/apps/web/src/routes/[...path]/+page.server.ts
@@ -15,5 +15,5 @@ export const load: PageServerLoad = async ({ locals, params }) => {
if (!page) error(404, 'No such page.');
const site = await loadSiteView(resolved.ctx, resolved.handle, resolved.sources);
- return { site, base: '', page };
+ return { site, base: '', page, path: `/${params.path.toLowerCase()}` };
};
diff --git a/apps/web/src/routes/[...path]/+page.svelte b/apps/web/src/routes/[...path]/+page.svelte
index aec17f9..f33d830 100644
--- a/apps/web/src/routes/[...path]/+page.svelte
+++ b/apps/web/src/routes/[...path]/+page.svelte
@@ -10,12 +10,15 @@
{data.page.title} — {data.site.name}
{#if data.page.description}
+
{/if}
+
+
{#if !data.site.discoverable || !data.page.listed}
{/if}
-
+
diff --git a/apps/web/src/routes/s/[handle]/+page.svelte b/apps/web/src/routes/s/[handle]/+page.svelte
index af54265..07d4cec 100644
--- a/apps/web/src/routes/s/[handle]/+page.svelte
+++ b/apps/web/src/routes/s/[handle]/+page.svelte
@@ -10,6 +10,12 @@
{data.site.name}
{#if data.site.description}
+
+ {/if}
+
+
+ {#if data.site.avatarUrl}
+
{/if}
{#if !data.site.discoverable}
diff --git a/apps/web/src/routes/s/[handle]/[...path]/+page.server.ts b/apps/web/src/routes/s/[handle]/[...path]/+page.server.ts
index 17c4267..5180924 100644
--- a/apps/web/src/routes/s/[handle]/[...path]/+page.server.ts
+++ b/apps/web/src/routes/s/[handle]/[...path]/+page.server.ts
@@ -12,5 +12,5 @@ export const load: PageServerLoad = async ({ params }) => {
if (!page) error(404, 'No such page.');
const site = await loadSiteView(resolved.ctx, resolved.handle, resolved.sources);
- return { site, base: `/s/${params.handle}`, page };
+ return { site, base: `/s/${params.handle}`, page, path: `/${params.path.toLowerCase()}` };
};
diff --git a/apps/web/src/routes/s/[handle]/[...path]/+page.svelte b/apps/web/src/routes/s/[handle]/[...path]/+page.svelte
index aec17f9..f33d830 100644
--- a/apps/web/src/routes/s/[handle]/[...path]/+page.svelte
+++ b/apps/web/src/routes/s/[handle]/[...path]/+page.svelte
@@ -10,12 +10,15 @@
{data.page.title} — {data.site.name}
{#if data.page.description}
+
{/if}
+
+
{#if !data.site.discoverable || !data.page.listed}
{/if}
-
+