collections: a group's collection is the groups it collects - #366
collections: a group's collection is the groups it collects#366gitKrystan wants to merge 16 commits into
collection is the groups it collects#366Conversation
|
|
|
@gitKrystan is attempting to deploy a commit to the universal-ember Team on Vercel. A member of the Team first needs to authorize it. |
d4e42f2 to
4eb4f85
Compare
includes — merge groups into one nav entry (WIP)
includes — merge groups into one nav entry (WIP)collection merges it into one nav entry
collection merges it into one nav entrycollection is the groups it collects
| ], | ||
| ``` | ||
|
|
||
| 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. |
There was a problem hiding this comment.
TODO: We should confirm with @NullVoxPopuli that this is desirable. (I think it is?)
There was a problem hiding this comment.
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.
| /** | ||
| * Whether this entry stands in for more than its own group. | ||
| */ | ||
| isCollection: boolean; |
There was a problem hiding this comment.
Curious why this is needed.
There was a problem hiding this comment.
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.
| * 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[]; |
There was a problem hiding this comment.
How does this end up being different than tree?
There was a problem hiding this comment.
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.
|
|
||
| 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): |
There was a problem hiding this comment.
| 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): |
| 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. |
There was a problem hiding this comment.
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`. |
There was a problem hiding this comment.
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. |
There was a problem hiding this comment.
We should show a before and after migration for this.
| * 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'; |
There was a problem hiding this comment.
Does this duplicate another recently merged PR?
574523f to
46a6e84
Compare
| 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. |
There was a problem hiding this comment.
| 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. |
| - 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. | ||
| ``` |
There was a problem hiding this comment.
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?
| ```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}} | ||
| ``` |
There was a problem hiding this comment.
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. |
There was a problem hiding this comment.
Never use "carries" when you could use "has" instead.
98e94f1 to
aeca273
Compare
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>
aeca273 to
6b0525f
Compare
@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>
`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>
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:
collectionis 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 onmainwith neither rename in its own diff.What it does
A group's
collectionis the groups it presents together with its own pages: they become sections of its nav entry instead of top-level entries of their own.A collected entry is a group like any other — its own
src, its own markdown options, its owncollection— so nesting needs no extra concept. A collecting group'ssrcis 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
src/build/vite.jscollection?: Array<string | DocsEntry>onDocsEntry(sokolay.config.jsentries carry it;from-config.jsalready spreads entry options intodocs())src/build/plugins/docs-args.jscollection?: DocsEntry[]onDocsOptions;parseDocsArgsreturns a usage per group in the tree (so each keeps its own markdown options), the first carrying the tree asnavsrc/build/plugins/combined.jsdocsPluginsbuilds a plugin set per usage — the groups in a collection discover each other exactly as separatedocs()calls dosrc/build/plugins/setup.jsnavFor(state)assembles the metamanifest'snav: a node per top-level group, collected groups placed inside it, with the two name checkssrc/browser/load-compiled-docs.tsMetaManifest.nav, passed ontoManifest.navsrc/types.tsGroup(extracted),NavNode=NavGroup | NavCollection,NavEntrysrc/browser/collections.ts(new)redirects.ts:groupNamesIn,treeFor,navEntriesFor,navEntryFor,collectorOfsrc/browser/services/docs.tsnavEntries,activeNavEntry,collectorOf;treeis the current group's or the collecting group'sgroup-nav.gts,page-nav.gtsnavEntriesanddocs.tree(identical output when nothing collects anything)Decisions to confirm
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.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.docsPluginsreturns the per-usage plugin sets, which vite flattens — the same discovery path multipledocs()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 collecteddemosgroup keeps ascopeits sibling does not have.groupHrefFor, andhandlePotentialIndexVisitredirects togroup.list[0](itsindex.mdwhen it has one). A collecting group inherits that rule, so the collection's order chooses the landing.availableGroupsstill lists every group; the merge lives innavEntries.availableGroupsbacksselectedGroup,canonicalGroupName,groupForURL, andhandlePotentialIndexVisit— dropping collected groups from it would break resolving their own pages.selectedGroupstill names the current group;activeNavEntryis what a tab's active state reads.'root'parse placeholder so<PageNav />'s<:section>block gets a label. Sections nest for a group that collects others.Home, "named after a non-member") — all gone.NavNodeis a union, not an optional field. A group with nosrcmust collect at least one group, or its entry has nowhere to land — soNavCollectiontypeschildrenas non-empty andgroupasnull, andNavGrouphas a realgroup. That is what letslandingGrouprecurse andtreeFornarrow without a cast; the first draft had twoascasts, which were exactly this invariant going unstated.<PageNav />'s defaultaria-labelis"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...attributesstill lets an app override it. Raised by @NullVoxPopuli on refactor!: the page-tree node is aPageTree, not aCollection#368; the two in-repo selectors and the migration guide move with it.enumerateSource, once sorting is done), rather than renamed at read time. It gives a section its heading and acleanedNamelike every other folder's, and it deletes the| undefinedcascade that a read-time rename needed. This is a public shape change —docs.tree.namewas'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 untouchedsrc/build/plugins/docs-args.test.ts— the usage-per-group split, the tree it emits, per-group markdown options, and the malformed-collection errorssrc/build/plugins/setup.navFor.test.ts— the metamanifest'snav: placement, collected groups (however deep) staying out of the top level, and the two name checkstest-apps/multiple-docs-routes— a srclessDocsgroup 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 collecteddemosgroup keeps its ownscope. Every pre-existing routing test in that app still passes untouched, which is the "no impact on routing" claim.Docs
development/configuring-docs.mdgains a## collectionsection, written around one worked monorepo example — the nav before and after, the merged page list drawn out, the vite andkolay.config.jsforms, then the rules. ThedocsManagerpage separates whatavailableGroups/selectedGroupare for from whatnavEntriesis 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)" whendocs-args.js:255requires one unless the group collects others, and that nothing explained the bare-string entries thedocs()example uses. Both fixed, and the two build-time errors are now quoted fromsetup.jsrather 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
datagroup gave one Data tab in place of three: the metamanifest carried the tree,/datalanded on thedatagroup's ownindex.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 plusnavEntriesinstead ofavailableGroupsin its header — its sidebar needed nothing, sincedocs.treeaccounts for collections.Open questions
nav-entries.tshelpers be exported publicly (likeresolveRedirectis), or stay internal with the service as the only API? Currently internal;Group,NavNode, andNavEntrytypes are exported.srcis a name that is not a group: it has novirtual:kolay/docs/<name>module and is not inavailableGroups. That felt right (there are no pages to mount), but it does meannavcan name somethinggroupForcannot resolve — henceNavNode.group: string | null.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.