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
- 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.
- {item.a} + {item.answer}
- 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 = () => (
);
diff --git a/apps/web/src/content/faq.ts b/apps/web/src/content/faq.ts
new file mode 100644
index 0000000..8146837
--- /dev/null
+++ b/apps/web/src/content/faq.ts
@@ -0,0 +1,38 @@
+export type FaqItem = {
+ readonly question: string;
+ readonly answer: string;
+};
+
+/** Shown in the questions section and passed to search engines as structured data. */
+export const FAQ: readonly FaqItem[] = [
+ {
+ question: "I am not technical. Is that a problem?",
+ answer:
+ "No. I explain what I am doing in plain language, and you can ask at any time. You do not need to read code.",
+ },
+ {
+ question: "How long am I committed?",
+ answer:
+ "Project work is billed by effort. Either side can end it with two weeks notice to the end of a month.",
+ },
+ {
+ question: "Who owns what you build?",
+ answer:
+ "You do. Code and design belong to you, and you can hand them to another developer.",
+ },
+ {
+ question: "Can you work with our existing team and code?",
+ answer:
+ "Yes. I worked in agencies and product teams for years, so joining an existing project is familiar to me.",
+ },
+ {
+ question: "How do I reach you?",
+ answer:
+ "By email or video call. I work remotely and am available during European business hours.",
+ },
+ {
+ question: "What do you not do?",
+ answer:
+ "Mobile apps for the app stores, Windows software and running your servers. If your project needs those, I say so in the first call.",
+ },
+];
diff --git a/apps/web/src/content/paths.ts b/apps/web/src/content/paths.ts
index 758aff7..6c82d9b 100644
--- a/apps/web/src/content/paths.ts
+++ b/apps/web/src/content/paths.ts
@@ -16,6 +16,8 @@ export type PathPrice = {
export type ServicePath = {
readonly id: string;
+ /** The service in neutral words, as search engines list it. */
+ readonly service: string;
/** How the visitor would say it. */
readonly need: string;
readonly forWhom: string;
@@ -34,6 +36,7 @@ export type ServicePath = {
export const SERVICE_PATHS: readonly ServicePath[] = [
{
id: "developer",
+ service: "Web app and frontend development",
need: "We need a developer for our product",
forWhom: "Startups, product teams, agencies, Web3 teams",
headline: "An experienced developer who joins your project.",
@@ -76,6 +79,7 @@ export const SERVICE_PATHS: readonly ServicePath[] = [
},
{
id: "rescue",
+ service: "Code review and stabilisation of web apps",
need: "Our app needs to become reliable",
forWhom: "Teams whose app was built quickly and is now hard to change",
headline: "A week to look at your app and fix what is most urgent.",
@@ -115,6 +119,7 @@ export const SERVICE_PATHS: readonly ServicePath[] = [
},
{
id: "automation",
+ service: "Process automation and system integration",
need: "We want to automate a process",
forWhom:
"Companies where people copy data between tools or repeat the same steps",
@@ -156,6 +161,7 @@ export const SERVICE_PATHS: readonly ServicePath[] = [
},
{
id: "website",
+ service: "Website design and development",
need: "I just need a website",
forWhom: "Companies and self-employed people who want it taken care of",
headline: "A website, built and looked after for you.",
diff --git a/apps/web/src/content/site.ts b/apps/web/src/content/site.ts
index e3bba3f..21eb91c 100644
--- a/apps/web/src/content/site.ts
+++ b/apps/web/src/content/site.ts
@@ -1,13 +1,25 @@
import { parseEmailParts, type EmailParts } from "@/src/domain/email";
+import { indexingFor } from "@/src/domain/indexing";
export const SITE_NAME = "Marc Mintel";
export const SITE_URL = "https://mintel.me";
/** One wording for the browser title, search results and link previews. */
export const SITE_TITLE =
- "Marc Mintel — Freelance developer for web apps and websites";
+ "Freelance web developer for web apps & websites | Marc Mintel";
export const SITE_DESCRIPTION =
- "Freelance developer with 15 years of experience. I build web apps, interfaces and websites, and automate processes between your systems.";
+ "Freelance web developer with 15 years of experience. I build web apps, interfaces and websites with React, Next.js and TypeScript, and automate processes.";
+export const SITE_LANGUAGE = "en";
+export const JOB_TITLE = "Freelance web developer";
+
+/**
+ * Public profiles of the owner (LinkedIn, GitHub, ...). Search engines use them to
+ * connect this site with the same person elsewhere. Only add profiles the owner confirmed.
+ */
+export const PROFILES: readonly string[] = [];
+
+/** Only the production deployment may appear in search results. Set by the build. */
+export const INDEXING = indexingFor(process.env.NEXT_PUBLIC_TARGET);
// The contact address, kept in parts on purpose: the complete address must never
// appear as one string in the page source (see src/components/EmailLink.tsx).
diff --git a/apps/web/src/domain/indexing.spec.ts b/apps/web/src/domain/indexing.spec.ts
new file mode 100644
index 0000000..d9b2fc1
--- /dev/null
+++ b/apps/web/src/domain/indexing.spec.ts
@@ -0,0 +1,61 @@
+import { describe, expect, it } from "vitest";
+import { indexingFor, robotsFile, robotsMeta } from "./indexing";
+
+describe("indexingFor", () => {
+ it("lets search engines index the production deployment", () => {
+ expect(indexingFor("production")).toBe("indexable");
+ });
+
+ it.each(["testing", "staging", "branch"])(
+ "blocks the %s deployment, which is a copy of the real site",
+ (target) => {
+ expect(indexingFor(target)).toBe("blocked");
+ },
+ );
+
+ // A production build that lost its target must not drop out of the search index.
+ it.each([
+ ["a missing target", undefined],
+ ["an empty target", ""],
+ ["an unknown target", "preview"],
+ ["a target in another case", "Testing"],
+ ["a value that is not a string", 1],
+ ])("stays indexable for %s", (_case, target) => {
+ expect(indexingFor(target)).toBe("indexable");
+ });
+});
+
+describe("robotsFile", () => {
+ it("allows everything except the analytics and error proxies, and names the sitemap", () => {
+ expect(robotsFile("indexable", "https://mintel.me")).toEqual({
+ rules: { userAgent: "*", allow: "/", disallow: ["/stats/", "/errors/"] },
+ sitemap: "https://mintel.me/sitemap.xml",
+ });
+ });
+
+ it("disallows everything on a blocked deployment and names no sitemap", () => {
+ expect(robotsFile("blocked", "https://mintel.me")).toEqual({
+ rules: { userAgent: "*", disallow: "/" },
+ });
+ });
+});
+
+describe("robotsMeta", () => {
+ it("allows indexing with full snippets and large image previews", () => {
+ expect(robotsMeta("indexable")).toEqual({
+ index: true,
+ follow: true,
+ googleBot: {
+ index: true,
+ follow: true,
+ "max-snippet": -1,
+ "max-image-preview": "large",
+ "max-video-preview": -1,
+ },
+ });
+ });
+
+ it("forbids indexing and following on a blocked deployment", () => {
+ expect(robotsMeta("blocked")).toEqual({ index: false, follow: false });
+ });
+});
diff --git a/apps/web/src/domain/indexing.ts b/apps/web/src/domain/indexing.ts
new file mode 100644
index 0000000..49a174a
--- /dev/null
+++ b/apps/web/src/domain/indexing.ts
@@ -0,0 +1,66 @@
+/**
+ * Whether search engines may index a deployment. Only the production site belongs
+ * in search results; the testing, staging and branch deployments are copies of it.
+ */
+export type Indexing = "indexable" | "blocked";
+
+export type RobotsFile = {
+ readonly rules: {
+ readonly userAgent: string;
+ readonly allow?: string;
+ readonly disallow: string | readonly string[];
+ };
+ readonly sitemap?: string;
+};
+
+export type RobotsMeta = {
+ readonly index: boolean;
+ readonly follow: boolean;
+ readonly googleBot?: {
+ readonly index: boolean;
+ readonly follow: boolean;
+ readonly "max-snippet": number;
+ readonly "max-image-preview": "large";
+ readonly "max-video-preview": number;
+ };
+};
+
+const BLOCKED_TARGETS: readonly unknown[] = ["testing", "staging", "branch"];
+
+/** Paths that proxy analytics and error reports; nothing there is a page. */
+const PROXY_PATHS: readonly string[] = ["/stats/", "/errors/"];
+
+/**
+ * Only the deployments known to be copies are blocked. Anything else stays
+ * indexable, so a production build that lost its target does not drop out of
+ * the search index.
+ */
+export function indexingFor(target: unknown): Indexing {
+ return BLOCKED_TARGETS.includes(target) ? "blocked" : "indexable";
+}
+
+export function robotsFile(indexing: Indexing, siteUrl: string): RobotsFile {
+ if (indexing === "blocked") {
+ return { rules: { userAgent: "*", disallow: "/" } };
+ }
+ return {
+ rules: { userAgent: "*", allow: "/", disallow: PROXY_PATHS },
+ sitemap: `${siteUrl}/sitemap.xml`,
+ };
+}
+
+/** The robots meta tag. Google gets no limit on snippet length or image preview size. */
+export function robotsMeta(indexing: Indexing): RobotsMeta {
+ if (indexing === "blocked") return { index: false, follow: false };
+ return {
+ index: true,
+ follow: true,
+ googleBot: {
+ index: true,
+ follow: true,
+ "max-snippet": -1,
+ "max-image-preview": "large",
+ "max-video-preview": -1,
+ },
+ };
+}
diff --git a/apps/web/src/domain/structured-data.spec.ts b/apps/web/src/domain/structured-data.spec.ts
new file mode 100644
index 0000000..1686fb9
--- /dev/null
+++ b/apps/web/src/domain/structured-data.spec.ts
@@ -0,0 +1,172 @@
+import { describe, expect, it } from "vitest";
+import {
+ buildStructuredData,
+ serializeJsonLd,
+ type StructuredDataInput,
+} from "./structured-data";
+
+const input: StructuredDataInput = {
+ siteUrl: "https://example.com",
+ siteName: "Jane Doe",
+ language: "en",
+ description: "Freelance web developer.",
+ imageUrl: "https://example.com/jane.png",
+ person: {
+ name: "Jane Doe",
+ jobTitle: "Freelance web developer",
+ profiles: ["https://github.com/jane", "https://linkedin.com/in/jane"],
+ knowsAbout: ["React", "Websites"],
+ },
+ address: {
+ street: "Main Street 1",
+ postalCode: "12345",
+ locality: "Town",
+ countryCode: "DE",
+ },
+ services: [
+ { name: "Web app development", description: "I join your project." },
+ { name: "Website development", description: "A website, built for you." },
+ ],
+ faq: [
+ { question: "Who owns the code?", answer: "You do." },
+ { question: "How do I reach you?", answer: "By email." },
+ ],
+};
+
+const node = (data: ReturnType