diff --git a/docs-app/tests/docs-app/folder-index-redirect-test.gts b/docs-app/tests/docs-app/folder-index-redirect-test.gts new file mode 100644 index 00000000..b3667061 --- /dev/null +++ b/docs-app/tests/docs-app/folder-index-redirect-test.gts @@ -0,0 +1,67 @@ +import { visit } from '@ember/test-helpers'; +import { module, test } from 'qunit'; +import { setupApplicationTest } from 'ember-qunit'; + +/** + * A folder's own URL — `/authoring`, one level inside a group — names a + * real place in the docs, but no document of its own. It lands on the + * folder's first page, the same rule a group's own URL follows. + */ +module('folder index redirects', function (hooks) { + setupApplicationTest(hooks); + + test("a folder's URL lands on its index page, when it has one", async function (assert) { + await visit('/authoring'); + + const router = this.owner.lookup('service:router'); + + assert.strictEqual(router.currentURL, '/authoring/index'); + assert.dom('[data-page-error]').doesNotExist(); + }); + + test("a folder with no index page lands on its first page", async function (assert) { + await visit('/development'); + + const router = this.owner.lookup('service:router'); + + assert.strictEqual(router.currentURL, '/development/rendering-pages'); + assert.dom('[data-page-error]').doesNotExist(); + }); + + test('a trailing slash lands in the same place', async function (assert) { + await visit('/authoring/'); + + const router = this.owner.lookup('service:router'); + + assert.strictEqual(router.currentURL, '/authoring/index'); + }); + + test('the folder is matched case-insensitively, like every other path', async function (assert) { + await visit('/AUTHORING'); + + const router = this.owner.lookup('service:router'); + + assert.strictEqual(router.currentURL, '/authoring/index'); + }); + + // The guard that keeps the redirect from swallowing ordinary navigation: + // a page visit lands on the wildcard's index too, with the page as the + // wildcard param. + test("a page's own URL is left where it is", async function (assert) { + await visit('/authoring/code-fences'); + + const router = this.owner.lookup('service:router'); + + assert.strictEqual(router.currentURL, '/authoring/code-fences'); + assert.dom('[data-page-error]').doesNotExist(); + }); + + test('a path that is neither a page nor a folder still errors', async function (assert) { + await visit('/authoring/not-a-real-page'); + + const router = this.owner.lookup('service:router'); + + assert.strictEqual(router.currentURL, '/authoring/not-a-real-page', 'no redirect'); + assert.dom('[data-page-error]').exists('and the reader is told the page is missing'); + }); +}); diff --git a/docs/navigation/handle-potential-index-visit.gjs.md b/docs/navigation/handle-potential-index-visit.gjs.md index 16b20380..a76e3c7f 100644 --- a/docs/navigation/handle-potential-index-visit.gjs.md +++ b/docs/navigation/handle-potential-index-visit.gjs.md @@ -36,6 +36,14 @@ When a user visits `/Runtime` and the `Runtime` group has pages, they'll be redi It also handles the app's root: on a visit to `/`, there is no group in the URL, so the user is redirected to the first page of the default (first) group. Give your top-level `index` route the same `beforeModel` (e.g. in `routes/index.ts`) to enable this. +## Folders + +A folder inside a group works the same way. `/Runtime/rendering` names a real place in the docs but no document of its own, so it redirects to that folder's first page — just as the group's own URL does. These are URLs readers write by hand and link to, and without this they land on an error page. + +Sorting has already put an `index.md` at the top of the folder that holds it, so a folder with an index page lands there; a folder without one lands on its first ordered page. + +This applies only to a URL that names no page. A page's own URL lands on the wildcard's index route too, and is left exactly where it is. + ## Nested mounts `addRoutes()` may also be called inside nested routes, mounting each group as its own route (see [using the docs plugin multiple times](/development/configuring-docs.md)) — optionally scoped to a group via `addRoutes(this, 'group-name')`, in which case the mount's path is free to differ from the group's name. Either way, call `handlePotentialIndexVisit` in the mount route's `beforeModel` — visiting the mount's URL (e.g. `/guides`) lands on the mount's own index: diff --git a/package.json b/package.json index 71fc980e..651a0785 100644 --- a/package.json +++ b/package.json @@ -172,7 +172,7 @@ "node": ">= 18" }, "volta": { - "node": "24.18.0", + "node": "24.19.0", "pnpm": "10.34.5" }, "publishConfig": { diff --git a/src/browser/components/group-nav.gts b/src/browser/components/group-nav.gts index ea521fe9..fe3a2c75 100644 --- a/src/browser/components/group-nav.gts +++ b/src/browser/components/group-nav.gts @@ -2,6 +2,7 @@ import Component from '@glimmer/component'; import { service } from '@ember/service'; import { docsManager } from '../services/docs.ts'; +import { HOME_GROUP } from '../utils.ts'; import type RouterService from '@ember/routing/router-service'; @@ -65,8 +66,11 @@ export class GroupNav extends Component<{ get groups() { return this.#docs.availableGroups.map((groupName) => { - if (groupName === 'root') { - return { text: this.homeName, value: '/', href: this.rootURL }; + // The co-located pages are a group, but they live in the root URL + // space rather than under their name, so the link is the app's root + // and `@homeName` names it. + if (groupName === HOME_GROUP) { + return { text: this.homeName, value: HOME_GROUP, href: this.rootURL }; } return { @@ -79,13 +83,11 @@ export class GroupNav extends Component<{ }); } - isActive = (subPath: string) => { - if (subPath === '/') return false; - + isActive = (groupName: string) => { // The group is derived from the URL by the docs service (rootURL-aware), // rather than comparing the group name against currentURL directly // (which always failed: 'Docs' never prefixes '/Docs/...'). - return this.#docs.selectedGroup === subPath; + return this.#docs.selectedGroup === groupName; }; get activeClass() { diff --git a/src/browser/router.ts b/src/browser/router.ts index 8e20f82e..5c424160 100644 --- a/src/browser/router.ts +++ b/src/browser/router.ts @@ -4,6 +4,8 @@ import { getOwner } from '@ember/owner'; import { groupNameForRoute, registerScopedRoute, scopedRouteNameFor } from './scoped-routes.ts'; import { docsManager } from './services/docs.ts'; +import type { Page } from '../types.ts'; +import type { DocsService } from './services/docs.ts'; import type { RouterDSL } from '@ember/-internals/routing'; import type Transition from '@ember/routing/transition'; @@ -69,6 +71,58 @@ export function addRoutes( } } +type RouteInfoLike = NonNullable['parent']; + +/** + * The first page of a group, for a visit to the group's own URL. + */ +function landingForGroup(docs: DocsService, groupName: string): Page | undefined { + const first = docs.groupFor(groupName).list[0]; + + if (!first) { + console.warn(`Could not determine first page in group: ${groupName}`); + + return; + } + + return first; +} + +/** + * The first page of a sub-tree, for a visit to its own URL — + * `/Group/sub-folder`, which resolves to no document of its own. + * + * `undefined` for anything that is not a sub-tree visit, including the + * ordinary case: a page visit lands here too, with the page as the + * wildcard, and must be left alone. + */ +function landingForPageTreeVisit( + docs: DocsService, + parent: RouteInfoLike, + wildcardParam: unknown +): Page | undefined { + if (typeof wildcardParam !== 'string' || !wildcardParam) return; + + /** + * The wildcard holds only the part of the URL below the mount, so a mount + * with a path of its own needs its group put back on the front to name a + * sub-tree in the manifest — the same translation + * `docsManager.scopedPagePath` makes for the page it is on. + * + * A scoped mount (`addRoutes(context, groupName)`) names its group in the + * binding. An unscoped nested mount takes the group's name as its path, + * which is the route above the wildcard. A top-level mount has no path of + * its own, so its wildcard already carries the group. + */ + const mountGroup = + (parent ? groupNameForRoute(parent.name) : undefined) ?? + docs.canonicalGroupName(parent?.parent?.localName ?? ''); + + return docs.landingForPageTree( + mountGroup ? `/${mountGroup}/${wildcardParam}` : `/${wildcardParam}` + ); +} + /** * Does our target destination exist? if not, * redirect to the first page on the namespace @@ -120,17 +174,16 @@ export function handlePotentialIndexVisit(context: object, transition: Transitio ) .find((match): match is string => match !== undefined); - if (!groupName) return; - - const group = docs.groupFor(groupName); - - const first = group.list[0]; - - if (!first) { - console.warn(`Could not determine first page in group: ${groupName}`); + /** + * A group's own URL lands on its first page. So does a sub-tree's, one + * level down: `/Group/sub-folder` names a real place in the docs, but + * only a page path resolves to a document. + */ + const first = groupName + ? landingForGroup(docs, groupName) + : landingForPageTreeVisit(docs, parent, wildcardParam); - return; - } + if (!first) return; const router = getOwner(context)?.lookup('service:router'); diff --git a/src/browser/services/docs.ts b/src/browser/services/docs.ts index 8fce6093..56171ab4 100644 --- a/src/browser/services/docs.ts +++ b/src/browser/services/docs.ts @@ -14,7 +14,7 @@ import { APIDocs, CommentQuery } from '../typedoc/renderer.gts'; import { ComponentSignature } from '../typedoc/signature/component.gts'; import { HelperSignature } from '../typedoc/signature/helper.gts'; import { ModifierSignature } from '../typedoc/signature/modifier.gts'; -import { equalsIgnoreCase, samePagePath } from '../utils.ts'; +import { equalsIgnoreCase, findPageTree, firstPageIn, samePagePath } from '../utils.ts'; import { typedocLoader } from './api-docs.ts'; import { getKey } from './lazy-load.ts'; import { selected } from './selected.ts'; @@ -529,6 +529,40 @@ class DocsService { findByPath = (path: string) => { return this.pages.find((page) => samePagePath(page.appRelativePath, path)); }; + + /** + * Where to land when a sub-tree's own URL is visited: its first page — the + * same rule `group.list[0]` applies at a group's root. `/Group/sub-folder` + * is a URL readers write, and it has an obvious destination, but only a + * page path resolves to a document. + * + * Takes a manifest-space app-relative path, and returns `undefined` when + * the path names a page, or names nothing at all. + */ + landingForPageTree = (appRelativePath: string): Page | undefined => { + const groups = this.manifest?.groups ?? []; + + /** + * A path that already names a page is not a sub-tree visit, and must not + * redirect — every page visit lands on the wildcard's index too. + * + * Searched across every group rather than through `findByPath`, which + * looks only in `currentGroup`: that derives from `router.currentURL`, + * which still names the *previous* page while a transition is being + * resolved. Callers here are mid-transition. + */ + for (const group of groups) { + if (group.list.some((page) => samePagePath(page.appRelativePath, appRelativePath))) return; + } + + for (const group of groups) { + const tree = findPageTree(group.tree, appRelativePath); + + if (tree) return firstPageIn(tree); + } + + return undefined; + }; } export type { DocsService }; diff --git a/src/browser/utils.ts b/src/browser/utils.ts index a8690e94..5546c2e9 100644 --- a/src/browser/utils.ts +++ b/src/browser/utils.ts @@ -4,6 +4,14 @@ import { getOwner } from '@ember/owner'; import type { Page, PageTree } from '../types.ts'; import type Owner from '@ember/owner'; +/** + * The co-located pages' group (app/templates, src/templates), as the build + * names it (`displayName` in build/plugins/setup.js's `homeSource`). Its + * pages live in the root URL space rather than under the group's name, so + * its nav link is the app's root. + */ +export const HOME_GROUP = 'Home'; + export function isPageTree(x: Page | PageTree): x is PageTree { return 'pages' in x; } @@ -22,6 +30,43 @@ export function getIndexPage(x: PageTree): Page | undefined { return page; } +/** + * The sub-tree at an app-relative path (`/Group/sub-folder`), searched for + * in `root` and its descendants. A group's own tree is a `PageTree` too, so + * the group's root path matches its tree. + */ +export function findPageTree(root: PageTree, appRelativePath: string): PageTree | undefined { + if (equalsIgnoreCase(root.appRelativePath, appRelativePath)) return root; + + for (const child of root.pages) { + if (!isPageTree(child)) continue; + + const match = findPageTree(child, appRelativePath); + + if (match) return match; + } + + return undefined; +} + +/** + * The first page in a tree, descending into sub-trees until it finds one. + * Sorting has already hoisted `index.md` to the top of the tree that holds + * it, so this is that tree's index page whenever it has one, and its first + * ordered page when it doesn't. + */ +export function firstPageIn(tree: PageTree): Page | undefined { + for (const child of tree.pages) { + if (!isPageTree(child)) return child; + + const nested = firstPageIn(child); + + if (nested) return nested; + } + + return undefined; +} + /** * URLs are conventionally case-insensitive; path/route matching in this * library follows that convention rather than treating paths as opaque, diff --git a/test-apps/custom-root-url/tests/index-redirect-test.ts b/test-apps/custom-root-url/tests/index-redirect-test.ts index c6ebd4e0..c5536fe1 100644 --- a/test-apps/custom-root-url/tests/index-redirect-test.ts +++ b/test-apps/custom-root-url/tests/index-redirect-test.ts @@ -39,6 +39,34 @@ module("Group index redirects under a custom rootURL", function (hooks) { ); }); + // A folder's own URL follows the same rule one level down, and has the + // same rootURL-doubling hazard. + test("visiting a folder root redirects to its first page without doubling the rootURL", async function (assert) { + await visit("/Documentation/sub-folder"); + assert.strictEqual( + currentURL(), + "/Documentation/sub-folder/lonely-page.md", + "the folder index redirects to its first page (rootURL stripped)", + ); + }); + + test("visiting a folder root with a trailing slash also redirects", async function (assert) { + await visit("/Documentation/sub-folder/"); + assert.strictEqual(currentURL(), "/Documentation/sub-folder/lonely-page.md"); + }); + + test("visiting a folder root with different casing still redirects", async function (assert) { + await visit("/documentation/SUB-FOLDER"); + assert.strictEqual(currentURL(), "/Documentation/sub-folder/lonely-page.md"); + }); + + // The guard that keeps the folder redirect from swallowing ordinary + // navigation: a page visit lands on the wildcard's index too. + test("visiting a page leaves it where it is", async function (assert) { + await visit("/Documentation/sub-folder/ember-primitives.md"); + assert.strictEqual(currentURL(), "/Documentation/sub-folder/ember-primitives.md"); + }); + test("visiting a group root with different casing still redirects to its first page", async function (assert) { await visit("/home"); assert.strictEqual( diff --git a/test-apps/markdown-only/tests/application-test.ts b/test-apps/markdown-only/tests/application-test.ts index fdaadf5e..a273d165 100644 --- a/test-apps/markdown-only/tests/application-test.ts +++ b/test-apps/markdown-only/tests/application-test.ts @@ -29,28 +29,24 @@ module("All Links", function (hooks) { return new Promise((resolve) => setTimeout(resolve, 250)); }); + // The co-located pages' link is the app root now, rather than `/Home` + // where nothing is served — so the crawl no longer visits `/Home`, and + // the root sends it on to the first group. assert.verifySteps([ - "/Home", "/Docs", "/my-folder-name/bar.md", "/my-folder-name/foo.md", - "/Home", - "/my-folder-name/bar.md", - "/Home", + "/Docs", "/Docs/sub-folder/ember-primitives.md", "/Docs/sub-folder/ember-resources.md", - "/Home", - "/my-folder-name/foo.md", "/Docs", "/my-folder-name/foo.md", "/my-folder-name/bar.md", "/Docs", - "/Home", - "/Home", "/Docs/sub-folder/ember-resources.md", - "/Docs", "/Docs/sub-folder/ember-primitives.md", - "/Home", + "/Docs", + "/Docs", ]); }); }); diff --git a/test-apps/multiple-docs-routes/app/templates/application.gts b/test-apps/multiple-docs-routes/app/templates/application.gts index b8894424..c2052ded 100644 --- a/test-apps/multiple-docs-routes/app/templates/application.gts +++ b/test-apps/multiple-docs-routes/app/templates/application.gts @@ -38,7 +38,7 @@ const SideNav: TOC<{ Element: HTMLElement }> =