feat: share image, metadata and icons that match the new site
The link preview used an old template with a blog description. The share image is now a screenshot of the real hero (scripts/render-share-image.mjs), served as Open Graph and Twitter image. Title and description come from one place and are used by the page, the previews and the structured data. Adds an apple touch icon and removes the old share-image template and its fonts. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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,
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
Web apps and websites. Marc Mintel, freelance developer with 15 years of experience.
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 190 KiB |
@@ -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(
|
||||
<OGImageTemplate
|
||||
title="Marc Mintel"
|
||||
description="Freelance developer. I build web apps, interfaces and websites."
|
||||
label="Web apps and websites"
|
||||
/>,
|
||||
{
|
||||
...OG_IMAGE_SIZE,
|
||||
fonts: fonts as any,
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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: "/" },
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
Web apps and websites. Marc Mintel, freelance developer with 15 years of experience.
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 190 KiB |
@@ -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",
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 4.8 KiB |
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
Before Width: | Height: | Size: 3.5 MiB |
@@ -1,4 +1,4 @@
|
||||
User-agent: *
|
||||
Allow: /
|
||||
|
||||
Sitemap: https://mintel.me/sitemap.xml
|
||||
Sitemap: https://mintel.me/sitemap.xml
|
||||
|
||||
@@ -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");
|
||||
@@ -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 (
|
||||
<div style={containerStyle}>
|
||||
{/* Background Grid Pattern */}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundImage: `linear-gradient(rgba(148,163,184,0.06) 1px, transparent 1px), linear-gradient(90deg, rgba(148,163,184,0.06) 1px, transparent 1px)`,
|
||||
backgroundSize: "40px 40px",
|
||||
display: "flex",
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Accent geometric block (right side) */}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: "60px",
|
||||
right: "80px",
|
||||
width: "200px",
|
||||
height: "200px",
|
||||
borderRadius: "24px",
|
||||
border: `3px solid ${accent}`,
|
||||
opacity: 0.15,
|
||||
display: "flex",
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: "100px",
|
||||
right: "120px",
|
||||
width: "160px",
|
||||
height: "160px",
|
||||
borderRadius: "20px",
|
||||
backgroundColor: accent,
|
||||
opacity: 0.08,
|
||||
display: "flex",
|
||||
}}
|
||||
/>
|
||||
{/* Small accent circles */}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: "80px",
|
||||
right: "100px",
|
||||
width: "16px",
|
||||
height: "16px",
|
||||
borderRadius: "8px",
|
||||
backgroundColor: accent,
|
||||
opacity: 0.5,
|
||||
display: "flex",
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: "240px",
|
||||
right: "90px",
|
||||
width: "10px",
|
||||
height: "10px",
|
||||
borderRadius: "5px",
|
||||
backgroundColor: accent,
|
||||
opacity: 0.3,
|
||||
display: "flex",
|
||||
}}
|
||||
/>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
position: "relative",
|
||||
zIndex: 10,
|
||||
maxWidth: "850px",
|
||||
}}
|
||||
>
|
||||
{/* Label / Category */}
|
||||
{label && (
|
||||
<div
|
||||
style={{
|
||||
fontSize: "18px",
|
||||
fontWeight: 700,
|
||||
color: accent,
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: "0.2em",
|
||||
marginBottom: "24px",
|
||||
display: "flex",
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Title */}
|
||||
<div
|
||||
style={{
|
||||
fontSize: title.length > 40 ? "56px" : "72px",
|
||||
fontWeight: 700,
|
||||
color: slateDark,
|
||||
lineHeight: "1.1",
|
||||
marginBottom: "28px",
|
||||
display: "flex",
|
||||
letterSpacing: "-0.03em",
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
{description && (
|
||||
<div
|
||||
style={{
|
||||
fontSize: "26px",
|
||||
color: slateLight,
|
||||
lineHeight: "1.45",
|
||||
display: "flex",
|
||||
fontWeight: 400,
|
||||
}}
|
||||
>
|
||||
{description.length > 120
|
||||
? description.substring(0, 117) + "..."
|
||||
: description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Brand Footer */}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
bottom: "60px",
|
||||
left: "80px",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: "50px",
|
||||
height: "3px",
|
||||
backgroundColor: slateDark,
|
||||
borderRadius: "2px",
|
||||
marginRight: "16px",
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
fontSize: "20px",
|
||||
fontWeight: 700,
|
||||
color: slateDark,
|
||||
textTransform: "lowercase",
|
||||
letterSpacing: "0.05em",
|
||||
display: "flex",
|
||||
}}
|
||||
>
|
||||
mintel.me
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Keyword badge (bottom-right) */}
|
||||
{keyword && (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
bottom: "60px",
|
||||
right: "80px",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "8px",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: "8px",
|
||||
height: "8px",
|
||||
borderRadius: "4px",
|
||||
backgroundColor: accent,
|
||||
display: "flex",
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
fontSize: "14px",
|
||||
fontWeight: 700,
|
||||
color: "#94a3b8",
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: "0.15em",
|
||||
fontFamily: "ui-monospace, monospace",
|
||||
display: "flex",
|
||||
}}
|
||||
>
|
||||
{keyword}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Accent Strip */}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: "100%",
|
||||
height: "5px",
|
||||
backgroundColor: accent,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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. */
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
Reference in New Issue
Block a user