Skip to content
Merged
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
.vscode
.cursor
.claude
.scout

# environment variables
.env
Expand Down
101 changes: 81 additions & 20 deletions scripts/generate-spectaql-md.js
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,44 @@ function letterRangeFor(typeName) {
return TYPE_LETTER_RANGES.find(range => range.letters.includes(letter));
}

// SpectaQL emits every field/argument type reference as a bare same-page
// anchor, e.g. `[Cart!](#cart)`. That only worked when the whole schema lived
// on one page; now that queries/mutations/types are split across pages, a
// bare anchor is broken unless its target happens to land on the same page
// as the link. Collect every H3 heading's page (case-sensitive, since
// GraphQL names are case-significant — a `cart` query and a `Cart` type are
// different headings) so those links can be re-pointed at the correct page.
function collectHeadingPages(headingToPage, content, pageFile) {
for (const m of content.matchAll(/^### (\S+)/gm)) {
headingToPage.set(m[1], pageFile);
}
}

// Re-point bare `(#anchor)` links to an absolute `<basePath>/pageFile#anchor`
// link when their target lives on a different page than the link itself.
// A bare relative filename (`types-c-e.md#anchor`) resolves against the
// fragment's own physical location (`src/pages/includes/autogenerated/`),
// where no such file exists — only the prefixed `graphql-api-<version>-
// types-c-e.md` does. The rest of the repo's cross-page links use an
// absolute path rooted at `src/pages` (e.g.
// `/reference/graphql/latest/types-c-e.md#anchor`), which resolves correctly
// regardless of where the fragment physically lives, so rewritten links use
// that same convention. The link text reliably names the target (SpectaQL
// always links a field/argument to its own type), so matching it exactly
// against the case-sensitive heading map resolves the target unambiguously —
// no need to guess from the anchor text alone.
// List types render as `[Type!]`, nesting a nested `[...]` pair inside the
// label itself, so the label can't be captured with a plain `[^\]]*` — that
// stops at the list type's own inner `]`. Allow one level of nested brackets.
function rewriteBareAnchors(content, currentPageFile, headingToPage, basePath) {
return content.replace(/\[((?:[^\[\]\n]|\[[^\[\]\n]*\])*)\]\(#([a-z0-9_-]+)\)/g, (fullMatch, label, anchor) => {
const cleanLabel = label.replace(/`/g, '').replace(/[![\]]/g, '').trim();
const targetPage = headingToPage.get(cleanLabel);
if (!targetPage || targetPage === currentPageFile) return fullMatch;
return `[${label}](${basePath}/${targetPage}#${anchor})`;
});
}

// Split the types section at H3 boundaries into fixed alphabetical ranges.
function chunkByLetterRange(content) {
const parts = content.split(/(?=^### )/m);
Expand Down Expand Up @@ -270,10 +308,32 @@ for (const schema of toRun) {
const sections = parseSections(content);
const pageSpecs = [];

// Compute every fragment body up front (page assignment only, no writes
// yet) so bare `(#anchor)` cross-references can be resolved against every
// heading in the schema version before any file hits disk.
const queriesBody = ((sections.preamble || '') + (sections.queries || '')).trimEnd() + '\n';
const mutationsBody = sections.mutations ? sections.mutations.trimEnd() + '\n' : null;
const subscriptionsBody = sections.subscriptions ? sections.subscriptions.trimEnd() + '\n' : null;
const typeChunks = sections.types ? chunkByLetterRange(sections.types) : [];

const headingToPage = new Map();
collectHeadingPages(headingToPage, queriesBody, 'index.md');
if (mutationsBody) collectHeadingPages(headingToPage, mutationsBody, 'mutations.md');
if (subscriptionsBody) collectHeadingPages(headingToPage, subscriptionsBody, 'subscriptions.md');
for (const chunk of typeChunks) {
collectHeadingPages(headingToPage, chunk.content, `types-${chunk.suffix}.md`);
}

// Absolute path the rewritten links resolve against, e.g.
// `/reference/graphql/latest` — matches the convention used by hand-written
// cross-links elsewhere in the repo (see rewriteBareAnchors above for why a
// bare relative filename doesn't work from inside a fragment file).
const basePath = '/' + path.relative(path.resolve(ROOT, 'src/pages'), path.resolve(ROOT, indexDir));

// Queries file: preamble (endpoint/header boilerplate) + queries section.
{
const fragmentFile = `${baseName}-queries.md`;
const body = ((sections.preamble || '') + (sections.queries || '')).trimEnd() + '\n';
const body = rewriteBareAnchors(queriesBody, 'index.md', headingToPage, basePath);
fs.writeFileSync(path.join(outputDir, fragmentFile), body, 'utf8');
console.log(` wrote ${fragmentFile}`);
pageSpecs.push({
Expand All @@ -286,9 +346,10 @@ for (const schema of toRun) {
}

// Mutations section.
if (sections.mutations) {
if (mutationsBody) {
const fragmentFile = `${baseName}-mutations.md`;
fs.writeFileSync(path.join(outputDir, fragmentFile), sections.mutations.trimEnd() + '\n', 'utf8');
const body = rewriteBareAnchors(mutationsBody, 'mutations.md', headingToPage, basePath);
fs.writeFileSync(path.join(outputDir, fragmentFile), body, 'utf8');
console.log(` wrote ${fragmentFile}`);
pageSpecs.push({
pageFile: 'mutations.md',
Expand All @@ -301,9 +362,10 @@ for (const schema of toRun) {

// Subscriptions section (not present in current schemas, included for
// forward-compatibility).
if (sections.subscriptions) {
if (subscriptionsBody) {
const fragmentFile = `${baseName}-subscriptions.md`;
fs.writeFileSync(path.join(outputDir, fragmentFile), sections.subscriptions.trimEnd() + '\n', 'utf8');
const body = rewriteBareAnchors(subscriptionsBody, 'subscriptions.md', headingToPage, basePath);
fs.writeFileSync(path.join(outputDir, fragmentFile), body, 'utf8');
console.log(` wrote ${fragmentFile}`);
pageSpecs.push({
pageFile: 'subscriptions.md',
Expand All @@ -315,21 +377,20 @@ for (const schema of toRun) {
}

// Types section: split into fixed alphabetical ranges.
if (sections.types) {
const chunks = chunkByLetterRange(sections.types);
for (const chunk of chunks) {
const range = TYPE_LETTER_RANGES.find(entry => entry.suffix === chunk.suffix);
const fragmentFile = `${baseName}-types-${chunk.suffix}.md`;
fs.writeFileSync(path.join(outputDir, fragmentFile), chunk.content, 'utf8');
console.log(` wrote ${fragmentFile}`);
pageSpecs.push({
pageFile: `types-${chunk.suffix}.md`,
fragmentFile,
pageTitleSuffix: ` – ${range.navTitle}`,
heading: () => range.heading,
description: meta => schemaDescription(meta, range.navTitle.toLowerCase()),
});
}
for (const chunk of typeChunks) {
const range = TYPE_LETTER_RANGES.find(entry => entry.suffix === chunk.suffix);
const pageFile = `types-${chunk.suffix}.md`;
const fragmentFile = `${baseName}-types-${chunk.suffix}.md`;
const body = rewriteBareAnchors(chunk.content, pageFile, headingToPage, basePath);
fs.writeFileSync(path.join(outputDir, fragmentFile), body, 'utf8');
console.log(` wrote ${fragmentFile}`);
pageSpecs.push({
pageFile,
fragmentFile,
pageTitleSuffix: ` – ${range.navTitle}`,
heading: () => range.heading,
description: meta => schemaDescription(meta, range.navTitle.toLowerCase()),
});
}

const autogeneratedDir = path.resolve(ROOT, 'src/pages/includes/autogenerated');
Expand Down
Loading
Loading