Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
123ab8e354 |
@@ -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
|
- 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
|
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`).
|
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`.
|
- 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.
|
Never write the full address into markup, metadata or copy.
|
||||||
- The hero design (binary-digit headline) is approved: do not change its design.
|
- The hero design (binary-digit headline) is approved: do not change its design.
|
||||||
|
|||||||
@@ -4,6 +4,12 @@ import { Analytics } from "@/src/components/Analytics";
|
|||||||
import { Footer } from "@/src/components/Footer";
|
import { Footer } from "@/src/components/Footer";
|
||||||
import { Header } from "@/src/components/Header";
|
import { Header } from "@/src/components/Header";
|
||||||
import { InteractiveElements } from "@/src/components/InteractiveElements";
|
import { InteractiveElements } from "@/src/components/InteractiveElements";
|
||||||
|
import {
|
||||||
|
SITE_DESCRIPTION,
|
||||||
|
SITE_NAME,
|
||||||
|
SITE_TITLE,
|
||||||
|
SITE_URL,
|
||||||
|
} from "@/src/content/site";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
|
|
||||||
const inter = Inter({ subsets: ["latin"], variable: "--font-inter" });
|
const inter = Inter({ subsets: ["latin"], variable: "--font-inter" });
|
||||||
@@ -20,15 +26,32 @@ const newsreader = Newsreader({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
|
metadataBase: new URL(SITE_URL),
|
||||||
title: {
|
title: {
|
||||||
default: "Marc Mintel — Senior developer for web apps and websites",
|
default: SITE_TITLE,
|
||||||
template: "%s | Marc Mintel",
|
template: `%s | ${SITE_NAME}`,
|
||||||
},
|
},
|
||||||
description:
|
description: SITE_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.",
|
applicationName: SITE_NAME,
|
||||||
metadataBase: new URL("https://mintel.me"),
|
authors: [{ name: SITE_NAME, url: SITE_URL }],
|
||||||
|
creator: SITE_NAME,
|
||||||
icons: {
|
icons: {
|
||||||
icon: "/favicon.svg",
|
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";
|
} from "@/src/components/illustrations/WorkShowcase";
|
||||||
import { Reveal } from "@/src/components/Reveal";
|
import { Reveal } from "@/src/components/Reveal";
|
||||||
import { CAREER, FIT, TECH } from "@/src/content/about";
|
import { CAREER, FIT, TECH } from "@/src/content/about";
|
||||||
|
import { SITE_DESCRIPTION, SITE_TITLE } from "@/src/content/site";
|
||||||
import { AUTOMATION_NOTES } from "@/src/content/automation";
|
import { AUTOMATION_NOTES } from "@/src/content/automation";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Marc Mintel — Senior developer for web apps and websites",
|
title: { absolute: SITE_TITLE },
|
||||||
description:
|
description: SITE_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.",
|
|
||||||
alternates: { canonical: "/" },
|
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",
|
"name": "@mintel/web",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"version": "0.1.0",
|
"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": {
|
"scripts": {
|
||||||
"dev": "next dev --webpack --hostname 0.0.0.0",
|
"dev": "next dev --webpack --hostname 0.0.0.0",
|
||||||
"dev:native": "next dev --webpack",
|
"dev:native": "next dev --webpack",
|
||||||
@@ -14,7 +14,8 @@
|
|||||||
"pagespeed:test": "npx tsx ./scripts/pagespeed-sitemap.ts",
|
"pagespeed:test": "npx tsx ./scripts/pagespeed-sitemap.ts",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"test:mutation": "stryker run",
|
"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": {
|
"dependencies": {
|
||||||
"@aws-sdk/client-s3": "^3.750.0",
|
"@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 |
@@ -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 = {
|
const STRUCTURED_DATA = {
|
||||||
"@context": "https://schema.org",
|
"@context": "https://schema.org",
|
||||||
"@type": "ProfessionalService",
|
"@type": "ProfessionalService",
|
||||||
name: SITE_NAME,
|
name: SITE_NAME,
|
||||||
url: SITE_URL,
|
url: SITE_URL,
|
||||||
description:
|
description: SITE_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.",
|
image: `${SITE_URL}/marc-mintel.png`,
|
||||||
founder: { "@type": "Person", name: SITE_NAME },
|
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;
|
} as const;
|
||||||
|
|
||||||
/** Serialized server-side from constants only; "<" is escaped to keep it inert inside a script tag. */
|
/** 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_NAME = "Marc Mintel";
|
||||||
export const SITE_URL = "https://mintel.me";
|
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
|
// 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).
|
// appear as one string in the page source (see src/components/EmailLink.tsx).
|
||||||
const parsedEmail = parseEmailParts({
|
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