From 4100b25c8f413b88dc0b2ea5d2b82518c9b09214 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 02:40:52 +0000 Subject: [PATCH 01/13] internal: Fail CI when a blog post publishes with its draft date Blog post dates and URLs come from the filename prefix, so removing `draft: true` alone publishes the post with the day the draft was started. website/scripts/blog-publish-date.mjs fails the website check for a post published in the PR whose date is more than 3 days from today; --fix renames it to today and rewrites its /blog/YYYY/MM/DD links. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_016BYJtNfgrkJzBYthZA1WED --- .claude/rules/ci-config.md | 1 + .cursor/rules/ci-config.mdc | 1 + .github/workflows/site-preview.yml | 7 ++ website/blog/.claude/rules/blog-posts.md | 1 + website/blog/.cursor/rules/blog-posts.mdc | 1 + website/scripts/blog-publish-date.mjs | 92 +++++++++++++++++++++++ 6 files changed, 103 insertions(+) create mode 100644 website/scripts/blog-publish-date.mjs diff --git a/.claude/rules/ci-config.md b/.claude/rules/ci-config.md index 2f272e1e89b7..c5302fde6c55 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` also runs `website/scripts/blog-publish-date.mjs` against the PR base: a blog post published in the PR (added without `draft: true`, or its draft removed) must be dated within 3 days of today; `--fix` renames it and its links. - 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..7445403fbc9c 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` also runs `website/scripts/blog-publish-date.mjs` against the PR base: a blog post published in the PR (added without `draft: true`, or its draft removed) must be dated within 3 days of today; `--fix` renames it and its links. - 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..101a6a0b32ea 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-date.mjs "$BASE_SHA" - name: Install packages run: ./scripts/ci-install.sh website - name: Build package types and the packages the website imports diff --git a/website/blog/.claude/rules/blog-posts.md b/website/blog/.claude/rules/blog-posts.md index c2771f6d5749..46739404113e 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 +- Publishing: the filename date is the post's date and URL, so when removing `draft: true` run `node website/scripts/blog-publish-date.mjs origin/master --fix` to rename the post to today and rewrite its `/blog/YYYY/MM/DD/...` links. The `website` CI check fails a newly published post dated more than 3 days from today. ## Package Layers diff --git a/website/blog/.cursor/rules/blog-posts.mdc b/website/blog/.cursor/rules/blog-posts.mdc index 0a6500602ad8..c953ea2928ee 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 +- Publishing: the filename date is the post's date and URL, so when removing `draft: true` run `node website/scripts/blog-publish-date.mjs origin/master --fix` to rename the post to today and rewrite its `/blog/YYYY/MM/DD/...` links. The `website` CI check fails a newly published post dated more than 3 days from today. ## Package Layers diff --git a/website/scripts/blog-publish-date.mjs b/website/scripts/blog-publish-date.mjs new file mode 100644 index 000000000000..1ae45f242ef0 --- /dev/null +++ b/website/scripts/blog-publish-date.mjs @@ -0,0 +1,92 @@ +#!/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. This fails +// when a post is published (newly added without `draft: true`, or its +// `draft: true` removed) and its date isn't within MAX_DAYS of today. +// +// node website/scripts/blog-publish-date.mjs check +// node website/scripts/blog-publish-date.mjs --fix rename to today +// +// --fix renames the file and rewrites its /blog/YYYY/MM/DD/slug links repo-wide. +import { execFileSync } from 'node:child_process'; +import { readdirSync, readFileSync, writeFileSync } from 'node:fs'; + +const BLOG_DIR = 'website/blog'; +const MAX_DAYS = 3; +const POST = /^(\d{4})-(\d{2})-(\d{2})-(.+)\.mdx?$/; + +const [base, flag] = process.argv.slice(2); +if (!base) { + console.error('usage: blog-publish-date.mjs [--fix]'); + process.exit(2); +} +const fix = flag === '--fix'; + +const git = (...args) => execFileSync('git', args, { encoding: 'utf8' }); + +// 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(/^---\n([\s\S]*?)\n---/)?.[1] ?? ''; + return { + draft: /^draft:\s*true\s*$/m.test(block), + date: block.match(/^date:\s*['"]?([^'"\n]+)/m)?.[1], + }; +} + +const publishedAtBase = new Set( + git('ls-tree', '--name-only', base, `${BLOG_DIR}/`) + .split('\n') + .map(path => path.slice(BLOG_DIR.length + 1)) + .filter(file => POST.test(file)) + .filter( + file => !frontMatter(git('show', `${base}:${BLOG_DIR}/${file}`)).draft, + ), +); + +const today = new Date().toISOString().slice(0, 10); +let failed = false; + +for (const file of readdirSync(BLOG_DIR)) { + const match = file.match(POST); + if (!match || publishedAtBase.has(file)) continue; + const path = `${BLOG_DIR}/${file}`; + const { draft, date } = frontMatter(readFileSync(path, 'utf8')); + if (draft) continue; + + const [, year, month, day, slug] = match; + const postDate = date?.slice(0, 10) ?? `${year}-${month}-${day}`; + const days = Math.abs(Date.parse(postDate) - Date.parse(today)) / 864e5; + if (days <= MAX_DAYS) continue; + + if (date) { + console.error( + `${path}: published with \`date: ${date}\`; set it to ${today} or remove it`, + ); + failed = true; + } else if (fix) { + const newFile = today + file.slice(today.length); + git('mv', path, `${BLOG_DIR}/${newFile}`); + const oldUrl = `/blog/${year}/${month}/${day}/${slug}`; + const newUrl = oldUrl.replace( + `${year}/${month}/${day}`, + today.replaceAll('-', '/'), + ); + const linking = git('grep', '-lF', oldUrl).split('\n').filter(Boolean); + for (const linked of linking) { + const text = readFileSync(linked, 'utf8'); + writeFileSync(linked, text.replaceAll(oldUrl, newUrl)); + } + console.log( + `renamed ${file} -> ${newFile}; updated links in ${linking.length} file(s)`, + ); + } else { + console.error( + `${path}: published with date ${postDate}, but today is ${today}. ` + + `Run \`node website/scripts/blog-publish-date.mjs origin/master --fix\` to rename it and its links.`, + ); + failed = true; + } +} + +process.exit(failed ? 1 : 0); From 6273c7fab2e78b60019f3d3c89796d83abf3874a Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 02:42:24 +0000 Subject: [PATCH 02/13] internal: Don't crash blog-publish-date --fix when nothing links to the post Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_016BYJtNfgrkJzBYthZA1WED --- website/scripts/blog-publish-date.mjs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/website/scripts/blog-publish-date.mjs b/website/scripts/blog-publish-date.mjs index 1ae45f242ef0..4442de008f30 100644 --- a/website/scripts/blog-publish-date.mjs +++ b/website/scripts/blog-publish-date.mjs @@ -72,7 +72,13 @@ for (const file of readdirSync(BLOG_DIR)) { `${year}/${month}/${day}`, today.replaceAll('-', '/'), ); - const linking = git('grep', '-lF', oldUrl).split('\n').filter(Boolean); + let linking = []; + try { + linking = git('grep', '-lF', 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.replaceAll(oldUrl, newUrl)); From 6765cfa134793c6b650749f2cc052f161d138d4d Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 02:46:41 +0000 Subject: [PATCH 03/13] internal: blog-publish-date ignores renamed published posts and commented draft flags Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_016BYJtNfgrkJzBYthZA1WED --- website/scripts/blog-publish-date.mjs | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/website/scripts/blog-publish-date.mjs b/website/scripts/blog-publish-date.mjs index 4442de008f30..7cf68ae0c946 100644 --- a/website/scripts/blog-publish-date.mjs +++ b/website/scripts/blog-publish-date.mjs @@ -29,7 +29,7 @@ const git = (...args) => execFileSync('git', args, { encoding: 'utf8' }); function frontMatter(source) { const block = source.match(/^---\n([\s\S]*?)\n---/)?.[1] ?? ''; return { - draft: /^draft:\s*true\s*$/m.test(block), + draft: /^draft:\s*true\s*(#.*)?$/m.test(block), date: block.match(/^date:\s*['"]?([^'"\n]+)/m)?.[1], }; } @@ -44,12 +44,23 @@ const publishedAtBase = new Set( ), ); +// A published post that was only renamed (slug fix, .md -> .mdx) stays published +const renamedFrom = new Map( + git('diff', '--name-status', '-M', '--diff-filter=R', base, '--', BLOG_DIR) + .split('\n') + .filter(Boolean) + .map(line => { + const [, from, to] = line.split('\t'); + return [to.slice(BLOG_DIR.length + 1), from.slice(BLOG_DIR.length + 1)]; + }), +); + const today = new Date().toISOString().slice(0, 10); let failed = false; for (const file of readdirSync(BLOG_DIR)) { const match = file.match(POST); - if (!match || publishedAtBase.has(file)) continue; + if (!match || publishedAtBase.has(renamedFrom.get(file) ?? file)) continue; const path = `${BLOG_DIR}/${file}`; const { draft, date } = frontMatter(readFileSync(path, 'utf8')); if (draft) continue; From 74a6ca50915aa4852667062723417e526200472d Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 02:52:40 +0000 Subject: [PATCH 04/13] internal: blog-publish-date requires filename dates on newly published posts Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_016BYJtNfgrkJzBYthZA1WED --- website/blog/.claude/rules/blog-posts.md | 2 +- website/blog/.cursor/rules/blog-posts.mdc | 2 +- website/scripts/blog-publish-date.mjs | 30 ++++++++++++++--------- 3 files changed, 20 insertions(+), 14 deletions(-) diff --git a/website/blog/.claude/rules/blog-posts.md b/website/blog/.claude/rules/blog-posts.md index 46739404113e..b2f53202cb8e 100644 --- a/website/blog/.claude/rules/blog-posts.md +++ b/website/blog/.claude/rules/blog-posts.md @@ -14,7 +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 -- Publishing: the filename date is the post's date and URL, so when removing `draft: true` run `node website/scripts/blog-publish-date.mjs origin/master --fix` to rename the post to today and rewrite its `/blog/YYYY/MM/DD/...` links. The `website` CI check fails a newly published post dated more than 3 days from today. +- Publishing: the filename date is the post's date and URL, so when removing `draft: true` run `node website/scripts/blog-publish-date.mjs origin/master --fix` to rename the post to today and rewrite its `/blog/YYYY/MM/DD/...` links. The `website` CI check fails a newly published post dated more than 3 days from today, or dated by a `date:` field instead of its filename. ## Package Layers diff --git a/website/blog/.cursor/rules/blog-posts.mdc b/website/blog/.cursor/rules/blog-posts.mdc index c953ea2928ee..926ba347a1f4 100644 --- a/website/blog/.cursor/rules/blog-posts.mdc +++ b/website/blog/.cursor/rules/blog-posts.mdc @@ -13,7 +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 -- Publishing: the filename date is the post's date and URL, so when removing `draft: true` run `node website/scripts/blog-publish-date.mjs origin/master --fix` to rename the post to today and rewrite its `/blog/YYYY/MM/DD/...` links. The `website` CI check fails a newly published post dated more than 3 days from today. +- Publishing: the filename date is the post's date and URL, so when removing `draft: true` run `node website/scripts/blog-publish-date.mjs origin/master --fix` to rename the post to today and rewrite its `/blog/YYYY/MM/DD/...` links. The `website` CI check fails a newly published post dated more than 3 days from today, or dated by a `date:` field instead of its filename. ## Package Layers diff --git a/website/scripts/blog-publish-date.mjs b/website/scripts/blog-publish-date.mjs index 7cf68ae0c946..9eba636f375e 100644 --- a/website/scripts/blog-publish-date.mjs +++ b/website/scripts/blog-publish-date.mjs @@ -8,11 +8,13 @@ // node website/scripts/blog-publish-date.mjs --fix rename to today // // --fix renames the file and rewrites its /blog/YYYY/MM/DD/slug links repo-wide. +// A newly published post must be dated by its filename alone (no `date:`). import { execFileSync } from 'node:child_process'; import { readdirSync, readFileSync, writeFileSync } from 'node:fs'; const BLOG_DIR = 'website/blog'; const MAX_DAYS = 3; +const MARKDOWN = /\.mdx?$/; const POST = /^(\d{4})-(\d{2})-(\d{2})-(.+)\.mdx?$/; const [base, flag] = process.argv.slice(2); @@ -30,7 +32,7 @@ function frontMatter(source) { const block = source.match(/^---\n([\s\S]*?)\n---/)?.[1] ?? ''; return { draft: /^draft:\s*true\s*(#.*)?$/m.test(block), - date: block.match(/^date:\s*['"]?([^'"\n]+)/m)?.[1], + hasDate: /^date:/m.test(block), }; } @@ -38,7 +40,7 @@ const publishedAtBase = new Set( git('ls-tree', '--name-only', base, `${BLOG_DIR}/`) .split('\n') .map(path => path.slice(BLOG_DIR.length + 1)) - .filter(file => POST.test(file)) + .filter(file => MARKDOWN.test(file)) .filter( file => !frontMatter(git('show', `${base}:${BLOG_DIR}/${file}`)).draft, ), @@ -59,23 +61,27 @@ const today = new Date().toISOString().slice(0, 10); let failed = false; for (const file of readdirSync(BLOG_DIR)) { - const match = file.match(POST); - if (!match || publishedAtBase.has(renamedFrom.get(file) ?? file)) continue; + if (!MARKDOWN.test(file)) continue; + if (publishedAtBase.has(renamedFrom.get(file) ?? file)) continue; const path = `${BLOG_DIR}/${file}`; - const { draft, date } = frontMatter(readFileSync(path, 'utf8')); + const { draft, hasDate } = frontMatter(readFileSync(path, 'utf8')); if (draft) continue; + // Only the filename dates a post, so --fix can rename it and its links + const match = file.match(POST); + if (!match || hasDate) { + console.error( + `${path}: name published posts YYYY-MM-DD-slug.md, without a \`date:\` front matter field`, + ); + failed = true; + continue; + } const [, year, month, day, slug] = match; - const postDate = date?.slice(0, 10) ?? `${year}-${month}-${day}`; + const postDate = `${year}-${month}-${day}`; const days = Math.abs(Date.parse(postDate) - Date.parse(today)) / 864e5; if (days <= MAX_DAYS) continue; - if (date) { - console.error( - `${path}: published with \`date: ${date}\`; set it to ${today} or remove it`, - ); - failed = true; - } else if (fix) { + if (fix) { const newFile = today + file.slice(today.length); git('mv', path, `${BLOG_DIR}/${newFile}`); const oldUrl = `/blog/${year}/${month}/${day}/${slug}`; From 1ed7ae0b01c5e006997f21b408ed0314a273a7da Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 02:57:32 +0000 Subject: [PATCH 05/13] internal: blog-publish-date --fix handles untracked posts and exact slug links Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_016BYJtNfgrkJzBYthZA1WED --- website/scripts/blog-publish-date.mjs | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/website/scripts/blog-publish-date.mjs b/website/scripts/blog-publish-date.mjs index 9eba636f375e..341fa83d6bb8 100644 --- a/website/scripts/blog-publish-date.mjs +++ b/website/scripts/blog-publish-date.mjs @@ -10,7 +10,7 @@ // --fix renames the file and rewrites its /blog/YYYY/MM/DD/slug links repo-wide. // A newly published post must be dated by its filename alone (no `date:`). import { execFileSync } from 'node:child_process'; -import { readdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { readdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'; const BLOG_DIR = 'website/blog'; const MAX_DAYS = 3; @@ -83,12 +83,20 @@ for (const file of readdirSync(BLOG_DIR)) { if (fix) { const newFile = today + file.slice(today.length); - git('mv', path, `${BLOG_DIR}/${newFile}`); + // Not `git mv`: a new post may not be tracked yet + renameSync(path, `${BLOG_DIR}/${newFile}`); + git('add', '--', `${BLOG_DIR}/${newFile}`); + git('rm', '--cached', '-q', '--ignore-unmatch', '--', path); const oldUrl = `/blog/${year}/${month}/${day}/${slug}`; const newUrl = oldUrl.replace( `${year}/${month}/${day}`, today.replaceAll('-', '/'), ); + // Stop at the slug's end so a sibling like `${slug}-notes` is untouched + const oldUrlPattern = new RegExp( + `${oldUrl.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?![\\w-])`, + 'g', + ); let linking = []; try { linking = git('grep', '-lF', oldUrl).split('\n').filter(Boolean); @@ -98,7 +106,7 @@ for (const file of readdirSync(BLOG_DIR)) { } for (const linked of linking) { const text = readFileSync(linked, 'utf8'); - writeFileSync(linked, text.replaceAll(oldUrl, newUrl)); + writeFileSync(linked, text.replace(oldUrlPattern, newUrl)); } console.log( `renamed ${file} -> ${newFile}; updated links in ${linking.length} file(s)`, From ee12d91de9fea4a37d235da218bf5815255225fd Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 03:03:29 +0000 Subject: [PATCH 06/13] internal: blog-publish-date --fix won't overwrite posts; checks untracked links, dotted slugs and post folders Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_016BYJtNfgrkJzBYthZA1WED --- website/scripts/blog-publish-date.mjs | 32 ++++++++++++++++++++++----- 1 file changed, 27 insertions(+), 5 deletions(-) diff --git a/website/scripts/blog-publish-date.mjs b/website/scripts/blog-publish-date.mjs index 341fa83d6bb8..e9c385b9b9ad 100644 --- a/website/scripts/blog-publish-date.mjs +++ b/website/scripts/blog-publish-date.mjs @@ -10,7 +10,13 @@ // --fix renames the file and rewrites its /blog/YYYY/MM/DD/slug links repo-wide. // A newly published post must be dated by its filename alone (no `date:`). import { execFileSync } from 'node:child_process'; -import { readdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'; +import { + existsSync, + readdirSync, + readFileSync, + renameSync, + writeFileSync, +} from 'node:fs'; const BLOG_DIR = 'website/blog'; const MAX_DAYS = 3; @@ -60,7 +66,16 @@ const renamedFrom = new Map( const today = new Date().toISOString().slice(0, 10); let failed = false; -for (const file of readdirSync(BLOG_DIR)) { +for (const entry of readdirSync(BLOG_DIR, { withFileTypes: true })) { + const file = entry.name; + // Docusaurus also builds posts from folders, which this check can't date + if (entry.isDirectory() && !file.startsWith('.')) { + console.error( + `${BLOG_DIR}/${file}: use a YYYY-MM-DD-slug.md file, not a post folder`, + ); + failed = true; + continue; + } if (!MARKDOWN.test(file)) continue; if (publishedAtBase.has(renamedFrom.get(file) ?? file)) continue; const path = `${BLOG_DIR}/${file}`; @@ -84,6 +99,11 @@ for (const file of readdirSync(BLOG_DIR)) { if (fix) { const newFile = today + file.slice(today.length); // Not `git mv`: a new post may not be tracked yet + if (existsSync(`${BLOG_DIR}/${newFile}`)) { + console.error(`${path}: can't rename, ${newFile} already exists`); + failed = true; + continue; + } renameSync(path, `${BLOG_DIR}/${newFile}`); git('add', '--', `${BLOG_DIR}/${newFile}`); git('rm', '--cached', '-q', '--ignore-unmatch', '--', path); @@ -92,14 +112,16 @@ for (const file of readdirSync(BLOG_DIR)) { `${year}/${month}/${day}`, today.replaceAll('-', '/'), ); - // Stop at the slug's end so a sibling like `${slug}-notes` is untouched + // Stop at the slug's end so siblings like `${slug}-notes` are untouched const oldUrlPattern = new RegExp( - `${oldUrl.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?![\\w-])`, + `${oldUrl.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?![\\w.-])`, 'g', ); let linking = []; try { - linking = git('grep', '-lF', oldUrl).split('\n').filter(Boolean); + linking = git('grep', '--untracked', '-lF', oldUrl) + .split('\n') + .filter(Boolean); } catch (error) { // git grep exits 1 when nothing links to the post if (error.status !== 1) throw error; From ad71285f498e0fce42544f911a974a70965e08b2 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 03:09:37 +0000 Subject: [PATCH 07/13] internal: blog-publish-date stages the post after rewriting links; accepts CRLF front matter Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_016BYJtNfgrkJzBYthZA1WED --- website/scripts/blog-publish-date.mjs | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/website/scripts/blog-publish-date.mjs b/website/scripts/blog-publish-date.mjs index e9c385b9b9ad..80c7f88dd80d 100644 --- a/website/scripts/blog-publish-date.mjs +++ b/website/scripts/blog-publish-date.mjs @@ -35,7 +35,7 @@ const git = (...args) => execFileSync('git', args, { encoding: 'utf8' }); // 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(/^---\n([\s\S]*?)\n---/)?.[1] ?? ''; + const block = source.match(/^---\r?\n([\s\S]*?)\r?\n---/)?.[1] ?? ''; return { draft: /^draft:\s*true\s*(#.*)?$/m.test(block), hasDate: /^date:/m.test(block), @@ -98,15 +98,13 @@ for (const entry of readdirSync(BLOG_DIR, { withFileTypes: true })) { if (fix) { const newFile = today + file.slice(today.length); - // Not `git mv`: a new post may not be tracked yet if (existsSync(`${BLOG_DIR}/${newFile}`)) { console.error(`${path}: can't rename, ${newFile} already exists`); failed = true; continue; } + // Not `git mv`: a new post may not be tracked yet renameSync(path, `${BLOG_DIR}/${newFile}`); - git('add', '--', `${BLOG_DIR}/${newFile}`); - git('rm', '--cached', '-q', '--ignore-unmatch', '--', path); const oldUrl = `/blog/${year}/${month}/${day}/${slug}`; const newUrl = oldUrl.replace( `${year}/${month}/${day}`, @@ -130,6 +128,9 @@ for (const entry of readdirSync(BLOG_DIR, { withFileTypes: true })) { const text = readFileSync(linked, 'utf8'); writeFileSync(linked, text.replace(oldUrlPattern, newUrl)); } + // Stage after the rewrite so the commit gets the updated self-links + git('add', '--', `${BLOG_DIR}/${newFile}`); + git('rm', '--cached', '-q', '--ignore-unmatch', '--', path); console.log( `renamed ${file} -> ${newFile}; updated links in ${linking.length} file(s)`, ); From 5e87b036ff7911afda99a90bca6a63d46af5199a Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 03:54:40 +0000 Subject: [PATCH 08/13] internal: Add yarn blog:publish to take a post out of draft dated today It removes draft: true, renames the post to today and rewrites its links. The CI check (blog-publish.mjs --check) now points failures at it, replacing --fix. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_016BYJtNfgrkJzBYthZA1WED --- .claude/rules/ci-config.md | 2 +- .cursor/rules/ci-config.mdc | 2 +- .github/workflows/site-preview.yml | 2 +- package.json | 1 + website/blog/.claude/rules/blog-posts.md | 2 +- website/blog/.cursor/rules/blog-posts.mdc | 2 +- website/scripts/blog-publish-date.mjs | 146 ------------------ website/scripts/blog-publish.mjs | 173 ++++++++++++++++++++++ 8 files changed, 179 insertions(+), 151 deletions(-) delete mode 100644 website/scripts/blog-publish-date.mjs create mode 100644 website/scripts/blog-publish.mjs diff --git a/.claude/rules/ci-config.md b/.claude/rules/ci-config.md index c5302fde6c55..56a2261ede72 100644 --- a/.claude/rules/ci-config.md +++ b/.claude/rules/ci-config.md @@ -33,7 +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` also runs `website/scripts/blog-publish-date.mjs` against the PR base: a blog post published in the PR (added without `draft: true`, or its draft removed) must be dated within 3 days of today; `--fix` renames it and its links. +- `site-preview.yml` also runs `website/scripts/blog-publish.mjs --check` against the PR base: a blog post published in the PR (added without `draft: true`, or its draft removed) must be dated within 3 days of today by its filename; `yarn blog:publish ` fixes it. - 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 7445403fbc9c..31685cf6e804 100644 --- a/.cursor/rules/ci-config.mdc +++ b/.cursor/rules/ci-config.mdc @@ -31,7 +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` also runs `website/scripts/blog-publish-date.mjs` against the PR base: a blog post published in the PR (added without `draft: true`, or its draft removed) must be dated within 3 days of today; `--fix` renames it and its links. +- `site-preview.yml` also runs `website/scripts/blog-publish.mjs --check` against the PR base: a blog post published in the PR (added without `draft: true`, or its draft removed) must be dated within 3 days of today by its filename; `yarn blog:publish ` fixes it. - 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 101a6a0b32ea..36c11fe3b72f 100644 --- a/.github/workflows/site-preview.yml +++ b/.github/workflows/site-preview.yml @@ -48,7 +48,7 @@ jobs: BASE_SHA: ${{ github.event.pull_request.base.sha }} run: | git fetch --depth=1 origin "$BASE_SHA" - node website/scripts/blog-publish-date.mjs "$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 b2f53202cb8e..a6331170b864 100644 --- a/website/blog/.claude/rules/blog-posts.md +++ b/website/blog/.claude/rules/blog-posts.md @@ -14,7 +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 -- Publishing: the filename date is the post's date and URL, so when removing `draft: true` run `node website/scripts/blog-publish-date.mjs origin/master --fix` to rename the post to today and rewrite its `/blog/YYYY/MM/DD/...` links. The `website` CI check fails a newly published post dated more than 3 days from today, or dated by a `date:` field instead of its filename. +- Publishing: run `yarn blog:publish website/blog/.md`. The filename date is the post's date and URL, so it removes `draft: true`, renames the post to today and rewrites its `/blog/YYYY/MM/DD/...` links. The `website` CI check fails a newly published post dated more than 3 days from today, or dated by a `date:` field instead of its filename. ## Package Layers diff --git a/website/blog/.cursor/rules/blog-posts.mdc b/website/blog/.cursor/rules/blog-posts.mdc index 926ba347a1f4..afbf4a770336 100644 --- a/website/blog/.cursor/rules/blog-posts.mdc +++ b/website/blog/.cursor/rules/blog-posts.mdc @@ -13,7 +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 -- Publishing: the filename date is the post's date and URL, so when removing `draft: true` run `node website/scripts/blog-publish-date.mjs origin/master --fix` to rename the post to today and rewrite its `/blog/YYYY/MM/DD/...` links. The `website` CI check fails a newly published post dated more than 3 days from today, or dated by a `date:` field instead of its filename. +- Publishing: run `yarn blog:publish website/blog/.md`. The filename date is the post's date and URL, so it removes `draft: true`, renames the post to today and rewrites its `/blog/YYYY/MM/DD/...` links. The `website` CI check fails a newly published post dated more than 3 days from today, or dated by a `date:` field instead of its filename. ## Package Layers diff --git a/website/scripts/blog-publish-date.mjs b/website/scripts/blog-publish-date.mjs deleted file mode 100644 index 80c7f88dd80d..000000000000 --- a/website/scripts/blog-publish-date.mjs +++ /dev/null @@ -1,146 +0,0 @@ -#!/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. This fails -// when a post is published (newly added without `draft: true`, or its -// `draft: true` removed) and its date isn't within MAX_DAYS of today. -// -// node website/scripts/blog-publish-date.mjs check -// node website/scripts/blog-publish-date.mjs --fix rename to today -// -// --fix renames the file and rewrites its /blog/YYYY/MM/DD/slug links repo-wide. -// A newly published post must be dated by its filename alone (no `date:`). -import { execFileSync } from 'node:child_process'; -import { - existsSync, - readdirSync, - readFileSync, - renameSync, - writeFileSync, -} from 'node:fs'; - -const BLOG_DIR = 'website/blog'; -const MAX_DAYS = 3; -const MARKDOWN = /\.mdx?$/; -const POST = /^(\d{4})-(\d{2})-(\d{2})-(.+)\.mdx?$/; - -const [base, flag] = process.argv.slice(2); -if (!base) { - console.error('usage: blog-publish-date.mjs [--fix]'); - process.exit(2); -} -const fix = flag === '--fix'; - -const git = (...args) => execFileSync('git', args, { encoding: 'utf8' }); - -// 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(/^---\r?\n([\s\S]*?)\r?\n---/)?.[1] ?? ''; - return { - draft: /^draft:\s*true\s*(#.*)?$/m.test(block), - hasDate: /^date:/m.test(block), - }; -} - -const publishedAtBase = new Set( - git('ls-tree', '--name-only', base, `${BLOG_DIR}/`) - .split('\n') - .map(path => path.slice(BLOG_DIR.length + 1)) - .filter(file => MARKDOWN.test(file)) - .filter( - file => !frontMatter(git('show', `${base}:${BLOG_DIR}/${file}`)).draft, - ), -); - -// A published post that was only renamed (slug fix, .md -> .mdx) stays published -const renamedFrom = new Map( - git('diff', '--name-status', '-M', '--diff-filter=R', base, '--', BLOG_DIR) - .split('\n') - .filter(Boolean) - .map(line => { - const [, from, to] = line.split('\t'); - return [to.slice(BLOG_DIR.length + 1), from.slice(BLOG_DIR.length + 1)]; - }), -); - -const today = new Date().toISOString().slice(0, 10); -let failed = false; - -for (const entry of readdirSync(BLOG_DIR, { withFileTypes: true })) { - const file = entry.name; - // Docusaurus also builds posts from folders, which this check can't date - if (entry.isDirectory() && !file.startsWith('.')) { - console.error( - `${BLOG_DIR}/${file}: use a YYYY-MM-DD-slug.md file, not a post folder`, - ); - failed = true; - continue; - } - if (!MARKDOWN.test(file)) continue; - if (publishedAtBase.has(renamedFrom.get(file) ?? file)) continue; - const path = `${BLOG_DIR}/${file}`; - const { draft, hasDate } = frontMatter(readFileSync(path, 'utf8')); - if (draft) continue; - - // Only the filename dates a post, so --fix can rename it and its links - const match = file.match(POST); - if (!match || hasDate) { - console.error( - `${path}: name published posts YYYY-MM-DD-slug.md, without a \`date:\` front matter field`, - ); - failed = true; - continue; - } - const [, year, month, day, slug] = match; - const postDate = `${year}-${month}-${day}`; - const days = Math.abs(Date.parse(postDate) - Date.parse(today)) / 864e5; - if (days <= MAX_DAYS) continue; - - if (fix) { - const newFile = today + file.slice(today.length); - if (existsSync(`${BLOG_DIR}/${newFile}`)) { - console.error(`${path}: can't rename, ${newFile} already exists`); - failed = true; - continue; - } - // Not `git mv`: a new post may not be tracked yet - renameSync(path, `${BLOG_DIR}/${newFile}`); - const oldUrl = `/blog/${year}/${month}/${day}/${slug}`; - const newUrl = oldUrl.replace( - `${year}/${month}/${day}`, - today.replaceAll('-', '/'), - ); - // 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', '-lF', 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)); - } - // Stage after the rewrite so the commit gets the updated self-links - git('add', '--', `${BLOG_DIR}/${newFile}`); - git('rm', '--cached', '-q', '--ignore-unmatch', '--', path); - console.log( - `renamed ${file} -> ${newFile}; updated links in ${linking.length} file(s)`, - ); - } else { - console.error( - `${path}: published with date ${postDate}, but today is ${today}. ` + - `Run \`node website/scripts/blog-publish-date.mjs origin/master --fix\` to rename it and its links.`, - ); - failed = true; - } -} - -process.exit(failed ? 1 : 0); diff --git a/website/scripts/blog-publish.mjs b/website/scripts/blog-publish.mjs new file mode 100644 index 000000000000..28c4e4fec4c4 --- /dev/null +++ b/website/scripts/blog-publish.mjs @@ -0,0 +1,173 @@ +#!/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, + readdirSync, + readFileSync, + renameSync, + writeFileSync, +} from 'node:fs'; +import { basename } from 'node:path'; + +const BLOG_DIR = 'website/blog'; +const MAX_DAYS = 3; +const MARKDOWN = /\.mdx?$/; +const POST = /^(\d{4})-(\d{2})-(\d{2})-(.+)\.mdx?$/; +const FRONT_MATTER = /^---\r?\n([\s\S]*?)\r?\n---/; +const DRAFT = /^draft:\s*true\s*(#.*)?$/m; + +const git = (...args) => execFileSync('git', args, { encoding: 'utf8' }); +const today = new Date().toISOString().slice(0, 10); + +// 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(path) { + const file = basename(path); + const match = file.match(POST); + if (!existsSync(`${BLOG_DIR}/${file}`) || !match) { + throw new Error(`${path}: expected ${BLOG_DIR}/YYYY-MM-DD-slug.md`); + } + path = `${BLOG_DIR}/${file}`; + const source = readFileSync(path, 'utf8'); + if (frontMatter(source).hasDate) { + throw new Error( + `${path}: remove its \`date:\` field; the filename dates it`, + ); + } + writeFileSync( + path, + source.replace(FRONT_MATTER, block => + block.replace(/^draft:\s*true\s*(#.*)?\r?\n/m, ''), + ), + ); + + const [, year, month, day, slug] = match; + const newFile = today + file.slice(today.length); + if (newFile !== file) { + if (existsSync(`${BLOG_DIR}/${newFile}`)) { + throw new Error(`${path}: can't rename, ${newFile} already exists`); + } + // Not `git mv`: a new post may not be tracked yet + renameSync(path, `${BLOG_DIR}/${newFile}`); + rewriteLinks( + `/blog/${year}/${month}/${day}/${slug}`, + `/blog/${today.replaceAll('-', '/')}/${slug}`, + ); + } + // Stage after the rewrite so the commit gets the updated self-links + git('add', '--', `${BLOG_DIR}/${newFile}`); + if (newFile !== file) { + git('rm', '--cached', '-q', '--ignore-unmatch', '--', path); + } + console.log(`published ${BLOG_DIR}/${newFile}`); +} + +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', '-lF', 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)); + } +} + +function check(base) { + const publishedAtBase = new Set( + git('ls-tree', '--name-only', base, `${BLOG_DIR}/`) + .split('\n') + .map(path => path.slice(BLOG_DIR.length + 1)) + .filter(file => MARKDOWN.test(file)) + .filter( + file => !frontMatter(git('show', `${base}:${BLOG_DIR}/${file}`)).draft, + ), + ); + // A published post that was only renamed (slug fix, .md -> .mdx) stays published + const renamedFrom = new Map( + git('diff', '--name-status', '-M', '--diff-filter=R', base, '--', BLOG_DIR) + .split('\n') + .filter(Boolean) + .map(line => { + const [, from, to] = line.split('\t'); + return [to.slice(BLOG_DIR.length + 1), from.slice(BLOG_DIR.length + 1)]; + }), + ); + + let failed = false; + const fail = message => { + console.error(message); + failed = true; + }; + for (const entry of readdirSync(BLOG_DIR, { withFileTypes: true })) { + const file = entry.name; + const path = `${BLOG_DIR}/${file}`; + // Docusaurus also builds posts from folders, which this check can't date + if (entry.isDirectory() && !file.startsWith('.')) { + fail(`${path}: use a YYYY-MM-DD-slug.md file, not a post folder`); + continue; + } + if (!MARKDOWN.test(file)) continue; + if (publishedAtBase.has(renamedFrom.get(file) ?? file)) continue; + const { draft, hasDate } = frontMatter(readFileSync(path, 'utf8')); + if (draft) continue; + + const match = file.match(POST); + if (!match || hasDate) { + fail( + `${path}: name published posts YYYY-MM-DD-slug.md, without a \`date:\` front matter field`, + ); + continue; + } + const postDate = `${match[1]}-${match[2]}-${match[3]}`; + const days = Math.abs(Date.parse(postDate) - Date.parse(today)) / 864e5; + if (days > MAX_DAYS) { + fail( + `${path}: published with date ${postDate}, but today is ${today}. ` + + `Run \`yarn blog:publish ${path}\` 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); +} From 9348c3d07f1ccc5b3ee32162fe1425b0c93bd2ae Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 03:58:58 +0000 Subject: [PATCH 09/13] internal: Simplify blog-publish: check only posts the PR changed, one draft regex Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_016BYJtNfgrkJzBYthZA1WED --- website/scripts/blog-publish.mjs | 106 ++++++++++++------------------- 1 file changed, 41 insertions(+), 65 deletions(-) diff --git a/website/scripts/blog-publish.mjs b/website/scripts/blog-publish.mjs index 28c4e4fec4c4..17332c8b82f1 100644 --- a/website/scripts/blog-publish.mjs +++ b/website/scripts/blog-publish.mjs @@ -9,24 +9,18 @@ // (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, - readdirSync, - readFileSync, - renameSync, - writeFileSync, -} from 'node:fs'; +import { existsSync, readFileSync, renameSync, writeFileSync } from 'node:fs'; import { basename } from 'node:path'; const BLOG_DIR = 'website/blog'; const MAX_DAYS = 3; -const MARKDOWN = /\.mdx?$/; const POST = /^(\d{4})-(\d{2})-(\d{2})-(.+)\.mdx?$/; const FRONT_MATTER = /^---\r?\n([\s\S]*?)\r?\n---/; -const DRAFT = /^draft:\s*true\s*(#.*)?$/m; +const DRAFT = /^draft:\s*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. @@ -35,13 +29,13 @@ function frontMatter(source) { return { draft: DRAFT.test(block), hasDate: /^date:/m.test(block) }; } -function publish(path) { - const file = basename(path); - const match = file.match(POST); - if (!existsSync(`${BLOG_DIR}/${file}`) || !match) { - throw new Error(`${path}: expected ${BLOG_DIR}/YYYY-MM-DD-slug.md`); +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`); } - path = `${BLOG_DIR}/${file}`; const source = readFileSync(path, 'utf8'); if (frontMatter(source).hasDate) { throw new Error( @@ -50,30 +44,25 @@ function publish(path) { } writeFileSync( path, - source.replace(FRONT_MATTER, block => - block.replace(/^draft:\s*true\s*(#.*)?\r?\n/m, ''), - ), + source.replace(FRONT_MATTER, block => block.replace(DRAFT, '')), ); - const [, year, month, day, slug] = match; - const newFile = today + file.slice(today.length); - if (newFile !== file) { - if (existsSync(`${BLOG_DIR}/${newFile}`)) { - throw new Error(`${path}: can't rename, ${newFile} already exists`); + const newPath = `${BLOG_DIR}/${today}${file.slice(today.length)}`; + if (newPath !== path) { + if (existsSync(newPath)) { + throw new Error(`${path}: can't rename, ${newPath} already exists`); } // Not `git mv`: a new post may not be tracked yet - renameSync(path, `${BLOG_DIR}/${newFile}`); + renameSync(path, newPath); rewriteLinks( - `/blog/${year}/${month}/${day}/${slug}`, - `/blog/${today.replaceAll('-', '/')}/${slug}`, + `/blog/${urlDate(file.slice(0, 10))}/${slug}`, + `/blog/${urlDate(today)}/${slug}`, ); - } - // Stage after the rewrite so the commit gets the updated self-links - git('add', '--', `${BLOG_DIR}/${newFile}`); - if (newFile !== file) { git('rm', '--cached', '-q', '--ignore-unmatch', '--', path); } - console.log(`published ${BLOG_DIR}/${newFile}`); + // Stage after the rewrite so the commit gets the updated self-links + git('add', '--', newPath); + console.log(`published ${newPath}`); } function rewriteLinks(oldUrl, newUrl) { @@ -98,57 +87,44 @@ function rewriteLinks(oldUrl, newUrl) { } function check(base) { - const publishedAtBase = new Set( - git('ls-tree', '--name-only', base, `${BLOG_DIR}/`) - .split('\n') - .map(path => path.slice(BLOG_DIR.length + 1)) - .filter(file => MARKDOWN.test(file)) - .filter( - file => !frontMatter(git('show', `${base}:${BLOG_DIR}/${file}`)).draft, - ), - ); - // A published post that was only renamed (slug fix, .md -> .mdx) stays published - const renamedFrom = new Map( - git('diff', '--name-status', '-M', '--diff-filter=R', base, '--', BLOG_DIR) - .split('\n') - .filter(Boolean) - .map(line => { - const [, from, to] = line.split('\t'); - return [to.slice(BLOG_DIR.length + 1), from.slice(BLOG_DIR.length + 1)]; - }), - ); - let failed = false; const fail = message => { console.error(message); failed = true; }; - for (const entry of readdirSync(BLOG_DIR, { withFileTypes: true })) { - const file = entry.name; - const path = `${BLOG_DIR}/${file}`; + // 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 (entry.isDirectory() && !file.startsWith('.')) { - fail(`${path}: use a YYYY-MM-DD-slug.md file, not a post folder`); + if (file.includes('/')) { + fail(`${to}: use a YYYY-MM-DD-slug.md file, not a post folder`); continue; } - if (!MARKDOWN.test(file)) continue; - if (publishedAtBase.has(renamedFrom.get(file) ?? file)) continue; - const { draft, hasDate } = frontMatter(readFileSync(path, 'utf8')); + const { draft, hasDate } = frontMatter(readFileSync(to, 'utf8')); if (draft) continue; + if (status !== 'A' && !frontMatter(git('show', `${base}:${from}`)).draft) { + continue; + } - const match = file.match(POST); - if (!match || hasDate) { + if (!POST.test(file) || hasDate) { fail( - `${path}: name published posts YYYY-MM-DD-slug.md, without a \`date:\` front matter field`, + `${to}: name published posts YYYY-MM-DD-slug.md, without a \`date:\` front matter field`, ); continue; } - const postDate = `${match[1]}-${match[2]}-${match[3]}`; + const postDate = file.slice(0, 10); const days = Math.abs(Date.parse(postDate) - Date.parse(today)) / 864e5; if (days > MAX_DAYS) { fail( - `${path}: published with date ${postDate}, but today is ${today}. ` + - `Run \`yarn blog:publish ${path}\` to date it today and fix its links.`, + `${to}: published with date ${postDate}, but today is ${today}. ` + + `Run \`yarn blog:publish ${to}\` to date it today and fix its links.`, ); } } From a6f6fdc3b86c37b2688b1287c6718554e3117774 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 04:04:37 +0000 Subject: [PATCH 10/13] internal: Check blog:publish rename target before editing the post Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_016BYJtNfgrkJzBYthZA1WED --- website/scripts/blog-publish.mjs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/website/scripts/blog-publish.mjs b/website/scripts/blog-publish.mjs index 17332c8b82f1..032d2652f86a 100644 --- a/website/scripts/blog-publish.mjs +++ b/website/scripts/blog-publish.mjs @@ -42,16 +42,16 @@ function publish(arg) { `${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, '')), ); - const newPath = `${BLOG_DIR}/${today}${file.slice(today.length)}`; if (newPath !== path) { - if (existsSync(newPath)) { - throw new Error(`${path}: can't rename, ${newPath} already exists`); - } // Not `git mv`: a new post may not be tracked yet renameSync(path, newPath); rewriteLinks( From e1c37435acb5dbfe4322ffad35c0ca45d91db649 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 14:20:41 +0000 Subject: [PATCH 11/13] internal: Shorten blog:publish rule entries Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_016BYJtNfgrkJzBYthZA1WED --- .claude/rules/ci-config.md | 2 +- .cursor/rules/ci-config.mdc | 2 +- website/blog/.claude/rules/blog-posts.md | 2 +- website/blog/.cursor/rules/blog-posts.mdc | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.claude/rules/ci-config.md b/.claude/rules/ci-config.md index 56a2261ede72..2d190c730ee5 100644 --- a/.claude/rules/ci-config.md +++ b/.claude/rules/ci-config.md @@ -33,7 +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` also runs `website/scripts/blog-publish.mjs --check` against the PR base: a blog post published in the PR (added without `draft: true`, or its draft removed) must be dated within 3 days of today by its filename; `yarn blog:publish ` fixes it. +- `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 31685cf6e804..d0e11b9b7c9c 100644 --- a/.cursor/rules/ci-config.mdc +++ b/.cursor/rules/ci-config.mdc @@ -31,7 +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` also runs `website/scripts/blog-publish.mjs --check` against the PR base: a blog post published in the PR (added without `draft: true`, or its draft removed) must be dated within 3 days of today by its filename; `yarn blog:publish ` fixes it. +- `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/website/blog/.claude/rules/blog-posts.md b/website/blog/.claude/rules/blog-posts.md index a6331170b864..cc94d3aaa72b 100644 --- a/website/blog/.claude/rules/blog-posts.md +++ b/website/blog/.claude/rules/blog-posts.md @@ -14,7 +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 -- Publishing: run `yarn blog:publish website/blog/.md`. The filename date is the post's date and URL, so it removes `draft: true`, renames the post to today and rewrites its `/blog/YYYY/MM/DD/...` links. The `website` CI check fails a newly published post dated more than 3 days from today, or dated by a `date:` field instead of its filename. +- 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 afbf4a770336..888cbc9caf34 100644 --- a/website/blog/.cursor/rules/blog-posts.mdc +++ b/website/blog/.cursor/rules/blog-posts.mdc @@ -13,7 +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 -- Publishing: run `yarn blog:publish website/blog/.md`. The filename date is the post's date and URL, so it removes `draft: true`, renames the post to today and rewrites its `/blog/YYYY/MM/DD/...` links. The `website` CI check fails a newly published post dated more than 3 days from today, or dated by a `date:` field instead of its filename. +- 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 From bc58117120a57018f3a9669b0a3fa7589ee0691b Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 14:29:42 +0000 Subject: [PATCH 12/13] internal: blog:publish stages rewritten links, skips binaries, reads uppercase draft booleans Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_016BYJtNfgrkJzBYthZA1WED --- website/scripts/blog-publish.mjs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/website/scripts/blog-publish.mjs b/website/scripts/blog-publish.mjs index 032d2652f86a..78dd8a18c2cd 100644 --- a/website/scripts/blog-publish.mjs +++ b/website/scripts/blog-publish.mjs @@ -16,7 +16,7 @@ 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[^\S\r\n]*(#.*)?(\r?\n|$)/m; +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); @@ -73,7 +73,7 @@ function rewriteLinks(oldUrl, newUrl) { ); let linking = []; try { - linking = git('grep', '--untracked', '-lF', oldUrl) + linking = git('grep', '--untracked', '-IlF', oldUrl) .split('\n') .filter(Boolean); } catch (error) { @@ -84,6 +84,9 @@ function rewriteLinks(oldUrl, newUrl) { const text = readFileSync(linked, 'utf8'); writeFileSync(linked, text.replace(oldUrlPattern, newUrl)); } + // Stage the rewrites so the commit has no broken links; -u leaves untracked + // files for their author to add + if (linking.length) git('add', '-u', '--', ...linking); } function check(base) { From 4daee1e212f25cee7c6600baad5548ca1bc3eb2e Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 14:31:38 +0000 Subject: [PATCH 13/13] internal: blog:publish stages only tracked files whose links it rewrote Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_016BYJtNfgrkJzBYthZA1WED --- website/scripts/blog-publish.mjs | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/website/scripts/blog-publish.mjs b/website/scripts/blog-publish.mjs index 78dd8a18c2cd..ad3ab166e311 100644 --- a/website/scripts/blog-publish.mjs +++ b/website/scripts/blog-publish.mjs @@ -84,9 +84,13 @@ function rewriteLinks(oldUrl, newUrl) { const text = readFileSync(linked, 'utf8'); writeFileSync(linked, text.replace(oldUrlPattern, newUrl)); } - // Stage the rewrites so the commit has no broken links; -u leaves untracked - // files for their author to add - if (linking.length) git('add', '-u', '--', ...linking); + 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) {