diff --git a/.githooks/pre-commit b/.githooks/pre-commit deleted file mode 100755 index f081a6bd..00000000 --- a/.githooks/pre-commit +++ /dev/null @@ -1,9 +0,0 @@ -#!/bin/sh -set -e - -if ! command -v pnpm >/dev/null 2>&1; then - echo "warning: pnpm not found; skipping stable ID hook" >&2 - exit 0 -fi - -pnpm stable-ids:ensure --staged diff --git a/.githooks/pre-push b/.githooks/pre-push deleted file mode 100755 index 08e9dd86..00000000 --- a/.githooks/pre-push +++ /dev/null @@ -1,13 +0,0 @@ -#!/bin/sh -set -e - -if ! git fetch origin main --quiet; then - echo "warning: failed to fetch origin/main; using local ref for redirect check" >&2 -fi - -if git diff --quiet origin/main...HEAD -- content redirects.json content.config.ts; then - exit 0 -fi - -echo "Checking redirects for docs changes..." -pnpm redirects:check diff --git a/.github/workflows/docs-checks.yml b/.github/workflows/docs-checks.yml new file mode 100644 index 00000000..d1199bcf --- /dev/null +++ b/.github/workflows/docs-checks.yml @@ -0,0 +1,58 @@ +name: Docs Checks + +on: + push: + branches: + - main + paths: &docs-check-paths + - 'content/**' + - 'content.config.ts' + - 'redirects.json' + - 'scripts/_content-lib.ts' + - 'scripts/check-stable-ids.ts' + - 'scripts/ensure-stable-ids.ts' + - 'scripts/redirects-sync.ts' + - 'package.json' + - 'pnpm-lock.yaml' + - '.github/workflows/docs-checks.yml' + pull_request: + branches: + - main + paths: *docs-check-paths + workflow_dispatch: + +permissions: + contents: read + +jobs: + docs-checks: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile --ignore-scripts + + - name: Typecheck scripts + run: pnpm typecheck:scripts + + - name: Check stable IDs + run: pnpm stable-ids:check + + # Redirect check diffs against origin/main, so it only makes sense as a + # pre-merge gate on PRs. On push to main the merge already landed and + # origin/main points at the pushed commit (self-comparison), so skip it. + - name: Fetch base ref for redirect diff + if: github.event_name == 'pull_request' + run: git fetch --depth=1 origin main + + - name: Check redirects + if: github.event_name == 'pull_request' + run: pnpm redirects:check diff --git a/.github/workflows/search-index-cleanup.yml b/.github/workflows/search-index-cleanup.yml new file mode 100644 index 00000000..b453c0cb --- /dev/null +++ b/.github/workflows/search-index-cleanup.yml @@ -0,0 +1,45 @@ +name: Search Index Cleanup + +on: + pull_request: + branches: + - main + types: + - closed + workflow_dispatch: + inputs: + branch: + description: Branch name for the preview index to delete + required: true + type: string + +permissions: + contents: read + +jobs: + cleanup-preview-index: + if: github.event_name == 'workflow_dispatch' || github.event.pull_request.head.repo.full_name == github.repository + runs-on: ubuntu-latest + concurrency: + group: search-index-preview-${{ github.event.pull_request.number || inputs.branch }} + cancel-in-progress: true + env: + TYPESENSE_URL: ${{ secrets.TYPESENSE_URL }} + TYPESENSE_PRIVATE_API_KEY: ${{ secrets.TYPESENSE_PRIVATE_API_KEY }} + TYPESENSE_PREVIEW_BRANCH: ${{ github.event.pull_request.head.ref || inputs.branch }} + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + + - name: Install dependencies + # This job runs one repository script only; skip package postinstall/build scripts. + run: pnpm install --frozen-lockfile --ignore-scripts + + - name: Delete preview collection + run: pnpm typesense:cleanup-preview --branch "$TYPESENSE_PREVIEW_BRANCH" diff --git a/.github/workflows/search-index.yml b/.github/workflows/search-index.yml index 12f49ec8..91cc6753 100644 --- a/.github/workflows/search-index.yml +++ b/.github/workflows/search-index.yml @@ -8,30 +8,37 @@ on: - 'content/**' - 'scripts/index-docs.ts' - 'scripts/index-docs-chunker.ts' + - 'scripts/_content-lib.ts' - 'shared/utils/parseTypesenseUrl.ts' - 'shared/utils/docsSections.ts' - 'app/utils/slugify.ts' - 'server/data/synonyms.ts' - 'lib/typesenseAlias.ts' + - 'package.json' - 'pnpm-lock.yaml' - '.github/workflows/search-index.yml' pull_request: branches: - main + types: + - opened + - synchronize + - reopened paths: *index-paths workflow_dispatch: -concurrency: - group: search-index-${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: false - permissions: contents: read jobs: preview-index: - if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository + if: github.event_name == 'pull_request' && github.event.action != 'closed' && github.event.pull_request.head.repo.full_name == github.repository runs-on: ubuntu-latest + # Cancel superseded preview runs so the alias never ends up pointing at a + # stale index from an out-of-order swap. + concurrency: + group: search-index-preview-${{ github.event.pull_request.number }} + cancel-in-progress: true env: TYPESENSE_URL: ${{ secrets.TYPESENSE_URL }} TYPESENSE_PUBLIC_API_KEY: ${{ secrets.TYPESENSE_PUBLIC_API_KEY }} @@ -47,7 +54,8 @@ jobs: cache: pnpm - name: Install dependencies - run: pnpm install --frozen-lockfile + # These jobs run repository scripts only; skip package postinstall/build scripts. + run: pnpm install --frozen-lockfile --ignore-scripts - name: Index preview collection run: pnpm index:docs @@ -55,6 +63,11 @@ jobs: prod-index: if: (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && github.ref == 'refs/heads/main' runs-on: ubuntu-latest + # Run prod indexing sequentially; the blue/green slot design tolerates + # re-runs, so never cancel a swap mid-flight. + concurrency: + group: search-index-prod + cancel-in-progress: false env: TYPESENSE_URL: ${{ secrets.TYPESENSE_URL }} TYPESENSE_PUBLIC_API_KEY: ${{ secrets.TYPESENSE_PUBLIC_API_KEY }} @@ -70,7 +83,8 @@ jobs: cache: pnpm - name: Install dependencies - run: pnpm install --frozen-lockfile + # These jobs run repository scripts only; skip package postinstall/build scripts. + run: pnpm install --frozen-lockfile --ignore-scripts - name: Index production collection run: pnpm index:docs diff --git a/README.md b/README.md index 8675d90f..27c47bc8 100644 --- a/README.md +++ b/README.md @@ -57,10 +57,30 @@ pnpm stable-ids:check # Validate stableId frontmatter pnpm redirects:sync # Update redirects.json for moved pages pnpm redirects:check # Check redirect coverage without writing files pnpm index:docs # Build the search index in Typesense +pnpm typesense:cleanup-preview # Delete stale Typesense preview indexes pnpm typecheck:scripts # Type check repository scripts ``` -`pnpm install` configures `.githooks` for the repository when no custom `core.hooksPath` is set. The pre-commit hook can add missing `stableId` values to staged docs files. The pre-push hook checks redirects when docs content, redirect configuration, or content configuration changes. +Stable IDs give each public docs page a permanent identity. Nuxt Content derives +its unique page IDs from file paths, so moving a page changes its built-in ID. +Redirect sync compares the current branch to `origin/main`, so moved pages keep +their old URLs working. + +CI runs `pnpm stable-ids:check` and `pnpm redirects:check` for docs changes. + +- New docs page: run `pnpm stable-ids:ensure`, then commit the new `stableId`. +- Moved docs page: keep the existing `stableId`, run `pnpm redirects:sync`, then commit `redirects.json`. +- Deleted, split, or merged docs page: run `pnpm redirects:sync`, review `.docs/redirect-decisions-needed.md`, choose target redirects, then re-run `pnpm redirects:check`. +- Before opening a PR: run `pnpm stable-ids:check` and `pnpm redirects:check`. + +Redirect scripts compare against `origin/main` by default. To check a release branch +or another target, fetch it first, then pass `--base` directly to the script: + +```bash +git fetch origin release/v13 +node scripts/redirects-sync.ts --base origin/release/v13 --no-write --fail-on-unresolved +node scripts/redirects-sync.ts --base origin/release/v13 --write-deterministic --fail-on-unresolved +``` ## ✍️ Authoring Content @@ -138,6 +158,23 @@ For one-off writes, override the index target with `TYPESENSE_INDEX_TARGET=...`. The browser reads from `TYPESENSE_COLLECTION` when set. Otherwise it derives the same branch alias as the indexer. The app reads the alias, never the `-a` / `-b` slot name. +### Preview Cleanup + +PR preview indexes are deleted when same-repo PRs close. The cleanup job deletes the branch alias and both fixed slots: + +```bash +pnpm typesense:cleanup-preview --branch bry/foo +``` + +For one-time cleanup of accumulated preview indexes, run a dry run first: + +```bash +pnpm typesense:cleanup-preview --stale --dry-run +pnpm typesense:cleanup-preview --stale +``` + +Stale cleanup keeps preview aliases for currently open PR branches and deletes the rest. It requires `TYPESENSE_URL`, `TYPESENSE_PRIVATE_API_KEY`, and authenticated `gh`. + ### Ranking Section boosts and personalization live in `buildPersonalizedSortBy` in `app/composables/useDocsSearch.ts`. The same `sectionPriority` array drives both the Typesense `_eval` boost order and the chip-bar render order in the palette. diff --git a/lib/typesenseAlias.ts b/lib/typesenseAlias.ts index f7588216..2c7224a7 100644 --- a/lib/typesenseAlias.ts +++ b/lib/typesenseAlias.ts @@ -2,6 +2,9 @@ import { execSync } from 'node:child_process'; import { createHash } from 'node:crypto'; import process from 'node:process'; +export const TYPESENSE_PROD_ALIAS = 'directus-docs'; +export const TYPESENSE_PREVIEW_ALIAS_PREFIX = 'directus-docs-preview-'; + export function slugifyBranch(branch: string) { const slug = branch .toLowerCase() @@ -38,6 +41,6 @@ export function getTypesenseBranchName() { export function resolveBranchTypesenseAlias(branch = getTypesenseBranchName()) { if (!branch) return null; - if (branch === 'main') return 'directus-docs'; - return `directus-docs-preview-${slugifyBranch(branch)}`; + if (branch === 'main') return TYPESENSE_PROD_ALIAS; + return `${TYPESENSE_PREVIEW_ALIAS_PREFIX}${slugifyBranch(branch)}`; } diff --git a/package.json b/package.json index 0a775639..542c5903 100644 --- a/package.json +++ b/package.json @@ -3,19 +3,20 @@ "private": true, "type": "module", "scripts": { - "api-ref:generate": "tsx scripts/generate-api-reference.ts", - "build": "tsx scripts/generate-api-reference.ts && nuxt build", - "dev": "tsx scripts/generate-api-reference.ts && nuxt dev", - "generate": "tsx scripts/generate-api-reference.ts && nuxt generate", + "api-ref:generate": "node scripts/generate-api-reference.ts", + "build": "node scripts/generate-api-reference.ts && nuxt build", + "dev": "node scripts/generate-api-reference.ts && nuxt dev", + "generate": "node scripts/generate-api-reference.ts && nuxt generate", "preview": "nuxt preview", - "postinstall": "node scripts/setup-hooks.ts && tsx scripts/generate-api-reference.ts && nuxt prepare", + "postinstall": "node scripts/generate-api-reference.ts && nuxt prepare", "stable-ids:ensure": "node scripts/ensure-stable-ids.ts", "stable-ids:check": "node scripts/check-stable-ids.ts", "redirects:sync": "node scripts/redirects-sync.ts --write-deterministic --fail-on-unresolved", "redirects:check": "node scripts/redirects-sync.ts --fail-on-unresolved --no-write", "typecheck:scripts": "tsc -p scripts/tsconfig.json", - "index:docs": "tsx scripts/index-docs.ts", - "test:search": "vitest run tests/scripts/index-docs-chunker.test.ts tests/components/DocsSearchPalette.test.ts tests/shared/parseTypesenseUrl.test.ts tests/lib/typesenseAlias.test.ts tests/services/typesenseService.test.ts tests/utils/highlightHtml.test.ts" + "index:docs": "node scripts/index-docs.ts", + "typesense:cleanup-preview": "node scripts/cleanup-typesense-preview.ts", + "test:search": "vitest run tests/scripts/index-docs-chunker.test.ts tests/scripts/cleanup-typesense-preview.test.ts tests/components/DocsSearchPalette.test.ts tests/shared/parseTypesenseUrl.test.ts tests/lib/typesenseAlias.test.ts tests/services/typesenseService.test.ts tests/utils/highlightHtml.test.ts" }, "dependencies": { "@directus/openapi": "0.3.0", @@ -32,19 +33,12 @@ "@nuxtjs/sitemap": "8.0.13", "@vueuse/core": "14.2.1", "@vueuse/nuxt": "14.2.1", - "dotenv": "^17.4.2", - "gray-matter": "^4.0.3", "h3": "1.15.11", - "js-yaml": "^4.1.1", - "lodash-es": "4.18.1", "nuxt": "4.4.2", "nuxt-llms": "0.2.0", "openapi3-ts": "4.5.0", "posthog-js": "1.371.2", "posthog-node": "5.29.7", - "remark": "^15.0.1", - "remark-mdc": "^3.11.0", - "remark-parse": "^11.0.0", "sharp": "^0.34.5", "tailwindcss": "^4.2.4", "typesense": "^3.0.6", @@ -61,8 +55,14 @@ "@types/lodash-es": "4.17.12", "@types/node": "^22", "@vue/test-utils": "^2.4.10", + "dotenv": "^17.4.2", + "gray-matter": "^4.0.3", "happy-dom": "^20.9.0", - "tsx": "^4.22.3", + "js-yaml": "^4.1.1", + "lodash-es": "4.18.1", + "remark": "^15.0.1", + "remark-mdc": "^3.11.0", + "remark-parse": "^11.0.0", "typescript": "6.0.3", "vitest": "^4.1.7", "vue-tsc": "^3.2.7" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ca0a5336..10b72eb1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -54,21 +54,9 @@ importers: '@vueuse/nuxt': specifier: 14.2.1 version: 14.2.1(magicast@0.5.2)(nuxt@4.4.2(@babel/core@7.29.0)(@babel/plugin-syntax-jsx@7.28.6(@babel/core@7.29.0))(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@netlify/blobs@9.1.2)(@parcel/watcher@2.5.6)(@types/node@22.19.17)(@vue/compiler-sfc@3.5.33)(better-sqlite3@11.10.0)(cac@6.7.14)(db0@0.3.4(better-sqlite3@11.10.0))(esbuild@0.27.7)(eslint@9.28.0(jiti@2.6.1))(ioredis@5.10.1)(magicast@0.5.2)(optionator@0.9.4)(rolldown@1.0.0-beta.53(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0))(rollup-plugin-visualizer@7.0.1(rolldown@1.0.0-beta.53(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0))(rollup@4.60.2))(rollup@4.60.2)(srvx@0.11.15)(terser@5.46.2)(tsx@4.22.3)(typescript@6.0.3)(vite@7.3.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.2)(tsx@4.22.3)(yaml@2.9.0))(vue-tsc@3.2.7(typescript@6.0.3))(yaml@2.9.0))(vue@3.5.33(typescript@6.0.3)) - dotenv: - specifier: ^17.4.2 - version: 17.4.2 - gray-matter: - specifier: ^4.0.3 - version: 4.0.3 h3: specifier: 1.15.11 version: 1.15.11 - js-yaml: - specifier: ^4.1.1 - version: 4.1.1 - lodash-es: - specifier: 4.18.1 - version: 4.18.1 nuxt: specifier: 4.4.2 version: 4.4.2(@babel/core@7.29.0)(@babel/plugin-syntax-jsx@7.28.6(@babel/core@7.29.0))(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@netlify/blobs@9.1.2)(@parcel/watcher@2.5.6)(@types/node@22.19.17)(@vue/compiler-sfc@3.5.33)(better-sqlite3@11.10.0)(cac@6.7.14)(db0@0.3.4(better-sqlite3@11.10.0))(esbuild@0.27.7)(eslint@9.28.0(jiti@2.6.1))(ioredis@5.10.1)(magicast@0.5.2)(optionator@0.9.4)(rolldown@1.0.0-beta.53(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0))(rollup-plugin-visualizer@7.0.1(rolldown@1.0.0-beta.53(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0))(rollup@4.60.2))(rollup@4.60.2)(srvx@0.11.15)(terser@5.46.2)(tsx@4.22.3)(typescript@6.0.3)(vite@7.3.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.2)(tsx@4.22.3)(yaml@2.9.0))(vue-tsc@3.2.7(typescript@6.0.3))(yaml@2.9.0) @@ -84,15 +72,6 @@ importers: posthog-node: specifier: 5.29.7 version: 5.29.7 - remark: - specifier: ^15.0.1 - version: 15.0.1 - remark-mdc: - specifier: ^3.11.0 - version: 3.11.0 - remark-parse: - specifier: ^11.0.0 - version: 11.0.0 sharp: specifier: ^0.34.5 version: 0.34.5 @@ -136,12 +115,30 @@ importers: '@vue/test-utils': specifier: ^2.4.10 version: 2.4.10(@vue/compiler-dom@3.5.33)(@vue/server-renderer@3.5.33(vue@3.5.33(typescript@6.0.3)))(vue@3.5.33(typescript@6.0.3)) + dotenv: + specifier: ^17.4.2 + version: 17.4.2 + gray-matter: + specifier: ^4.0.3 + version: 4.0.3 happy-dom: specifier: ^20.9.0 version: 20.9.0 - tsx: - specifier: ^4.22.3 - version: 4.22.3 + js-yaml: + specifier: ^4.1.1 + version: 4.1.1 + lodash-es: + specifier: 4.18.1 + version: 4.18.1 + remark: + specifier: ^15.0.1 + version: 15.0.1 + remark-mdc: + specifier: ^3.11.0 + version: 3.11.0 + remark-parse: + specifier: ^11.0.0 + version: 11.0.0 typescript: specifier: 6.0.3 version: 6.0.3 @@ -11806,6 +11803,7 @@ snapshots: '@esbuild/win32-arm64': 0.28.0 '@esbuild/win32-ia32': 0.28.0 '@esbuild/win32-x64': 0.28.0 + optional: true escalade@3.2.0: {} @@ -15386,6 +15384,7 @@ snapshots: esbuild: 0.28.0 optionalDependencies: fsevents: 2.3.3 + optional: true tunnel-agent@0.6.0: dependencies: diff --git a/scripts/_redirects-lib.ts b/scripts/_redirects-lib.ts deleted file mode 100644 index 9ee6565a..00000000 --- a/scripts/_redirects-lib.ts +++ /dev/null @@ -1,54 +0,0 @@ -import fs from 'node:fs'; - -export type RedirectStatusCode = 301 | 302 | 307 | 308; - -const VALID_STATUS_CODES: ReadonlySet = new Set([301, 302, 307, 308]); - -export interface RedirectEntry { - to: string; - statusCode: RedirectStatusCode; -} - -export type RedirectRouteRules = Record; - -export function loadRedirects(file: string): Record { - if (!fs.existsSync(file)) return {}; - - const raw = fs.readFileSync(file, 'utf8').trim(); - if (!raw) return {}; - - const parsed = JSON.parse(raw) as unknown; - if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { - throw new Error(`${file}: must be a JSON object keyed by source path`); - } - - const entries: Record = {}; - for (const [from, value] of Object.entries(parsed as Record)) { - entries[from] = parseEntry(file, from, value); - } - return entries; -} - -function parseEntry(file: string, from: string, value: unknown): RedirectEntry { - if (!value || typeof value !== 'object' || Array.isArray(value)) { - throw new Error(`${file}: entry for ${from} must be { to: string, statusCode: 301|302|307|308 }`); - } - const { to, statusCode } = value as { to?: unknown; statusCode?: unknown }; - if (typeof to !== 'string' || typeof statusCode !== 'number' || !VALID_STATUS_CODES.has(statusCode as RedirectStatusCode)) { - throw new Error(`${file}: entry for ${from} must be { to: string, statusCode: 301|302|307|308 }`); - } - return { to, statusCode: statusCode as RedirectStatusCode }; -} - -export function writeRedirects(file: string, entries: Record): void { - const sorted = Object.fromEntries(Object.entries(entries).sort(([a], [b]) => a.localeCompare(b))); - fs.writeFileSync(file, JSON.stringify(sorted, null, 2) + '\n'); -} - -export function toRouteRules(entries: Record, baseURL: string): RedirectRouteRules { - const rules: RedirectRouteRules = {}; - for (const [from, { to, statusCode }] of Object.entries(entries)) { - rules[`${baseURL}${from}`] = { redirect: { to: `${baseURL}${to}`, statusCode } }; - } - return rules; -} diff --git a/scripts/check-stable-ids.ts b/scripts/check-stable-ids.ts index d114f5bc..5f7bce4f 100755 --- a/scripts/check-stable-ids.ts +++ b/scripts/check-stable-ids.ts @@ -65,7 +65,18 @@ function main(): void { console.error(`\nTotal files: ${total}`); console.error(`Passing: ${passing}`); console.error(`Failing: ${failing} (${breakdown})`); - console.error('\nFix the files above and re-run the command.'); + console.error('\nHow to fix:'); + if (counts['missing frontmatter'] > 0) { + console.error('- Add frontmatter to files missing it.'); + } + if (counts['missing stableId'] > 0) { + console.error('- Run `pnpm stable-ids:ensure` to add missing stableId values.'); + } + if (counts['invalid stableId'] > 0) { + console.error('- Replace invalid stableId values with valid UUIDs.'); + console.error('- For new pages, remove invalid stableId values, then run `pnpm stable-ids:ensure`.'); + } + console.error('- Re-run `pnpm stable-ids:check`.'); process.exit(1); } diff --git a/scripts/cleanup-typesense-preview.ts b/scripts/cleanup-typesense-preview.ts new file mode 100644 index 00000000..e97561f5 --- /dev/null +++ b/scripts/cleanup-typesense-preview.ts @@ -0,0 +1,220 @@ +import 'dotenv/config'; +import { execFileSync } from 'node:child_process'; +import process from 'node:process'; +import { pathToFileURL } from 'node:url'; +import { Client } from 'typesense'; +import { parseTypesenseUrl } from '../shared/utils/parseTypesenseUrl.ts'; +import { + resolveBranchTypesenseAlias, + slugifyBranch, + TYPESENSE_PREVIEW_ALIAS_PREFIX, +} from '../lib/typesenseAlias.ts'; + +export interface CollectionAlias { + name: string; + collection_name: string; +} + +export interface Options { + branch?: string; + stale: boolean; + dryRun: boolean; +} + +function requiredEnv(name: string) { + const value = process.env[name]; + if (!value) throw new Error(`Missing required environment variable: ${name}`); + return value; +} + +function createClient() { + const node = parseTypesenseUrl(requiredEnv('TYPESENSE_URL')); + return new Client({ + nodes: [node], + apiKey: requiredEnv('TYPESENSE_PRIVATE_API_KEY'), + connectionTimeoutSeconds: 300, + }); +} + +function isTypesenseNotFoundError(error: unknown) { + return Boolean( + error + && typeof error === 'object' + && 'httpStatus' in error + && error.httpStatus === 404, + ); +} + +export function parseArgs(argv = process.argv.slice(2), env = process.env): Options { + const options: Options = { + branch: env.TYPESENSE_PREVIEW_BRANCH, + stale: false, + dryRun: env.TYPESENSE_CLEANUP_DRY_RUN === 'true', + }; + + for (let index = 0; index < argv.length; index++) { + const arg = argv[index]; + if (arg === '--branch') { + const branch = argv[++index]; + if (!branch || branch.startsWith('--')) throw new Error('--branch requires a branch name'); + options.branch = branch; + continue; + } + if (arg === '--stale') { + options.stale = true; + continue; + } + if (arg === '--dry-run') { + options.dryRun = true; + continue; + } + throw new Error(`Unknown argument: ${arg}`); + } + + if (options.branch && options.stale) throw new Error('Use either --branch or --stale, not both.'); + if (!options.branch && !options.stale) throw new Error('Set --branch or --stale.'); + return options; +} + +async function listAliases(client: Client) { + const response = await client.aliases().retrieve() as { aliases?: CollectionAlias[] }; + return response.aliases ?? []; +} + +async function listCollectionNames(client: Client) { + const collections = await client.collections().retrieve({ exclude_fields: 'fields' }) as Array<{ name: string }>; + return collections.map(collection => collection.name); +} + +async function deleteAlias(client: Client, alias: string, dryRun: boolean) { + if (dryRun) { + console.log(`[dry-run] Delete alias ${alias}`); + return; + } + try { + await client.aliases(alias).delete(); + console.log(`Deleted alias ${alias}`); + } + catch (error) { + if (!isTypesenseNotFoundError(error)) throw error; + } +} + +async function deleteCollection(client: Client, collection: string, dryRun: boolean) { + if (dryRun) { + console.log(`[dry-run] Delete collection ${collection}`); + return; + } + try { + await client.collections(collection).delete(); + console.log(`Deleted collection ${collection}`); + } + catch (error) { + if (!isTypesenseNotFoundError(error)) throw error; + } +} + +export async function cleanupAlias(client: Client, alias: string, dryRun: boolean, existingAlias?: CollectionAlias | null) { + if (!alias.startsWith(TYPESENSE_PREVIEW_ALIAS_PREFIX)) { + throw new Error(`Refusing to clean non-preview alias: ${alias}`); + } + + const collections = new Set([`${alias}-a`, `${alias}-b`]); + if (existingAlias?.collection_name) collections.add(existingAlias.collection_name); + + await deleteAlias(client, alias, dryRun); + for (const collection of collections) { + await deleteCollection(client, collection, dryRun); + } +} + +function getOpenBranchesFromEnv(env = process.env) { + const raw = env.TYPESENSE_OPEN_PREVIEW_BRANCHES; + if (!raw) return null; + return raw + .split(/[\n,]/) + .map(branch => branch.trim()) + .filter(Boolean); +} + +function getOpenPrBranches(env = process.env) { + const envBranches = getOpenBranchesFromEnv(env); + if (envBranches) return envBranches; + + const output = execFileSync('gh', [ + 'pr', + 'list', + '--state', + 'open', + '--json', + 'headRefName', + '--jq', + '.[].headRefName', + ], { encoding: 'utf8' }); + + return output + .split('\n') + .map(branch => branch.trim()) + .filter(Boolean); +} + +export function aliasFromSlot(collectionName: string) { + if (!collectionName.startsWith(TYPESENSE_PREVIEW_ALIAS_PREFIX)) return null; + if (collectionName.endsWith('-a') || collectionName.endsWith('-b')) return collectionName.slice(0, -2); + return null; +} + +async function cleanupStale(client: Client, dryRun: boolean) { + const openAliases = new Set( + getOpenPrBranches() + .map(branch => `${TYPESENSE_PREVIEW_ALIAS_PREFIX}${slugifyBranch(branch)}`), + ); + + const aliases = await listAliases(client); + const previewAliases = aliases.filter(alias => alias.name.startsWith(TYPESENSE_PREVIEW_ALIAS_PREFIX)); + const previewAliasNames = new Set(previewAliases.map(alias => alias.name)); + const collectionNames = await listCollectionNames(client); + const orphanAliases = collectionNames + .map(aliasFromSlot) + .filter((alias): alias is string => Boolean(alias)) + .filter(alias => !previewAliasNames.has(alias)); + + const staleAliases = new Map(); + for (const alias of previewAliases) { + if (!openAliases.has(alias.name)) staleAliases.set(alias.name, alias); + } + for (const alias of orphanAliases) { + if (!openAliases.has(alias)) staleAliases.set(alias, null); + } + + if (staleAliases.size === 0) { + console.log('No stale preview aliases found'); + return; + } + + for (const [alias, existingAlias] of staleAliases) { + await cleanupAlias(client, alias, dryRun, existingAlias); + } +} + +export async function main() { + const options = parseArgs(); + const client = createClient(); + + if (options.branch) { + const alias = resolveBranchTypesenseAlias(options.branch); + if (!alias) throw new Error(`Could not resolve preview alias for branch: ${options.branch}`); + const existingAlias = (await listAliases(client)).find(candidate => candidate.name === alias) ?? null; + await cleanupAlias(client, alias, options.dryRun, existingAlias); + return; + } + + await cleanupStale(client, options.dryRun); +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + main().catch((error) => { + console.error(error instanceof Error ? error.message : error); + process.exitCode = 1; + }); +} diff --git a/scripts/redirects-sync.ts b/scripts/redirects-sync.ts index 20687c7d..9dff0022 100755 --- a/scripts/redirects-sync.ts +++ b/scripts/redirects-sync.ts @@ -115,7 +115,7 @@ Options: --hints Manual redirect hints JSON (default: .docs/redirect-hints.json) --report Redirect decisions report (default: .docs/redirect-decisions-needed.json) --write-deterministic Auto-write deterministic redirects to the manifest - --fail-on-unresolved Exit non-zero if redirect decisions remain + --fail-on-unresolved Exit non-zero if redirect updates or decisions remain --no-write Do not write manifest or report files `); } @@ -400,6 +400,40 @@ function buildDecisionMarkdown(payload: DecisionPayload): string { return lines.join('\n') + '\n'; } +function reportMarkdownPath(file: string): string { + return file.endsWith('.json') ? file.replace(/\.json$/i, '.md') : `${file}.md`; +} + +function printLimitedRedirects(title: string, redirects: AcceptedResolution[]): void { + if (!redirects.length) return; + + console.error(`\n${title}:`); + for (const item of redirects.slice(0, 20)) { + console.error(`- ${item.from} -> ${item.to}`); + } + if (redirects.length > 20) { + console.error(`- ...and ${redirects.length - 20} more`); + } +} + +function printRedirectFixInstructions(options: CliOptions, hasDeterministicRedirects: boolean, hasUnresolvedRedirects: boolean): void { + console.error('\nHow to fix redirects:'); + console.error('- Run `pnpm redirects:sync`.'); + if (hasDeterministicRedirects) { + console.error(` - Writes safe redirects to ${options.manifest}.`); + } + if (hasUnresolvedRedirects) { + console.error(` - Writes manual decisions to ${reportMarkdownPath(options.report)}.`); + console.error('- For each manual decision, choose a target, then either:'); + console.error(` - Add a manual hint to ${options.hints}, then rerun \`pnpm redirects:sync\`.`); + console.error(' { "manualRedirects": { "/old-path": "/new-path" } }'); + console.error(` - Or add the redirect directly to ${options.manifest}:`); + console.error(' "/old-path": { "to": "/new-path", "statusCode": 301 }'); + } + console.error('- Commit updated redirect files.'); + console.error('- Re-run `pnpm redirects:check`.'); +} + function main(): void { const options = parseArgs(process.argv.slice(2)); assertBaseRefExists(options.base); @@ -427,6 +461,9 @@ function main(): void { const newEntries = options.writeDeterministic ? accepted.filter(row => !manifest.fromSet.has(row.from)) : []; + const missingDeterministicRedirects = options.noWrite + ? accepted.filter(row => !manifest.fromSet.has(row.from)) + : []; const summary: Summary = { baseRef: options.base, @@ -461,17 +498,26 @@ function main(): void { console.log(`Wrote ${newEntries.length} redirect(s) to ${options.manifest}`); } if (unresolved.length && !options.noWrite) { - console.log(`Review redirect decisions in ${options.report.replace(/\.json$/i, '.md')}`); + console.log(`Review redirect decisions in ${reportMarkdownPath(options.report)}`); } + printLimitedRedirects('Missing deterministic redirect entries', missingDeterministicRedirects); + if (unresolved.length) { console.error('\nRedirect decisions needed:'); for (const item of unresolved.slice(0, 20)) { console.error(`- ${item.old.path}: ${item.reason}`); } + if (unresolved.length > 20) { + console.error(`- ...and ${unresolved.length - 20} more`); + } + } + + if (missingDeterministicRedirects.length || unresolved.length) { + printRedirectFixInstructions(options, missingDeterministicRedirects.length > 0, unresolved.length > 0); } - if (options.failOnUnresolved && unresolved.length > 0) { + if (options.failOnUnresolved && (unresolved.length > 0 || missingDeterministicRedirects.length > 0)) { process.exit(1); } } diff --git a/scripts/setup-hooks.ts b/scripts/setup-hooks.ts deleted file mode 100755 index 4757314c..00000000 --- a/scripts/setup-hooks.ts +++ /dev/null @@ -1,39 +0,0 @@ -#!/usr/bin/env node - -import fs from 'node:fs'; -import { execFileSync } from 'node:child_process'; - -if (!fs.existsSync('.git')) { - process.exit(0); -} - -/** - * Hooks are a contributor convenience, not a hard install requirement. - * Warn instead of failing so package installation still works in tarballs, CI, or - * other environments where git metadata is intentionally absent. - */ -if (!fs.existsSync('.githooks')) { - console.error('warning: .githooks directory not found; hooks were not configured'); - process.exit(0); -} - -let currentHooksPath = ''; -try { - currentHooksPath = execFileSync('git', ['config', '--get', 'core.hooksPath'], { encoding: 'utf8' }).trim(); -} -catch { - // No local hooks path is configured. -} - -if (currentHooksPath && currentHooksPath !== '.githooks') { - console.error(`warning: core.hooksPath is already set to ${currentHooksPath}; hooks were not configured`); - process.exit(0); -} - -try { - execFileSync('git', ['config', 'core.hooksPath', '.githooks'], { stdio: 'ignore' }); -} -catch { - console.error('warning: failed to configure git core.hooksPath to .githooks; hooks may not run automatically'); - process.exit(0); -} diff --git a/tests/lib/typesenseAlias.test.ts b/tests/lib/typesenseAlias.test.ts index 91d7f4be..dc6725d9 100644 --- a/tests/lib/typesenseAlias.test.ts +++ b/tests/lib/typesenseAlias.test.ts @@ -1,5 +1,11 @@ import { afterEach, describe, expect, it } from 'vitest'; -import { getTypesenseBranchName, resolveBranchTypesenseAlias, slugifyBranch } from '../../lib/typesenseAlias'; +import { + getTypesenseBranchName, + resolveBranchTypesenseAlias, + slugifyBranch, + TYPESENSE_PREVIEW_ALIAS_PREFIX, + TYPESENSE_PROD_ALIAS, +} from '../../lib/typesenseAlias'; const originalEnv = { ...process.env }; @@ -14,7 +20,8 @@ describe('typesense alias helpers', () => { }); it('maps main to the production alias', () => { - expect(resolveBranchTypesenseAlias('main')).toBe('directus-docs'); + expect(resolveBranchTypesenseAlias('main')).toBe(TYPESENSE_PROD_ALIAS); + expect(resolveBranchTypesenseAlias('bry/foo')).toBe(`${TYPESENSE_PREVIEW_ALIAS_PREFIX}bry-foo`); }); it('uses the Vercel branch env when GitHub env is absent', () => { diff --git a/tests/scripts/cleanup-typesense-preview.test.ts b/tests/scripts/cleanup-typesense-preview.test.ts new file mode 100644 index 00000000..5c37f0d2 --- /dev/null +++ b/tests/scripts/cleanup-typesense-preview.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from 'vitest'; +import type { Client } from 'typesense'; +import { aliasFromSlot, cleanupAlias, parseArgs } from '../../scripts/cleanup-typesense-preview'; + +describe('cleanup Typesense preview indexes', () => { + it('parses targeted branch cleanup args', () => { + expect(parseArgs(['--branch', 'bry/foo'], {})).toEqual({ + branch: 'bry/foo', + stale: false, + dryRun: false, + }); + }); + + it('parses stale dry-run cleanup args and env defaults', () => { + expect(parseArgs(['--stale'], { TYPESENSE_CLEANUP_DRY_RUN: 'true' })).toEqual({ + stale: true, + dryRun: true, + }); + + expect(parseArgs([], { TYPESENSE_PREVIEW_BRANCH: 'bry/foo' })).toEqual({ + branch: 'bry/foo', + stale: false, + dryRun: false, + }); + }); + + it('rejects ambiguous or unknown cleanup args', () => { + expect(() => parseArgs(['--branch', 'bry/foo', '--stale'], {})).toThrow('Use either --branch or --stale'); + expect(() => parseArgs(['--branch', '--dry-run'], {})).toThrow('--branch requires a branch name'); + expect(() => parseArgs(['--wat'], {})).toThrow('Unknown argument'); + expect(() => parseArgs([], {})).toThrow('Set --branch or --stale'); + }); + + it('derives preview alias names from fixed slots', () => { + expect(aliasFromSlot('directus-docs-preview-bry-foo-a')).toBe('directus-docs-preview-bry-foo'); + expect(aliasFromSlot('directus-docs-preview-bry-foo-b')).toBe('directus-docs-preview-bry-foo'); + expect(aliasFromSlot('directus-docs-a')).toBeNull(); + expect(aliasFromSlot('directus-docs-preview-bry-foo')).toBeNull(); + }); + + it('refuses to clean non-preview aliases', async () => { + await expect(cleanupAlias({} as Client, 'directus-docs', true)).rejects.toThrow( + 'Refusing to clean non-preview alias: directus-docs', + ); + }); +});