diff --git a/docs/infra.md b/docs/infra.md new file mode 100644 index 0000000..97be9a7 --- /dev/null +++ b/docs/infra.md @@ -0,0 +1,19 @@ +# Infrastructure notes (state that lives outside this repo) + +## Domains (Vercel dashboard → Settings → Domains) + +- Production domains: `hackimi.dev` (apex) and `www.hackimi.dev`. +- **Canonical host: apex `https://hackimi.dev`** — every canonical tag, sitemap URL, robots Sitemap line, and JSON-LD url in this repo uses apex. +- Redirect direction: `www` → 308 → apex. (Flipped 2026-08-\_\_ by Nima; before that the dashboard had apex → www, contradicting the code's canonicals.) +- `hackimi.vercel.app` additionally 308s to apex via `next.config.js` (exact-host rule — preview URLs on \*.vercel.app are deliberately not matched). + +## Deployments + +- Production deploys are automatic but CI-gated (commit `1c92564`, 2026-06-02): GitHub Actions runs lint/types/Vitest/Playwright on every push, and a deploy job (`vercel build` + `vercel deploy --prebuilt --prod`) runs only on `main` after the tests pass. +- `vercel.json` sets `git.deploymentEnabled.main: false` to disable Vercel's OWN git auto-deploy for `main` only — so production flows exclusively through the tested CI gate while PR/branch previews keep deploying via Vercel as normal. Merging to `main` therefore DOES deploy, via CI. + +## Search Console + +- Property: `https://hackimi.dev` (URL-prefix), meta-tag verification. +- Token lives in `SITE_GOOGLE_SITE_VERIFICATION` (`src/lib/site.ts`); empty string disables the tag entirely. +- After each SEO-relevant deploy: submit `sitemap.xml`, request indexing of changed URLs. diff --git a/docs/superpowers/plans/2026-08-09-seo-name-query.md b/docs/superpowers/plans/2026-08-09-seo-name-query.md new file mode 100644 index 0000000..6c61371 --- /dev/null +++ b/docs/superpowers/plans/2026-08-09-seo-name-query.md @@ -0,0 +1,1005 @@ +# SEO Name-Query 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:** Make hackimi.dev win the "Nima Hakimi" name query: recover the dead `/projects` ranking URL, name-led titles, entity-graph JSON-LD, favicon + branded OG card, honest sitemap, Search Console wiring. + +**Architecture:** All changes ride the existing metadata pipeline (`src/lib/site.ts` constants → `src/lib/metadata.ts` builders → per-page `metadata` exports). New surfaces: `next.config.js` redirects, a `JsonLd` component for structured-data blocks, a site footer, Next file-convention icons, and a committed asset-generator script. + +**Tech Stack:** Next.js 16 App Router (Turbopack), React 19, Vitest, Playwright, Prettier 3.8.3. + +**Spec:** `docs/superpowers/specs/2026-08-09-seo-name-query-design.md` + +## Global Constraints + +- Positioning wording in SEO surfaces: exactly `Developer & AI Engineer` (prose form: `developer and AI engineer`). **Visible site copy keeps "frontend"** — do NOT touch hero/about/contact/case-study visible text (`src/app/page.tsx` JSX, `about` prose, `ContactClient` blurb, `src/data/*` narratives). +- Canonical host is apex `https://hackimi.dev` everywhere in code. Never introduce `www.` +- TDD: every task starts with a failing test. Run tests with `npm run test` (Vitest) / `npm run test:e2e` (Playwright). +- Playwright locally requires **unsandboxed Bash** and `ulimit -n 10240` (Chromium fails to launch sandboxed: "Permission denied (1100)"). Same for the asset-generator script (it launches Chromium). +- Turbopack dev server's first cold run may fail 1 random e2e while compiling on demand — re-run before blaming a change; production build is stable. +- Commits: conventional messages, no AI attribution, no session links, no Claude-Session trailer. +- `npm run lint` must stay 0 errors / 0 warnings; `npm run check` (Prettier) must pass before each commit. + +--- + +### Task 1: Permanent redirects — recover the dead /projects URL + +**Files:** + +- Modify: `next.config.js` (currently an empty `nextConfig = {}`) +- Create: `e2e/seo.spec.ts` + +**Interfaces:** + +- Produces: `e2e/seo.spec.ts` — later tasks append describe-blocks to this file. + +- [ ] **Step 1: Write the failing e2e test** + +Create `e2e/seo.spec.ts`: + +```ts +import { test, expect } from '@playwright/test'; + +test.describe('redirects', () => { + test('/projects permanently redirects to /work', async ({ request }) => { + const res = await request.get('/projects', { maxRedirects: 0 }); + expect(res.status()).toBe(308); + expect(res.headers()['location']).toBe('/work'); + }); + + test('/projects/anything redirects to /work', async ({ request }) => { + const res = await request.get('/projects/old-page', { maxRedirects: 0 }); + expect(res.status()).toBe(308); + expect(res.headers()['location']).toBe('/work'); + }); +}); +``` + +- [ ] **Step 2: Run to verify it fails** + +Run (unsandboxed, `ulimit -n 10240` first): `npx playwright test e2e/seo.spec.ts` Expected: FAIL — `/projects` returns 404, not 308. + +- [ ] **Step 3: Implement redirects** + +Replace `next.config.js` content: + +```js +/** @type {import('next').NextConfig} */ +const nextConfig = { + async redirects() { + return [ + // The pre-2026-06-02 site served /projects (Google's current #1 result + // for "Nima Hakimi developer"); the redesign renamed it to /work. + { source: '/projects', destination: '/work', permanent: true }, + { source: '/projects/:path*', destination: '/work', permanent: true }, + // Exact-host only: preview deployments on *.vercel.app must stay untouched. + { + source: '/:path*', + has: [{ type: 'host', value: 'hackimi.vercel.app' }], + destination: 'https://hackimi.dev/:path*', + permanent: true, + }, + ]; + }, +}; + +module.exports = nextConfig; +``` + +- [ ] **Step 4: Run to verify it passes** + +Run: `npx playwright test e2e/seo.spec.ts` — Expected: PASS. If it fails unexpectedly after the config edit: Turbopack's persistent FS cache is known to go stale across `next.config.js` changes in this repo — `rm -rf .next`, restart the server, re-run BEFORE debugging the redirect rule itself. Also verify the host rule manually (dev server on :3000): `curl -s -o /dev/null -w '%{http_code} %{redirect_url}' -H 'Host: hackimi.vercel.app' http://localhost:3000/about` Expected: `308 https://hackimi.dev/about`. If the dev server doesn't honor the Host match, verify against `npm run build && npm run start` instead; if it only works in prod mode, note that in the task report — the rule itself is correct. + +- [ ] **Step 5: Commit** + +```bash +git add next.config.js e2e/seo.spec.ts +git commit -m "feat: permanent redirects for legacy /projects and vercel.app host" +``` + +--- + +### Task 2: Positioning constants — Developer & AI Engineer + +**Files:** + +- Modify: `src/lib/site.ts` +- Modify: `tests/unit/site.test.ts` + +**Interfaces:** + +- Produces: `SITE_ROLE = 'Developer & AI Engineer'`, `SITE_TITLE = 'Nima Hakimi — Developer & AI Engineer'` (em-dash), updated `SITE_DESCRIPTION`/`SITE_KEYWORDS`, and `SITE_SOCIAL_LINKS.orcid`. Tasks 3–7 consume these. + +- [ ] **Step 1: Update the pinned test to the new contract** + +In `tests/unit/site.test.ts` replace the SITE_TITLE test: + +```ts +it('SITE_TITLE is `${SITE_AUTHOR} — ${SITE_ROLE}` (em-dash form)', () => { + expect(SITE_TITLE).toBe(`${SITE_AUTHOR} — ${SITE_ROLE}`); + expect(SITE_ROLE).toBe('Developer & AI Engineer'); +}); +``` + +And add to the same describe: + +```ts +it('social links include GitHub, LinkedIn and ORCID', () => { + expect(Object.keys(SITE_SOCIAL_LINKS).sort()).toEqual(['github', 'linkedin', 'orcid']); +}); + +it('keywords carry the new positioning, not "Frontend"', () => { + expect(SITE_KEYWORDS).toContain('AI Engineer'); + expect(SITE_KEYWORDS.join(' ')).not.toMatch(/frontend/i); +}); +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `npx vitest run tests/unit/site.test.ts` Expected: FAIL (pipe title, no orcid key, 'Frontend Developer' keyword). + +- [ ] **Step 3: Implement in `src/lib/site.ts`** + +```ts +export const SITE_ROLE = 'Developer & AI Engineer'; +export const SITE_TITLE = `${SITE_AUTHOR} — ${SITE_ROLE}`; +export const SITE_DESCRIPTION = + 'Portfolio of Nima Hakimi, a developer and AI engineer building accessible, high-performance web applications.'; +export const SITE_KEYWORDS = ['Nima Hakimi', 'Developer', 'AI Engineer', 'Next.js', 'React', 'Oslo']; +``` + +And extend the social links object: + +```ts +export const SITE_SOCIAL_LINKS = { + github: 'https://github.com/nimkimi', + linkedin: 'https://linkedin.com/in/nima-hakimi-387716175', + orcid: 'https://orcid.org/0009-0002-6656-2498', +}; +``` + +Leave `SITE_OG_IMAGE` untouched in this task (Task 7 changes it). + +- [ ] **Step 4: Run the full unit/server suite** + +Run: `npm run test` Expected: PASS — `tests/unit/metadata.test.ts` compares against the imported constants, so it rides through. If `buildPersonJsonLd`'s sameAs test fails (it pins `[github, linkedin]`), do NOT fix it here — that contract changes in Task 6; temporarily it still passes because the builder maps the two keys explicitly. + +- [ ] **Step 5: Commit** + +```bash +git add src/lib/site.ts tests/unit/site.test.ts +git commit -m "feat: reposition site constants to Developer & AI Engineer" +``` + +--- + +### Task 3: Name-led homepage title + +**Files:** + +- Modify: `src/lib/metadata.ts` (add `buildHomeMetadata`) +- Modify: `src/app/page.tsx:10-14` (metadata export only — no JSX changes) +- Modify: `tests/unit/metadata.test.ts`, `tests/integration/pages.test.tsx:58-62` +- Modify: `e2e/seo.spec.ts` (append) + +**Interfaces:** + +- Consumes: `SITE_TITLE`, `SITE_DESCRIPTION` from Task 2. +- Produces: `buildHomeMetadata(): Metadata` in `src/lib/metadata.ts`. + +Background for the implementer: Next's `title.template` applies only to **child** segments — the root `page.tsx` never receives it, so its current `title: 'Portfolio'` renders as literally `Portfolio` in production. That is why the fix must set an absolute full-string title and why the e2e assertion (rendered layer) is the one that counts: the unit-level metadata object looked correct the whole time this bug shipped. + +- [ ] **Step 1: Write the failing tests** + +Append to `tests/unit/metadata.test.ts`: + +```ts +describe('buildHomeMetadata', () => { + it('title is the absolute SITE_TITLE (root segment gets no template)', () => { + const meta = buildHomeMetadata(); + expect(meta.title).toBe(SITE_TITLE); + expect(meta.openGraph?.title).toBe(SITE_TITLE); + expect(meta.twitter?.title).toBe(SITE_TITLE); + }); + + it('canonical and og.url are the bare SITE_URL', () => { + const meta = buildHomeMetadata(); + expect(meta.alternates?.canonical).toBe(SITE_URL); + expect(meta.openGraph?.url).toBe(SITE_URL); + }); +}); +``` + +(Import `buildHomeMetadata` in the existing import list.) + +In `tests/integration/pages.test.tsx`, replace the home metadata test (lines 58–62): + +```ts +it('exports absolute name-led home metadata', async () => { + const { metadata } = await import('@/app/page'); + const { SITE_TITLE } = await import('@/lib/site'); + expect(metadata.title).toBe(SITE_TITLE); + expect(metadata.description).toContain('Nima Hakimi'); +}); +``` + +Append to `e2e/seo.spec.ts`: + +```ts +test.describe('rendered metadata', () => { + test('homepage title is the full name-led title', async ({ page }) => { + await page.goto('/'); + await expect(page).toHaveTitle('Nima Hakimi — Developer & AI Engineer'); + }); + + test('homepage canonical points at apex', async ({ page }) => { + await page.goto('/'); + await expect(page.locator('link[rel="canonical"]')).toHaveAttribute('href', 'https://hackimi.dev'); + }); +}); +``` + +- [ ] **Step 2: Run to verify failures** + +Run: `npx vitest run tests/unit/metadata.test.ts tests/integration/pages.test.tsx` Expected: FAIL — `buildHomeMetadata` doesn't exist; home title is 'Portfolio'. + +- [ ] **Step 3: Implement** + +In `src/lib/metadata.ts`, after `buildPageMetadata`, add (reusing the module's existing `defaultImage`, `defaultTwitterImage`, `defaultKeywords`): + +```ts +/** + * Home is the ONE page whose title must be absolute: Next's title.template + * only applies to child segments, so the root page.tsx never receives the + * "%s | Nima Hakimi" suffix — it must carry the full name-led string itself. + */ +export function buildHomeMetadata(): Metadata { + return { + title: SITE_TITLE, + description: SITE_DESCRIPTION, + keywords: defaultKeywords, + alternates: { canonical: SITE_URL }, + openGraph: { + title: SITE_TITLE, + description: SITE_DESCRIPTION, + url: SITE_URL, + siteName: SITE_AUTHOR, + type: 'website', + images: [defaultImage], + }, + twitter: { + card: 'summary_large_image', + title: SITE_TITLE, + description: SITE_DESCRIPTION, + images: [defaultTwitterImage], + }, + }; +} +``` + +(`SITE_TITLE` and `SITE_DESCRIPTION` are already imported at the top of the module.) + +In `src/app/page.tsx`, replace the metadata export (lines 10–14) with: + +```ts +export const metadata = buildHomeMetadata(); +``` + +and change the import from `buildPageMetadata` to `buildHomeMetadata`. Touch nothing else in the file. + +- [ ] **Step 4: Run to verify passes** + +Run: `npm run test` then `npx playwright test e2e/seo.spec.ts` (unsandboxed, ulimit set). Expected: all PASS. + +Contingency: if the e2e title assertion fails with a DOUBLED suffix (`Nima Hakimi — Developer & AI Engineer | Nima Hakimi`), the root template did apply after all — switch `buildHomeMetadata`'s title to `title: { absolute: SITE_TITLE }` and update the two object-level title assertions to match. Live-production evidence says this won't happen; the e2e exists to catch exactly this case. + +- [ ] **Step 5: Commit** + +```bash +git add src/lib/metadata.ts src/app/page.tsx tests/unit/metadata.test.ts tests/integration/pages.test.tsx e2e/seo.spec.ts +git commit -m "feat: absolute name-led homepage title via buildHomeMetadata" +``` + +--- + +### Task 4: Site footer with visible name line + +**Files:** + +- Create: `src/components/layout/SiteFooter.tsx` +- Modify: `src/app/layout.tsx:30-33` (add footer inside `SmoothScroll`, after `main`) +- Create: `tests/components/site-footer.test.tsx` +- Modify: `e2e/seo.spec.ts` (append) + +**Interfaces:** + +- Consumes: `SITE_AUTHOR`, `SITE_ROLE`, `SITE_LOCATION` from `src/lib/site.ts`. +- Produces: `SiteFooter` default-export server component, rendered on every page. + +- [ ] **Step 0: Pre-flight — check for existing `