Files
vercel__workflow/docs/scripts/check-docs-smoke.mjs
Pranay Prakash 8a872529fe docs: make /worlds the canonical home for World docs (#2934)
* docs: make /worlds the canonical home for World docs

The world pages (Local/Postgres/Vercel) and Building a World were
duplicated inside the v4 and v5 docs trees while /worlds/[id] rendered
the v4 copy — hiding v5-only content like multi-region and leaving two
diverging sources of truth.

- Move world docs to an unversioned docs/content/worlds/ collection
  (based on the v5 copies, with inline 4.x callouts for factory naming
  and 5.x-only env vars), rendered at /worlds/*
- Add /worlds/building-a-world; flatten the docs Deploying section to a
  single intro page and drop its Rocket icon
- Point every link, frontmatter ref, and worlds-manifest docs field at
  /worlds/*; add redirects for the removed v5 and building-a-world URLs
- Keep world docs on agent-facing surfaces: search, llms.txt,
  sitemap.md/.xml, and .md exports now serve the worlds collection
- Extend the docs link linter to validate worlds pages (with heading
  anchors) and their outgoing links

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Pranay Prakash <pranay.gp@gmail.com>

* docs: version the world docs like the docs trees (v4/v5 switcher)

Instead of a single unversioned copy, world docs now follow the same
versioning strategy as the docs pages: content/worlds/v4 is served at
/worlds/* (current) and content/worlds/v5 at /v5/worlds/*, restoring the
original per-version content. Each world detail page (and Building a
World) renders the docs version switcher — the worlds listing page has
no natural home for it, so it lives on the world pages themselves.

- Render-time href rewriting on v5 pages now covers /worlds/... links
  (shared rewriteHrefForVersion helper, also used by the v5 docs and
  cookbook routes), and the markdown-export rewrite does the same
- v5 world pages are noindexed with a canonical to /worlds/<id>;
  community worlds stay unversioned (/v5/worlds/<id> redirects)
- /v5/docs/deploying/world/* redirects now land on /v5/worlds/*;
  /v5/worlds and /v5/worlds/compare redirect to the unversioned pages
- Link linter models the versioned worlds URL spaces (v5 pages resolve
  /worlds hrefs against the v5 collection); sitemap.md and the .md
  export routes cover /v5/worlds/*

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Pranay Prakash <pranay.gp@gmail.com>

* docs: fix v4 multi-region anchor and tighten version-prefix matching

Address PR review:
- The v4 Deploying page linked /worlds/vercel#multi-region, but the
  Multi-region section only exists on the v5 world page; use the
  explicit cross-version /v5/worlds/vercel#multi-region link (this was
  the Docs Links CI failure)
- rewriteHrefForVersion now uses the boundary-checked hasPathPrefix
  (shared leaf module lib/geistdocs/path-prefix.ts, also used by
  source.ts) instead of bare startsWith
- buildVersionUrl's shared-route fast path is segment-based rather than
  substring includes()

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Pranay Prakash <pranay.gp@gmail.com>

---------

Signed-off-by: Pranay Prakash <pranay.gp@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 17:15:07 +07:00

335 lines
9.5 KiB
JavaScript

import { spawn } from 'node:child_process';
import { getTrustedSourcesHeaders } from '../../scripts/trusted-sources-headers.mjs';
/**
* Docs smoke checks.
*
* This script is intentionally small and dependency-free so it can run in CI.
* It validates critical public endpoints (OG images and sitemap) and can be
* extended with more lightweight checks over time.
*
* When DEPLOYMENT_URL or OG_BASE_URL is set, it targets a remote deployment.
* Otherwise it starts the local docs server and tests against localhost.
*/
const PORT = process.env.OG_TEST_PORT || '3100';
const HOST = '127.0.0.1';
const rawBaseUrl = process.env.DEPLOYMENT_URL || process.env.OG_BASE_URL || '';
const BASE_URL = rawBaseUrl
? rawBaseUrl.startsWith('http')
? rawBaseUrl
: `https://${rawBaseUrl}`
: `http://${HOST}:${PORT}`;
const USE_REMOTE = Boolean(rawBaseUrl);
const PNG_SIGNATURE = [137, 80, 78, 71, 13, 10, 26, 10];
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const assertNoProtection = async (path) => {
const res = await fetch(`${BASE_URL}${path}`, {
redirect: 'manual',
headers: await getTrustedSourcesHeaders(),
});
const location = res.headers.get('location') || '';
if (
res.status === 307 &&
(location.includes('vercel.com/login') ||
location.includes('/_vercel/login'))
) {
throw new Error(
`${path} redirected to Vercel login; check deployment protection/bypass`
);
}
};
const waitForServer = async (url, timeoutMs = 30_000) => {
const startedAt = Date.now();
let lastStatus = null;
let lastBody = null;
// x-vercel-id identifies the Vercel edge node that served the response.
// While the proxy-side trusted-sources changes are rolling out gradually,
// a failing request may be hitting an edge node that hasn't received the
// fix yet — surfacing the id in the timeout error makes that visible.
let lastVercelId = null;
while (Date.now() - startedAt < timeoutMs) {
try {
const res = await fetch(url, {
headers: await getTrustedSourcesHeaders(),
});
if (res.ok) return;
lastStatus = res.status;
lastVercelId = res.headers.get('x-vercel-id');
// Capture the first ~500 chars of the body to help diagnose
// protection/auth failures (e.g. SSO login redirects).
try {
lastBody = (await res.text()).slice(0, 500);
} catch {
lastBody = '<unable to read body>';
}
} catch (err) {
lastStatus = `network error: ${(err && err.message) || err}`;
}
await wait(500);
}
throw new Error(
`Timed out waiting for server at ${url} (last status: ${lastStatus}; x-vercel-id: ${lastVercelId}; body: ${lastBody})`
);
};
const assertPngResponse = async (path) => {
const res = await fetch(`${BASE_URL}${path}`, {
headers: await getTrustedSourcesHeaders(),
});
if (!res.ok) {
throw new Error(`${path} returned ${res.status}`);
}
const contentType = res.headers.get('content-type') || '';
if (!contentType.includes('image/png')) {
throw new Error(`${path} content-type was ${contentType}`);
}
const buf = new Uint8Array(await res.arrayBuffer());
for (let i = 0; i < PNG_SIGNATURE.length; i += 1) {
if (buf[i] !== PNG_SIGNATURE[i]) {
throw new Error(`${path} did not start with PNG signature bytes`);
}
}
};
const assertHtmlMeta = async (path, expectedOgImagePath) => {
const res = await fetch(`${BASE_URL}${path}`, {
headers: await getTrustedSourcesHeaders(),
});
if (!res.ok) {
throw new Error(`${path} returned ${res.status}`);
}
const contentType = res.headers.get('content-type') || '';
if (!contentType.includes('text/html')) {
throw new Error(`${path} content-type was ${contentType}`);
}
const html = await res.text();
const ogImage = html.match(
/<meta[^>]+property=["']og:image["'][^>]+content=["']([^"']+)["'][^>]*>/i
)?.[1];
if (!ogImage) {
throw new Error(`${path} missing og:image meta tag`);
}
if (expectedOgImagePath) {
const normalized = ogImage.startsWith('http')
? new URL(ogImage).pathname
: ogImage;
if (normalized !== expectedOgImagePath) {
throw new Error(
`${path} og:image was ${ogImage}, expected ${expectedOgImagePath}`
);
}
}
const twitterImage = html.match(
/<meta[^>]+name=["']twitter:image["'][^>]+content=["']([^"']+)["'][^>]*>/i
)?.[1];
if (!twitterImage) {
throw new Error(`${path} missing twitter:image meta tag`);
}
if (expectedOgImagePath) {
const normalized = twitterImage.startsWith('http')
? new URL(twitterImage).pathname
: twitterImage;
if (normalized !== expectedOgImagePath) {
throw new Error(
`${path} twitter:image was ${twitterImage}, expected ${expectedOgImagePath}`
);
}
}
const ogTitle = html.match(
/<meta[^>]+property=["']og:title["'][^>]+content=["']([^"']+)["'][^>]*>/i
)?.[1];
if (!ogTitle) {
throw new Error(`${path} missing og:title meta tag`);
}
const ogDescription = html.match(
/<meta[^>]+property=["']og:description["'][^>]+content=["']([^"']+)["'][^>]*>/i
)?.[1];
if (!ogDescription) {
throw new Error(`${path} missing og:description meta tag`);
}
};
const checks = [
{
name: 'Deployment protection',
run: () => assertNoProtection('/og'),
},
{
name: 'OG default image',
run: () => assertPngResponse('/og'),
},
{
name: 'HTML meta - docs root',
run: () => assertHtmlMeta('/docs', '/og/getting-started/image.png'),
},
{
name: 'HTML meta - docs idempotency',
run: () =>
assertHtmlMeta(
'/docs/foundations/idempotency',
'/og/foundations/idempotency/image.png'
),
},
{
name: 'HTML meta - cookbook sequential & parallel',
run: () =>
assertHtmlMeta(
'/cookbook/common-patterns/sequential-and-parallel',
'/og/cookbook/common-patterns/sequential-and-parallel/image.png'
),
},
{
name: 'HTML meta - docs get-writable',
run: () =>
assertHtmlMeta(
'/docs/api-reference/workflow/get-writable',
'/og/api-reference/workflow/get-writable/image.png'
),
},
{
name: 'HTML meta - worlds index',
run: () => assertHtmlMeta('/worlds', '/og/worlds'),
},
{
name: 'HTML meta - world local',
run: () => assertHtmlMeta('/worlds/local', '/og/worlds/local'),
},
{
name: 'HTML meta - world postgres',
run: () => assertHtmlMeta('/worlds/postgres', '/og/worlds/postgres'),
},
{
name: 'HTML meta - world vercel',
run: () => assertHtmlMeta('/worlds/vercel', '/og/worlds/vercel'),
},
{
name: 'HTML meta - worlds building-a-world',
run: () => assertHtmlMeta('/worlds/building-a-world', '/og/worlds'),
},
{
name: 'HTML meta - world vercel (v5)',
run: () => assertHtmlMeta('/v5/worlds/vercel', '/og/worlds/vercel'),
},
{
name: 'OG docs page image',
run: () => assertPngResponse('/og/foundations/idempotency/image.png'),
},
{
name: 'OG docs root image',
run: () => assertPngResponse('/og/getting-started/image.png'),
},
{
name: 'OG cookbook common-patterns image',
run: () =>
assertPngResponse(
'/og/cookbook/common-patterns/sequential-and-parallel/image.png'
),
},
{
name: 'OG docs reference image',
run: () =>
assertPngResponse('/og/api-reference/workflow/get-writable/image.png'),
},
{
name: 'OG worlds index image',
run: () => assertPngResponse('/og/worlds'),
},
{
name: 'OG world image (local)',
run: () => assertPngResponse('/og/worlds/local'),
},
{
name: 'OG world image (postgres)',
run: () => assertPngResponse('/og/worlds/postgres'),
},
{
name: 'OG world image (vercel)',
run: () => assertPngResponse('/og/worlds/vercel'),
},
{
name: 'Sitemap',
run: () => assertXmlResponse('/sitemap.xml'),
},
];
const assertXmlResponse = async (path) => {
const res = await fetch(`${BASE_URL}${path}`, {
headers: await getTrustedSourcesHeaders(),
});
if (!res.ok) {
throw new Error(`${path} returned ${res.status}`);
}
const contentType = res.headers.get('content-type') || '';
if (!contentType.includes('xml') && !contentType.includes('text/plain')) {
throw new Error(`${path} content-type was ${contentType}`);
}
const text = await res.text();
if (!text.includes('<?xml')) {
throw new Error(`${path} did not contain xml declaration`);
}
};
const run = async () => {
let child = null;
let stopServer = async () => {};
let cleanup = async () => {};
if (!USE_REMOTE) {
child = spawn('pnpm', ['-C', 'docs', 'start'], {
env: {
...process.env,
PORT,
HOSTNAME: HOST,
},
stdio: 'inherit',
});
let shuttingDown = false;
stopServer = async () => {
if (shuttingDown) return;
shuttingDown = true;
child.kill('SIGTERM');
await Promise.race([
new Promise((resolve) => child.once('exit', resolve)),
wait(5_000),
]);
if (!child.killed) {
child.kill('SIGKILL');
}
};
cleanup = async () => {
try {
await stopServer();
} catch {
// ignore cleanup errors
}
};
process.on('SIGINT', cleanup);
process.on('SIGTERM', cleanup);
process.on('exit', cleanup);
}
try {
await waitForServer(`${BASE_URL}/og`);
for (const check of checks) {
console.log(`Running docs smoke check: ${check.name}`);
await check.run();
}
await stopServer();
} catch (error) {
await stopServer();
throw error;
}
};
run().catch((error) => {
console.error(error);
process.exit(1);
});