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 */} -
-
-
- mintel.me -
-
- - {/* Keyword badge (bottom-right) */} - {keyword && ( -
-
-
- {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, -};