Skip to content
Closed
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
30 changes: 30 additions & 0 deletions .github/workflows/check-redirects.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
name: Check Redirects for Moved Pages

on:
pull_request:
types:
- opened
- synchronize
- reopened

permissions:
contents: read

jobs:
check-redirects:
runs-on: ubuntu-latest
steps:
- name: Check out repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0

- name: Set up Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: '20'

- name: Check for missing redirects
env:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
run: node bin/check-redirects-for-moved-pages.js
2 changes: 1 addition & 1 deletion .github/workflows/update-cli-docs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ jobs:
- name: Setup Go
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
with:
go-version: '1.22'
go-version: '1.26'

- name: Generate CLI docs
working-directory: cli
Expand Down
2 changes: 1 addition & 1 deletion CONTACT.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ We’re always happy to help and look forward to hearing from you!

We believe strongly in Open Source and encourage contributions to our documentation. If you’d like to contribute or help us improve, these links may help:

- **About the docs**: Learn more about the documentation structure and how we maintain it by visiting the [documentation overview](https://github.com/temporalio/documentation/blob/master/README.md).
- **About the docs**: Learn more about the documentation structure and how we maintain it by visiting the [documentation overview](https://github.com/temporalio/documentation/blob/main/README.md).
- **Contributing**: Check out our [Contributing Guidelines](https://github.com/temporalio/documentation/blob/main/CONTRIBUTING.md) for how to get started with contributing to the Temporal documentation or codebase.
- **We’re Hiring**: Interested in joining the Temporal team? We’re always looking for passionate individuals. Explore open positions at [Temporal Careers](https://temporal.io/careers).

Expand Down
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ For small changes like fixing typos, you can edit files directly on GitHub.
Once approved, your change goes live! 🎉


Maintainers and contributors to this project are expected to conduct themselves in a respectful way. See the [CNCF Community Code of Conduct](https://github.com/cncf/foundation/blob/master/code-of-conduct.md) as a reference.
Maintainers and contributors to this project are expected to conduct themselves in a respectful way. See the [CNCF Community Code of Conduct](https://github.com/cncf/foundation/blob/main/code-of-conduct.md) as a reference.

This repository and its contents are open-source; individual and commercial use are permitted.

Expand Down
184 changes: 184 additions & 0 deletions bin/check-redirects-for-moved-pages.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
#!/usr/bin/env node

const { execSync } = require('child_process');
const fs = require('fs');
const path = require('path');

const VERCEL_JSON = path.join(process.cwd(), 'vercel.json');

function filePathToUrlPath(filePath) {
let urlPath = filePath
.replace(/^docs\//, '/')
.replace(/\.mdx?$/, '')
.replace(/\/index$/, '');

if (urlPath === '') urlPath = '/';
return urlPath;
}

function extractFrontMatter(content) {
const match = content.match(/^---\n([\s\S]*?)\n---/);
if (!match) return {};
const block = match[1];
const result = {};
for (const field of ['slug', 'id']) {
const m = block.match(new RegExp(`^${field}:\\s*(.+)$`, 'm'));
if (m) result[field] = m[1].trim();
}
return result;
}

function getFileContentAtRef(filePath, ref) {
try {
return execSync(`git show ${ref}:${filePath}`, { encoding: 'utf8' });
} catch {
return null;
}
}

function resolveOldUrl(filePath, ref) {
const content = getFileContentAtRef(filePath, ref);
if (content) {
const fm = extractFrontMatter(content);

// slug takes highest precedence
if (fm.slug) {
if (fm.slug.startsWith('/')) return fm.slug;
const dirUrl = filePathToUrlPath(filePath).replace(/\/[^/]*$/, '');
return `${dirUrl}/${fm.slug}`;
}

// id replaces the filename segment in the URL
if (fm.id) {
const dirUrl = filePathToUrlPath(filePath).replace(/\/[^/]*$/, '');
return `${dirUrl}/${fm.id}`;
}
}
return filePathToUrlPath(filePath);
}

function getMovedOrDeletedDocFiles(baseSha) {
const mergeBase = execSync(`git merge-base HEAD ${baseSha}`, {
encoding: 'utf8',
}).trim();

// -M enables rename detection, --diff-filter=DR shows only deletes and renames
const output = execSync(
`git diff --name-status -M --diff-filter=DR ${mergeBase}..HEAD -- docs/`,
{ encoding: 'utf8' },
);

const results = [];
for (const line of output.split('\n').filter((l) => l.trim())) {
const parts = line.split('\t');
const status = parts[0];

if (status === 'D') {
results.push({ type: 'deleted', oldPath: parts[1] });
} else if (status.startsWith('R')) {
results.push({ type: 'renamed', oldPath: parts[1], newPath: parts[2] });
}
}

const files = results.filter((r) => /\.(mdx|md)$/.test(r.oldPath));
return { files, mergeBase };
}

function vercelPatternToRegex(pattern) {
// Convert Vercel redirect patterns like /foo/:path* to a regex.
// Replace named params before escaping so the colons and wildcards are
// consumed first, then escape whatever literal characters remain.
const tokens = [];
const tokenized = pattern
.replace(/:([a-zA-Z]+)\*/g, () => {
tokens.push('.+');
return `__TOKEN_${tokens.length - 1}__`;
})
.replace(/:([a-zA-Z]+)/g, () => {
tokens.push('[^/]+');
return `__TOKEN_${tokens.length - 1}__`;
});

let regexStr = tokenized.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
for (let i = 0; i < tokens.length; i++) {
regexStr = regexStr.replace(`__TOKEN_${i}__`, tokens[i]);
}
return new RegExp(`^${regexStr}$`);
}

function loadRedirects() {
const config = JSON.parse(fs.readFileSync(VERCEL_JSON, 'utf8'));
return (config.redirects || []).map((r) => ({
source: r.source,
regex: vercelPatternToRegex(r.source),
}));
}

function findMatchingRedirect(urlPath, redirects) {
return redirects.find((r) => r.regex.test(urlPath));
}

function main() {
const BASE_SHA = process.env.BASE_SHA;
if (!BASE_SHA) {
console.error('BASE_SHA environment variable is required.');
process.exit(1);
}

const { files: movedFiles, mergeBase } = getMovedOrDeletedDocFiles(BASE_SHA);

if (movedFiles.length === 0) {
console.log('No docs pages were moved or deleted. Nothing to check.');
process.exit(0);
}

const redirects = loadRedirects();
const missing = [];

for (const file of movedFiles) {
const oldUrl = resolveOldUrl(file.oldPath, mergeBase);
const match = findMatchingRedirect(oldUrl, redirects);

if (!match) {
file.oldUrl = oldUrl;
missing.push(file);
}
}

if (missing.length === 0) {
console.log(
`All ${movedFiles.length} moved/deleted page(s) have redirects.`,
);
process.exit(0);
}

console.error('Missing redirects for moved/deleted pages:\n');
for (const file of missing) {
if (file.type === 'renamed') {
const newUrl = resolveOldUrl(file.newPath, 'HEAD');
console.error(
` ${file.oldUrl} -> ${newUrl} (renamed, no redirect found)`,
);
} else {
console.error(` ${file.oldUrl} (deleted, no redirect found)`);
}
}

console.error(
`\nAdd redirect entries to vercel.json for the ${missing.length} path(s) above.`,
);
process.exit(1);
}

module.exports = {
filePathToUrlPath,
extractFrontMatter,
resolveOldUrl,
vercelPatternToRegex,
findMatchingRedirect,
loadRedirects,
};

if (require.main === module) {
main();
}
120 changes: 120 additions & 0 deletions bin/check-redirects-for-moved-pages.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
const { describe, it } = require('node:test');
const assert = require('node:assert');
const fs = require('fs');
const {
filePathToUrlPath,
extractFrontMatter,
resolveOldUrl,
vercelPatternToRegex,
findMatchingRedirect,
loadRedirects,
} = require('./check-redirects-for-moved-pages.js');

describe('filePathToUrlPath', () => {
it('strips docs/ prefix and extension', () => {
assert.strictEqual(
filePathToUrlPath('docs/cloud/terraform-provider.mdx'),
'/cloud/terraform-provider',
);
});

it('handles .md extension', () => {
assert.strictEqual(filePathToUrlPath('docs/glossary.md'), '/glossary');
});

it('strips /index from directory pages', () => {
assert.strictEqual(filePathToUrlPath('docs/cloud/index.mdx'), '/cloud');
});

it('handles deeply nested paths', () => {
assert.strictEqual(
filePathToUrlPath('docs/develop/go/best-practices/data-handling/data-conversion.mdx'),
'/develop/go/best-practices/data-handling/data-conversion',
);
});
});

describe('extractFrontMatter', () => {
it('extracts slug', () => {
const content = '---\nid: foo\nslug: /custom/path\n---\nBody text';
assert.deepStrictEqual(extractFrontMatter(content), {
id: 'foo',
slug: '/custom/path',
});
});

it('returns empty object when no frontmatter', () => {
assert.deepStrictEqual(extractFrontMatter('Just body text'), {});
});

it('handles frontmatter with only id', () => {
const content = '---\nid: my-page\ntitle: My Page\n---\n';
assert.deepStrictEqual(extractFrontMatter(content), { id: 'my-page' });
});
});

describe('resolveOldUrl against real repo pages', () => {
it('file path only (no slug, no id)', () => {
const url = resolveOldUrl('docs/cloud/terraform-provider.mdx', 'HEAD');
assert.strictEqual(url, '/cloud/terraform-provider');
});

it('absolute slug override', () => {
const url = resolveOldUrl(
'docs/develop/go/best-practices/data-handling/data-conversion.mdx',
'HEAD',
);
assert.strictEqual(url, '/develop/go/data-handling/data-conversion');
});

it('id replacing filename', () => {
const url = resolveOldUrl('docs/develop/go/set-up.mdx', 'HEAD');
assert.strictEqual(url, '/develop/go/set-up-your-local-go');
});

it('index page', () => {
const url = resolveOldUrl('docs/cloud/index.mdx', 'HEAD');
assert.strictEqual(url, '/cloud');
});
});

describe('vercelPatternToRegex', () => {
it('matches wildcard patterns', () => {
const regex = vercelPatternToRegex('/production-deployment/cloud/:path*');
assert.ok(regex.test('/production-deployment/cloud/terraform-provider'));
assert.ok(regex.test('/production-deployment/cloud/foo/bar'));
assert.ok(!regex.test('/cloud/terraform-provider'));
});

it('matches single-segment params', () => {
const regex = vercelPatternToRegex('/dev-guide/:slug');
assert.ok(regex.test('/dev-guide/hello'));
assert.ok(!regex.test('/dev-guide/hello/world'));
});

it('matches exact paths', () => {
const regex = vercelPatternToRegex('/cloud/billing-reports');
assert.ok(regex.test('/cloud/billing-reports'));
assert.ok(!regex.test('/cloud/billing-reports/extra'));
});
});

describe('findMatchingRedirect', () => {
it('finds a match from real vercel.json redirects', () => {
const redirects = loadRedirects();
const match = findMatchingRedirect(
'/production-deployment/cloud/terraform-provider',
redirects,
);
assert.ok(match, 'expected wildcard redirect to match');
});

it('returns undefined for paths with no redirect', () => {
const redirects = loadRedirects();
const match = findMatchingRedirect(
'/this/path/does/not/exist',
redirects,
);
assert.strictEqual(match, undefined);
});
});
2 changes: 1 addition & 1 deletion docs/cloud/audit-logs.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ tags:
- Temporal Cloud
---

Audit Logs is a feature of [Temporal Cloud](/cloud/overview) that provides forensic access information for a variety of operations in the Temporal Cloud control plane.
Audit Logs is a feature of [Temporal Cloud](/cloud/overview) that provides forensic access information for a variety of operations in the Temporal Cloud Control Plane.

Audit Logs answers "who, when, and what" questions about Temporal Cloud resources.
These answers can help you evaluate the security of your organization, and they can provide information that you need to satisfy audit and compliance requirements.
Expand Down
Loading