diff --git a/.env.example b/.env.example index 43f99f84..d7d9fc14 100644 --- a/.env.example +++ b/.env.example @@ -23,14 +23,16 @@ NEXT_PUBLIC_UMAMI_URL= NEXT_PUBLIC_GISCUS_REPO_ID= NEXT_PUBLIC_GISCUS_CATEGORY_ID= -# required for production. sends contact form submissions via a server action (see app/contact/actions.ts). +# required for production. sends contact form submissions via a server action (see app/contact/actions.ts). may be set +# automatically by Vercel's Resend integration. # https://resend.com/api-keys -# currently set automatically by Vercel's Resend integration. # https://vercel.com/integrations/resend RESEND_API_KEY= +# required. the destination email for contact form submissions. +RESEND_TO_EMAIL= # optional, but will throw a warning. send submissions from an approved domain (or subdomain) on the resend account. # defaults to onboarding@resend.dev. -# sender's real email is passed via a Reply-To header, setting this makes zero difference to the user. +# sender's real email is passed via a Reply-To header, so setting this makes zero difference to the user. # https://resend.com/domains RESEND_FROM_EMAIL= diff --git a/app/analytics.tsx b/app/analytics.tsx index f7f7dd74..b9c4e122 100644 --- a/app/analytics.tsx +++ b/app/analytics.tsx @@ -1,11 +1,12 @@ +import { env } from "../lib/env"; import Script from "next/script"; const Analytics = () => { - if (process.env.NEXT_PUBLIC_VERCEL_ENV !== "production") { + if (env.VERCEL_ENV !== "production") { return null; } - if (!process.env.NEXT_PUBLIC_UMAMI_WEBSITE_ID) { + if (!env.NEXT_PUBLIC_UMAMI_WEBSITE_ID) { return null; } @@ -14,8 +15,8 @@ const Analytics = () => { src="/_stream/u/script.js" // see next.config.ts rewrite id="umami-js" strategy="afterInteractive" - data-website-id={process.env.NEXT_PUBLIC_UMAMI_WEBSITE_ID} - data-domains={process.env.NEXT_PUBLIC_VERCEL_PROJECT_PRODUCTION_URL} + data-website-id={env.NEXT_PUBLIC_UMAMI_WEBSITE_ID} + data-domains={env.VERCEL_PROJECT_PRODUCTION_URL} /> ); }; diff --git a/app/api/hits/route.ts b/app/api/hits/route.ts index 8dc7e2b4..12197602 100644 --- a/app/api/hits/route.ts +++ b/app/api/hits/route.ts @@ -1,6 +1,6 @@ import { NextResponse } from "next/server"; import { unstable_cache as cache } from "next/cache"; -import redis from "../../../lib/helpers/redis"; +import redis from "../../../lib/redis"; // cache response from the db const getData = cache( diff --git a/app/contact/action.ts b/app/contact/action.ts index 150a9165..4dd8612a 100644 --- a/app/contact/action.ts +++ b/app/contact/action.ts @@ -1,5 +1,6 @@ "use server"; +import { env } from "../../lib/env"; import { headers } from "next/headers"; import * as v from "valibot"; import { Resend } from "resend"; @@ -49,10 +50,6 @@ export const send = async (state: ContactState, payload: FormData): Promise`, + from: `${data.output.name} <${env.RESEND_FROM_EMAIL || "onboarding@resend.dev"}>`, replyTo: `${data.output.name} <${data.output.email}>`, - to: [config.authorEmail], + to: [env.RESEND_TO_EMAIL], subject: `[${config.siteName}] Contact Form Submission`, text: data.output.message, }); diff --git a/app/contact/form.tsx b/app/contact/form.tsx index fb6bd9e2..22bc1862 100644 --- a/app/contact/form.tsx +++ b/app/contact/form.tsx @@ -1,5 +1,6 @@ "use client"; +import { env } from "../../lib/env"; import { useActionState, useState } from "react"; import TextareaAutosize from "react-textarea-autosize"; import Turnstile from "react-turnstile"; @@ -104,7 +105,7 @@ const ContactForm = () => {
- +
{!pending && formState.errors?.["cf-turnstile-response"] && ( {formState.errors["cf-turnstile-response"][0]} diff --git a/app/layout.tsx b/app/layout.tsx index d6591b64..3ea7547e 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -7,7 +7,7 @@ import { SkipToContentLink, SkipToContentTarget } from "../components/SkipToCont import { setRootCssVariables } from "../lib/helpers/styles"; import * as config from "../lib/config"; import { BASE_URL, MAX_WIDTH } from "../lib/config/constants"; -import defaultMetadata from "../lib/config/metadata"; +import defaultMetadata from "../lib/config/seo"; import type { Metadata } from "next"; import type { Person, WebSite } from "schema-dts"; @@ -41,7 +41,7 @@ const RootLayout = ({ children }: Readonly<{ children: React.ReactNode }>) => { height: `${ogImage.height}`, }, sameAs: [ - BASE_URL, + `${BASE_URL}`, `https://${config.authorSocial?.mastodon}`, `https://github.com/${config.authorSocial?.github}`, `https://bsky.app/profile/${config.authorSocial?.bluesky}`, diff --git a/app/notes/[slug]/counter.tsx b/app/notes/[slug]/counter.tsx index c3d61cdf..9641084e 100644 --- a/app/notes/[slug]/counter.tsx +++ b/app/notes/[slug]/counter.tsx @@ -1,6 +1,6 @@ import { connection } from "next/server"; import CountUp from "../../../components/CountUp"; -import redis from "../../../lib/helpers/redis"; +import redis from "../../../lib/redis"; import { siteLocale } from "../../../lib/config"; const HitCounter = async ({ slug }: { slug: string }) => { diff --git a/app/notes/[slug]/page.tsx b/app/notes/[slug]/page.tsx index 316b3eb8..9ac929c3 100644 --- a/app/notes/[slug]/page.tsx +++ b/app/notes/[slug]/page.tsx @@ -1,3 +1,4 @@ +import { env } from "../../../lib/env"; import { Suspense } from "react"; import { JsonLd } from "react-schemaorg"; import { CalendarIcon, TagIcon, SquarePenIcon, EyeIcon } from "lucide-react"; @@ -20,7 +21,6 @@ import styles from "./page.module.css"; export const dynamicParams = false; // https://nextjs.org/docs/app/building-your-application/rendering/partial-prerendering#using-partial-prerendering -// eslint-disable-next-line camelcase export const experimental_ppr = true; export const generateStaticParams = async () => { @@ -122,7 +122,7 @@ const Page = async ({ params }: { params: Promise<{ slug: string }> }) => { {/* only count hits on production site */} - {process.env.NEXT_PUBLIC_VERCEL_ENV !== "development" && process.env.NODE_ENV !== "development" ? ( + {env.VERCEL_ENV === "production" ? (
{ // don't fail the entire site build if the required API key for this page is missing - if (!process.env.GITHUB_TOKEN) { + if (!env.GITHUB_TOKEN) { console.warn(`ERROR: I can't fetch any GitHub projects without "GITHUB_TOKEN" set! Disabling projects page.`); // just return a 404 since this page would be blank anyways @@ -64,7 +65,7 @@ const getRepos = async () => { limit: 12, headers: { accept: "application/vnd.github.v3+json", - authorization: `token ${process.env.GITHUB_TOKEN}`, + authorization: `token ${env.GITHUB_TOKEN}`, }, request: { // override fetch() to use next's extension to cache the response diff --git a/app/sitemap.ts b/app/sitemap.ts index bf17771c..f30bd822 100644 --- a/app/sitemap.ts +++ b/app/sitemap.ts @@ -1,7 +1,7 @@ import path from "path"; import glob from "fast-glob"; import { getFrontMatter } from "../lib/helpers/posts"; -import { BASE_URL } from "../lib/config/constants"; +import { BASE_URL, RELEASE_TIMESTAMP } from "../lib/config/constants"; import type { MetadataRoute } from "next"; const sitemap = async (): Promise => { @@ -9,9 +9,9 @@ const sitemap = async (): Promise => { const routes: MetadataRoute.Sitemap = [ { // homepage - url: BASE_URL, + url: `${BASE_URL}`, priority: 1.0, - lastModified: new Date(process.env.RELEASE_DATE || Date.now()), // timestamp frozen when a new build is deployed + lastModified: new Date(RELEASE_TIMESTAMP), // timestamp frozen when a new build is deployed }, ]; diff --git a/components/Comments/Comments.tsx b/components/Comments/Comments.tsx index 0267cb6a..7b67e95a 100644 --- a/components/Comments/Comments.tsx +++ b/components/Comments/Comments.tsx @@ -1,5 +1,6 @@ "use client"; +import { env } from "../../lib/env"; import Giscus from "@giscus/react"; import * as config from "../../lib/config"; import type { GiscusProps } from "@giscus/react"; @@ -10,7 +11,7 @@ export type CommentsProps = { const Comments = ({ title }: CommentsProps) => { // fail silently if giscus isn't configured - if (!process.env.NEXT_PUBLIC_GISCUS_REPO_ID || !process.env.NEXT_PUBLIC_GISCUS_CATEGORY_ID) { + if (!env.NEXT_PUBLIC_GISCUS_REPO_ID || !env.NEXT_PUBLIC_GISCUS_CATEGORY_ID) { console.warn( "[giscus] not configured, ensure 'NEXT_PUBLIC_GISCUS_REPO_ID' and 'NEXT_PUBLIC_GISCUS_CATEGORY_ID' environment variables are set." ); @@ -21,10 +22,10 @@ const Comments = ({ title }: CommentsProps) => { return ( { {config.copyrightYearStart} {" "} - – {new Date(process.env.RELEASE_DATE || Date.now()).getUTCFullYear()}. + – {new Date(RELEASE_TIMESTAMP).getUTCFullYear()}.
diff --git a/contexts/ThemeContext.tsx b/contexts/ThemeContext.tsx index e0198958..7f882d22 100644 --- a/contexts/ThemeContext.tsx +++ b/contexts/ThemeContext.tsx @@ -65,8 +65,3 @@ export const ThemeScript = () => ( }} /> ); - -// debugging help pls -if (process.env.NODE_ENV !== "production") { - ThemeContext.displayName = "ThemeContext"; -} diff --git a/eslint.config.mjs b/eslint.config.mjs index e39faecf..6aab845f 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -26,7 +26,7 @@ export default [ camelcase: [ "error", { - allow: ["^unstable_"], + allow: ["^experimental_", "^unstable_"], }, ], "prettier/prettier": [ diff --git a/lib/config/constants.ts b/lib/config/constants.ts index a4916fb5..65793fb1 100644 --- a/lib/config/constants.ts +++ b/lib/config/constants.ts @@ -8,10 +8,6 @@ export const AVATAR_PATH = "app/avatar.jpg"; // maximum width of content wrapper (e.g. for images) in pixels export const MAX_WIDTH = 865; -// same logic as metadataBase: https://nextjs.org/docs/app/api-reference/functions/generate-metadata#default-value -export const BASE_URL = - process.env.NEXT_PUBLIC_VERCEL_ENV === "production" && process.env.NEXT_PUBLIC_VERCEL_PROJECT_PRODUCTION_URL - ? `https://${process.env.NEXT_PUBLIC_VERCEL_PROJECT_PRODUCTION_URL}` - : process.env.NEXT_PUBLIC_VERCEL_ENV === "preview" && process.env.NEXT_PUBLIC_VERCEL_URL - ? `https://${process.env.NEXT_PUBLIC_VERCEL_URL}` - : `http://localhost:${process.env.PORT || 3000}`; +// defined in next.config.ts +export const BASE_URL = process.env.NEXT_PUBLIC_BASE_URL!; +export const RELEASE_TIMESTAMP = process.env.NEXT_PUBLIC_RELEASE_TIMESTAMP!; diff --git a/lib/config/metadata.ts b/lib/config/seo.ts similarity index 93% rename from lib/config/metadata.ts rename to lib/config/seo.ts index bfa9194d..19039171 100644 --- a/lib/config/metadata.ts +++ b/lib/config/seo.ts @@ -2,7 +2,7 @@ import * as config from "."; import { BASE_URL } from "./constants"; import type { Metadata } from "next"; -const metadata: Metadata = { +const defaultMetadata: Metadata = { metadataBase: new URL(BASE_URL), title: { template: `%s – ${config.siteName}`, @@ -44,4 +44,4 @@ const metadata: Metadata = { }, }; -export default metadata; +export default defaultMetadata; diff --git a/lib/env.ts b/lib/env.ts new file mode 100644 index 00000000..b14ec9fb --- /dev/null +++ b/lib/env.ts @@ -0,0 +1,34 @@ +import { createEnv } from "@t3-oss/env-nextjs"; +import { vercel } from "@t3-oss/env-nextjs/presets-valibot"; +import * as v from "valibot"; + +export const env = createEnv({ + extends: [vercel()], + server: { + GITHUB_TOKEN: v.optional(v.pipe(v.string(), v.startsWith("ghp_"))), + KV_REST_API_TOKEN: v.string(), + KV_REST_API_URL: v.pipe(v.string(), v.url(), v.startsWith("https://"), v.endsWith(".upstash.io")), + RESEND_API_KEY: v.pipe(v.string(), v.startsWith("re_")), + RESEND_FROM_EMAIL: v.optional(v.pipe(v.string(), v.email())), + RESEND_TO_EMAIL: v.pipe(v.string(), v.email()), + TURNSTILE_SECRET_KEY: v.optional(v.string()), + }, + client: { + NEXT_PUBLIC_GISCUS_CATEGORY_ID: v.optional(v.string()), + NEXT_PUBLIC_GISCUS_REPO_ID: v.optional(v.string()), + NEXT_PUBLIC_ONION_DOMAIN: v.optional(v.pipe(v.string(), v.endsWith(".onion"))), + NEXT_PUBLIC_TURNSTILE_SITE_KEY: v.optional(v.string()), + NEXT_PUBLIC_UMAMI_URL: v.optional(v.pipe(v.string(), v.url())), + NEXT_PUBLIC_UMAMI_WEBSITE_ID: v.optional(v.string()), + }, + experimental__runtimeEnv: { + NEXT_PUBLIC_GISCUS_CATEGORY_ID: process.env.NEXT_PUBLIC_GISCUS_CATEGORY_ID, + NEXT_PUBLIC_GISCUS_REPO_ID: process.env.NEXT_PUBLIC_GISCUS_REPO_ID, + NEXT_PUBLIC_ONION_DOMAIN: process.env.NEXT_PUBLIC_ONION_DOMAIN, + NEXT_PUBLIC_TURNSTILE_SITE_KEY: process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY, + NEXT_PUBLIC_UMAMI_URL: process.env.NEXT_PUBLIC_UMAMI_URL, + NEXT_PUBLIC_UMAMI_WEBSITE_ID: process.env.NEXT_PUBLIC_UMAMI_WEBSITE_ID, + }, + emptyStringAsUndefined: true, + skipValidation: !!process.env.SKIP_ENV_VALIDATION, +}); diff --git a/lib/helpers/build-feed.ts b/lib/helpers/build-feed.ts index aa5af46e..94718402 100644 --- a/lib/helpers/build-feed.ts +++ b/lib/helpers/build-feed.ts @@ -1,7 +1,7 @@ import { Feed } from "feed"; import { getFrontMatter, getContent } from "./posts"; import * as config from "../config"; -import { BASE_URL } from "../config/constants"; +import { BASE_URL, RELEASE_TIMESTAMP } from "../config/constants"; import type { Item as FeedItem } from "feed"; import ogImage from "../../app/opengraph-image.jpg"; @@ -12,12 +12,12 @@ import ogImage from "../../app/opengraph-image.jpg"; */ export const buildFeed = async (): Promise => { const feed = new Feed({ - id: BASE_URL, - link: BASE_URL, + id: `${BASE_URL}`, + link: `${BASE_URL}`, title: config.siteName, description: config.longDescription, copyright: config.licenseUrl, - updated: new Date(process.env.RELEASE_DATE || Date.now()), + updated: new Date(RELEASE_TIMESTAMP), image: `${BASE_URL}${ogImage.src}`, feedLinks: { rss: `${BASE_URL}/feed.xml`, @@ -41,7 +41,7 @@ export const buildFeed = async (): Promise => { author: [ { name: config.authorName, - link: BASE_URL, + link: `${BASE_URL}`, }, ], date: new Date(post.date), diff --git a/lib/helpers/metadata.ts b/lib/helpers/metadata.ts index de7b85b4..ff2e5166 100644 --- a/lib/helpers/metadata.ts +++ b/lib/helpers/metadata.ts @@ -1,4 +1,4 @@ -import defaultMetadata from "../config/metadata"; +import defaultMetadata from "../config/seo"; import type { Metadata } from "next"; /** diff --git a/lib/helpers/redis.ts b/lib/redis.ts similarity index 100% rename from lib/helpers/redis.ts rename to lib/redis.ts diff --git a/next.config.ts b/next.config.ts index 2a5cc85d..6cdbaaa9 100644 --- a/next.config.ts +++ b/next.config.ts @@ -5,13 +5,27 @@ import { visit } from "unist-util-visit"; import * as mdxPlugins from "./lib/helpers/remark-rehype-plugins"; import type { NextConfig } from "next"; +// check environment variables at build time +// https://env.t3.gg/docs/nextjs#validate-schema-on-build-(recommended) +import "./lib/env"; + const nextConfig: NextConfig = { reactStrictMode: true, productionBrowserSourceMaps: true, env: { + // same logic as metadataBase: https://nextjs.org/docs/app/api-reference/functions/generate-metadata#default-value + NEXT_PUBLIC_BASE_URL: + process.env.VERCEL_ENV === "production" && process.env.VERCEL_PROJECT_PRODUCTION_URL + ? `https://${process.env.VERCEL_PROJECT_PRODUCTION_URL}` + : process.env.VERCEL_ENV === "preview" && process.env.VERCEL_BRANCH_URL + ? `https://${process.env.VERCEL_BRANCH_URL}` + : process.env.VERCEL_URL + ? `https://${process.env.VERCEL_URL}` + : `http://localhost:${process.env.PORT || 3000}`, + // freeze timestamp at build time for when server-side pages need a "last updated" date. calling Date.now() from // pages using getServerSideProps will return the current(ish) time instead, which is usually not what we want. - RELEASE_DATE: new Date().toISOString(), + NEXT_PUBLIC_RELEASE_TIMESTAMP: new Date().toISOString(), }, pageExtensions: ["js", "jsx", "ts", "tsx", "md", "mdx"], outputFileTracingIncludes: { @@ -25,6 +39,7 @@ const nextConfig: NextConfig = { outputFileTracingExcludes: { "*": ["./public/**/*", "**/*.mp4", "**/*.webm", "**/*.vtt"], }, + transpilePackages: ["@t3-oss/env-nextjs", "@t3-oss/env-core"], webpack: (config) => { config.module.rules.push({ test: /\.(mp4|webm|vtt)$/i, diff --git a/package.json b/package.json index 869ee571..c70df3a6 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "@next/mdx": "15.3.0-canary.45", "@octokit/graphql": "^8.2.1", "@octokit/graphql-schema": "^15.26.0", + "@t3-oss/env-nextjs": "^0.12.0", "@upstash/redis": "^1.34.7", "clsx": "^2.1.1", "copy-to-clipboard": "^3.3.3", @@ -74,7 +75,7 @@ "@types/node": "^22.14.0", "@types/prop-types": "^15.7.14", "@types/react": "^19.1.0", - "@types/react-dom": "^19.1.1", + "@types/react-dom": "^19.1.2", "@types/react-is": "^19.0.0", "babel-plugin-react-compiler": "19.0.0-beta-e993439-20250405", "cross-env": "^7.0.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 48ea31c2..eeb04524 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -38,6 +38,9 @@ importers: '@octokit/graphql-schema': specifier: ^15.26.0 version: 15.26.0 + '@t3-oss/env-nextjs': + specifier: ^0.12.0 + version: 0.12.0(typescript@5.8.3)(valibot@1.0.0(typescript@5.8.3))(zod@3.24.2) '@upstash/redis': specifier: ^1.34.7 version: 1.34.7 @@ -175,8 +178,8 @@ importers: specifier: ^19.1.0 version: 19.1.0 '@types/react-dom': - specifier: ^19.1.1 - version: 19.1.1(@types/react@19.1.0) + specifier: ^19.1.2 + version: 19.1.2(@types/react@19.1.0) '@types/react-is': specifier: ^19.0.0 version: 19.0.0 @@ -832,6 +835,34 @@ packages: '@swc/helpers@0.5.15': resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==} + '@t3-oss/env-core@0.12.0': + resolution: {integrity: sha512-lOPj8d9nJJTt81mMuN9GMk8x5veOt7q9m11OSnCBJhwp1QrL/qR+M8Y467ULBSm9SunosryWNbmQQbgoiMgcdw==} + peerDependencies: + typescript: '>=5.0.0' + valibot: ^1.0.0-beta.7 || ^1.0.0 + zod: ^3.24.0 + peerDependenciesMeta: + typescript: + optional: true + valibot: + optional: true + zod: + optional: true + + '@t3-oss/env-nextjs@0.12.0': + resolution: {integrity: sha512-rFnvYk1049RnNVUPvY8iQ55AuQh1Rr+qZzQBh3t++RttCGK4COpXGNxS4+45afuQq02lu+QAOy/5955aU8hRKw==} + peerDependencies: + typescript: '>=5.0.0' + valibot: ^1.0.0-beta.7 || ^1.0.0 + zod: ^3.24.0 + peerDependenciesMeta: + typescript: + optional: true + valibot: + optional: true + zod: + optional: true + '@tybys/wasm-util@0.9.0': resolution: {integrity: sha512-6+7nlbMVX/PVDCwaIQ8nTOPveOcFLSt8GcXdx8hD0bt39uWxYT88uXzqTd4fTvqta7oeUJqudepapKNt2DYJFw==} @@ -877,8 +908,8 @@ packages: '@types/prop-types@15.7.14': resolution: {integrity: sha512-gNMvNH49DJ7OJYv+KAKn0Xp45p8PLl6zo2YnvDIbTd4J6MER2BmWN49TG7n9LvkyihINxeKW8+3bfS2yDC9dzQ==} - '@types/react-dom@19.1.1': - resolution: {integrity: sha512-jFf/woGTVTjUJsl2O7hcopJ1r0upqoq/vIOoCj0yLh3RIXxWcljlpuZ+vEBRXsymD1jhfeJrlyTy/S1UW+4y1w==} + '@types/react-dom@19.1.2': + resolution: {integrity: sha512-XGJkWF41Qq305SKWEILa1O8vzhb3aOo3ogBlSmiqNko/WmRb6QIaweuZCXjKygVDXpzXb5wyxKTSOsmkuqj+Qw==} peerDependencies: '@types/react': ^19.0.0 @@ -4350,6 +4381,20 @@ snapshots: dependencies: tslib: 2.8.1 + '@t3-oss/env-core@0.12.0(typescript@5.8.3)(valibot@1.0.0(typescript@5.8.3))(zod@3.24.2)': + optionalDependencies: + typescript: 5.8.3 + valibot: 1.0.0(typescript@5.8.3) + zod: 3.24.2 + + '@t3-oss/env-nextjs@0.12.0(typescript@5.8.3)(valibot@1.0.0(typescript@5.8.3))(zod@3.24.2)': + dependencies: + '@t3-oss/env-core': 0.12.0(typescript@5.8.3)(valibot@1.0.0(typescript@5.8.3))(zod@3.24.2) + optionalDependencies: + typescript: 5.8.3 + valibot: 1.0.0(typescript@5.8.3) + zod: 3.24.2 + '@tybys/wasm-util@0.9.0': dependencies: tslib: 2.8.1 @@ -4397,7 +4442,7 @@ snapshots: '@types/prop-types@15.7.14': {} - '@types/react-dom@19.1.1(@types/react@19.1.0)': + '@types/react-dom@19.1.2(@types/react@19.1.0)': dependencies: '@types/react': 19.1.0