diff --git a/.env.example b/.env.example index 2e5758e..648bb17 100644 --- a/.env.example +++ b/.env.example @@ -1,10 +1,11 @@ -# Optional for the public site. Copy this file to .env.local when needed. +# Optional for the public site. Copy this file to .env at the repository root. +# All workspace commands load root .env files; process variables take precedence. # Neon PostgreSQL connection string; leave blank until database features are added. # Example: postgresql://USER:PASSWORD@HOST/DATABASE?sslmode=require DATABASE_URL= -# Better Auth is installed for future use; no auth routes are configured yet. +# Shared auth factories are ready for future use; no auth routes are mounted. # Generate a secret with: openssl rand -base64 32 BETTER_AUTH_SECRET= BETTER_AUTH_URL=http://localhost:3000 @@ -14,5 +15,5 @@ BETTER_AUTH_URL=http://localhost:3000 RESEND_API_KEY= RESEND_FROM_EMAIL= -# reccomended as 5000 reqs can be sent per hour +# Optional GitHub token for a higher API rate limit. GITHUB_TOKEN= diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 614a775..2151fed 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,4 +1,4 @@ -name: Next.js checks +name: Workspace checks on: push: @@ -21,4 +21,7 @@ jobs: - run: pnpm install --frozen-lockfile - run: pnpm typecheck - run: pnpm lint + - run: pnpm test - run: pnpm build + - run: node scripts/prepare-vercel.mjs + - run: node scripts/verify-vercel.mjs diff --git a/.gitignore b/.gitignore index 2435e48..409dc3c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,19 +1,18 @@ # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. # dependencies -/node_modules/ +node_modules/ /.pnp .pnp.js # testing -/coverage +coverage/ -# next.js -/.next/ -/out/ +# Astro +.astro/ # production -/build +build/ # misc .DS_Store @@ -30,15 +29,23 @@ yarn-error.log* !.env.example # React Email preview and exported templates -/.react-email/ -/email-out/ +.react-email/ +email-out/ # vercel .vercel # typescript *.tsbuildinfo -/next-env.d.ts /.yarn /.yarnrc.yml + +# monorepo task and package outputs +.turbo/ +dist/ +.cache/ + +# Legacy local Next build artifacts +.next/ +next-env.d.ts diff --git a/.prettierignore b/.prettierignore index c7a34bd..2d98082 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,3 +1,11 @@ -build -node_modules -.next +**/node_modules +**/.next +**/.turbo +**/.react-email +**/build +**/dist +**/next-env.d.ts +**/*.tsbuildinfo +pnpm-lock.yaml +**/.astro +**/.vercel diff --git a/.prettierrc b/.prettierrc deleted file mode 100644 index 1d9fbef..0000000 --- a/.prettierrc +++ /dev/null @@ -1,10 +0,0 @@ -{ - "tabWidth": 2, - "useTabs": false, - "semi": true, - "singleQuote": true, - "jsxSingleQuote": true, - "bracketSpacing": true, - "bracketSameLine": false, - "arrowParens": "always" -} diff --git a/README.md b/README.md index 6ebf10f..8d94f03 100644 --- a/README.md +++ b/README.md @@ -1,90 +1,110 @@ # Manipal OSF website -The official Manipal OSF website is a single Next.js application at the repository root. It uses TypeScript, React, Tailwind CSS, and the App Router. Blog posts live in the repository as Markdown files. +The Manipal OSF website lives in a pnpm and Turborepo monorepo modeled on [create-t3-turbo](https://github.com/t3-oss/create-t3-turbo). The public website uses Astro components and TypeScript browser scripts, with Vercel ISR for live GitHub data. ## Development -Use Node.js 24 LTS (minimum 22) and PNPM 11.2.2. Run commands from the repository root: +Use Node.js 24 LTS (minimum 22.22 for Astro) and pnpm 11.2.2. Run commands from the repository root: ```sh pnpm install pnpm dev ``` -Open http://localhost:3000. No environment variables or separate service are required for the existing site; an optional `GITHUB_TOKEN` raises the GitHub API limit for the home page statistics. - -Development and production builds use Turbopack. +Open http://localhost:4321. The public website needs no service credentials. An optional `GITHUB_TOKEN` increases the GitHub API rate limit for statistics. ```sh pnpm typecheck pnpm lint +pnpm test +pnpm build # Vercel deployment output +pnpm build:local # Node production build +pnpm start # Serve the Node build on port 4321 pnpm format ``` -CI runs the production build with `pnpm build`. A deployed Node.js application starts with `pnpm start`. Configure hosting to use the repository root, with `pnpm install --frozen-lockfile` as the install command and `pnpm build` as the build command. +`dev` starts workspaces under `apps/`. `build`, `lint`, `typecheck`, and `test` use Turbo's dependency graph and cache. Development servers and email previews are uncached. Turbo caches `dist/`, `.astro/`, and `.vercel/output/` build artifacts. Environment changes invalidate task caches. -See [website configuration](./WEBSITE_CONFIG.md) for the Markdown format, images, and publishing instructions. The blog initially displays “No posts yet.” Add your own content to `content/posts/`; existing hosted content is not copied automatically. +To target the website directly: -The `@next/mdx` component registry lives in `mdx-components.tsx` at the repository root, beside `app/`, as required by Next.js. Keep it there when adding MDX components. The filesystem blog continues to render Markdown using `MarkdownRenderer`. +```sh +pnpm --filter @apps/astro dev +pnpm build --filter=@apps/astro... +``` -## UI components +## Workspace structure -shadcn/ui is configured with the Base UI Nova style, TypeScript, React Server Components, Lucide icons, and the `@/*` import alias pointing to the repository root. Its CSS variables extend the existing Nord light and Catppuccin dark themes, using the existing `data-theme` switch. +| Location | Package | Responsibility | +| ---------------- | ----------------- | ------------------------------------------------------------- | +| `apps/astro` | `@apps/astro` | Astro website, content, public assets, and site-specific code | +| `packages/env` | `@packages/env` | T3 Env validation and root environment loading | +| `packages/db` | `@packages/db` | Lazy Neon HTTP / Drizzle database clients | +| `packages/auth` | `@packages/auth` | Configurable Better Auth factory | +| `packages/ui` | `@packages/ui` | Astro avatar/badge components and class helpers | +| `packages/email` | `@packages/email` | React Email templates and local preview server | +| `tooling/*` | `@tooling/*` | ESLint, Prettier, TypeScript, and Tailwind configuration | -Import the initial Button from `@/components/ui/button` and shared class helpers from `@/lib/utils`. Add more components from the repository root: +Workspace imports use real package exports and `workspace:*` dependencies. The website's `@/*` alias refers to its `src/` directory. Shared dependencies are versioned centrally in the pnpm catalog. -```sh -pnpm exec shadcn add input -``` - -`components.json` points to `styles/globals.css`; Tailwind v4 does not need a separate Tailwind configuration file. Keep the existing palettes and `data-theme` convention when adding theme tokens. +See [website configuration](./WEBSITE_CONFIG.md) for posts, authors, images, and publishing. Content lives under `apps/astro/content/`. Astro collections validate Markdown posts and authors, and pages live under `apps/astro/src/pages/`. Public URLs continue to use post frontmatter IDs and author filenames. ## Environment variables -The public site runs without service credentials. When preparing backend features, copy the example file: +Keep environment files at the repository root: ```sh -cp .env.example .env.local +cp .env.example .env ``` -`lib/env.ts` uses T3 Env and Zod to validate settings during development and builds. Service settings are optional, and blank values are treated as unset. Values supplied must be valid. Import `env` from `@/lib/env` in server code instead of reading `process.env` directly. All settings below are server variables; do not expose credentials using `NEXT_PUBLIC_`. +`with-env` loads root environment files before running Turbo or an application command. It uses `dotenv` and `dotenv-expand`, resolving the root from the loader's location rather than the current directory. Existing process variables win, followed by `.env..local`, `.env.local`, `.env.`, and `.env`. Test mode skips `.env.local`. Vercel supplies process variables through project settings and needs no committed environment file. + +Import `env` from `@packages/env` in server code. T3 Env and Zod validate supplied values, treat empty strings as unset, and prevent access to server variables in browser code. The filesystem loader is separate at `@packages/env/load`; do not import it into application runtime code. -| Variable | Purpose | -| -------------------- | -------------------------------------------------------------------------------------- | -| `DATABASE_URL` | Neon PostgreSQL connection string (`postgres://` or `postgresql://`). | -| `BETTER_AUTH_SECRET` | Future auth secret of at least 32 characters; generate with `openssl rand -base64 32`. | -| `BETTER_AUTH_URL` | Future auth server origin, such as `http://localhost:3000`. | -| `RESEND_API_KEY` | API key required when calling `getResend()`. | -| `RESEND_FROM_EMAIL` | Plain sender email address on a Resend-verified domain, for future sending features. | +| Variable | Purpose | +| -------------------- | --------------------------------------------------------------- | +| `DATABASE_URL` | Optional Neon PostgreSQL URL (`postgres://` or `postgresql://`) | +| `BETTER_AUTH_SECRET` | Optional auth secret, at least 32 characters | +| `BETTER_AUTH_URL` | Optional auth origin for a future app instance | +| `RESEND_API_KEY` | Required when calling the website's `getResend()` | +| `RESEND_FROM_EMAIL` | Optional sender address on a verified Resend domain | +| `GITHUB_TOKEN` | Optional higher GitHub API rate limit | -Real `.env*` files are ignored by Git; `.env.example` contains no credentials and is tracked. +All current variables are server-side. Keep `.env*` ignored except the credential-free example. When adding variables, update the shared validation schema, example file, and Turbo environment inputs together. Future apps can compose their own validation from this package as their requirements diverge. ## Database and authentication -Drizzle ORM, Drizzle Kit, the Neon serverless driver, Better Auth, and its Drizzle adapter are installed for later use. Database clients, application/auth schemas, migrations, auth routes, and login pages are deferred. The blog still uses repository Markdown files. +`@packages/db` exports `createDb(url, schema?)` and `getDb()`. Constructing a client does not execute a query. `getDb()` lazily creates a shared schema-less client from `env.DATABASE_URL`, with a clear error when it is unset. Use `createDb` when supplying a typed schema or a different database URL. These APIs are server-only. Neon HTTP supports individual queries and non-interactive batches; interactive transactions require a different driver. -When implementing these features, configure a Neon PostgreSQL database with the validated `DATABASE_URL`, use `drizzle-orm/neon-http` for HTTP queries (or `drizzle-orm/neon-serverless` when interactive transactions are needed), and connect Better Auth through `@better-auth/drizzle-adapter` with PostgreSQL provider `pg`. Generate the auth schema from the final auth configuration before creating Drizzle migrations. Add the auth Route Handler under `app/api/auth/[...all]/route.ts` and keep client auth code separate from server credentials. +`@packages/auth` exports `createAuth({ db, schema, options })`. Supply an explicit Drizzle auth schema and app-specific `secret`, `baseURL`, and `trustedOrigins`; `options` also accepts Better Auth providers, plugins, and session configuration. The factory preserves plugin and additional-field inference, uses the PostgreSQL Drizzle adapter, and disables interactive transactions. It does not establish a global auth singleton or shared cookie domain. -Drizzle Kit runs outside the Next.js runtime. Before adding its configuration, use `@next/env` to load the root `.env*` files so CLI commands follow the same environment loading rules as Next.js. +The website does not instantiate auth or mount auth endpoints. Auth tables, login methods, schema generation, migrations, and login pages remain deferred. When implementing them, generate the schema from the final auth configuration, keep tables and migrations in `packages/db`, and mount the framework-specific handler inside the consuming app. Set each app's URL and trusted origins explicitly. Drizzle Kit is owned by the database package; migration commands will be added with the actual schema. -## Email templates +## Shared UI and Tailwind + +Import Astro components from `@packages/ui/components/avatar` and `@packages/ui/components/badge`, and `cn` from `@packages/ui/lib/utils`. The unused button and extra avatar components have been removed. There is no React/shadcn component-generation command. + +`@tooling/tailwind/styles.css` provides Tailwind v4, animation imports, theme tokens, the `data-theme` dark variant, common base styles, and shared UI source registration. Applications import this CSS and register their own source directory with `@source`. The website retains its Nord light and Catppuccin dark palettes and self-hosted Cascadia Code font. -Resend and React Email are ready for future email features. Components and rendering utilities use the current `react-email` package; `@react-email/ui` provides local previews. +The website ships Astro HTML with small browser modules for theme switching, map controls, reading progress, and timeline effects. MapLibre is loaded near the viewport, with campus links available when the map cannot load. React remains in the email package for React Email templates. + +## Email templates ```sh pnpm email:dev ``` -Open http://localhost:3001 to preview `emails/example.tsx`. Previews need no API key and send no email. +Open http://localhost:3001 to preview `packages/email/templates/example.tsx`. Previews need no API key and send no email. Apps can import the template from `@packages/email/example`. + +The website retains its lazy `getResend()` helper at `@/lib/resend`. There are no sending endpoints or automatic email triggers. -Server code can import `getResend` from `@/lib/resend` to obtain a lazily initialized Resend client. It throws a clear error if `RESEND_API_KEY` is unset when called. When adding a sending feature, pass a verified sender, recipient, subject, and React Email template to `getResend().emails.send(...)`, and handle the returned `error`. There are no sending endpoints or automatic email triggers yet. +## Vercel and additional apps -## Server features +The root `vercel.json` deploys `@apps/astro` using a filtered Turbo build and the Vercel adapter's Build Output API artifacts. The deployment command copies `apps/astro/.vercel/output` into the root `.vercel/output` expected by Vercel, including functions, hourly ISR configuration, redirects, and PNG content types. Keep the Vercel project's Root Directory at the repository root, select Node.js 24, and enable Corepack (`ENABLE_EXPERIMENTAL_COREPACK=1`) to use the pinned pnpm version. Remove conflicting dashboard command overrides. No root framework dependency is needed. -Add server endpoints as Route Handlers in `app/**/route.ts`, using the Web `Request` and `Response` APIs or Next.js `NextRequest` and `NextResponse`. Keep private environment variables server-side and put reusable server logic in `lib/`. Async Server Components, post metadata, and `generateStaticParams` call the server-only filesystem blog loader directly; it does not make HTTP calls. +Content pages, feeds, metadata, and social preview PNGs are prerendered. Home and team pages render on demand and use a 3,600-second ISR expiration; after expiry, a request triggers regeneration. Failed GitHub loads return an uncached 503 page with offline messaging or the authored timeline so failed regeneration does not replace successful cached content. Actual Vercel cache hits and regeneration must be verified on a deployment; local Node runs do not emulate the platform cache. -Use a deployment that supports the Next.js Node.js runtime when adding Route Handlers or request-time server rendering. +For a local production server, run `pnpm build:local`, then `pnpm start`. The local build explicitly selects `@astrojs/node`; the default `pnpm build` selects `@astrojs/vercel`. You can override the local server's interface and port using `HOST` and `PORT`. -## Contributing +Add future applications under `apps/`, name them `@apps/`, declare shared packages using `workspace:*`, and extend shared tooling. Each future app should have its own Vercel project, environment values, and app-specific deployment configuration. The root config remains specific to the current website; it does not deploy every app. For a project rooted in a new app directory, enable access to files outside that root so shared packages are available. Do not reuse the website's root build and output settings for another app. -React, Next.js, TypeScript, Tailwind CSS, and basic HTML knowledge are useful. Contributions to content and bug reports are welcome too. +GitHub Actions installs with the frozen lockfile and runs typecheck, lint, content/GitHub and service-package tests, the Vercel production build, and execution of the packaged server functions with GitHub offline. No live database, migration, email, or deployment is required for these checks. diff --git a/WEBSITE_CONFIG.md b/WEBSITE_CONFIG.md index 3228809..54b517a 100644 --- a/WEBSITE_CONFIG.md +++ b/WEBSITE_CONFIG.md @@ -1,16 +1,16 @@ # Website configuration -All source, public assets, and configuration are at the repository root. Run `pnpm dev` from this directory. No environment variables are needed for the existing site. Future server features can read private values from `.env.local`; never prefix secrets with `NEXT_PUBLIC_`. +The website is in `apps/astro/`. Source files are under `src/`, while `content/`, `public/`, and Astro configuration are at the app root. Run `pnpm dev` from the repository root. No environment variables are needed for the public site. Shared environment files belong at the repository root; never prefix secrets with `PUBLIC_`. Paths below are relative to `apps/astro/`. ## Home page -The home page is a bento grid (`app/page.tsx`, laid out by the `.bento` rules in `styles/globals.css`). Its statistics, language breakdown, and commit calendar come from the public GitHub API for the [Manipal-OSF](https://github.com/Manipal-OSF) organization (`lib/github.ts`). Responses are cached for a day and the page re-renders hourly; if GitHub is unreachable, those cells say so instead of failing the page. Unauthenticated requests are limited to 60 an hour, so set `GITHUB_TOKEN` in `.env.local` or the hosting environment if builds or previews run often. A fine-grained token without any permissions is enough. +The home page is a bento grid (`src/pages/index.astro`, laid out by the `.bento` rules in `src/styles/globals.css`). Its statistics, language breakdown, and commit calendar come from the public GitHub API for the [Manipal-OSF](https://github.com/Manipal-OSF) organization (`src/lib/github.ts`). Home and team pages use Vercel ISR with a one-hour expiration. Duplicate GitHub calls within one render share a response. If GitHub is unreachable, the website returns an uncached 503 fallback, preserving offline messaging and the authored team timeline. On Vercel, unsuccessful regeneration retains the previous cached page. Unauthenticated requests are limited to 60 an hour, so set `GITHUB_TOKEN` in the repository-root `.env` or the hosting environment if live page regeneration or previews run often. A fine-grained token without any permissions is enough. -The map uses [MapLibre](https://maplibre.org/) with [OpenFreeMap](https://openfreemap.org/) tiles; edit `regions` in `components/home/KarnatakaMap.tsx` to change the places in its menu. +The map uses [MapLibre](https://maplibre.org/) with [OpenFreeMap](https://openfreemap.org/) tiles; edit `regions` in `src/lib/map.ts` to change the places in its menu. ## SEO -It's added now +The shared Astro layout renders canonical URLs, social metadata, RSS discovery, icons, and the manifest. Pages retain their JSON-LD structured data. `/sitemap.xml`, `/robots.txt`, `/blog/feed.xml`, `/opengraph-image`, and each published post's `/blog//opengraph-image` are generated at build time. Social previews use a bundled Noto Sans font; its license is included beside the font. ## Write a blog post @@ -44,7 +44,7 @@ Write standard Markdown here, including **bold text**, links, fenced code blocks - `coverImage` is optional. Place the example image in `public/images/your-post.jpg`; omit this field to display the existing placeholder. Images must use local public paths beginning with a single `/`. - The body supports Markdown rather than JSX or raw HTML. Start headings at `##`, since the page already renders the title as an `h1`. -The directory initially contains only `.gitkeep`, so the blog shows “No posts yet.” Invalid metadata and duplicate IDs produce errors identifying the offending post or ID. +Invalid metadata and duplicate IDs produce errors identifying the offending post or ID. ## Add an author profile @@ -68,6 +68,6 @@ Only `name` is required. `avatar` must be a local public path (place the image i ## Publish -Change `status` to `published`, commit the Markdown and any images, and deploy. App Router Server Components and `generateStaticParams` generate blog pages during the production build; additions, edits, and removals require a rebuild and deployment. During development, the content loader reads files on each page request. Reload the page to see content changes; new published IDs work without restarting the dev server. +Change `status` to `published`, commit the Markdown and any images, and deploy. Astro content collections and `getStaticPaths` generate blog and author pages during the production build; additions, edits, and removals require a rebuild and deployment. During development, Astro watches the content collections; saved changes and new published IDs work without restarting the dev server. Unknown or draft post URLs return 404. Historical hosted posts must be exported and converted to this format separately, preserving their IDs if their existing URLs need to keep working. diff --git a/app/authors/[id]/page.tsx b/app/authors/[id]/page.tsx deleted file mode 100644 index 4a3287d..0000000 --- a/app/authors/[id]/page.tsx +++ /dev/null @@ -1,82 +0,0 @@ -import type { Metadata } from 'next'; -import { notFound } from 'next/navigation'; -import AuthorCard from '@/components/authors/AuthorCard'; -import PostList from '@/components/blog/PostList'; -import Breadcrumbs from '@/components/common/Breadcrumbs'; -import Container from '@/components/common/Container'; -import JsonLd from '@/components/common/JsonLd'; -import { getAllAuthors, getAuthor } from '@/lib/content/authors'; -import { getPostsByAuthor } from '@/lib/content/posts'; -import { absoluteUrl, pageMetadata } from '@/lib/seo'; - -// reference: https://pranavu.dev - -export const dynamicParams = false; - -export async function generateStaticParams() { - return (await getAllAuthors()).map((author) => ({ id: author.id })); -} - -type AuthorPageProps = { params: Promise<{ id: string }> }; - -async function loadAuthor(params: AuthorPageProps['params']) { - const { id } = await params; - const author = await getAuthor(id); - if (!author) notFound(); - return author; -} - -export async function generateMetadata({ - params, -}: AuthorPageProps): Promise { - const author = await loadAuthor(params); - return pageMetadata({ - title: author.name, - description: author.bio ?? `Posts by ${author.name}.`, - path: `/authors/${author.id}`, - openGraph: { type: 'profile' }, - }); -} - -export default async function AuthorPage({ params }: AuthorPageProps) { - const author = await loadAuthor(params); - const posts = await getPostsByAuthor(author.id); - - return ( - - link.href.startsWith('https://')) - .map((link) => link.href), - }, - }} - /> - - -

- Posts by {author.name} -

- {posts.length === 0 ? ( -

- No posts available from this author. -

- ) : ( - - )} -
- ); -} diff --git a/app/authors/page.tsx b/app/authors/page.tsx deleted file mode 100644 index dff4034..0000000 --- a/app/authors/page.tsx +++ /dev/null @@ -1,35 +0,0 @@ -import type { Metadata } from 'next'; -import AuthorCard from '@/components/authors/AuthorCard'; -import Breadcrumbs from '@/components/common/Breadcrumbs'; -import Container from '@/components/common/Container'; -import { getAllAuthors } from '@/lib/content/authors'; -import { pageMetadata } from '@/lib/seo'; -import { siteName } from '@/lib/site'; - -export const metadata: Metadata = pageMetadata({ - title: 'Authors', - description: `The people who write for the ${siteName} blog.`, - path: '/authors', -}); - -export default async function AuthorsPage() { - const authors = await getAllAuthors(); - return ( - - - {authors.length === 0 ? ( -

No authors yet.

- ) : ( -
    - {authors.map((author) => ( -
  • - -
  • - ))} -
- )} -
- ); -} diff --git a/app/blog/[uid]/opengraph-image.tsx b/app/blog/[uid]/opengraph-image.tsx deleted file mode 100644 index f30edf4..0000000 --- a/app/blog/[uid]/opengraph-image.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import { notFound } from 'next/navigation'; -import { resolveAuthors } from '@/lib/content/authors'; -import { getPost, getPublishedPosts } from '@/lib/content/posts'; -import { formatDate } from '@/lib/format'; -import { ogContentType, ogImage, ogSize } from '@/lib/og'; - -export const alt = 'Blog post preview'; -export const size = ogSize; -export const contentType = ogContentType; - -export async function generateStaticParams() { - return (await getPublishedPosts()).map((post) => ({ uid: post.id })); -} - -export default async function Image({ - params, -}: { - params: Promise<{ uid: string }>; -}) { - const post = await getPost((await params).uid); - if (!post) notFound(); - const authors = await resolveAuthors(post.authors); - return ogImage({ - eyebrow: 'Blog', - title: post.title, - subtitle: `${authors - .map((author) => author.name) - .join(', ')} · ${formatDate(post.publishDate)} · ${post.readingTime}`, - }); -} diff --git a/app/blog/[uid]/page.tsx b/app/blog/[uid]/page.tsx deleted file mode 100644 index 96998bc..0000000 --- a/app/blog/[uid]/page.tsx +++ /dev/null @@ -1,135 +0,0 @@ -import type { Metadata } from 'next'; -import { notFound } from 'next/navigation'; -import AuthorProfiles from '@/components/authors/AuthorProfiles'; -import CoverImage from '@/components/blog/CoverImage'; -import MarkdownRenderer from '@/components/blog/MarkdownRenderer'; -import PostHeader from '@/components/blog/PostHeader'; -import PostNavigation from '@/components/blog/PostNavigation'; -import MobileTableOfContents from '@/components/blog/MobileTableOfContents'; -import { TableOfContentsSidebar } from '@/components/blog/TableOfContents'; -import Breadcrumbs from '@/components/common/Breadcrumbs'; -import Container from '@/components/common/Container'; -import JsonLd from '@/components/common/JsonLd'; -import { resolveAuthors } from '@/lib/content/authors'; -import { - getAdjacentPosts, - getPost, - getPublishedPosts, -} from '@/lib/content/posts'; -import { absoluteUrl, pageMetadata } from '@/lib/seo'; -import { siteName, siteUrl } from '@/lib/site'; - -export const dynamicParams = false; - -export async function generateStaticParams() { - return (await getPublishedPosts()).map((post) => ({ uid: post.id })); -} - -type PostPageProps = { params: Promise<{ uid: string }> }; - -async function loadPost(params: PostPageProps['params']) { - const { uid } = await params; - const post = await getPost(uid); - if (!post) notFound(); - return post; -} - -export async function generateMetadata({ - params, -}: PostPageProps): Promise { - const post = await loadPost(params); - const authors = await resolveAuthors(post.authors); - const description = post.description ?? undefined; - return { - ...pageMetadata({ - title: post.title, - description, - path: `/blog/${post.id}`, - image: `/blog/${post.id}/opengraph-image`, - openGraph: { - type: 'article', - publishedTime: post.publishDate, - authors: authors.map((author) => author.name), - tags: post.tags, - }, - }), - authors: authors.map((author) => ({ - name: author.name, - url: author.isRegistered ? `/authors/${author.id}` : undefined, - })), - keywords: post.tags, - }; -} - -export default async function PostPage({ params }: PostPageProps) { - const post = await loadPost(params); - const [authors, { newer, older }] = await Promise.all([ - resolveAuthors(post.authors), - getAdjacentPosts(post.id), - ]); - const url = absoluteUrl(`/blog/${post.id}`); - - return ( - // data-wide widens the site's main column so the sidebar fits beside the text. -
- ({ - '@type': 'Person', - name: author.name, - url: author.isRegistered - ? absoluteUrl(`/authors/${author.id}`) - : undefined, - })), - publisher: { - '@type': 'Organization', - '@id': `${siteUrl}/#organization`, - name: siteName, - logo: absoluteUrl('/logo.png'), - }, - }} - /> - - - - - - - -
- - - - - - - -
-
- ); -} diff --git a/app/blog/page.tsx b/app/blog/page.tsx deleted file mode 100644 index b0ba754..0000000 --- a/app/blog/page.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import type { Metadata } from 'next'; -import PostList from '@/components/blog/PostList'; -import Breadcrumbs from '@/components/common/Breadcrumbs'; -import Container from '@/components/common/Container'; -import { getPublishedPosts } from '@/lib/content/posts'; -import { pageMetadata } from '@/lib/seo'; -import { siteName } from '@/lib/site'; - -export const metadata: Metadata = pageMetadata({ - title: 'Blog', - description: `Articles, guides, and stories from the ${siteName} community.`, - path: '/blog', -}); - -export default async function BlogPage() { - const posts = await getPublishedPosts(); - // Posts are sorted newest first, so years come out in descending order. - const postsByYear = Map.groupBy(posts, (post) => - post.publishDate.slice(0, 4) - ); - - return ( - - - {posts.length === 0 ? ( -

No posts yet.

- ) : ( - [...postsByYear].map(([year, yearPosts]) => ( -
-

{year}

- -
- )) - )} -
- ); -} diff --git a/app/error.tsx b/app/error.tsx deleted file mode 100644 index d5074fa..0000000 --- a/app/error.tsx +++ /dev/null @@ -1,7 +0,0 @@ -'use client'; - -import ServerError from '@/components/common/ServerError'; - -export default function ErrorPage({ retry }: { retry: () => void }) { - return ; -} diff --git a/app/global-error.tsx b/app/global-error.tsx deleted file mode 100644 index 9605d8c..0000000 --- a/app/global-error.tsx +++ /dev/null @@ -1,18 +0,0 @@ -'use client'; - -import ServerError from '@/components/common/ServerError'; -import ThemeScript from '@/components/common/ThemeScript'; -import '@/styles/globals.css'; - -export default function GlobalError({ retry }: { retry: () => void }) { - return ( - - - -
- -
- - - ); -} diff --git a/app/layout.tsx b/app/layout.tsx deleted file mode 100644 index 7ce8cf0..0000000 --- a/app/layout.tsx +++ /dev/null @@ -1,55 +0,0 @@ -import { Analytics } from '@vercel/analytics/next'; -import { SpeedInsights } from '@vercel/speed-insights/next'; -import type { Metadata, Viewport } from 'next'; -import type { ReactNode } from 'react'; -import ThemeScript from '@/components/common/ThemeScript'; -import Footer from '@/components/layout/Footer'; -import Header from '@/components/layout/Header'; -import Layout from '@/components/layout/Layout'; -import '@/lib/fonts'; -import { baseOpenGraph, feedPath } from '@/lib/seo'; -import { siteDescription, siteName, siteUrl } from '@/lib/site'; -import '@/styles/globals.css'; - -export const metadata: Metadata = { - metadataBase: new URL(siteUrl), - title: { template: `%s | ${siteName}`, default: siteName }, - description: siteDescription, - applicationName: siteName, - alternates: { types: { 'application/rss+xml': feedPath } }, - openGraph: { - ...baseOpenGraph, - title: siteName, - description: siteDescription, - }, - twitter: { card: 'summary_large_image' }, - robots: { - index: true, - follow: true, - googleBot: { 'max-image-preview': 'large', 'max-snippet': -1 }, - }, -}; - -// Matches the light (Nord) and dark (Catppuccin) backgrounds in globals.css. -export const viewport: Viewport = { - colorScheme: 'light dark', - themeColor: [ - { media: '(prefers-color-scheme: light)', color: '#eceff4' }, - { media: '(prefers-color-scheme: dark)', color: '#1e1e2e' }, - ], -}; - -export default function RootLayout({ children }: { children: ReactNode }) { - return ( - - - - } footer={