Skip to content

collections: a group's collection is the groups it collects - #366

Draft
gitKrystan wants to merge 16 commits into
universal-ember:mainfrom
gitKrystan:gitkrystan/collections
Draft

collections: a group's collection is the groups it collects#366
gitKrystan wants to merge 16 commits into
universal-ember:mainfrom
gitKrystan:gitkrystan/collections

Conversation

@gitKrystan

@gitKrystan gitKrystan commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Note

Still a draft — the shape is implemented end to end (build → metamanifest → manifest → docs service → nav components), with tests and docs, so it can be reviewed as code rather than as a sketch. @NullVoxPopuli, the decisions I had to make on your behalf are listed under Decisions to confirm; say the word on any of them and I will reshape.

Shape settled through review with @gitKrystan: collection is a property of a group in the plain sense — the group has a collection of other groups — rather than a name that several groups point at. It still satisfies your steer (an option on the docs plugin, the data rides the metamanifest and is parsed in the docs service, no impact on routing); the tree just lives in the config where the reader looks for it.

No longer stacked on anything. #368 freed this word by renaming the page-tree node to PageTree (and <PageNav />'s block to <:section>), and #367 fixed the co-located pages' nav link; both are merged, and this branch is rebased on main with neither rename in its own diff.

What it does

A group's collection is the groups it presents together with its own pages: they become sections of its nav entry instead of top-level entries of their own.

// vite.config.js — or the same entries in kolay.config.js
docs('Packages', {
  collection: [
    { name: 'store', src:  },
    { name: 'ember', src: , collection: [{ name: 'json-api', src:  }] },  // nests
  ],
}),
docs('utils', { src:  }),   // collects nothing: its own entry, as always

A collected entry is a group like any other — its own src, its own markdown options, its own collection — so nesting needs no extra concept. A collecting group's src is optional: with one, its pages come first in the page list and its entry links at its own URL; without, it exists to present the groups it collects, and links at the first of them.

The docs stay where they are authored (co-located with the library they document) while the reader sees one "Data" section. It is a nav-layer merge and nothing else: routing is untouched — every group keeps its pages, URLs, scoped mount, index redirects, and its identity in availableGroups / selectedGroup.

The pipeline

Layer File Change
config types src/build/vite.js collection?: Array<string | DocsEntry> on DocsEntry (so kolay.config.js entries carry it; from-config.js already spreads entry options into docs())
plugin args src/build/plugins/docs-args.js collection?: DocsEntry[] on DocsOptions; parseDocsArgs returns a usage per group in the tree (so each keeps its own markdown options), the first carrying the tree as nav
plugin factory src/build/plugins/combined.js docsPlugins builds a plugin set per usage — the groups in a collection discover each other exactly as separate docs() calls do
build src/build/plugins/setup.js navFor(state) assembles the metamanifest's nav: a node per top-level group, collected groups placed inside it, with the two name checks
metamanifest src/browser/load-compiled-docs.ts MetaManifest.nav, passed onto Manifest.nav
types src/types.ts Group (extracted), NavNode = NavGroup | NavCollection, NavEntry
nav model src/browser/collections.ts (new) pure module, like redirects.ts: groupNamesIn, treeFor, navEntriesFor, navEntryFor, collectorOf
docs service src/browser/services/docs.ts navEntries, activeNavEntry, collectorOf; tree is the current group's or the collecting group's
components group-nav.gts, page-nav.gts render navEntries and docs.tree (identical output when nothing collects anything)

Decisions to confirm

  1. A collection is navigation-only. No group's URLs, routes, scoped mounts, or index redirects change, and every group stays in availableGroups — collecting groups only changes what the navigation shows. @NullVoxPopuli, this is the load-bearing assumption: it means a site can adopt or drop a collection without moving a single page or breaking a link, and it is what lets the pages stay colocated with the packages they document. The alternative — a collection owning a URL space, so a collected group's pages live under /packages/core/... — would move every URL and need redirects. Please confirm the nav-only semantic is what you want before the rest matters.
  2. A collection contributes one docs() usage per group in its tree, so every collected group keeps its own markdown options (remarkPlugins / rehypePlugins / scope), inheriting the collecting group's unless it sets its own. docsPlugins returns the per-usage plugin sets, which vite flattens — the same discovery path multiple docs() calls already take. The alternative (one usage for the whole tree) would have forced one set of markdown options per collection; the test app now proves the per-group version, since its collected demos group keeps a scope its sibling does not have.
  3. The entry links at the first group with pages — its own, or the first it collects, however deep. kolay has no authored-landing concept for a group: a tab links at groupHrefFor, and handlePotentialIndexVisit redirects to group.list[0] (its index.md when it has one). A collecting group inherits that rule, so the collection's order chooses the landing.
  4. availableGroups still lists every group; the merge lives in navEntries. availableGroups backs selectedGroup, canonicalGroupName, groupForURL, and handlePotentialIndexVisit — dropping collected groups from it would break resolving their own pages. selectedGroup still names the current group; activeNavEntry is what a tab's active state reads.
  5. A collecting group's own pages come first, above the sections, and each collected group is a section labeled with its name — its tree renamed from the 'root' parse placeholder so <PageNav />'s <:section> block gets a label. Sections nest for a group that collects others.
  6. Two build-time name checks, both falling out of the shape: two nav entries cannot share a name, and a group cannot be collected twice. The shared-string design needed more (exact-vs-case matching, a reserved Home, "named after a non-member") — all gone.
  7. NavNode is a union, not an optional field. A group with no src must collect at least one group, or its entry has nowhere to land — so NavCollection types children as non-empty and group as null, and NavGroup has a real group. That is what lets landingGroup recurse and treeFor narrow without a cast; the first draft had two as casts, which were exactly this invariant going unstated.
  8. <PageNav />'s default aria-label is "Pages", not "Selected Group". One entry can present several groups now, and a group that only collects has no pages of its own, so the old label was wrong twice over. It pairs with <GroupNav />'s "Groups", and ...attributes still lets an app override it. Raised by @NullVoxPopuli on refactor!: the page-tree node is a PageTree, not a Collection #368; the two in-repo selectors and the migration guide move with it.
  9. A group's tree root is named after the group at build time (in enumerateSource, once sorting is done), rather than renamed at read time. It gives a section its heading and a cleanedName like every other folder's, and it deletes the | undefined cascade that a read-time rename needed. This is a public shape change — docs.tree.name was 'root' — so the migration guide covers it.

Tests

  • src/browser/nav-entries.test.ts — the tree → entries mapping, per function, on fixture manifests: group order, nesting, the landing group, an own-pages-first tree, and a plain group's tree passed through untouched
  • src/build/plugins/docs-args.test.ts — the usage-per-group split, the tree it emits, per-group markdown options, and the malformed-collection errors
  • src/build/plugins/setup.navFor.test.ts — the metamanifest's nav: placement, collected groups (however deep) staying out of the top level, and the two name checks
  • test-apps/multiple-docs-routes — a srcless Docs group collects both of that app's groups, which are both in scoped mounts (/help, /demos): one nav entry, a section per group, the entry links at the first, a collected page keeps the entry active and gets the collecting tree, and the collected demos group keeps its own scope. Every pre-existing routing test in that app still passes untouched, which is the "no impact on routing" claim.

Docs

development/configuring-docs.md gains a ## collection section, written around one worked monorepo example — the nav before and after, the merged page list drawn out, the vite and kolay.config.js forms, then the rules. The docsManager page separates what availableGroups / selectedGroup are for from what navEntries is for. DocsEntry's rendered API docs on the config-file page pick up the new option automatically.

The section was then reviewed by @gitKrystan, run through a humanizer pass, and reader-tested against an agent with no context and no access to the source. That last step earned its keep: it caught that the page called a collected group's src "(optional)" when docs-args.js:255 requires one unless the group collects others, and that nothing explained the bare-string entries the docs() example uses. Both fixed, and the two build-time errors are now quoted from setup.js rather than merely asserted to exist.

Tried on a real site

Built locally and installed into our docs app (7 groups, 5 of them library-co-located, some behind scoped mounts). Nesting three of them inside a srcless data group gave one Data tab in place of three: the metamanifest carried the tree, /data landed on the data group's own index.md, the sidebar listed Data / Warp Drive Config / Schema Decorators as sections, and /schema-decorators/... kept its own URL with the Data tab still reading active. The whole app-side change is the config plus navEntries instead of availableGroups in its header — its sidebar needed nothing, since docs.tree accounts for collections.

Open questions

  • Should the nav-entries.ts helpers be exported publicly (like resolveRedirect is), or stay internal with the service as the only API? Currently internal; Group, NavNode, and NavEntry types are exported.
  • A collecting group with no src is a name that is not a group: it has no virtual:kolay/docs/<name> module and is not in availableGroups. That felt right (there are no pages to mount), but it does mean nav can name something groupFor cannot resolve — hence NavNode.group: string | null.
  • Should handlePotentialIndexVisit's root-visit default (availableGroups[0]) become the first nav entry's landing? Same group today, but they could diverge.

🤖 Implemented by Claude Code (Opus 5, 1M context), pairing with @gitKrystan.

@bolt-new-by-stackblitz

Copy link
Copy Markdown

Review PR in StackBlitz Codeflow Run & review this pull request in StackBlitz Codeflow.

@vercel

vercel Bot commented Aug 11, 2026

Copy link
Copy Markdown

@gitKrystan is attempting to deploy a commit to the universal-ember Team on Vercel.

A member of the Team first needs to authorize it.

Comment thread src/build/plugins/kolay-config.js Outdated
Comment thread src/build/plugins/kolay-config.js Outdated
@gitKrystan
gitKrystan force-pushed the gitkrystan/collections branch from d4e42f2 to 4eb4f85 Compare August 12, 2026 00:06
@gitKrystan gitKrystan changed the title feat: collections — merge doc groups into one nav entry (WIP, config slice) feat(docs): includes — merge groups into one nav entry (WIP) Aug 12, 2026
@gitKrystan gitKrystan changed the title feat(docs): includes — merge groups into one nav entry (WIP) RFC: collections / group nesting — options & open questions (not for merge) Aug 12, 2026
@gitKrystan gitKrystan changed the title RFC: collections / group nesting — options & open questions (not for merge) collections: a group's collection merges it into one nav entry Aug 12, 2026
Comment thread docs-app/src/templates/development/configuring-docs.gjs.md
Comment thread docs-app/src/templates/development/configuring-docs.gjs.md Outdated
Comment thread docs-app/src/templates/development/configuring-docs.gjs.md Outdated
Comment thread docs-app/src/templates/development/configuring-docs.gjs.md Outdated
Comment thread docs-app/src/templates/development/configuring-docs.gjs.md Outdated
Comment thread docs/utilities/docs-manager.gjs.md Outdated
@gitKrystan gitKrystan changed the title collections: a group's collection merges it into one nav entry collections: a group's collection is the groups it collects Aug 12, 2026
Comment thread docs-app/src/templates/development/configuring-docs.gjs.md Outdated
Comment thread docs-app/src/templates/development/configuring-docs.gjs.md Outdated
Comment thread docs-app/src/templates/development/configuring-docs.gjs.md Outdated
],
```

Nothing about the docs themselves moves: each package's pages stay in its own folder, and stay at their own URLs — `/ember/components.md` is still `/ember/components.md`, still served by that group's own route or [scoped mount](#using-the-plugin-multiple-times). A collection exists only in the navigation.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TODO: We should confirm with @NullVoxPopuli that this is desirable. (I think it is?)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, and I have made it the first item under Decisions to confirm in the PR description so it is the first thing @NullVoxPopuli is asked, rather than something to infer from the diff: a collection is nav-only — no group's URLs, routes, scoped mounts, or index redirects change, and every group stays in availableGroups.

My read matches yours: it is what keeps the feature cheap (any site can adopt or drop it without breaking a link), and it is what makes it possible for the pages to stay colocated. The alternative — a collection owning a URL space, so collected pages live under /packages/core/... — would move every URL and need redirects, which is exactly what we did not want.

Drafted by Claude Code (Opus 5, 1M context), reviewed before posting.

Comment thread docs-app/src/templates/development/configuring-docs.gjs.md Outdated
Comment thread docs-app/src/templates/development/configuring-docs.gjs.md Outdated
Comment thread test-apps/multiple-docs-routes/tests/multiple-docs-routes-test.gts
Comment thread src/types.ts Outdated
Comment thread src/types.ts
/**
* Whether this entry stands in for more than its own group.
*/
isCollection: boolean;

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Curious why this is needed.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is the one thing an app cannot derive from the other fields. groups.length > 1 looks equivalent but is not: a collecting group with no pages of its own that collects exactly one group presents exactly one group, same as a plain group. <GroupNav /> does not use it, but an app that wants to style or label a collecting entry differently (or set aria-expanded-ish semantics) needs the answer.

I did drop the neighbouring node: NavNode field in the same pass — that one was derivable noise: it existed only so navEntryFor could walk the subtree, and that now uses groups instead. So NavEntry is { name, isCollection, groups, href, tree }, and every field has a caller.

Drafted by Claude Code (Opus 5, 1M context), reviewed before posting.

Comment thread src/types.ts
* The groups this entry presents: its own (when it has pages of its
* own), then the ones it collects, in declaration order, depth first.
*/
groups: Group[];

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How does this end up being different than tree?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Different shapes for different jobs. groups is the flat list of manifest records ({ name, list, tree }) whose pages the entry presents — its own first, then the collected ones, depth first; it is what you iterate to answer "which groups are in here" (navEntryFor uses it, and collectorOf is built on the same idea). tree is the single merged page hierarchy to render for the entry: the collecting group's own pages followed by one labeled section per collected group, nesting as deep as the config does.

For a plain group the two overlap — groups is [thatGroup] and tree is thatGroup.tree (the same object, not a copy). They diverge exactly when the group collects others.

Drafted by Claude Code (Opus 5, 1M context), reviewed before posting.

Comment thread src/build/plugins/setup.js Outdated
Comment thread src/browser/components/page-nav.gts Outdated
Comment thread src/browser/components/group-nav.gts
Comment thread src/browser/services/docs.ts

A collected group is an ordinary [`PageTree`](/Runtime/utilities/page-tree-utils.md), so it goes through `<PageNav />`'s `<:section>` block. That is the same block a folder of markdown files goes through, which is why the block is named for the nav role rather than the filesystem. A section's label is a link when its group has an `index.md`, and plain text otherwise.

Hand-rolled navigation reads the entries from [`docsManager`](/Runtime/utilities/docs-manager.md):

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
Hand-rolled navigation reads the entries from [`docsManager`](/Runtime/utilities/docs-manager.md):
Hand-rolled navigation can read the entries from [`docsManager`](/Runtime/utilities/docs-manager.md):

Comment on lines +258 to +271
A top-level nav needs three things from each entry, and `{ name, href, tree }` are them: what to show, where to link, and the page list to render beside it. Compare `entry.name` against `activeNavEntry.name` for the active state:

```hbs
{{#each this.docs.navEntries as |entry|}}
<a
href={{entry.href}}
class={{if (eq entry.name this.docs.activeNavEntry.name) "active"}}
>{{entry.name}}</a>
{{/each}}
```

An entry carries two more fields, for rarer jobs. `groups` is the flat list of groups whose pages it presents, its own first and then the collected ones, depth first, for a nav that wants to name what is inside an entry. `isCollection` answers whether the entry collects anything at all, which `groups.length` cannot: a group collecting exactly one group presents exactly one group, the same as a plain group does.

A sidebar built on `docs.tree` needs no change, because it is already the tree to render, whether that is the current group's or the collection group's. `currentGroup.tree` is still the group's own.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this content specific to Collection?


A sidebar built on `docs.tree` needs no change, because it is already the tree to render, whether that is the current group's or the collection group's. `currentGroup.tree` is still the group's own.

`availableGroups` and `selectedGroup` are deliberately untouched, because they answer where a page lives rather than how the navigation is arranged. `selectedGroup` is the group the current URL resolves to — always the group itself, never the entry presenting it — where `activeNavEntry` is that entry, which is the collection group when one is involved. Reading `/plugins/writing-one.md`, `selectedGroup` is `plugins` and `activeNavEntry.name` is `Packages`.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what does "where a page lives" mean? routes?


`availableGroups` and `selectedGroup` are deliberately untouched, because they answer where a page lives rather than how the navigation is arranged. `selectedGroup` is the group the current URL resolves to — always the group itself, never the entry presenting it — where `activeNavEntry` is that entry, which is the collection group when one is involved. Reading `/plugins/writing-one.md`, `selectedGroup` is `plugins` and `activeNavEntry.name` is `Packages`.

So a top-level nav built on `availableGroups` keeps working, and keeps listing every group individually. Switch that loop to `navEntries` to make a collection visible.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should show a before and after migration for this.

Comment thread src/nav.js
* from no `docs()` usage, so it can never have options of its own, and its
* pages live in the root URL space rather than under the group's name.
*/
export const HOME_GROUP = 'Home';

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this duplicate another recently merged PR?

@gitKrystan
gitKrystan force-pushed the gitkrystan/collections branch from 574523f to 46a6e84 Compare August 14, 2026 20:25
Helpers
```

Every page in a collected group shows the collection group's page list, with a section per collected group in declaration order. In our example, a reader on `/plugins/writing-one.md` still sees `Core` / `Plugins` / `Utilities`, with `Packages` highlighted.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
Every page in a collected group shows the collection group's page list, with a section per collected group in declaration order. In our example, a reader on `/plugins/writing-one.md` still sees `Core` / `Plugins` / `Utilities`, with `Packages` highlighted.
Every page in a collected group shows the parent collection group's page list, with a section per collected group in declaration order. In our example, a reader on `/plugins/writing-one.md` still sees `Core` / `Plugins` / `Utilities`, with `Packages` highlighted.

Comment on lines +206 to +211
- Every entry in the top-level navigation needs its own name. A collection group is named there alongside the groups that no collection group collects, so it cannot reuse one of those names.

```
Two navigation entries are named 'Packages'. Every entry in the top-level navigation
needs its own name, whether it collects other groups or not.
```

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is/should this validation applied only to collection groups? It seems like it should always be applied to groups that are nav or route siblings regardless of collection-ness?

Comment thread docs-app/src/templates/development/configuring-docs.gjs.md
Comment on lines +270 to +277
```hbs
{{#each this.docs.navEntries as |entry|}}
<a
href={{entry.href}}
class={{if (eq entry.name this.docs.activeNavEntry.name) "active"}}
>{{entry.name}}</a>
{{/each}}
```

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Prob can remove this in favor of the diff example below.

{{/each}}
```

An entry carries two more fields, for rarer jobs. `groups` is the flat list of groups whose pages it presents, its own first and then the collected ones, depth first, for a nav that wants to name what is inside an entry. `isCollection` answers whether the entry collects anything at all, which `groups.length` cannot: a group collecting exactly one group presents exactly one group, the same as a plain group does.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Never use "carries" when you could use "has" instead.

@gitKrystan
gitKrystan force-pushed the gitkrystan/collections branch from 98e94f1 to aeca273 Compare August 14, 2026 21:52
gitKrystan and others added 12 commits August 14, 2026 15:29
A monorepo grows a package at a time and a docs group with it, until the
top of the site is a row of package names. A group's `collection` is the
groups it presents together with its own pages, so those become one entry
with a section each.

Nothing about the docs moves: every collected group keeps its own pages,
its own URLs, and its own scoped mount. A collection exists only in the
navigation. The build emits it as `nav` on the metamanifest, and
`Manifest.nav` carries it to the browser, where `docs.navEntries` and
`docs.activeNavEntry` describe the entries and `docs.tree` renders the
active one.

`src` becomes optional for a group that collects others — it then has no
pages of its own, and its entry lands on the first group it collects.

Two consequences worth calling out separately, both breaking:

- A group's page tree root is named after the group rather than `'root'`,
  which is what lets it render as a labeled section inside another
  group's page list.
- `<PageNav />`'s default `aria-label` is `"Pages"`, not `"Selected
  Group"`. One entry can now present several groups, and a group that
  only collects has no pages of its own, so the old label was wrong twice
  over. It pairs with `<GroupNav />`'s `"Groups"`, and `...attributes`
  still lets an app override it.

`<GroupNav />` iterates entries rather than groups, which also settles the
co-located pages' link: they are a group ('Home') whose pages live in the
root URL space, so the entry links at the app root and `@homeName` names
it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The `collection` section of the docs() page walks a monorepo example —
four packages, four entries, one `Packages` entry with a section each —
and the migration guide gains the entries that changed under it: `nav` on
the metamanifest, what `docs.tree` now returns, the tree root's name, the
`aria-label`, and the co-located pages' link.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
universal-ember#368 renamed the types in these tests but left the local helper called
`collection()` and the descriptions reading 'a collection is never an
index'. Now that a collection is the set of groups another group presents,
that wording names the wrong concept — these fixtures are page trees.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Applies @gitKrystan's review of the docs, which was the only part she got
through: a plainer opening, her wording for the lead-ins, the dense
'collected group is a group like any other' paragraph split in two, and
the seven-bullet 'In detail' list folded into two paragraphs plus the
rules the build actually enforces.

The co-location point moves up to where a reader meets the feature, since
that is the reason to reach for it: the docs stay next to the code, and
only the rendered grouping changes.

Two small departures from the suggestions as written, both flagged for
review: 'For example, a repo publishing ...' was a sentence fragment, so
it is 'take a repo publishing ...'; and 'a collected group can group
groups' now reads 'can itself collect groups'.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Ran the section through the humanizer skill, which caught a cluster the
hand pass missed: 'nesting is what makes the difference', 'its name is
what the navigation shows', and 'switching that loop is what makes a
collection visible' were the same formula three times. Also drops a
'Two things are worth knowing' announcement, a 'Reading X shows Y' that
gave the reading agency, and two em dashes doing no work.

No claims changed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Reader-tested the page against an agent with no context and no access to
the source, then fixed what it could not answer:

- `src` is REQUIRED for a collected group that collects nothing. The
  wording said '(optional)', which `docs-args.js:255` contradicts:
  `if (!childSrc) throw missingSrc(childName)`. Only a group that
  collects others may omit it.
- A `collection` entry can be a plain string whose last segment names the
  group (docs-args.js:221). The reader could not write a three-package
  config from the `docs()` example without guessing, because that example
  uses bare strings and nothing said what they mean.
- `selectedGroup` read as a synonym for `activeNavEntry`. Now says which
  is the group and which is the entry, with a worked example.
- `collectionOf` had no prose at all, only a trailing comment.
- 'named alongside the groups that nothing collects' was unparseable on
  one read; it now states the uniqueness rule plainly.
- `PageTree` links to its own page rather than appearing undefined.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The two remaining reader-test findings:

- The rules said they were 'enforced at buildtime' without showing what a
  failure looks like. Both messages are now quoted from setup.js:113-119,
  with the placeholders filled in from this page's own example, plus a
  note that a collected group missing an `src` fails the same way.
- The entry shape read like generated API docs: define the type, then
  define each key in declaration order. It now leads with what a nav
  actually needs from an entry (`name`, `href`, `tree`), and `groups` and
  `isCollection` are described after the example by the job each does —
  including why `isCollection` is not `groups.length > 1`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@gitKrystan asked for consistent terminology. The docs said "collecting
group" where the glossary wants "collection group" (a group with a
`collection` property); "collected group" (a member of one) was already
right. 24 uses across 8 files, including code comments and test names, so
the vocabulary holds wherever a reader lands rather than only on the page
she was reading.

Also takes her wording for the opening example, and states the one thing
the section never said outright: a collection is orthogonal to how a group
is mounted. A collected group in a scoped or nested mount keeps that
mount's URLs, and its section links there rather than at /GroupName.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@gitKrystan asked whether collections and nested mounts compose. They do,
and the test app already combined them, but nothing said so and the
assertions only ever entered through the scoped mount.

- A `Collections and mounts` section states the split — a collection is
  navigation, a mount is routing — with the test app's own two-mount
  config as the worked example, plus the two consequences: a collection
  adds no route, so `handlePotentialIndexVisit` still belongs to each
  mount; and a collection group with no `src` has no mount and no
  `virtual:kolay/docs/<name>` module at all (`docs-args.js:152` gives it a
  nav-only usage with `groups: []`, so the build never emits one).
- A test enters through `demos`, the unscoped nested mount, and asserts
  the same invariants the scoped-mount test asserts for `guides`: routing
  untouched, the group still resolves, the collection entry active,
  `docs.tree` the collection group's, `currentGroup.tree` still its own.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Addresses @gitKrystan's review.

A collection group with no `src` names no group, so nothing in
`availableGroups` resolved its URL and `/Packages` rendered a missing page.
`handlePotentialIndexVisit` now falls back to the navigation: `navEntryNamed`
looks an entry up by its own name, and the redirect lands on the first
group it collects. Related to universal-ember#370, which covers the adjacent case of a
page tree with no page of its own; a collection group is neither a page
tree nor a group, so neither path reached it.

The redirect needs the catching route to call `handlePotentialIndexVisit`,
same as a group root does. The test app only wired that on its two nested
mounts, so its top-level mount gets a route too — which is what the docs
have always told apps to do.

Also, per review:
- Both nav assertions are reworded; the wording about "the groups nothing
  collects" was as confusing in the error as it was in the docs. The docs
  quote the new text verbatim.
- The `collection` section is restructured to her suggestions: the
  before/after moves either side of the example, an example for a
  collection group WITH an `src`, and a before/after diff for the
  `availableGroups` → `navEntries` switch.

`navEntryNamed` compares case-insensitively inline rather than reusing
`equalsIgnoreCase`: this module's unit tests run in node, and
`browser/utils.ts` imports `@ember/debug`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Addresses @gitKrystan's latest round.

- "parent collection group" where the page list comes from (T28).
- The uniqueness rule no longer reads as collection-specific. Group names
  already have to be unique across every `docs()` usage — `assertUniqueGroupNames`
  predates this PR and covers all of them. The nav check exists for the one
  case that one cannot see: a collection group with no `src` is a name in
  the navigation without being a group (T29).
- The mounts example says what it produces, and answers whether the mounts
  can share a prefix: nesting both routes inside a `docs` route gives
  `/docs/guides` and `/docs/demos`, and the collection is unaffected,
  because mount depth only changes the route names `addRoutes` registers
  under (`scopedRouteNameFor('help.nested')` is unit-tested) (T30).
- Dropped the standalone hbs nav example: it was byte-identical to the
  "after" side of the diff below it (T31).
- "carries" is gone from every line this PR adds — six of them, in the
  migration guide heading, a test name, and three build comments. The one
  in docs-manager.gjs.md predates this branch, so it is left alone (T32).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Corrects an overstatement from 94594fc: "mount depth only changes the
route names addRoutes registers under" holds for a scoped mount, whose
group is bound by name and resolved by walking up the route hierarchy
(docs.ts:355), but not for an unscoped one. That takes its group from the
first segment of the URL (docs.ts:335), so nesting it below the top level
would resolve the wrong group.

So collected groups can share a URL prefix, and the collection is
indifferent to it — but every mount under that prefix has to be scoped.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@gitKrystan
gitKrystan force-pushed the gitkrystan/collections branch from aeca273 to 6b0525f Compare August 14, 2026 22:31
gitKrystan and others added 4 commits August 14, 2026 15:47
@gitKrystan asked whether these validations should apply to all groups
rather than only collection ones. Two gaps that had nothing to do with
collections:

- `registerScopedRoute` was a bare map.set, so two scoped mounts in one
  route both claimed its single wildcard and the second silently won,
  leaving the first group unreachable with no warning. It refuses now.
  A plain throw rather than @ember/debug's assert: this module is
  unit-tested in node, and an unreachable mount is as broken in production
  as in development.
- `assertNav` compared names exactly while `canonicalGroupName` and
  `navEntryNamed` both resolve without regard to case, so entries named
  'Docs' and 'docs' passed the build and were then ambiguous at runtime.
  It is keyed case-insensitively now, and says so when that is why two
  entries collide.

`assertUniqueGroupNames` is deliberately untouched: it predates this work,
and tightening it could fail a build that currently succeeds. The
mismatch there is worth its own change.

Also drops the trailing clause from the duplicate-name error, per review —
'whether it collects other groups or not' was answering a question the
reader has not asked yet.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
From a simplification pass. Two of the four reviewers landed on the same
code from different angles: handlePotentialIndexVisit resolved a URL
segment as a group, and then, on the miss, resolved the same candidates
again as nav-entry names and reached into entry.groups[0].

That put a navigation concept inside routing and depended on an ordering
guarantee that lived only in a comment — the opposite of what this feature
claims about itself, twice, in its own doc comments. docs.groupToLandOn
answers both cases now, so routing asks which group answers for a segment
and never learns collections exist. One pass instead of two.

The collapse changes precedence from all-groups-then-all-entries to
per-candidate group-then-entry. That is safe because the first two
candidates come from groupNameForRoute, which only ever stores real group
names, so only the URL-derived candidates reach the entry fallback. All
five test apps still pass, including every redirect path.

Also computes the Home check once per entry in group-nav, and trims the
type comments that restated their field instead of saying something the
field could not.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…tself twice

Two review findings, both about validation and shape rather than behavior.

`NavGroup.group` only ever held the node's own name, and `NavCollection.group`
only ever held null — a boolean spelled as a string, with the invariant
`group === name` unenforced by the type. A node could be built as
{ name: 'Packages', group: 'core' } with no error, and `treeFor` would then
label an entry 'Packages' while rendering group 'core'\'s pages, silently.
It is `hasOwnPages: boolean` now, which is what every consumer was reading
it as.

`assertUniqueGroupNames` compares without regard to case, matching
`canonicalGroupName`: two groups differing only in case are one group as
far as a URL is concerned, so which one a reader reached was an accident of
declaration order.

Both are breaking, and neither has shipped: `Manifest.nav` is new in this
PR, and the case rule only rejects configs that were already ambiguous. The
migration guide's hand-built manifest example moves with the shape.

Also adds a test proving `assertNav` does NOT subsume
`assertUniqueGroupNames` — `navFor` skips a collected group, so a name
that is both standalone and collected never meets itself in the nav tree.
A simplification pass proposed dropping the older check as redundant; it
is not, and now something says so.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…says

Two things, both from review.

The docs service now answers index-visit resolution with a Page rather than
a Group, as `landingForSegment` — a sibling to universal-ember#370's `landingForPageTree`,
which the other session confirmed should be the shape we match since that PR
merges first. The `list[0]` lookup and the empty-group warning move out of
router.ts, so routing asks one question and does nothing with the answer but
transition.

The comment pass follows @gitKrystan's review of universal-ember#370: assume a comment is
not needed, and reserve jsdoc for what surfaces on hover or in published API
docs. nav-entries.ts goes from 48 comment lines to 21, keeping only the two
things a reader cannot recover — that nothing in it touches routing, and why
the case comparison is inline rather than importing equalsIgnoreCase.

Two comments were not verbose but wrong, left stale by the hasOwnPages
change: nav.js still described nodes as { name, group, children } and
explained a null that no longer exists, and navNode's docstring said the
same. Both fixed, which was the more valuable half of this pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
gitKrystan added a commit to gitKrystan/kolay that referenced this pull request Aug 15, 2026
`handlePotentialIndexVisit` and `#landingForRouteInfo` both answer "given this
index arrival, what is the wildcard segment and which group does the mount
serve". Each had its own copy, and between them they encoded all three mount
shapes — the kind of knowledge that gets corrected in one file and missed in
the other.

`mountLocationFor(routeInfo)` in `scoped-routes.ts` now owns it, next to the
`groupNameForRoute` registry it reads. It returns the normalized wildcard
param and the raw group-name candidates; canonicalization stays at the
callsites, which have the docs service.

The two callers arrive at index routes from opposite directions, which is why
the copies looked different rather than identical:

  /guides      -> guides.index       parent is the mount route, wildcard a sibling
  /guides/foo  -> guides.page.index  parent is the wildcard route itself

`mountLocationFor` branches on whether the parent carries a `page` param and
resolves both.

What deliberately did not move: the `!wildcardParam` guard. A page visit
resolves to the wildcard's index too, so `handlePotentialIndexVisit` may only
treat the mount's group as a destination when nothing was requested, while
`#landingForRouteInfo` wants that same group precisely when something was —
it scopes the lookup rather than redirecting to it. Two opposite policies over
one fact, so the fact is shared and the policies stay where they are.

Verified the guard is load-bearing rather than assuming it: dropping it fails
two `multiple-docs-routes` tests, the ones entering a mount at a page that is
not the group's first.

Requested by the universal-ember#366 (collections) work, which needs to call this after
rebasing and cannot reach a `#`-private method.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant