diff --git a/CLAUDE.md b/CLAUDE.md
index b726477..a531183 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -28,6 +28,9 @@ App lives in `apps/web`. Concept and scope: `apps/web/plans/freelance-relaunch.m
- One page: everything lives on the homepage. The header has only "Get in touch", which jumps to the contact
section (`#contact`). Contact is one email address: no calendar integration, no form. The only other
pages are `/imprint` and `/privacy`. URLs of the previous site redirect to `/` (see `next.config.mjs`).
+- Title, description and link previews come from `SITE_TITLE` and `SITE_DESCRIPTION` in `src/content/site.ts`.
+ The share image is a screenshot of the real hero: after changing the hero headline or its design, run
+ `pnpm --filter @mintel/web share-image` and commit the two PNGs in `app/(site)/`.
- The email address is kept in parts (`src/content/site.ts`) and assembled in the browser by `EmailLink`.
Never write the full address into markup, metadata or copy.
- The hero design (binary-digit headline) is approved: do not change its design.
diff --git a/apps/web/app/(site)/layout.tsx b/apps/web/app/(site)/layout.tsx
index e3d6e8b..8a71f3c 100644
--- a/apps/web/app/(site)/layout.tsx
+++ b/apps/web/app/(site)/layout.tsx
@@ -4,6 +4,12 @@ import { Analytics } from "@/src/components/Analytics";
import { Footer } from "@/src/components/Footer";
import { Header } from "@/src/components/Header";
import { InteractiveElements } from "@/src/components/InteractiveElements";
+import {
+ SITE_DESCRIPTION,
+ SITE_NAME,
+ SITE_TITLE,
+ SITE_URL,
+} from "@/src/content/site";
import "./globals.css";
const inter = Inter({ subsets: ["latin"], variable: "--font-inter" });
@@ -20,15 +26,32 @@ const newsreader = Newsreader({
});
export const metadata: Metadata = {
+ metadataBase: new URL(SITE_URL),
title: {
- default: "Marc Mintel — Senior developer for web apps and websites",
- template: "%s | Marc Mintel",
+ default: SITE_TITLE,
+ template: `%s | ${SITE_NAME}`,
},
- description:
- "Senior developer for web apps, interfaces and websites. Book weekly hours for your product, get an unreliable app back on track, or have a website built for you.",
- metadataBase: new URL("https://mintel.me"),
+ description: SITE_DESCRIPTION,
+ applicationName: SITE_NAME,
+ authors: [{ name: SITE_NAME, url: SITE_URL }],
+ creator: SITE_NAME,
icons: {
icon: "/favicon.svg",
+ apple: "/apple-touch-icon.png",
+ },
+ // The images come from opengraph-image.png and twitter-image.png next to this file.
+ openGraph: {
+ type: "website",
+ url: "/",
+ siteName: SITE_NAME,
+ locale: "en_US",
+ title: SITE_TITLE,
+ description: SITE_DESCRIPTION,
+ },
+ twitter: {
+ card: "summary_large_image",
+ title: SITE_TITLE,
+ description: SITE_DESCRIPTION,
},
};
diff --git a/apps/web/app/(site)/opengraph-image.alt.txt b/apps/web/app/(site)/opengraph-image.alt.txt
new file mode 100644
index 0000000..56535b5
--- /dev/null
+++ b/apps/web/app/(site)/opengraph-image.alt.txt
@@ -0,0 +1 @@
+Web apps and websites. Marc Mintel, freelance 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
new file mode 100644
index 0000000..341a658
Binary files /dev/null and b/apps/web/app/(site)/opengraph-image.png differ
diff --git a/apps/web/app/(site)/opengraph-image.tsx b/apps/web/app/(site)/opengraph-image.tsx
deleted file mode 100644
index 3f284de..0000000
--- a/apps/web/app/(site)/opengraph-image.tsx
+++ /dev/null
@@ -1,23 +0,0 @@
-import { ImageResponse } from "next/og";
-import { OGImageTemplate } from "../../src/components/OGImageTemplate";
-import { getOgFonts, OG_IMAGE_SIZE } from "../../src/lib/og-helper";
-
-export const size = OG_IMAGE_SIZE;
-export const contentType = "image/png";
-export const runtime = "nodejs";
-
-export default async function Image() {
- const fonts = await getOgFonts();
-
- return new ImageResponse(
- ,
- {
- ...OG_IMAGE_SIZE,
- fonts: fonts as any,
- },
- );
-}
diff --git a/apps/web/app/(site)/page.tsx b/apps/web/app/(site)/page.tsx
index c05f8f1..22c00f7 100644
--- a/apps/web/app/(site)/page.tsx
+++ b/apps/web/app/(site)/page.tsx
@@ -17,12 +17,12 @@ import {
} from "@/src/components/illustrations/WorkShowcase";
import { Reveal } from "@/src/components/Reveal";
import { CAREER, FIT, TECH } from "@/src/content/about";
+import { SITE_DESCRIPTION, SITE_TITLE } from "@/src/content/site";
import { AUTOMATION_NOTES } from "@/src/content/automation";
export const metadata: Metadata = {
- title: "Marc Mintel — Senior developer for web apps and websites",
- description:
- "Freelance developer with 15 years of experience. I build web apps, interfaces and websites: by the hour for product teams, or a complete website for your business.",
+ title: { absolute: SITE_TITLE },
+ description: SITE_DESCRIPTION,
alternates: { canonical: "/" },
};
diff --git a/apps/web/app/(site)/twitter-image.alt.txt b/apps/web/app/(site)/twitter-image.alt.txt
new file mode 100644
index 0000000..56535b5
--- /dev/null
+++ b/apps/web/app/(site)/twitter-image.alt.txt
@@ -0,0 +1 @@
+Web apps and websites. Marc Mintel, freelance 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
new file mode 100644
index 0000000..341a658
Binary files /dev/null and b/apps/web/app/(site)/twitter-image.png differ
diff --git a/apps/web/package.json b/apps/web/package.json
index feb6fea..45ce579 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -2,7 +2,7 @@
"name": "@mintel/web",
"type": "module",
"version": "0.1.0",
- "description": "Technical problem solver's blog - practical insights and learning notes",
+ "description": "Website of Marc Mintel, freelance developer for web apps and websites",
"scripts": {
"dev": "next dev --webpack --hostname 0.0.0.0",
"dev:native": "next dev --webpack",
@@ -14,7 +14,8 @@
"pagespeed:test": "npx tsx ./scripts/pagespeed-sitemap.ts",
"typecheck": "tsc --noEmit",
"test:mutation": "stryker run",
- "check:og": "tsx scripts/check-og-images.ts"
+ "check:og": "tsx scripts/check-og-images.ts",
+ "share-image": "node scripts/render-share-image.mjs"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.750.0",
diff --git a/apps/web/public/apple-touch-icon.png b/apps/web/public/apple-touch-icon.png
new file mode 100644
index 0000000..7e2d4fb
Binary files /dev/null and b/apps/web/public/apple-touch-icon.png differ
diff --git a/apps/web/public/fonts/Inter-Bold.woff b/apps/web/public/fonts/Inter-Bold.woff
deleted file mode 100644
index 161b01e..0000000
Binary files a/apps/web/public/fonts/Inter-Bold.woff and /dev/null differ
diff --git a/apps/web/public/fonts/Inter-Regular.woff b/apps/web/public/fonts/Inter-Regular.woff
deleted file mode 100644
index 2f21ed4..0000000
Binary files a/apps/web/public/fonts/Inter-Regular.woff and /dev/null differ
diff --git a/apps/web/public/header.webp b/apps/web/public/header.webp
deleted file mode 100755
index 1980a59..0000000
Binary files a/apps/web/public/header.webp and /dev/null differ
diff --git a/apps/web/public/robots.txt b/apps/web/public/robots.txt
index aa8cbee..8aaf444 100644
--- a/apps/web/public/robots.txt
+++ b/apps/web/public/robots.txt
@@ -1,4 +1,4 @@
User-agent: *
Allow: /
-Sitemap: https://mintel.me/sitemap.xml
\ No newline at end of file
+Sitemap: https://mintel.me/sitemap.xml
diff --git a/apps/web/scripts/render-share-image.mjs b/apps/web/scripts/render-share-image.mjs
new file mode 100644
index 0000000..bf951e0
--- /dev/null
+++ b/apps/web/scripts/render-share-image.mjs
@@ -0,0 +1,54 @@
+// Renders the share image (Open Graph and Twitter) from the real hero, so it always
+// matches the site. Run it after the hero headline or its design changes:
+//
+// pnpm --filter @mintel/web share-image (uses the live site)
+// pnpm --filter @mintel/web share-image http://mintel.localhost/
+//
+// It drives the locally installed Chrome through Playwright and writes
+// app/(site)/opengraph-image.png and app/(site)/twitter-image.png.
+import { copyFileSync } from "node:fs";
+import { chromium } from "playwright";
+
+const url = process.argv[2] ?? "https://mintel.me/";
+const out = "app/(site)/opengraph-image.png";
+const browser = await chromium.launch({ channel: "chrome", headless: true });
+const page = await browser.newPage({
+ viewport: { width: 1200, height: 630 },
+ deviceScaleFactor: 1,
+});
+await page.goto(url, { waitUntil: "load", timeout: 90000 });
+await page.addStyleTag({
+ content: `
+ header, footer, #hero-cta, [class*="fixed"] { display: none !important; }
+ main > div > section:first-of-type > div.relative { min-height: 630px !important; height: 630px !important; padding: 0 72px !important; justify-content: center !important; max-width: none !important; }
+ main > div > section:first-of-type ul { display: none !important; }
+ main > div > section:first-of-type h1 { font-size: 150px !important; }
+ main > div > section:first-of-type p { font-size: 34px !important; max-width: 900px !important; margin-top: 36px !important; }
+ main > div > section:first-of-type .grid { margin-top: 0 !important; display: block !important; }
+ main > div > section:first-of-type h1 span:nth-of-type(2) { color: rgba(15, 23, 42, 0.2) !important; }
+ main > div > section:first-of-type > div.relative { padding-top: 34px !important; }
+ html, body { overflow: hidden !important; }
+`,
+});
+await page.evaluate(() => {
+ const section = document.querySelector("main section");
+ const tag = document.createElement("div");
+ tag.textContent = "MARC MINTEL";
+ tag.style.cssText =
+ "position:absolute;left:72px;top:54px;z-index:20;font:700 15px ui-monospace,Menlo,monospace;letter-spacing:.3em;color:#0f172a";
+ const site = document.createElement("div");
+ site.textContent = "mintel.me";
+ site.style.cssText =
+ "position:absolute;right:72px;top:54px;z-index:20;font:500 15px ui-monospace,Menlo,monospace;letter-spacing:.2em;color:#64748b";
+ section.style.position = "relative";
+ section.append(tag, site);
+ window.scrollTo(0, 0);
+});
+await page.waitForTimeout(4500);
+await page.screenshot({
+ path: out,
+ clip: { x: 0, y: 0, width: 1200, height: 630 },
+});
+await browser.close();
+copyFileSync(out, "app/(site)/twitter-image.png");
+console.log("written:", out, "and app/(site)/twitter-image.png");
diff --git a/apps/web/src/components/OGImageTemplate.tsx b/apps/web/src/components/OGImageTemplate.tsx
deleted file mode 100644
index 3db1300..0000000
--- a/apps/web/src/components/OGImageTemplate.tsx
+++ /dev/null
@@ -1,248 +0,0 @@
-import React from "react";
-
-interface OGImageTemplateProps {
- title: string;
- description?: string;
- label?: string;
- accentColor?: string;
- keyword?: string;
-}
-
-export function OGImageTemplate({
- title,
- description,
- label,
- accentColor,
- keyword,
-}: OGImageTemplateProps) {
- const accent = accentColor || "#3b82f6";
- const slateDark = "#0f172a";
- const slateLight = "#64748b";
-
- const containerStyle: React.CSSProperties = {
- height: "100%",
- width: "100%",
- display: "flex",
- flexDirection: "column",
- alignItems: "flex-start",
- justifyContent: "center",
- backgroundColor: "#ffffff",
- padding: "80px",
- position: "relative",
- fontFamily: "Inter",
- };
-
- return (
-
- {/* Background Grid Pattern */}
-
-
- {/* Accent geometric block (right side) */}
-
-
- {/* Small accent circles */}
-
-
-
-
- {/* Label / Category */}
- {label && (
-
- {label}
-
- )}
-
- {/* Title */}
-
40 ? "56px" : "72px",
- fontWeight: 700,
- color: slateDark,
- lineHeight: "1.1",
- marginBottom: "28px",
- display: "flex",
- letterSpacing: "-0.03em",
- }}
- >
- {title}
-
-
- {/* Description */}
- {description && (
-
- {description.length > 120
- ? description.substring(0, 117) + "..."
- : description}
-
- )}
-
-
- {/* Brand Footer */}
-
-
- {/* Keyword badge (bottom-right) */}
- {keyword && (
-
- )}
-
- {/* Accent Strip */}
-
-
- );
-}
diff --git a/apps/web/src/components/home/HomeJsonLd.tsx b/apps/web/src/components/home/HomeJsonLd.tsx
index bc64413..29e3812 100644
--- a/apps/web/src/components/home/HomeJsonLd.tsx
+++ b/apps/web/src/components/home/HomeJsonLd.tsx
@@ -1,13 +1,37 @@
-import { SITE_NAME, SITE_URL } from "@/src/content/site";
+import {
+ OWNER,
+ SITE_DESCRIPTION,
+ SITE_NAME,
+ SITE_URL,
+} from "@/src/content/site";
const STRUCTURED_DATA = {
"@context": "https://schema.org",
"@type": "ProfessionalService",
name: SITE_NAME,
url: SITE_URL,
- description:
- "Senior freelance developer for web apps, interfaces and websites: weekly capacity for product teams, making unreliable apps dependable, and websites built end to end.",
- founder: { "@type": "Person", name: SITE_NAME },
+ description: SITE_DESCRIPTION,
+ image: `${SITE_URL}/marc-mintel.png`,
+ address: {
+ "@type": "PostalAddress",
+ streetAddress: OWNER.street,
+ postalCode: OWNER.city.split(" ")[0],
+ addressLocality: OWNER.city.split(" ").slice(1).join(" "),
+ addressCountry: "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;
/** Serialized server-side from constants only; "<" is escaped to keep it inert inside a script tag. */
diff --git a/apps/web/src/content/site.ts b/apps/web/src/content/site.ts
index a8b588b..e3bba3f 100644
--- a/apps/web/src/content/site.ts
+++ b/apps/web/src/content/site.ts
@@ -3,6 +3,12 @@ import { parseEmailParts, type EmailParts } from "@/src/domain/email";
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";
+export const SITE_DESCRIPTION =
+ "Freelance developer with 15 years of experience. I build web apps, interfaces and websites, and automate processes between your systems.";
+
// 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).
const parsedEmail = parseEmailParts({
diff --git a/apps/web/src/lib/og-helper.tsx b/apps/web/src/lib/og-helper.tsx
deleted file mode 100644
index cdfb512..0000000
--- a/apps/web/src/lib/og-helper.tsx
+++ /dev/null
@@ -1,76 +0,0 @@
-import { readFileSync } from "fs";
-import { join } from "path";
-
-/**
- * Loads the Inter fonts for use in Satori (Next.js OG Image generation).
- * Since we are using runtime = 'nodejs', we can read them from the filesystem.
- */
-export async function getOgFonts() {
- // Use relative path from process.cwd() which usually is the monorepo root or apps/web
- // We try both to be safe during different execution contexts (dev vs build vs runtime)
- const pathsToTry = [
- join(process.cwd(), "apps/web/public/fonts"),
- join(process.cwd(), "public/fonts"),
- ];
-
- let boldFontPath = "";
- let regularFontPath = "";
-
- for (const basePath of pathsToTry) {
- const b = join(basePath, "Inter-Bold.woff");
- const r = join(basePath, "Inter-Regular.woff");
- try {
- if (readFileSync(b) && readFileSync(r)) {
- boldFontPath = b;
- regularFontPath = r;
- break;
- }
- } catch (_e) {
- continue;
- }
- }
-
- if (!boldFontPath || !regularFontPath) {
- console.error(
- `[OG] Could not find fonts in either ${pathsToTry.join(" or ")}`,
- );
- return [];
- }
-
- try {
- console.log(
- `[OG] Loading fonts: bold=${boldFontPath}, regular=${regularFontPath}`,
- );
- const boldFont = readFileSync(boldFontPath);
- const regularFont = readFileSync(regularFontPath);
- console.log(
- `[OG] Fonts loaded successfully (${boldFont.length} and ${regularFont.length} bytes)`,
- );
-
- return [
- {
- name: "Inter",
- data: boldFont,
- weight: 700 as const,
- style: "normal" as const,
- },
- {
- name: "Inter",
- data: regularFont,
- weight: 400 as const,
- style: "normal" as const,
- },
- ];
- } catch (error) {
- console.error(`[OG] Failed to load fonts:`, error);
- return [];
- }
-}
-
-/**
- * Common configuration for OG images
- */
-export const OG_IMAGE_SIZE = {
- width: 1200,
- height: 630,
-};