Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 19 additions & 5 deletions content/docs/guide/ci-cd-pipeline.md
Original file line number Diff line number Diff line change
Expand Up @@ -251,10 +251,24 @@ all**, which is the point of the workflow. It appears in the checks list as **In
Check**.

Runs `scripts/check-doc-links.mjs`, which walks every `.md` / `.mdx` file under `content/docs/` and
resolves each internal markdown link against the files actually on disk (`/docs/foo` must have a
`foo.md`, `foo.mdx` or `foo/index.md*` under `content/docs/`). External `http(s)` and `mailto:`
links and bare `#anchors` are skipped — those belong to Lychee, below. No install, no build, no
network: a checkout and one `node` call.
asks of each internal markdown link the only question a reader cares about — **does the site serve
this URL?** Four checks, by href shape:

| Href shape | Resolved against | Rejected when |
|---|---|---|
| relative (`../plugins/plugin-charts.mdx`) | the linking file's directory | the target file is missing… |
| relative escaping the collection (`../../../packages/x/README.md`) | — | …**or** it resolves outside `content/docs/` (fumadocs can only resolve inside its page index, so the href reaches the browser verbatim — a 404 even though the file exists) |
| absolute `/docs/...` | `content/docs/` as a **route** | no `foo.md`, `foo.mdx` or `foo/index.md*` backs it — a `.md`/`.mdx` suffix always fails, since that URL 404s whatever is on disk |
| any other absolute (`/spec/...`, `/img/...`) | the **site itself**: route segments enumerated from `apps/site/app`, plus static files under `apps/site/public` | no route pattern or static file matches |

External `http(s)` and `mailto:` links and bare `#anchors` are skipped — those belong to Lychee,
below. No install, no build, no network: a checkout and one `node` call.

The last two rows are objectui#3490. Reading `apps/site` widens the script's responsibility, and
that is the deliberate purchase: it is the only way to catch a link to a route that does not exist,
and 18 such 404s had accumulated while the check waved every non-`/docs` absolute href through. The
cost is that a docs PR can now go red because `apps/site` moved under it — correct, but real. The
header of the script argues the trade-off in full.

**Why it blocks a merge.** A broken internal link is a 404 on the published site, and nothing else
in CI sees it: the site build succeeds with a dead link in it. The script itself is older than its
Expand Down Expand Up @@ -292,7 +306,7 @@ There are **two** link checkers, and they cover different things (objectui#3213)

| | Covers | Network | Runs |
|---|---|---|---|
| `scripts/check-doc-links.mjs` | **Internal** `/docs/...` routes, resolved against `content/docs/` | 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, and every other site-absolute href (against `apps/site`) | 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
Expand Down
8 changes: 4 additions & 4 deletions content/docs/guide/component-registry.md
Original file line number Diff line number Diff line change
Expand Up @@ -459,10 +459,10 @@ export { RatingComponent }

- [Expression System](./expressions.md) - Learn about dynamic expressions
- [Schema Rendering](./schema-rendering.md) - Understand the rendering engine
- [Creating Custom Components](/spec/component-package.md) - Deep dive into component creation
- [Custom Plugin Development](/docs/guide/plugin-development) - Deep dive into component creation

## Related Documentation

- [Core API](/api/core) - Component registry API
- [React API](/api/react) - React integration
- [Component Specification](/spec/component.md) - Component metadata spec
- [`@object-ui/core` README](https://github.com/objectstack-ai/objectui/tree/main/packages/core) - Component registry API
- [`@object-ui/react` README](https://github.com/objectstack-ai/objectui/tree/main/packages/react) - React integration
- [Schema Type Reference](/docs/api/schema-reference) - Component metadata reference
2 changes: 1 addition & 1 deletion content/docs/guide/data-source.md
Original file line number Diff line number Diff line change
Expand Up @@ -199,5 +199,5 @@ behaviour: probe `/batch` and fall back to the non-atomic emulation on
`404`/`405`/`501`. Atomic cross-object saves are therefore guaranteed only
against backends that advertise the capability; older ones still save, but
best-effort. See the
[adapter README](../../../packages/data-objectstack/README.md#cross-object-atomic-batch-batchtransaction)
[adapter README](https://github.com/objectstack-ai/objectui/blob/main/packages/data-objectstack/README.md#cross-object-atomic-batch-batchtransaction)
for the full capability table and minimum-backend note.
8 changes: 4 additions & 4 deletions content/docs/guide/expressions.md
Original file line number Diff line number Diff line change
Expand Up @@ -636,10 +636,10 @@ const data: UserData = { /* ... */ }

- [Schema Rendering](./schema-rendering.md) - Learn the rendering engine
- [Component Registry](./component-registry.md) - Understand components
- [Protocol Overview](/protocol/overview) - Explore schema specifications
- [Schema Overview](/docs/guide/schema-overview) - Explore schema specifications

## Related Documentation

- [Core API](/api/core) - Expression evaluator API
- [Form Protocol](/protocol/form) - Form-specific expressions
- [View Protocol](/protocol/view) - Data view expressions
- [`@object-ui/core` README](https://github.com/objectstack-ai/objectui/tree/main/packages/core) - Expression evaluator API
- [Form Plugin](/docs/plugins/plugin-form) - Form-specific expressions
- [View Plugin](/docs/plugins/plugin-view) - Data view expressions
2 changes: 1 addition & 1 deletion content/docs/guide/fields.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ Once registered, you can simply use the new type in your JSON schema.

## Standard Fields

Object UI comes with built-in support for the standard [ObjectStack Protocol](/protocol) types:
Object UI comes with built-in support for the standard [ObjectStack Protocol](https://github.com/objectstack-ai/objectstack/tree/main/packages/spec) types:

| Type | Description |
|---|---|
Expand Down
4 changes: 2 additions & 2 deletions content/docs/guide/objectos-integration.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -659,8 +659,8 @@ test('create contact workflow', async ({ page }) => {
- [ObjectStack Documentation](https://docs.objectstack.ai)
- [ObjectUI Components Reference](/docs/components)
- [ObjectQL Schemas](/docs/api/schema-reference#objectql-schemas)
- [Example: CRM Application](/examples/crm)
- [Example: Kitchen Sink](/examples/kitchen-sink)
- [Example: ObjectStack Console Starter](https://github.com/objectstack-ai/objectui/tree/main/examples/console-starter)
- [Schema Catalog](/docs/guide/schema-catalog) - every schema rendered in these docs

## Support

Expand Down
2 changes: 1 addition & 1 deletion content/docs/guide/plugins.md
Original file line number Diff line number Diff line change
Expand Up @@ -512,7 +512,7 @@ export function registerComponents() {

- [Component Registry](./component-registry.md) - Understanding the registry
- [Schema Rendering](./schema-rendering.md) - How schemas become UI
- [Creating Components](/spec/component-package.md) - Component development
- [Custom Plugin Development](/docs/guide/plugin-development) - Component development
- **[Create Plugin Utility](/docs/utilities/create-plugin)** - Scaffold new plugins quickly
- **[CLI Tool](/docs/utilities/cli)** - Test plugins with the CLI
- **[All Utilities](/docs/utilities)** - Complete toolkit for development
Expand Down
10 changes: 5 additions & 5 deletions content/docs/guide/schema-rendering.md
Original file line number Diff line number Diff line change
Expand Up @@ -411,11 +411,11 @@ Always type your schemas for better IDE support and fewer runtime errors.

- [Component Registry](./component-registry.md) - Learn about component registration
- [Expression System](./expressions.md) - Master expressions
- [Protocol Overview](/protocol/overview) - Explore all available schemas
- [Schema Overview](/docs/guide/schema-overview) - Explore all available schemas

## Related Documentation

- [Schema Specification](/spec/schema-rendering) - Technical specification
- [Architecture](/spec/architecture) - System architecture
- [Core API](/api/core) - Core package API reference
- [React API](/api/react) - React package API reference
- [SchemaRenderer](/docs/core/schema-renderer) - Technical reference for the renderer
- [Architecture Overview](/docs/guide/architecture) - System architecture
- [`@object-ui/core` README](https://github.com/objectstack-ai/objectui/tree/main/packages/core) - Core package API reference
- [`@object-ui/react` README](https://github.com/objectstack-ai/objectui/tree/main/packages/react) - React package API reference
209 changes: 195 additions & 14 deletions scripts/__tests__/check-doc-links.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import os from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';

import { collectBrokenLinks, routeExists, stripCode } from '../check-doc-links.mjs';
import { collectBrokenLinks, collectSiteRoutes, routeExists, siteUrlExists, stripCode } from '../check-doc-links.mjs';

/**
* objectui#3479 — the behaviour test for `scripts/check-doc-links.mjs`.
Expand All @@ -27,13 +27,34 @@ import { collectBrokenLinks, routeExists, stripCode } from '../check-doc-links.m
*
* The A-class defect in #3479 is exactly the first shape violated: 13 links
* spelled `../plugins/plugin-*.md` while every one of those files is `.mdx`.
*
* objectui#3490 removed the two waivers those checks were still built on — a
* non-`/docs` absolute href was waved through unchecked, and a relative href was
* judged purely as a path on disk. Both answered "does a file exist?" when the
* question is "does the site serve this URL?", and 18 live 404s had collected
* behind them. The gate now enumerates `apps/site/app` + `apps/site/public` as
* its truth source for absolute hrefs, and rejects relative hrefs that resolve
* out of the docs collection. The describes at the bottom pin both.
*/

const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
const tempRoots: string[] = [];

/** Materialises `{ 'guide/a.md': '...' }` into a throwaway docs root. */
function docsRootWith(files: Record<string, string>): string {
/**
* The default site fixture: the shapes the real `apps/site/app` uses — a route
* group serving `/`, a plain segment, an optional catch-all, a nested route
* handler — plus one static file under `public/`.
*/
const SITE_FIXTURE: Record<string, string> = {
'apps/site/app/(home)/page.tsx': 'export default () => null',
'apps/site/app/playground/page.tsx': 'export default () => null',
'apps/site/app/docs/[[...slug]]/page.tsx': 'export default () => null',
'apps/site/app/api/search/route.ts': 'export const GET = () => null',
'apps/site/public/img/guide/shot.png': 'PNG',
};

/** Materialises `{ 'content/docs/guide/a.md': '...' }` into a throwaway repo. */
function repoWith(files: Record<string, string>): string {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'check-doc-links-'));
tempRoots.push(root);
for (const [rel, contents] of Object.entries(files)) {
Expand All @@ -44,9 +65,21 @@ function docsRootWith(files: Record<string, string>): string {
return root;
}

/** Docs-only sugar: keys are relative to `content/docs`, site fixture implied. */
function docsRootWith(files: Record<string, string>): string {
const prefixed = Object.fromEntries(Object.entries(files).map(([rel, body]) => [`content/docs/${rel}`, body]));
return path.join(repoWith({ ...SITE_FIXTURE, ...prefixed }), 'content/docs');
}

function scan(repo: string): { file: string; href: string; line: number; reason: string }[] {
return collectBrokenLinks(path.join(repo, 'content/docs'), path.join(repo, 'apps/site'));
}

/** The hrefs reported broken, in file order. */
function brokenHrefs(files: Record<string, string>): string[] {
return collectBrokenLinks(docsRootWith(files)).map((item: { href: string }) => item.href);
const docsRoot = docsRootWith(files);
const repo = path.resolve(docsRoot, '../..');
return scan(repo).map((item) => item.href);
}

afterAll(() => {
Expand Down Expand Up @@ -179,14 +212,16 @@ describe('absolute /docs hrefs stay strict — they are routes, not files', () =
expect(brokenHrefs({ 'index.md': '[home](/docs)' })).toEqual([]);
});

it('waves through site routes outside the docs collection', () => {
// `/api/core`, `/img/...`, `/spec/...` are not in this collection and cannot
// be resolved from `content/docs` alone.
it('is checked before the site-route table, so the /docs rule stays the strict one', () => {
// `/docs/[[...slug]]` is a real route in the site fixture, so the generic
// route matcher would accept `/docs/guide/b.md`. It must not get the chance:
// the extension makes it a 404, and the `/docs` branch above owns that call.
expect(
brokenHrefs({
'guide/a.md': '[api](/api/core) ![shot](/img/guide/x.png)',
'guide/a.md': '[b](/docs/guide/b.md)',
'guide/b.md': '# B',
}),
).toEqual([]);
).toEqual(['/docs/guide/b.md']);
});
});

Expand Down Expand Up @@ -263,11 +298,7 @@ describe('the repo it guards', () => {
it('has no broken internal docs links', () => {
// The other half of objectui#3479: the extended check must land GREEN, not
// arrive with a backlog it merely describes.
const broken = collectBrokenLinks(path.join(repoRoot, 'content/docs')) as {
file: string;
href: string;
line: number;
}[];
const broken = scan(repoRoot);
const report = broken.map((b) => `${path.relative(repoRoot, b.file)}:${b.line} -> ${b.href}`);
expect(report).toEqual([]);
});
Expand All @@ -279,4 +310,154 @@ describe('the repo it guards', () => {
expect(routeExists('../plugins/plugin-charts.mdx', { fromFile, docsRoot: root })).toBe(true);
expect(routeExists('../plugins/plugin-charts.md', { fromFile, docsRoot: root })).toBe(false);
});

it('reads the real apps/site tree as its route truth source', () => {
// The coupling objectui#3490 deliberately bought, pinned against the actual
// site: the enumerator must understand this router's shapes (a `(home)`
// group serving `/`, dot-named literal segments, an optional catch-all) and
// must NOT invent the prefixes the 17 dead links assumed.
const site = collectSiteRoutes(path.join(repoRoot, 'apps/site'));

for (const served of ['/', '/playground', '/llms.txt', '/docs/guide/expressions', '/logo.svg']) {
expect([served, siteUrlExists(served, site)]).toEqual([served, true]);
}
for (const dead of ['/spec/component.md', '/protocol/overview', '/api/core', '/api/react', '/examples/crm']) {
expect([dead, siteUrlExists(dead, site)]).toEqual([dead, false]);
}
});
});

describe('absolute non-/docs hrefs are resolved against the site — objectui#3490 B-class', () => {
it('reports the shape all 17 dead links had: a prefix the router has no segment for', () => {
// The assertion that dies if `if (cleanHref.startsWith('/')) return true;`
// ever comes back. `/spec`, `/protocol`, `/examples` are not route segments
// in apps/site/app, and never were.
expect(
brokenHrefs({
'guide/a.md': [
'[spec](/spec/component-package.md)',
'[protocol](/protocol/overview)',
'[example](/examples/crm)',
].join('\n\n'),
}),
).toEqual(['/spec/component-package.md', '/protocol/overview', '/examples/crm']);
});

it('reports a sibling of a real segment — /api exists, /api/core does not', () => {
// The near-miss that makes prefix-allowlisting useless: the fixture has
// `app/api/search/route.ts`, so `/api` is a real path prefix. It is not a
// route, and neither is `/api/core`.
expect(
brokenHrefs({ 'guide/a.md': '[core](/api/core) and [search](/api/search)' }),
).toEqual(['/api/core']);
});

it('accepts a static file under public/ — the 3 /img links in #3490 were fine', () => {
expect(brokenHrefs({ 'guide/a.md': '![shot](/img/guide/shot.png)' })).toEqual([]);
});

it('reports a public/ path that does not exist', () => {
expect(brokenHrefs({ 'guide/a.md': '![gone](/img/guide/missing.png)' })).toEqual(['/img/guide/missing.png']);
});

it('accepts a plain route and the site root, trailing slash or not', () => {
expect(brokenHrefs({ 'guide/a.md': '[p](/playground) [q](/playground/) [home](/)' })).toEqual([]);
});

it('refuses to judge an absolute href with no route table rather than waving it through', () => {
// A silent `true` here is exactly how the 18 accumulated. Callers that skip
// the truth source get an error, not a green.
const docsRoot = docsRootWith({ 'guide/a.md': '# A' });
expect(() => routeExists('/spec/component.md', { fromFile: path.join(docsRoot, 'guide/a.md'), docsRoot })).toThrow(
/site route table/,
);
});
});

describe('the route table follows Next.js segment semantics', () => {
function tableFor(files: Record<string, string>) {
return collectSiteRoutes(path.join(repoWith(files), 'apps/site'));
}

it('drops route groups, skips private and slot folders, and needs a page or route file', () => {
const site = tableFor({
'apps/site/app/(marketing)/pricing/page.tsx': 'x',
'apps/site/app/_internal/secret/page.tsx': 'x',
'apps/site/app/components/Thing.tsx': 'x',
'apps/site/app/feed/route.ts': 'x',
});

expect(siteUrlExists('/pricing', site)).toBe(true);
expect(siteUrlExists('/(marketing)/pricing', site)).toBe(false);
expect(siteUrlExists('/_internal/secret', site)).toBe(false);
expect(siteUrlExists('/components', site)).toBe(false);
expect(siteUrlExists('/feed', site)).toBe(true);
});

it('matches dynamic, catch-all and optional catch-all segments by arity', () => {
const site = tableFor({
'apps/site/app/blog/[slug]/page.tsx': 'x',
'apps/site/app/og/[...slug]/page.tsx': 'x',
'apps/site/app/wiki/[[...slug]]/page.tsx': 'x',
});

expect(siteUrlExists('/blog/hello', site)).toBe(true);
expect(siteUrlExists('/blog', site)).toBe(false);
expect(siteUrlExists('/blog/a/b', site)).toBe(false);

expect(siteUrlExists('/og/a', site)).toBe(true);
expect(siteUrlExists('/og/a/b', site)).toBe(true);
expect(siteUrlExists('/og', site)).toBe(false);

expect(siteUrlExists('/wiki', site)).toBe(true);
expect(siteUrlExists('/wiki/a/b', site)).toBe(true);
});
});

describe('relative hrefs may not leave the collection — objectui#3490 A-class', () => {
it('reports a link out of content/docs even though the file is really there', () => {
// `guide/data-source.md:202` pointed at `../../../packages/data-objectstack/
// README.md#...`. The file exists, so `lychee --offline` and the pre-#3490
// check both passed it; the site still 404s, because fumadocs can only
// resolve inside the docs page index. The fixture materialises the target,
// so this test fails if existence is ever allowed to decide the verdict.
const repo = repoWith({
...SITE_FIXTURE,
'content/docs/guide/data-source.md': '[adapter README](../../../packages/data-objectstack/README.md#batch)',
'packages/data-objectstack/README.md': '# Adapter',
});

expect(fs.existsSync(path.join(repo, 'packages/data-objectstack/README.md'))).toBe(true);
expect(scan(repo).map((item) => [item.href, item.reason])).toEqual([
['../../../packages/data-objectstack/README.md#batch', 'escapes-collection'],
]);
});

it('still accepts relative links that stay inside the collection', () => {
expect(
brokenHrefs({
'guide/plugins.md': '[Charts](../plugins/plugin-charts.mdx) and [self](./plugins.md)',
'plugins/plugin-charts.mdx': '# Charts',
}),
).toEqual([]);
});

it('labels each failure with the check that rejected it', () => {
const repo = repoWith({
...SITE_FIXTURE,
'content/docs/guide/a.md': [
'[out](../../../elsewhere/README.md)',
'[site](/spec/component.md)',
'[docs](/docs/guide/a.md)',
'[rel](./nowhere.md)',
].join('\n\n'),
});

expect(scan(repo).map((item) => item.reason)).toEqual([
'escapes-collection',
'site-route',
'docs-route',
'relative',
]);
});
});
Loading
Loading