From ccf015b9224352742ece57f469120b1eae8d05ef Mon Sep 17 00:00:00 2001 From: Chris Arter Date: Fri, 8 May 2026 11:15:46 -0400 Subject: [PATCH] =?UTF-8?q?docs:=20add=20Astro=E2=86=92Payload=20migration?= =?UTF-8?q?=20implementation=20plan?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 14-phase implementation plan derived from the design spec. Phase 0 foundation; Phases 1–2 schema + custom blocks; Phases 3–5 import script; Phases 6–11 frontend, Lexical renderer, components, list/detail pages, RSS/sitemap/OG; Phase 12 e2e + visual diff; Phases 13–14 prod deploy + cutover. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../2026-05-08-astro-to-payload-migration.md | 3966 +++++++++++++++++ 1 file changed, 3966 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-08-astro-to-payload-migration.md 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('![alt](/images/foo.png)\n![x](/images/bar.jpg)') + 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('![a](/images/x.png) ![b](/images/x.png)') + expect(r).toEqual(['/images/x.png']) + }) + it('ignores external URLs', () => { + const r = scanImageRefs('![a](https://example.com/x.png)') + 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('![Alt](/images/foo.png)', 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 ( +
+ {alt} + {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}
    +