Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
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
3 changes: 2 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,8 @@ sessions `devenv` is a shell function wrapping it.
in `src/lib/i18n/index.ts` gives a page's final URL; use it for canonical, hreflang, sitemap and
feed links. Host redirects live in `vercel.json` (English pages on .de → www.maxdaten.io, `/` on
.io → `/en`, www.maxdaten.de → apex), tested in `tests/vercel-redirects.test.ts`.
- `hero.subheadline` doubles as `meta.description` — keep them in sync.
- The meta description is the first paragraph of `hero.subheadline` (`heroSummary()`, also on the OG
card and in llms.txt); keep that paragraph at 155 characters or fewer.
- When changing translation text, update `tests/e2e/i18n.test.ts`. `src/lib/i18n/i18n.test.ts`
enforces identical keys across locales and differing values (except `nav.blog`, `nav.gems`,
`footer.impressum`, `meta.title`, `services.platform.title`, `services.delivery.title`). Test both
Expand Down
15 changes: 15 additions & 0 deletions scripts/prod-check.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -64,6 +65,20 @@ for (const [url, lang, canonical] of pages) {
);
}

// Social cards and the robots Sitemap line must point at the final host, not the build origin.
const postHtml = await (await get(`${io}${post}`)).text();
const ogImage = postHtml.match(
/<meta property="og:image" content="([^"]+)"/
)?.[1];
report(ogImage?.startsWith(`${io}/`), `og:image ${ogImage}`);
for (const host of [io, de]) {
const robots = await (await get(`${host}/robots.txt`)).text();
report(
robots.includes(`Sitemap: ${io}/sitemap.xml`),
`${host}/robots.txt names ${io}/sitemap.xml`
);
}

const headers = (await get(`${io}/en`)).headers;
report(
headers.get('content-security-policy')?.includes("frame-ancestors 'none'"),
Expand Down
4 changes: 2 additions & 2 deletions src/lib/components/atoms/Button.svelte
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<script lang="ts">
import { HttpRegex } from '#lib/utils/regex.js';
import { isExternalHref } from '#lib/i18n/index.js';
import type { Snippet } from 'svelte';
import type { ClassValue } from 'svelte/elements';

Expand Down Expand Up @@ -30,7 +30,7 @@
...rest
}: Props = $props();

const isExternalLink = $derived(!!href && HttpRegex.test(href));
const isExternalLink = $derived(!!href && isExternalHref(href));
let tag = $derived(href ? 'a' : 'button');
let linkProps = $derived({
href,
Expand Down
4 changes: 2 additions & 2 deletions src/lib/components/atoms/Card.svelte
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<script lang="ts">
import { HttpRegex } from '#lib/utils/regex.js';
import { isExternalHref } from '#lib/i18n/index.js';
import type { ClassValue, HTMLAnchorAttributes } from 'svelte/elements';
import type { Snippet } from 'svelte';

Expand All @@ -22,7 +22,7 @@
...rest
}: Props = $props();

const isExternalLink = $derived(!!href && HttpRegex.test(href));
const isExternalLink = $derived(!!href && isExternalHref(href));
let tag = $derived(href ? 'a' : 'article');
let linkProps = $derived({
href,
Expand Down
7 changes: 4 additions & 3 deletions src/lib/components/atoms/RssLink.svelte
Original file line number Diff line number Diff line change
@@ -1,15 +1,16 @@
<script lang="ts">
import { resolve } from '$app/paths';
import { page } from '$app/state';
import RssIcon from '#lib/icons/rss.svelte';
import { getContext } from 'svelte';
import { t, type Locale } from '#lib/i18n/index.js';
import { siteHref, t, type Locale } from '#lib/i18n/index.js';

const getLocale: () => Locale = getContext('locale');
let locale = $derived(getLocale());
</script>

<!-- eslint-disable-next-line svelte/no-navigation-without-resolve -- the feed lives on www.maxdaten.io -->
<a
href={resolve('rss.xml')}
href={siteHref('/rss.xml', page.url.origin)}
target="_blank"
rel="noopener noreferrer"
aria-label={t(locale, 'social.rss')}
Expand Down
4 changes: 3 additions & 1 deletion src/lib/components/molecules/BlogPostCard.svelte
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
<script lang="ts">
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';
Expand Down Expand Up @@ -49,7 +51,7 @@
{/snippet}

<Card
href="/{post.slug}"
href={siteHref(`/${post.slug}`, page.url.origin)}
class="blog-post-card"
image={showImage && hasCoverImage ? sanityImage : undefined}
>
Expand Down
5 changes: 4 additions & 1 deletion src/lib/components/molecules/GemCard.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -23,6 +25,7 @@
}

let {
id,
title,
coverImage,
excerpt,
Expand All @@ -41,7 +44,7 @@
);
</script>

<Card {href} target="_self" class="gem-card" data-testid="gem-card">
<Card {id} {href} target="_self" class="gem-card" data-testid="gem-card">
{#snippet image()}
{#if cover}
<div class="cover-image-container">
Expand Down
9 changes: 6 additions & 3 deletions src/lib/components/organisms/Footer.svelte
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
<script lang="ts">
import { resolve } from '$app/paths';
import { page } from '$app/state';
import { siteHref } from '#lib/i18n/index.js';
import Socials from '#lib/components/molecules/Socials.svelte';
import RssLink from '#lib/components/atoms/RssLink.svelte';
import type { Author } from '#lib/utils/types.js';
Expand All @@ -10,8 +11,10 @@
<footer>
<div class="footer-content">
<div class="legal">
<a href={resolve('impressum')}>Impressum</a>
<a href={resolve('datenschutz')}>Datenschutz</a>
<!-- eslint-disable svelte/no-navigation-without-resolve -- the legal pages live on maxdaten.de -->
<a href={siteHref('/impressum', page.url.origin)}>Impressum</a>
<a href={siteHref('/datenschutz', page.url.origin)}>Datenschutz</a>
<!-- eslint-enable svelte/no-navigation-without-resolve -->
<span class="copyright">© {new Date().getFullYear()}</span>
</div>
<div class="socials">
Expand Down
16 changes: 11 additions & 5 deletions src/lib/components/organisms/Header.svelte
Original file line number Diff line number Diff line change
@@ -1,11 +1,10 @@
<script lang="ts">
import { page } from '$app/state';
import { resolve } from '$app/paths';
import Logo from '#lib/components/atoms/Logo.svelte';
import RssLink from '#lib/components/atoms/RssLink.svelte';
import LanguageSwitcher from '#lib/components/molecules/LanguageSwitcher.svelte';
import { getContext } from 'svelte';
import { t, type Locale } from '#lib/i18n/index.js';
import { homeHref, siteHref, t, type Locale } from '#lib/i18n/index.js';

interface Props {
showBackground?: boolean;
Expand All @@ -26,20 +25,25 @@
}
</script>

<!-- eslint-disable svelte/no-navigation-without-resolve -- siteHref() gives each page's final URL, which may be on the other domain -->
<header class:has-background={showBackground}>
<nav class="container">
<a class="logo" href={resolve('')} aria-label="maxdaten.io">
<a
class="logo"
href={homeHref(locale, page.url.origin)}
aria-label="maxdaten.io"
>
<Logo />
</a>
<div class="links">
<a
href={resolve('blog')}
href={siteHref('/blog', page.url.origin)}
class:active={isActive('/blog')}
aria-current={isActive('/blog') ? 'page' : undefined}
>{t(locale, 'nav.blog')}</a
>
<a
href={resolve('gems')}
href={siteHref('/gems', page.url.origin)}
class:active={isActive('/gems')}
aria-current={isActive('/gems') ? 'page' : undefined}
>{t(locale, 'nav.gems')}</a
Expand All @@ -50,6 +54,8 @@
</nav>
</header>

<!-- eslint-enable svelte/no-navigation-without-resolve -->

<style>
header {
position: relative;
Expand Down
7 changes: 5 additions & 2 deletions src/lib/components/organisms/RecentPosts.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,9 @@
import ContentSection from '#lib/components/organisms/ContentSection.svelte';
import type { ListingPost } from '#lib/sanity/listing.js';
import Button from '#lib/components/atoms/Button.svelte';
import { page } from '$app/state';
import { getContext } from 'svelte';
import { t, type Locale } from '#lib/i18n/index.js';
import { siteHref, t, type Locale } from '#lib/i18n/index.js';

interface Props {
posts: ListingPost[];
Expand All @@ -24,7 +25,9 @@
>
{#snippet button()}
<div>
<Button href="/blog">{t(locale, 'recentPosts.viewMore')}</Button>
<Button href={siteHref('/blog', page.url.origin)}
>{t(locale, 'recentPosts.viewMore')}</Button
>
</div>
{/snippet}
<!-- Posts are written in English, also on the German home page. -->
Expand Down
58 changes: 39 additions & 19 deletions src/lib/data/meta.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
// Via <svelte:head>

import MeSrc from '#lib/assets/images/authors/jloos.png?enhanced';
import type { Author, BlogPost } from '#lib/utils/types.js';
import type { Author } from '#lib/utils/types.js';
import type {
BlogPosting,
BreadcrumbList,
Expand All @@ -23,6 +23,8 @@ export const description =

export const title = 'Jan-Philip Loos | maxdaten.io';

const portraitUrl = `${siteBaseUrl}${MeSrc?.img.src}`;

/** The author bio lives in Sanity in English; this is its German translation. */
const germanPersonDescription =
'Jan-Philip Loos ist Freelance Platform & Product Engineer in Hamburg. Seit über 15 Jahren baut er Produkte und die Plattformen, auf denen sie laufen — von der Mitgründung der Briends GmbH, dem Unternehmen hinter Papego, bis zu Systemen mit 100M+ Requests am Tag. Er schreibt über Platform Engineering, Nix und Continuous Delivery.';
Expand All @@ -35,11 +37,10 @@ const organizationDescriptions: Record<Locale, string> = {
export function getBaseSchema(
locale: Locale,
author: Author
): [WebSite, Person, ProfilePage, Organization, ProfessionalService] {
): [WebSite, Person, Organization, ProfessionalService] {
// One name and URL per entity, whatever the page language: answer engines merge entities
// by @id and distrust ones whose properties disagree.
const entityName = 'maxdaten.io';
const portraitUrl = `${siteBaseUrl}${MeSrc?.img.src}`;
const desc = {
person: locale === 'de' ? germanPersonDescription : (author.bio ?? ''),
organization: organizationDescriptions[locale],
Expand Down Expand Up @@ -73,17 +74,6 @@ export function getBaseSchema(
url.startsWith('https://') && !url.includes('signal.me')
),
},
<WithContext<ProfilePage>>{
'@context': 'https://schema.org',
'@type': 'ProfilePage',
'@id': 'https://maxdaten.io/#profile',
url: canonicalUrl('/about/jloos'),
inLanguage: 'en-US',
mainEntity: {
'@id': 'https://maxdaten.io/#jloos',
},
image: portraitUrl,
},
<WithContext<Organization>>{
'@context': 'https://schema.org',
'@type': 'Organization',
Expand Down Expand Up @@ -127,11 +117,41 @@ export function getBaseSchema(
];
}

/**
* The site author's about page is their ProfilePage. Only that page says so: on every other
* page it would claim the page itself is a profile.
*/
export function createProfilePageSchema(): WithContext<ProfilePage> {
return {
'@context': 'https://schema.org',
'@type': 'ProfilePage',
'@id': 'https://maxdaten.io/#profile',
url: canonicalUrl('/about/jloos'),
inLanguage: 'en-US',
mainEntity: {
'@id': 'https://maxdaten.io/#jloos',
},
image: portraitUrl,
};
}

/** What a BlogPosting needs to know about a post. */
export type BlogPostingInput = {
title: string;
date: string;
updated: string;
excerpt: string;
tags: string[];
image: string;
wordCount?: number;
readingTimeMinutes?: number;
authorId?: string;
};

// Simple mapping function for blog posts (not a complex generator)
export function createBlogPostingSchema(
post: BlogPost,
pageUrl: string,
image: string
post: BlogPostingInput,
pageUrl: string
): WithContext<BlogPosting> {
return {
'@context': 'https://schema.org',
Expand All @@ -143,7 +163,7 @@ export function createBlogPostingSchema(
keywords: post.tags,
url: pageUrl,
mainEntityOfPage: pageUrl,
image,
image: post.image,
inLanguage: 'en',
...(post.authorId && {
author: {
Expand All @@ -153,8 +173,8 @@ export function createBlogPostingSchema(
publisher: {
'@id': 'https://maxdaten.io/#organization',
},
...(post.wordCount && { wordCount: post.wordCount }),
...(post.readingTimeMinutes && {
wordCount: post.readingTimeMinutes * 200, // estimate
timeRequired: `PT${post.readingTimeMinutes}M`,
}),
};
Expand Down
6 changes: 2 additions & 4 deletions src/lib/i18n/de.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ export const de: TranslationKeys = {
'hero.headline': 'Software, die liefert',
'hero.headlineAccent': 'Systeme, die skalieren',
'hero.subheadline':
'Ich entwickle Ihr digitales Produkt end-to-end und sorge dafür, dass das Wissen in Ihrem Team bleibt — mit Continuous Delivery und Hochverfügbarkeit als Grundlage.<br><br>Freelance Platform & Product Engineer mit 15+ Jahren Erfahrung in Produktentwicklung, Plattform-Architektur und Tech-Leadership — vom Startup bis zu Systemen mit 100M+ Requests am Tag. Hamburg.',
'Ich entwickle Ihr Produkt und die Plattform darunter — Kubernetes, Nix, Continuous Delivery. Hands-on in Ihrem Team, und das Wissen bleibt. Hamburg & remote.<br><br>Freelance Platform & Product Engineer mit 15+ Jahren Erfahrung in Produktentwicklung, Plattform-Architektur und Tech-Leadership — vom Startup bis zu Systemen mit 100M+ Requests am Tag.',
'hero.ctaBook': 'Gespräch buchen',
'hero.ctaProjects': 'Projekte ansehen',
'hero.abilityDescription': 'Ausgerollt an 700+ Fielmann-Filialen',
Expand Down Expand Up @@ -57,9 +57,7 @@ export const de: TranslationKeys = {
'Mitgründer des Unternehmens hinter Papego, ausgezeichnet mit dem German Design Award 2018.',

// Meta / SEO
'meta.title': 'Jan-Philip Loos | maxdaten.de',
'meta.description':
'Ich entwickle Ihr digitales Produkt end-to-end und sorge dafür, dass das Wissen in Ihrem Team bleibt — mit Continuous Delivery und Hochverfügbarkeit als Grundlage. Freelance Platform & Product Engineer mit 15+ Jahren Erfahrung in Produktentwicklung, Plattform-Architektur und Tech-Leadership — vom Startup bis zu Systemen mit 100M+ Requests am Tag. Hamburg.',
'meta.title': 'Platform & Product Engineer, Hamburg | Jan-Philip Loos',
'meta.ogImageAlt':
'Jan-Philip Loos - Software, die liefert. Systeme, die skalieren.',
'meta.keywords':
Expand Down
6 changes: 2 additions & 4 deletions src/lib/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ export const en: TranslationKeys = {
'hero.headline': 'Products that ship',
'hero.headlineAccent': 'Systems that scale',
'hero.subheadline':
'I build your digital product end to end and make sure the knowledge stays in your team — with continuous delivery and high availability as the foundation.<br><br>Freelance Platform & Product Engineer with 15+ years in product development, platform architecture and tech leadership — from startup to systems at 100M+ requests a day. Hamburg.',
'I build your product and the platform under it: Kubernetes, Nix, continuous delivery. Hands-on in your team, and the knowledge stays. Hamburg & remote.<br><br>Freelance Platform & Product Engineer with 15+ years in product development, platform architecture and tech leadership — from startup to systems at 100M+ requests a day.',
'hero.ctaBook': 'Book a Call',
'hero.ctaProjects': 'View Projects',
'hero.abilityDescription': 'Deployed to 700+ retail locations at Fielmann',
Expand Down Expand Up @@ -57,9 +57,7 @@ export const en: TranslationKeys = {
'Co-founded the company behind Papego, winner of the German Design Award 2018.',

// Meta / SEO
'meta.title': 'Jan-Philip Loos | maxdaten.io',
'meta.description':
'I build your digital product end to end and make sure the knowledge stays in your team — with continuous delivery and high availability as the foundation. Freelance Platform & Product Engineer with 15+ years in product development, platform architecture and tech leadership — from startup to systems at 100M+ requests a day. Hamburg.',
'meta.title': 'Platform & Product Engineer, Hamburg | Jan-Philip Loos',
'meta.ogImageAlt':
'Jan-Philip Loos - Products that ship. Systems that scale.',
'meta.keywords':
Expand Down
Loading
Loading