diff --git a/docs/superpowers/plans/2026-05-08-astro-to-payload-migration.md b/docs/superpowers/plans/2026-05-08-astro-to-payload-migration.md
new file mode 100644
index 0000000..d15f831
--- /dev/null
+++ b/docs/superpowers/plans/2026-05-08-astro-to-payload-migration.md
@@ -0,0 +1,3966 @@
+# Astro → Payload Migration Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Migrate `arter.dev` from a static Astro site to a Payload-CMS-driven Next.js application running on Cloudflare Workers, preserving the current visual design and all public URLs.
+
+**Architecture:** Four Payload collections (`posts`, `projects`, `pages`, `tags`) with Lexical bodies and three custom blocks (`linkPreview`, `callout`, `mediaBlock`). A one-shot Local API import script imports content from `~/Documents/projects/arter-blog`. Frontend rebuilt as Next.js App Router routes under `(frontend)`. Auxiliary endpoints (RSS, sitemap, robots, OG) re-implemented as route handlers.
+
+**Tech Stack:** Payload 3.84, Next.js 15.4 (App Router), React 19, Cloudflare Workers / D1 / R2 via opennextjs-cloudflare, Tailwind 4, Vitest (int), Playwright (e2e), `@payloadcms/richtext-lexical`, `gray-matter`, `remark-mdx`, `satori`, `@resvg/resvg-js`.
+
+**Source spec:** `docs/superpowers/specs/2026-05-08-astro-to-payload-migration-design.md`
+
+**Old project (read-only source of truth for content + design):** `~/Documents/projects/arter-blog`
+
+---
+
+## Phase 0 — Foundation
+
+**Milestone:** project deps, lint, fonts, slugify, and site config in place. `pnpm dev` boots; `pnpm test:int` runs an empty suite.
+
+### Task 0.1: Add runtime dependencies
+
+**Files:**
+- Modify: `package.json`
+
+- [ ] **Step 1: Add deps**
+
+```bash
+pnpm add @fontsource-variable/inter @fontsource-variable/newsreader \
+ @fontsource/dm-serif-text @fontsource/krona-one \
+ gray-matter remark-mdx remark-parse remark-stringify unified \
+ satori @resvg/resvg-js \
+ zod
+```
+
+- [ ] **Step 2: Add dev deps**
+
+```bash
+pnpm add -D @types/node tailwindcss @tailwindcss/postcss @tailwindcss/typography
+```
+
+- [ ] **Step 3: Verify install**
+
+Run: `pnpm install`
+Expected: no errors. `node_modules/satori`, `node_modules/gray-matter`, etc. exist.
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add package.json pnpm-lock.yaml
+git commit -m "chore: add migration runtime deps"
+```
+
+### Task 0.2: Configure trailing slash to match old URLs
+
+**Files:**
+- Modify: `next.config.ts`
+
+- [ ] **Step 1: Add `trailingSlash`**
+
+In `next.config.ts`, add `trailingSlash: 'always',` inside the `nextConfig` object alongside `images:` and `serverExternalPackages:`.
+
+- [ ] **Step 2: Verify dev still boots**
+
+Run: `pnpm dev`
+Expected: Next starts on http://localhost:3000 with no error.
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add next.config.ts
+git commit -m "chore: enable trailingSlash to match legacy URLs"
+```
+
+### Task 0.3: Configure Tailwind 4
+
+**Files:**
+- Create: `postcss.config.mjs`
+- Modify: `src/app/(frontend)/styles.css`
+
+- [ ] **Step 1: Create PostCSS config**
+
+```js
+// postcss.config.mjs
+export default {
+ plugins: {
+ '@tailwindcss/postcss': {},
+ },
+}
+```
+
+- [ ] **Step 2: Add Tailwind import to styles.css**
+
+At the very top of `src/app/(frontend)/styles.css`, prepend:
+
+```css
+@import 'tailwindcss';
+@plugin '@tailwindcss/typography';
+```
+
+- [ ] **Step 3: Verify dev rebuild**
+
+Run: `pnpm dev` and visit http://localhost:3000
+Expected: page loads without 500. CSS bundle includes Tailwind base styles (visible utility classes resolve).
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add postcss.config.mjs src/app/(frontend)/styles.css
+git commit -m "feat: enable Tailwind 4 in frontend"
+```
+
+### Task 0.4: Port the global stylesheet
+
+**Files:**
+- Modify: `src/app/(frontend)/styles.css`
+- Reference (read-only): `~/Documents/projects/arter-blog/src/styles/`
+
+- [ ] **Step 1: Find the source stylesheet**
+
+```bash
+ls ~/Documents/projects/arter-blog/src/styles/
+```
+
+- [ ] **Step 2: Append CSS variables and base styles**
+
+Copy the CSS custom properties (`--ink`, `--ink-2`, `--ink-3`, `--ink-4`, `--paper`, `--paper-2`, `--hair`, `--hair-2`, plus any font-related vars) and the `prose-editorial` typography block from the old `src/styles/` files into `src/app/(frontend)/styles.css` after the Tailwind imports. Preserve the original property names exactly.
+
+- [ ] **Step 3: Verify**
+
+Run: `pnpm dev` and visit http://localhost:3000
+Expected: no CSS errors in browser console.
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add src/app/(frontend)/styles.css
+git commit -m "feat: port global styles and CSS variables"
+```
+
+### Task 0.5: Wire up fonts
+
+**Files:**
+- Modify: `src/app/(frontend)/layout.tsx`
+
+- [ ] **Step 1: Import fontsource CSS**
+
+Replace the contents of `src/app/(frontend)/layout.tsx` with:
+
+```tsx
+import React from 'react'
+import './styles.css'
+import '@fontsource-variable/inter'
+import '@fontsource-variable/newsreader'
+import '@fontsource/dm-serif-text'
+import '@fontsource/krona-one'
+
+export const metadata = {
+ description: "Chris Arter's blog — software, life, cars.",
+ title: 'Chris Arter | A Blog of Tech & Cars',
+}
+
+export default async function RootLayout(props: { children: React.ReactNode }) {
+ const { children } = props
+ return (
+
+
{children}
+
+ )
+}
+```
+
+- [ ] **Step 2: Verify dev**
+
+Run: `pnpm dev` and visit http://localhost:3000
+Expected: page loads; font files are requested in network tab.
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add src/app/(frontend)/layout.tsx
+git commit -m "feat: wire up site fonts in root layout"
+```
+
+### Task 0.6: Port `slugify` (TDD)
+
+**Files:**
+- Create: `src/lib/slugify.ts`
+- Create: `tests/int/slugify.int.spec.ts`
+
+- [ ] **Step 1: Write the failing test**
+
+```ts
+// tests/int/slugify.int.spec.ts
+import { describe, expect, it } from 'vitest'
+import { slugify } from '@/lib/slugify'
+
+describe('slugify', () => {
+ it('lowercases and replaces spaces with hyphens', () => {
+ expect(slugify('Hello World')).toBe('hello-world')
+ })
+ it('strips accents', () => {
+ expect(slugify('Café Résumé')).toBe('cafe-resume')
+ })
+ it('collapses multiple separators', () => {
+ expect(slugify('a b---c')).toBe('a-b-c')
+ })
+ it('drops disallowed characters', () => {
+ expect(slugify("It's awesome!")).toBe('it-s-awesome')
+ })
+ it('returns empty string for undefined input', () => {
+ expect(slugify(undefined)).toBe('')
+ })
+})
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `pnpm test:int -- slugify`
+Expected: FAIL with "Cannot find module '@/lib/slugify'".
+
+- [ ] **Step 3: Verify path alias**
+
+Open `tsconfig.json` and confirm `compilerOptions.paths` contains `"@/*": ["./src/*"]`. If absent, add it.
+
+- [ ] **Step 4: Implement slugify**
+
+```ts
+// src/lib/slugify.ts
+export function slugify(input?: string): string {
+ if (!input) return ''
+ let slug = input.toLowerCase().trim()
+ slug = slug.normalize('NFD').replace(/[̀-ͯ]/g, '')
+ slug = slug.replace(/[^a-z0-9\s-]/g, ' ').trim()
+ slug = slug.replace(/[\s-]+/g, '-')
+ return slug
+}
+```
+
+- [ ] **Step 5: Run test**
+
+Run: `pnpm test:int -- slugify`
+Expected: PASS, 5 tests.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add src/lib/slugify.ts tests/int/slugify.int.spec.ts
+git commit -m "feat: port slugify utility with tests"
+```
+
+### Task 0.7: Port `site-config`
+
+**Files:**
+- Create: `src/lib/site-config.ts`
+- Reference: `~/Documents/projects/arter-blog/src/data/site-config.ts`
+
+- [ ] **Step 1: Copy verbatim**
+
+Copy the entire file contents from the reference into `src/lib/site-config.ts`. The exported `siteConfig` and types (`Image`, `Link`, `Hero`, `Subscribe`, `SiteConfig`) come along.
+
+- [ ] **Step 2: Update image paths**
+
+For now, leave `siteConfig.image.src` and `siteConfig.hero.image.src` as `/images/...`. We'll copy `public/images/` over in Task 0.8.
+
+- [ ] **Step 3: Tsc-check**
+
+Run: `pnpm tsc --noEmit`
+Expected: no errors in `site-config.ts`.
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add src/lib/site-config.ts
+git commit -m "feat: port site-config"
+```
+
+### Task 0.8: Copy non-post static images and fonts
+
+**Files:**
+- Create: `public/images/*` (multiple)
+- Create: `public/fonts/KronaOne-Regular.ttf`
+
+- [ ] **Step 1: Copy images that are referenced from layout/site-config (not from posts)**
+
+The post-body images (slug-style names like `1737473617680-…png`) move to R2 via the import script later. The hero/about/auth-onion etc. assets stay static.
+
+```bash
+mkdir -p public/images public/fonts
+cp ~/Documents/projects/arter-blog/public/fonts/KronaOne-Regular.ttf public/fonts/
+cp ~/Documents/projects/arter-blog/public/images/chris-arter.jpg public/images/ 2>/dev/null || true
+cp ~/Documents/projects/arter-blog/public/images/chris-arter.jpeg public/images/ 2>/dev/null || true
+cp ~/Documents/projects/arter-blog/public/images/auth-onion.svg public/images/ 2>/dev/null || true
+cp ~/Documents/projects/arter-blog/public/images/auth-onion.png public/images/ 2>/dev/null || true
+cp ~/Documents/projects/arter-blog/public/images/bluesky-logo-dark-theme.svg public/images/ 2>/dev/null || true
+```
+
+- [ ] **Step 2: Verify**
+
+Run: `ls public/images/ public/fonts/`
+Expected: lists the copied files.
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add public/images public/fonts
+git commit -m "feat: bring over static images and Krona One font"
+```
+
+---
+
+## Phase 1 — Schema
+
+**Milestone:** four new collections registered in `payload.config.ts`. `pnpm payload generate:types` produces the right `Post`, `Project`, `Page`, `Tag` types. A migration is created and applied locally; the admin UI shows all four collections.
+
+### Task 1.1: Create the `tags` collection
+
+**Files:**
+- Create: `src/collections/Tags.ts`
+
+- [ ] **Step 1: Write the collection**
+
+```ts
+// src/collections/Tags.ts
+import type { CollectionConfig } from 'payload'
+import { slugify } from '@/lib/slugify'
+
+export const Tags: CollectionConfig = {
+ slug: 'tags',
+ admin: { useAsTitle: 'name', defaultColumns: ['name', 'slug'] },
+ access: {
+ read: () => true,
+ },
+ fields: [
+ { name: 'name', type: 'text', required: true, unique: true },
+ {
+ name: 'slug',
+ type: 'text',
+ required: true,
+ unique: true,
+ index: true,
+ hooks: {
+ beforeChange: [
+ ({ value, data }) => value || slugify(data?.name as string | undefined),
+ ],
+ },
+ admin: { description: 'Auto-generated from name if empty.' },
+ },
+ { name: 'description', type: 'textarea' },
+ ],
+}
+```
+
+- [ ] **Step 2: Commit**
+
+```bash
+git add src/collections/Tags.ts
+git commit -m "feat(schema): add Tags collection"
+```
+
+### Task 1.2: Create the `pages` collection
+
+**Files:**
+- Create: `src/collections/Pages.ts`
+
+- [ ] **Step 1: Write the collection**
+
+```ts
+// src/collections/Pages.ts
+import type { CollectionConfig } from 'payload'
+import { lexicalEditor } from '@payloadcms/richtext-lexical'
+
+export const Pages: CollectionConfig = {
+ slug: 'pages',
+ admin: { useAsTitle: 'title', defaultColumns: ['title', 'slug', 'updatedAt'] },
+ versions: { drafts: true },
+ access: {
+ read: () => true,
+ },
+ fields: [
+ { name: 'title', type: 'text', required: true },
+ { name: 'slug', type: 'text', required: true, unique: true, index: true },
+ { name: 'subtitle', type: 'text' },
+ { name: 'description', type: 'textarea' },
+ {
+ name: 'seo',
+ type: 'group',
+ fields: [
+ { name: 'title', type: 'text' },
+ { name: 'description', type: 'textarea' },
+ {
+ name: 'pageType',
+ type: 'select',
+ options: ['website', 'article'],
+ defaultValue: 'website',
+ },
+ { name: 'image', type: 'upload', relationTo: 'media' },
+ ],
+ },
+ { name: 'body', type: 'richText', editor: lexicalEditor() },
+ ],
+}
+```
+
+- [ ] **Step 2: Commit**
+
+```bash
+git add src/collections/Pages.ts
+git commit -m "feat(schema): add Pages collection"
+```
+
+### Task 1.3: Create the `projects` collection
+
+**Files:**
+- Create: `src/collections/Projects.ts`
+
+- [ ] **Step 1: Write the collection**
+
+```ts
+// src/collections/Projects.ts
+import type { CollectionConfig } from 'payload'
+import { lexicalEditor } from '@payloadcms/richtext-lexical'
+
+export const Projects: CollectionConfig = {
+ slug: 'projects',
+ admin: { useAsTitle: 'title', defaultColumns: ['title', 'slug', 'publishDate', 'isFeatured'] },
+ versions: { drafts: true },
+ access: {
+ read: () => true,
+ },
+ fields: [
+ { name: 'title', type: 'text', required: true },
+ { name: 'slug', type: 'text', required: true, unique: true, index: true },
+ { name: 'description', type: 'textarea' },
+ { name: 'publishDate', type: 'date', required: true, index: true },
+ { name: 'isFeatured', type: 'checkbox', defaultValue: false },
+ { name: 'coverImage', type: 'upload', relationTo: 'media' },
+ {
+ name: 'seo',
+ type: 'group',
+ fields: [
+ { name: 'title', type: 'text' },
+ { name: 'description', type: 'textarea' },
+ {
+ name: 'pageType',
+ type: 'select',
+ options: ['website', 'article'],
+ defaultValue: 'article',
+ },
+ { name: 'image', type: 'upload', relationTo: 'media' },
+ ],
+ },
+ { name: 'body', type: 'richText', editor: lexicalEditor() },
+ ],
+}
+```
+
+- [ ] **Step 2: Commit**
+
+```bash
+git add src/collections/Projects.ts
+git commit -m "feat(schema): add Projects collection"
+```
+
+### Task 1.4: Create the `posts` collection
+
+**Files:**
+- Create: `src/collections/Posts.ts`
+
+- [ ] **Step 1: Write the collection**
+
+```ts
+// src/collections/Posts.ts
+import type { CollectionConfig } from 'payload'
+import { lexicalEditor } from '@payloadcms/richtext-lexical'
+
+export const Posts: CollectionConfig = {
+ slug: 'posts',
+ admin: {
+ useAsTitle: 'title',
+ defaultColumns: ['title', 'slug', 'publishDate', '_status'],
+ },
+ versions: { drafts: true },
+ access: {
+ read: () => true,
+ },
+ fields: [
+ { name: 'title', type: 'text', required: true },
+ { name: 'slug', type: 'text', required: true, unique: true, index: true },
+ { name: 'subtitle', type: 'text' },
+ { name: 'excerpt', type: 'textarea' },
+ { name: 'publishDate', type: 'date', required: true, index: true },
+ { name: 'updatedDate', type: 'date' },
+ { name: 'isFeatured', type: 'checkbox', defaultValue: false },
+ {
+ name: 'tags',
+ type: 'relationship',
+ relationTo: 'tags',
+ hasMany: true,
+ index: true,
+ },
+ { name: 'coverImage', type: 'upload', relationTo: 'media' },
+ {
+ name: 'seo',
+ type: 'group',
+ fields: [
+ { name: 'title', type: 'text' },
+ { name: 'description', type: 'textarea' },
+ {
+ name: 'pageType',
+ type: 'select',
+ options: ['website', 'article'],
+ defaultValue: 'article',
+ },
+ { name: 'image', type: 'upload', relationTo: 'media' },
+ ],
+ },
+ { name: 'body', type: 'richText', editor: lexicalEditor() },
+ ],
+ indexes: [
+ { fields: ['publishDate', '_status'] },
+ ],
+}
+```
+
+- [ ] **Step 2: Commit**
+
+```bash
+git add src/collections/Posts.ts
+git commit -m "feat(schema): add Posts collection"
+```
+
+### Task 1.5: Register new collections in `payload.config.ts`
+
+**Files:**
+- Modify: `src/payload.config.ts`
+
+- [ ] **Step 1: Update imports and `collections` array**
+
+Replace the imports and `collections: [Users, Media]` line:
+
+```ts
+import { Users } from './collections/Users'
+import { Media } from './collections/Media'
+import { Tags } from './collections/Tags'
+import { Pages } from './collections/Pages'
+import { Projects } from './collections/Projects'
+import { Posts } from './collections/Posts'
+```
+
+```ts
+collections: [Users, Media, Tags, Pages, Projects, Posts],
+```
+
+- [ ] **Step 2: Generate types**
+
+Run: `pnpm generate:types:payload`
+Expected: `src/payload-types.ts` updates to include `Post`, `Project`, `Page`, `Tag` types.
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add src/payload.config.ts src/payload-types.ts
+git commit -m "feat(schema): register new collections in payload.config"
+```
+
+### Task 1.6: Create and apply schema migration locally
+
+**Files:**
+- Create: `src/migrations/_.ts` (auto-generated)
+
+- [ ] **Step 1: Generate migration**
+
+Run: `pnpm payload migrate:create --name add_posts_projects_pages_tags`
+Expected: a new file appears under `src/migrations/`.
+
+- [ ] **Step 2: Apply locally**
+
+Run: `pnpm payload migrate`
+Expected: migration applies without error.
+
+- [ ] **Step 3: Smoke-test the admin**
+
+Run: `pnpm dev`, visit http://localhost:3000/admin (create first user if prompted), confirm Posts, Projects, Pages, Tags appear in the sidebar and you can open each collection's empty list.
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add src/migrations
+git commit -m "feat(schema): add migration for new collections"
+```
+
+---
+
+## Phase 2 — Lexical custom blocks
+
+**Milestone:** when you create a Post in the admin, the rich-text editor offers `Link Preview`, `Callout`, and `Media` blocks alongside paragraphs/headings. Inserted blocks render filled-in fields in admin and serialize cleanly.
+
+### Task 2.1: Define block configs
+
+**Files:**
+- Create: `src/blocks/linkPreview.ts`
+- Create: `src/blocks/callout.ts`
+- Create: `src/blocks/mediaBlock.ts`
+- Create: `src/blocks/index.ts`
+
+- [ ] **Step 1: Create `linkPreview` block**
+
+```ts
+// src/blocks/linkPreview.ts
+import type { Block } from 'payload'
+
+export const LinkPreviewBlock: Block = {
+ slug: 'linkPreview',
+ interfaceName: 'LinkPreviewBlock',
+ fields: [
+ { name: 'url', type: 'text', required: true },
+ { name: 'title', type: 'text' },
+ { name: 'description', type: 'textarea' },
+ { name: 'image', type: 'upload', relationTo: 'media' },
+ ],
+}
+```
+
+- [ ] **Step 2: Create `callout` block**
+
+```ts
+// src/blocks/callout.ts
+import type { Block } from 'payload'
+import { lexicalEditor } from '@payloadcms/richtext-lexical'
+
+export const CalloutBlock: Block = {
+ slug: 'callout',
+ interfaceName: 'CalloutBlock',
+ fields: [
+ { name: 'title', type: 'text' },
+ {
+ name: 'variant',
+ type: 'select',
+ options: ['default', 'note', 'warning', 'tip'],
+ defaultValue: 'default',
+ },
+ { name: 'content', type: 'richText', editor: lexicalEditor() },
+ ],
+}
+```
+
+- [ ] **Step 3: Create `mediaBlock` block**
+
+```ts
+// src/blocks/mediaBlock.ts
+import type { Block } from 'payload'
+
+export const MediaBlock: Block = {
+ slug: 'mediaBlock',
+ interfaceName: 'MediaBlock',
+ fields: [
+ { name: 'media', type: 'upload', relationTo: 'media', required: true },
+ { name: 'caption', type: 'text' },
+ {
+ name: 'widthHint',
+ type: 'select',
+ options: ['full', 'wide', 'inline'],
+ defaultValue: 'full',
+ },
+ ],
+}
+```
+
+- [ ] **Step 4: Index file**
+
+```ts
+// src/blocks/index.ts
+export { LinkPreviewBlock } from './linkPreview'
+export { CalloutBlock } from './callout'
+export { MediaBlock } from './mediaBlock'
+```
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/blocks
+git commit -m "feat(blocks): define linkPreview, callout, mediaBlock"
+```
+
+### Task 2.2: Register blocks on body fields
+
+**Files:**
+- Modify: `src/collections/Posts.ts`
+- Modify: `src/collections/Projects.ts`
+- Modify: `src/collections/Pages.ts`
+
+- [ ] **Step 1: Add a shared body editor**
+
+Create `src/lib/lexical-editor.ts`:
+
+```ts
+// src/lib/lexical-editor.ts
+import {
+ BlocksFeature,
+ HeadingFeature,
+ HorizontalRuleFeature,
+ lexicalEditor,
+} from '@payloadcms/richtext-lexical'
+import { LinkPreviewBlock, CalloutBlock, MediaBlock } from '@/blocks'
+
+export const bodyEditor = lexicalEditor({
+ features: ({ defaultFeatures }) => [
+ ...defaultFeatures,
+ HeadingFeature({ enabledHeadingSizes: ['h2', 'h3', 'h4', 'h5', 'h6'] }),
+ HorizontalRuleFeature(),
+ BlocksFeature({ blocks: [LinkPreviewBlock, CalloutBlock, MediaBlock] }),
+ ],
+})
+```
+
+- [ ] **Step 2: Use it in each collection**
+
+In `Posts.ts`, `Projects.ts`, `Pages.ts`, replace `editor: lexicalEditor()` on the `body` field with `editor: bodyEditor` and add `import { bodyEditor } from '@/lib/lexical-editor'` at the top of each file.
+
+- [ ] **Step 3: Generate types**
+
+Run: `pnpm generate:types:payload`
+Expected: `LinkPreviewBlock`, `CalloutBlock`, `MediaBlock` types appear in `payload-types.ts`.
+
+- [ ] **Step 4: Smoke-test admin**
+
+Run: `pnpm dev`, open admin, edit a post body, verify the "Insert Block" menu shows the three blocks.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/collections src/lib/lexical-editor.ts src/payload-types.ts
+git commit -m "feat(blocks): register custom blocks on body fields"
+```
+
+---
+
+## Phase 3 — Import script: scaffold, parsing, tags, images
+
+**Milestone:** `pnpm import:astro --dry-run` parses every old markdown file, validates frontmatter, plans tag upserts and image uploads, and prints a summary — without writing to the DB.
+
+### Task 3.1: Add `import:astro` script entry
+
+**Files:**
+- Modify: `package.json`
+
+- [ ] **Step 1: Add script**
+
+In `package.json` `scripts`, add:
+
+```json
+"import:astro": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm\" tsx scripts/import-from-astro.ts",
+```
+
+- [ ] **Step 2: Commit**
+
+```bash
+git add package.json
+git commit -m "chore: add import:astro script entry"
+```
+
+### Task 3.2: Frontmatter Zod schemas
+
+**Files:**
+- Create: `scripts/import/schemas.ts`
+- Create: `tests/int/import-schemas.int.spec.ts`
+
+- [ ] **Step 1: Failing test**
+
+```ts
+// tests/int/import-schemas.int.spec.ts
+import { describe, expect, it } from 'vitest'
+import { postFrontmatterSchema } from '../../scripts/import/schemas'
+
+describe('postFrontmatterSchema', () => {
+ it('accepts a valid post', () => {
+ const ok = postFrontmatterSchema.parse({
+ title: 'X',
+ publishDate: '2025-01-21',
+ tags: ['Laravel'],
+ })
+ expect(ok.title).toBe('X')
+ })
+ it('rejects a missing title', () => {
+ expect(() =>
+ postFrontmatterSchema.parse({ publishDate: '2025-01-21' }),
+ ).toThrow()
+ })
+ it('coerces publishDate to a Date', () => {
+ const r = postFrontmatterSchema.parse({ title: 'X', publishDate: '2025-01-21' })
+ expect(r.publishDate).toBeInstanceOf(Date)
+ })
+})
+```
+
+- [ ] **Step 2: Run, verify fail**
+
+Run: `pnpm test:int -- import-schemas`
+Expected: FAIL "Cannot find module".
+
+- [ ] **Step 3: Implement**
+
+```ts
+// scripts/import/schemas.ts
+import { z } from 'zod'
+
+const seoSchema = z
+ .object({
+ title: z.string().optional(),
+ description: z.string().optional(),
+ image: z
+ .object({ src: z.string(), alt: z.string().optional() })
+ .optional(),
+ pageType: z.enum(['website', 'article']).default('website'),
+ })
+ .partial()
+
+export const postFrontmatterSchema = z.object({
+ title: z.string(),
+ slug: z.string().optional(),
+ subtitle: z.string().optional(),
+ author: z.string().optional(),
+ excerpt: z.string().optional(),
+ publishDate: z.coerce.date(),
+ updatedDate: z.coerce.date().optional(),
+ isFeatured: z.boolean().default(false),
+ tags: z.array(z.string()).default([]),
+ seo: seoSchema.optional(),
+})
+
+export const projectFrontmatterSchema = z.object({
+ title: z.string(),
+ description: z.string().optional(),
+ publishDate: z.coerce.date(),
+ isFeatured: z.boolean().default(false),
+ seo: seoSchema.optional(),
+})
+
+export const pageFrontmatterSchema = z.object({
+ title: z.string(),
+ description: z.string().optional(),
+ subtitle: z.string().optional(),
+ seo: seoSchema.optional(),
+})
+
+export type PostFrontmatter = z.infer
+export type ProjectFrontmatter = z.infer
+export type PageFrontmatter = z.infer
+```
+
+- [ ] **Step 4: Run tests**
+
+Run: `pnpm test:int -- import-schemas`
+Expected: PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add scripts/import/schemas.ts tests/int/import-schemas.int.spec.ts
+git commit -m "feat(import): frontmatter validation schemas"
+```
+
+### Task 3.3: File discovery + parsing
+
+**Files:**
+- Create: `scripts/import/discover.ts`
+
+- [ ] **Step 1: Implement**
+
+```ts
+// scripts/import/discover.ts
+import { readFile, readdir } from 'node:fs/promises'
+import { extname, join, basename } from 'node:path'
+import matter from 'gray-matter'
+import {
+ pageFrontmatterSchema,
+ postFrontmatterSchema,
+ projectFrontmatterSchema,
+ type PageFrontmatter,
+ type PostFrontmatter,
+ type ProjectFrontmatter,
+} from './schemas'
+
+export type DiscoveredFile = {
+ type: 'post' | 'project' | 'page'
+ filePath: string
+ slug: string
+ body: string
+ frontmatter: T
+ isMdx: boolean
+}
+
+async function listMarkdown(dir: string): Promise {
+ const out: string[] = []
+ const entries = await readdir(dir, { withFileTypes: true })
+ for (const e of entries) {
+ if (e.isFile() && (extname(e.name) === '.md' || extname(e.name) === '.mdx')) {
+ out.push(join(dir, e.name))
+ }
+ }
+ return out
+}
+
+function slugFromFile(filePath: string): string {
+ return basename(filePath).replace(/\.(md|mdx)$/, '')
+}
+
+async function parseOne(filePath: string, schema: { parse: (raw: unknown) => T }) {
+ const raw = await readFile(filePath, 'utf8')
+ const { data, content } = matter(raw)
+ const frontmatter = schema.parse(data)
+ return {
+ filePath,
+ slug: slugFromFile(filePath),
+ body: content,
+ frontmatter,
+ isMdx: extname(filePath) === '.mdx',
+ }
+}
+
+export async function discoverAll(sourceRoot: string) {
+ const blogDir = join(sourceRoot, 'src/content/blog')
+ const projectsDir = join(sourceRoot, 'src/content/projects')
+ const pagesDir = join(sourceRoot, 'src/content/pages')
+
+ const [blogFiles, projectFiles, pageFiles] = await Promise.all([
+ listMarkdown(blogDir),
+ listMarkdown(projectsDir),
+ listMarkdown(pagesDir),
+ ])
+
+ const posts: DiscoveredFile[] = []
+ for (const f of blogFiles) posts.push({ type: 'post', ...(await parseOne(f, postFrontmatterSchema)) })
+
+ const projects: DiscoveredFile[] = []
+ for (const f of projectFiles)
+ projects.push({ type: 'project', ...(await parseOne(f, projectFrontmatterSchema)) })
+
+ const pages: DiscoveredFile[] = []
+ for (const f of pageFiles)
+ pages.push({ type: 'page', ...(await parseOne(f, pageFrontmatterSchema)) })
+
+ return { posts, projects, pages }
+}
+```
+
+- [ ] **Step 2: Commit**
+
+```bash
+git add scripts/import/discover.ts
+git commit -m "feat(import): file discovery and frontmatter parsing"
+```
+
+### Task 3.4: Tag upsert (idempotent)
+
+**Files:**
+- Create: `scripts/import/tags.ts`
+- Create: `tests/int/import-tags.int.spec.ts`
+
+- [ ] **Step 1: Failing test**
+
+```ts
+// tests/int/import-tags.int.spec.ts
+import { describe, expect, it } from 'vitest'
+import { collectUniqueTags } from '../../scripts/import/tags'
+
+describe('collectUniqueTags', () => {
+ it('flattens and dedupes by slug', () => {
+ const r = collectUniqueTags([
+ { tags: ['Laravel', 'PHP'] },
+ { tags: ['laravel', 'Docker'] },
+ ])
+ expect(r.map((t) => t.slug).sort()).toEqual(['docker', 'laravel', 'php'])
+ })
+ it('preserves first-seen display name', () => {
+ const r = collectUniqueTags([{ tags: ['Laravel'] }, { tags: ['laravel'] }])
+ expect(r[0].name).toBe('Laravel')
+ })
+})
+```
+
+- [ ] **Step 2: Run, verify fail**
+
+Run: `pnpm test:int -- import-tags`
+Expected: FAIL.
+
+- [ ] **Step 3: Implement**
+
+```ts
+// scripts/import/tags.ts
+import type { Payload } from 'payload'
+import { slugify } from '@/lib/slugify'
+
+export function collectUniqueTags(posts: { tags: string[] }[]) {
+ const map = new Map()
+ for (const p of posts) {
+ for (const name of p.tags ?? []) {
+ const slug = slugify(name)
+ if (!slug) continue
+ if (!map.has(slug)) map.set(slug, { name, slug })
+ }
+ }
+ return [...map.values()]
+}
+
+export async function upsertTags(payload: Payload, tags: { name: string; slug: string }[]) {
+ const idBySlug = new Map()
+ for (const tag of tags) {
+ const existing = await payload.find({
+ collection: 'tags',
+ where: { slug: { equals: tag.slug } },
+ limit: 1,
+ })
+ if (existing.docs[0]) {
+ idBySlug.set(tag.slug, existing.docs[0].id)
+ continue
+ }
+ const created = await payload.create({ collection: 'tags', data: tag })
+ idBySlug.set(tag.slug, created.id)
+ }
+ return idBySlug
+}
+```
+
+- [ ] **Step 4: Run tests**
+
+Run: `pnpm test:int -- import-tags`
+Expected: PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add scripts/import/tags.ts tests/int/import-tags.int.spec.ts
+git commit -m "feat(import): tag collection + upsert helper"
+```
+
+### Task 3.5: Image upload with disk cache
+
+**Files:**
+- Create: `scripts/import/images.ts`
+
+- [ ] **Step 1: Implement**
+
+```ts
+// scripts/import/images.ts
+import { readFile, mkdir, stat, writeFile } from 'node:fs/promises'
+import { basename, dirname, join } from 'node:path'
+import type { Payload } from 'payload'
+
+const CACHE_PATH = '.import-cache/media.json'
+
+type Cache = Record
+
+async function loadCache(): Promise {
+ try {
+ const raw = await readFile(CACHE_PATH, 'utf8')
+ return JSON.parse(raw)
+ } catch {
+ return {}
+ }
+}
+
+async function saveCache(cache: Cache) {
+ await mkdir(dirname(CACHE_PATH), { recursive: true })
+ await writeFile(CACHE_PATH, JSON.stringify(cache, null, 2))
+}
+
+export async function uploadImage(
+ payload: Payload,
+ publicImagePath: string, // e.g. /images/foo.png
+ sourceRoot: string,
+ alt: string,
+ cache: Cache,
+): Promise {
+ if (cache[publicImagePath]) return cache[publicImagePath]
+ const filePath = join(sourceRoot, 'public', publicImagePath)
+ try {
+ await stat(filePath)
+ } catch {
+ console.warn(`[image] missing ${filePath}`)
+ return undefined
+ }
+ const data = await readFile(filePath)
+ const filename = basename(filePath)
+ const created = await payload.create({
+ collection: 'media',
+ data: { alt },
+ file: { data, mimetype: mimeFor(filename), name: filename, size: data.byteLength },
+ })
+ cache[publicImagePath] = created.id
+ return created.id
+}
+
+export async function loadImageCache() {
+ return loadCache()
+}
+export async function persistImageCache(cache: Cache) {
+ await saveCache(cache)
+}
+
+function mimeFor(name: string) {
+ const ext = name.toLowerCase().split('.').pop() || ''
+ return (
+ {
+ png: 'image/png',
+ jpg: 'image/jpeg',
+ jpeg: 'image/jpeg',
+ webp: 'image/webp',
+ gif: 'image/gif',
+ svg: 'image/svg+xml',
+ } as Record
+ )[ext] ?? 'application/octet-stream'
+}
+```
+
+- [ ] **Step 2: Commit**
+
+```bash
+git add scripts/import/images.ts
+git commit -m "feat(import): media upload helper with disk cache"
+```
+
+### Task 3.6: Image reference scanner
+
+**Files:**
+- Create: `scripts/import/scan-image-refs.ts`
+- Create: `tests/int/scan-image-refs.int.spec.ts`
+
+- [ ] **Step 1: Failing test**
+
+```ts
+// tests/int/scan-image-refs.int.spec.ts
+import { describe, expect, it } from 'vitest'
+import { scanImageRefs } from '../../scripts/import/scan-image-refs'
+
+describe('scanImageRefs', () => {
+ it('finds markdown image syntax', () => {
+ const r = scanImageRefs('\n')
+ expect(r.sort()).toEqual(['/images/bar.jpg', '/images/foo.png'])
+ })
+ it('finds raw
src', () => {
+ const r = scanImageRefs('
')
+ expect(r).toEqual(['/images/foo.png'])
+ })
+ it('dedupes', () => {
+ const r = scanImageRefs(' ')
+ expect(r).toEqual(['/images/x.png'])
+ })
+ it('ignores external URLs', () => {
+ const r = scanImageRefs('')
+ expect(r).toEqual([])
+ })
+})
+```
+
+- [ ] **Step 2: Verify fail**
+
+Run: `pnpm test:int -- scan-image-refs`
+Expected: FAIL.
+
+- [ ] **Step 3: Implement**
+
+```ts
+// scripts/import/scan-image-refs.ts
+const MD_IMG = /!\[[^\]]*\]\((\/images\/[^)\s]+)\)/g
+const HTML_IMG = /
]+src=["'](\/images\/[^"']+)["']/g
+
+export function scanImageRefs(source: string): string[] {
+ const set = new Set()
+ for (const m of source.matchAll(MD_IMG)) set.add(m[1])
+ for (const m of source.matchAll(HTML_IMG)) set.add(m[1])
+ return [...set]
+}
+```
+
+- [ ] **Step 4: Run tests**
+
+Run: `pnpm test:int -- scan-image-refs`
+Expected: PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add scripts/import/scan-image-refs.ts tests/int/scan-image-refs.int.spec.ts
+git commit -m "feat(import): scan markdown/html for image refs"
+```
+
+### Task 3.7: Orchestrator skeleton with `--dry-run`
+
+**Files:**
+- Create: `scripts/import-from-astro.ts`
+
+- [ ] **Step 1: Implement**
+
+```ts
+// scripts/import-from-astro.ts
+import path from 'node:path'
+import { fileURLToPath } from 'node:url'
+import { getPayload } from 'payload'
+import config from '@/payload.config'
+import { discoverAll } from './import/discover'
+import { collectUniqueTags, upsertTags } from './import/tags'
+import { scanImageRefs } from './import/scan-image-refs'
+
+const args = new Map(
+ process.argv.slice(2).map((a) => {
+ const m = a.match(/^--([^=]+)(?:=(.*))?$/)
+ return m ? [m[1], m[2] ?? true] : ['_', a]
+ }),
+)
+const dryRun = args.get('dry-run') === true
+const sourceRoot =
+ (args.get('source') as string | undefined) ??
+ path.resolve(fileURLToPath(import.meta.url), '../../../arter-blog')
+
+async function main() {
+ console.log(`[import] source=${sourceRoot} dryRun=${dryRun}`)
+ const { posts, projects, pages } = await discoverAll(sourceRoot)
+ console.log(`[import] discovered: ${posts.length} posts, ${projects.length} projects, ${pages.length} pages`)
+
+ const tags = collectUniqueTags(posts.map((p) => ({ tags: p.frontmatter.tags ?? [] })))
+ console.log(`[import] unique tags: ${tags.length}`)
+
+ const allRefs = new Set()
+ for (const p of [...posts, ...projects, ...pages]) {
+ for (const ref of scanImageRefs(p.body)) allRefs.add(ref)
+ const seoSrc = (p.frontmatter as { seo?: { image?: { src?: string } } }).seo?.image?.src
+ if (seoSrc?.startsWith('/images/')) allRefs.add(seoSrc)
+ }
+ console.log(`[import] unique image refs: ${allRefs.size}`)
+
+ if (dryRun) {
+ console.log('[import] --dry-run: skipping all writes')
+ return
+ }
+
+ const payload = await getPayload({ config })
+ const tagIdBySlug = await upsertTags(payload, tags)
+ console.log(`[import] tags upserted: ${tagIdBySlug.size}`)
+ // (image upload and content write follow in later tasks)
+}
+
+main().catch((err) => {
+ console.error(err)
+ process.exit(1)
+})
+```
+
+- [ ] **Step 2: Smoke-test dry run**
+
+Run: `pnpm import:astro --dry-run`
+Expected: prints discovery counts (43/8/2 ish) and aborts before writing.
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add scripts/import-from-astro.ts
+git commit -m "feat(import): orchestrator with --dry-run and tag pass"
+```
+
+---
+
+## Phase 4 — Markdown → Lexical converter
+
+**Milestone:** every `.md`/`.mdx` file in the old project converts to a Lexical JSON tree without crashing. ``, ``, ``, raw `
`, and standard markdown all map to the right Lexical nodes/blocks. Conversion is unit-tested for each construct.
+
+### Task 4.1: MDX pre-processor — replace JSX with placeholder fences
+
+**Files:**
+- Create: `scripts/import/preprocess-mdx.ts`
+- Create: `tests/int/preprocess-mdx.int.spec.ts`
+
+- [ ] **Step 1: Failing test**
+
+```ts
+// tests/int/preprocess-mdx.int.spec.ts
+import { describe, expect, it } from 'vitest'
+import { preprocessMdx } from '../../scripts/import/preprocess-mdx'
+
+describe('preprocessMdx', () => {
+ it('replaces self-closing LinkPreview with a placeholder fence', () => {
+ const out = preprocessMdx('')
+ expect(out).toMatch(/```block:linkPreview/)
+ expect(out).toMatch(/"url": ?"https:\/\/x\.com\/"/)
+ })
+ it('replaces ListCallout with a callout placeholder containing inner content', () => {
+ const out = preprocessMdx('\n- a\n- b\n')
+ expect(out).toMatch(/```block:callout/)
+ expect(out).toMatch(/"title": ?"T"/)
+ expect(out).toMatch(/- a/)
+ })
+ it('leaves plain markdown untouched', () => {
+ expect(preprocessMdx('# Hi\nhello')).toBe('# Hi\nhello')
+ })
+})
+```
+
+- [ ] **Step 2: Verify fail**
+
+Run: `pnpm test:int -- preprocess-mdx`
+Expected: FAIL.
+
+- [ ] **Step 3: Implement**
+
+```ts
+// scripts/import/preprocess-mdx.ts
+const SELF_CLOSING = /<(LinkPreview)\s+([^/>]*)\/>/g
+const PAIRED = /<(ListCallout|Callout)\s*([^>]*)>([\s\S]*?)<\/\1>/g
+
+function attrsToObject(attrs: string): Record {
+ const out: Record = {}
+ for (const m of attrs.matchAll(/(\w+)=["']([^"']*)["']/g)) {
+ out[m[1]] = m[2]
+ }
+ return out
+}
+
+export function preprocessMdx(source: string): string {
+ let out = source.replace(SELF_CLOSING, (_match, _tag, attrs: string) => {
+ const obj = attrsToObject(attrs)
+ return ['```block:linkPreview', JSON.stringify(obj, null, 2), '```'].join('\n')
+ })
+ out = out.replace(PAIRED, (_match, _tag, attrs: string, inner: string) => {
+ const obj = attrsToObject(attrs)
+ const payload = { ...obj, _children: inner.trim() }
+ return ['```block:callout', JSON.stringify(payload, null, 2), '```'].join('\n')
+ })
+ return out
+}
+```
+
+- [ ] **Step 4: Run tests**
+
+Run: `pnpm test:int -- preprocess-mdx`
+Expected: PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add scripts/import/preprocess-mdx.ts tests/int/preprocess-mdx.int.spec.ts
+git commit -m "feat(import): MDX-to-fence preprocessor"
+```
+
+### Task 4.2: OG fetch with disk cache
+
+**Files:**
+- Create: `scripts/import/og-fetch.ts`
+
+- [ ] **Step 1: Implement**
+
+```ts
+// scripts/import/og-fetch.ts
+import { readFile, mkdir, writeFile } from 'node:fs/promises'
+import { dirname } from 'node:path'
+
+const CACHE_PATH = '.import-cache/og.json'
+type Cache = Record
+export type OgMeta = { title?: string; description?: string; image?: string }
+
+async function load(): Promise {
+ try {
+ return JSON.parse(await readFile(CACHE_PATH, 'utf8'))
+ } catch {
+ return {}
+ }
+}
+
+async function save(c: Cache) {
+ await mkdir(dirname(CACHE_PATH), { recursive: true })
+ await writeFile(CACHE_PATH, JSON.stringify(c, null, 2))
+}
+
+function pick(html: string, prop: string): string | undefined {
+ const re = new RegExp(
+ `]+(?:property|name)=["']${prop}["'][^>]+content=["']([^"']+)["']`,
+ 'i',
+ )
+ return html.match(re)?.[1]
+}
+
+export async function fetchOg(url: string, cache: Cache): Promise {
+ if (cache[url]) return cache[url]
+ try {
+ const res = await fetch(url, { headers: { 'user-agent': 'arter-blog-importer/1.0' } })
+ if (!res.ok) throw new Error(`HTTP ${res.status}`)
+ const html = await res.text()
+ const meta: OgMeta = {
+ title: pick(html, 'og:title') ?? pick(html, 'twitter:title'),
+ description: pick(html, 'og:description') ?? pick(html, 'twitter:description'),
+ image: pick(html, 'og:image') ?? pick(html, 'twitter:image'),
+ }
+ cache[url] = meta
+ return meta
+ } catch (err) {
+ console.warn(`[og] failed for ${url}: ${(err as Error).message}`)
+ cache[url] = {}
+ return {}
+ }
+}
+
+export async function loadOgCache() {
+ return load()
+}
+export async function persistOgCache(c: Cache) {
+ await save(c)
+}
+```
+
+- [ ] **Step 2: Commit**
+
+```bash
+git add scripts/import/og-fetch.ts
+git commit -m "feat(import): OG metadata fetcher with disk cache"
+```
+
+### Task 4.3: Markdown→Lexical conversion
+
+**Files:**
+- Create: `scripts/import/markdown-to-lexical.ts`
+- Create: `tests/int/markdown-to-lexical.int.spec.ts`
+
+- [ ] **Step 1: Failing test**
+
+```ts
+// tests/int/markdown-to-lexical.int.spec.ts
+import { describe, expect, it } from 'vitest'
+import { markdownToLexical } from '../../scripts/import/markdown-to-lexical'
+
+const stubCtx = {
+ imageMap: new Map([['/images/foo.png', 'media-1']]),
+ fetchOg: async (_url: string) => ({}),
+}
+
+describe('markdownToLexical', () => {
+ it('produces a root with paragraph for plain text', async () => {
+ const r = await markdownToLexical('hello world', stubCtx)
+ expect(r.root.type).toBe('root')
+ expect(r.root.children[0].type).toBe('paragraph')
+ })
+
+ it('preserves headings as heading nodes', async () => {
+ const r = await markdownToLexical('## Section', stubCtx)
+ const h = r.root.children[0]
+ expect(h.type).toBe('heading')
+ expect(h.tag).toBe('h2')
+ })
+
+ it('replaces images that match imageMap with mediaBlock', async () => {
+ const r = await markdownToLexical('', stubCtx)
+ const node = r.root.children[0]
+ expect(node.type).toBe('block')
+ expect(node.fields.blockType).toBe('mediaBlock')
+ expect(node.fields.media).toBe('media-1')
+ })
+
+ it('handles ```block:linkPreview fences', async () => {
+ const md = '```block:linkPreview\n{"url":"https://x.com/"}\n```'
+ const r = await markdownToLexical(md, stubCtx)
+ expect(r.root.children[0].fields.blockType).toBe('linkPreview')
+ expect(r.root.children[0].fields.url).toBe('https://x.com/')
+ })
+
+ it('handles ```block:callout fences with nested content', async () => {
+ const md = '```block:callout\n{"title":"T","_children":"hi"}\n```'
+ const r = await markdownToLexical(md, stubCtx)
+ expect(r.root.children[0].fields.blockType).toBe('callout')
+ expect(r.root.children[0].fields.title).toBe('T')
+ expect(r.root.children[0].fields.content.root.children[0].type).toBe('paragraph')
+ })
+})
+```
+
+- [ ] **Step 2: Verify fail**
+
+Run: `pnpm test:int -- markdown-to-lexical`
+Expected: FAIL.
+
+- [ ] **Step 3: Implement**
+
+```ts
+// scripts/import/markdown-to-lexical.ts
+import { unified } from 'unified'
+import remarkParse from 'remark-parse'
+import type { Root, Content, Image, Code, Paragraph, Html } from 'mdast'
+import type { OgMeta } from './og-fetch'
+
+export type Ctx = {
+ imageMap: Map
+ fetchOg: (url: string) => Promise
+}
+
+type LexNode = Record
+
+const HTML_IMG = /
]+src=["'](\/images\/[^"']+)["'][^>]*?(?:alt=["']([^"']*)["'])?[^>]*?\/?>/g
+
+function textNode(value: string): LexNode {
+ return { type: 'text', text: value, format: 0, version: 1 }
+}
+
+function paragraph(children: LexNode[]): LexNode {
+ return { type: 'paragraph', children, direction: 'ltr', format: '', indent: 0, version: 1 }
+}
+
+function heading(tag: string, text: string): LexNode {
+ return {
+ type: 'heading',
+ tag,
+ children: [textNode(text)],
+ direction: 'ltr',
+ format: '',
+ indent: 0,
+ version: 1,
+ }
+}
+
+function block(blockType: string, fields: Record): LexNode {
+ return { type: 'block', fields: { blockType, ...fields }, format: '', version: 2 }
+}
+
+async function convertMdast(node: Content, ctx: Ctx): Promise {
+ if (node.type === 'paragraph') {
+ const inline = await Promise.all(node.children.map((c) => convertInline(c, ctx)))
+ const flat = inline.flat().filter(Boolean) as LexNode[]
+ return paragraph(flat)
+ }
+ if (node.type === 'heading') {
+ const text = (node.children as { type: string; value?: string }[])
+ .map((c) => c.value ?? '')
+ .join('')
+ return heading(`h${node.depth}`, text)
+ }
+ if (node.type === 'list') {
+ return {
+ type: 'list',
+ listType: node.ordered ? 'number' : 'bullet',
+ tag: node.ordered ? 'ol' : 'ul',
+ start: node.start ?? 1,
+ children: await Promise.all(
+ node.children.map(async (li) => ({
+ type: 'listitem',
+ value: 1,
+ children: (
+ await Promise.all(li.children.map((c) => convertMdast(c as Content, ctx)))
+ ).filter(Boolean),
+ direction: 'ltr',
+ format: '',
+ indent: 0,
+ version: 1,
+ })),
+ ),
+ direction: 'ltr',
+ format: '',
+ indent: 0,
+ version: 1,
+ }
+ }
+ if (node.type === 'code') {
+ return await convertCode(node as Code, ctx)
+ }
+ if (node.type === 'blockquote') {
+ return {
+ type: 'quote',
+ children: (
+ await Promise.all(node.children.map((c) => convertMdast(c as Content, ctx)))
+ ).filter(Boolean),
+ direction: 'ltr',
+ format: '',
+ indent: 0,
+ version: 1,
+ }
+ }
+ if (node.type === 'thematicBreak') {
+ return { type: 'horizontalrule', version: 1 }
+ }
+ if (node.type === 'html') {
+ return convertHtml(node as Html, ctx)
+ }
+ return null
+}
+
+async function convertInline(node: Content, ctx: Ctx): Promise {
+ if (node.type === 'text') return textNode(node.value)
+ if (node.type === 'strong')
+ return {
+ type: 'text',
+ text: (node.children as { value?: string }[]).map((c) => c.value ?? '').join(''),
+ format: 1,
+ version: 1,
+ }
+ if (node.type === 'emphasis')
+ return {
+ type: 'text',
+ text: (node.children as { value?: string }[]).map((c) => c.value ?? '').join(''),
+ format: 2,
+ version: 1,
+ }
+ if (node.type === 'inlineCode')
+ return { type: 'text', text: node.value, format: 16, version: 1 }
+ if (node.type === 'link') {
+ return {
+ type: 'link',
+ url: node.url,
+ children: [textNode((node.children[0] as { value?: string })?.value ?? node.url)],
+ direction: 'ltr',
+ format: '',
+ indent: 0,
+ version: 1,
+ }
+ }
+ if (node.type === 'image') {
+ const img = node as Image
+ const id = ctx.imageMap.get(img.url)
+ if (id) {
+ return block('mediaBlock', { media: id, caption: img.alt ?? '', widthHint: 'full' })
+ }
+ return textNode(`[image: ${img.url}]`)
+ }
+ return null
+}
+
+async function convertCode(node: Code, ctx: Ctx): Promise {
+ if (node.lang === 'block:linkPreview') {
+ const obj = JSON.parse(node.value) as { url: string; title?: string; description?: string }
+ const og = obj.url ? await ctx.fetchOg(obj.url) : {}
+ return block('linkPreview', {
+ url: obj.url,
+ title: obj.title ?? og.title ?? '',
+ description: obj.description ?? og.description ?? '',
+ })
+ }
+ if (node.lang === 'block:callout') {
+ const obj = JSON.parse(node.value) as {
+ title?: string
+ variant?: string
+ _children?: string
+ }
+ const innerLex = obj._children ? await markdownToLexical(obj._children, ctx) : { root: emptyRoot() }
+ return block('callout', {
+ title: obj.title ?? '',
+ variant: obj.variant ?? 'default',
+ content: innerLex,
+ })
+ }
+ return {
+ type: 'code',
+ language: node.lang ?? '',
+ children: [textNode(node.value)],
+ direction: 'ltr',
+ format: '',
+ indent: 0,
+ version: 1,
+ }
+}
+
+function emptyRoot(): LexNode {
+ return { type: 'root', children: [], direction: 'ltr', format: '', indent: 0, version: 1 }
+}
+
+async function convertHtml(node: Html, ctx: Ctx): Promise {
+ // Handle a paragraph that is just an
: turn into mediaBlock
+ const m = [...node.value.matchAll(HTML_IMG)]
+ if (m.length === 1) {
+ const url = m[0][1]
+ const alt = m[0][2] ?? ''
+ const id = ctx.imageMap.get(url)
+ if (id) return block('mediaBlock', { media: id, caption: alt, widthHint: 'full' })
+ }
+ // Otherwise, drop into a paragraph with raw text fallback
+ return paragraph([textNode(node.value)])
+}
+
+export async function markdownToLexical(source: string, ctx: Ctx) {
+ const tree = unified().use(remarkParse).parse(source) as Root
+ const children: LexNode[] = []
+ for (const child of tree.children) {
+ const out = await convertMdast(child as Content, ctx)
+ if (out) children.push(out)
+ }
+ return {
+ root: {
+ type: 'root',
+ children,
+ direction: 'ltr',
+ format: '',
+ indent: 0,
+ version: 1,
+ },
+ }
+}
+```
+
+- [ ] **Step 4: Run tests**
+
+Run: `pnpm test:int -- markdown-to-lexical`
+Expected: PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add scripts/import/markdown-to-lexical.ts tests/int/markdown-to-lexical.int.spec.ts
+git commit -m "feat(import): markdown to lexical converter"
+```
+
+### Task 4.4: Wire converter into orchestrator (still dry-run safe)
+
+**Files:**
+- Modify: `scripts/import-from-astro.ts`
+
+- [ ] **Step 1: Extend the orchestrator to perform a full per-file conversion in dry-run**
+
+Replace the body of `main` with the version that:
+1. Loads `imageCache` and `ogCache`.
+2. After the `dryRun` check, walks each post/project/page; for each, runs `preprocessMdx` (if `.mdx`) then `markdownToLexical` to ensure conversion succeeds.
+3. Logs warnings instead of throwing; tracks an `errors` counter; exits non-zero if any.
+
+```ts
+import { preprocessMdx } from './import/preprocess-mdx'
+import { markdownToLexical } from './import/markdown-to-lexical'
+import { loadImageCache, persistImageCache } from './import/images'
+import { loadOgCache, persistOgCache, fetchOg } from './import/og-fetch'
+
+// inside main(), after discovery + image refs scan:
+const imageCache = await loadImageCache()
+const ogCache = await loadOgCache()
+
+const ctx = {
+ imageMap: new Map(Object.entries(imageCache)),
+ fetchOg: (url: string) => fetchOg(url, ogCache),
+}
+
+let errors = 0
+for (const file of [...posts, ...projects, ...pages]) {
+ try {
+ const pre = file.isMdx ? preprocessMdx(file.body) : file.body
+ await markdownToLexical(pre, ctx)
+ } catch (err) {
+ errors += 1
+ console.error(`[convert] ${file.filePath}: ${(err as Error).message}`)
+ }
+}
+console.log(`[import] conversion check: ${errors} errors`)
+await persistImageCache(imageCache)
+await persistOgCache(ogCache)
+
+if (errors > 0) process.exit(1)
+if (dryRun) return
+```
+
+- [ ] **Step 2: Run dry-run end-to-end**
+
+Run: `pnpm import:astro --dry-run`
+Expected: counts printed, conversion errors = 0, exits 0.
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add scripts/import-from-astro.ts
+git commit -m "feat(import): full conversion check in dry-run"
+```
+
+---
+
+## Phase 5 — Import script: writes
+
+**Milestone:** `pnpm import:astro` (no flag) populates the local D1 with all 43 posts, 8 projects, 2 pages, all tags, all post images uploaded to local R2. Re-running is a no-op (slugs match, image cache hits).
+
+### Task 5.1: Image pre-pass (uploads before conversion)
+
+**Files:**
+- Modify: `scripts/import-from-astro.ts`
+
+- [ ] **Step 1: Add image upload loop**
+
+After tag upsert, before the per-file conversion check, add:
+
+```ts
+// upload all unique image refs first
+for (const ref of allRefs) {
+ const id = await uploadImage(payload, ref, sourceRoot, ref.split('/').pop() ?? ref, imageCache)
+ if (id !== undefined) imageCache[ref] = id
+}
+ctx.imageMap = new Map(Object.entries(imageCache))
+console.log(`[import] images uploaded/cached: ${Object.keys(imageCache).length}`)
+```
+
+(Add `import { uploadImage } from './import/images'` at the top.)
+
+- [ ] **Step 2: Verify in admin**
+
+Run: `pnpm import:astro` and visit http://localhost:3000/admin/collections/media
+Expected: ~30+ media entries.
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add scripts/import-from-astro.ts
+git commit -m "feat(import): upload images before content conversion"
+```
+
+### Task 5.2: Record upsert helper
+
+**Files:**
+- Create: `scripts/import/upsert-record.ts`
+
+- [ ] **Step 1: Implement**
+
+```ts
+// scripts/import/upsert-record.ts
+import type { Payload } from 'payload'
+
+export type Slug = 'posts' | 'projects' | 'pages'
+
+export async function upsertRecord(
+ payload: Payload,
+ collection: Slug,
+ slug: string,
+ data: Record,
+) {
+ const existing = await payload.find({
+ collection,
+ where: { slug: { equals: slug } },
+ limit: 1,
+ })
+ if (existing.docs[0]) {
+ await payload.update({
+ collection,
+ id: existing.docs[0].id,
+ data: { ...data, slug },
+ })
+ return { action: 'updated' as const }
+ }
+ await payload.create({ collection, data: { ...data, slug } })
+ return { action: 'created' as const }
+}
+```
+
+- [ ] **Step 2: Commit**
+
+```bash
+git add scripts/import/upsert-record.ts
+git commit -m "feat(import): record upsert helper"
+```
+
+### Task 5.3: Write posts
+
+**Files:**
+- Modify: `scripts/import-from-astro.ts`
+
+- [ ] **Step 1: Add the posts loop**
+
+Replace the conversion-check loop with one that builds full data and upserts. For posts:
+
+```ts
+import { upsertRecord } from './import/upsert-record'
+
+// ... after image upload + ctx setup (tagIdBySlug already captured from Task 3.7)
+let created = 0, updated = 0
+for (const file of posts) {
+ const pre = file.isMdx ? preprocessMdx(file.body) : file.body
+ const body = await markdownToLexical(pre, ctx)
+ const fm = file.frontmatter
+ const tagIds = (fm.tags ?? [])
+ .map((t) => tagIdBySlug.get(slugify(t)))
+ .filter((id): id is string | number => id !== undefined)
+ const seoSrc = fm.seo?.image?.src
+ const coverImage =
+ seoSrc?.startsWith('/images/') ? imageCache[seoSrc] : undefined
+ const data = {
+ title: fm.title,
+ subtitle: fm.subtitle,
+ excerpt: fm.excerpt,
+ publishDate: fm.publishDate,
+ updatedDate: fm.updatedDate,
+ isFeatured: fm.isFeatured,
+ tags: tagIds,
+ coverImage,
+ seo: {
+ title: fm.seo?.title,
+ description: fm.seo?.description,
+ pageType: fm.seo?.pageType ?? 'article',
+ image: coverImage,
+ },
+ body,
+ _status: 'published',
+ }
+ const r = await upsertRecord(payload, 'posts', file.slug, data)
+ r.action === 'created' ? created++ : updated++
+}
+console.log(`[import] posts: ${created} created, ${updated} updated`)
+```
+
+(Add `import { slugify } from '@/lib/slugify'`.)
+
+- [ ] **Step 2: Run import**
+
+Run: `pnpm import:astro`
+Expected: posts loop reports 43 created (or 0 created / 43 updated on a re-run).
+
+- [ ] **Step 3: Spot-check admin**
+
+Visit http://localhost:3000/admin/collections/posts and open a few posts. Confirm titles, dates, tag chips, and rich-text body render.
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add scripts/import-from-astro.ts
+git commit -m "feat(import): write posts into Payload"
+```
+
+### Task 5.4: Write projects and pages
+
+**Files:**
+- Modify: `scripts/import-from-astro.ts`
+
+- [ ] **Step 1: Add project loop (after posts loop)**
+
+```ts
+let pCreated = 0, pUpdated = 0
+for (const file of projects) {
+ const pre = file.isMdx ? preprocessMdx(file.body) : file.body
+ const body = await markdownToLexical(pre, ctx)
+ const fm = file.frontmatter
+ const seoSrc = fm.seo?.image?.src
+ const coverImage = seoSrc?.startsWith('/images/') ? imageCache[seoSrc] : undefined
+ const data = {
+ title: fm.title,
+ description: fm.description,
+ publishDate: fm.publishDate,
+ isFeatured: fm.isFeatured,
+ coverImage,
+ seo: {
+ title: fm.seo?.title,
+ description: fm.seo?.description,
+ pageType: fm.seo?.pageType ?? 'article',
+ image: coverImage,
+ },
+ body,
+ _status: 'published',
+ }
+ const r = await upsertRecord(payload, 'projects', file.slug, data)
+ r.action === 'created' ? pCreated++ : pUpdated++
+}
+console.log(`[import] projects: ${pCreated} created, ${pUpdated} updated`)
+```
+
+- [ ] **Step 2: Add pages loop**
+
+```ts
+let pgCreated = 0, pgUpdated = 0
+for (const file of pages) {
+ const pre = file.isMdx ? preprocessMdx(file.body) : file.body
+ const body = await markdownToLexical(pre, ctx)
+ const fm = file.frontmatter
+ const seoSrc = fm.seo?.image?.src
+ const coverImage = seoSrc?.startsWith('/images/') ? imageCache[seoSrc] : undefined
+ const data = {
+ title: fm.title,
+ subtitle: fm.subtitle,
+ description: fm.description,
+ seo: {
+ title: fm.seo?.title,
+ description: fm.seo?.description,
+ pageType: fm.seo?.pageType ?? 'website',
+ image: coverImage,
+ },
+ body,
+ _status: 'published',
+ }
+ const r = await upsertRecord(payload, 'pages', file.slug, data)
+ r.action === 'created' ? pgCreated++ : pgUpdated++
+}
+console.log(`[import] pages: ${pgCreated} created, ${pgUpdated} updated`)
+```
+
+- [ ] **Step 3: Run end-to-end**
+
+Run: `pnpm import:astro`
+Expected: 43 posts, 8 projects, 2 pages, ~30 tags. Second run: all updated, none created.
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add scripts/import-from-astro.ts
+git commit -m "feat(import): write projects and pages into Payload"
+```
+
+---
+
+## Phase 6 — Lexical renderer
+
+**Milestone:** a Lexical JSON tree from Payload renders as accessible HTML in a browser. Headings have stable anchor IDs. Custom blocks render with the right components. `mediaBlock` resolves to an R2 URL via `next/image`.
+
+### Task 6.1: Server-side heading anchor utility (TDD)
+
+**Files:**
+- Create: `src/lib/lexical-headings.ts`
+- Create: `tests/int/lexical-headings.int.spec.ts`
+
+- [ ] **Step 1: Failing test**
+
+```ts
+// tests/int/lexical-headings.int.spec.ts
+import { describe, expect, it } from 'vitest'
+import { extractHeadingsFromLexical, anchorFor } from '@/lib/lexical-headings'
+
+const sample = {
+ root: {
+ children: [
+ { type: 'heading', tag: 'h2', children: [{ type: 'text', text: 'Hello World' }] },
+ { type: 'heading', tag: 'h3', children: [{ type: 'text', text: 'Sub' }] },
+ { type: 'paragraph', children: [{ type: 'text', text: 'p' }] },
+ ],
+ },
+}
+
+describe('extractHeadingsFromLexical', () => {
+ it('returns nested headings with slugified ids', () => {
+ const r = extractHeadingsFromLexical(sample)
+ expect(r).toEqual([
+ {
+ id: 'hello-world',
+ text: 'Hello World',
+ level: 2,
+ children: [{ id: 'sub', text: 'Sub', level: 3, children: [] }],
+ },
+ ])
+ })
+})
+
+describe('anchorFor', () => {
+ it('matches the same slug used in headings list', () => {
+ expect(anchorFor({ children: [{ type: 'text', text: 'Hello World' }] })).toBe('hello-world')
+ })
+})
+```
+
+- [ ] **Step 2: Verify fail**
+
+Run: `pnpm test:int -- lexical-headings`
+Expected: FAIL.
+
+- [ ] **Step 3: Implement**
+
+```ts
+// src/lib/lexical-headings.ts
+import { slugify } from './slugify'
+
+export type Heading = { id: string; text: string; level: number; children: Heading[] }
+
+type Node = { type: string; tag?: string; children?: Node[]; text?: string }
+
+function textOf(node: { children?: Node[] }): string {
+ return (node.children ?? []).map((c) => c.text ?? '').join('')
+}
+
+export function anchorFor(node: { children?: Node[] }): string {
+ return slugify(textOf(node))
+}
+
+export function extractHeadingsFromLexical(value: { root: { children: Node[] } }): Heading[] {
+ const flat: Heading[] = []
+ for (const node of value.root.children) {
+ if (node.type !== 'heading' || !node.tag) continue
+ const level = Number(node.tag.replace('h', ''))
+ if (level < 2 || level > 6) continue
+ const text = textOf(node)
+ flat.push({ id: slugify(text), text, level, children: [] })
+ }
+ // build nesting
+ const root: Heading[] = []
+ for (const h of flat) {
+ let inserted = false
+ for (let i = root.length - 1; i >= 0; i--) {
+ if (root[i].level < h.level) {
+ let cursor = root[i]
+ while (cursor.children.length && cursor.children[cursor.children.length - 1].level < h.level) {
+ cursor = cursor.children[cursor.children.length - 1]
+ }
+ cursor.children.push(h)
+ inserted = true
+ break
+ }
+ }
+ if (!inserted) root.push(h)
+ }
+ return root
+}
+```
+
+- [ ] **Step 4: Run tests**
+
+Run: `pnpm test:int -- lexical-headings`
+Expected: PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/lib/lexical-headings.ts tests/int/lexical-headings.int.spec.ts
+git commit -m "feat(lexical): server-side heading extraction"
+```
+
+### Task 6.2: Custom block React components
+
+**Files:**
+- Create: `src/components/lexical/Callout.tsx`
+- Create: `src/components/lexical/LinkPreviewCard.tsx`
+- Create: `src/components/lexical/PostImage.tsx`
+
+- [ ] **Step 1: Implement Callout**
+
+```tsx
+// src/components/lexical/Callout.tsx
+import type { ReactNode } from 'react'
+
+export function Callout({
+ title,
+ variant = 'default',
+ children,
+}: {
+ title?: string
+ variant?: 'default' | 'note' | 'warning' | 'tip'
+ children: ReactNode
+}) {
+ return (
+
+ )
+}
+```
+
+- [ ] **Step 2: Implement LinkPreviewCard**
+
+```tsx
+// src/components/lexical/LinkPreviewCard.tsx
+import Image from 'next/image'
+
+export function LinkPreviewCard({
+ url,
+ title,
+ description,
+ imageUrl,
+}: {
+ url: string
+ title?: string
+ description?: string
+ imageUrl?: string
+}) {
+ let host = url
+ try { host = new URL(url).host } catch {}
+ return (
+
+
+
{host}
+ {title &&
{title}
}
+ {description &&
{description}
}
+
+ {imageUrl && (
+
+ )}
+
+ )
+}
+```
+
+- [ ] **Step 3: Implement PostImage**
+
+```tsx
+// src/components/lexical/PostImage.tsx
+import Image from 'next/image'
+
+const widthClass: Record = {
+ inline: 'max-w-[24rem]',
+ wide: 'max-w-[56rem]',
+ full: '',
+}
+
+export function PostImage({
+ src,
+ alt,
+ caption,
+ widthHint = 'full',
+}: {
+ src: string
+ alt: string
+ caption?: string
+ widthHint?: 'full' | 'wide' | 'inline'
+}) {
+ return (
+
+
+ {caption && {caption}}
+
+ )
+}
+```
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add src/components/lexical
+git commit -m "feat(lexical): callout, link preview, post image components"
+```
+
+### Task 6.3: Lexical renderer
+
+**Files:**
+- Create: `src/components/lexical/Render.tsx`
+- Create: `src/lib/media-url.ts`
+
+- [ ] **Step 1: Media URL helper**
+
+```ts
+// src/lib/media-url.ts
+import type { Media } from '@/payload-types'
+
+export function mediaUrl(m: Media | string | number | null | undefined): string | undefined {
+ if (!m || typeof m === 'string' || typeof m === 'number') return undefined
+ return m.url ?? undefined
+}
+```
+
+- [ ] **Step 2: Render component**
+
+```tsx
+// src/components/lexical/Render.tsx
+import { Fragment, type ReactNode } from 'react'
+import { Callout } from './Callout'
+import { LinkPreviewCard } from './LinkPreviewCard'
+import { PostImage } from './PostImage'
+import { anchorFor } from '@/lib/lexical-headings'
+import { mediaUrl } from '@/lib/media-url'
+
+type Node = { type: string; [key: string]: any }
+
+function renderChildren(children: Node[] | undefined): ReactNode {
+ return (children ?? []).map((c, i) => {renderNode(c)})
+}
+
+function renderText(node: Node): ReactNode {
+ let el: ReactNode = node.text
+ const fmt = (node.format as number | undefined) ?? 0
+ if (fmt & 1) el = {el}
+ if (fmt & 2) el = {el}
+ if (fmt & 16) el = {el}
+ return el
+}
+
+function renderNode(node: Node): ReactNode {
+ switch (node.type) {
+ case 'text':
+ return renderText(node)
+ case 'paragraph':
+ return {renderChildren(node.children)}
+ case 'heading': {
+ const Tag = (node.tag as 'h2' | 'h3' | 'h4' | 'h5' | 'h6') ?? 'h2'
+ const id = anchorFor(node)
+ return {renderChildren(node.children)}
+ }
+ case 'list': {
+ const Tag = node.tag === 'ol' ? 'ol' : 'ul'
+ return {renderChildren(node.children)}
+ }
+ case 'listitem':
+ return {renderChildren(node.children)}
+ case 'quote':
+ return {renderChildren(node.children)}
+ case 'code':
+ return (
+
+
+ {(node.children as Node[])?.map((c) => c.text).join('')}
+
+
+ )
+ case 'horizontalrule':
+ return
+ case 'link':
+ return (
+
+ {renderChildren(node.children)}
+
+ )
+ case 'block': {
+ const f = node.fields ?? {}
+ if (f.blockType === 'callout') {
+ return (
+
+
+
+ )
+ }
+ if (f.blockType === 'linkPreview') {
+ return (
+
+ )
+ }
+ if (f.blockType === 'mediaBlock') {
+ const url = mediaUrl(f.media)
+ if (!url) return null
+ const alt = (typeof f.media === 'object' && f.media?.alt) || f.caption || ''
+ return
+ }
+ return null
+ }
+ default:
+ return null
+ }
+}
+
+export function Render({ value }: { value?: { root: { children: Node[] } } | null }) {
+ if (!value?.root?.children) return null
+ return <>{renderChildren(value.root.children as Node[])}>
+}
+```
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add src/components/lexical/Render.tsx src/lib/media-url.ts
+git commit -m "feat(lexical): server-side renderer"
+```
+
+---
+
+## Phase 7 — Layout and shared components
+
+**Milestone:** root layout includes Nav, Header, Footer, BaseHead-equivalent metadata defaults; navigating to `/` shows the Hero (even with no list content yet); navigating to `/` shows a 404 with the same shell.
+
+For each component port: copy structure verbatim from the Astro source, convert `Astro.props` to React props, swap `class:list` for `clsx`-style conditional joining, use `Link` from `next/link` for internal navigation.
+
+### Task 7.1: Add `clsx` dep and basic helpers
+
+- [ ] **Step 1: Add dep**
+
+```bash
+pnpm add clsx
+git add package.json pnpm-lock.yaml
+git commit -m "chore: add clsx"
+```
+
+### Task 7.2: Port `Header`, `Nav`, `NavLink`, `Footer`
+
+**Files:**
+- Create: `src/components/Header.tsx`
+- Create: `src/components/Nav.tsx`
+- Create: `src/components/NavLink.tsx`
+- Create: `src/components/Footer.tsx`
+- Reference: `~/Documents/projects/arter-blog/src/components/{Header,Nav,NavLink,Footer}.astro`
+
+- [ ] **Step 1: Port**
+
+For each, mirror the Astro component's JSX one-to-one in React. Rules:
+- `` → `` for internal links, `` for external
+- `Astro.url.pathname` → `usePathname()` in NavLink (so it's a client component) — leave `Header`, `Nav`, `Footer` as server components and pass current path down
+- Class strings copy unchanged
+
+- [ ] **Step 2: Verify visually**
+
+Run: `pnpm dev` and load `/`; expect markup similar to the old site (still without Hero data — that's next).
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add src/components/{Header,Nav,NavLink,Footer}.tsx
+git commit -m "feat(components): port Header, Nav, NavLink, Footer"
+```
+
+### Task 7.3: Wire layout to use them
+
+**Files:**
+- Modify: `src/app/(frontend)/layout.tsx`
+
+- [ ] **Step 1: Update layout**
+
+```tsx
+import React from 'react'
+import './styles.css'
+import '@fontsource-variable/inter'
+import '@fontsource-variable/newsreader'
+import '@fontsource/dm-serif-text'
+import '@fontsource/krona-one'
+import { Header } from '@/components/Header'
+import { Footer } from '@/components/Footer'
+import siteConfig from '@/lib/site-config'
+
+export const metadata = {
+ title: { default: siteConfig.title, template: `%s — ${siteConfig.title}` },
+ description: siteConfig.description,
+}
+
+export default async function RootLayout({ children }: { children: React.ReactNode }) {
+ return (
+
+
+
+ {children}
+
+
+
+ )
+}
+```
+
+- [ ] **Step 2: Verify**
+
+Run: `pnpm dev`; `/` shows nav at top + footer at bottom.
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add src/app/(frontend)/layout.tsx
+git commit -m "feat(frontend): wire shared layout shell"
+```
+
+### Task 7.4: Port `Hero`
+
+**Files:**
+- Create: `src/components/Hero.tsx`
+- Reference: `~/Documents/projects/arter-blog/src/components/Hero.astro`
+
+- [ ] **Step 1: Port**
+
+Server component reading from `siteConfig.hero`. Use `next/image` for the hero image. Copy classes verbatim.
+
+- [ ] **Step 2: Commit**
+
+```bash
+git add src/components/Hero.tsx
+git commit -m "feat(components): port Hero"
+```
+
+### Task 7.5: Port utility components used everywhere
+
+**Files:**
+- Create: `src/components/FormattedDate.tsx`
+- Create: `src/components/Breadcrumbs.tsx`
+- Create: `src/components/Pagination.tsx`
+- Reference: same-named `.astro` files in old project
+
+- [ ] **Step 1: Port each verbatim**
+
+- [ ] **Step 2: Commit**
+
+```bash
+git add src/components/{FormattedDate,Breadcrumbs,Pagination}.tsx
+git commit -m "feat(components): port FormattedDate, Breadcrumbs, Pagination"
+```
+
+### Task 7.6: Port JSON-LD schema components
+
+**Files:**
+- Create: `src/components/schema/Article.tsx`
+- Create: `src/components/schema/Breadcrumb.tsx`
+- Create: `src/components/schema/Website.tsx`
+- Reference: `ArticleSchema.astro`, `BreadcrumbSchema.astro`, `WebsiteSchema.astro`
+
+- [ ] **Step 1: Port**
+
+Each emits ``. Keep field names identical to the old components.
+
+- [ ] **Step 2: Commit**
+
+```bash
+git add src/components/schema
+git commit -m "feat(components): port JSON-LD schema components"
+```
+
+---
+
+## Phase 8 — List pages
+
+**Milestone:** `/`, `/blog`, `/blog/page/2`, `/projects`, `/tags`, `/tags/` all render correctly against local Payload data and visually match the old site.
+
+### Task 8.1: Data helpers
+
+**Files:**
+- Create: `src/lib/payload.ts`
+- Create: `src/lib/posts.ts`
+
+- [ ] **Step 1: `getPayloadInstance` helper**
+
+```ts
+// src/lib/payload.ts
+import 'server-only'
+import { getPayload } from 'payload'
+import config from '@/payload.config'
+
+let cached: Awaited> | undefined
+export async function getPayloadInstance() {
+ if (!cached) cached = await getPayload({ config })
+ return cached
+}
+```
+
+- [ ] **Step 2: Posts query helpers**
+
+```ts
+// src/lib/posts.ts
+import { getPayloadInstance } from './payload'
+import type { Post, Tag } from '@/payload-types'
+
+export async function listPosts(opts: { limit?: number; page?: number; tag?: string } = {}) {
+ const payload = await getPayloadInstance()
+ const where: Record = { _status: { equals: 'published' } }
+ if (opts.tag) where['tags.slug'] = { equals: opts.tag }
+ return payload.find({
+ collection: 'posts',
+ where,
+ sort: '-publishDate',
+ limit: opts.limit ?? 8,
+ page: opts.page ?? 1,
+ })
+}
+
+export async function getPostBySlug(slug: string): Promise {
+ const payload = await getPayloadInstance()
+ const r = await payload.find({
+ collection: 'posts',
+ where: { slug: { equals: slug } },
+ limit: 1,
+ depth: 2,
+ })
+ return r.docs[0]
+}
+
+export async function listAllPostSlugs(): Promise {
+ const payload = await getPayloadInstance()
+ const r = await payload.find({
+ collection: 'posts',
+ where: { _status: { equals: 'published' } },
+ limit: 0,
+ pagination: false,
+ })
+ return r.docs.map((d) => d.slug)
+}
+
+export async function listAllTags(): Promise<{ tag: Tag; count: number }[]> {
+ const payload = await getPayloadInstance()
+ const tags = await payload.find({ collection: 'tags', limit: 0, pagination: false })
+ const counts = await Promise.all(
+ tags.docs.map(async (t) => {
+ const r = await payload.count({
+ collection: 'posts',
+ where: { 'tags.slug': { equals: t.slug }, _status: { equals: 'published' } },
+ })
+ return { tag: t, count: r.totalDocs }
+ }),
+ )
+ return counts.filter((c) => c.count > 0)
+}
+```
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add src/lib/payload.ts src/lib/posts.ts
+git commit -m "feat(data): payload + posts query helpers"
+```
+
+### Task 8.2: Port `PostList` and `PostPreview`
+
+**Files:**
+- Create: `src/components/PostList.tsx`
+- Create: `src/components/PostPreview.tsx`
+- Reference: same-named `.astro` files
+
+- [ ] **Step 1: Port**
+
+`PostPreview` renders one post as the old Astro version did, taking a `Post` from `@/payload-types` plus an optional `number` prop (the small numeric label the old list uses). `PostList` accepts `posts: Post[]` and `numbers: number[]`.
+
+- [ ] **Step 2: Commit**
+
+```bash
+git add src/components/{PostList,PostPreview}.tsx
+git commit -m "feat(components): port PostList and PostPreview"
+```
+
+### Task 8.3: Home page
+
+**Files:**
+- Modify: `src/app/(frontend)/page.tsx`
+- Reference: `~/Documents/projects/arter-blog/src/pages/index.astro`
+
+- [ ] **Step 1: Replace contents**
+
+Port the old `index.astro` server logic into a server component. Use `listPosts({ limit: 0 })` then split by `LIFE_TAGS`/`GARAGE_TAGS` exactly as the old code does. Render ``, the three sections, and the topics grid.
+
+- [ ] **Step 2: Verify visually**
+
+Run: `pnpm dev`, load `/`. Compare side-by-side with `https://arter.dev/`.
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add src/app/(frontend)/page.tsx
+git commit -m "feat(frontend): port home page"
+```
+
+### Task 8.4: Blog list with pagination
+
+**Files:**
+- Create: `src/app/(frontend)/blog/page.tsx`
+- Create: `src/app/(frontend)/blog/page/[page]/page.tsx`
+- Reference: `~/Documents/projects/arter-blog/src/pages/blog/[...page].astro`
+
+- [ ] **Step 1: Implement `/blog` (page 1)**
+
+```tsx
+// src/app/(frontend)/blog/page.tsx
+import { listPosts } from '@/lib/posts'
+import { PostList } from '@/components/PostList'
+import { Pagination } from '@/components/Pagination'
+
+export const revalidate = 60
+
+export default async function BlogIndex() {
+ const r = await listPosts({ limit: 8, page: 1 })
+ return (
+
+
Writing
+
r.totalDocs - i)} />
+
+
+ )
+}
+```
+
+- [ ] **Step 2: Implement paginated route**
+
+```tsx
+// src/app/(frontend)/blog/page/[page]/page.tsx
+import { notFound } from 'next/navigation'
+import { listPosts } from '@/lib/posts'
+import { PostList } from '@/components/PostList'
+import { Pagination } from '@/components/Pagination'
+
+export const revalidate = 60
+
+export async function generateStaticParams() {
+ const total = (await listPosts({ limit: 8, page: 1 })).totalPages
+ return Array.from({ length: Math.max(0, total - 1) }, (_, i) => ({ page: String(i + 2) }))
+}
+
+export default async function BlogPage({ params }: { params: Promise<{ page: string }> }) {
+ const { page } = await params
+ const num = Number(page)
+ if (!Number.isInteger(num) || num < 2) notFound()
+ const r = await listPosts({ limit: 8, page: num })
+ if (r.docs.length === 0) notFound()
+ const startNum = r.totalDocs - (num - 1) * 8
+ return (
+
+
Writing
+
startNum - i)} />
+
+
+ )
+}
+```
+
+- [ ] **Step 3: Verify**
+
+Visit `/blog/`, `/blog/page/2/`. Confirm post counts and pagination links.
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add src/app/(frontend)/blog
+git commit -m "feat(frontend): port /blog list with pagination"
+```
+
+### Task 8.5: Projects list and project pages
+
+**Files:**
+- Create: `src/components/ProjectPreview.tsx`
+- Create: `src/lib/projects.ts`
+- Create: `src/app/(frontend)/projects/page.tsx`
+- Create: `src/app/(frontend)/projects/[slug]/page.tsx`
+
+- [ ] **Step 1: `lib/projects.ts`**
+
+```ts
+import { getPayloadInstance } from './payload'
+import type { Project } from '@/payload-types'
+
+export async function listProjects() {
+ const payload = await getPayloadInstance()
+ return payload.find({
+ collection: 'projects',
+ where: { _status: { equals: 'published' } },
+ sort: '-publishDate',
+ limit: 0,
+ pagination: false,
+ })
+}
+
+export async function getProjectBySlug(slug: string): Promise {
+ const payload = await getPayloadInstance()
+ const r = await payload.find({ collection: 'projects', where: { slug: { equals: slug } }, depth: 2, limit: 1 })
+ return r.docs[0]
+}
+
+export async function listAllProjectSlugs(): Promise {
+ const r = await listProjects()
+ return r.docs.map((d) => d.slug)
+}
+```
+
+- [ ] **Step 2: `ProjectPreview.tsx`**
+
+Port verbatim from the Astro counterpart, accepting a `Project` from payload-types.
+
+- [ ] **Step 3: List page**
+
+```tsx
+// src/app/(frontend)/projects/page.tsx
+import { listProjects } from '@/lib/projects'
+import { ProjectPreview } from '@/components/ProjectPreview'
+
+export const revalidate = 60
+
+export default async function ProjectsIndex() {
+ const r = await listProjects()
+ return (
+
+
Projects
+
+ {r.docs.map((p) => (
+
+ ))}
+
+
+ )
+}
+```
+
+- [ ] **Step 4: Detail page (placeholder, full impl in Phase 9)**
+
+```tsx
+// src/app/(frontend)/projects/[slug]/page.tsx
+import { notFound } from 'next/navigation'
+import { getProjectBySlug, listAllProjectSlugs } from '@/lib/projects'
+import { Render } from '@/components/lexical/Render'
+
+export const revalidate = 60
+
+export async function generateStaticParams() {
+ return (await listAllProjectSlugs()).map((slug) => ({ slug }))
+}
+
+export default async function ProjectPage({ params }: { params: Promise<{ slug: string }> }) {
+ const { slug } = await params
+ const project = await getProjectBySlug(slug)
+ if (!project) notFound()
+ return (
+
+ {project.title}
+
+
+
+
+ )
+}
+```
+
+- [ ] **Step 5: Verify**
+
+Run: `pnpm dev`, hit `/projects/` and one project URL.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add src/components/ProjectPreview.tsx src/lib/projects.ts src/app/(frontend)/projects
+git commit -m "feat(frontend): port projects list and detail"
+```
+
+### Task 8.6: Tags pages
+
+**Files:**
+- Create: `src/app/(frontend)/tags/page.tsx`
+- Create: `src/app/(frontend)/tags/[slug]/page.tsx`
+- Reference: `~/Documents/projects/arter-blog/src/pages/tags/index.astro`, `tags/[id]/...`
+
+- [ ] **Step 1: Tag cloud**
+
+```tsx
+// src/app/(frontend)/tags/page.tsx
+import Link from 'next/link'
+import { listAllTags } from '@/lib/posts'
+
+export const revalidate = 60
+
+export default async function TagsPage() {
+ const tags = await listAllTags()
+ tags.sort((a, b) => b.count - a.count)
+ return (
+
+
Tags
+
+ {tags.map(({ tag, count }) => (
+ -
+
+ {tag.name}
+
+ {count} post{count === 1 ? '' : 's'}
+
+
+
+ ))}
+
+
+ )
+}
+```
+
+- [ ] **Step 2: Tag detail**
+
+```tsx
+// src/app/(frontend)/tags/[slug]/page.tsx
+import { notFound } from 'next/navigation'
+import { listPosts, listAllTags } from '@/lib/posts'
+import { PostList } from '@/components/PostList'
+
+export const revalidate = 60
+
+export async function generateStaticParams() {
+ const tags = await listAllTags()
+ return tags.map(({ tag }) => ({ slug: tag.slug }))
+}
+
+export default async function TagPage({ params }: { params: Promise<{ slug: string }> }) {
+ const { slug } = await params
+ const r = await listPosts({ tag: slug, limit: 0 })
+ if (r.docs.length === 0) notFound()
+ return (
+
+
#{slug}
+
r.totalDocs - i)} />
+
+ )
+}
+```
+
+- [ ] **Step 3: Verify**
+
+Visit `/tags/`, click a tag, confirm posts list.
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add src/app/(frontend)/tags
+git commit -m "feat(frontend): port /tags index and detail"
+```
+
+---
+
+## Phase 9 — Detail pages: posts, project bodies, catch-all pages
+
+**Milestone:** `/blog//` renders a real post with TOC, share links, breadcrumbs, JSON-LD, footnotes, prev/next, and Giscus. `/about/`, `/hire-me/` resolve via the catch-all.
+
+### Task 9.1: Port `TableOfContents` (server) and `MobileTableOfContents` (client)
+
+**Files:**
+- Create: `src/components/TableOfContents.tsx`
+- Create: `src/components/MobileTableOfContents.tsx`
+- Reference: `~/Documents/projects/arter-blog/src/components/TableOfContents.astro` and the `