diff --git a/.claude/rules/ci-config.md b/.claude/rules/ci-config.md index 2f272e1e89b7..2d190c730ee5 100644 --- a/.claude/rules/ci-config.md +++ b/.claude/rules/ci-config.md @@ -33,6 +33,7 @@ paths: - `agent-rules.yml` runs `scripts/agent-rules.mjs --check` and the agent hook tests (`node --test '.cursor/hooks/*.test.js'`; a bare directory runs nothing on Node 22) with no install (node and git only). Its `paths` must cover every input and output of that script, and `.cursor/hooks/`. - `editor-types.yml` reruns `yarn copy:websitetypes` and fails if `website/src/components/Playground/editor-types` changes. It needs the `website` workspace (for deps like `bignumber.js`), which CircleCI's `setup` drops. Its `paths` must cover every input of `scripts/copywebsitetypes.sh`. - `site-preview.yml` runs one `build` job (one install for the typecheck and the build). It builds the site directly (no Vercel CLI, only the packages it imports via `ci:build:website`, `VERCEL_ENV=preview` to include drafts), restores Docusaurus' webpack cache (only master pushes save it, so PRs share one entry), and fails on any `[WARNING]`/`[ERROR]` line. Broken links are `warn` in `docusaurus.config.ts` so this check catches them without failing Vercel deploys. +- `site-preview.yml` runs `blog-publish.mjs --check`: posts published in the PR must be dated within 3 days by filename. - Production docs deploys come from Vercel's Git integration only (gated by `vercel-ignore.sh`); there is no Actions deploy workflow. Vercel clones ~10 commits deep, so `website/scripts/deepenGitHistory.cjs` (called from `docusaurus.config.ts`) fetches 800 more for the "Last updated" dates. - `site-preview.yml` `paths` (`website/**`, `docs/{core,rest,graphql}/**`) must match `SITE_PATHS` in `website/scripts/vercel-ignore.sh`. - `benchmark-react.yml` caches Playwright browsers keyed on the resolved `playwright` version from `examples/benchmark-react`; bumping playwright invalidates the cache automatically. diff --git a/.cursor/rules/ci-config.mdc b/.cursor/rules/ci-config.mdc index ee20ee2e6762..d0e11b9b7c9c 100644 --- a/.cursor/rules/ci-config.mdc +++ b/.cursor/rules/ci-config.mdc @@ -31,6 +31,7 @@ alwaysApply: false - `agent-rules.yml` runs `scripts/agent-rules.mjs --check` and the agent hook tests (`node --test '.cursor/hooks/*.test.js'`; a bare directory runs nothing on Node 22) with no install (node and git only). Its `paths` must cover every input and output of that script, and `.cursor/hooks/`. - `editor-types.yml` reruns `yarn copy:websitetypes` and fails if `website/src/components/Playground/editor-types` changes. It needs the `website` workspace (for deps like `bignumber.js`), which CircleCI's `setup` drops. Its `paths` must cover every input of `scripts/copywebsitetypes.sh`. - `site-preview.yml` runs one `build` job (one install for the typecheck and the build). It builds the site directly (no Vercel CLI, only the packages it imports via `ci:build:website`, `VERCEL_ENV=preview` to include drafts), restores Docusaurus' webpack cache (only master pushes save it, so PRs share one entry), and fails on any `[WARNING]`/`[ERROR]` line. Broken links are `warn` in `docusaurus.config.ts` so this check catches them without failing Vercel deploys. +- `site-preview.yml` runs `blog-publish.mjs --check`: posts published in the PR must be dated within 3 days by filename. - Production docs deploys come from Vercel's Git integration only (gated by `vercel-ignore.sh`); there is no Actions deploy workflow. Vercel clones ~10 commits deep, so `website/scripts/deepenGitHistory.cjs` (called from `docusaurus.config.ts`) fetches 800 more for the "Last updated" dates. - `site-preview.yml` `paths` (`website/**`, `docs/{core,rest,graphql}/**`) must match `SITE_PATHS` in `website/scripts/vercel-ignore.sh`. - `benchmark-react.yml` caches Playwright browsers keyed on the resolved `playwright` version from `examples/benchmark-react`; bumping playwright invalidates the cache automatically. diff --git a/.github/workflows/site-preview.yml b/.github/workflows/site-preview.yml index e85e32c0cca8..36c11fe3b72f 100644 --- a/.github/workflows/site-preview.yml +++ b/.github/workflows/site-preview.yml @@ -42,6 +42,13 @@ jobs: with: node-version: '26' cache: 'yarn' + - name: Check published blog posts are dated today + if: github.event_name == 'pull_request' + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + run: | + git fetch --depth=1 origin "$BASE_SHA" + node website/scripts/blog-publish.mjs --check "$BASE_SHA" - name: Install packages run: ./scripts/ci-install.sh website - name: Build package types and the packages the website imports diff --git a/package.json b/package.json index cf30312960a0..955344a507fd 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,7 @@ "build:skills": "yarn workspace rdc-website build:skills", "check:vue-examples": "yarn workspace rdc-website check:vue-examples", "build:agent-rules": "node scripts/agent-rules.mjs", + "blog:publish": "node website/scripts/blog-publish.mjs", "test": "NODE_ENV=test run jest", "test:ci": "ANANSI_JEST_TYPECHECK=false yarn test --ci", "test:coverage": "ANANSI_JEST_TYPECHECK=false yarn test --coverage --selectProjects ReactDOM Node --coverageThreshold='{\"global\":{\"statements\":98,\"branches\":96,\"functions\":90,\"lines\":98}}'", diff --git a/website/blog/.claude/rules/blog-posts.md b/website/blog/.claude/rules/blog-posts.md index c2771f6d5749..cc94d3aaa72b 100644 --- a/website/blog/.claude/rules/blog-posts.md +++ b/website/blog/.claude/rules/blog-posts.md @@ -14,6 +14,7 @@ Files: `YYYY-MM-DD-vX.Y-short-description.md` - Title format: `vX.Y: Feature1, Feature2, Feature3` or `vX.Y Feature-focused Title` - Include `releases` tag plus relevant feature tags from [tags.yml](./tags.yml) - `draft: true` for unpublished/WIP posts +- Publish a draft with `yarn blog:publish website/blog/.md` (removes `draft: true`, re-dates the filename to today, fixes links). CI fails posts published with a stale date. ## Package Layers diff --git a/website/blog/.cursor/rules/blog-posts.mdc b/website/blog/.cursor/rules/blog-posts.mdc index 0a6500602ad8..888cbc9caf34 100644 --- a/website/blog/.cursor/rules/blog-posts.mdc +++ b/website/blog/.cursor/rules/blog-posts.mdc @@ -13,6 +13,7 @@ Files: `YYYY-MM-DD-vX.Y-short-description.md` - Title format: `vX.Y: Feature1, Feature2, Feature3` or `vX.Y Feature-focused Title` - Include `releases` tag plus relevant feature tags from [tags.yml](./tags.yml) - `draft: true` for unpublished/WIP posts +- Publish a draft with `yarn blog:publish website/blog/.md` (removes `draft: true`, re-dates the filename to today, fixes links). CI fails posts published with a stale date. ## Package Layers diff --git a/website/scripts/blog-publish.mjs b/website/scripts/blog-publish.mjs new file mode 100644 index 000000000000..ad3ab166e311 --- /dev/null +++ b/website/scripts/blog-publish.mjs @@ -0,0 +1,156 @@ +#!/usr/bin/env node +// Blog posts take their date (and URL) from the filename prefix, so a draft +// written weeks before release would publish with its draft date. +// +// yarn blog:publish website/blog/.md +// removes `draft: true`, renames the post to today's date, rewrites its +// /blog/YYYY/MM/DD/slug links repo-wide and stages the post +// node website/scripts/blog-publish.mjs --check +// (CI) fails when a post published since isn't dated within +// MAX_DAYS of today, or is dated any way but its filename +import { execFileSync } from 'node:child_process'; +import { existsSync, readFileSync, renameSync, writeFileSync } from 'node:fs'; +import { basename } from 'node:path'; + +const BLOG_DIR = 'website/blog'; +const MAX_DAYS = 3; +const POST = /^(\d{4})-(\d{2})-(\d{2})-(.+)\.mdx?$/; +const FRONT_MATTER = /^---\r?\n([\s\S]*?)\r?\n---/; +const DRAFT = /^draft:\s*(true|True|TRUE)[^\S\r\n]*(#.*)?(\r?\n|$)/m; + +const git = (...args) => execFileSync('git', args, { encoding: 'utf8' }); +const today = new Date().toISOString().slice(0, 10); +const urlDate = date => date.replaceAll('-', '/'); + +// Front matter is read with regexes rather than @docusaurus/utils so CI can +// run this before installing packages. +function frontMatter(source) { + const block = source.match(FRONT_MATTER)?.[1] ?? ''; + return { draft: DRAFT.test(block), hasDate: /^date:/m.test(block) }; +} + +function publish(arg) { + const file = basename(arg); + const path = `${BLOG_DIR}/${file}`; + const slug = file.match(POST)?.[4]; + if (!slug || !existsSync(path)) { + throw new Error(`${arg}: expected ${BLOG_DIR}/YYYY-MM-DD-slug.md`); + } + const source = readFileSync(path, 'utf8'); + if (frontMatter(source).hasDate) { + throw new Error( + `${path}: remove its \`date:\` field; the filename dates it`, + ); + } + const newPath = `${BLOG_DIR}/${today}${file.slice(today.length)}`; + if (newPath !== path && existsSync(newPath)) { + throw new Error(`${path}: can't rename, ${newPath} already exists`); + } + writeFileSync( + path, + source.replace(FRONT_MATTER, block => block.replace(DRAFT, '')), + ); + + if (newPath !== path) { + // Not `git mv`: a new post may not be tracked yet + renameSync(path, newPath); + rewriteLinks( + `/blog/${urlDate(file.slice(0, 10))}/${slug}`, + `/blog/${urlDate(today)}/${slug}`, + ); + git('rm', '--cached', '-q', '--ignore-unmatch', '--', path); + } + // Stage after the rewrite so the commit gets the updated self-links + git('add', '--', newPath); + console.log(`published ${newPath}`); +} + +function rewriteLinks(oldUrl, newUrl) { + // Stop at the slug's end so siblings like `${slug}-notes` are untouched + const oldUrlPattern = new RegExp( + `${oldUrl.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?![\\w.-])`, + 'g', + ); + let linking = []; + try { + linking = git('grep', '--untracked', '-IlF', oldUrl) + .split('\n') + .filter(Boolean); + } catch (error) { + // git grep exits 1 when nothing links to the post + if (error.status !== 1) throw error; + } + for (const linked of linking) { + const text = readFileSync(linked, 'utf8'); + writeFileSync(linked, text.replace(oldUrlPattern, newUrl)); + } + if (!linking.length) return; + // Stage the rewrites so the commit has no broken links. Untracked files are + // left for their author to add (`git add -u` rejects them on newer git) + const tracked = git('ls-files', '-z', '--', ...linking) + .split('\0') + .filter(Boolean); + if (tracked.length) git('add', '--', ...tracked); +} + +function check(base) { + let failed = false; + const fail = message => { + console.error(message); + failed = true; + }; + // Only added, modified or renamed posts can have gone from draft to published + const changes = git('diff', '--name-status', '-M', base, '--', BLOG_DIR) + .split('\n') + .filter(Boolean) + .map(line => line.split('\t')); + for (const [status, from, to = from] of changes) { + const file = to.slice(BLOG_DIR.length + 1); + if (status === 'D' || file.startsWith('.') || !/\.mdx?$/.test(file)) { + continue; + } + // Docusaurus also builds posts from folders, which this check can't date + if (file.includes('/')) { + fail(`${to}: use a YYYY-MM-DD-slug.md file, not a post folder`); + continue; + } + const { draft, hasDate } = frontMatter(readFileSync(to, 'utf8')); + if (draft) continue; + if (status !== 'A' && !frontMatter(git('show', `${base}:${from}`)).draft) { + continue; + } + + if (!POST.test(file) || hasDate) { + fail( + `${to}: name published posts YYYY-MM-DD-slug.md, without a \`date:\` front matter field`, + ); + continue; + } + const postDate = file.slice(0, 10); + const days = Math.abs(Date.parse(postDate) - Date.parse(today)) / 864e5; + if (days > MAX_DAYS) { + fail( + `${to}: published with date ${postDate}, but today is ${today}. ` + + `Run \`yarn blog:publish ${to}\` to date it today and fix its links.`, + ); + } + } + return failed; +} + +const [first, second] = process.argv.slice(2); +if (first === '--check' && second) { + process.exit(check(second) ? 1 : 0); +} else if (first && !first.startsWith('-')) { + try { + publish(first); + } catch (error) { + console.error(error.message); + process.exit(1); + } +} else { + console.error( + 'usage: yarn blog:publish | blog-publish.mjs --check ', + ); + process.exit(2); +}