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
53 changes: 53 additions & 0 deletions .agents/RM-17139/spec.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
# RM-17139: AI Importer (mintlify): entry page slug generated as "docs" — resulting in doubled /docs/docs URL

## Problem

When the AI Importer builds the skeleton for a docs site whose entry page path ends at a generic documentation route such as `/docs`, the CLI derives the ReadMe page slug from that route segment. For Mintlify, `https://www.mintlify.com/docs` becomes a page slug of `docs`, which publishes as `/docs/docs` because ReadMe already nests guide pages under `/docs`.

The same generic route terms can also pollute collision resolution. A category or path segment like `Docs`, `Doc`, or `Documentation` can be prepended to otherwise good slugs, producing values such as `docs-getting-started` instead of preserving `getting-started` where possible.

## Success criteria

1. A source page whose derived slug would be exactly `docs`, `doc`, `documentation`, or `documentations` is emitted with a meaningful fallback slug, `introduction`, instead of the generic route slug.
2. Generic docs route/category terms are normalized before unique-slug resolution, so they are attempted first and do not unnecessarily reserve or pollute later slugs.
- For example, if the organized tree has `Docs` and `AI` categories, run unique-slug assignment for the `Docs` category first so generic docs normalization cannot be forced into polluted slugs by later categories.
3. Generic docs category/path terms are not used as disambiguating prefixes for page slugs. For example, a `Docs / Getting Started` page should prefer `getting-started`, not `docs-getting-started`.
4. If normalization still leaves a slug collision, the CLI keeps the existing collision fallback behavior and suffix style; this ticket does not change global suffix semantics.
5. Non-generic route segments keep current behavior. For example, `/docs/api`, `/docs/getting-started`, and segments like `docs-api` or `documentation-settings` are not rewritten just because they contain a generic docs word.
6. Running the runner repo smoke command `npm run step:skeleton https://www.mintlify.com/docs` uses the CLI worktree and no longer produces problematic `docs.md`, `doc.md`, `documentation.md`, `documentations.md`, or `docs-*` slugs caused solely by generic docs route/category normalization.

## Out of scope

- Runner page formatting, MDX conversion, hydration, or post-skeleton cleanup.
- ReadMe URL routing or published-site behavior outside the generated skeleton slugs.
- Changing global slug collision suffix behavior.
- Fixing unrelated model/Claude invalid-JSON failures that can block a full skeleton smoke run before staging.
- Broad slug quality improvements unrelated to exact generic docs route/category terms.

## Implementation decisions

- Implement the fix in the CLI skeleton slug planning path, not in the runner.
- Normalize slug candidate segments before uniqueness resolution so filenames, `_order.yaml`, frontmatter, and slug logging all derive from one source of truth.
- Treat exact generic docs terms as non-semantic only when they are standalone segments/category labels: `docs`, `doc`, `documentation`, and `documentations`.
- Replace a terminal slug candidate that would be exactly one of those generic terms with `introduction`.
- Ensure generic docs category/path terms are tried/normalized before other slug assignment work so they do not claim useful slugs or force later pages into `docs-*` expansions.
- Process categories with generic docs labels before non-generic categories during unique-slug assignment; for example, `Docs` should be assigned before `AI`.
- Do not rewrite partial matches such as `docs-api` or `documentation-settings`.
- Preserve the CLI's existing final collision fallback behavior, including its current numeric suffix style.

## Proposed test seams

1. Unit test the slug planner with a synthetic tree containing a page at `https://example.com/docs`; assert the assigned slug is `introduction`.
2. Unit test the same behavior for `doc`, `documentation`, and `documentations` terminal paths.
3. Unit test a generic `Docs`/`Documentation` category containing `Getting Started`; assert the slug is `getting-started` when available, not `docs-getting-started`.
4. Unit test a collision where a generic docs category/page competes with another page for the same slug; assert existing collision fallback is used rather than introducing a generic `docs-` prefix.
5. Unit test non-generic cases such as `/docs/api`, `/docs/getting-started`, `docs-api`, and `documentation-settings`; assert current slug behavior is preserved.
6. Smoke validate from the runner repo with `npm run step:skeleton https://www.mintlify.com/docs`, confirming the generated skeleton no longer contains generic docs slugs for the entry page or generic `docs-*` prefixes introduced solely by docs-route normalization.

## Further notes

The runner repo depends on the CLI via the local `@readme/cli` file dependency in this worktree setup, so the final Mintlify smoke should be run from the runner repo after CLI changes are in place.

## Approval

Approved by Xavier on 2026-06-29T07:18:23Z.
26 changes: 22 additions & 4 deletions src/commands/import.js
Original file line number Diff line number Diff line change
Expand Up @@ -4192,7 +4192,7 @@ function buildFrontmatter(topDir, page, slug, pickIcon, opts = {}) {
* // <Overview (Guides)> => 'guides-overview', // expanded (collides)
* // }
*/
function ensureUniqueSlugs(categories) {
export function ensureUniqueSlugs(categories) {
const entries = []
const segmentsFor = (p) => {
if (p._virtualPathSegs && p._virtualPathSegs.length > 0) {
Expand All @@ -4202,10 +4202,18 @@ function ensureUniqueSlugs(categories) {
}
return extractUrlPathSegments(p.url)
}
const normalizedSegmentsFor = (p) => {
const semanticSegments = segmentsFor(p).filter((seg) => !isGenericDocsSegment(seg))
return semanticSegments.length > 0 ? semanticSegments : ['introduction']
}
const expansionSegmentsFor = (p, categorySeg) => {
const pageSegments = normalizedSegmentsFor(p)
if (!categorySeg || isGenericDocsSegment(categorySeg)) return pageSegments
return [categorySeg, ...pageSegments]
}
const walk = (pages, categorySeg) => {
for (const p of pages || []) {
const urlSegs = segmentsFor(p)
const segments = categorySeg ? [categorySeg, ...urlSegs] : urlSegs
const segments = expansionSegmentsFor(p, categorySeg)
// Synthetic/empty-parent nodes have no content. Never let one win a bare
// single-segment slug (e.g. a placeholder `…/vercel-flags/cli` claiming
// `cli`), or it squats the slug a real overview page wants and ReadMe
Expand All @@ -4215,7 +4223,12 @@ function ensureUniqueSlugs(categories) {
if (p.pages) walk(p.pages, categorySeg)
}
}
for (const c of categories || []) {
const genericCategoriesFirst = [...(categories || [])].sort((a, b) => {
const aGeneric = isGenericDocsSegment(a?.title || '')
const bGeneric = isGenericDocsSegment(b?.title || '')
return Number(bGeneric) - Number(aGeneric)
})
for (const c of genericCategoriesFirst) {
const rawTitle = (c?.title || '').trim()
const categorySeg = rawTitle ? kebabCase(rawTitle) : ''
walk(c.pages, categorySeg)
Expand Down Expand Up @@ -4272,6 +4285,11 @@ function ensureUniqueSlugs(categories) {
return result
}

const GENERIC_DOCS_SEGMENTS = new Set(['doc', 'docs', 'documentation', 'documentations'])
function isGenericDocsSegment(segment) {
return GENERIC_DOCS_SEGMENTS.has(kebabCase(segment))
}

// Values YAML interprets as non-strings need quoting when used as _order entries.
const YAML_UNSAFE = /^(?:\d+\.?\d*|true|false|yes|no|on|off|null|~)$/i
function yamlSafeSlug(slug) {
Expand Down
86 changes: 86 additions & 0 deletions src/commands/import.slug-planner.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { ensureUniqueSlugs } from './import.js'

function assignedSlug(categories, page) {
return ensureUniqueSlugs(categories).get(page)
}

test('ensureUniqueSlugs: generic docs terminal paths fall back to introduction', () => {
for (const segment of ['doc', 'docs', 'documentation', 'documentations']) {
const page = { title: segment, url: `https://example.com/${segment}` }
assert.equal(
assignedSlug([{ title: 'Documentation', pages: [page] }], page),
'introduction',
)
}
})

test('ensureUniqueSlugs: terminal generic docs paths use introduction under non-generic categories', () => {
const page = { title: 'Docs', url: 'https://example.com/docs' }

assert.equal(
assignedSlug([{ title: 'Guides', pages: [page] }], page),
'introduction',
)
})

test('ensureUniqueSlugs: category segment disambiguates introduction collisions', () => {
const guidesPage = { title: 'Docs', url: 'https://example.com/docs' }
const apiPage = { title: 'Docs', url: 'https://example.com/docs' }
const slugs = ensureUniqueSlugs([
{ title: 'Guides', pages: [guidesPage] },
{ title: 'API', pages: [apiPage] },
])

assert.equal(slugs.get(guidesPage), 'guides-introduction')
assert.equal(slugs.get(apiPage), 'api-introduction')
})

test('ensureUniqueSlugs: generic docs category does not prefix an available page slug', () => {
const page = { title: 'Getting Started', url: 'https://example.com/docs/getting-started' }

assert.equal(
assignedSlug([{ title: 'Docs', pages: [page] }], page),
'getting-started',
)
})

test('ensureUniqueSlugs: generic docs categories use existing numeric suffix fallback for irreducible collisions', () => {
const first = { title: 'Getting Started', url: 'https://example.com/docs/getting-started' }
const second = { title: 'Getting Started', url: 'https://example.com/documentation/getting-started' }
const slugs = ensureUniqueSlugs([
{ title: 'Docs', pages: [first] },
{ title: 'Documentation', pages: [second] },
])

assert.equal(slugs.get(first), 'getting-started')
assert.equal(slugs.get(second), 'getting-started-2')
})

test('ensureUniqueSlugs: generic docs category is assigned before non-generic categories', () => {
const docsPage = { title: 'Getting Started', url: 'https://example.com/docs/getting-started' }
const aiPage = { title: 'Getting Started', url: 'https://example.com/ai/getting-started' }
const slugs = ensureUniqueSlugs([
{ title: 'AI', pages: [aiPage] },
{ title: 'Docs', pages: [docsPage] },
])

assert.equal(slugs.get(docsPage), 'getting-started')
assert.equal(slugs.get(aiPage), 'ai-getting-started')
})

test('ensureUniqueSlugs: non-generic docs paths keep current base slug behavior', () => {
const api = { title: 'API', url: 'https://example.com/docs/api' }
const gettingStarted = { title: 'Getting Started', url: 'https://example.com/docs/getting-started' }
const docsApi = { title: 'Docs API', url: 'https://example.com/docs-api' }
const documentationSettings = { title: 'Documentation Settings', url: 'https://example.com/documentation-settings' }
const slugs = ensureUniqueSlugs([
{ title: 'Guides', pages: [api, gettingStarted, docsApi, documentationSettings] },
])

assert.equal(slugs.get(api), 'api')
assert.equal(slugs.get(gettingStarted), 'getting-started')
assert.equal(slugs.get(docsApi), 'docs-api')
assert.equal(slugs.get(documentationSettings), 'documentation-settings')
})