From 006265198668e4139863feb28349ac9b0477e3a7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 17:51:32 +0000 Subject: [PATCH 01/17] fix(seo): honour Sanity SEO overrides and noindex hidden posts The post query never projected the studio's seo object, so metaTitle, metaDescription, ogImage and "No Index" had no effect. Posts now prefer the overrides, and hidden or "No Index" posts emit noindex,follow. "No Index" posts also drop out of the sitemap, RSS and llms.txt. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MNw4RCS11ZZRVjxynk9baS --- src/lib/sanity/queries.ts | 23 ++++++++++++++++++++--- src/lib/utils/types.ts | 8 ++++++++ src/routes/[slug]/+page.ts | 29 ++++++++++++++++++----------- src/routes/sitemap.xml/+server.ts | 4 ++-- tests/e2e/geo.test.ts | 20 ++++++++++++++++++++ 5 files changed, 68 insertions(+), 16 deletions(-) diff --git a/src/lib/sanity/queries.ts b/src/lib/sanity/queries.ts index fc596e7..c2af480 100644 --- a/src/lib/sanity/queries.ts +++ b/src/lib/sanity/queries.ts @@ -29,6 +29,12 @@ export const postBySlugQuery = defineQuery(` hidden, keywords, outroText, + seo { + metaTitle, + metaDescription, + noIndex, + ogImage + }, author-> { "id": slug.current, name, @@ -84,12 +90,23 @@ export const allPostsQuery = defineQuery(` `); /** - * GROQ query for fetching all posts for RSS feed. - * Includes full body for HTML rendering. + * GROQ query for the sitemap: listed posts that editors haven't marked "No Index". + */ +export const sitemapPostsQuery = defineQuery(` + *[_type == "post" && !hidden && seo.noIndex != true] | order(date desc) { + "slug": slug.current, + lastModified, + date + } +`); + +/** + * GROQ query for fetching all posts for RSS feed (and llms.txt). + * Includes full body for HTML rendering. Leaves out hidden and "No Index" posts. * Sorted by date descending. */ export const rssPostsQuery = defineQuery(` - *[_type == "post" && !hidden] | order(date desc) { + *[_type == "post" && !hidden && seo.noIndex != true] | order(date desc) { _id, title, "slug": slug.current, diff --git a/src/lib/utils/types.ts b/src/lib/utils/types.ts index d8fd23a..b8bc683 100644 --- a/src/lib/utils/types.ts +++ b/src/lib/utils/types.ts @@ -1,5 +1,6 @@ import type { Component } from 'svelte'; import type { PortableTextBlock } from '@portabletext/types'; +import type { SanityImageSource } from '@sanity/image-url'; export type SparkleType = { id: string; @@ -77,6 +78,13 @@ export type SanityPost = { hidden?: boolean; keywords?: string[]; outroText?: string; + /** Editor overrides from the studio's SEO group. */ + seo?: { + metaTitle?: string; + metaDescription?: string; + noIndex?: boolean; + ogImage?: SanityImageSource; + }; author?: { id?: string; name: string; diff --git a/src/routes/[slug]/+page.ts b/src/routes/[slug]/+page.ts index 7d3884f..3fe369b 100644 --- a/src/routes/[slug]/+page.ts +++ b/src/routes/[slug]/+page.ts @@ -6,6 +6,7 @@ import type { BlogPosting, BreadcrumbList, WithContext } from 'schema-dts'; import { version } from '$app/env'; import { canonicalUrl } from '#lib/i18n/index.js'; import { createBreadcrumbSchema } from '#lib/data/meta.js'; +import { urlFor } from '#lib/sanity/image.js'; type PageData = PostData & { pageMetaTags: MetaTagsProps; @@ -15,10 +16,15 @@ type PageData = PostData & { export const load: PageLoad = async ({ data, url }): Promise => { const serverData = data as PostData; const post = serverData.post as SanityPost; - const ogImageUrl = new URL( - `${url.pathname}/og.jpg?v=${version}`, - url.origin - ).href; + // Editors can override title, description and social image in the studio's SEO group. + const seo = post.seo ?? {}; + const metaTitle = seo.metaTitle || post.title; + const metaDescription = seo.metaDescription || post.excerpt || ''; + const ogImageUrl = seo.ogImage + ? urlFor(seo.ogImage).width(1200).height(630).format('jpg').url() + : new URL(`${url.pathname}/og.jpg?v=${version}`, url.origin).href; + // Hidden posts are unlisted, not private: reachable by link, but kept out of search. + const noIndex = (post.hidden ?? false) || (seo.noIndex ?? false); // Build schema with Sanity post data const pageSchema = post.author @@ -32,7 +38,7 @@ export const load: PageLoad = async ({ data, url }): Promise => { // for machines, fall back to the document's last edit. updated: post.lastModified ?? post._updatedAt ?? post.date, - excerpt: post.excerpt ?? '', + excerpt: metaDescription, tags: post.tags?.map((t) => t.name) ?? [], keywords: post.keywords ?? [], hidden: post.hidden ?? false, @@ -53,12 +59,13 @@ export const load: PageLoad = async ({ data, url }): Promise => { : []; const pageMetaTags = Object.freeze({ - title: post.title, - description: post.excerpt ?? '', + title: metaTitle, + description: metaDescription, canonical: canonicalUrl(url.pathname), + ...(noIndex && { robots: 'noindex,follow' }), openGraph: { - title: post.title, - description: post.excerpt ?? '', + title: metaTitle, + description: metaDescription, url: canonicalUrl(url.pathname), type: 'article', images: [ @@ -73,8 +80,8 @@ export const load: PageLoad = async ({ data, url }): Promise => { ], }, twitter: { - title: post.title, - description: post.excerpt ?? '', + title: metaTitle, + description: metaDescription, cardType: 'summary_large_image', image: ogImageUrl, imageAlt: post.title, diff --git a/src/routes/sitemap.xml/+server.ts b/src/routes/sitemap.xml/+server.ts index e6d8ce8..12e3ef6 100644 --- a/src/routes/sitemap.xml/+server.ts +++ b/src/routes/sitemap.xml/+server.ts @@ -1,6 +1,6 @@ import * as sitemap from 'super-sitemap/sveltekit'; import { client } from '#lib/sanity/client.js'; -import { allPostsQuery } from '#lib/sanity/queries.js'; +import { sitemapPostsQuery } from '#lib/sanity/queries.js'; import { canonicalUrl } from '#lib/i18n/index.js'; import { getAuthorSlugs } from '#lib/sanity/author.js'; @@ -11,7 +11,7 @@ const translatedPaths = new Set(['/']); export async function GET({ url }) { // Get all blog posts from Sanity with lastmod data for parameterized routes - const sanityPosts = await client.fetch(allPostsQuery); + const sanityPosts = await client.fetch(sitemapPostsQuery); const blogPostParams = sanityPosts.map( (post: { slug: string; lastModified?: string; date: string }) => ({ diff --git a/tests/e2e/geo.test.ts b/tests/e2e/geo.test.ts index 4a97777..314ef34 100644 --- a/tests/e2e/geo.test.ts +++ b/tests/e2e/geo.test.ts @@ -221,3 +221,23 @@ test('the author card and footer use the one contact address', async ({ ).toHaveAttribute('href', 'mailto:jloos@maxdaten.com'); } }); + +test('hidden posts stay reachable but out of search', async ({ + page, + request, +}) => { + await page.goto('/2025-07-31-continuous-care-no-to-maintenance-processes'); + await expect(page.locator('meta[name="robots"]')).toHaveAttribute( + 'content', + /^noindex,follow/ + ); + + await page.goto('/2026-01-31-ship-your-toolchain-not-just-infrastructure'); + await expect(page.locator('meta[name="robots"]')).toHaveAttribute( + 'content', + /^index,follow/ + ); + + const sitemap = await (await request.get('/sitemap.xml')).text(); + expect(sitemap).not.toContain('continuous-care-no-to-maintenance'); +}); From 8ba379e559f56b8b2d23206bc176166c0e0a6b09 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 17:52:32 +0000 Subject: [PATCH 02/17] fix(seo): date sitemap entries by a post's last edit The sitemap used lastModified || date and never saw _updatedAt, while the JSON-LD fell back to it, so lastmod stayed at the publish date. Both now share postModifiedAt(). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MNw4RCS11ZZRVjxynk9baS --- src/lib/sanity/post-dates.test.ts | 24 ++++++++++++++++++++++++ src/lib/sanity/post-dates.ts | 13 +++++++++++++ src/lib/sanity/queries.ts | 1 + src/routes/[slug]/+page.ts | 6 ++---- src/routes/sitemap.xml/+server.ts | 5 +++-- tests/e2e/sitemap.test.ts | 11 +++++++++++ 6 files changed, 54 insertions(+), 6 deletions(-) create mode 100644 src/lib/sanity/post-dates.test.ts create mode 100644 src/lib/sanity/post-dates.ts diff --git a/src/lib/sanity/post-dates.test.ts b/src/lib/sanity/post-dates.test.ts new file mode 100644 index 0000000..2e07885 --- /dev/null +++ b/src/lib/sanity/post-dates.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from 'vitest'; +import { postModifiedAt } from './post-dates.js'; + +describe('postModifiedAt', () => { + const date = '2026-01-31T12:00:00.000Z'; + const _updatedAt = '2026-02-04T18:44:11Z'; + const lastModified = '2026-03-01T00:00:00.000Z'; + + it('prefers the editor-set lastModified', () => { + expect(postModifiedAt({ date, _updatedAt, lastModified })).toBe( + lastModified + ); + }); + + it('falls back to the last document edit', () => { + expect(postModifiedAt({ date, _updatedAt, lastModified: null })).toBe( + _updatedAt + ); + }); + + it('falls back to the publish date', () => { + expect(postModifiedAt({ date })).toBe(date); + }); +}); diff --git a/src/lib/sanity/post-dates.ts b/src/lib/sanity/post-dates.ts new file mode 100644 index 0000000..5582f2a --- /dev/null +++ b/src/lib/sanity/post-dates.ts @@ -0,0 +1,13 @@ +type PostDates = { + date: string; + lastModified?: string | null; + _updatedAt?: string | null; +}; + +/** + * When a post last changed, for machines (sitemap lastmod, JSON-LD dateModified). Editors rarely + * set lastModified (shown on the page as "Updated"), so fall back to the document's last edit. + */ +export function postModifiedAt(post: PostDates): string { + return post.lastModified ?? post._updatedAt ?? post.date; +} diff --git a/src/lib/sanity/queries.ts b/src/lib/sanity/queries.ts index c2af480..e27a41f 100644 --- a/src/lib/sanity/queries.ts +++ b/src/lib/sanity/queries.ts @@ -96,6 +96,7 @@ export const sitemapPostsQuery = defineQuery(` *[_type == "post" && !hidden && seo.noIndex != true] | order(date desc) { "slug": slug.current, lastModified, + _updatedAt, date } `); diff --git a/src/routes/[slug]/+page.ts b/src/routes/[slug]/+page.ts index 3fe369b..5ba1f16 100644 --- a/src/routes/[slug]/+page.ts +++ b/src/routes/[slug]/+page.ts @@ -7,6 +7,7 @@ import { version } from '$app/env'; import { canonicalUrl } from '#lib/i18n/index.js'; import { createBreadcrumbSchema } from '#lib/data/meta.js'; import { urlFor } from '#lib/sanity/image.js'; +import { postModifiedAt } from '#lib/sanity/post-dates.js'; type PageData = PostData & { pageMetaTags: MetaTagsProps; @@ -34,10 +35,7 @@ export const load: PageLoad = async ({ data, url }): Promise => { title: post.title, slug: post.slug, date: post.date, - // Editors rarely set lastModified (shown on the page as "Updated"); - // for machines, fall back to the document's last edit. - updated: - post.lastModified ?? post._updatedAt ?? post.date, + updated: postModifiedAt(post), excerpt: metaDescription, tags: post.tags?.map((t) => t.name) ?? [], keywords: post.keywords ?? [], diff --git a/src/routes/sitemap.xml/+server.ts b/src/routes/sitemap.xml/+server.ts index 12e3ef6..e52d0fa 100644 --- a/src/routes/sitemap.xml/+server.ts +++ b/src/routes/sitemap.xml/+server.ts @@ -3,6 +3,7 @@ import { client } from '#lib/sanity/client.js'; import { sitemapPostsQuery } from '#lib/sanity/queries.js'; import { canonicalUrl } from '#lib/i18n/index.js'; import { getAuthorSlugs } from '#lib/sanity/author.js'; +import { postModifiedAt } from '#lib/sanity/post-dates.js'; export const prerender = true; @@ -14,9 +15,9 @@ export async function GET({ url }) { const sanityPosts = await client.fetch(sitemapPostsQuery); const blogPostParams = sanityPosts.map( - (post: { slug: string; lastModified?: string; date: string }) => ({ + (post: Parameters[0] & { slug: string }) => ({ values: [post.slug], - lastmod: post.lastModified || post.date, + lastmod: postModifiedAt(post), }) ); diff --git a/tests/e2e/sitemap.test.ts b/tests/e2e/sitemap.test.ts index 6160adf..6aa6b5d 100644 --- a/tests/e2e/sitemap.test.ts +++ b/tests/e2e/sitemap.test.ts @@ -45,3 +45,14 @@ test('/sitemap.xml lists each page on its final host', async ({ request }) => { expect(url.origin, url.href).toBe(expectedOrigin); } }); + +test('/sitemap.xml dates a post by its last edit', async ({ request }) => { + const xml = await (await request.get('/sitemap.xml')).text(); + const entry = xml.match( + /\s*[^<]*ship-your-toolchain-not-just-infrastructure<\/loc>[\s\S]*?<\/url>/ + )?.[0]; + + // Published 2026-01-31, last edited later; lastmod must not be the publish date. + expect(entry).toMatch(//); + expect(entry).not.toMatch(/2026-01-31/); +}); From 74b0470268757e44e1b0ef5910e170817d98dbd5 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 17:53:42 +0000 Subject: [PATCH 03/17] fix(rss): render inline post images The renderer read value.asset.url, but a portableImage holds its asset under value.image as an unresolved reference, so every inline image was dropped from the feed. Build the URL with urlFor(value.image) and keep the caption. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MNw4RCS11ZZRVjxynk9baS --- src/routes/rss.xml/+server.ts | 17 +++++++++++++---- tests/e2e/rss.test.ts | 9 +++++++++ 2 files changed, 22 insertions(+), 4 deletions(-) create mode 100644 tests/e2e/rss.test.ts diff --git a/src/routes/rss.xml/+server.ts b/src/routes/rss.xml/+server.ts index 34964fe..1c5f7c3 100644 --- a/src/routes/rss.xml/+server.ts +++ b/src/routes/rss.xml/+server.ts @@ -7,6 +7,8 @@ import type { PortableTextMarkDefinition, } from '@portabletext/types'; import { encode } from 'html-entities'; +import type { SanityImageSource } from '@sanity/image-url'; +import { urlFor } from '#lib/sanity/image.js'; export const prerender = true; @@ -40,12 +42,19 @@ const rssComponents = { portableImage: ({ value, }: { - value: { asset?: { url?: string }; alt?: string; caption?: string }; + value: { + image?: SanityImageSource; + alt?: string; + caption?: string; + }; }) => { - const url = value.asset?.url; - if (!url) return ''; + if (!value.image) return ''; + const url = encode(urlFor(value.image).width(1280).url()); const alt = encode(value.alt || ''); - return `
${alt}
`; + const caption = value.caption + ? `
${encode(value.caption)}
` + : ''; + return `
${alt}${caption}
`; }, callout: ({ value, diff --git a/tests/e2e/rss.test.ts b/tests/e2e/rss.test.ts new file mode 100644 index 0000000..e11c227 --- /dev/null +++ b/tests/e2e/rss.test.ts @@ -0,0 +1,9 @@ +import { expect, test } from '@playwright/test'; + +test('/rss.xml carries inline post images', async ({ request }) => { + const xml = await (await request.get('/rss.xml')).text(); + + expect(xml).toMatch( + /
[^/ + ); +}); From 446d46229c17890ac8083530631c28a75f76642d Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 17:55:09 +0000 Subject: [PATCH 04/17] fix(seo): resolve internal links to gems everywhere RSS and llms.txt rendered every internal link as /, so links to gems pointed at pages that don't exist, and on the site /gems# had no matching anchor. One internalLinkPath() now serves the page, RSS and llms.txt, and each gem card carries its slug as id. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MNw4RCS11ZZRVjxynk9baS --- src/lib/components/molecules/GemCard.svelte | 5 ++++- src/lib/sanity/internal-link.test.ts | 21 +++++++++++++++++++ src/lib/sanity/internal-link.ts | 17 +++++++++++++++ .../sanity/portable-text/InternalLink.svelte | 18 +++++++--------- src/lib/server/llms.ts | 7 +++---- src/routes/gems/+page.svelte | 1 + src/routes/rss.xml/+server.ts | 13 +++++++++--- 7 files changed, 63 insertions(+), 19 deletions(-) create mode 100644 src/lib/sanity/internal-link.test.ts create mode 100644 src/lib/sanity/internal-link.ts diff --git a/src/lib/components/molecules/GemCard.svelte b/src/lib/components/molecules/GemCard.svelte index 1b8248d..c5a4ebe 100644 --- a/src/lib/components/molecules/GemCard.svelte +++ b/src/lib/components/molecules/GemCard.svelte @@ -12,6 +12,8 @@ } interface Props { + /** Anchor id, so internal links to a gem (/gems#slug) land on its card. */ + id?: string; title: string; coverImage: string | SanityCoverImage; excerpt: string; @@ -23,6 +25,7 @@ } let { + id, title, coverImage, excerpt, @@ -41,7 +44,7 @@ ); - + {#snippet image()} {#if cover}
diff --git a/src/lib/sanity/internal-link.test.ts b/src/lib/sanity/internal-link.test.ts new file mode 100644 index 0000000..6d2b31d --- /dev/null +++ b/src/lib/sanity/internal-link.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'vitest'; +import { internalLinkPath } from './internal-link.js'; + +describe('internalLinkPath', () => { + it('links a post to its page', () => { + expect( + internalLinkPath({ _type: 'post', slug: { current: 'a-post' } }) + ).toBe('/a-post'); + }); + + it('links a gem to its card on /gems', () => { + expect( + internalLinkPath({ _type: 'gem', slug: { current: 'a-gem' } }) + ).toBe('/gems#a-gem'); + }); + + it('has no path for a missing reference', () => { + expect(internalLinkPath(undefined)).toBeUndefined(); + expect(internalLinkPath({ _type: 'post' })).toBeUndefined(); + }); +}); diff --git a/src/lib/sanity/internal-link.ts b/src/lib/sanity/internal-link.ts new file mode 100644 index 0000000..146e50f --- /dev/null +++ b/src/lib/sanity/internal-link.ts @@ -0,0 +1,17 @@ +/** The target of an internalLink mark, as the post query dereferences it. */ +export type InternalLinkReference = { + _type: string; + slug?: { current?: string }; +}; + +/** + * Site path of an internal link: a post's own page, or a gem's card on /gems (gems have no page + * of their own). Undefined when the reference is missing or unpublished. + */ +export function internalLinkPath( + reference: InternalLinkReference | undefined +): string | undefined { + const slug = reference?.slug?.current; + if (!slug) return undefined; + return reference._type === 'gem' ? `/gems#${slug}` : `/${slug}`; +} diff --git a/src/lib/sanity/portable-text/InternalLink.svelte b/src/lib/sanity/portable-text/InternalLink.svelte index 3544e9c..7a2b4f0 100644 --- a/src/lib/sanity/portable-text/InternalLink.svelte +++ b/src/lib/sanity/portable-text/InternalLink.svelte @@ -1,12 +1,13 @@ -{#if value.reference} +{#if href} {@render children()} diff --git a/src/lib/server/llms.ts b/src/lib/server/llms.ts index ede5840..d4f2d29 100644 --- a/src/lib/server/llms.ts +++ b/src/lib/server/llms.ts @@ -10,6 +10,7 @@ import { import type { ArbitraryTypedObject } from '@portabletext/types'; import type { Author } from '#lib/utils/types.js'; import { canonicalUrl, localeDomains, t } from '#lib/i18n/index.js'; +import { internalLinkPath } from '#lib/sanity/internal-link.js'; export type LlmsPost = { title: string; @@ -36,10 +37,8 @@ const renderers: Partial = { }, marks: { internalLink: ({ children, value }) => { - const slug = value?.reference?.slug?.current; - return slug - ? `[${children}](${canonicalUrl(`/${slug}`)})` - : children; + const path = internalLinkPath(value?.reference); + return path ? `[${children}](${canonicalUrl(path)})` : children; }, }, }; diff --git a/src/routes/gems/+page.svelte b/src/routes/gems/+page.svelte index 96eef91..af8f0ad 100644 --- a/src/routes/gems/+page.svelte +++ b/src/routes/gems/+page.svelte @@ -44,6 +44,7 @@
{#each gems as gem, index (index)} { - const slug = value?.reference?.slug?.current || ''; - return `${children}`; + const path = internalLinkPath(value?.reference); + return path + ? `${children}` + : children; }, link: ({ children, From 3ffaf1e527c2eff78dac74c524c9e8ef9b9ea5bd Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 17:56:31 +0000 Subject: [PATCH 05/17] fix(seo): give /gems its own metadata and an ItemList /gems returned no pageMetaTags, so it shipped the English home page's title, description and Open Graph tags. It now has its own, plus ItemList and BreadcrumbList JSON-LD. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MNw4RCS11ZZRVjxynk9baS --- src/routes/gems/+page.server.ts | 49 ++++++++++++++++++++++++++++++++- tests/e2e/geo.test.ts | 16 +++++++++++ 2 files changed, 64 insertions(+), 1 deletion(-) diff --git a/src/routes/gems/+page.server.ts b/src/routes/gems/+page.server.ts index f286116..dddc9dd 100644 --- a/src/routes/gems/+page.server.ts +++ b/src/routes/gems/+page.server.ts @@ -1,5 +1,15 @@ +import type { MetaTagsProps } from 'svelte-meta-tags'; +import type { ItemList, WithContext } from 'schema-dts'; import { client } from '#lib/sanity/client.js'; import { allGemsQuery } from '#lib/sanity/queries.js'; +import { canonicalUrl } from '#lib/i18n/index.js'; +import { createBreadcrumbSchema } from '#lib/data/meta.js'; + +const title = 'Gems of Precious Friends'; +const description = + 'Projects and channels by friends of Jan-Philip Loos: tools, tutorials and videos worth your time.'; + +type Gem = { title: string; url: string; description: string }; export async function load({ setHeaders }) { setHeaders({ @@ -13,7 +23,44 @@ export async function load({ setHeaders }) { const day = new Date().getDay(); const rotated = rotateArray(gems, day); - return { gems: rotated }; + // Without its own tags, /gems inherited the English home page's title and description. + const pageMetaTags = Object.freeze({ + title, + description, + openGraph: { title, description }, + twitter: { title, description }, + }) satisfies MetaTagsProps; + + return { + gems: rotated, + pageMetaTags, + pageSchema: [ + gemListSchema(gems), + createBreadcrumbSchema([ + ['Home', canonicalUrl('/en')], + ['Gems', canonicalUrl('/gems')], + ]), + ], + }; +} + +/** The gems in a stable (alphabetical) order, unlike the rotated page. */ +function gemListSchema(gems: Gem[]): WithContext { + return { + '@context': 'https://schema.org', + '@type': 'ItemList', + name: title, + description, + url: canonicalUrl('/gems'), + numberOfItems: gems.length, + itemListElement: gems.map((gem, index) => ({ + '@type': 'ListItem', + position: index + 1, + name: gem.title, + description: gem.description, + url: gem.url, + })), + }; } function rotateArray(arr: T[], count: number) { diff --git a/tests/e2e/geo.test.ts b/tests/e2e/geo.test.ts index 314ef34..412c3fe 100644 --- a/tests/e2e/geo.test.ts +++ b/tests/e2e/geo.test.ts @@ -241,3 +241,19 @@ test('hidden posts stay reachable but out of search', async ({ const sitemap = await (await request.get('/sitemap.xml')).text(); expect(sitemap).not.toContain('continuous-care-no-to-maintenance'); }); + +test('/gems has its own title, description and item list', async ({ page }) => { + await page.goto('/gems'); + await expect(page).toHaveTitle('Gems of Precious Friends | maxdaten.io'); + const description = await page + .locator('meta[name="description"]') + .getAttribute('content'); + expect(description).toMatch(/friends/); + await expect(page.locator('meta[property="og:title"]')).toHaveAttribute( + 'content', + 'Gems of Precious Friends' + ); + + const list = byType(await jsonLd(page, '/gems'), 'ItemList'); + expect(list?.numberOfItems).toBeGreaterThan(0); +}); From 76c473a4f410508fbd04d69daa2f4be56ebedd3f Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 17:59:53 +0000 Subject: [PATCH 06/17] fix(seo): link to each page's final URL instead of through redirects MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On maxdaten.de, relative links to English pages (blog, gems, posts, feed) went through a 308 to www.maxdaten.io, and the logo and "Start over" on English pages hit www.maxdaten.io/ → /en. The legal pages were served on both hosts. - siteHref() links to canonicalUrl() on the production hosts (including the apex the prerender may run as) and stays relative elsewhere; header, footer, post cards, recent posts, RSS link and error pages use it - the logo and "Start over" go to the home page in the page's language - Card and Button treat links to the site's own hosts as internal, so they no longer open in a new tab (replaces the unused HttpRegex) - www.maxdaten.io/{impressum,datenschutz} redirect to maxdaten.de Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MNw4RCS11ZZRVjxynk9baS --- scripts/prod-check.mjs | 1 + src/lib/components/atoms/Button.svelte | 4 +- src/lib/components/atoms/Card.svelte | 4 +- src/lib/components/atoms/RssLink.svelte | 7 +-- .../components/molecules/BlogPostCard.svelte | 4 +- src/lib/components/organisms/Footer.svelte | 9 ++-- src/lib/components/organisms/Header.svelte | 16 ++++--- .../components/organisms/RecentPosts.svelte | 7 ++- src/lib/i18n/i18n.test.ts | 44 +++++++++++++++++++ src/lib/i18n/index.ts | 35 ++++++++++++--- src/lib/utils/regex.test.ts | 30 ------------- src/lib/utils/regex.ts | 1 - src/routes/+error.svelte | 3 +- src/routes/404/+page.svelte | 4 +- tests/e2e/i18n.test.ts | 27 ++++++++++++ tests/vercel-redirects.test.ts | 4 +- vercel.json | 11 +++++ 17 files changed, 153 insertions(+), 58 deletions(-) delete mode 100644 src/lib/utils/regex.test.ts delete mode 100644 src/lib/utils/regex.ts diff --git a/scripts/prod-check.mjs b/scripts/prod-check.mjs index 5986997..93f8a75 100644 --- a/scripts/prod-check.mjs +++ b/scripts/prod-check.mjs @@ -12,6 +12,7 @@ const redirects = [ [`${de}/en`, 308, `${io}/en`], [`${de}${post}`, 308, `${io}${post}`], [`${io}/`, 308, '/en'], + [`${io}/impressum`, 308, `${de}/impressum`], ]; // [url, lang, canonical] diff --git a/src/lib/components/atoms/Button.svelte b/src/lib/components/atoms/Button.svelte index 54df01f..3bad62a 100644 --- a/src/lib/components/atoms/Button.svelte +++ b/src/lib/components/atoms/Button.svelte @@ -1,5 +1,5 @@ + + import { page } from '$app/state'; import Card from '#lib/components/atoms/Card.svelte'; + import { siteHref } from '#lib/i18n/index.js'; import Tag from '#lib/components/atoms/Tag.svelte'; import type { ListingPost } from '#lib/sanity/listing.js'; import { urlFor, generateSrcSet } from '#lib/sanity/image.js'; @@ -49,7 +51,7 @@ {/snippet} diff --git a/src/lib/components/organisms/Footer.svelte b/src/lib/components/organisms/Footer.svelte index 04c909d..509877c 100644 --- a/src/lib/components/organisms/Footer.svelte +++ b/src/lib/components/organisms/Footer.svelte @@ -1,5 +1,6 @@ +
+ +