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} + +
+
@@ -36,18 +51,21 @@ {#if nav.length > 0} {/if}
-
+
{@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} - +