Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .githooks/pre-commit
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
#!/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
22 changes: 22 additions & 0 deletions .githooks/pre-push
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
#!/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 rev-parse --verify origin/main >/dev/null 2>&1; then
base_ref="origin/main"
elif git rev-parse --verify main >/dev/null 2>&1; then
base_ref="main"
else
echo "warning: no origin/main or main ref found; skipping redirect check" >&2
exit 0
fi

if git diff --quiet "$base_ref"...HEAD -- content redirects.json content.config.ts; then
exit 0
fi

echo "Checking redirects for docs changes..."
pnpm redirects:check -- --base "$base_ref"
16 changes: 15 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ Welcome! This is the repo for [Directus' documentation](https://docs.directus.io

### Requirements

- Node.js 22
- Node.js 22.18 or later
- pnpm

### Install Dependencies
Expand Down Expand Up @@ -47,6 +47,20 @@ pnpm dev
pnpm build
```

### Repository Tooling

The repository includes scripts that keep docs routes stable when files move.

```bash
pnpm stable-ids:ensure # Add missing stableId frontmatter
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 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.

## ☁️ Deploying the Docs

The documentation automatically deploys to Vercel when changes are merged into the main branch. Simply:
Expand Down
1 change: 1 addition & 0 deletions content.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export default defineContentConfig({
title: z.string(),
description: z.string().optional(),
headline: z.string().optional(),
stableId: z.string().uuid().optional(),
authors: z.array(z.object({
name: z.string(),
title: z.string(),
Comment on lines 16 to 22
Expand Down
8 changes: 7 additions & 1 deletion nuxt.config.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
import { loadRedirects, toRouteRules } from './scripts/_redirects-lib.ts';

const BASE_URL = '/docs';

// https://nuxt.com/docs/api/configuration/nuxt-config
export default defineNuxtConfig({
modules: [
Expand All @@ -18,7 +22,7 @@ export default defineNuxtConfig({
},

app: {
baseURL: '/docs',
baseURL: BASE_URL,
},

css: ['~/assets/css/main.css', '~/assets/css/algolia.css'],
Expand Down Expand Up @@ -107,6 +111,8 @@ export default defineNuxtConfig({
transpile: ['shiki'],
},

routeRules: toRouteRules(loadRedirects('redirects.json'), BASE_URL),

future: {
compatibilityVersion: 4,
},
Expand Down
13 changes: 11 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,12 @@
"dev": "nuxt dev",
"generate": "nuxt generate",
"preview": "nuxt preview",
"postinstall": "nuxt prepare"
"postinstall": "node scripts/setup-hooks.ts && nuxt prepare",
"stable-ids:ensure": "node scripts/ensure-stable-ids.ts",
"stable-ids:check": "node scripts/ensure-stable-ids.ts --check",
"redirects:sync": "node scripts/redirects-sync.ts --fail-on-unresolved",
"redirects:check": "node scripts/redirects-sync.ts --dry-run --fail-on-unresolved",
"typecheck:scripts": "tsc -p scripts/tsconfig.json"
},
"dependencies": {
"@directus/openapi": "0.3.0",
Expand Down Expand Up @@ -41,6 +46,7 @@
"@nuxt/eslint": "1.15.2",
"@nuxt/scripts": "1.0.2",
"@types/lodash-es": "4.17.12",
"@types/node": "^22",
"typescript": "6.0.3",
"vue-tsc": "^3.2.7"
},
Expand All @@ -49,5 +55,8 @@
"vite": "npm:rolldown-vite@latest"
}
},
"packageManager": "pnpm@10.29.2+sha512.bef43fa759d91fd2da4b319a5a0d13ef7a45bb985a3d7342058470f9d2051a3ba8674e629672654686ef9443ad13a82da2beb9eeb3e0221c87b8154fff9d74b8"
"packageManager": "pnpm@10.29.2+sha512.bef43fa759d91fd2da4b319a5a0d13ef7a45bb985a3d7342058470f9d2051a3ba8674e629672654686ef9443ad13a82da2beb9eeb3e0221c87b8154fff9d74b8",
"engines": {
"node": ">=22.18"
}
}
285 changes: 138 additions & 147 deletions pnpm-lock.yaml

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions redirects.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{}
95 changes: 95 additions & 0 deletions scripts/_content-lib.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import fs from 'node:fs';
import path from 'node:path';

const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;

export type Frontmatter = Record<string, string | undefined>;

export interface EnsureResult {
changed: boolean;
source: string;
reason: 'inserted' | 'already-present' | 'missing-frontmatter';
}

interface FrontmatterBlock {
newline: string;
body: string;
closingWhitespace: string;
end: number;
}

export function isRoutableContentFile(file: string): boolean {
const f = file.replace(/\\/g, '/');
return f.startsWith('content/') && f.endsWith('.md') && !f.startsWith('content/_partials/');
}

// content/index.md is owned by the marketing site and skipped for stable-id backfill,
// but still counts as a routable page for redirect continuity.
export function isStableIdContentFile(file: string): boolean {
return isRoutableContentFile(file) && file.replace(/\\/g, '/') !== 'content/index.md';
}

export function listRoutableContentFiles(dir = 'content'): string[] {
return walkContent(dir, isRoutableContentFile);
}

export function listStableIdContentFiles(dir = 'content'): string[] {
return walkContent(dir, isStableIdContentFile);
}

function walkContent(dir: string, predicate: (file: string) => boolean): string[] {
const out: string[] = [];
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name).replace(/\\/g, '/');
if (entry.isDirectory()) {
if (entry.name === '_partials') continue;
out.push(...walkContent(full, predicate));
}
else if (entry.isFile() && predicate(full)) {
out.push(full);
}
}
return out;
}

// Minimal frontmatter parser: top-level scalar fields only. Used by hooks/scripts where
// fast startup matters more than full YAML support.
export function parseFrontmatter(source: string): Frontmatter {
const block = getFrontmatterBlock(source);
if (!block) return {};

const data: Frontmatter = {};
for (const line of block.body.split(/\r?\n/)) {
const m = line.match(/^([A-Za-z_][\w-]*):\s*(.+)$/);
if (m) data[m[1]] = unquote(m[2]);
}
return data;
}

function getFrontmatterBlock(source: string): FrontmatterBlock | null {
const m = source.match(/^---(\r?\n)([\s\S]*?)\r?\n---(\r?\n|$)/);
if (!m) return null;
return { newline: m[1], body: m[2], closingWhitespace: m[3], end: m[0].length };
}

function unquote(value: string): string {
const v = value.trim();
if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) {
return v.slice(1, -1).trim();
}
return v;
}

export function ensureStableIdInSource(source: string, stableId: string): EnsureResult {
const block = getFrontmatterBlock(source);
if (!block) return { changed: false, source, reason: 'missing-frontmatter' };
if (/(?:^|\r?\n)stableId:\s*\S/.test(block.body)) {
return { changed: false, source, reason: 'already-present' };
}
const head = `---${block.newline}stableId: ${stableId}${block.newline}${block.body}${block.newline}---${block.closingWhitespace}`;
return { changed: true, source: head + source.slice(block.end), reason: 'inserted' };
}

export function isValidUuid(value: unknown): value is string {
return typeof value === 'string' && UUID_RE.test(value.trim());
}
54 changes: 54 additions & 0 deletions scripts/_redirects-lib.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import fs from 'node:fs';

export type RedirectStatusCode = 301 | 302 | 307 | 308;

const VALID_STATUS_CODES: ReadonlySet<RedirectStatusCode> = new Set([301, 302, 307, 308]);

export interface RedirectEntry {
to: string;
statusCode: RedirectStatusCode;
}

export type RedirectRouteRules = Record<string, { redirect: { to: string; statusCode: RedirectStatusCode } }>;

export function loadRedirects(file: string): Record<string, RedirectEntry> {
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<string, RedirectEntry> = {};
for (const [from, value] of Object.entries(parsed as Record<string, unknown>)) {
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<string, RedirectEntry>): 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<string, RedirectEntry>, 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;
}
Loading