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
17 changes: 17 additions & 0 deletions .changeset/adr-0046-doc-description.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
"@objectstack/spec": minor
"@objectstack/cli": minor
---

feat(ADR-0046): add optional `description` to package docs

A doc can now carry a one-line `description` (frontmatter `description:`),
giving the natural minimal model: title / summary / body. `DocSchema` gains an
optional `description`; `os build` reads it from frontmatter. It travels in the
`GET /meta/doc` list response (unlike `content`, which the list omits), so a
docs portal can show summaries without fetching each body. Example docs
(app-showcase, app-todo) updated.

Also records the deferred-to-P3 design for doc **tags** in ADR-0046: tags are
keys (i18n-resolved, never display strings), with a small protocol core
vocabulary plus namespace-prefixed package tags — not a field to bolt on early.
2 changes: 1 addition & 1 deletion .objectui-sha
Original file line number Diff line number Diff line change
@@ -1 +1 @@
6164366619a136ba301580322e72a99b8a19c738
893e5302174c7cbf75a7bed9e8e6dcb935273363
9 changes: 7 additions & 2 deletions content/docs/guides/metadata/doc.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -31,19 +31,23 @@ stable (a link resolves by basename, never by path).

### Frontmatter

A leading YAML frontmatter block is optional. Only `title` is read:
A leading YAML frontmatter block is optional. `title` and `description`
are read:

```md
---
title: User Guide
description: How to create, organize, and complete records in CRM.
---

# Getting started with CRM
...
```

The display title resolves in order: frontmatter `title:` → the first
`#` heading → the doc `name`.
`#` heading → the doc `name`. The optional `description` is a one-line
summary the docs portal renders under the title (it travels in the doc
list, so the portal shows summaries without fetching each body).

## Doc Properties

Expand All @@ -53,6 +57,7 @@ When a `*.md` file is collected, it becomes a `doc` item with this shape:
| :--- | :--- | :--- | :--- |
| `name` | `string` | ✅ | Machine name (`snake_case`), from the filename stem. Must match `^[a-z][a-z0-9_]*$` and be **namespace-prefixed** |
| `label` | `string` | — | Display title (from frontmatter `title`) |
| `description` | `string` | — | One-line summary for listings (from frontmatter `description`) |
| `content` | `string` | ✅ | The Markdown body |

You can also declare a doc programmatically with `DocSchema` in a stack
Expand Down
31 changes: 31 additions & 0 deletions docs/adr/0046-package-docs-as-metadata.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,8 @@ export const DocSchema = z.object({
.describe('Unique doc name; MUST carry the package namespace prefix (enforced by build/publish lint)'),
label: z.string().optional()
.describe('Display title; defaults to the first `#` heading, then the name'),
description: z.string().optional()
.describe('One-line summary for listings (frontmatter `description:`); travels in the list response'),
content: z.string().describe('Raw Markdown (CommonMark + GFM)'),
});
```
Expand Down Expand Up @@ -144,6 +146,8 @@ unbounded, and manifest-size pressure lands here first.
legacy, so day-one lint costs nothing — the same reasoning as the
image ban.
- `label` = frontmatter `title:` if present, else first `#` heading.
- `description` = frontmatter `description:` if present (optional one-line
summary; the docs portal renders it under the title).
- `content` = the file body (frontmatter stripped).
- **Subdirectories under `src/docs/` are a build error**, not silently
flattened — flatness is the contract that keeps references stable.
Expand Down Expand Up @@ -240,3 +244,30 @@ maintained, so it cannot rot.

P0 conventions are isomorphic to the P1 schema by construction: when the
compiler lands, pilot content migrates with zero edits.

### P3 design note — tags / categorization (deferred, not bolted on early)

Tags, categories, and ordering are **navigation-model** concerns and stay
in P3 by design — adding them before there is enough doc volume to need
filtering buys an i18n-bearing protocol field with no payoff. When they
land, design the discovery surface (tags + category + search + cross-package
aggregation) as one thing, not field-by-field. The agreed shape for tags:

- **A tag is a stable key, never a display string.** Display always resolves
through the platform's existing label-key → i18n mechanism, exactly like
every other label. Free-form display strings fail twice: cross-package
fragmentation (`setup` vs `getting-started` vs `quickstart`) and no i18n
owner. Keying fixes both.
- **Layered vocabulary, not closed-vs-open.** The protocol blesses a *small
core* of cross-cutting "purpose" tags (`getting-started`, `guide`,
`reference`, `tutorial`, `api`, `migration`, `troubleshooting`) — central
i18n, eligible for dedicated UI. Packages extend with **namespace-prefixed**
tags (`crm_*`, same rule as doc names) for domain topics, shipping their
translations in the package i18n bundle (same path as object/field labels).
- Pure closed enum is too rigid (packages can't express domain topics); pure
open free-form is too fragmented and has no i18n home. The layered
key-based model is the resolution.

The current addition — `description` — deliberately stops short of this: it is
a per-doc summary, not a taxonomy, so it carries no i18n-keying or
cross-package-coherence burden.
1 change: 1 addition & 0 deletions examples/app-showcase/src/docs/showcase_docs_guide.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
---
title: Documentation Guide
description: The authoring rules every package doc must follow — flat directory, namespace prefix, links, and the Markdown subset.
---

# Writing package docs
Expand Down
5 changes: 5 additions & 0 deletions examples/app-showcase/src/docs/showcase_index.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
---
title: Showcase
description: Overview of the showcase package and the docs-as-metadata feature it demonstrates.
---

# Showcase

The living conformance fixture for the ObjectStack protocol: every field
Expand Down
5 changes: 5 additions & 0 deletions examples/app-todo/src/docs/todo_index.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
---
title: Todo App
description: A minimal task-management app that demonstrates the ObjectStack metadata protocol end to end.
---

# Todo App

A minimal task-management app demonstrating the ObjectStack metadata
Expand Down
1 change: 1 addition & 0 deletions examples/app-todo/src/docs/todo_user_guide.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
---
title: User Guide
description: How to create, organize, and complete tasks in the Todo app.
---

# Working with Tasks
Expand Down
12 changes: 12 additions & 0 deletions packages/cli/src/utils/collect-docs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,18 @@ describe('collectDocsFromSrc (ADR-0046 §3.2)', () => {
expect(guide.content).not.toContain('title:'); // frontmatter stripped
});

it('reads optional frontmatter `description:` (and omits it when absent)', () => {
write('crm_index.md', '---\ntitle: CRM\ndescription: Start here.\n---\n\n# CRM\n\nBody.');
write('crm_plain.md', '# Plain\n\nNo frontmatter.');
const { docs, issues } = collectDocsFromSrc(configPath);
expect(issues).toHaveLength(0);
const index = docs.find((d) => d.name === 'crm_index')!;
expect(index.description).toBe('Start here.');
expect(index.content).not.toContain('description:'); // frontmatter stripped
const plain = docs.find((d) => d.name === 'crm_plain')!;
expect(plain.description).toBeUndefined(); // absent → omitted, not ''
});

it('errors on subdirectories — flatness is the contract', () => {
fs.mkdirSync(path.join(docsDir, 'user'));
write('crm_index.md', '# x');
Expand Down
36 changes: 27 additions & 9 deletions packages/cli/src/utils/collect-docs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import path from 'path';
export interface DocItem {
name: string;
label?: string;
description?: string;
content: string;
}

Expand All @@ -41,19 +42,31 @@ export interface DocIssue {

const DOC_NAME_RE = /^[a-z][a-z0-9_]*$/;

/** Strip a leading `---` frontmatter block; extract a `title:` if present. */
function parseFrontmatter(raw: string): { title?: string; body: string } {
/** Extract a single-line scalar `key: value` from a frontmatter block. */
function frontmatterScalar(block: string, key: string): string | undefined {
const re = new RegExp(`^${key}\\s*:`, 'i');
const line = block.split(/\r?\n/).find((l) => re.test(l));
if (!line) return undefined;
const value = line.replace(re, '').trim().replace(/^['"]|['"]$/g, '');
return value || undefined;
}

/**
* Strip a leading `---` frontmatter block; extract `title:` and
* `description:` if present (both optional, single-line scalars).
*/
function parseFrontmatter(raw: string): { title?: string; description?: string; body: string } {
if (!raw.startsWith('---\n') && !raw.startsWith('---\r\n')) return { body: raw };
const end = raw.indexOf('\n---', 3);
if (end === -1) return { body: raw };
const block = raw.slice(raw.indexOf('\n') + 1, end);
const bodyStart = raw.indexOf('\n', end + 1);
const body = bodyStart === -1 ? '' : raw.slice(bodyStart + 1);
const titleLine = block.split(/\r?\n/).find((l) => /^title\s*:/.test(l));
const title = titleLine
? titleLine.replace(/^title\s*:\s*/, '').trim().replace(/^['"]|['"]$/g, '')
: undefined;
return { title: title || undefined, body };
return {
title: frontmatterScalar(block, 'title'),
description: frontmatterScalar(block, 'description'),
body,
};
}

/** Remove fenced code blocks and inline code spans before content scans. */
Expand Down Expand Up @@ -105,8 +118,13 @@ export function collectDocsFromSrc(configPath: string): { docs: DocItem[]; issue
}

const raw = fs.readFileSync(path.join(docsDir, entry.name), 'utf-8');
const { title, body } = parseFrontmatter(raw);
docs.push({ name: stem, label: title ?? firstHeading(body), content: body });
const { title, description, body } = parseFrontmatter(raw);
docs.push({
name: stem,
label: title ?? firstHeading(body),
...(description ? { description } : {}),
content: body,
});
}
return { docs, issues };
}
Expand Down
101 changes: 101 additions & 0 deletions packages/objectql/src/registry-namespace-install-gate.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

/**
* ADR-0048 Phase 1 — install-time namespace gate.
*
* A package's `manifest.namespace` is the mandatory object-name prefix and the
* container that scopes its UI metadata, so it must be unique per installation.
* `installPackage` refuses a package whose namespace is already owned by a
* *different* installed package. Same-package reinstall and shareable platform
* namespaces (`base`/`system`/`sys`) pass through; `OS_METADATA_COLLISION=warn`
* downgrades the refusal to a warning.
*/

import { describe, it, expect, beforeEach } from 'vitest';
import { SchemaRegistry, NamespaceConflictError } from './registry';

const manifest = (id: string, namespace: string) => ({
id,
name: id,
namespace,
version: '1.0.0',
});

describe('SchemaRegistry — namespace install gate (ADR-0048 Phase 1)', () => {
let registry: SchemaRegistry;

beforeEach(() => {
registry = new SchemaRegistry({ multiTenant: false, collisionPolicy: 'error' });
registry.logLevel = 'silent';
});

it('refuses a package whose namespace is already owned by a different package', () => {
registry.installPackage(manifest('com.acme.crm', 'crm') as any);
expect(() =>
registry.installPackage(manifest('com.beta.crm', 'crm') as any),
).toThrowError(NamespaceConflictError);
});

it('error names both packages and the namespace', () => {
registry.installPackage(manifest('com.acme.crm', 'crm') as any);
try {
registry.installPackage(manifest('com.beta.crm', 'crm') as any);
throw new Error('expected a namespace conflict error');
} catch (e) {
expect(e).toBeInstanceOf(NamespaceConflictError);
const err = e as NamespaceConflictError;
expect(err.namespace).toBe('crm');
expect(err.existingPackageId).toBe('com.acme.crm');
expect(err.incomingPackageId).toBe('com.beta.crm');
expect(err.message).toContain('com.acme.crm');
expect(err.message).toContain('com.beta.crm');
expect(err.message).toContain('crm');
}
// The conflicting package must NOT have been recorded.
expect(registry.getPackage('com.beta.crm')).toBeUndefined();
expect(registry.getNamespaceOwners('crm')).toEqual(['com.acme.crm']);
});

it('allows the same package to reinstall/reload its own namespace', () => {
registry.installPackage(manifest('com.acme.crm', 'crm') as any);
expect(() =>
registry.installPackage(manifest('com.acme.crm', 'crm') as any),
).not.toThrow();
});

it('allows two packages with distinct namespaces', () => {
registry.installPackage(manifest('com.acme.crm', 'crm') as any);
expect(() =>
registry.installPackage(manifest('com.acme.hr', 'hr') as any),
).not.toThrow();
});

it('exempts shareable platform namespaces (base/system/sys)', () => {
for (const ns of ['base', 'system', 'sys']) {
registry.installPackage(manifest(`com.a.${ns}`, ns) as any);
expect(() =>
registry.installPackage(manifest(`com.b.${ns}`, ns) as any),
).not.toThrow();
}
});

it('downgrades to a warning under collisionPolicy "warn"', () => {
const warnReg = new SchemaRegistry({ multiTenant: false, collisionPolicy: 'warn' });
warnReg.logLevel = 'silent';
warnReg.installPackage(manifest('com.acme.crm', 'crm') as any);
expect(() =>
warnReg.installPackage(manifest('com.beta.crm', 'crm') as any),
).not.toThrow();
// Both packages are recorded; the namespace now has two owners.
expect(warnReg.getNamespaceOwners('crm').sort()).toEqual(['com.acme.crm', 'com.beta.crm']);
});

it('releases the namespace on uninstall, allowing a different package to claim it', () => {
registry.installPackage(manifest('com.acme.crm', 'crm') as any);
registry.uninstallPackage('com.acme.crm');
expect(() =>
registry.installPackage(manifest('com.beta.crm', 'crm') as any),
).not.toThrow();
expect(registry.getNamespaceOwners('crm')).toEqual(['com.beta.crm']);
});
});
Loading