diff --git a/action.yml b/action.yml index e1ef947..7921408 100644 --- a/action.yml +++ b/action.yml @@ -33,6 +33,10 @@ outputs: description: Reference pages added (oas:sync only) deleted-count: description: Reference pages deleted (oas:sync only) + moved-count: + description: Reference pages moved to match changed tags via apply-tag-changes (oas:sync only) + updated-count: + description: Reference pages or _order.yaml files updated in place, e.g. by x-internal or apply-endpoint-order (oas:sync only) skipped-count: description: Reference pages skipped because the destination filename already exists (oas:sync only) skipped: diff --git a/dist-gha/index.js b/dist-gha/index.js index ef6e81b..fe8818d 100644 --- a/dist-gha/index.js +++ b/dist-gha/index.js @@ -49094,6 +49094,60 @@ function resolveLocalPathItemRef(entry, spec) { return finish(); } +/** + * Resolve the `x-internal` extension for an operation the way the platform + * does on OAS upload: an operation-level value wins, falling back to the + * spec root. `present` is false when neither sets it, in which case the + * page's visibility is left to whoever owns it (new pages default to + * visible, existing pages keep whatever `hidden` they already have). + * `x-readme: { internal: true }` is deliberately not read — the platform's + * page sync only honors the bare `x-internal` key. + */ +function resolveXInternal(operation, spec) { + if (operation && 'x-internal' in operation) return { present: true, value: operation['x-internal'] }; + if (spec && 'x-internal' in spec) return { present: true, value: spec['x-internal'] }; + return { present: false, value: undefined }; +} + +/** + * Find every `x-readme: { internal: ... }` in a spec. The `oas` package + * documents it as an alternative spelling of `x-internal`, but the platform's + * page sync never reads it (see `resolveXInternal`), so it silently has no + * effect. Returns human-readable locations (`root`, `GET /pets`, + * `webhook POST newPet`) for lint to warn about. + */ +function findIgnoredInternalExtensions(spec) { + const hasInternal = (obj) => { + const xReadme = obj?.['x-readme']; + return !!xReadme && typeof xReadme === 'object' && 'internal' in xReadme; + }; + + const locations = []; + if (hasInternal(spec)) locations.push('root'); + + for (const [entries, isWebhook] of [[spec?.paths, false], [spec?.webhooks, true]]) { + for (const [name, rawItem] of Object.entries(entries || {})) { + for (const [method, operation] of Object.entries(resolveLocalPathItemRef(rawItem, spec) || {})) { + if (!HTTP_METHODS.has(method) || !hasInternal(operation)) continue; + locations.push(`${isWebhook ? 'webhook ' : ''}${method.toUpperCase()} ${name}`); + } + } + } + return locations; +} + +/** + * Read a root-level ReadMe extension, in the same precedence as the `oas` + * package's `getExtension()` with no operation: `x-readme.`, then + * `x-`, then a bare ``. + */ +function getRootExtension(spec, name) { + const xReadme = spec?.['x-readme']; + if (xReadme && typeof xReadme === 'object' && name in xReadme) return xReadme[name]; + if (spec && `x-${name}` in spec) return spec[`x-${name}`]; + return spec?.[name]; +} + /** * Extract operations from an OAS spec's `paths`, plus its OAS 3.1 `webhooks` * (callouts the API itself makes to a client-registered URL, not endpoints the @@ -49122,9 +49176,11 @@ function extractOperations(spec) { operationId, summary: operation.summary || null, description: operation.description || null, - tag: (operation.tags && operation.tags[0]) || null, + // The platform groups by the first *non-empty* tag. + tag: (Array.isArray(operation.tags) && operation.tags.find((t) => t)) || null, path: pathStr, isWebhook, + xInternal: resolveXInternal(operation, spec), }); } } @@ -49243,7 +49299,7 @@ function stringifyFrontmatter(frontmatter) { return gray_matter.stringify('', frontmatter).replace(/\n+$/, ''); } -function buildPageContent({ oasFilename, operationId, isWebhook }) { +function buildPageContent({ oasFilename, operationId, isWebhook, hidden = false }) { const frontmatter = { api: { file: oasFilename, @@ -49253,19 +49309,12 @@ function buildPageContent({ oasFilename, operationId, isWebhook }) { // what the platform stamps on a page generated from `webhooks`. ...(isWebhook ? { webhook: true } : {}), }, - // Mirror the platform's OAS-upload behavior: a newly added endpoint is - // always written `hidden: false`, even when its tag and siblings are - // `hidden: true`. The backend does not infer this from a missing field, so - // it must be written explicitly. - // - // @todo Honor the `x-internal` OpenAPI extension for page visibility, to - // match gitto#2095 (RM-4616 / CX-3303): resolve `hidden` from operation-level - // `x-internal`, falling back to root-level, else false; and hide a tag's - // index page when all of its operations are `x-internal: true`. Deferred to - // keep oas:sync create-only — the resync-side rules (re-applying x-internal - // to existing pages, parent hide-ratchet) would require mutating existing - // pages, which this command intentionally never does. - hidden: false, + // The backend does not infer visibility from a missing field, so it's + // always written explicitly: `x-internal` when the spec sets it (see + // `resolveXInternal`), otherwise `false` — mirroring the platform's + // OAS-upload, which writes a new endpoint visible even when its tag and + // siblings are hidden. + hidden, }; return stringifyFrontmatter(frontmatter); @@ -49286,6 +49335,25 @@ function buildTagIndexContent(title, description) { return stringifyFrontmatter(frontmatter); } +/** + * Kebab-case a folder segment: lowercase, with any run of whitespace or other + * non-alphanumeric characters (a space, an underscore, ...) collapsed to a + * single hyphen. Two roles: (1) as an equivalence key, so "Shipping Labels", + * "shipping labels" and "shipping-labels" are recognized as the same + * folder no matter which spelling is already on disk (see + * `existingFoldersBySlug`); and (2) as the spelling used when *creating* a + * folder that doesn't exist under any spelling yet, matching what a fresh + * platform OAS-upload would produce. Neither spelling is the one "true" + * form — this just needs one fixed spelling to create with and to compare + * against. + */ +function slugifyFolder(value) { + return value + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, ''); +} + /** * The category-folder grouping for an operation. A tagged operation groups * under its own tag, as before. An untagged operation groups under a folder @@ -49295,11 +49363,35 @@ function buildTagIndexContent(title, description) { * one "Other" folder. */ function operationGroup(op) { - if (op.tag) return { folder: safeSegment(op.tag, 'Other').toLowerCase(), title: op.tag }; - const folder = safeSegment(op.path.replace(/[/{}]/g, ''), 'operation').toLowerCase(); + if (op.tag) return { folder: slugifyFolder(safeSegment(op.tag, 'Other')) || 'other', title: op.tag }; + const folder = slugifyFolder(safeSegment(op.path.replace(/[/{}]/g, ''), 'operation')) || 'operation'; return { folder, title: op.path }; } +/** + * Map every existing directory directly under `apiDir` to its slugified + * name (`slug -> actual on-disk name`), with a single directory read. Used + * to resolve a group's folder: whatever spelling is already on disk + * (hyphenated, space-separated, hand-authored, ...) is authoritative and + * must be reused rather than spawning a second, differently-spelled folder + * next to it. A slug with no entry here has nothing on disk yet, so the + * caller falls back to creating it under its hyphenated slug — the spelling + * a fresh platform OAS-upload would produce. + */ +function existingFoldersBySlug(apiDir) { + const bySlug = new Map(); + let entries; + try { + entries = external_node_fs_namespaceObject.readdirSync(apiDir, { withFileTypes: true }); + } catch { + return bySlug; // apiDir doesn't exist yet — nothing to reuse. + } + for (const entry of entries) { + if (entry.isDirectory()) bySlug.set(slugifyFolder(entry.name), entry.name); + } + return bySlug; +} + /** * Collect every slug already used across the entire reference/ tree, as a * lowercase-slug -> owner-count map. Reference page slugs share one flat @@ -49376,6 +49468,133 @@ function reserveSlug(takenSlugs, base) { return chosen; } +/** + * Rewrite a page's frontmatter in place, keeping its body. `mutate` receives + * a copy of the parsed frontmatter and edits it. Returns true if the file + * changed. A copy matters: gray-matter caches parse results by input string, + * so mutating the returned `data` would poison later parses of that content. + */ +function updateFrontmatter(filePath, mutate) { + const content = external_node_fs_namespaceObject.readFileSync(filePath, 'utf-8'); + const parsed = gray_matter(content); + const data = structuredClone(parsed.data); + mutate(data); + const next = parsed.content.trim() + ? gray_matter.stringify(parsed.content, data) + : stringifyFrontmatter(data); + if (next === content || JSON.stringify(data) === JSON.stringify(parsed.data)) return false; + external_node_fs_namespaceObject.writeFileSync(filePath, next); + return true; +} + +/** + * Merge an OAS-derived slug order into an existing `_order.yaml` list, the + * way the platform does (gitto's `applyOASOrder`): only the slots already + * held by one of `orderedSlugs` are refilled, left to right, in OAS order; + * every other entry (hand-authored pages, other APIs) keeps its position. + * Slugs not yet listed are inserted right after the last refilled slot, or + * appended when none of them are listed yet. + */ +function applyOASOrder(currentOrder, orderedSlugs) { + const desired = [...new Set(orderedSlugs)]; + const desiredSet = new Set(desired); + // A hand-edited _order.yaml can list the same slug more than once. Collapse + // duplicates up front so each slot corresponds to exactly one distinct slug; + // otherwise there are more slots than slugs to refill them with, and the + // surplus slots would be filled with `undefined`. + const order = [...new Set(currentOrder)]; + if (!order.length) return desired; + + const slots = order.map((s, i) => (desiredSet.has(s) ? i : -1)).filter((i) => i > -1); + if (!slots.length) return [...order, ...desired]; + + const remaining = [...desired]; + for (const index of slots) order[index] = remaining.shift(); + if (remaining.length) order.splice(slots.at(-1) + 1, 0, ...remaining); + return order; +} + +const INDEX_FILES = ['index.md', 'index.mdx', 'index.html']; + +/** + * Whether a tag folder's category page looks untouched since it was + * generated (mirrors gitto's `isAutoGeneratedParentPage`): no body, no + * frontmatter beyond title/hidden/excerpt, a title that slugifies to the + * folder name (allowing a `-N` uniqueness suffix), and an excerpt, if any, + * that is text the spec supplies. + * + * The excerpt needs care. The platform never writes one, but this CLI stamps + * the tag's description as `excerpt` on the pages it generates (see + * `buildTagIndexContent`), so its presence alone can't mean "hand-edited". + * Nothing records what a page was generated from, and by the time a folder + * is being cleaned up its tag has often left the spec, so the excerpt can't + * be checked against "its" tag either. What can be checked is whether the + * current spec still supplies that exact text under *any* tag + * (`specDescriptions`): a generated excerpt is always lifted from + * `tags[].description`, and a retag that renames a tag usually keeps its + * description, so this recognizes generated pages across the common rename. + * An excerpt the spec no longer supplies could be a description that left + * the spec, or a person's edit; with no way to tell them apart, the page is + * reported as hand-edited and `cleanupTagFolder` flattens it rather than + * deleting it. A stale page can be removed by hand; a deleted edit is gone. + */ +function isGeneratedTagIndex(indexPath, specDescriptions) { + let parsed; + try { + parsed = gray_matter(external_node_fs_namespaceObject.readFileSync(indexPath, 'utf-8')); + } catch { + return false; + } + const { data, content } = parsed; + if (content.trim()) return false; + if (typeof data.title !== 'string' || !('hidden' in data)) return false; + if (Object.keys(data).some((k) => !['title', 'hidden', 'excerpt'].includes(k))) return false; + if ('excerpt' in data && !specDescriptions.has(data.excerpt)) return false; + + const dirSlug = slugifyFolder(external_node_path_namespaceObject.basename(external_node_path_namespaceObject.dirname(indexPath))); + const titleSlug = slugifyFolder(data.title); + return dirSlug === titleSlug || new RegExp(`^${titleSlug.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}-\\d+$`).test(dirSlug); +} + +/** + * After `apply-tag-changes` moves pages out of a tag folder, clean up the + * folder if it's now empty (gitto's `cleanupParentDirectory`). A generated + * category page is deleted along with its folder; a hand-edited one is kept + * by flattening `tag/index.md` into a sibling `tag.md` (same slug, so the + * parent `_order.yaml` entry still applies). + */ +function cleanupTagFolder(dir, { refDir, specDescriptions, takenSlugs, changes }) { + let entries; + try { + entries = external_node_fs_namespaceObject.readdirSync(dir); + } catch { + return; + } + const others = entries.filter((e) => !INDEX_FILES.includes(e) && e !== '_order.yaml'); + if (others.length) return; + + const parentDir = external_node_path_namespaceObject.dirname(dir); + const slug = external_node_path_namespaceObject.basename(dir); + const indexFile = entries.find((e) => INDEX_FILES.includes(e)); + + if (!indexFile || isGeneratedTagIndex(external_node_path_namespaceObject.join(dir, indexFile), specDescriptions)) { + external_node_fs_namespaceObject.rmSync(dir, { recursive: true, force: true }); + removeFromOrder(external_node_path_namespaceObject.join(parentDir, '_order.yaml'), slug); + if (indexFile) { + releaseSlug(takenSlugs, slug); + changes.deleted.push(external_node_path_namespaceObject.relative(refDir, external_node_path_namespaceObject.join(dir, indexFile))); + } + return; + } + + const from = external_node_path_namespaceObject.join(dir, indexFile); + const to = external_node_path_namespaceObject.join(parentDir, `${slug}${external_node_path_namespaceObject.extname(indexFile)}`); + if (external_node_fs_namespaceObject.existsSync(to)) return; + external_node_fs_namespaceObject.renameSync(from, to); + external_node_fs_namespaceObject.rmSync(dir, { recursive: true, force: true }); + changes.moved.push({ from: external_node_path_namespaceObject.relative(refDir, from), to: external_node_path_namespaceObject.relative(refDir, to) }); +} + /** * Run the sync for a single OAS file. Returns changes for that file. * @@ -49389,6 +49608,15 @@ function syncOneOas(refDir, oasFilename, spec, takenSlugs) { 'api', ); + // Hyphen vs. space in a group's folder name is not a meaningful difference + // — "shipping-labels" and "shipping labels" are the same folder to a + // human and to the platform, just spelled differently. One directory read + // up front (existingFoldersBySlug) is enough to resolve every group's + // folder for this API as an O(1) lookup, rather than walking apiDir again + // for each distinct tag/group. + const apiDir = external_node_path_namespaceObject.join(refDir, infoTitle); + const foldersBySlug = existingFoldersBySlug(apiDir); + const existingPages = collectExistingPages(refDir).filter( (p) => p.data.api.file === oasFilename, ); @@ -49401,7 +49629,12 @@ function syncOneOas(refDir, oasFilename, spec, takenSlugs) { ); } - const changes = { added: [], deleted: [], skipped: [] }; + const changes = { added: [], deleted: [], moved: [], updated: [], skipped: [] }; + + // Root-only opt-ins; only an explicit `true` counts, so a missing or + // malformed value leaves existing placement and order alone. + const applyTagChanges = getRootExtension(spec, 'apply-tag-changes') === true; + const applyEndpointOrder = getRootExtension(spec, 'apply-endpoint-order') === true; // Tag descriptions from the spec's top-level `tags` array, used for the // per-tag category landing page (index.md). @@ -49410,6 +49643,9 @@ function syncOneOas(refDir, oasFilename, spec, takenSlugs) { .filter((t) => t && t.name) .map((t) => [t.name, t.description || null]), ); + // Every description the spec supplies, regardless of which tag: what an + // emptied tag folder's excerpt is checked against (see `isGeneratedTagIndex`). + const specDescriptions = new Set([...tagDescriptions.values()].filter(Boolean)); // Deletes: pages referencing operations that no longer exist. for (const [opId, page] of pagesByOpId) { @@ -49457,16 +49693,20 @@ function syncOneOas(refDir, oasFilename, spec, takenSlugs) { // declared tag. const declaredOrder = (Array.isArray(spec.tags) ? spec.tags : []) .filter((t) => t && t.name) - .map((t) => safeSegment(t.name, 'Other').toLowerCase()); + .map((t) => slugifyFolder(safeSegment(t.name, 'Other')) || 'other'); const orderedFolders = [ ...declaredOrder.filter((folder) => groupsByFolder.has(folder)), ...[...groupsByFolder.keys()].filter((folder) => !declaredOrder.includes(folder)), ]; + const groupDirs = new Map(); + const createdIndexes = new Set(); for (const folder of orderedFolders) { const { title, description } = groupsByFolder.get(folder); - const pageDir = external_node_path_namespaceObject.join(refDir, infoTitle, folder); + const actualFolder = foldersBySlug.get(folder) || folder; + const pageDir = external_node_path_namespaceObject.join(refDir, infoTitle, actualFolder); if (!isWithin(refDir, pageDir)) continue; + groupDirs.set(folder, pageDir); const indexPath = external_node_path_namespaceObject.join(pageDir, 'index.md'); if (!external_node_fs_namespaceObject.existsSync(indexPath)) { @@ -49474,15 +49714,80 @@ function syncOneOas(refDir, oasFilename, spec, takenSlugs) { external_node_fs_namespaceObject.mkdirSync(pageDir, { recursive: true }); external_node_fs_namespaceObject.writeFileSync(indexPath, buildTagIndexContent(title, description)); changes.added.push(external_node_path_namespaceObject.relative(refDir, indexPath)); + createdIndexes.add(indexPath); // The category page's slug is the folder name; reserve it so no operation // takes it. Only when just-created — an existing index.md was already // counted by collectReferenceSlugs's initial disk walk. - takeSlug(takenSlugs, folder); + takeSlug(takenSlugs, actualFolder); + } else if (applyTagChanges) { + // With `apply-tag-changes`, the spec owns the category page's title and + // excerpt too (a tag with no description clears the excerpt). Its body + // and any other frontmatter are the user's and are kept. + const updated = updateFrontmatter(indexPath, (data) => { + data.title = title; + if (description) data.excerpt = description; + else delete data.excerpt; + }); + if (updated) changes.updated.push(external_node_path_namespaceObject.relative(refDir, indexPath)); } - addToOrder(external_node_path_namespaceObject.join(refDir, infoTitle, '_order.yaml'), folder); + addToOrder(external_node_path_namespaceObject.join(refDir, infoTitle, '_order.yaml'), actualFolder); addToOrder(external_node_path_namespaceObject.join(refDir, '_order.yaml'), infoTitle); } + // Where each of this spec's operations ends up this run, by operationKey. + const opPaths = new Map(); + const vacatedDirs = new Set(); + + // Existing pages: by default they stay wherever they are. Two opt-ins can + // touch them: `x-internal` (visibility) and `apply-tag-changes` (placement). + for (const [key, op] of specOps) { + const page = pagesByOpId.get(key); + if (!page) continue; + let filePath = page.filePath; + + // `apply-tag-changes`: a page still inside this API's category follows + // its tag's folder, even if it was hand-moved or nested elsewhere in the + // category. A page moved to another category is the user's call and is + // never touched. Legacy pages literally named index.md are left alone — + // moving one would turn it into the destination folder's category page. + const targetDir = groupDirs.get(operationGroup(op).folder); + if ( + applyTagChanges && + targetDir && + isWithin(apiDir, filePath) && + external_node_path_namespaceObject.basename(filePath) !== 'index.md' && + external_node_path_namespaceObject.dirname(filePath) !== targetDir + ) { + const target = external_node_path_namespaceObject.join(targetDir, external_node_path_namespaceObject.basename(filePath)); + if (external_node_fs_namespaceObject.existsSync(target)) { + changes.skipped.push({ path: external_node_path_namespaceObject.relative(refDir, target), operationId: op.operationId }); + } else { + const fromDir = external_node_path_namespaceObject.dirname(filePath); + const slug = external_node_path_namespaceObject.basename(filePath, '.md'); + external_node_fs_namespaceObject.mkdirSync(targetDir, { recursive: true }); + external_node_fs_namespaceObject.renameSync(filePath, target); + removeFromOrder(external_node_path_namespaceObject.join(fromDir, '_order.yaml'), slug); + addToOrder(external_node_path_namespaceObject.join(targetDir, '_order.yaml'), slug); + vacatedDirs.add(fromDir); + changes.moved.push({ from: page.relativePath, to: external_node_path_namespaceObject.relative(refDir, target) }); + filePath = target; + } + } + + // `x-internal`, when the spec sets it (operation or root), decides the + // page's visibility in both directions. When it's absent the page keeps + // its own `hidden` — removing the extension never unhides a page. + if (op.xInternal.present) { + const hidden = Boolean(op.xInternal.value); + const updated = updateFrontmatter(filePath, (data) => { + data.hidden = hidden; + }); + if (updated) changes.updated.push(external_node_path_namespaceObject.relative(refDir, filePath)); + } + + opPaths.set(key, filePath); + } + // Adds: operation pages with no page yet. Title/excerpt are owned by the OAS // spec at render time, so generated pages carry only the api reference. Slugs // are lowercased to match the platform's OAS-upload output. @@ -49490,7 +49795,7 @@ function syncOneOas(refDir, oasFilename, spec, takenSlugs) { if (pagesByOpId.has(key)) continue; const { folder } = operationGroup(op); - const pageDir = external_node_path_namespaceObject.join(refDir, infoTitle, folder); + const pageDir = external_node_path_namespaceObject.join(refDir, infoTitle, foldersBySlug.get(folder) || folder); // Reference slugs share one flat namespace, so uniquify against every slug // already in reference/ — a collision (or the reserved `index` slug) gets a // numeric suffix rather than being skipped. @@ -49505,14 +49810,82 @@ function syncOneOas(refDir, oasFilename, spec, takenSlugs) { } external_node_fs_namespaceObject.mkdirSync(pageDir, { recursive: true }); - const content = buildPageContent({ oasFilename, operationId: op.operationId, isWebhook: op.isWebhook }); + const content = buildPageContent({ + oasFilename, + operationId: op.operationId, + isWebhook: op.isWebhook, + hidden: op.xInternal.present ? Boolean(op.xInternal.value) : false, + }); external_node_fs_namespaceObject.writeFileSync(pagePath, content); addToOrder(external_node_path_namespaceObject.join(pageDir, '_order.yaml'), slug); changes.added.push(external_node_path_namespaceObject.relative(refDir, pagePath)); + opPaths.set(key, pagePath); + } + + // `x-internal` on category pages: a folder whose operations are *all* + // internal gets its category page hidden too. This only ever hides — a + // category page is never unhidden by sync, so a manual `hidden: true` + // survives. Mirroring the platform, a category page created this run + // counts any truthy `x-internal`; an existing one needs an explicit `true`. + const childrenByDir = new Map(); + for (const [key, filePath] of opPaths) { + const dir = external_node_path_namespaceObject.dirname(filePath); + if (!childrenByDir.has(dir)) childrenByDir.set(dir, []); + childrenByDir.get(dir).push(specOps.get(key).xInternal); + } + for (const [dir, children] of childrenByDir) { + const indexPath = external_node_path_namespaceObject.join(dir, 'index.md'); + if (!external_node_fs_namespaceObject.existsSync(indexPath)) continue; + const isNew = createdIndexes.has(indexPath); + const allHidden = children.every((x) => x.present && (isNew ? Boolean(x.value) : x.value === true)); + if (!allHidden) continue; + const updated = updateFrontmatter(indexPath, (data) => { + data.hidden = true; + }); + if (updated && !isNew) changes.updated.push(external_node_path_namespaceObject.relative(refDir, indexPath)); + } + + // Tag folders emptied by `apply-tag-changes` moves. Never the API's own + // category folder, and never a folder an operation still lives in. + for (const dir of vacatedDirs) { + if (!isWithin(apiDir, dir) || childrenByDir.has(dir)) continue; + cleanupTagFolder(dir, { refDir, specDescriptions, takenSlugs, changes }); + } + + // `apply-endpoint-order`: reorder each folder's operation pages to match + // the order they're declared in the spec (paths, then webhooks). Only this + // API's pages are reordered, and only among the `_order.yaml` slots they + // already hold — other entries keep their place. Files never move. + if (applyEndpointOrder) { + const orderByDir = new Map(); + for (const key of specOps.keys()) { + const filePath = opPaths.get(key); + if (!filePath || !isWithin(apiDir, filePath)) continue; + // A legacy page named index.md is ordered in its parent, by folder name. + const isIndex = external_node_path_namespaceObject.basename(filePath) === 'index.md'; + const dir = isIndex ? external_node_path_namespaceObject.dirname(external_node_path_namespaceObject.dirname(filePath)) : external_node_path_namespaceObject.dirname(filePath); + const slug = isIndex ? external_node_path_namespaceObject.basename(external_node_path_namespaceObject.dirname(filePath)) : external_node_path_namespaceObject.basename(filePath, '.md'); + if (!orderByDir.has(dir)) orderByDir.set(dir, []); + orderByDir.get(dir).push(slug); + } + for (const [dir, slugs] of orderByDir) { + const orderPath = external_node_path_namespaceObject.join(dir, '_order.yaml'); + const current = external_node_fs_namespaceObject.existsSync(orderPath) ? parseOrderYaml(external_node_fs_namespaceObject.readFileSync(orderPath, 'utf-8')) : []; + const next = applyOASOrder(current, slugs); + if (next.join('\n') === current.join('\n')) continue; + writeOrderYaml(orderPath, next); + changes.updated.push(external_node_path_namespaceObject.relative(refDir, orderPath)); + } } + // Two passes can each touch the same tag page in one run (`apply-tag-changes` + // syncing its title/excerpt, then `x-internal` hiding it once every operation + // in it is internal). Report each file once so the printed list and the + // `updated-count` output reflect files, not writes. + changes.updated = [...new Set(changes.updated)]; + return changes; } @@ -49525,7 +49898,8 @@ function syncOneOas(refDir, oasFilename, spec, takenSlugs) { * * @param {string | { cwd?: string }} input Repo root path, or `{ cwd }` object. * @returns {null | Array<{ filename: string, spec: object, opCount: number, - * changes: { added: string[], deleted: string[] } }>} + * changes: { added: string[], deleted: string[], moved: { from, to }[], + * updated: string[], skipped: { path, operationId }[] } }>} * Returns null if there's no reference/ dir or no specs. */ function syncOas(input) { @@ -49557,19 +49931,27 @@ function syncOas(input) { * programmatic API above. * * @param {ReturnType} results - * @returns {{ totalAdded: number, totalDeleted: number, totalSkipped: number, + * @returns {{ totalAdded: number, totalDeleted: number, totalMoved: number, + * totalUpdated: number, totalSkipped: number, * skipped: Array<{ filename: string, path: string, operationId: string }> }} */ function printSyncResults(results) { let totalAdded = 0; let totalDeleted = 0; + let totalMoved = 0; + let totalUpdated = 0; let totalSkipped = 0; const skipped = []; for (const { filename, spec, opCount, changes } of results) { const title = spec.info?.title || filename; const hasChanges = - changes.added.length + changes.deleted.length + changes.skipped.length > 0; + changes.added.length + + changes.deleted.length + + changes.moved.length + + changes.updated.length + + changes.skipped.length > + 0; const dot = hasChanges ? warn('●') : success('●'); console.log(); @@ -49585,6 +49967,12 @@ function printSyncResults(results) { for (const file of changes.deleted) { console.log(` ${err('−')} Deleted ${file}`); } + for (const { from, to } of changes.moved) { + console.log(` ${warn('→')} Moved ${from} to ${to}`); + } + for (const file of changes.updated) { + console.log(` ${warn('~')} Updated ${file}`); + } for (const { path: file, operationId } of changes.skipped) { console.log( ` ${warn('!')} Skipped ${file} for "${operationId}" (destination already exists)`, @@ -49594,10 +49982,12 @@ function printSyncResults(results) { totalAdded += changes.added.length; totalDeleted += changes.deleted.length; + totalMoved += changes.moved.length; + totalUpdated += changes.updated.length; totalSkipped += changes.skipped.length; } - return { totalAdded, totalDeleted, totalSkipped, skipped }; + return { totalAdded, totalDeleted, totalMoved, totalUpdated, totalSkipped, skipped }; } async function run(_options, _cmd, ctx) { @@ -49609,6 +49999,8 @@ async function run(_options, _cmd, ctx) { writeGithubActionsOutputs({ 'added-count': '0', 'deleted-count': '0', + 'moved-count': '0', + 'updated-count': '0', 'skipped-count': '0', skipped: [], 'has-errors': 'true', @@ -49623,6 +50015,8 @@ async function run(_options, _cmd, ctx) { writeGithubActionsOutputs({ 'added-count': '0', 'deleted-count': '0', + 'moved-count': '0', + 'updated-count': '0', 'skipped-count': '0', skipped: [], 'has-errors': 'false', @@ -49630,10 +50024,16 @@ async function run(_options, _cmd, ctx) { return; } - const { totalAdded, totalDeleted, totalSkipped, skipped } = printSyncResults(results); + const { totalAdded, totalDeleted, totalMoved, totalUpdated, totalSkipped, skipped } = + printSyncResults(results); console.log(); - const total = totalAdded + totalDeleted; + const total = totalAdded + totalDeleted + totalMoved + totalUpdated; + const extra = [ + totalMoved > 0 ? `${totalMoved} moved` : null, + totalUpdated > 0 ? `${totalUpdated} updated` : null, + ].filter(Boolean); + const extraNote = extra.length ? `, ${extra.join(', ')}` : ''; // A skip means a page couldn't be written where it should've gone — either // a spec-crafted path trying to escape reference/, or the destination // already existing in a way sync's own bookkeeping didn't expect. Neither @@ -49641,17 +50041,19 @@ async function run(_options, _cmd, ctx) { // and oas:validate already fail on a real problem, rather than leaving it // to whoever wraps this command in CI to notice and fail on it themselves. if (totalSkipped > 0) { - const syncedNote = total > 0 ? ` (${totalAdded} added, ${totalDeleted} deleted)` : ''; + const syncedNote = total > 0 ? ` (${totalAdded} added, ${totalDeleted} deleted${extraNote})` : ''; error(`${totalSkipped} ${totalSkipped === 1 ? 'page' : 'pages'} skipped${syncedNote} — see above for which, and why.`); } else if (total === 0) { ok('Reference pages are already in sync.'); } else { - ok(`Synced: ${totalAdded} added, ${totalDeleted} deleted.`); + ok(`Synced: ${totalAdded} added, ${totalDeleted} deleted${extraNote}.`); } writeGithubActionsOutputs({ 'added-count': String(totalAdded), 'deleted-count': String(totalDeleted), + 'moved-count': String(totalMoved), + 'updated-count': String(totalUpdated), 'skipped-count': String(totalSkipped), skipped, 'has-errors': String(totalSkipped > 0), @@ -49681,6 +50083,17 @@ function oas_reference_validateAll(files, gitRoot, { fix } = {}) { const oasMap = new Map(); for (const { filename, spec } of oasFiles) { oasMap.set(filename, { spec, ops: extractOperations(spec) }); + + // Check: `x-readme.internal`, which ReadMe ignores for page visibility. + for (const location of findIgnoredInternalExtensions(spec)) { + results.push({ + file: `reference/${filename}`, + rule: oas_reference_name, + severity: 'warning', + message: `"x-readme.internal" is ignored by ReadMe (${location}); use "x-internal" instead to hide pages`, + fixable: false, + }); + } } // Collect all reference pages with api frontmatter. @@ -49752,8 +50165,11 @@ function oas_reference_validateAll(files, gitRoot, { fix } = {}) { } } - // Apply fixes by running the full sync. - if (fix && results.length > 0) { + // Apply fixes by running the full sync — but only when something reported + // is actually fixable. The sync adds, deletes, moves, hides and reorders + // reference files, which is far too much to do on the strength of an + // unfixable warning (`x-readme.internal`, a page pointing at a missing spec). + if (fix && results.some((r) => r.fixable)) { const syncResults = syncOas(gitRoot); if (syncResults) { for (const r of results) { diff --git a/src/commands/oas-sync.js b/src/commands/oas-sync.js index bf14316..dcc5a84 100644 --- a/src/commands/oas-sync.js +++ b/src/commands/oas-sync.js @@ -116,6 +116,60 @@ function resolveLocalPathItemRef(entry, spec) { return finish(); } +/** + * Resolve the `x-internal` extension for an operation the way the platform + * does on OAS upload: an operation-level value wins, falling back to the + * spec root. `present` is false when neither sets it, in which case the + * page's visibility is left to whoever owns it (new pages default to + * visible, existing pages keep whatever `hidden` they already have). + * `x-readme: { internal: true }` is deliberately not read — the platform's + * page sync only honors the bare `x-internal` key. + */ +function resolveXInternal(operation, spec) { + if (operation && 'x-internal' in operation) return { present: true, value: operation['x-internal'] }; + if (spec && 'x-internal' in spec) return { present: true, value: spec['x-internal'] }; + return { present: false, value: undefined }; +} + +/** + * Find every `x-readme: { internal: ... }` in a spec. The `oas` package + * documents it as an alternative spelling of `x-internal`, but the platform's + * page sync never reads it (see `resolveXInternal`), so it silently has no + * effect. Returns human-readable locations (`root`, `GET /pets`, + * `webhook POST newPet`) for lint to warn about. + */ +export function findIgnoredInternalExtensions(spec) { + const hasInternal = (obj) => { + const xReadme = obj?.['x-readme']; + return !!xReadme && typeof xReadme === 'object' && 'internal' in xReadme; + }; + + const locations = []; + if (hasInternal(spec)) locations.push('root'); + + for (const [entries, isWebhook] of [[spec?.paths, false], [spec?.webhooks, true]]) { + for (const [name, rawItem] of Object.entries(entries || {})) { + for (const [method, operation] of Object.entries(resolveLocalPathItemRef(rawItem, spec) || {})) { + if (!HTTP_METHODS.has(method) || !hasInternal(operation)) continue; + locations.push(`${isWebhook ? 'webhook ' : ''}${method.toUpperCase()} ${name}`); + } + } + } + return locations; +} + +/** + * Read a root-level ReadMe extension, in the same precedence as the `oas` + * package's `getExtension()` with no operation: `x-readme.`, then + * `x-`, then a bare ``. + */ +function getRootExtension(spec, name) { + const xReadme = spec?.['x-readme']; + if (xReadme && typeof xReadme === 'object' && name in xReadme) return xReadme[name]; + if (spec && `x-${name}` in spec) return spec[`x-${name}`]; + return spec?.[name]; +} + /** * Extract operations from an OAS spec's `paths`, plus its OAS 3.1 `webhooks` * (callouts the API itself makes to a client-registered URL, not endpoints the @@ -144,9 +198,11 @@ export function extractOperations(spec) { operationId, summary: operation.summary || null, description: operation.description || null, - tag: (operation.tags && operation.tags[0]) || null, + // The platform groups by the first *non-empty* tag. + tag: (Array.isArray(operation.tags) && operation.tags.find((t) => t)) || null, path: pathStr, isWebhook, + xInternal: resolveXInternal(operation, spec), }); } } @@ -265,7 +321,7 @@ function stringifyFrontmatter(frontmatter) { return matter.stringify('', frontmatter).replace(/\n+$/, ''); } -function buildPageContent({ oasFilename, operationId, isWebhook }) { +function buildPageContent({ oasFilename, operationId, isWebhook, hidden = false }) { const frontmatter = { api: { file: oasFilename, @@ -275,19 +331,12 @@ function buildPageContent({ oasFilename, operationId, isWebhook }) { // what the platform stamps on a page generated from `webhooks`. ...(isWebhook ? { webhook: true } : {}), }, - // Mirror the platform's OAS-upload behavior: a newly added endpoint is - // always written `hidden: false`, even when its tag and siblings are - // `hidden: true`. The backend does not infer this from a missing field, so - // it must be written explicitly. - // - // @todo Honor the `x-internal` OpenAPI extension for page visibility, to - // match gitto#2095 (RM-4616 / CX-3303): resolve `hidden` from operation-level - // `x-internal`, falling back to root-level, else false; and hide a tag's - // index page when all of its operations are `x-internal: true`. Deferred to - // keep oas:sync create-only — the resync-side rules (re-applying x-internal - // to existing pages, parent hide-ratchet) would require mutating existing - // pages, which this command intentionally never does. - hidden: false, + // The backend does not infer visibility from a missing field, so it's + // always written explicitly: `x-internal` when the spec sets it (see + // `resolveXInternal`), otherwise `false` — mirroring the platform's + // OAS-upload, which writes a new endpoint visible even when its tag and + // siblings are hidden. + hidden, }; return stringifyFrontmatter(frontmatter); @@ -441,6 +490,133 @@ function reserveSlug(takenSlugs, base) { return chosen; } +/** + * Rewrite a page's frontmatter in place, keeping its body. `mutate` receives + * a copy of the parsed frontmatter and edits it. Returns true if the file + * changed. A copy matters: gray-matter caches parse results by input string, + * so mutating the returned `data` would poison later parses of that content. + */ +function updateFrontmatter(filePath, mutate) { + const content = fs.readFileSync(filePath, 'utf-8'); + const parsed = matter(content); + const data = structuredClone(parsed.data); + mutate(data); + const next = parsed.content.trim() + ? matter.stringify(parsed.content, data) + : stringifyFrontmatter(data); + if (next === content || JSON.stringify(data) === JSON.stringify(parsed.data)) return false; + fs.writeFileSync(filePath, next); + return true; +} + +/** + * Merge an OAS-derived slug order into an existing `_order.yaml` list, the + * way the platform does (gitto's `applyOASOrder`): only the slots already + * held by one of `orderedSlugs` are refilled, left to right, in OAS order; + * every other entry (hand-authored pages, other APIs) keeps its position. + * Slugs not yet listed are inserted right after the last refilled slot, or + * appended when none of them are listed yet. + */ +export function applyOASOrder(currentOrder, orderedSlugs) { + const desired = [...new Set(orderedSlugs)]; + const desiredSet = new Set(desired); + // A hand-edited _order.yaml can list the same slug more than once. Collapse + // duplicates up front so each slot corresponds to exactly one distinct slug; + // otherwise there are more slots than slugs to refill them with, and the + // surplus slots would be filled with `undefined`. + const order = [...new Set(currentOrder)]; + if (!order.length) return desired; + + const slots = order.map((s, i) => (desiredSet.has(s) ? i : -1)).filter((i) => i > -1); + if (!slots.length) return [...order, ...desired]; + + const remaining = [...desired]; + for (const index of slots) order[index] = remaining.shift(); + if (remaining.length) order.splice(slots.at(-1) + 1, 0, ...remaining); + return order; +} + +const INDEX_FILES = ['index.md', 'index.mdx', 'index.html']; + +/** + * Whether a tag folder's category page looks untouched since it was + * generated (mirrors gitto's `isAutoGeneratedParentPage`): no body, no + * frontmatter beyond title/hidden/excerpt, a title that slugifies to the + * folder name (allowing a `-N` uniqueness suffix), and an excerpt, if any, + * that is text the spec supplies. + * + * The excerpt needs care. The platform never writes one, but this CLI stamps + * the tag's description as `excerpt` on the pages it generates (see + * `buildTagIndexContent`), so its presence alone can't mean "hand-edited". + * Nothing records what a page was generated from, and by the time a folder + * is being cleaned up its tag has often left the spec, so the excerpt can't + * be checked against "its" tag either. What can be checked is whether the + * current spec still supplies that exact text under *any* tag + * (`specDescriptions`): a generated excerpt is always lifted from + * `tags[].description`, and a retag that renames a tag usually keeps its + * description, so this recognizes generated pages across the common rename. + * An excerpt the spec no longer supplies could be a description that left + * the spec, or a person's edit; with no way to tell them apart, the page is + * reported as hand-edited and `cleanupTagFolder` flattens it rather than + * deleting it. A stale page can be removed by hand; a deleted edit is gone. + */ +function isGeneratedTagIndex(indexPath, specDescriptions) { + let parsed; + try { + parsed = matter(fs.readFileSync(indexPath, 'utf-8')); + } catch { + return false; + } + const { data, content } = parsed; + if (content.trim()) return false; + if (typeof data.title !== 'string' || !('hidden' in data)) return false; + if (Object.keys(data).some((k) => !['title', 'hidden', 'excerpt'].includes(k))) return false; + if ('excerpt' in data && !specDescriptions.has(data.excerpt)) return false; + + const dirSlug = slugifyFolder(path.basename(path.dirname(indexPath))); + const titleSlug = slugifyFolder(data.title); + return dirSlug === titleSlug || new RegExp(`^${titleSlug.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}-\\d+$`).test(dirSlug); +} + +/** + * After `apply-tag-changes` moves pages out of a tag folder, clean up the + * folder if it's now empty (gitto's `cleanupParentDirectory`). A generated + * category page is deleted along with its folder; a hand-edited one is kept + * by flattening `tag/index.md` into a sibling `tag.md` (same slug, so the + * parent `_order.yaml` entry still applies). + */ +function cleanupTagFolder(dir, { refDir, specDescriptions, takenSlugs, changes }) { + let entries; + try { + entries = fs.readdirSync(dir); + } catch { + return; + } + const others = entries.filter((e) => !INDEX_FILES.includes(e) && e !== '_order.yaml'); + if (others.length) return; + + const parentDir = path.dirname(dir); + const slug = path.basename(dir); + const indexFile = entries.find((e) => INDEX_FILES.includes(e)); + + if (!indexFile || isGeneratedTagIndex(path.join(dir, indexFile), specDescriptions)) { + fs.rmSync(dir, { recursive: true, force: true }); + removeFromOrder(path.join(parentDir, '_order.yaml'), slug); + if (indexFile) { + releaseSlug(takenSlugs, slug); + changes.deleted.push(path.relative(refDir, path.join(dir, indexFile))); + } + return; + } + + const from = path.join(dir, indexFile); + const to = path.join(parentDir, `${slug}${path.extname(indexFile)}`); + if (fs.existsSync(to)) return; + fs.renameSync(from, to); + fs.rmSync(dir, { recursive: true, force: true }); + changes.moved.push({ from: path.relative(refDir, from), to: path.relative(refDir, to) }); +} + /** * Run the sync for a single OAS file. Returns changes for that file. * @@ -475,7 +651,12 @@ function syncOneOas(refDir, oasFilename, spec, takenSlugs) { ); } - const changes = { added: [], deleted: [], skipped: [] }; + const changes = { added: [], deleted: [], moved: [], updated: [], skipped: [] }; + + // Root-only opt-ins; only an explicit `true` counts, so a missing or + // malformed value leaves existing placement and order alone. + const applyTagChanges = getRootExtension(spec, 'apply-tag-changes') === true; + const applyEndpointOrder = getRootExtension(spec, 'apply-endpoint-order') === true; // Tag descriptions from the spec's top-level `tags` array, used for the // per-tag category landing page (index.md). @@ -484,6 +665,9 @@ function syncOneOas(refDir, oasFilename, spec, takenSlugs) { .filter((t) => t && t.name) .map((t) => [t.name, t.description || null]), ); + // Every description the spec supplies, regardless of which tag: what an + // emptied tag folder's excerpt is checked against (see `isGeneratedTagIndex`). + const specDescriptions = new Set([...tagDescriptions.values()].filter(Boolean)); // Deletes: pages referencing operations that no longer exist. for (const [opId, page] of pagesByOpId) { @@ -537,11 +721,14 @@ function syncOneOas(refDir, oasFilename, spec, takenSlugs) { ...[...groupsByFolder.keys()].filter((folder) => !declaredOrder.includes(folder)), ]; + const groupDirs = new Map(); + const createdIndexes = new Set(); for (const folder of orderedFolders) { const { title, description } = groupsByFolder.get(folder); const actualFolder = foldersBySlug.get(folder) || folder; const pageDir = path.join(refDir, infoTitle, actualFolder); if (!isWithin(refDir, pageDir)) continue; + groupDirs.set(folder, pageDir); const indexPath = path.join(pageDir, 'index.md'); if (!fs.existsSync(indexPath)) { @@ -549,15 +736,80 @@ function syncOneOas(refDir, oasFilename, spec, takenSlugs) { fs.mkdirSync(pageDir, { recursive: true }); fs.writeFileSync(indexPath, buildTagIndexContent(title, description)); changes.added.push(path.relative(refDir, indexPath)); + createdIndexes.add(indexPath); // The category page's slug is the folder name; reserve it so no operation // takes it. Only when just-created — an existing index.md was already // counted by collectReferenceSlugs's initial disk walk. takeSlug(takenSlugs, actualFolder); + } else if (applyTagChanges) { + // With `apply-tag-changes`, the spec owns the category page's title and + // excerpt too (a tag with no description clears the excerpt). Its body + // and any other frontmatter are the user's and are kept. + const updated = updateFrontmatter(indexPath, (data) => { + data.title = title; + if (description) data.excerpt = description; + else delete data.excerpt; + }); + if (updated) changes.updated.push(path.relative(refDir, indexPath)); } addToOrder(path.join(refDir, infoTitle, '_order.yaml'), actualFolder); addToOrder(path.join(refDir, '_order.yaml'), infoTitle); } + // Where each of this spec's operations ends up this run, by operationKey. + const opPaths = new Map(); + const vacatedDirs = new Set(); + + // Existing pages: by default they stay wherever they are. Two opt-ins can + // touch them: `x-internal` (visibility) and `apply-tag-changes` (placement). + for (const [key, op] of specOps) { + const page = pagesByOpId.get(key); + if (!page) continue; + let filePath = page.filePath; + + // `apply-tag-changes`: a page still inside this API's category follows + // its tag's folder, even if it was hand-moved or nested elsewhere in the + // category. A page moved to another category is the user's call and is + // never touched. Legacy pages literally named index.md are left alone — + // moving one would turn it into the destination folder's category page. + const targetDir = groupDirs.get(operationGroup(op).folder); + if ( + applyTagChanges && + targetDir && + isWithin(apiDir, filePath) && + path.basename(filePath) !== 'index.md' && + path.dirname(filePath) !== targetDir + ) { + const target = path.join(targetDir, path.basename(filePath)); + if (fs.existsSync(target)) { + changes.skipped.push({ path: path.relative(refDir, target), operationId: op.operationId }); + } else { + const fromDir = path.dirname(filePath); + const slug = path.basename(filePath, '.md'); + fs.mkdirSync(targetDir, { recursive: true }); + fs.renameSync(filePath, target); + removeFromOrder(path.join(fromDir, '_order.yaml'), slug); + addToOrder(path.join(targetDir, '_order.yaml'), slug); + vacatedDirs.add(fromDir); + changes.moved.push({ from: page.relativePath, to: path.relative(refDir, target) }); + filePath = target; + } + } + + // `x-internal`, when the spec sets it (operation or root), decides the + // page's visibility in both directions. When it's absent the page keeps + // its own `hidden` — removing the extension never unhides a page. + if (op.xInternal.present) { + const hidden = Boolean(op.xInternal.value); + const updated = updateFrontmatter(filePath, (data) => { + data.hidden = hidden; + }); + if (updated) changes.updated.push(path.relative(refDir, filePath)); + } + + opPaths.set(key, filePath); + } + // Adds: operation pages with no page yet. Title/excerpt are owned by the OAS // spec at render time, so generated pages carry only the api reference. Slugs // are lowercased to match the platform's OAS-upload output. @@ -580,14 +832,82 @@ function syncOneOas(refDir, oasFilename, spec, takenSlugs) { } fs.mkdirSync(pageDir, { recursive: true }); - const content = buildPageContent({ oasFilename, operationId: op.operationId, isWebhook: op.isWebhook }); + const content = buildPageContent({ + oasFilename, + operationId: op.operationId, + isWebhook: op.isWebhook, + hidden: op.xInternal.present ? Boolean(op.xInternal.value) : false, + }); fs.writeFileSync(pagePath, content); addToOrder(path.join(pageDir, '_order.yaml'), slug); changes.added.push(path.relative(refDir, pagePath)); + opPaths.set(key, pagePath); + } + + // `x-internal` on category pages: a folder whose operations are *all* + // internal gets its category page hidden too. This only ever hides — a + // category page is never unhidden by sync, so a manual `hidden: true` + // survives. Mirroring the platform, a category page created this run + // counts any truthy `x-internal`; an existing one needs an explicit `true`. + const childrenByDir = new Map(); + for (const [key, filePath] of opPaths) { + const dir = path.dirname(filePath); + if (!childrenByDir.has(dir)) childrenByDir.set(dir, []); + childrenByDir.get(dir).push(specOps.get(key).xInternal); + } + for (const [dir, children] of childrenByDir) { + const indexPath = path.join(dir, 'index.md'); + if (!fs.existsSync(indexPath)) continue; + const isNew = createdIndexes.has(indexPath); + const allHidden = children.every((x) => x.present && (isNew ? Boolean(x.value) : x.value === true)); + if (!allHidden) continue; + const updated = updateFrontmatter(indexPath, (data) => { + data.hidden = true; + }); + if (updated && !isNew) changes.updated.push(path.relative(refDir, indexPath)); } + // Tag folders emptied by `apply-tag-changes` moves. Never the API's own + // category folder, and never a folder an operation still lives in. + for (const dir of vacatedDirs) { + if (!isWithin(apiDir, dir) || childrenByDir.has(dir)) continue; + cleanupTagFolder(dir, { refDir, specDescriptions, takenSlugs, changes }); + } + + // `apply-endpoint-order`: reorder each folder's operation pages to match + // the order they're declared in the spec (paths, then webhooks). Only this + // API's pages are reordered, and only among the `_order.yaml` slots they + // already hold — other entries keep their place. Files never move. + if (applyEndpointOrder) { + const orderByDir = new Map(); + for (const key of specOps.keys()) { + const filePath = opPaths.get(key); + if (!filePath || !isWithin(apiDir, filePath)) continue; + // A legacy page named index.md is ordered in its parent, by folder name. + const isIndex = path.basename(filePath) === 'index.md'; + const dir = isIndex ? path.dirname(path.dirname(filePath)) : path.dirname(filePath); + const slug = isIndex ? path.basename(path.dirname(filePath)) : path.basename(filePath, '.md'); + if (!orderByDir.has(dir)) orderByDir.set(dir, []); + orderByDir.get(dir).push(slug); + } + for (const [dir, slugs] of orderByDir) { + const orderPath = path.join(dir, '_order.yaml'); + const current = fs.existsSync(orderPath) ? parseOrderYaml(fs.readFileSync(orderPath, 'utf-8')) : []; + const next = applyOASOrder(current, slugs); + if (next.join('\n') === current.join('\n')) continue; + writeOrderYaml(orderPath, next); + changes.updated.push(path.relative(refDir, orderPath)); + } + } + + // Two passes can each touch the same tag page in one run (`apply-tag-changes` + // syncing its title/excerpt, then `x-internal` hiding it once every operation + // in it is internal). Report each file once so the printed list and the + // `updated-count` output reflect files, not writes. + changes.updated = [...new Set(changes.updated)]; + return changes; } @@ -600,7 +920,8 @@ function syncOneOas(refDir, oasFilename, spec, takenSlugs) { * * @param {string | { cwd?: string }} input Repo root path, or `{ cwd }` object. * @returns {null | Array<{ filename: string, spec: object, opCount: number, - * changes: { added: string[], deleted: string[] } }>} + * changes: { added: string[], deleted: string[], moved: { from, to }[], + * updated: string[], skipped: { path, operationId }[] } }>} * Returns null if there's no reference/ dir or no specs. */ export function syncOas(input) { @@ -632,19 +953,27 @@ export function syncOas(input) { * programmatic API above. * * @param {ReturnType} results - * @returns {{ totalAdded: number, totalDeleted: number, totalSkipped: number, + * @returns {{ totalAdded: number, totalDeleted: number, totalMoved: number, + * totalUpdated: number, totalSkipped: number, * skipped: Array<{ filename: string, path: string, operationId: string }> }} */ export function printSyncResults(results) { let totalAdded = 0; let totalDeleted = 0; + let totalMoved = 0; + let totalUpdated = 0; let totalSkipped = 0; const skipped = []; for (const { filename, spec, opCount, changes } of results) { const title = spec.info?.title || filename; const hasChanges = - changes.added.length + changes.deleted.length + changes.skipped.length > 0; + changes.added.length + + changes.deleted.length + + changes.moved.length + + changes.updated.length + + changes.skipped.length > + 0; const dot = hasChanges ? styles.warn('●') : styles.success('●'); console.log(); @@ -660,6 +989,12 @@ export function printSyncResults(results) { for (const file of changes.deleted) { console.log(` ${styles.err('−')} Deleted ${file}`); } + for (const { from, to } of changes.moved) { + console.log(` ${styles.warn('→')} Moved ${from} to ${to}`); + } + for (const file of changes.updated) { + console.log(` ${styles.warn('~')} Updated ${file}`); + } for (const { path: file, operationId } of changes.skipped) { console.log( ` ${styles.warn('!')} Skipped ${file} for "${operationId}" (destination already exists)`, @@ -669,10 +1004,12 @@ export function printSyncResults(results) { totalAdded += changes.added.length; totalDeleted += changes.deleted.length; + totalMoved += changes.moved.length; + totalUpdated += changes.updated.length; totalSkipped += changes.skipped.length; } - return { totalAdded, totalDeleted, totalSkipped, skipped }; + return { totalAdded, totalDeleted, totalMoved, totalUpdated, totalSkipped, skipped }; } export async function run(_options, _cmd, ctx) { @@ -684,6 +1021,8 @@ export async function run(_options, _cmd, ctx) { writeGithubActionsOutputs({ 'added-count': '0', 'deleted-count': '0', + 'moved-count': '0', + 'updated-count': '0', 'skipped-count': '0', skipped: [], 'has-errors': 'true', @@ -698,6 +1037,8 @@ export async function run(_options, _cmd, ctx) { writeGithubActionsOutputs({ 'added-count': '0', 'deleted-count': '0', + 'moved-count': '0', + 'updated-count': '0', 'skipped-count': '0', skipped: [], 'has-errors': 'false', @@ -705,10 +1046,16 @@ export async function run(_options, _cmd, ctx) { return; } - const { totalAdded, totalDeleted, totalSkipped, skipped } = printSyncResults(results); + const { totalAdded, totalDeleted, totalMoved, totalUpdated, totalSkipped, skipped } = + printSyncResults(results); console.log(); - const total = totalAdded + totalDeleted; + const total = totalAdded + totalDeleted + totalMoved + totalUpdated; + const extra = [ + totalMoved > 0 ? `${totalMoved} moved` : null, + totalUpdated > 0 ? `${totalUpdated} updated` : null, + ].filter(Boolean); + const extraNote = extra.length ? `, ${extra.join(', ')}` : ''; // A skip means a page couldn't be written where it should've gone — either // a spec-crafted path trying to escape reference/, or the destination // already existing in a way sync's own bookkeeping didn't expect. Neither @@ -716,17 +1063,19 @@ export async function run(_options, _cmd, ctx) { // and oas:validate already fail on a real problem, rather than leaving it // to whoever wraps this command in CI to notice and fail on it themselves. if (totalSkipped > 0) { - const syncedNote = total > 0 ? ` (${totalAdded} added, ${totalDeleted} deleted)` : ''; + const syncedNote = total > 0 ? ` (${totalAdded} added, ${totalDeleted} deleted${extraNote})` : ''; styles.error(`${totalSkipped} ${totalSkipped === 1 ? 'page' : 'pages'} skipped${syncedNote} — see above for which, and why.`); } else if (total === 0) { styles.ok('Reference pages are already in sync.'); } else { - styles.ok(`Synced: ${totalAdded} added, ${totalDeleted} deleted.`); + styles.ok(`Synced: ${totalAdded} added, ${totalDeleted} deleted${extraNote}.`); } writeGithubActionsOutputs({ 'added-count': String(totalAdded), 'deleted-count': String(totalDeleted), + 'moved-count': String(totalMoved), + 'updated-count': String(totalUpdated), 'skipped-count': String(totalSkipped), skipped, 'has-errors': String(totalSkipped > 0), diff --git a/src/validators/oas-reference.js b/src/validators/oas-reference.js index a95d4f2..82dbf73 100644 --- a/src/validators/oas-reference.js +++ b/src/validators/oas-reference.js @@ -1,7 +1,14 @@ import fs from 'node:fs'; import path from 'node:path'; import matter from 'gray-matter'; -import { findOasFiles, extractOperations, collectExistingPages, syncOas, operationKey } from '../commands/oas-sync.js'; +import { + findOasFiles, + extractOperations, + collectExistingPages, + syncOas, + operationKey, + findIgnoredInternalExtensions, +} from '../commands/oas-sync.js'; export const name = 'oas-reference'; @@ -16,6 +23,17 @@ export function validateAll(files, gitRoot, { fix } = {}) { const oasMap = new Map(); for (const { filename, spec } of oasFiles) { oasMap.set(filename, { spec, ops: extractOperations(spec) }); + + // Check: `x-readme.internal`, which ReadMe ignores for page visibility. + for (const location of findIgnoredInternalExtensions(spec)) { + results.push({ + file: `reference/${filename}`, + rule: name, + severity: 'warning', + message: `"x-readme.internal" is ignored by ReadMe (${location}); use "x-internal" instead to hide pages`, + fixable: false, + }); + } } // Collect all reference pages with api frontmatter. @@ -87,8 +105,11 @@ export function validateAll(files, gitRoot, { fix } = {}) { } } - // Apply fixes by running the full sync. - if (fix && results.length > 0) { + // Apply fixes by running the full sync — but only when something reported + // is actually fixable. The sync adds, deletes, moves, hides and reorders + // reference files, which is far too much to do on the strength of an + // unfixable warning (`x-readme.internal`, a page pointing at a missing spec). + if (fix && results.some((r) => r.fixable)) { const syncResults = syncOas(gitRoot); if (syncResults) { for (const r of results) { diff --git a/test/oas-reference.test.js b/test/oas-reference.test.js index 704b24e..0d1b5eb 100644 --- a/test/oas-reference.test.js +++ b/test/oas-reference.test.js @@ -1,5 +1,7 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; import { collectFiles } from '../src/utils/lint.js'; import { validateAll } from '../src/validators/oas-reference.js'; import { makeRepo, rmRepo } from './helpers.js'; @@ -91,3 +93,71 @@ test('a path page and a webhook page sharing an operationId are both recognized, rmRepo(root); } }); + +test('x-readme.internal is warned about at the root and on operations, x-internal is not', () => { + const spec = JSON.stringify({ + openapi: '3.1.0', + info: { title: 'Pets' }, + 'x-readme': { internal: true }, + paths: { + '/pets': { + get: { operationId: 'listPets', 'x-readme': { internal: true } }, + post: { operationId: 'addPet', 'x-internal': true }, + }, + }, + webhooks: { newPet: { post: { operationId: 'newPet', 'x-readme': { internal: false } } } }, + }); + const root = makeRepo({ 'reference/pets.json': spec }); + try { + const res = validateAll(collectFiles(root), root, {}).filter((r) => + r.message.includes('x-readme.internal'), + ); + assert.deepEqual( + res.map((r) => r.message.match(/\((.+?)\)/)[1]), + ['root', 'GET /pets', 'webhook POST newPet'], + ); + assert.ok(res.every((r) => r.file === 'reference/pets.json' && r.severity === 'warning' && !r.fixable)); + } finally { + rmRepo(root); + } +}); + +test('lint --fix leaves the reference alone when every finding is unfixable', () => { + // The only finding is the unfixable `x-readme.internal` warning; the page + // for the one operation already exists, so nothing is missing either. + const spec = JSON.stringify({ + openapi: '3.1.0', + info: { title: 'Pets' }, + 'x-readme': { internal: true }, + paths: { '/pets': { get: { operationId: 'listPets' } } }, + }); + const page = '---\napi:\n file: pets.json\n operationId: listPets\nhidden: false\n---\n'; + const root = makeRepo({ 'reference/pets.json': spec, 'reference/Pets/pets/listpets.md': page }); + try { + const res = validateAll(collectFiles(root), root, { fix: true }); + assert.equal(res.length, 1); + assert.equal(res[0].fixable, false); + assert.equal(res[0].message.endsWith('(fixed)'), false); + // Had the sync run, it would have backfilled the category page and the + // _order.yaml files around the existing operation page. + assert.equal(fs.existsSync(path.join(root, 'reference/Pets/pets/index.md')), false); + assert.equal(fs.existsSync(path.join(root, 'reference/_order.yaml')), false); + } finally { + rmRepo(root); + } + + // Control: the same spec with a fixable finding (a missing page) does sync. + const fixableRoot = makeRepo({ 'reference/pets.json': spec }); + try { + const res = validateAll(collectFiles(fixableRoot), fixableRoot, { fix: true }); + const missing = res.find((r) => r.message.includes('Missing page')); + assert.ok(missing && missing.fixable); + assert.ok(missing.message.endsWith('(fixed)')); + assert.ok(fs.existsSync(path.join(fixableRoot, 'reference/Pets/pets/listpets.md'))); + // The unfixable warning is still reported, but never marked fixed. + const warning = res.find((r) => r.message.includes('x-readme.internal')); + assert.ok(warning && !warning.message.endsWith('(fixed)')); + } finally { + rmRepo(fixableRoot); + } +}); diff --git a/test/oas-sync.test.js b/test/oas-sync.test.js index bf76447..ec34b14 100644 --- a/test/oas-sync.test.js +++ b/test/oas-sync.test.js @@ -935,3 +935,454 @@ test('an inline operation alongside a $ref sibling is not discarded', () => { rmRepo(root); } }); + +// --- x-internal --------------------------------------------------------- + +function fm(root, rel) { + return matter(fs.readFileSync(path.join(root, rel), 'utf-8')).data; +} + +function order(root, rel) { + return fs + .readFileSync(path.join(root, rel), 'utf-8') + .trim() + .split('\n') + .map((l) => l.replace(/^- /, '')); +} + +test('x-internal: operation-level value wins over root, absent falls back to root', () => { + const spec = JSON.stringify({ + openapi: '3.0.0', + info: { title: 'Api' }, + 'x-internal': true, + tags: [{ name: 'pets' }], + paths: { + '/a': { get: { operationId: 'a', tags: ['pets'], 'x-internal': false } }, + '/b': { get: { operationId: 'b', tags: ['pets'] } }, + }, + }); + const root = makeRepo({ 'reference/api.json': spec }); + try { + syncOas(root); + assert.equal(fm(root, 'reference/Api/pets/a.md').hidden, false); + assert.equal(fm(root, 'reference/Api/pets/b.md').hidden, true); + // One child visible, so the tag page stays visible. + assert.equal(fm(root, 'reference/Api/pets/index.md').hidden, false); + } finally { + rmRepo(root); + } +}); + +test('x-internal: a new tag page is hidden when every operation in it is internal', () => { + const spec = JSON.stringify({ + openapi: '3.0.0', + info: { title: 'Api' }, + paths: { + '/a': { get: { operationId: 'a', tags: ['secret'], 'x-internal': true } }, + '/b': { get: { operationId: 'b', tags: ['secret'], 'x-internal': true } }, + '/c': { get: { operationId: 'c', tags: ['open'] } }, + }, + }); + const root = makeRepo({ 'reference/api.json': spec }); + try { + syncOas(root); + assert.equal(fm(root, 'reference/Api/secret/index.md').hidden, true); + assert.equal(fm(root, 'reference/Api/open/index.md').hidden, false); + assert.equal(fm(root, 'reference/Api/open/c.md').hidden, false); + } finally { + rmRepo(root); + } +}); + +test('x-internal: resync applies the spec value to existing pages in both directions, keeping the body', () => { + const spec = (value) => + JSON.stringify({ + openapi: '3.0.0', + info: { title: 'Api' }, + paths: { + '/a': { get: { operationId: 'a', tags: ['t'], 'x-internal': value } }, + '/b': { get: { operationId: 'b', tags: ['t'] } }, + }, + }); + const root = makeRepo({ + 'reference/api.json': spec(true), + 'reference/Api/t/index.md': '---\ntitle: t\nhidden: false\n---\n', + 'reference/Api/t/a.md': '---\napi:\n file: api.json\n operationId: a\nhidden: false\n---\nCustom body\n', + 'reference/Api/t/b.md': '---\napi:\n file: api.json\n operationId: b\nhidden: true\n---\n', + }); + try { + let [result] = syncOas(root); + assert.deepEqual(result.changes.updated, ['Api/t/a.md']); + assert.equal(fm(root, 'reference/Api/t/a.md').hidden, true); + assert.match(fs.readFileSync(path.join(root, 'reference/Api/t/a.md'), 'utf-8'), /Custom body/); + // No x-internal on b: its manual hidden: true is preserved. + assert.equal(fm(root, 'reference/Api/t/b.md').hidden, true); + + fs.writeFileSync(path.join(root, 'reference/api.json'), spec(false)); + [result] = syncOas(root); + assert.equal(fm(root, 'reference/Api/t/a.md').hidden, false); + + // Re-running is a no-op. + [result] = syncOas(root); + assert.deepEqual(result.changes.updated, []); + } finally { + rmRepo(root); + } +}); + +test('x-internal: removing the extension does not unhide an existing page', () => { + const root = makeRepo({ + 'reference/api.json': JSON.stringify({ + openapi: '3.0.0', + info: { title: 'Api' }, + paths: { '/a': { get: { operationId: 'a', tags: ['t'] } } }, + }), + 'reference/Api/t/index.md': '---\ntitle: t\nhidden: false\n---\n', + 'reference/Api/t/a.md': '---\napi:\n file: api.json\n operationId: a\nhidden: true\n---\n', + }); + try { + syncOas(root); + assert.equal(fm(root, 'reference/Api/t/a.md').hidden, true); + } finally { + rmRepo(root); + } +}); + +test('x-internal: an existing tag page is hidden once all its operations are internal, and never unhidden', () => { + const spec = (value) => + JSON.stringify({ + openapi: '3.0.0', + info: { title: 'Api' }, + 'x-internal': value, + paths: { '/a': { get: { operationId: 'a', tags: ['t'] } } }, + }); + const root = makeRepo({ + 'reference/api.json': spec(true), + 'reference/Api/t/index.md': '---\ntitle: t\nhidden: false\n---\n', + 'reference/Api/t/a.md': '---\napi:\n file: api.json\n operationId: a\nhidden: false\n---\n', + }); + try { + syncOas(root); + assert.equal(fm(root, 'reference/Api/t/index.md').hidden, true); + + fs.writeFileSync(path.join(root, 'reference/api.json'), spec(false)); + syncOas(root); + assert.equal(fm(root, 'reference/Api/t/a.md').hidden, false); + assert.equal(fm(root, 'reference/Api/t/index.md').hidden, true); + } finally { + rmRepo(root); + } +}); + +// --- apply-tag-changes -------------------------------------------------- + +function retaggedSpec(extra = {}) { + return JSON.stringify({ + openapi: '3.0.0', + info: { title: 'Api' }, + ...extra, + tags: [{ name: 'new', description: 'New tag' }], + paths: { '/a': { get: { operationId: 'a', tags: ['new'] } } }, + }); +} + +const RETAG_FILES = { + 'reference/_order.yaml': '- Api\n', + 'reference/Api/_order.yaml': '- old\n', + 'reference/Api/old/index.md': '---\ntitle: old\nhidden: false\n---\n', + 'reference/Api/old/_order.yaml': '- a\n', + 'reference/Api/old/a.md': '---\napi:\n file: api.json\n operationId: a\nhidden: false\n---\n', +}; + +test('without apply-tag-changes, a retagged page stays where it is', () => { + const root = makeRepo({ ...RETAG_FILES, 'reference/api.json': retaggedSpec() }); + try { + const [result] = syncOas(root); + assert.ok(fs.existsSync(path.join(root, 'reference/Api/old/a.md'))); + assert.equal(fs.existsSync(path.join(root, 'reference/Api/new/a.md')), false); + assert.deepEqual(result.changes.moved, []); + } finally { + rmRepo(root); + } +}); + +test('apply-tag-changes moves a retagged page to its new tag and removes the emptied generated tag folder', () => { + const root = makeRepo({ + ...RETAG_FILES, + 'reference/api.json': retaggedSpec({ 'x-readme': { 'apply-tag-changes': true } }), + }); + try { + const [result] = syncOas(root); + assert.ok(fs.existsSync(path.join(root, 'reference/Api/new/a.md'))); + assert.equal(fs.existsSync(path.join(root, 'reference/Api/old')), false); + assert.deepEqual(result.changes.moved, [{ from: 'Api/old/a.md', to: 'Api/new/a.md' }]); + assert.ok(result.changes.deleted.includes('Api/old/index.md')); + assert.deepEqual(order(root, 'reference/Api/_order.yaml'), ['new']); + assert.deepEqual(order(root, 'reference/Api/new/_order.yaml'), ['a']); + } finally { + rmRepo(root); + } +}); + +test('apply-tag-changes deletes an emptied generated tag folder after a rename that kept the tag\'s description', () => { + // The page was generated when the tag was still called `old`, with the + // description that became its excerpt. The tag has since been renamed to + // `new` but kept that description, so the excerpt is still text the spec + // supplies: the page is recognized as generated and removed, not flattened + // into a stale `old.md` that keeps a sidebar entry alive. + const root = makeRepo({ + ...RETAG_FILES, + 'reference/Api/old/index.md': '---\ntitle: old\nexcerpt: New tag\nhidden: false\n---\n', + 'reference/api.json': retaggedSpec({ 'x-readme': { 'apply-tag-changes': true } }), + }); + try { + const [result] = syncOas(root); + assert.equal(fs.existsSync(path.join(root, 'reference/Api/old')), false); + assert.equal(fs.existsSync(path.join(root, 'reference/Api/old.md')), false); + assert.ok(result.changes.deleted.includes('Api/old/index.md')); + assert.deepEqual(result.changes.moved, [{ from: 'Api/old/a.md', to: 'Api/new/a.md' }]); + assert.deepEqual(order(root, 'reference/Api/_order.yaml'), ['new']); + } finally { + rmRepo(root); + } +}); + +test('apply-tag-changes flattens, never deletes, an emptied tag folder whose excerpt is not text from the spec', () => { + // Only the excerpt differs from a generated page. It may be a person's + // edit, or the description of a tag that has since left the spec; there is + // no way to tell, so the page is kept. + const root = makeRepo({ + ...RETAG_FILES, + 'reference/Api/old/index.md': '---\ntitle: old\nexcerpt: My own words\nhidden: false\n---\n', + 'reference/api.json': retaggedSpec({ 'x-readme': { 'apply-tag-changes': true } }), + }); + try { + const [result] = syncOas(root); + assert.equal(fs.existsSync(path.join(root, 'reference/Api/old')), false); + assert.equal(fm(root, 'reference/Api/old.md').excerpt, 'My own words'); + assert.deepEqual(result.changes.deleted, []); + assert.ok(result.changes.moved.some((m) => m.from === 'Api/old/index.md' && m.to === 'Api/old.md')); + assert.deepEqual(order(root, 'reference/Api/_order.yaml'), ['old', 'new']); + } finally { + rmRepo(root); + } +}); + +test('apply-tag-changes flattens an emptied tag folder whose category page was hand-edited', () => { + const root = makeRepo({ + ...RETAG_FILES, + 'reference/Api/old/index.md': '---\ntitle: old\nhidden: false\n---\nHand-written intro\n', + 'reference/api.json': retaggedSpec({ 'x-apply-tag-changes': true }), + }); + try { + syncOas(root); + assert.equal(fs.existsSync(path.join(root, 'reference/Api/old')), false); + assert.match(fs.readFileSync(path.join(root, 'reference/Api/old.md'), 'utf-8'), /Hand-written intro/); + assert.deepEqual(order(root, 'reference/Api/_order.yaml'), ['old', 'new']); + } finally { + rmRepo(root); + } +}); + +test('apply-tag-changes keeps an old tag folder that still has other pages', () => { + const root = makeRepo({ + ...RETAG_FILES, + 'reference/Api/old/guide.md': '---\ntitle: Guide\n---\nHi\n', + 'reference/api.json': retaggedSpec({ 'x-readme': { 'apply-tag-changes': true } }), + }); + try { + syncOas(root); + assert.ok(fs.existsSync(path.join(root, 'reference/Api/old/index.md'))); + assert.ok(fs.existsSync(path.join(root, 'reference/Api/old/guide.md'))); + assert.ok(fs.existsSync(path.join(root, 'reference/Api/new/a.md'))); + } finally { + rmRepo(root); + } +}); + +test('apply-tag-changes pulls a hand-nested page back to its tag folder, but leaves one moved to another category', () => { + const spec = JSON.stringify({ + openapi: '3.0.0', + info: { title: 'Api' }, + 'x-readme': { 'apply-tag-changes': true }, + paths: { + '/a': { get: { operationId: 'a', tags: ['t'] } }, + '/b': { get: { operationId: 'b', tags: ['t'] } }, + }, + }); + const root = makeRepo({ + 'reference/api.json': spec, + 'reference/Api/t/index.md': '---\ntitle: t\nhidden: false\n---\n', + 'reference/Api/t/custom/index.md': '---\ntitle: Custom\n---\nMine\n', + 'reference/Api/t/custom/a.md': '---\napi:\n file: api.json\n operationId: a\nhidden: false\n---\n', + 'reference/Elsewhere/b.md': '---\napi:\n file: api.json\n operationId: b\nhidden: false\n---\n', + }); + try { + syncOas(root); + assert.ok(fs.existsSync(path.join(root, 'reference/Api/t/a.md'))); + assert.ok(fs.existsSync(path.join(root, 'reference/Elsewhere/b.md'))); + assert.equal(fs.existsSync(path.join(root, 'reference/Api/t/b.md')), false); + // The user's custom parent was hand-edited, so it's flattened, not deleted. + assert.match(fs.readFileSync(path.join(root, 'reference/Api/t/custom.md'), 'utf-8'), /Mine/); + } finally { + rmRepo(root); + } +}); + +test('apply-tag-changes syncs an existing tag page\'s title and excerpt, keeping its body', () => { + const spec = JSON.stringify({ + openapi: '3.0.0', + info: { title: 'Api' }, + 'x-readme': { 'apply-tag-changes': true }, + tags: [{ name: 'Pets', description: 'All about pets' }, { name: 'Plain' }], + paths: { + '/a': { get: { operationId: 'a', tags: ['Pets'] } }, + '/b': { get: { operationId: 'b', tags: ['Plain'] } }, + }, + }); + const root = makeRepo({ + 'reference/api.json': spec, + 'reference/Api/pets/index.md': '---\ntitle: Custom title\nexcerpt: old\nhidden: false\n---\nBody\n', + 'reference/Api/plain/index.md': '---\ntitle: plain\nexcerpt: stale\nhidden: false\n---\n', + }); + try { + syncOas(root); + const pets = matter(fs.readFileSync(path.join(root, 'reference/Api/pets/index.md'), 'utf-8')); + assert.equal(pets.data.title, 'Pets'); + assert.equal(pets.data.excerpt, 'All about pets'); + assert.match(pets.content, /Body/); + const plain = fm(root, 'reference/Api/plain/index.md'); + assert.equal(plain.title, 'Plain'); + assert.equal('excerpt' in plain, false); + } finally { + rmRepo(root); + } +}); + +test('a tag page updated by both apply-tag-changes and x-internal is reported as updated once', () => { + const spec = JSON.stringify({ + openapi: '3.0.0', + info: { title: 'Api' }, + 'x-readme': { 'apply-tag-changes': true }, + 'x-internal': true, + tags: [{ name: 't', description: 'Fresh' }], + paths: { '/a': { get: { operationId: 'a', tags: ['t'] } } }, + }); + const root = makeRepo({ + 'reference/api.json': spec, + 'reference/Api/t/index.md': '---\ntitle: t\nexcerpt: stale\nhidden: false\n---\n', + 'reference/Api/t/a.md': '---\napi:\n file: api.json\n operationId: a\nhidden: false\n---\n', + }); + try { + const [result] = syncOas(root); + // Both passes really did write to it: excerpt synced, then hidden. + const index = fm(root, 'reference/Api/t/index.md'); + assert.equal(index.excerpt, 'Fresh'); + assert.equal(index.hidden, true); + assert.deepEqual(result.changes.updated.slice().sort(), ['Api/t/a.md', 'Api/t/index.md']); + } finally { + rmRepo(root); + } +}); + +test('apply-tag-changes must be exactly true and set at the root', () => { + const root = makeRepo({ + ...RETAG_FILES, + 'reference/api.json': retaggedSpec({ 'x-readme': { 'apply-tag-changes': 'true' } }), + }); + try { + syncOas(root); + assert.ok(fs.existsSync(path.join(root, 'reference/Api/old/a.md'))); + } finally { + rmRepo(root); + } +}); + +// --- apply-endpoint-order ----------------------------------------------- + +function orderedSpec(extra = {}) { + return JSON.stringify({ + openapi: '3.0.0', + info: { title: 'Api' }, + ...extra, + paths: { + '/c': { get: { operationId: 'c', tags: ['t'] } }, + '/a': { get: { operationId: 'a', tags: ['t'] } }, + '/b': { get: { operationId: 'b', tags: ['t'] } }, + }, + }); +} + +const ORDER_FILES = { + 'reference/Api/t/index.md': '---\ntitle: t\nhidden: false\n---\n', + 'reference/Api/t/_order.yaml': '- a\n- guide\n- b\n', + 'reference/Api/t/a.md': '---\napi:\n file: api.json\n operationId: a\nhidden: false\n---\n', + 'reference/Api/t/b.md': '---\napi:\n file: api.json\n operationId: b\nhidden: false\n---\n', + 'reference/Api/t/guide.md': '---\ntitle: Guide\n---\nHi\n', +}; + +test('without apply-endpoint-order, new endpoints are appended and existing order is kept', () => { + const root = makeRepo({ ...ORDER_FILES, 'reference/api.json': orderedSpec() }); + try { + syncOas(root); + assert.deepEqual(order(root, 'reference/Api/t/_order.yaml'), ['a', 'guide', 'b', 'c']); + } finally { + rmRepo(root); + } +}); + +test('apply-endpoint-order reorders endpoints to spec order, leaving other pages in their slots', () => { + const root = makeRepo({ + ...ORDER_FILES, + 'reference/api.json': orderedSpec({ 'x-readme': { 'apply-endpoint-order': true } }), + }); + try { + const [result] = syncOas(root); + assert.deepEqual(order(root, 'reference/Api/t/_order.yaml'), ['c', 'guide', 'a', 'b']); + assert.ok(result.changes.updated.includes('Api/t/_order.yaml')); + + const [again] = syncOas(root); + assert.deepEqual(again.changes.updated, []); + } finally { + rmRepo(root); + } +}); + +test('apply-endpoint-order collapses a duplicated slug in _order.yaml instead of writing "undefined"', () => { + const root = makeRepo({ + ...ORDER_FILES, + 'reference/Api/t/_order.yaml': '- a\n- guide\n- a\n- b\n', + 'reference/api.json': orderedSpec({ 'x-readme': { 'apply-endpoint-order': true } }), + }); + try { + syncOas(root); + const raw = fs.readFileSync(path.join(root, 'reference/Api/t/_order.yaml'), 'utf-8'); + assert.equal(raw.includes('undefined'), false); + assert.deepEqual(order(root, 'reference/Api/t/_order.yaml'), ['c', 'guide', 'a', 'b']); + } finally { + rmRepo(root); + } +}); + +test('applyOASOrder refills only API slots and inserts new slugs after the last one', async () => { + const { applyOASOrder } = await import('../src/commands/oas-sync.js'); + assert.deepEqual(applyOASOrder([], ['b', 'a']), ['b', 'a']); + assert.deepEqual(applyOASOrder(['x', 'y'], ['a']), ['x', 'y', 'a']); + assert.deepEqual(applyOASOrder(['a', 'x', 'b', 'y'], ['b', 'c', 'a']), ['b', 'x', 'c', 'a', 'y']); +}); + +test('applyOASOrder never emits undefined when the current order repeats a slug', async () => { + const { applyOASOrder } = await import('../src/commands/oas-sync.js'); + // More slots than distinct slugs to fill them with. + assert.deepEqual(applyOASOrder(['a', 'a', 'b'], ['b', 'a']), ['b', 'a']); + assert.deepEqual(applyOASOrder(['a', 'x', 'a'], ['a']), ['a', 'x']); + assert.deepEqual(applyOASOrder(['b', 'x', 'b', 'a'], ['a', 'b']), ['a', 'x', 'b']); + // Duplicates in the requested order are collapsed too. + assert.deepEqual(applyOASOrder(['a', 'b'], ['b', 'b', 'a']), ['b', 'a']); + for (const result of [ + applyOASOrder(['a', 'a', 'b'], ['b', 'a']), + applyOASOrder(['a', 'x', 'a'], ['a']), + ]) { + assert.ok(result.every((s) => typeof s === 'string')); + } +});