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
24 changes: 24 additions & 0 deletions .circleci/config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,29 @@ jobs:
name: Validate llms.txt
command: yarn validate-llms-txt

check-error-docs:
executor:
name: default
steps:
- checkout
- run:
name: Fetch error code registry (ably-common submodule)
command: git submodule update --init ably-common
- run:
name: Regenerate error code pages from the registry
command: node bin/generate-error-pages.ts
- run:
name: Ensure committed error pages match the registry
command: |
CHANGES="$(git status --porcelain src/pages/docs/platform/errors/codes src/pages/docs/platform/errors/codes.mdx)"
if [ -n "$CHANGES" ]; then
echo "❌ Generated error code pages are out of date or were hand-edited:"
echo "$CHANGES"
echo "Run 'yarn generate:errors' and commit the result."
exit 1
fi
echo "✓ Error code pages are up to date"

test-nginx:
docker:
- image: heroku/heroku:24-build
Expand Down Expand Up @@ -174,6 +197,7 @@ workflows:
unless: << pipeline.parameters.content-update >>
jobs:
- install-dependencies
- check-error-docs
- security-audit:
requires:
- install-dependencies
Expand Down
3 changes: 3 additions & 0 deletions .gitmodules
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
[submodule "ably-common"]
path = ably-common
url = https://github.com/ably/ably-common.git
1 change: 1 addition & 0 deletions ably-common
Submodule ably-common added at fcc793
194 changes: 194 additions & 0 deletions bin/generate-error-pages.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
import fs from 'fs';
import path from 'path';

// Generates the docs pages for Ably error codes from the ably-common registry
// (the pinned `ably-common` git submodule). Each `ably-common/errors/codes/<code>.md`
// becomes a docs page at `/docs/platform/errors/codes/<code>-<identifier>` (the bare
// `/docs/platform/errors/codes/<code>` redirects to it), plus an index at
// `/docs/platform/errors/codes`.
//
// The generated pages are committed to the repo, so the site build consumes them
// without needing the submodule checked out. Run `yarn generate:errors` after
// bumping the submodule; CI regenerates and fails on any diff, so the committed
// output can't drift from the registry or be hand-edited (see .circleci/config.yml).

const REGISTRY_DIR = path.resolve('ably-common/errors/codes');
const DETAIL_OUT_DIR = path.resolve('src/pages/docs/platform/errors/codes');
const INDEX_OUT = path.resolve('src/pages/docs/platform/errors/codes.mdx');
const DETAIL_BASE_URL = '/docs/platform/errors/codes';

interface ErrorEntry {
code: number;
identifier: string;
title: string;
summary: string;
body: string;
}

// Mirrors ably-common's `errors/scripts/frontmatter.js`: a `---` fence, then one
// `key: value` per line (split on the first `: ` only, so values may contain
// colons), optional surrounding double quotes, then a closing `---`. We parse it
// the same way rather than with a strict YAML parser, which rejects the colons
// that appear in some summaries.
const parseFrontmatter = (content: string): { fields: Record<string, string>; body: string } | null => {
if (!content.startsWith('---\n')) {
return null;
}
const rest = content.slice(4);
const end = rest.indexOf('\n---');
if (end === -1) {
return null;
}
const body = rest.slice(end + 4).replace(/^\n+/, '');
const fields: Record<string, string> = {};

for (const raw of rest.slice(0, end).split('\n')) {
const line = raw.trimEnd();
if (line === '') {
continue;
}
const sep = line.indexOf(': ');
const key = sep === -1 ? line.replace(/:$/, '') : line.slice(0, sep);
let value = sep === -1 ? '' : line.slice(sep + 2);
if (value.length >= 2 && value.startsWith('"') && value.endsWith('"')) {
value = value.slice(1, -1).replace(/\\"/g, '"');
}
fields[key.trim()] = value;
}

return { fields, body };
};

// Frontmatter values are single-line; a double-quoted YAML scalar with `\` and
// `"` escaped is safe for the titles, summaries, identifiers and URLs we emit.
const yamlQuote = (value: string): string => `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;

// An MDX comment (`{/* … */}`, not an HTML `<!-- -->` comment, which MDX rejects)
// that renders to nothing. Marks the file as generated so it isn't hand-edited.
const generatedBanner = (source: string): string =>
`{/* AUTOGENERATED — DO NOT EDIT. Generated from ${source} by bin/generate-error-pages.ts; run \`yarn generate:errors\` to update. */}`;

// The URL slug pairs the code with the identifier using hyphens (identifiers are
// snake_case), giving pretty URLs like `…/codes/40142-token-expired`. The bare
// `…/codes/<code>` URL redirects here (see writeDetailPage), so links by code
// alone still resolve.
const pageSlug = (entry: ErrorEntry): string => `${entry.code}-${entry.identifier.replace(/_/g, '-')}`;

// Table cells are single-line and pipe-delimited: flatten whitespace and escape pipes.
const escapeCell = (value: string): string =>
value
.replace(/\s*\n\s*/g, ' ')
.replace(/\|/g, '\\|')
.trim();

const readRegistry = (): ErrorEntry[] => {
const entries: ErrorEntry[] = [];

for (const file of fs.readdirSync(REGISTRY_DIR)) {
if (!file.endsWith('.md')) {
continue;
}
const parsed = parseFrontmatter(fs.readFileSync(path.join(REGISTRY_DIR, file), 'utf8'));
if (!parsed) {
console.warn(`[error-docs] skipping ${file}: could not parse frontmatter`);
continue;
}
const { identifier, title, summary } = parsed.fields;
const code = Number(parsed.fields.code);

if (!Number.isFinite(code) || !identifier || !title || !summary) {
console.warn(`[error-docs] skipping ${file}: missing required frontmatter (code, identifier, title, summary)`);
continue;
}
entries.push({ code, identifier, title, summary, body: parsed.body.trim() });
}

return entries.sort((a, b) => a.code - b.code);
};

const writeDetailPage = (entry: ErrorEntry): void => {
const frontmatter = [
'---',
`title: ${yamlQuote(`${entry.code}: ${entry.title}`)}`,
`meta_description: ${yamlQuote(entry.summary)}`,
`identifier: ${yamlQuote(entry.identifier)}`,
// The canonical URL carries the identifier; redirect the bare-code URL to it
// so links by code alone (inline docs links, error `href`) keep working.
'redirect_from:',
` - ${yamlQuote(`${DETAIL_BASE_URL}/${entry.code}`)}`,
'---',
'',
].join('\n');
const banner = generatedBanner(`ably-common/errors/codes/${entry.code}.md`);
// The summary is the error's primary description, so it leads the body as
// normal prose (not the muted PageHeader `intro`). Any detail body follows.
const content = [entry.summary, entry.body].filter(Boolean).join('\n\n');
fs.writeFileSync(path.join(DETAIL_OUT_DIR, `${pageSlug(entry)}.mdx`), `${frontmatter}\n${banner}\n\n${content}\n`);
};

const writeIndexPage = (entries: ErrorEntry[]): void => {
const frontmatter = [
'---',
`title: ${yamlQuote('Error codes')}`,
`meta_description: ${yamlQuote('Understand Ably error codes and their causes, to resolve them efficiently.')}`,
'redirect_from:',
` - ${yamlQuote('/docs/errors/codes')}`,
'---',
'',
].join('\n');
const intro =
'\nEvery Ably error code, its identifier, and a short description. ' +
'Select a code for details on what it means, why it happens, and how to resolve it.\n\n';
// Group codes into a table per thousands bucket (40xxx, 80xxx, …) for easier
// navigation. Each row links to the detail page, shows the identifier beneath
// the code in smaller text, and keeps a numeric anchor so old
// `…/codes#<code>` deep links still resolve.
const groups = new Map<number, ErrorEntry[]>();
for (const e of entries) {
const bucket = Math.floor(e.code / 1000);
const list = groups.get(bucket);
if (list) {
list.push(e);
} else {
groups.set(bucket, [e]);
}
}

const sections = [...groups.entries()]
.sort(([a], [b]) => a - b)
.map(([bucket, bucketEntries]) => {
const rows = bucketEntries
.map((e) => {
const codeCell = `<a id="${e.code}"/>[${e.code}](${DETAIL_BASE_URL}/${pageSlug(e)})<br /><small>${e.identifier}</small>`;
return `| ${codeCell} | ${escapeCell(e.title)} | ${escapeCell(e.summary)} |`;
})
.join('\n');
return `## ${bucket}xxx\n\n| Code | Title | Summary |\n| --- | --- | --- |\n${rows}\n`;
})
.join('\n');

const banner = generatedBanner('the ably-common errors registry');
fs.writeFileSync(INDEX_OUT, `${frontmatter}\n${banner}\n${intro}${sections}`);
};

const generate = (): void => {
if (!fs.existsSync(REGISTRY_DIR)) {
console.error(
`[error-docs] registry not found at ${REGISTRY_DIR}. Run \`git submodule update --init ably-common\` first.`,
);
process.exit(1);
}

// Rebuild the output directory from scratch so codes removed from the registry
// don't leave stale pages behind (a stale deletion then shows up in the CI diff).
fs.rmSync(DETAIL_OUT_DIR, { recursive: true, force: true });
fs.mkdirSync(DETAIL_OUT_DIR, { recursive: true });

const entries = readRegistry();
entries.forEach(writeDetailPage);
writeIndexPage(entries);

console.log(`[error-docs] generated ${entries.length} error code pages and the index`);
};

generate();
27 changes: 27 additions & 0 deletions data/onPostBuild/transpileMdxToMarkdown.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,33 @@ Some content here.`;
});
});

describe('identifier frontmatter (error-code pages)', () => {
it('surfaces the identifier beneath the title for LLM consumption', () => {
const input = `---
title: "40142: Token expired"
identifier: token_expired
---

The token had expired.`;

const { content } = transformMdxToMarkdown(input, 'https://ably.com');

expect(content).toContain('# 40142: Token expired\n\nIdentifier: `token_expired`');
});

it('omits the identifier line when frontmatter has no identifier', () => {
const input = `---
title: Test Page
---

Some content here.`;

const { content } = transformMdxToMarkdown(input, 'https://ably.com');

expect(content).not.toContain('Identifier:');
});
});

describe('removeImportExportStatements', () => {
it('should remove single-line imports', () => {
const input = `import Foo from 'bar'\n\nContent here`;
Expand Down
9 changes: 7 additions & 2 deletions data/onPostBuild/transpileMdxToMarkdown.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,7 @@ interface MdxQueryResult {

interface FrontMatterAttributes {
title?: string;
identifier?: string;
[key: string]: any;
}

Expand Down Expand Up @@ -572,6 +573,7 @@ function transformMdxToMarkdown(

const title = parsed.attributes.title;
const intro = parsed.attributes.intro;
const identifier = parsed.attributes.identifier;
let content = parsed.body;

// Stage 2: Remove import/export statements
Expand Down Expand Up @@ -616,8 +618,11 @@ function transformMdxToMarkdown(
// Stage 15: Add language subheadings to code blocks within <Code> tags
content = addLanguageSubheadingsToCodeBlocks(content);

// Stage 16: Prepend title as markdown heading
let finalContent = `# ${title}\n\n${intro ? `${intro}\n\n` : ''}${content}`;
// Stage 16: Prepend title as markdown heading. Error-code pages carry a stable
// `identifier`; surface it beneath the title for LLM consumption (the `.md`
// otherwise loses it, since it is rendered from frontmatter on the HTML page).
const identifierBlock = identifier ? `Identifier: \`${identifier}\`\n\n` : '';
let finalContent = `# ${title}\n\n${identifierBlock}${intro ? `${intro}\n\n` : ''}${content}`;

// Stage 17: Append navigation footer (after all transformations to avoid double-processing)
if (navContext) {
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,8 @@
"lint-staged": "lint-staged",
"repo-githooks": "git config core.hooksPath .githooks",
"no-githooks": "git config --unset core.hooksPath",
"validate-llms-txt": "node bin/validate-llms.txt.ts"
"validate-llms-txt": "node bin/validate-llms.txt.ts",
"generate:errors": "node bin/generate-error-pages.ts"
},
"dependencies": {
"@ably/ui": "18.3.1",
Expand Down
11 changes: 11 additions & 0 deletions src/components/Layout/Footer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,17 @@ const Footer: React.FC<{ pageContext: PageContextType }> = ({ pageContext }) =>
let path = '#';
const pathName = location.pathname.replace('docs/', '');

// Error-code pages are generated from the ably-common registry, not authored
// in this repo. Point edits at the source file there; hide the link for the
// generated aggregate index (no single source file).
const errorCodeMatch = pathName.match(/^\/platform\/errors\/codes\/(\d+)\/?$/);
if (errorCodeMatch) {
return `https://github.com/ably/ably-common/blob/main/errors/codes/${errorCodeMatch[1]}.md`;
}
if (pathName === '/platform/errors/codes' || pathName === '/platform/errors/codes/') {
return null;
}

if (customGithubPaths[pathName]) {
path = customGithubPaths[pathName];
} else if (activePage.template === 'mdx') {
Expand Down
3 changes: 3 additions & 0 deletions src/components/Layout/Layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@ export type Frontmatter = {
last_updated?: string;
intro?: string;
json_ld?: Record<string, unknown>;
// Error-code pages (generated from the ably-common registry) carry the stable
// snake_case identifier, rendered as a soft sub-title beneath the title.
identifier?: string;
};

export type PageContextType = {
Expand Down
5 changes: 4 additions & 1 deletion src/components/Layout/MDXWrapper.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,9 @@ const MDXWrapper: React.FC<MDXWrapperProps> = ({ children, pageContext, location
const description = getFrontmatter(frontmatter, 'meta_description', META_DESCRIPTION_FALLBACK) as string;
const intro = getFrontmatter(frontmatter, 'intro') as string;
const keywords = getFrontmatter(frontmatter, 'meta_keywords') as string;
// Error-code pages carry a stable snake_case identifier, shown as a soft
// sub-title beneath the title.
const identifier = getFrontmatter(frontmatter, 'identifier') as string;
const metaTitle = getMetaTitle(title, (activePage.product as ProductName) || META_PRODUCT_FALLBACK) as string;

const { canonicalUrl } = useSiteMetadata();
Expand Down Expand Up @@ -362,7 +365,7 @@ const MDXWrapper: React.FC<MDXWrapperProps> = ({ children, pageContext, location
RequiredBadge,
}}
>
<PageHeader title={title} intro={intro} />
<PageHeader title={title} intro={intro} subtitle={identifier ? <code>{identifier}</code> : undefined} />
{children}
</MarkdownProvider>
</NestedTableProvider>
Expand Down
8 changes: 6 additions & 2 deletions src/components/Layout/mdx/PageHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,16 @@ import cn from 'src/utilities/cn';
type PageHeaderProps = {
title: string;
intro: string;
subtitle?: React.ReactNode;
};

export const PageHeader: React.FC<PageHeaderProps> = ({ title, intro }) => {
export const PageHeader: React.FC<PageHeaderProps> = ({ title, intro, subtitle }) => {
return (
<div className="my-8 border-b border-neutral-300 dark:border-neutral-1000 pb-8">
<h1 className={cn('ui-text-h1', intro ? 'mb-4' : 'mb-0')}>{title}</h1>
<h1 className={cn('ui-text-h1', intro || subtitle ? 'mb-4' : 'mb-0')}>{title}</h1>
{subtitle && (
<div className={cn('text-neutral-800 dark:text-neutral-500', intro ? 'mb-4' : 'mb-0')}>{subtitle}</div>
)}
{intro && (
<p className="text-neutral-800 dark:text-neutral-500 font-serif italic tracking-tight text-lg leading-normal mb-0">
{intro}
Expand Down
9 changes: 8 additions & 1 deletion src/contexts/layout-context.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,14 @@ export const LayoutProvider: React.FC<PropsWithChildren<{ pageContext: PageConte
const location = useLocation();

const activePage = useMemo(() => {
const activePageData = determineActivePage(productData, location.pathname);
const activePageData =
determineActivePage(productData, location.pathname) ??
// Error-code detail pages (/docs/platform/errors/codes/<code>) are generated
// from the ably-common registry and not individually in the nav; resolve them
// to the Error codes index so they inherit the Platform sidebar.
(/^\/docs\/platform\/errors\/codes\/[^/]+\/?$/.test(location.pathname)
? determineActivePage(productData, '/docs/platform/errors/codes')
: null);

let languages: LanguageKey[] = [];
if (activePageData?.page.languages) {
Expand Down
Loading