diff --git a/content/docs/guide/ci-cd-pipeline.md b/content/docs/guide/ci-cd-pipeline.md index a841f81372..4f6ad7d37f 100644 --- a/content/docs/guide/ci-cd-pipeline.md +++ b/content/docs/guide/ci-cd-pipeline.md @@ -252,8 +252,8 @@ Check**. Runs `scripts/check-doc-links.mjs`, which walks every `.md` / `.mdx` file in the surfaces listed in its `SCAN_ROOTS` — `content/docs/`, `examples/`, the root `README.md`, `CONTRIBUTING.md`, -`ROADMAP.md` and the internal `docs/` tree — and asks of each internal markdown link whether its -target is really there. +`ROADMAP.md`, the internal `docs/` tree and every package `README.md` — and asks of each internal +markdown link whether its target is really there. **Two rules, because the two groups are read through different machinery** (objectui#3536). For `content/docs/` the question is the one a site reader cares about, **does the site serve this URL?** @@ -282,16 +282,31 @@ rather than special-casing any surface. Measured before landing: 11 across the s dead. Prefer the origin-less form (`/docs/guide/plugins`) in new prose: it survives a domain change, and both spellings are now checked identically. -Every other surface — `examples/`, `README.md`, `CONTRIBUTING.md`, `ROADMAP.md`, `docs/` — is read -on **GitHub**, not served by the site, so a relative href there names a path on disk and is checked -for existence only: a directory (`./packages/core`) or a non-markdown file (`./vite.config.ts`) is a -perfectly good target, and there is no collection to escape. A leading `/` is rejected outright: -GitHub resolves it against `github.com`, not against this repository. Applying the `content/docs/` -rules to these files instead would reject 111 links that render correctly today. - -The last three surfaces are objectui#3572. They cost one `SCAN_ROOTS` row each and no new rule, -because "read on GitHub" already had one; their own backlog — three dead links — was cleared first -and separately (objectui#3545), so the rows landed on a green tree. +Every other surface — `examples/`, `README.md`, `CONTRIBUTING.md`, `ROADMAP.md`, `docs/` and the +package READMEs — is read on **GitHub** (and, for the package READMEs, on **npm**), not served by +the site, so a relative href there names a path on disk and is checked for existence only: a +directory (`./packages/core`) or a non-markdown file (`./vite.config.ts`) is a perfectly good +target, and there is no collection to escape. A leading `/` is rejected outright: GitHub resolves it +against `github.com`, not against this repository. Applying the `content/docs/` rules to these files +instead would reject 186 links that render correctly today. + +`CONTRIBUTING.md`, `ROADMAP.md` and `docs/` are objectui#3572. They cost one `SCAN_ROOTS` row each +and no new rule, because "read on GitHub" already had one; their own backlog — three dead links — +was cleared first and separately (objectui#3545), so the rows landed on a green tree. + +The package READMEs are objectui#3622, and the same shape: **one row, its backlog paid first**. That +backlog was 11 dead links in seven packages — three `/api/` routes the site has never served, +four site URLs naming three `content/docs/` directories that have no index page (so fumadocs +generates no route for them), a `/docs/types` tree that does not exist, an `/examples` route that +does not either, and two disk paths that were simply absent. Each was repointed at a real page, or +replaced with the repository URL that does exist, before the row went in. The row is also the table's only wildcard: `packages/*/README.md` stands for +one file per package directory (38 of the 39 today), and only the README — a package's +`CHANGELOG.md`, `TESTING.md` and its own `docs/` tree stay unscanned. + +**Package READMEs must keep the origin on site links.** Inside `content/docs/` the origin-less +`/docs/guide/plugins` is preferred; in a README it would be wrong, because GitHub and npm both +resolve a leading `/` against their own host, not against this site. Write +`https://www.objectui.org/docs/guide/plugins` there — it is checked exactly as strictly. **One boundary, stated because it is easy to mistake for coverage:** links written inside a code fence are invisible to this check. `stripCode()` blanks fenced blocks and inline spans before @@ -350,7 +365,7 @@ There are **two** link checkers, and they cover different things (objectui#3213) | | Covers | Network | Runs | |---|---|---|---| -| `scripts/check-doc-links.mjs` | **Internal** links in `content/docs/` (relative hrefs, `/docs/...` routes, every other site-absolute href against `apps/site`), in `examples/`, `README.md`, `CONTRIBUTING.md`, `ROADMAP.md` and `docs/` (as paths on disk), plus this repo's own `blob/main/` and `tree/main/` GitHub URLs and this site's own `objectui.org` URLs everywhere — **except** anything inside a code fence | No | `docs-links.yml` — every push and PR, no path filter (previous section) | +| `scripts/check-doc-links.mjs` | **Internal** links in `content/docs/` (relative hrefs, `/docs/...` routes, every other site-absolute href against `apps/site`), in `examples/`, `README.md`, `CONTRIBUTING.md`, `ROADMAP.md`, `docs/` and every package `README.md` (as paths on disk), plus this repo's own `blob/main/` and `tree/main/` GitHub URLs and this site's own `objectui.org` URLs everywhere — **except** anything inside a code fence | No | `docs-links.yml` — every push and PR, no path filter (previous section) | | Lychee (this workflow) | **External** URLs, plus **relative** in-repo file links, in `content/docs/`, `docs/` and `README.md` | Yes | Weekly cron and manual dispatch | Lychee sweeps **both** documentation trees: `content/docs/` (the 183 pages the site publishes) and diff --git a/packages/components/README.md b/packages/components/README.md index eb9337fe2d..1f3528da21 100644 --- a/packages/components/README.md +++ b/packages/components/README.md @@ -27,7 +27,7 @@ pnpm shadcn:check pnpm shadcn:update button --backup ``` -**📚 See [SHADCN_SYNC.md](../../docs/SHADCN_SYNC.md) for the complete guide.** +**📚 See [README_SHADCN_SYNC.md](./README_SHADCN_SYNC.md) for the complete guide.** ## Installation @@ -209,7 +209,7 @@ registerRenderer('custom-button', CustomButton) ## API Reference -See [full documentation](https://objectui.org/api/components) for detailed API reference. +See [full documentation](https://objectui.org/docs/components) for detailed API reference. diff --git a/packages/core/README.md b/packages/core/README.md index 4a4fd579d9..52e8cf1713 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -141,7 +141,7 @@ This allows the core types and logic to be used in: ## API Reference -See [full documentation](https://objectui.org/api/core) for detailed API reference. +See [full documentation](https://objectui.org/docs/api) for detailed API reference. @@ -155,7 +155,7 @@ See [full documentation](https://objectui.org/api/core) for detailed API referen ## Links -- 📚 [Documentation](https://www.objectui.org/docs/core) +- 📚 [Documentation](https://www.objectui.org/docs/guide/architecture) - 📦 [npm package](https://www.npmjs.com/package/@object-ui/core) - 📝 [Changelog](./CHANGELOG.md) - 🐛 [Report an issue](https://github.com/objectstack-ai/objectui/issues) diff --git a/packages/fields/README.md b/packages/fields/README.md index db702d9e8e..bc133f2a58 100644 --- a/packages/fields/README.md +++ b/packages/fields/README.md @@ -128,7 +128,7 @@ server too. See ## Links -- 📚 [Documentation](https://www.objectui.org/docs/fields) +- 📚 [Documentation](https://www.objectui.org/docs/guide/fields) - 📦 [npm package](https://www.npmjs.com/package/@object-ui/fields) - 📝 [Changelog](./CHANGELOG.md) - 🐛 [Report an issue](https://github.com/objectstack-ai/objectui/issues) diff --git a/packages/layout/README.md b/packages/layout/README.md index 576443d3a0..e72d97bd89 100644 --- a/packages/layout/README.md +++ b/packages/layout/README.md @@ -119,7 +119,7 @@ All components accept `className` prop for Tailwind customization: ## API Reference -For detailed API documentation, visit the [Object UI Documentation](https://www.objectui.org/docs/layout). +For detailed API documentation, visit the [Object UI Documentation](https://www.objectui.org/docs/layout/app-shell). @@ -134,7 +134,7 @@ For detailed API documentation, visit the [Object UI Documentation](https://www. ## Links -- 📚 [Documentation](https://www.objectui.org/docs/layout) +- 📚 [Documentation](https://www.objectui.org/docs/guide/layout) - 📦 [npm package](https://www.npmjs.com/package/@object-ui/layout) - 📝 [Changelog](./CHANGELOG.md) - 🐛 [Report an issue](https://github.com/objectstack-ai/objectui/issues) diff --git a/packages/react/README.md b/packages/react/README.md index 3de10048e6..bd87cbb79b 100644 --- a/packages/react/README.md +++ b/packages/react/README.md @@ -225,7 +225,7 @@ leaves the surface on its own anchor), `visibleNotificationStack` (`maxVisible` ## API Reference -See [full documentation](https://objectui.org/api/react) for detailed API reference. +See [full documentation](https://objectui.org/docs/core/schema-renderer) for detailed API reference. diff --git a/packages/types/README.md b/packages/types/README.md index 30819e451f..3e77876fa0 100644 --- a/packages/types/README.md +++ b/packages/types/README.md @@ -326,6 +326,6 @@ MIT ## Links -- [Documentation](https://objectui.org/docs/types) +- [Documentation](https://objectui.org/docs/api/schema-reference) - [GitHub](https://github.com/objectstack-ai/objectui) - [NPM](https://www.npmjs.com/package/@object-ui/types) diff --git a/packages/vscode-extension/README.md b/packages/vscode-extension/README.md index 43a93171a6..4bc1697889 100644 --- a/packages/vscode-extension/README.md +++ b/packages/vscode-extension/README.md @@ -187,7 +187,7 @@ pnpm publish - [Object UI Documentation](https://www.objectui.org) - [Schema Reference](https://www.objectui.org/docs/api/schema-reference) - [Component Library](https://www.objectui.org/docs/components) -- [Examples](https://www.objectui.org/examples) +- [Examples](https://github.com/objectstack-ai/objectui/tree/main/examples) ## 🤝 Contributing @@ -239,4 +239,4 @@ Built with: ## License -MIT — see [LICENSE](./LICENSE). +MIT — see [LICENSE](https://github.com/objectstack-ai/objectui/blob/main/LICENSE). diff --git a/scripts/__tests__/check-doc-links.test.ts b/scripts/__tests__/check-doc-links.test.ts index 5662e6c258..effa10c5a2 100644 --- a/scripts/__tests__/check-doc-links.test.ts +++ b/scripts/__tests__/check-doc-links.test.ts @@ -81,10 +81,15 @@ import { * fail if either is. * * That card's OTHER half — adding the package READMEs to SCAN_ROOTS — was - * deliberately not bought, because measuring it first turned up 11 more dead - * links in packages the card never touched (objectui#3622). Nothing here pins - * a `packages/**` row for that reason; when its backlog is paid, the SCAN_ROOTS - * assertions below are what will need extending. + * deliberately not bought at the time, because measuring it first turned up 11 + * more dead links in packages the card never touched. objectui#3622 paid those + * 11 and added the row, and the last describe pins it. Two things there are + * worth reading before editing it: the scan root carries the table's only + * wildcard segment, so `collectFiles` has an expansion step whose failure mode + * is a surface that silently expands to nothing — which is why the row's floor + * is pinned on DECIDABLE HREFS and not only on file count — and the fixture + * pair that keeps the row on the `disk` rule, since a package README is read on + * npm and GitHub and never served by the site. */ const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); @@ -386,6 +391,7 @@ describe('the repo it guards', () => { 'CONTRIBUTING.md', 'ROADMAP.md', 'docs', + 'packages/*/README.md', ]); expect(scanned['README.md']).toBe(1); expect(scanned['CONTRIBUTING.md']).toBe(1); @@ -393,6 +399,11 @@ describe('the repo it guards', () => { expect(scanned['examples']).toBeGreaterThanOrEqual(4); expect(scanned['docs']).toBeGreaterThanOrEqual(15); expect(scanned['content/docs']).toBeGreaterThanOrEqual(100); + // objectui#3622. 38 of the 39 package directories carry a README today + // (`sdui-parser` does not), and this row is the only one whose path needs + // expanding before anything is opened — so a floor here is also the floor + // under `expandWildcard()`. + expect(scanned['packages/*/README.md']).toBeGreaterThanOrEqual(35); }); it('pairs each scan root with the link semantics that root actually has', () => { @@ -403,6 +414,10 @@ describe('the repo it guards', () => { // objectui#3572 added the last three, all `disk` — they are read on GitHub // like the first two. The table is the whole configuration surface, so it // is pinned whole: a row silently changing rule would re-judge a tree. + // + // objectui#3622 added the package READMEs, `disk` for the same reason and + // one more: they are read on **npm** as well, where a leading `/` is no + // more this repo's root than it is on github.com. expect(SCAN_ROOTS).toEqual([ { path: 'content/docs', rule: 'docs' }, { path: 'examples', rule: 'disk' }, @@ -410,6 +425,7 @@ describe('the repo it guards', () => { { path: 'CONTRIBUTING.md', rule: 'disk' }, { path: 'ROADMAP.md', rule: 'disk' }, { path: 'docs', rule: 'disk' }, + { path: 'packages/*/README.md', rule: 'disk' }, ]); }); @@ -1136,10 +1152,14 @@ describe("this site's own absolute URLs are resolved as internal routes — obje // `siteAbsoluteRoute()` returned null for everything. This pins that the // URLs are really there and really reaching the check. // - // Measured on main: 11 of them, of which 6 sit inside `content/docs` - // itself — the fact that makes this a general fix rather than a package - // README one — and 3 carry a `/docs/...` path, so the strict branch above - // is genuinely exercised by real content and not only by fixtures. + // Measured on main when #3603 landed: 11 of them, of which 6 sit inside + // `content/docs` itself — the fact that makes this a general fix rather + // than a package README one — and 3 carry a `/docs/...` path, so the strict + // branch above is genuinely exercised by real content and not only by + // fixtures. objectui#3622 took the total to 58 by adding a surface that + // writes 47 of them; the floors below stay at #3603's measurement, since + // they exist to pin THAT card's minimum and the new root has its own floor + // in the last describe. const seen: { root: string; route: string }[] = []; for (const root of SCAN_ROOTS as { path: string }[]) { for (const file of collectFiles(path.join(repoRoot, root.path)) as string[]) { @@ -1155,3 +1175,139 @@ describe("this site's own absolute URLs are resolved as internal routes — obje expect(seen.filter((item) => item.route.startsWith('/docs')).length).toBeGreaterThanOrEqual(3); }); }); + +describe('packages/*/README.md joined the disk surface — objectui#3622', () => { + it('reports the four shapes the 11 dead links really had', () => { + // One fixture carrying all four, each next to a live control of the same + // shape — so no verdict here can be right for the wrong reason: + // + // /api/core a route prefix the router has never had (#3490's class) + // /docs/core a REAL directory with no index page, so not a route + // ../../docs/SHA… a disk path that has never existed + // ./LICENSE a disk path this package really does not have + // + // The middle one is the one objectui#3603's own verification script scored + // green, by accepting a bare directory as a fumadocs candidate. The fixture + // materialises `content/docs/core/schema-renderer.mdx` so the directory is + // genuinely there and only the missing index decides the verdict. + expect( + rejections({ + ...SITE_FIXTURE, + 'content/docs/core/schema-renderer.mdx': '# SchemaRenderer', + 'packages/core/README.md': [ + '[api](https://objectui.org/api/core)', + '[group](https://www.objectui.org/docs/core)', + '[renderer](https://www.objectui.org/docs/core/schema-renderer)', + '[sync](../../docs/SHADCN_SYNC.md)', + '[changelog](./CHANGELOG.md)', + '[licence](./LICENSE)', + ].join('\n\n'), + 'packages/core/CHANGELOG.md': '# Changelog', + }), + ).toEqual([ + ['https://objectui.org/api/core', 'site-absolute-url'], + ['https://www.objectui.org/docs/core', 'site-absolute-url'], + ['../../docs/SHADCN_SYNC.md', 'example-relative'], + ['./LICENSE', 'example-relative'], + ]); + }); + + it('expands the wildcard to one path per package directory, sorted', () => { + // The row is the table's only wildcard, so this is where `expandWildcard()` + // is pinned: one path per directory, a package without a README dropped + // (`sdui-parser` on main), and a stable order so the report does not depend + // on the filesystem's. + const repo = repoWith({ + 'packages/gamma/README.md': '# Gamma', + 'packages/alpha/README.md': '# Alpha', + 'packages/beta/index.ts': 'export {}', + }); + + expect( + collectFiles(path.join(repo, 'packages/*/README.md')).map((file: string) => path.relative(repo, file)), + ).toEqual([path.join('packages', 'alpha', 'README.md'), path.join('packages', 'gamma', 'README.md')]); + }); + + it('takes only the README — a package CHANGELOG or TESTING.md stays unscanned', () => { + // The boundary this row deliberately stops at, pinned as a decision rather + // than left to be discovered as coverage that was never there. The README's + // OWN dead link is the control: an expectation of "nothing from the other + // two files" would hold just as well if the row had been dropped entirely. + expect( + rejections({ + 'packages/core/README.md': '[fine](./CHANGELOG.md) and [gone](./NOWHERE.md)', + 'packages/core/CHANGELOG.md': '[also gone](./NOWHERE.md)', + 'packages/core/TESTING.md': '[also gone](./NOWHERE.md)', + 'packages/core/docs/FilterBuilder.md': '[also gone](./NOWHERE.md)', + }), + ).toEqual([['./NOWHERE.md', 'example-relative']]); + }); + + it('does not treat a dependency tree under packages/ as a package', () => { + // `packages/node_modules` is a directory like any other to a wildcard, and + // its READMEs are not ours. Same exclusion the tree walk already applies. + expect( + rejections({ + 'packages/core/README.md': '[gone](./NOWHERE.md)', + 'packages/node_modules/README.md': '[gone](./NOWHERE.md)', + }), + ).toEqual([['./NOWHERE.md', 'example-relative']]); + }); + + it('keeps the docs rules off package READMEs — the contrast pair', () => { + // A package README is read on npm and on GitHub, never served by the site, + // so it takes the `disk` rule. Two hrefs make that visible in both + // directions: a link INTO content/docs is fine from a README (there is no + // collection to escape) and rejected from inside content/docs, while the + // extensionless spelling the docs rule accepts is a 404 on GitHub. + const repo = repoWith({ + ...SITE_FIXTURE, + 'content/docs/guide/a.md': '[readme](../../../packages/core/README.md)', + 'content/docs/fields/lookup.mdx': '# Lookup', + 'packages/core/README.md': '[lookup](../../content/docs/fields/lookup.mdx) and [bare](../../content/docs/fields/lookup)', + }); + + expect(scan(repo).map((item) => [path.relative(repo, item.file), item.href, item.reason])).toEqual([ + [path.join('content', 'docs', 'guide', 'a.md'), '../../../packages/core/README.md', 'escapes-collection'], + [path.join('packages', 'core', 'README.md'), '../../content/docs/fields/lookup', 'example-relative'], + ]); + }); + + it('refuses a wildcard that is not a whole path segment, rather than matching nothing', () => { + // The failure mode a glob row has and a plain path does not: a pattern that + // expands to zero paths is a surface silently dropped, and every test above + // stays green about a tree nobody opened. Loud beats quiet, as with the + // missing route table in `routeExists()`. + const repo = repoWith({ 'packages/plugin-charts/README.md': '# Charts' }); + + expect(() => collectFiles(path.join(repo, 'packages/plugin-*/README.md'))).toThrow(/whole path segment/); + }); + + it('really judges the links these READMEs carry — the floor under the green', () => { + // The anti-vacuous-green floor for this row, and the reason it counts HREFS + // rather than files: the file count above proves the wildcard expanded, but + // a surface of 38 files carrying nothing this gate can decide would still + // leave the repo-wide green meaning "checked nothing". Measured when the row + // landed: 200 decidable hrefs across the 38 READMEs, 47 of them site-absolute + // URLs — the shape 9 of the 11 dead links had. + // The root is read out of SCAN_ROOTS rather than spelled again here, so + // deleting the row takes this floor with it instead of leaving a green test + // measuring a surface the gate no longer scans. + const root = (SCAN_ROOTS as { path: string }[]).find((item) => item.path.startsWith('packages/')); + expect(root).toBeDefined(); + + const decidable: string[] = []; + const siteAbsolute: string[] = []; + for (const file of collectFiles(path.join(repoRoot, root!.path)) as string[]) { + for (const match of stripCode(fs.readFileSync(file, 'utf8')).matchAll(/\[[^\]]+\]\(([^)]+)\)/g)) { + const href = match[1].trim(); + const decidableScheme = siteAbsoluteRoute(href) !== null || selfRepoPath(href) !== null; + if (siteAbsoluteRoute(href) !== null) siteAbsolute.push(href); + if (decidableScheme || !/^(?:#|[a-zA-Z][a-zA-Z0-9+.-]*:)/.test(href)) decidable.push(href); + } + } + + expect(decidable.length).toBeGreaterThanOrEqual(150); + expect(siteAbsolute.length).toBeGreaterThanOrEqual(40); + }); +}); diff --git a/scripts/check-doc-links.mjs b/scripts/check-doc-links.mjs index b77ef08c00..af2a83bab5 100644 --- a/scripts/check-doc-links.mjs +++ b/scripts/check-doc-links.mjs @@ -223,11 +223,10 @@ * link. A gate for prose *about* links inside fences would need to tell an * illustrative route from an executable one, which is a different gate. * - * ### Still not bought - * - * `QUICK_REFERENCE.md`, `AGENTS.md`, `CLAUDE.md` and `CHANGELOG.md` remain - * unscanned. Same one-row price, same caveat — measure the surface first, pay - * its backlog separately, then add the row. + * (A "Still not bought" list closed this section, naming `QUICK_REFERENCE.md`, + * `AGENTS.md`, `CLAUDE.md` and `CHANGELOG.md`. It has moved to the end of the + * objectui#3622 section below, which is the current one; those four are still + * on it.) * * ## Why this file changed again (objectui#3603) * @@ -261,33 +260,61 @@ * dead. The #3572 shape again — the check arrives green, its backlog already * paid. * - * ### Measured and NOT bought: the package READMEs (objectui#3622) - * - * objectui#3603 asked for a second half: growing the scan surface by the 38 - * package READMEs, which is where the 9 dead links that prompted it lived. - * Those 9 are fixed by hand in the same PR. The scan row is **not** added, and - * the reason is this file's own rule from #3572 — measure the surface, pay its - * backlog separately, then add the row. - * - * (Note for whoever writes that row: a glob naming each package README cannot - * be spelled inside this block comment, because the star-slash in it closes the - * comment. Say it in prose here, or move the note outside.) - * - * The surface was measured, and its backlog is not the 9. With the resolution - * above in place, a `disk`-rule row covering those 38 files - * reports **11 more** dead links in five packages that card never touched: - * `/api/core`, `/api/react`, `/api/components` (no such routes — the class - * #3490 swept), `/docs/core`, `/docs/fields`, `/docs/layout` (real directories - * with no index page, so no route), `/docs/types` and `/examples` (neither - * exists), plus two disk paths that are simply absent - * (`../../docs/SHADCN_SYNC.md`, `./LICENSE`). + * ## Why this file changed again (objectui#3622): the package READMEs, bought + * + * The section that stood here was headed "Measured and NOT bought". It recorded + * the price of objectui#3603's other half — growing the scan surface by the 38 + * package READMEs, which is where the 9 dead links that prompted that card lived + * — and refused to add the row until that price was paid, per this file's rule + * from #3572. It is paid, so the row is in the table above. + * + * (Note kept for whoever edits that row: a glob naming each package README + * cannot be spelled inside this block comment, because the star-slash in it + * closes the comment. It is described in prose here and written out only in the + * table below, which is code.) + * + * The price was **11 more** dead links, none of them among #3603's 9. They sit + * in seven packages: five that card never opened, plus two where it had already + * fixed a different line. `/api/core`, `/api/react` and `/api/components` (no + * such routes — the class #3490 swept); `/docs/core`, `/docs/fields` and + * `/docs/layout`, twice (real directories with no index page, so no route); + * `/docs/types` and `/examples` (neither exists); and two disk paths that were + * simply absent (a `docs/SHADCN_SYNC.md` that has never existed, and a + * per-package `LICENSE` that `packages/vscode-extension` does not have). + * + * All eleven were repointed at a page or path that does exist, in the same PR as + * this row — none had to be dropped, unlike #3603's 9, where 6 assumed a whole + * `/docs/packages/...` namespace that was never written. So the row arrives on a + * green tree, the #3572 shape. * * Three of those deserve naming, because objectui#3603's own verification * script scored them GREEN: it accepted a bare DIRECTORY as a fumadocs * candidate. This gate does not, and is right not to — `routeCandidates()` has * no such spelling, and the pinned test "rejects a relative link to a directory * that has no index page" is that decision. `content/docs/core/` really has no - * index page, so `/docs/core` really does 404. The class is 20 links, not 9. + * index page, so `/docs/core` really does 404. The class was 20 links, not 9. + * + * **The rule is `disk`, and that is not a coin toss.** A package README is read + * on npm and on GitHub, never served by the site, so its relative hrefs are + * paths on disk exactly like `examples/`, the root `README.md`, + * `CONTRIBUTING.md`, `ROADMAP.md` and `docs/`: `./CHANGELOG.md` is a file next + * to it, not a route. The same reading forbids the origin-less `/docs/...` + * spelling this file prefers inside `content/docs` — GitHub and npm resolve a + * leading `/` against their own host — which is why the site links repaired + * above all keep the `https://[www.]objectui.org/...` origin, and are still + * checked as strictly as the origin-less form (see the section above). + * + * The one thing the row does need is a wildcard segment in the scan-root path, + * since the surface is one file per package directory rather than a tree. + * `expandWildcard()` is that, and no more than that. + * + * ### Still not bought + * + * `QUICK_REFERENCE.md`, `AGENTS.md`, `CLAUDE.md` and `CHANGELOG.md` remain + * unscanned, along with the non-README markdown inside packages (`CHANGELOG.md`, + * `TESTING.md`, `MIGRATION.md`, per-package `docs/` trees). Same one-row price, + * same caveat — measure the surface first, pay its backlog separately, then add + * the row. * * ## Code spans are stripped before scanning * @@ -346,11 +373,17 @@ const UNSCANNED_DIRS = new Set(['node_modules', 'dist', 'build', '.next', '.turb * * The split is the point (objectui#3536). See the header for why applying the * docs rules to the second group would reject links that render perfectly well: - * over today's `disk` surface it would reject 111 links that all render. + * over today's `disk` surface it would reject 186 links that all render — 111 + * of them before objectui#3622 widened that surface, 75 in the package READMEs + * it added. * * Adding a surface is one row. Adding one that is read on GitHub needs no new * rule class at all — which is why objectui#3572 could take the last three for - * the price of the table entry. + * the price of the table entry, and objectui#3622 the package READMEs. + * + * A row's `path` is a directory to walk, a single markdown file, or a pattern + * whose one wildcard SEGMENT stands for every directory at that level — see + * `expandWildcard()` below, which is all the glob syntax this table has. */ export const SCAN_ROOTS = [ { path: 'content/docs', rule: 'docs' }, @@ -359,6 +392,7 @@ export const SCAN_ROOTS = [ { path: 'CONTRIBUTING.md', rule: 'disk' }, { path: 'ROADMAP.md', rule: 'disk' }, { path: 'docs', rule: 'disk' }, + { path: 'packages/*/README.md', rule: 'disk' }, ]; const blank = (text) => text.replace(/[^\n]/g, ' '); @@ -383,8 +417,49 @@ export function walk(dir, files = []) { return files; } -/** One scan root, which may be a directory to walk or a single markdown file. */ +/** + * Expands the leftmost wildcard segment of a scan root into one path per + * directory at that level, sorted so the scan order — and therefore the report + * order — does not depend on the filesystem's (objectui#3622). `collectFiles` + * recurses, so a later wildcard segment expands on the next pass. + * + * Deliberately not a glob library: a whole segment that is exactly `*`, and + * nothing else. Anything richer THROWS rather than quietly matching nothing, + * because a scan root that expands to zero paths is a surface silently dropped + * — the one failure mode this gate must not have (same stance as the missing + * route table in `routeExists()`). + */ +function expandWildcard(pattern) { + const segments = pattern.split(path.sep); + const index = segments.findIndex((segment) => segment.includes('*')); + if (segments[index] !== '*') { + throw new Error( + `A SCAN_ROOTS path may only use a wildcard as a whole path segment, and got "${pattern}". ` + + 'Widen the row to the directory above, or add the paths one row each.', + ); + } + + const parent = segments.slice(0, index).join(path.sep); + const rest = segments.slice(index + 1); + let entries; + try { + entries = readdirSync(parent, { withFileTypes: true }); + } catch { + return []; // the parent is not here — same contract as a missing scan root + } + + return entries + .filter((entry) => entry.isDirectory() && !UNSCANNED_DIRS.has(entry.name)) + .map((entry) => [parent, entry.name, ...rest].join(path.sep)) + .sort(); +} + +/** + * One scan root, which may be a directory to walk, a single markdown file, or a + * wildcard pattern standing for one path per directory at that level. + */ export function collectFiles(root) { + if (root.includes('*')) return expandWildcard(root).flatMap((expanded) => collectFiles(expanded)); try { if (statSync(root).isFile()) return /\.(md|mdx)$/.test(root) ? [root] : []; } catch { @@ -765,7 +840,7 @@ const HINTS = { ' (the "Package README" form used throughout content/docs/plugins/).', 'example-relative': 'Outside content/docs (examples/**, README.md, CONTRIBUTING.md, ROADMAP.md,' + - ' docs/**) a relative link is a' + + ' docs/** and each package README) a relative link is a' + ' PATH IN THIS REPO, resolved by GitHub against the linking file — so it' + ' must name something that exists and lives inside the repository. A' + ' directory or a non-markdown file is fine; an extensionless spelling of a' +