diff --git a/.agents/skills/blog-authoring/SKILL.md b/.agents/skills/blog-authoring/SKILL.md new file mode 100644 index 0000000000..275ee059c8 --- /dev/null +++ b/.agents/skills/blog-authoring/SKILL.md @@ -0,0 +1,95 @@ +--- +name: blog-authoring +description: Use when creating, editing, previewing, or publishing blog posts through the local working-copy flow, especially when the task involves `content/blog`, `npm run blog:*` commands, or `/blog-preview/...` URLs. +--- + +# Neon Blog Authoring + +Default editing workflow: + +1. Work in the site repository. +2. Edit the local working copy under `content/blog/`. +3. Run the local site to visually inspect the result. +4. Publish the snapshot into the configured remote blog-content branch with the provided CLI. + +Do not default to editing the remote source-of-truth repository directly unless the user explicitly asks for it. + +## Required checks + +Before changing content, verify the current state: + +```bash +git branch --show-current +npm run blog:status +``` + +If `content/blog` is missing, initialize it: + +```bash +npm run blog:bootstrap +``` + +If the user explicitly wants to refresh the working copy from remote state: + +```bash +npm run blog:sync -- --force +``` + +## Core workflow + +### 1. Edit locally + +Edit only: + +- `content/blog/posts/*.md` +- `content/blog/authors/data.json` +- `content/blog/categories/data.json` + +Then run: + +```bash +npm run dev +``` + +Use the local app to review: + +- `/blog` +- `/blog/` + +### 2. Publish to a branch in `blog` + +Use the built-in CLI: + +```bash +npm run blog:publish-branch -- --branch +``` + +Defaults: + +- if `--branch` is omitted, the current git branch name is used +- the command pushes the current `content/blog` snapshot into the configured remote content repository +- the command prints preview URLs under `/blog-preview/...` + +### 3. Review branch preview + +Open the generated preview URL, for example: + +```text +/blog-preview/?branch=&secret= +``` + +If the preview returns `404`, treat that as “access denied or missing branch/slug”. Do not assume the route is public. + +## Guardrails + +- Prefer `content/blog` in the current repository as the editing surface. +- Do not overwrite existing `content/blog` automatically; only use `blog:sync -- --force` when the user explicitly wants a refresh. +- Do not push to the main source-of-truth remote accidentally. If you must work in a separate source repo directly, inspect `git remote -v` first and confirm which remote is safe to push to. +- When credentials are missing, limit yourself to local editing and local preview; explain exactly which publish/preview actions are blocked. +- Do not touch unrelated local files such as `src/scripts/compare-sites.js`. + +## References + +Load only what you need: + +- Commands and environment expectations: `references/commands.md` diff --git a/.agents/skills/blog-authoring/references/commands.md b/.agents/skills/blog-authoring/references/commands.md new file mode 100644 index 0000000000..9bdd9ba458 --- /dev/null +++ b/.agents/skills/blog-authoring/references/commands.md @@ -0,0 +1,76 @@ +# Commands + +## Blog CLI + +Run these from the site repository: + +```bash +npm run blog:bootstrap +npm run blog:sync -- --force +npm run blog:status +npm run blog:publish-branch -- --branch +npm run dev +npm run build +``` + +Behavior: + +- `blog:bootstrap`: materializes `content/blog` only if it is missing +- `blog:sync -- --force`: replaces the local working copy from the branch source or CDN +- `blog:status`: shows current branch, whether a matching branch exists in the configured `blog` repo, whether local content exists, and whether local content differs from remote +- `blog:publish-branch`: publishes the current `content/blog` snapshot into the configured `blog` repo branch and prints preview URLs + +## Environment needed for publish / branch preview + +Expected in env: + +```bash +BLOG_CDN_URL=... +BLOG_REPO_OWNER=... +BLOG_REPO_NAME=... +BLOG_GITHUB_TOKEN=... +BLOG_PREVIEW_SECRET=... +``` + +Notes: + +- `BLOG_GITHUB_TOKEN` is currently used for both branch fetches and `blog:publish-branch`, so it needs write access to the configured remote content repo. +- Without the repo credentials, local editing still works, but publishing and branch-aware remote fetches do not. + +## Useful git checks + +Current branch in `website`: + +```bash +git branch --show-current +``` + +Remotes in a separate source repo: + +```bash +git remote -v +``` + +Use this before pushing directly from a separate source repo. Prefer a personal/fork remote over the source-of-truth remote unless the user explicitly asks otherwise. + +## Preview routes + +Examples: + +```text +/blog-preview/?branch=&secret= +``` + +Expected behavior: + +- valid branch + valid secret -> preview page +- missing/invalid secret -> `404` +- missing branch or missing slug -> `404` + +## Editing targets + +Only edit these blog-content working copy files unless the user explicitly asks otherwise: + +- `content/blog/posts/*.md` +- `content/blog/authors/data.json` +- `content/blog/categories/data.json` diff --git a/.env.example b/.env.example index dc5a868747..3bf05416b5 100644 --- a/.env.example +++ b/.env.example @@ -1,10 +1,5 @@ NEXT_PUBLIC_DEFAULT_SITE_URL=http://localhost:3000 -WP_GRAPHQL_URL=https://neondatabase.wpengine.com/graphql -WP_GRAPHQL_USER_AGENT= -WP_GRAPHQL_AUTH_TOKEN= -WP_PREVIEW_SECRET= - NEXT_PUBLIC_GITHUB_PATH=https://github.com/neondatabase/website/tree/main/ NEXT_PUBLIC_NEON_STATUS_API=https://7687492087503394.hostedstatus.com/1.0/status/6878fc85709daa75be6c7e3c @@ -19,4 +14,27 @@ NEXT_PUBLIC_POSTHOG_HOST=https://us.i.posthog.com GITHUB_APP_SECRET= GITHUB_APP_CLIENT_ID= +DEPLOY_POSTGRES_DATABASE_URL= +DEPLOY_POSTGRES_NEON_API_KEY= +DEPLOY_POSTGRES_UPSTASH_REDIS_REST_URL= +DEPLOY_POSTGRES_UPSTASH_REDIS_REST_TOKEN= + +NEON_BRANCHING_DEMO_PROJECT_ID= +NEON_BRANCHING_DEMO_PARENT_ID= +NEON_BRANCHING_DEMO_API_KEY= + +NEON_BRANCHING_DEMO_DB_CONNECTION_STRING= +NEON_BRANCHING_DEMO_DB_PGHOST_DEFAULT= +NEON_BRANCHING_DEMO_DB_PGDATABASE="neondb" +NEON_BRANCHING_DEMO_DB_PGUSER="neondb_owner" +NEON_BRANCHING_DEMO_DB_PGPASSWORD= + +NEON_BRANCHING_DEMO_QSTASH_TOKEN= +QSTASH_CURRENT_SIGNING_KEY= +QSTASH_NEXT_SIGNING_KEY= + +BLOG_CDN_URL= +BLOG_REPO_OWNER= +BLOG_REPO_NAME= BLOG_GITHUB_TOKEN= +BLOG_PREVIEW_SECRET= diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index d0baa9b95c..0edd25d117 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -35,5 +35,4 @@ jobs: wait-on: 'http://localhost:3000' env: NEXT_PUBLIC_DEFAULT_SITE_URL: http://localhost:3000 - WP_GRAPHQL_URL: ${{ secrets.WP_GRAPHQL_URL }} NEXT_PUBLIC_GITHUB_PATH: ${{ secrets.NEXT_PUBLIC_GITHUB_PATH }} diff --git a/.gitignore b/.gitignore index b3265368f2..523c5f8630 100644 --- a/.gitignore +++ b/.gitignore @@ -94,5 +94,5 @@ package-lock.json # Evals directory (test validation for documentation) .evals/ -# Blog content gets automatically imported from another repo +# Blog content gets imported automatically content/blog diff --git a/package-lock.json b/package-lock.json index ce2c6b616a..14adb3fd9a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -44,8 +44,6 @@ "framer-motion": "^12.36.0", "geist": "^1.5.1", "glob": "^10.5.0", - "graphql": "^16.8.1", - "graphql-request": "^6.1.0", "gray-matter": "^4.0.3", "gsap": "^3.12.5", "he": "^1.2.0", @@ -88,6 +86,7 @@ "swiper": "^12.1.2", "tailwind-merge": "^3.5.0", "tailwindcss-safe-area": "^0.5.1", + "tar": "^7.5.13", "unified": "^11.0.5", "unique-username-generator": "^1.4.0", "unist-util-visit": "^5.0.0", @@ -2906,15 +2905,6 @@ "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==", "license": "MIT" }, - "node_modules/@graphql-typed-document-node/core": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/@graphql-typed-document-node/core/-/core-3.2.0.tgz", - "integrity": "sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ==", - "license": "MIT", - "peerDependencies": { - "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" - } - }, "node_modules/@headlessui/react": { "version": "2.2.9", "resolved": "https://registry.npmjs.org/@headlessui/react/-/react-2.2.9.tgz", @@ -3691,6 +3681,18 @@ "url": "https://github.com/chalk/strip-ansi?sponsor=1" } }, + "node_modules/@isaacs/fs-minipass": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", + "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", + "license": "ISC", + "dependencies": { + "minipass": "^7.0.4" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.5", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.5.tgz", @@ -4178,6 +4180,111 @@ "node": ">= 10" } }, + "node_modules/@next/swc-darwin-x64": { + "version": "16.2.1", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.2.1.tgz", + "integrity": "sha512-/vrcE6iQSJq3uL3VGVHiXeaKbn8Es10DGTGRJnRZlkNQQk3kaNtAJg8Y6xuAlrx/6INKVjkfi5rY0iEXorZ6uA==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-gnu": { + "version": "16.2.1", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.2.1.tgz", + "integrity": "sha512-uLn+0BK+C31LTVbQ/QU+UaVrV0rRSJQ8RfniQAHPghDdgE+SlroYqcmFnO5iNjNfVWCyKZHYrs3Nl0mUzWxbBw==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-musl": { + "version": "16.2.1", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.2.1.tgz", + "integrity": "sha512-ssKq6iMRnHdnycGp9hCuGnXJZ0YPr4/wNwrfE5DbmvEcgl9+yv97/Kq3TPVDfYome1SW5geciLB9aiEqKXQjlQ==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-gnu": { + "version": "16.2.1", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.2.1.tgz", + "integrity": "sha512-HQm7SrHRELJ30T1TSmT706IWovFFSRGxfgUkyWJZF/RKBMdbdRWJuFrcpDdE5vy9UXjFOx6L3mRdqH04Mmx0hg==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-musl": { + "version": "16.2.1", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.2.1.tgz", + "integrity": "sha512-aV2iUaC/5HGEpbBkE+4B8aHIudoOy5DYekAKOMSHoIYQ66y/wIVeaRx8MS2ZMdxe/HIXlMho4ubdZs/J8441Tg==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-arm64-msvc": { + "version": "16.2.1", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.2.1.tgz", + "integrity": "sha512-IXdNgiDHaSk0ZUJ+xp0OQTdTgnpx1RCfRTalhn3cjOP+IddTMINwA7DXZrwTmGDO8SUr5q2hdP/du4DcrB1GxA==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-x64-msvc": { + "version": "16.2.1", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.2.1.tgz", + "integrity": "sha512-qvU+3a39Hay+ieIztkGSbF7+mccbbg1Tk25hc4JDylf8IHjYmY/Zm64Qq1602yPyQqvie+vf5T/uPwNxDNIoeg==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -13140,6 +13247,15 @@ "chevrotain": "^11.0.0" } }, + "node_modules/chownr": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", + "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, "node_modules/chrome-trace-event": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.4.tgz", @@ -13573,15 +13689,6 @@ "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz", "integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==" }, - "node_modules/cross-fetch": { - "version": "3.1.8", - "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-3.1.8.tgz", - "integrity": "sha512-cvA+JwZoU0Xq+h6WkMvAUqPEYy92Obet6UdKLfW60qn99ftItKjB5T+BkyWOFWe2pUyfQ+IJHmpOTznqk1M6Kg==", - "license": "MIT", - "dependencies": { - "node-fetch": "^2.6.12" - } - }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -16932,26 +17039,6 @@ "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "license": "ISC" }, - "node_modules/graphql": { - "version": "16.8.1", - "license": "MIT", - "engines": { - "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0" - } - }, - "node_modules/graphql-request": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/graphql-request/-/graphql-request-6.1.0.tgz", - "integrity": "sha512-p+XPfS4q7aIpKVcgmnZKhMNqhltk20hfXtkaIkTfjjmiKMJ5xrt5c743cL03y/K7y1rg3WrIC49xGiEQ4mxdNw==", - "license": "MIT", - "dependencies": { - "@graphql-typed-document-node/core": "^3.2.0", - "cross-fetch": "^3.1.5" - }, - "peerDependencies": { - "graphql": "14 - 16" - } - }, "node_modules/gray-matter": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/gray-matter/-/gray-matter-4.0.3.tgz", @@ -22656,6 +22743,18 @@ "node": ">=16 || 14 >=14.17" } }, + "node_modules/minizlib": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz", + "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==", + "license": "MIT", + "dependencies": { + "minipass": "^7.1.2" + }, + "engines": { + "node": ">= 18" + } + }, "node_modules/mlly": { "version": "1.8.0", "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.0.tgz", @@ -26227,6 +26326,31 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/tar": { + "version": "7.5.13", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.13.tgz", + "integrity": "sha512-tOG/7GyXpFevhXVh8jOPJrmtRpOTsYqUIkVdVooZYJS/z8WhfQUX8RJILmeuJNinGAMSu1veBr4asSHFt5/hng==", + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/fs-minipass": "^4.0.0", + "chownr": "^3.0.0", + "minipass": "^7.1.2", + "minizlib": "^3.1.0", + "yallist": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/tar/node_modules/yallist": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", + "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, "node_modules/terser": { "version": "5.43.1", "resolved": "https://registry.npmjs.org/terser/-/terser-5.43.1.tgz", @@ -28207,111 +28331,6 @@ "type": "github", "url": "https://github.com/sponsors/wooorm" } - }, - "node_modules/@next/swc-darwin-x64": { - "version": "16.2.1", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.2.1.tgz", - "integrity": "sha512-/vrcE6iQSJq3uL3VGVHiXeaKbn8Es10DGTGRJnRZlkNQQk3kaNtAJg8Y6xuAlrx/6INKVjkfi5rY0iEXorZ6uA==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-linux-arm64-gnu": { - "version": "16.2.1", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.2.1.tgz", - "integrity": "sha512-uLn+0BK+C31LTVbQ/QU+UaVrV0rRSJQ8RfniQAHPghDdgE+SlroYqcmFnO5iNjNfVWCyKZHYrs3Nl0mUzWxbBw==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-linux-arm64-musl": { - "version": "16.2.1", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.2.1.tgz", - "integrity": "sha512-ssKq6iMRnHdnycGp9hCuGnXJZ0YPr4/wNwrfE5DbmvEcgl9+yv97/Kq3TPVDfYome1SW5geciLB9aiEqKXQjlQ==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-linux-x64-gnu": { - "version": "16.2.1", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.2.1.tgz", - "integrity": "sha512-HQm7SrHRELJ30T1TSmT706IWovFFSRGxfgUkyWJZF/RKBMdbdRWJuFrcpDdE5vy9UXjFOx6L3mRdqH04Mmx0hg==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-linux-x64-musl": { - "version": "16.2.1", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.2.1.tgz", - "integrity": "sha512-aV2iUaC/5HGEpbBkE+4B8aHIudoOy5DYekAKOMSHoIYQ66y/wIVeaRx8MS2ZMdxe/HIXlMho4ubdZs/J8441Tg==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-win32-arm64-msvc": { - "version": "16.2.1", - "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.2.1.tgz", - "integrity": "sha512-IXdNgiDHaSk0ZUJ+xp0OQTdTgnpx1RCfRTalhn3cjOP+IddTMINwA7DXZrwTmGDO8SUr5q2hdP/du4DcrB1GxA==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-win32-x64-msvc": { - "version": "16.2.1", - "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.2.1.tgz", - "integrity": "sha512-qvU+3a39Hay+ieIztkGSbF7+mccbbg1Tk25hc4JDylf8IHjYmY/Zm64Qq1602yPyQqvie+vf5T/uPwNxDNIoeg==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } } } } diff --git a/package.json b/package.json index 817268ce8e..96756faa15 100644 --- a/package.json +++ b/package.json @@ -7,11 +7,15 @@ "npm": ">=8.6.0" }, "scripts": { - "prebuild": "node scripts/fetch-blog-content.js && node src/scripts/update-github-stars.js && node src/scripts/generate-docs-icons-config.js && npm run check:content-data && npm run generate:pricing", - "predev": "node scripts/fetch-blog-content.js && node src/scripts/update-github-stars.js && node src/scripts/generate-docs-icons-config.js && npm run check:content-data && npm run generate:pricing", + "prebuild": "node scripts/blog-content-cli.js bootstrap && node src/scripts/update-github-stars.js && node src/scripts/generate-docs-icons-config.js && npm run check:content-data && npm run generate:pricing", + "predev": "node scripts/blog-content-cli.js bootstrap && node src/scripts/update-github-stars.js && node src/scripts/generate-docs-icons-config.js && npm run check:content-data && npm run generate:pricing", "dev": "next dev", "build": "next build", "dev:build": "next build", + "blog:bootstrap": "node scripts/blog-content-cli.js bootstrap", + "blog:sync": "node scripts/blog-content-cli.js sync", + "blog:status": "node scripts/blog-content-cli.js status", + "blog:publish-branch": "node scripts/blog-content-cli.js publish-branch", "postbuild": "node src/scripts/copy-md-content.js && node src/scripts/generate-llms-index.js && node src/scripts/generate-llms-full.js && next-sitemap --config next-sitemap.config.js && next-sitemap --config next-sitemap-postgres.config.js", "start": "next start", "format": "prettier --write .", @@ -35,8 +39,8 @@ "check:docs:neonctl": "vitest run scripts/docs-checks/neonctl/__tests__/neonctl-docs.test.js" }, "dependencies": { - "@emotion/memoize": "0.7.4", "@codemirror/lang-sql": "^6.9.1", + "@emotion/memoize": "0.7.4", "@headlessui/react": "^2.2.9", "@headlessui/tailwindcss": "^0.2.2", "@hookform/resolvers": "^3.3.4", @@ -71,8 +75,6 @@ "framer-motion": "^12.36.0", "geist": "^1.5.1", "glob": "^10.5.0", - "graphql": "^16.8.1", - "graphql-request": "^6.1.0", "gray-matter": "^4.0.3", "gsap": "^3.12.5", "he": "^1.2.0", @@ -115,6 +117,7 @@ "swiper": "^12.1.2", "tailwind-merge": "^3.5.0", "tailwindcss-safe-area": "^0.5.1", + "tar": "^7.5.13", "unified": "^11.0.5", "unique-username-generator": "^1.4.0", "unist-util-visit": "^5.0.0", diff --git a/scripts/blog-content-cli.js b/scripts/blog-content-cli.js new file mode 100644 index 0000000000..3ee19e4aa6 --- /dev/null +++ b/scripts/blog-content-cli.js @@ -0,0 +1,369 @@ +#!/usr/bin/env node + +require('dotenv').config({ path: '.env' }); + +const { execFileSync } = require('child_process'); +const fs = require('fs/promises'); +const os = require('os'); +const path = require('path'); +const { pathToFileURL } = require('url'); + +const BLOG_DIR = path.join(process.cwd(), 'content/blog'); +const WEBSITE_DEFAULT_URL = process.env.NEXT_PUBLIC_DEFAULT_SITE_URL || 'http://localhost:3000'; + +function printUsage() { + console.log(` +Usage: + npm run blog:bootstrap + npm run blog:sync -- [--force] + npm run blog:status + npm run blog:publish-branch -- [--branch ] +`); +} + +function parseArgs(argv) { + const [command, ...rest] = argv; + const options = { + command, + force: false, + branch: null, + }; + + for (let index = 0; index < rest.length; index += 1) { + const arg = rest[index]; + + if (arg === '--force') { + options.force = true; + continue; + } + + if (arg === '--branch') { + options.branch = rest[index + 1]; + index += 1; + continue; + } + + throw new Error(`Unsupported argument "${arg}"`); + } + + return options; +} + +async function loadBlogContentModule() { + const moduleUrl = pathToFileURL( + path.join(process.cwd(), 'src/utils/blog-content-source.mjs') + ).href; + + return import(moduleUrl); +} + +function runGit(args, cwd) { + return execFileSync('git', args, { + cwd, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + }).trim(); +} + +function tryRunGit(args, cwd) { + try { + return runGit(args, cwd); + } catch { + return null; + } +} + +function getCurrentWebsiteBranch() { + const branch = tryRunGit(['branch', '--show-current'], process.cwd()); + + return branch || 'main'; +} + +function getBlogRepoConfig() { + return { + owner: process.env.BLOG_REPO_OWNER, + repo: process.env.BLOG_REPO_NAME, + token: process.env.BLOG_GITHUB_TOKEN, + }; +} + +function getBlogRepoHttpsUrl() { + const { owner, repo, token } = getBlogRepoConfig(); + + if (!owner || !repo || !token) { + throw new Error('BLOG_REPO_OWNER, BLOG_REPO_NAME, and BLOG_GITHUB_TOKEN are required'); + } + + return `https://x-access-token:${token}@github.com/${owner}/${repo}.git`; +} + +async function resolveRemoteSnapshotForBranch(moduleApi, branch) { + const { + BlogContentBranchNotFoundError, + BlogContentConfigError, + readBlogSnapshotFromCdn, + readBlogSnapshotFromGitHubBranch, + } = moduleApi; + const { owner, repo, token } = getBlogRepoConfig(); + const cdnUrl = process.env.BLOG_CDN_URL || 'https://blog.neonapi.io/blog'; + + if (branch && branch !== 'main') { + try { + const snapshot = await readBlogSnapshotFromGitHubBranch({ + owner, + repo, + branch, + token, + }); + + return snapshot; + } catch (error) { + if ( + error instanceof BlogContentBranchNotFoundError || + error instanceof BlogContentConfigError + ) { + try { + return await readBlogSnapshotFromCdn(cdnUrl); + } catch (cdnError) { + const fallbackReason = + error instanceof BlogContentBranchNotFoundError + ? `matching blog branch "${branch}" was not found in ${owner}/${repo}` + : 'blog repo credentials are incomplete for branch-based bootstrap'; + + throw new Error( + `Failed to load blog content for website branch "${branch}": ${fallbackReason}, then CDN fallback ${cdnUrl} failed. ${cdnError.message}` + ); + } + } + + throw error; + } + } + + try { + return await readBlogSnapshotFromCdn(cdnUrl); + } catch (error) { + throw new Error(`Failed to load blog content from CDN ${cdnUrl}. ${error.message}`); + } +} + +async function handleSync(options) { + const moduleApi = await loadBlogContentModule(); + const { writeBlogSnapshotToDirectory } = moduleApi; + const branch = getCurrentWebsiteBranch(); + const snapshot = await resolveRemoteSnapshotForBranch(moduleApi, branch); + + await writeBlogSnapshotToDirectory({ + snapshot, + destinationDir: BLOG_DIR, + force: options.force, + }); + + console.log( + `Synced ${snapshot.posts.length} posts from ${snapshot.source}${ + snapshot.branch ? ` (${snapshot.branch})` : '' + } into ${BLOG_DIR}` + ); +} + +async function handleBootstrap() { + const moduleApi = await loadBlogContentModule(); + const { hasLocalBlogContent, writeBlogSnapshotToDirectory } = moduleApi; + + if (await hasLocalBlogContent(process.cwd())) { + console.log(`Blog content already present at ${BLOG_DIR}, skipping bootstrap`); + return; + } + + const branch = getCurrentWebsiteBranch(); + const snapshot = await resolveRemoteSnapshotForBranch(moduleApi, branch); + + await writeBlogSnapshotToDirectory({ + snapshot, + destinationDir: BLOG_DIR, + force: false, + }); + + console.log( + `Bootstrapped ${snapshot.posts.length} posts from ${snapshot.source}${ + snapshot.branch ? ` (${snapshot.branch})` : '' + } into ${BLOG_DIR}` + ); +} + +function formatStatusLine(label, value) { + console.log(`${label.padEnd(22)} ${value}`); +} + +async function handleStatus() { + const moduleApi = await loadBlogContentModule(); + const { compareSnapshots, hasLocalBlogContent, readLocalBlogSnapshot } = moduleApi; + const branch = getCurrentWebsiteBranch(); + const localExists = await hasLocalBlogContent(process.cwd()); + const remoteSnapshot = await resolveRemoteSnapshotForBranch(moduleApi, branch); + const branchExists = remoteSnapshot.source === 'branch' && remoteSnapshot.branch === branch; + + formatStatusLine('Website branch', branch); + formatStatusLine('Matching blog branch', branchExists ? 'yes' : 'no'); + formatStatusLine('Remote source', branchExists ? `branch:${branch}` : 'cdn'); + formatStatusLine('Local content/blog', localExists ? 'present' : 'missing'); + + if (!localExists) { + return; + } + + const localSnapshot = await readLocalBlogSnapshot(process.cwd()); + const comparison = compareSnapshots(localSnapshot, remoteSnapshot); + + formatStatusLine('Local dirty vs remote', comparison.isEqual ? 'no' : 'yes'); + + if (!comparison.isEqual) { + console.log('\nChanged paths:'); + comparison.changedPaths.slice(0, 20).forEach((filePath) => { + console.log(`- ${filePath}`); + }); + + if (comparison.changedPaths.length > 20) { + console.log(`- ... and ${comparison.changedPaths.length - 20} more`); + } + } +} + +async function writeSnapshotIntoBlogRepo(snapshot, repoRoot, createSnapshotFileMap) { + const snapshotFileMap = createSnapshotFileMap(snapshot); + + await Promise.all( + ['posts', 'authors', 'categories'].map((dirName) => + fs.rm(path.join(repoRoot, dirName), { recursive: true, force: true }) + ) + ); + + for (const [relativePath, contents] of snapshotFileMap.entries()) { + const targetPath = path.join(repoRoot, relativePath); + + await fs.mkdir(path.dirname(targetPath), { recursive: true }); + await fs.writeFile(targetPath, contents, 'utf8'); + } +} + +function getCommitIdentity() { + return { + name: tryRunGit(['config', '--get', 'user.name'], process.cwd()) || 'Codex', + email: + tryRunGit(['config', '--get', 'user.email'], process.cwd()) || 'codex@localhost.localdomain', + }; +} + +async function handlePublishBranch(options) { + const moduleApi = await loadBlogContentModule(); + const { + compareSnapshots, + createSnapshotFileMap, + hasLocalBlogContent, + readLocalBlogSnapshot, + resolveChangedPostSlugs, + } = moduleApi; + const localExists = await hasLocalBlogContent(process.cwd()); + + if (!localExists) { + throw new Error( + 'Local content/blog is missing. Run `npm run blog:sync -- --force` or create the working copy first.' + ); + } + + const targetBranch = options.branch || getCurrentWebsiteBranch(); + const localSnapshot = await readLocalBlogSnapshot(process.cwd()); + const baselineSnapshot = await resolveRemoteSnapshotForBranch(moduleApi, targetBranch); + const comparison = compareSnapshots(localSnapshot, baselineSnapshot); + const previewSecret = process.env.BLOG_PREVIEW_SECRET || ''; + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'neon-blog-publish-')); + const repoUrl = getBlogRepoHttpsUrl(); + const { name, email } = getCommitIdentity(); + let branchExists = false; + + try { + runGit(['clone', '--depth', '1', '--branch', 'main', repoUrl, tmpDir], process.cwd()); + + branchExists = Boolean(tryRunGit(['ls-remote', '--heads', 'origin', targetBranch], tmpDir)); + + if (branchExists) { + runGit(['fetch', 'origin', `${targetBranch}:${targetBranch}`], tmpDir); + runGit(['checkout', targetBranch], tmpDir); + } else { + runGit(['checkout', '-b', targetBranch], tmpDir); + } + + await writeSnapshotIntoBlogRepo(localSnapshot, tmpDir, createSnapshotFileMap); + + runGit(['add', 'posts', 'authors', 'categories'], tmpDir); + + const hasStagedDiff = Boolean(tryRunGit(['status', '--porcelain'], tmpDir)); + + if (hasStagedDiff) { + runGit(['config', 'user.name', name], tmpDir); + runGit(['config', 'user.email', email], tmpDir); + runGit(['commit', '-m', `chore: sync website content for ${targetBranch}`], tmpDir); + } + + if (!branchExists || hasStagedDiff) { + runGit(['push', 'origin', `HEAD:${targetBranch}`], tmpDir); + } + + console.log( + branchExists ? `Updated blog branch ${targetBranch}` : `Created blog branch ${targetBranch}` + ); + + const changedSlugs = resolveChangedPostSlugs(comparison.changedPaths); + + if (changedSlugs.length > 0) { + console.log('\nPreview URLs:'); + changedSlugs.slice(0, 10).forEach((slug) => { + console.log( + `${WEBSITE_DEFAULT_URL}/blog-preview/${slug}?branch=${encodeURIComponent( + targetBranch + )}&secret=${encodeURIComponent(previewSecret)}` + ); + }); + } else { + console.log('\nPreview entrypoint:'); + console.log( + `${WEBSITE_DEFAULT_URL}/blog-preview?branch=${encodeURIComponent( + targetBranch + )}&secret=${encodeURIComponent(previewSecret)}` + ); + } + } finally { + await fs.rm(tmpDir, { recursive: true, force: true }); + } +} + +async function main() { + const options = parseArgs(process.argv.slice(2)); + + if (!options.command || options.command === '--help') { + printUsage(); + return; + } + + switch (options.command) { + case 'sync': + await handleSync(options); + return; + case 'bootstrap': + await handleBootstrap(options); + return; + case 'status': + await handleStatus(options); + return; + case 'publish-branch': + await handlePublishBranch(options); + return; + default: + throw new Error(`Unsupported command "${options.command}"`); + } +} + +main().catch((error) => { + console.error(error.message); + process.exit(1); +}); diff --git a/scripts/fetch-blog-content.js b/scripts/fetch-blog-content.js deleted file mode 100644 index 1e3ef50e33..0000000000 --- a/scripts/fetch-blog-content.js +++ /dev/null @@ -1,61 +0,0 @@ -#!/usr/bin/env node - -require('dotenv').config({ path: '.env' }); -require('dotenv').config({ path: '.env.local' }); - -const fs = require('fs'); -const path = require('path'); - -const BLOG_DIR = 'content/blog'; -const CDN_BASE = process.env.BLOG_CDN_URL || 'https://blog.neonapi.io/blog'; -const BATCH_SIZE = 50; - -async function main() { - if (fs.existsSync(BLOG_DIR)) { - console.log(`Blog content already present at ${BLOG_DIR}, skipping`); - return; - } - - console.log(`Fetching blog content from ${CDN_BASE}...`); - - const manifestRes = await fetch(`${CDN_BASE}/manifest.json`); - if (!manifestRes.ok) throw new Error(`Failed to fetch manifest: ${manifestRes.status}`); - const { posts: slugs } = await manifestRes.json(); - - fs.mkdirSync(path.join(BLOG_DIR, 'posts'), { recursive: true }); - fs.mkdirSync(path.join(BLOG_DIR, 'authors'), { recursive: true }); - fs.mkdirSync(path.join(BLOG_DIR, 'categories'), { recursive: true }); - - // Download posts in batches to avoid connection limits - let downloaded = 0; - for (let i = 0; i < slugs.length; i += BATCH_SIZE) { - const batch = slugs.slice(i, i + BATCH_SIZE); - await Promise.all( - batch.map(async (slug) => { - const res = await fetch(`${CDN_BASE}/posts/${slug}.md`); - if (!res.ok) throw new Error(`Failed to fetch post "${slug}": ${res.status}`); - fs.writeFileSync(path.join(BLOG_DIR, 'posts', `${slug}.md`), await res.text()); - }) - ); - downloaded += batch.length; - process.stdout.write(`\r ${downloaded}/${slugs.length} posts`); - } - console.log(); - - const [authorsRes, categoriesRes] = await Promise.all([ - fetch(`${CDN_BASE}/authors/data.json`), - fetch(`${CDN_BASE}/categories/data.json`), - ]); - if (!authorsRes.ok) throw new Error(`Failed to fetch authors: ${authorsRes.status}`); - if (!categoriesRes.ok) throw new Error(`Failed to fetch categories: ${categoriesRes.status}`); - - fs.writeFileSync(path.join(BLOG_DIR, 'authors', 'data.json'), await authorsRes.text()); - fs.writeFileSync(path.join(BLOG_DIR, 'categories', 'data.json'), await categoriesRes.text()); - - console.log(`Done: ${slugs.length} posts fetched`); -} - -main().catch((err) => { - console.error('Error fetching blog content:', err.message); - process.exit(1); -}); diff --git a/src/app/(blog)/blog-preview/[slug]/page.jsx b/src/app/(blog)/blog-preview/[slug]/page.jsx new file mode 100644 index 0000000000..f8c1dba141 --- /dev/null +++ b/src/app/(blog)/blog-preview/[slug]/page.jsx @@ -0,0 +1,84 @@ +/* eslint-disable react/prop-types */ + +import { notFound } from 'next/navigation'; + +import resolveBlogPreviewRequest from 'app/blog-preview/blog-preview'; +import BlogPostPage from 'components/pages/blog-post/blog-post-page'; +import { BLOG_PREVIEW_BASE_PATH, buildBlogPostPath } from 'constants/blog'; +import { getBlogPostBySlug, getBlogSnapshot } from 'utils/api-blog'; +import getMetadata from 'utils/get-metadata'; + +const BlogPreviewPostPage = async ({ params, searchParams }) => { + const { slug: routeSlug } = await params; + const { branch, routeConfig, snapshot } = await resolveBlogPreviewRequest( + searchParams, + getBlogSnapshot + ); + const { post, relatedPosts } = await getBlogPostBySlug(routeSlug, { + previewBranch: branch, + strictBranch: true, + }); + + if (!post) { + return notFound(); + } + + const { slug } = post; + const previewUrl = buildBlogPostPath(routeConfig, slug); + + return ( + + ); +}; + +export async function generateMetadata({ params, searchParams }) { + const { slug } = await params; + const { branch } = await resolveBlogPreviewRequest(searchParams, getBlogSnapshot); + const { post } = await getBlogPostBySlug(slug, { + previewBranch: branch, + strictBranch: true, + }); + + if (!post) return notFound(); + + const { + seo: { + title, + metaDesc, + metaKeywords, + metaRobotsNoindex, + opengraphTitle, + opengraphDescription, + twitterImage, + }, + date, + pageBlogPost, + categories, + } = post; + const authors = pageBlogPost.authors.map(({ author }) => author?.title); + + return getMetadata({ + title: `[Preview] ${opengraphTitle || title}`, + description: opengraphDescription || metaDesc, + keywords: metaKeywords, + robotsNoindex: metaRobotsNoindex || 'noindex', + pathname: `${BLOG_PREVIEW_BASE_PATH}${slug}`, + imagePath: twitterImage?.mediaItemUrl, + type: 'article', + publishedTime: date, + category: categories.nodes[0]?.name, + authors, + }); +} + +export const revalidate = 0; +export const dynamic = 'force-dynamic'; + +export default BlogPreviewPostPage; diff --git a/src/app/(blog)/blog-preview/category/[slug]/page.jsx b/src/app/(blog)/blog-preview/category/[slug]/page.jsx new file mode 100644 index 0000000000..9c6d3c62de --- /dev/null +++ b/src/app/(blog)/blog-preview/category/[slug]/page.jsx @@ -0,0 +1,107 @@ +/* eslint-disable react/prop-types */ +import { notFound } from 'next/navigation'; +import { Suspense } from 'react'; + +import resolveBlogPreviewRequest from 'app/blog-preview/blog-preview'; +import BlogGridItem from 'components/pages/blog/blog-grid-item'; +import BlogHeader from 'components/pages/blog/blog-header'; +import BlogLayout from 'components/pages/blog/blog-layout'; +import BlogPreviewBanner from 'components/pages/blog/blog-preview-banner'; +import BlogSearch from 'components/shared/blog-search'; +import ScrollLoader from 'components/shared/scroll-loader'; +import { BLOG_PREVIEW_CATEGORY_BASE_PATH } from 'constants/blog'; +import { getBlogCategoryDescription } from 'constants/seo-data'; +import { + getAllBlogCategories, + getBlogSnapshot, + getCategoryBySlug, + getPostsByCategorySlug, +} from 'utils/api-blog'; +import getMetadata from 'utils/get-metadata'; + +const BlogPreviewCategoryPage = async ({ params, searchParams }) => { + const { slug } = await params; + const { branch, routeConfig, snapshot } = await resolveBlogPreviewRequest( + searchParams, + getBlogSnapshot + ); + const [categories, category, posts] = await Promise.all([ + getAllBlogCategories({ previewBranch: branch, strictBranch: true }), + getCategoryBySlug(slug, { previewBranch: branch, strictBranch: true }), + getPostsByCategorySlug(slug, { previewBranch: branch, strictBranch: true }), + ]); + const validPosts = Array.isArray(posts) ? posts.filter(Boolean) : []; + + if (!category) return notFound(); + + return ( + + + + What we’re shipping. +
+ What you’re building. + + } + rssTitle="What we're shipping. What you're building." + category={category.name} + basePath={routeConfig.basePath} + withLabel + /> + + +
+ {validPosts.slice(0, 10).map((post, index) => ( + + ))} + {validPosts.length > 10 && ( + + {validPosts.slice(10).map((post) => ( + + ))} + + )} +
+
+
+
+ ); +}; + +export async function generateMetadata({ params, searchParams }) { + const { slug } = await params; + await resolveBlogPreviewRequest(searchParams, getBlogSnapshot); + + return getMetadata({ + title: `Preview ${slug} Blog - Neon`, + description: getBlogCategoryDescription(slug), + pathname: `${BLOG_PREVIEW_CATEGORY_BASE_PATH}${slug}`, + imagePath: '/images/social-previews/blog.jpg', + robotsNoindex: 'noindex', + }); +} + +export const revalidate = 0; +export const dynamic = 'force-dynamic'; + +export default BlogPreviewCategoryPage; diff --git a/src/app/blog/layout.jsx b/src/app/(blog)/blog-preview/layout.jsx similarity index 100% rename from src/app/blog/layout.jsx rename to src/app/(blog)/blog-preview/layout.jsx diff --git a/src/app/(blog)/blog-preview/page.jsx b/src/app/(blog)/blog-preview/page.jsx new file mode 100644 index 0000000000..13aece1063 --- /dev/null +++ b/src/app/(blog)/blog-preview/page.jsx @@ -0,0 +1,87 @@ +/* eslint-disable react/prop-types */ +import { Suspense } from 'react'; + +import resolveBlogPreviewRequest from 'app/blog-preview/blog-preview'; +import BlogGridItem from 'components/pages/blog/blog-grid-item'; +import BlogHeader from 'components/pages/blog/blog-header'; +import BlogLayout from 'components/pages/blog/blog-layout'; +import BlogPreviewBanner from 'components/pages/blog/blog-preview-banner'; +import BlogSearch from 'components/shared/blog-search'; +import ScrollLoader from 'components/shared/scroll-loader'; +import { BLOG_PREVIEW_BASE_PATH } from 'constants/blog'; +import { getAllBlogCategories, getAllPosts, getBlogSnapshot } from 'utils/api-blog'; +import getMetadata from 'utils/get-metadata'; + +const BlogPreviewIndexPage = async ({ searchParams }) => { + const { branch, routeConfig, snapshot } = await resolveBlogPreviewRequest( + searchParams, + getBlogSnapshot + ); + const [categories, posts] = await Promise.all([ + getAllBlogCategories({ previewBranch: branch, strictBranch: true }), + getAllPosts({ previewBranch: branch, strictBranch: true }), + ]); + const validPosts = Array.isArray(posts) ? posts.filter(Boolean) : []; + + return ( + + + + What we’re shipping. +
+ What you’re building. + + } + rssTitle="What we're shipping. What you're building." + basePath={routeConfig.basePath} + withLabel + /> + + +
+ {validPosts.slice(0, 10).map((post, index) => ( + + ))} + {validPosts.length > 10 && ( + + {validPosts.slice(10).map((post) => ( + + ))} + + )} +
+
+
+
+ ); +}; + +export async function generateMetadata({ searchParams }) { + await resolveBlogPreviewRequest(searchParams, getBlogSnapshot); + + return getMetadata({ + title: 'Blog Preview - Neon', + description: 'Branch-aware preview for private blog content.', + pathname: BLOG_PREVIEW_BASE_PATH, + robotsNoindex: 'noindex', + }); +} + +export const revalidate = 0; +export const dynamic = 'force-dynamic'; + +export default BlogPreviewIndexPage; diff --git a/src/app/blog/(index)/category/[slug]/page.jsx b/src/app/(blog)/blog/(index)/category/[slug]/page.jsx similarity index 87% rename from src/app/blog/(index)/category/[slug]/page.jsx rename to src/app/(blog)/blog/(index)/category/[slug]/page.jsx index f6d227b653..cdbef22be0 100644 --- a/src/app/blog/(index)/category/[slug]/page.jsx +++ b/src/app/(blog)/blog/(index)/category/[slug]/page.jsx @@ -5,7 +5,7 @@ import BlogGridItem from 'components/pages/blog/blog-grid-item'; import BlogHeader from 'components/pages/blog/blog-header'; import BlogSearch from 'components/shared/blog-search'; import ScrollLoader from 'components/shared/scroll-loader'; -import { BLOG_BASE_PATH, BLOG_CATEGORY_BASE_PATH } from 'constants/blog'; +import { BLOG_BASE_PATH, BLOG_CATEGORY_BASE_PATH, DEFAULT_BLOG_ROUTE_CONFIG } from 'constants/blog'; import { getBlogCategoryDescription } from 'constants/seo-data'; import { getAllCategories, getCategoryBySlug, getPostsByCategorySlug } from 'utils/api-blog'; import getMetadata from 'utils/get-metadata'; @@ -40,6 +40,7 @@ const BlogCategoryPage = async ({ params }) => {
{validPosts.slice(0, 10).map((post, index) => ( @@ -50,12 +51,18 @@ const BlogCategoryPage = async ({ params }) => { category={category} isFeatured={post.isFeatured} isPriority={index < 5} + routeConfig={DEFAULT_BLOG_ROUTE_CONFIG} /> ))} {validPosts.length > 10 && ( {validPosts.slice(10).map((post) => ( - + ))} )} diff --git a/src/app/(blog)/blog/(index)/layout.jsx b/src/app/(blog)/blog/(index)/layout.jsx new file mode 100644 index 0000000000..f980c5a6b6 --- /dev/null +++ b/src/app/(blog)/blog/(index)/layout.jsx @@ -0,0 +1,16 @@ +import BlogLayout from 'components/pages/blog/blog-layout'; +import { DEFAULT_BLOG_ROUTE_CONFIG } from 'constants/blog'; +import { getAllCategories } from 'utils/api-blog'; + +// eslint-disable-next-line react/prop-types +const BlogPageLayout = async ({ children }) => { + const categories = await getAllCategories(); + + return ( + + {children} + + ); +}; + +export default BlogPageLayout; diff --git a/src/app/blog/(index)/page.jsx b/src/app/(blog)/blog/(index)/page.jsx similarity index 85% rename from src/app/blog/(index)/page.jsx rename to src/app/(blog)/blog/(index)/page.jsx index 288e342c6c..89adae3f8b 100644 --- a/src/app/blog/(index)/page.jsx +++ b/src/app/(blog)/blog/(index)/page.jsx @@ -4,7 +4,7 @@ import BlogGridItem from 'components/pages/blog/blog-grid-item'; import BlogHeader from 'components/pages/blog/blog-header'; import BlogSearch from 'components/shared/blog-search'; import ScrollLoader from 'components/shared/scroll-loader'; -import { BLOG_BASE_PATH } from 'constants/blog'; +import { BLOG_BASE_PATH, DEFAULT_BLOG_ROUTE_CONFIG } from 'constants/blog'; import SEO_DATA from 'constants/seo-data'; import { getAllPosts } from 'utils/api-blog'; import getMetadata from 'utils/get-metadata'; @@ -34,6 +34,7 @@ const BlogPage = async () => {
{validPosts.slice(0, 10).map((post, index) => ( @@ -43,12 +44,17 @@ const BlogPage = async () => { post={post} isFeatured={post.isFeatured} isPriority={index < 5} + routeConfig={DEFAULT_BLOG_ROUTE_CONFIG} /> ))} {validPosts.length > 10 && ( {validPosts.slice(10).map((post) => ( - + ))} )} diff --git a/src/app/(blog)/blog/[slug]/page.jsx b/src/app/(blog)/blog/[slug]/page.jsx new file mode 100644 index 0000000000..e377c7d583 --- /dev/null +++ b/src/app/(blog)/blog/[slug]/page.jsx @@ -0,0 +1,82 @@ +import { notFound } from 'next/navigation'; +import PropTypes from 'prop-types'; + +import BlogPostPage from 'components/pages/blog-post/blog-post-page'; +import { DEFAULT_BLOG_ROUTE_CONFIG } from 'constants/blog'; +import LINKS from 'constants/links'; +import { getAllBlogPosts, getBlogPostBySlug } from 'utils/api-blog'; +import getMetadata from 'utils/get-metadata'; + +const BlogPage = async ({ params: paramsPromise }) => { + const params = await paramsPromise; + const { post, relatedPosts } = await getBlogPostBySlug(params?.slug); + + if (!post) { + return notFound(); + } + + const { slug } = post; + const shareUrl = `${process.env.NEXT_PUBLIC_DEFAULT_SITE_URL}${LINKS.blog}/${slug}`; + + return ( + + ); +}; + +BlogPage.propTypes = { + params: PropTypes.object.isRequired, +}; + +export async function generateMetadata(props) { + const params = await props.params; + const { slug } = params; + const { post } = await getBlogPostBySlug(slug); + + if (!post) return notFound(); + + const { + seo: { + title, + metaDesc, + metaKeywords, + metaRobotsNoindex, + opengraphTitle, + opengraphDescription, + twitterImage, + }, + date, + pageBlogPost, + categories, + } = post; + + const authors = pageBlogPost.authors.map(({ author }) => author?.title); + + return getMetadata({ + title: opengraphTitle || title, + description: opengraphDescription || metaDesc, + keywords: metaKeywords, + robotsNoindex: metaRobotsNoindex, + pathname: `${LINKS.blog}/${slug}`, + imagePath: twitterImage?.mediaItemUrl, + type: 'article', + publishedTime: date, + category: categories.nodes[0]?.name, + authors, + }); +} + +export async function generateStaticParams() { + const posts = await getAllBlogPosts(); + return posts.map((post) => ({ + slug: post.slug, + })); +} + +export const revalidate = 60; + +export default BlogPage; diff --git a/src/app/(blog)/blog/layout.jsx b/src/app/(blog)/blog/layout.jsx new file mode 100644 index 0000000000..bcd2ca8448 --- /dev/null +++ b/src/app/(blog)/blog/layout.jsx @@ -0,0 +1,6 @@ +import Layout from 'components/shared/layout'; + +// eslint-disable-next-line react/prop-types +const BlogLayout = async ({ children }) => {children}; + +export default BlogLayout; diff --git a/src/app/blog/rss.xml/route.js b/src/app/(blog)/blog/rss.xml/route.js similarity index 94% rename from src/app/blog/rss.xml/route.js rename to src/app/(blog)/blog/rss.xml/route.js index 93ea69fe06..3dc3f3778f 100644 --- a/src/app/blog/rss.xml/route.js +++ b/src/app/(blog)/blog/rss.xml/route.js @@ -6,7 +6,7 @@ import { getAllBlogPosts } from 'utils/api-blog'; const SITE_URL = process.env.NEXT_PUBLIC_DEFAULT_SITE_URL; export async function GET() { - const allBlogPosts = getAllBlogPosts(); + const allBlogPosts = await getAllBlogPosts({ fullList: true }); const feed = new Rss({ id: BLOG_BASE_PATH, diff --git a/src/app/api/preview-off/route.js b/src/app/api/preview-off/route.js deleted file mode 100644 index 26a95871e0..0000000000 --- a/src/app/api/preview-off/route.js +++ /dev/null @@ -1,7 +0,0 @@ -import { draftMode } from 'next/headers'; - -export async function GET() { - (await draftMode()).disable(); - - return new Response('Draft mode is disabled'); -} diff --git a/src/app/api/preview/route.js b/src/app/api/preview/route.js deleted file mode 100644 index 9264739024..0000000000 --- a/src/app/api/preview/route.js +++ /dev/null @@ -1,38 +0,0 @@ -import { draftMode } from 'next/headers'; -import { redirect } from 'next/navigation'; - -export async function GET(request) { - const { searchParams } = new URL(request.url); - const data = searchParams.get('data'); - - const { - id, - post_type: postType, - post_status: postStatus, - secret, - permalink, - } = JSON.parse(Buffer.from(data, 'base64').toString()); - - if (secret !== process.env.WP_PREVIEW_SECRET) { - return new Response('Invalid token', { status: 401 }); - } - - if (postType !== 'post' && postType !== 'page') { - return new Response('Preview functionality only works for blog posts and pages', { - status: 401, - }); - } - - (await draftMode()).enable(); - - const redirectSearchParams = new URLSearchParams({ - id, - status: postStatus, - }); - - const slug = permalink && postStatus === 'publish' ? permalink : 'wp-draft-post-preview-page'; - - postType === 'page' - ? redirect(`/${slug}?${redirectSearchParams.toString()}`) - : redirect(`/blog/${slug}?${redirectSearchParams.toString()}`); -} diff --git a/src/app/blog-preview/blog-preview.js b/src/app/blog-preview/blog-preview.js new file mode 100644 index 0000000000..51075b317c --- /dev/null +++ b/src/app/blog-preview/blog-preview.js @@ -0,0 +1,58 @@ +import { notFound } from 'next/navigation'; + +import { createBlogRouteConfig } from 'constants/blog'; +import { + BlogContentBranchNotFoundError, + BlogContentConfigError, +} from 'utils/blog-content-source.mjs'; + +const normalizeSearchParam = (value) => { + if (Array.isArray(value)) { + return value[0] || null; + } + + return value || null; +}; + +const validateBlogPreviewAccess = ({ branch, secret }) => { + const previewSecret = process.env.BLOG_PREVIEW_SECRET; + + if (!previewSecret || secret !== previewSecret) { + notFound(); + } + + if (!branch) { + notFound(); + } +}; + +const resolveBlogPreviewRequest = async (searchParamsPromise, resolver) => { + const searchParams = await searchParamsPromise; + const branch = normalizeSearchParam(searchParams?.branch); + const secret = normalizeSearchParam(searchParams?.secret); + + validateBlogPreviewAccess({ branch, secret }); + + try { + const snapshot = await resolver({ previewBranch: branch, strictBranch: true }); + + return { + branch, + secret, + routeConfig: createBlogRouteConfig({ branch, secret }), + snapshot, + }; + } catch (error) { + if (error instanceof BlogContentBranchNotFoundError) { + notFound(); + } + + if (error instanceof BlogContentConfigError) { + throw new Error(error.message); + } + + throw error; + } +}; + +export default resolveBlogPreviewRequest; diff --git a/src/app/blog-sitemap.xml/route.js b/src/app/blog-sitemap.xml/route.js index 2ff8d3da95..4b0394c205 100644 --- a/src/app/blog-sitemap.xml/route.js +++ b/src/app/blog-sitemap.xml/route.js @@ -4,8 +4,8 @@ export async function GET() { const headers = new Headers(); headers.set('Content-Type', 'application/xml'); - const posts = getAllBlogPosts(); - const categories = getAllBlogCategories(); + const posts = await getAllBlogPosts({ fullList: true }); + const categories = await getAllBlogCategories(); return new Response( ` diff --git a/src/app/blog/(index)/layout.jsx b/src/app/blog/(index)/layout.jsx deleted file mode 100644 index edcc6a3bd6..0000000000 --- a/src/app/blog/(index)/layout.jsx +++ /dev/null @@ -1,21 +0,0 @@ -import Sidebar from 'components/pages/blog/sidebar'; -import Container from 'components/shared/container'; -import { getAllCategories } from 'utils/api-blog'; - -// eslint-disable-next-line react/prop-types -const BlogPageLayout = async ({ children }) => { - const categories = await getAllCategories(); - - return ( -
- -
- -
{children}
-
-
-
- ); -}; - -export default BlogPageLayout; diff --git a/src/app/blog/[slug]/page.jsx b/src/app/blog/[slug]/page.jsx deleted file mode 100644 index e3cdfa5bb6..0000000000 --- a/src/app/blog/[slug]/page.jsx +++ /dev/null @@ -1,144 +0,0 @@ -import { notFound } from 'next/navigation'; -import { MDXRemote } from 'next-mdx-remote/rsc'; -import remarkGfm from 'remark-gfm'; - -import Aside from 'components/pages/blog-post/aside'; -import Content from 'components/pages/blog-post/content'; -import Hero from 'components/pages/blog-post/hero'; -import MoreArticles from 'components/pages/blog-post/more-articles'; -import SocialShare from 'components/pages/blog-post/social-share'; -import YoutubeIframe from 'components/pages/doc/youtube-iframe'; -import Admonition from 'components/shared/admonition'; -import BlogQuote from 'components/shared/blog-quote'; -import Button from 'components/shared/button'; -import ChangelogForm from 'components/shared/changelog-form'; -import EmbedTweet from 'components/shared/embed-tweet'; -import LINKS from 'constants/links'; -import { getAllBlogPosts, getBlogPostBySlug } from 'utils/api-blog'; -import getFormattedDate from 'utils/get-formatted-date'; -import getMarkdownTableOfContents from 'utils/get-markdown-table-of-contents'; -import getMetadata from 'utils/get-metadata'; - -const BlogPage = async (props0) => { - const params = await props0.params; - const { post, relatedPosts } = getBlogPostBySlug(params?.slug); - - if (!post) { - return notFound(); - } - - const { slug, title, content, pageBlogPost, date, dateGmt, modifiedGmt, categories, seo } = post; - const shareUrl = `${process.env.NEXT_PUBLIC_DEFAULT_SITE_URL}${LINKS.blog}/${slug}`; - const formattedDate = getFormattedDate(date); - - const tableOfContents = getMarkdownTableOfContents(content); - - const jsonLd = { - '@context': 'https://schema.org', - '@type': 'Article', - headline: title, - image: [seo?.twitterImage?.mediaItemUrl], - datePublished: dateGmt, - dateModified: modifiedGmt, - description: pageBlogPost?.description, - author: { - '@type': 'Person', - name: pageBlogPost?.authors?.[0].author.title, - }, - }; - - return ( - <> -