Skip to content
Closed
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
67 changes: 67 additions & 0 deletions docs-app/tests/docs-app/folder-index-redirect-test.gts
Original file line number Diff line number Diff line change
@@ -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');
});
});
8 changes: 8 additions & 0 deletions docs/navigation/handle-potential-index-visit.gjs.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -172,7 +172,7 @@
"node": ">= 18"
},
"volta": {
"node": "24.18.0",
"node": "24.19.0",
"pnpm": "10.34.5"
},
"publishConfig": {
Expand Down
14 changes: 8 additions & 6 deletions src/browser/components/group-nav.gts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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 {
Expand All @@ -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() {
Expand Down
73 changes: 63 additions & 10 deletions src/browser/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -69,6 +71,58 @@ export function addRoutes(
}
}

type RouteInfoLike = NonNullable<Transition['to']>['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
Expand Down Expand Up @@ -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');

Expand Down
36 changes: 35 additions & 1 deletion src/browser/services/docs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 };
45 changes: 45 additions & 0 deletions src/browser/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand All @@ -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,
Expand Down
28 changes: 28 additions & 0 deletions test-apps/custom-root-url/tests/index-redirect-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Loading