diff --git a/apps/web/app/(site)/not-found.tsx b/apps/web/app/(site)/not-found.tsx index 34fa5f6..24cd93d 100644 --- a/apps/web/app/(site)/not-found.tsx +++ b/apps/web/app/(site)/not-found.tsx @@ -1,9 +1,8 @@ export const metadata = { - title: "404 - Seite nicht gefunden | Marc Mintel", - description: "Diese Seite konnte leider nicht gefunden werden.", + title: "Page not found", + description: "This page does not exist.", }; -import Link from "next/link"; import { Button } from "@/src/components/Button"; export default function NotFound() { @@ -14,19 +13,15 @@ export default function NotFound() { Error 404

- System-Anomalie. + Page not found.

- Die angeforderte URL existiert nicht in dieser Zeitleiste. - Möglicherweise wurde die Seite verschoben oder gelöscht. + This address does not exist. The page may have been moved or removed.

-
diff --git a/apps/web/app/(site)/opengraph-image.alt.txt b/apps/web/app/(site)/opengraph-image.alt.txt index 56535b5..7809acd 100644 --- a/apps/web/app/(site)/opengraph-image.alt.txt +++ b/apps/web/app/(site)/opengraph-image.alt.txt @@ -1 +1 @@ -Web apps and websites. Marc Mintel, freelance developer with 15 years of experience. \ No newline at end of file +Web apps and websites. Marc Mintel, freelance web developer with 15 years of experience. \ No newline at end of file diff --git a/apps/web/app/(site)/opengraph-image.png b/apps/web/app/(site)/opengraph-image.png index 341a658..d099c52 100644 Binary files a/apps/web/app/(site)/opengraph-image.png and b/apps/web/app/(site)/opengraph-image.png differ diff --git a/apps/web/app/(site)/sitemap.ts b/apps/web/app/(site)/sitemap.ts index a7f000a..342efc7 100644 --- a/apps/web/app/(site)/sitemap.ts +++ b/apps/web/app/(site)/sitemap.ts @@ -1,14 +1,11 @@ import type { MetadataRoute } from "next"; +import { SITE_URL } from "@/src/content/site"; -/** The site is a single page. */ +/** + * The site is a single page; the legal pages are not meant for search results. + * No modification date: it would have to be the date the content last changed, + * and a date that moves with every build teaches search engines to ignore it. + */ export default function sitemap(): MetadataRoute.Sitemap { - const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || "https://mintel.me"; - return [ - { - url: baseUrl, - lastModified: new Date(), - changeFrequency: "monthly", - priority: 1, - }, - ]; + return [{ url: `${SITE_URL}/` }]; } diff --git a/apps/web/app/(site)/twitter-image.alt.txt b/apps/web/app/(site)/twitter-image.alt.txt index 56535b5..7809acd 100644 --- a/apps/web/app/(site)/twitter-image.alt.txt +++ b/apps/web/app/(site)/twitter-image.alt.txt @@ -1 +1 @@ -Web apps and websites. Marc Mintel, freelance developer with 15 years of experience. \ No newline at end of file +Web apps and websites. Marc Mintel, freelance web developer with 15 years of experience. \ No newline at end of file diff --git a/apps/web/app/(site)/twitter-image.png b/apps/web/app/(site)/twitter-image.png index 341a658..d099c52 100644 Binary files a/apps/web/app/(site)/twitter-image.png and b/apps/web/app/(site)/twitter-image.png differ diff --git a/apps/web/app/robots.ts b/apps/web/app/robots.ts new file mode 100644 index 0000000..79eeec7 --- /dev/null +++ b/apps/web/app/robots.ts @@ -0,0 +1,17 @@ +import type { MetadataRoute } from "next"; +import { INDEXING, SITE_URL } from "@/src/content/site"; +import { robotsFile } from "@/src/domain/indexing"; + +export default function robots(): MetadataRoute.Robots { + const { rules, sitemap } = robotsFile(INDEXING, SITE_URL); + return { + rules: { + ...rules, + disallow: + typeof rules.disallow === "string" + ? rules.disallow + : [...rules.disallow], + }, + sitemap, + }; +} diff --git a/apps/web/public/robots.txt b/apps/web/public/robots.txt deleted file mode 100644 index 8aaf444..0000000 --- a/apps/web/public/robots.txt +++ /dev/null @@ -1,4 +0,0 @@ -User-agent: * -Allow: / - -Sitemap: https://mintel.me/sitemap.xml diff --git a/apps/web/src/components/home/Faq.tsx b/apps/web/src/components/home/Faq.tsx index 28abaf0..08b9851 100644 --- a/apps/web/src/components/home/Faq.tsx +++ b/apps/web/src/components/home/Faq.tsx @@ -1,41 +1,15 @@ import * as React from "react"; - -const QUESTIONS = [ - { - q: "I am not technical. Is that a problem?", - a: "No. I explain what I am doing in plain language, and you can ask at any time. You do not need to read code.", - }, - { - q: "How long am I committed?", - a: "Project work is billed by effort. Either side can end it with two weeks notice to the end of a month.", - }, - { - q: "Who owns what you build?", - a: "You do. Code and design belong to you, and you can hand them to another developer.", - }, - { - q: "Can you work with our existing team and code?", - a: "Yes. I worked in agencies and product teams for years, so joining an existing project is familiar to me.", - }, - { - q: "How do I reach you?", - a: "By email or video call. I work remotely and am available during European business hours.", - }, - { - q: "What do you not do?", - a: "Mobile apps for the app stores, Windows software and running your servers. If your project needs those, I say so in the first call.", - }, -] as const; +import { FAQ } from "@/src/content/faq"; export const Faq: React.FC = () => (
- {QUESTIONS.map((item) => ( + {FAQ.map((item) => (
- {item.q} + {item.question}

- {item.a} + {item.answer}

))} diff --git a/apps/web/src/components/home/HeroStatement.tsx b/apps/web/src/components/home/HeroStatement.tsx index 4c4e5d7..7bd2af9 100644 --- a/apps/web/src/components/home/HeroStatement.tsx +++ b/apps/web/src/components/home/HeroStatement.tsx @@ -24,7 +24,7 @@ export const HeroStatement: React.FC = () => (

- Freelance developer with 15 years of experience.{" "} + Freelance web developer with 15 years of experience.{" "} I build web apps, interfaces and websites. diff --git a/apps/web/src/components/home/HomeJsonLd.tsx b/apps/web/src/components/home/HomeJsonLd.tsx index 29e3812..94117a6 100644 --- a/apps/web/src/components/home/HomeJsonLd.tsx +++ b/apps/web/src/components/home/HomeJsonLd.tsx @@ -1,45 +1,58 @@ +import { TECH } from "@/src/content/about"; +import { FAQ } from "@/src/content/faq"; +import { SERVICE_PATHS } from "@/src/content/paths"; import { + JOB_TITLE, OWNER, + PROFILES, SITE_DESCRIPTION, + SITE_LANGUAGE, SITE_NAME, SITE_URL, } from "@/src/content/site"; +import { + buildStructuredData, + serializeJsonLd, +} from "@/src/domain/structured-data"; -const STRUCTURED_DATA = { - "@context": "https://schema.org", - "@type": "ProfessionalService", - name: SITE_NAME, - url: SITE_URL, +const [postalCode = "", ...locality] = OWNER.city.split(" "); + +const STRUCTURED_DATA = buildStructuredData({ + siteUrl: SITE_URL, + siteName: SITE_NAME, + language: SITE_LANGUAGE, description: SITE_DESCRIPTION, - image: `${SITE_URL}/marc-mintel.png`, + imageUrl: `${SITE_URL}/marc-mintel.png`, + person: { + name: OWNER.name, + jobTitle: JOB_TITLE, + profiles: PROFILES, + knowsAbout: [ + "Web applications", + "User interfaces", + "Websites", + "Process automation", + "Web3 frontends", + ...TECH.flatMap((group) => group.items), + ], + }, address: { - "@type": "PostalAddress", - streetAddress: OWNER.street, - postalCode: OWNER.city.split(" ")[0], - addressLocality: OWNER.city.split(" ").slice(1).join(" "), - addressCountry: "DE", + street: OWNER.street, + postalCode, + locality: locality.join(" "), + countryCode: "DE", }, - founder: { - "@type": "Person", - name: SITE_NAME, - jobTitle: "Freelance developer", - url: SITE_URL, - }, - knowsAbout: [ - "Web applications", - "User interfaces", - "Websites", - "Process automation", - "Web3 frontends", - ], -} as const; + services: SERVICE_PATHS.map((path) => ({ + name: path.service, + description: path.intro, + })), + faq: FAQ, +}); -/** Serialized server-side from constants only; "<" is escaped to keep it inert inside a script tag. */ +/** Built server-side from the page content; see src/domain/structured-data.ts. */ export const HomeJsonLd: React.FC = () => ( ", + }); + const json = serializeJsonLd(data); + expect(json).not.toContain("<"); + expect(json).toContain("\\u003c/script>\\u003cscript>"); + expect(JSON.parse(json)).toEqual(data); + }); +}); diff --git a/apps/web/src/domain/structured-data.ts b/apps/web/src/domain/structured-data.ts new file mode 100644 index 0000000..71cbb3b --- /dev/null +++ b/apps/web/src/domain/structured-data.ts @@ -0,0 +1,127 @@ +/** + * Structured data (schema.org JSON-LD) that tells search engines who runs the site, + * what is offered and which questions the page answers. Built from the same content + * the page shows, so the two cannot drift apart. + */ +export type StructuredDataInput = { + readonly siteUrl: string; + readonly siteName: string; + readonly language: string; + readonly description: string; + readonly imageUrl: string; + readonly person: { + readonly name: string; + readonly jobTitle: string; + /** Public profiles of the same person elsewhere on the web. */ + readonly profiles: readonly string[]; + readonly knowsAbout: readonly string[]; + }; + readonly address: { + readonly street: string; + readonly postalCode: string; + readonly locality: string; + readonly countryCode: string; + }; + readonly services: readonly { + readonly name: string; + readonly description: string; + }[]; + readonly faq: readonly { + readonly question: string; + readonly answer: string; + }[]; +}; + +export type StructuredDataNode = { + readonly "@type": string; + readonly [property: string]: unknown; +}; + +export type StructuredData = { + readonly "@context": "https://schema.org"; + readonly "@graph": readonly StructuredDataNode[]; +}; + +export function buildStructuredData( + input: StructuredDataInput, +): StructuredData { + const { siteUrl, siteName, description, imageUrl, person, address } = input; + const personRef = { "@id": `${siteUrl}/#person` }; + const businessRef = { "@id": `${siteUrl}/#business` }; + + const website: StructuredDataNode = { + "@type": "WebSite", + "@id": `${siteUrl}/#website`, + url: siteUrl, + name: siteName, + description, + inLanguage: input.language, + publisher: personRef, + }; + + const personNode: StructuredDataNode = { + "@type": "Person", + ...personRef, + name: person.name, + url: siteUrl, + image: imageUrl, + jobTitle: person.jobTitle, + knowsAbout: person.knowsAbout, + ...(person.profiles.length > 0 ? { sameAs: person.profiles } : {}), + }; + + const business: StructuredDataNode = { + "@type": "ProfessionalService", + ...businessRef, + name: siteName, + url: siteUrl, + description, + image: imageUrl, + founder: personRef, + address: { + "@type": "PostalAddress", + streetAddress: address.street, + postalCode: address.postalCode, + addressLocality: address.locality, + addressCountry: address.countryCode, + }, + hasOfferCatalog: { + "@type": "OfferCatalog", + name: "Services", + itemListElement: input.services.map((service) => ({ + "@type": "Offer", + itemOffered: { + "@type": "Service", + name: service.name, + description: service.description, + provider: businessRef, + }, + })), + }, + }; + + const faq: readonly StructuredDataNode[] = + input.faq.length > 0 + ? [ + { + "@type": "FAQPage", + "@id": `${siteUrl}/#faq`, + mainEntity: input.faq.map((item) => ({ + "@type": "Question", + name: item.question, + acceptedAnswer: { "@type": "Answer", text: item.answer }, + })), + }, + ] + : []; + + return { + "@context": "https://schema.org", + "@graph": [website, personNode, business, ...faq], + }; +} + +/** JSON for a script tag. "<" is escaped so that no text can close the tag. */ +export function serializeJsonLd(data: StructuredData): string { + return JSON.stringify(data).replace(/