Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 32 additions & 1 deletion src/hooks.server.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,41 @@
import type { Handle } from '@sveltejs/kit';
import { HOMEPAGE_MARKDOWN } from '$lib/server/agent-readiness';
import { selectHomepageRepresentation } from '$lib/server/content-negotiation';
import { readOwnerSession } from '$lib/server/owner-session';

export const handle: Handle = async ({ event, resolve }) => {
const owner = readOwnerSession(event.cookies);
event.locals.owner = owner;
event.locals.isOwner = Boolean(owner);

return resolve(event);
if (
event.url.pathname !== '/' ||
(event.request.method !== 'GET' && event.request.method !== 'HEAD')
) {
return resolve(event);
}

const representation = selectHomepageRepresentation(event.request.headers.get('accept'));
if (!representation) {
return new Response('Not Acceptable\n', {
status: 406,
headers: {
'Content-Type': 'text/plain; charset=utf-8',
Vary: 'Accept'
}
});
}

if (representation === 'text/markdown') {
return new Response(event.request.method === 'HEAD' ? null : HOMEPAGE_MARKDOWN, {
headers: {
'Content-Type': 'text/markdown; charset=utf-8',
Vary: 'Accept'
}
});
}

const response = await resolve(event);
response.headers.append('Vary', 'Accept');
return response;
};
41 changes: 41 additions & 0 deletions src/lib/server/agent-readiness.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { XMLParser } from 'fast-xml-parser';
import { describe, expect, it } from 'vitest';
import { HOMEPAGE_MARKDOWN, LLMS_TEXT } from './agent-readiness';
import { serializeSitemap } from './sitemap';

describe('agent-facing resources', () => {
it('keeps homepage Markdown specific and honest', () => {
expect(HOMEPAGE_MARKDOWN).toContain('# Tempo Immaterial');
expect(HOMEPAGE_MARKDOWN).toContain('## When to use this site');
expect(HOMEPAGE_MARKDOWN).toContain('https://www.alicealexandra.com/about');
expect(HOMEPAGE_MARKDOWN).toContain('not an agent API or transactional service');
});

it('gives agents use guidance and canonical links', () => {
expect(LLMS_TEXT).toContain('## When to use this site');
expect(LLMS_TEXT).toContain('https://www.alicealexandra.com/sitemap.xml');
expect(LLMS_TEXT).toContain('Do not treat this site as an API');
});

it('serializes unique canonical sitemap entries with evidence-based dates', () => {
const xml = serializeSitemap([
{ path: '/about' },
{ path: '/blog/example', lastmod: '2026-08-20' },
{ path: '/about', lastmod: 'not-a-date' },
{ path: '/blog/impossible-date', lastmod: '2026-02-31' },
{ path: 'not-absolute' }
]);
const parsed = new XMLParser().parse(xml);
const urls = parsed.urlset.url as Array<{ loc: string; lastmod?: string }>;

expect(urls).toHaveLength(3);
expect(urls).toContainEqual({ loc: 'https://www.alicealexandra.com/about' });
expect(urls).toContainEqual({
loc: 'https://www.alicealexandra.com/blog/impossible-date'
});
expect(urls).toContainEqual({
loc: 'https://www.alicealexandra.com/blog/example',
lastmod: '2026-08-20T00:00:00.000Z'
});
});
});
44 changes: 44 additions & 0 deletions src/lib/server/agent-readiness.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
export const CANONICAL_ORIGIN = 'https://www.alicealexandra.com';

export const HOMEPAGE_MARKDOWN = `# Tempo Immaterial

Tempo Immaterial is the creative studio and personal portfolio of Alice Alexandra Moore, a queer multidisciplinary artist, writer, designer, and developer.

## When to use this site

Use Tempo Immaterial when you want to learn about Alice, explore her creative work, read her essays and poetry, review her career writing, or discover recent additions to the studio.

## Explore

- [About Alice](${CANONICAL_ORIGIN}/about)
- [Studio](${CANONICAL_ORIGIN}/studio)
- [Career](${CANONICAL_ORIGIN}/career)
- [Blog](${CANONICAL_ORIGIN}/blog)
- [What's new](${CANONICAL_ORIGIN}/news)

This is a public creative portfolio, not an agent API or transactional service.
`;

export const LLMS_TEXT = `# Tempo Immaterial

> The creative studio and personal portfolio of Alice Alexandra Moore.

## When to use this site

Use this site to learn about Alice, explore her multidisciplinary creative work, read essays and poetry, review career writing, or discover recent additions to her studio. Cite the canonical page that supports your answer.

Do not treat this site as an API, a booking service, or authorization to act on Alice's behalf.

## Primary pages

- [Home](${CANONICAL_ORIGIN}/): Identity and top-level navigation
- [About](${CANONICAL_ORIGIN}/about): Biography, values, and creative practice
- [Studio](${CANONICAL_ORIGIN}/studio): Art, poetry, illustrations, postcards, and experiments
- [Career](${CANONICAL_ORIGIN}/career): Professional work and publications
- [Blog](${CANONICAL_ORIGIN}/blog): Essays and long-form writing
- [News](${CANONICAL_ORIGIN}/news): Recently published and updated work

## Machine-readable resources

- [XML sitemap](${CANONICAL_ORIGIN}/sitemap.xml)
`;
23 changes: 23 additions & 0 deletions src/lib/server/content-negotiation.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { describe, expect, it } from 'vitest';
import { selectHomepageRepresentation } from './content-negotiation';

describe('selectHomepageRepresentation', () => {
it.each([
[null, 'text/html'],
['', 'text/html'],
['*/*', 'text/html'],
['text/html', 'text/html'],
['text/markdown', 'text/markdown'],
['text/markdown, text/html;q=0.8', 'text/markdown'],
['text/html;q=0.8, text/markdown;q=0.5', 'text/html'],
['text/markdown, text/html', 'text/markdown'],
['text/*;q=0.5, text/markdown;q=0.5', 'text/markdown'],
['text/markdown;q=0, */*', 'text/html'],
['application/json, */*;q=0.1', 'text/html'],
['application/json', null],
['text/html;q=0, text/markdown;q=0', null],
['not-a-media-range', null]
])('selects %s as %s', (header, expected) => {
expect(selectHomepageRepresentation(header)).toBe(expected);
});
});
94 changes: 94 additions & 0 deletions src/lib/server/content-negotiation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
export type HomepageRepresentation = 'text/html' | 'text/markdown';

interface AcceptRange {
type: string;
subtype: string;
quality: number;
index: number;
}

interface RepresentationScore {
representation: HomepageRepresentation;
quality: number;
specificity: number;
index: number;
}

const DEFAULT_REPRESENTATION: HomepageRepresentation = 'text/html';
const SUPPORTED_REPRESENTATIONS: HomepageRepresentation[] = ['text/html', 'text/markdown'];

function parseQuality(parameters: string[]): number {
const qualityParameter = parameters.find((parameter) => parameter.toLowerCase().startsWith('q='));
if (!qualityParameter) return 1;

const quality = Number(qualityParameter.slice(2).trim());
return Number.isFinite(quality) && quality >= 0 && quality <= 1 ? quality : 0;
}

function parseAccept(header: string): AcceptRange[] {
return header
.split(',')
.map((rawRange, index) => {
const [mediaRange = '', ...parameters] = rawRange.split(';').map((part) => part.trim());
const [type = '', subtype = ''] = mediaRange.toLowerCase().split('/');
if (!type || !subtype || (type === '*' && subtype !== '*')) return null;

return {
type,
subtype,
quality: parseQuality(parameters),
index
};
})
.filter((range): range is AcceptRange => range !== null);
}

function scoreRepresentation(
representation: HomepageRepresentation,
ranges: AcceptRange[]
): RepresentationScore | null {
const [type, subtype] = representation.split('/');
const matches = ranges
.map((range) => {
if (range.type === type && range.subtype === subtype) return { range, specificity: 2 };
if (range.type === type && range.subtype === '*') return { range, specificity: 1 };
if (range.type === '*' && range.subtype === '*') return { range, specificity: 0 };
return null;
})
.filter((match): match is { range: AcceptRange; specificity: number } => match !== null)
.sort((a, b) => b.specificity - a.specificity || a.range.index - b.range.index);

const match = matches[0];
if (!match || match.range.quality === 0) return null;

return {
representation,
quality: match.range.quality,
specificity: match.specificity,
index: match.range.index
};
}

export function selectHomepageRepresentation(
acceptHeader: string | null
): HomepageRepresentation | null {
if (!acceptHeader?.trim()) return DEFAULT_REPRESENTATION;

const ranges = parseAccept(acceptHeader);
if (ranges.length === 0) return null;

const scores = SUPPORTED_REPRESENTATIONS.map((representation) =>
scoreRepresentation(representation, ranges)
)
.filter((score): score is RepresentationScore => score !== null)
.sort(
(a, b) =>
b.quality - a.quality ||
b.specificity - a.specificity ||
a.index - b.index ||
SUPPORTED_REPRESENTATIONS.indexOf(a.representation) -
SUPPORTED_REPRESENTATIONS.indexOf(b.representation)
);

return scores[0]?.representation ?? null;
}
83 changes: 83 additions & 0 deletions src/lib/server/sitemap.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { CANONICAL_ORIGIN } from '$lib/server/agent-readiness';

export interface SitemapEntry {
path: string;
lastmod?: string;
}

export const STATIC_SITEMAP_PATHS = [
'/',
'/about',
'/career',
'/career/builder',
'/career/vercel',
'/blog',
'/news',
'/studio',
'/studio/illustrations',
'/studio/tall-tales',
'/studio/hfc',
'/studio/postcards'
] as const;

function escapeXml(value: string): string {
return value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
}

function normalizeLastmod(value: string | undefined): string | undefined {
if (!value?.trim()) return undefined;
const match = value.match(
/^(\d{4})-(\d{2})-(\d{2})(?:T(\d{2}):(\d{2}):(\d{2})(?:\.(\d{1,9}))?(Z|[+-]\d{2}:\d{2}))?$/
);
if (!match) return undefined;

const [, yearText, monthText, dayText, hourText, minuteText, secondText, , timezone] = match;
const year = Number(yearText);
const month = Number(monthText);
const day = Number(dayText);
const calendarDate = new Date(Date.UTC(year, month - 1, day));
if (
calendarDate.getUTCFullYear() !== year ||
calendarDate.getUTCMonth() !== month - 1 ||
calendarDate.getUTCDate() !== day
) {
return undefined;
}

if (hourText !== undefined) {
if (Number(hourText) > 23 || Number(minuteText) > 59 || Number(secondText) > 59) {
return undefined;
}
if (timezone && timezone !== 'Z') {
const [offsetHour, offsetMinute] = timezone.slice(1).split(':').map(Number);
if ((offsetHour ?? 0) > 14 || (offsetMinute ?? 0) > 59) return undefined;
}
}

const date = new Date(value);
return Number.isNaN(date.getTime()) ? undefined : date.toISOString();
}

export function serializeSitemap(entries: SitemapEntry[]): string {
const uniqueEntries = new Map<string, SitemapEntry>();
for (const entry of entries) {
if (!entry.path.startsWith('/')) continue;
const lastmod = normalizeLastmod(entry.lastmod);
uniqueEntries.set(entry.path, lastmod ? { ...entry, lastmod } : { path: entry.path });
}

const urls = [...uniqueEntries.values()]
.sort((a, b) => a.path.localeCompare(b.path))
.map((entry) => {
const lastmod = entry.lastmod ? `\n <lastmod>${escapeXml(entry.lastmod)}</lastmod>` : '';
return ` <url>\n <loc>${escapeXml(`${CANONICAL_ORIGIN}${entry.path}`)}</loc>${lastmod}\n </url>`;
})
.join('\n');

return `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${urls}\n</urlset>\n`;
}
18 changes: 16 additions & 2 deletions src/routes/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
import { onMount } from 'svelte';
import { IMAGE_DOMAIN } from '$lib/utils/images';

const canonicalUrl = 'https://www.alicealexandra.com/';

onMount(() => {
pageState.set('home');
});
Expand All @@ -17,9 +19,21 @@
name="description"
content="The studio of Alice Alexandra Moore. Creative work, ramblings, career and more."
/>
<link rel="canonical" href={canonicalUrl} />
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "Person",
"name": "Alice Alexandra Moore",
"alternateName": "Tempo Immaterial",
"url": "https://www.alicealexandra.com/",
"description": "A queer multidisciplinary artist, writer, designer, and developer.",
"sameAs": ["https://hachyderm.io/@tempoimmaterial"]
}
</script>

<!-- Facebook Meta Tags -->
<meta property="og:url" content="https://www.alicealexandra.com" />
<meta property="og:url" content={canonicalUrl} />
<meta property="og:type" content="website" />
<meta property="og:title" content="Tempo Immaterial" />
<meta
Expand All @@ -33,7 +47,7 @@
<meta name="twitter:site" content="@tempoimmaterial" />
<meta name="twitter:creator" content="@tempoimmaterial" />
<meta name="twitter:domain" content="alicealexandra.com" />
<meta name="twitter:url" content="https://www.alicealexandra.com" />
<meta name="twitter:url" content={canonicalUrl} />
<meta name="twitter:title" content="Tempo Immaterial" />
<meta
name="twitter:description"
Expand Down
11 changes: 11 additions & 0 deletions src/routes/llms.txt/+server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { LLMS_TEXT } from '$lib/server/agent-readiness';
import type { RequestHandler } from './$types';

export const prerender = true;

export const GET: RequestHandler = () =>
new Response(LLMS_TEXT, {
headers: {
'Content-Type': 'text/plain; charset=utf-8'
}
});
Loading
Loading