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
13 changes: 13 additions & 0 deletions .changeset/adr-0046-package-docs-as-metadata.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
"@objectstack/spec": minor
"@objectstack/cli": minor
"@objectstack/objectql": minor
"@objectstack/runtime": patch
---

ADR-0046 P1: package documentation as metadata. New `doc` metadata element — flat Markdown files under `src/docs/*.md` compile into `docs: DocSchema[]` on the stack and register like any other metadata.

- spec: `DocSchema` ({ name, label?, content }) in `system/`, `StackDefinition.docs`, `doc` in `MetadataTypeSchema` + type registry (inert data, runtime-creatable) + canonical schema map, `docs → doc` plural mapping.
- cli: `os build` collects flat `src/docs/*.md` (frontmatter `title:`/first `#` heading → label) and enforces the ADR lint — flat directory, namespace-prefixed snake_case names, namespace required when docs ship, MDX/image ban, same-package relative-link resolution. Same rules surface in `os lint`.
- objectql: `docs` joins the generic metadata registration loop (manifest + nested plugins).
- runtime: docs count as app payload; `GET /metadata/doc` list responses omit `content` by default (`?include=content` opts in) so unbounded manuals stay off hot paths.
394 changes: 195 additions & 199 deletions docs/adr/0046-package-docs-as-metadata.md

Large diffs are not rendered by default.

11 changes: 11 additions & 0 deletions examples/app-todo/src/docs/todo_index.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# Todo App

A minimal task-management app demonstrating the ObjectStack metadata
protocol end to end: objects, views, flows, agents — and this manual,
which ships inside the package as `doc` metadata (ADR-0046).

Each Markdown file in the flat `src/docs/` directory compiles into one
`doc` item at build time; the console renders it at `/docs/<name>`.

To learn how to work with tasks day to day, see the
[user guide](./todo_user_guide.md).
16 changes: 16 additions & 0 deletions examples/app-todo/src/docs/todo_user_guide.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
title: User Guide
---

# Working with Tasks

Create a task from the **Tasks** list view, set a priority, and assign
it to a project. Completed tasks are archived automatically by the
cleanup flow.

Tips:

- Use the kanban view to drag tasks between statuses.
- Overdue tasks are highlighted in the default list view.

Back to the [overview](./todo_index.md).
34 changes: 34 additions & 0 deletions packages/cli/src/commands/compile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { loadConfig } from '../utils/config.js';
import { lowerCallables } from '../utils/lower-callables.js';
import { validateStackExpressions } from '../utils/validate-expressions.js';
import { validateWidgetBindings } from '../utils/validate-widget-bindings.js';
import { collectAndLintDocs } from '../utils/collect-docs.js';
import { buildRuntimeBundle, cleanupOldRuntimeBundles } from '../utils/build-runtime.js';
import {
printHeader,
Expand Down Expand Up @@ -181,6 +182,36 @@ export default class Compile extends Command {
}
}

// 3d. Package docs (ADR-0046): compile flat `src/docs/*.md` into
// `docs: DocSchema[]` and lint the combined set (flatness,
// namespace-prefixed names, MDX/image ban, same-package link
// resolution). Errors fail the build — the artifact is the
// publish unit, so this IS the publish lint for docs.
if (!flags.json) printStep('Collecting package docs (ADR-0046)...');
const docsResult = collectAndLintDocs(absolutePath, result.data as Record<string, unknown>);
const docErrors = docsResult.issues.filter((i) => i.severity === 'error');
const docWarnings = docsResult.issues.filter((i) => i.severity === 'warning');
if (docErrors.length > 0) {
if (flags.json) {
console.log(JSON.stringify({ success: false, error: 'docs validation failed', issues: docErrors }));
this.exit(1);
}
console.log('');
printError(`Package docs validation failed (${docErrors.length} issue${docErrors.length > 1 ? 's' : ''})`);
for (const i of docErrors.slice(0, 50)) {
console.log(` • ${i.path}: ${i.message}`);
console.log(chalk.dim(` rule: ${i.rule}`));
}
this.exit(1);
}
if (docWarnings.length > 0 && !flags.json) {
console.log('');
for (const w of docWarnings) {
printWarning(`${w.path}: ${w.message}`);
console.log(chalk.dim(` rule: ${w.rule}`));
}
}

// 4. Generate Artifact
if (!flags.json) printStep('Writing artifact...');
const output = flags.output!;
Expand All @@ -192,6 +223,9 @@ export default class Compile extends Command {
}

const finalBundle: Record<string, unknown> = { ...(result.data as Record<string, unknown>) };
if (docsResult.docs.length > 0) {
finalBundle.docs = docsResult.docs;
}

// 4b. Bundle handler functions into `<artifactDir>/objectstack-runtime.{hash}.mjs`
// and stamp the relative path into the JSON so the runtime can
Expand Down
8 changes: 8 additions & 0 deletions packages/cli/src/commands/lint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { loadConfig, BUNDLE_REQUIRE_EXTERNALS } from '../utils/config.js';
import { computeI18nCoverage } from '../utils/i18n-coverage.js';
import { lintDataModel } from '../lint/data-model-rules.js';
import { validateWidgetBindings } from '../utils/validate-widget-bindings.js';
import { collectAndLintDocs } from '../utils/collect-docs.js';
import { scoreMetadata } from '../lint/score.js';
import { runMetadataEval } from '../lint/metadata-eval.js';
import { DEFAULT_METADATA_EVAL_CORPUS } from '../lint/corpus.js';
Expand Down Expand Up @@ -303,6 +304,13 @@ export default class Lint extends Command {
const normalized = normalizeStackInput(config as Record<string, unknown>);
const issues = lintConfig(normalized);

// ── Package docs (ADR-0046) ── collected src/docs/*.md + inline docs:
// flatness, namespace-prefixed names, MDX/image ban, link resolution.
const docsResult = collectAndLintDocs(absolutePath, normalized as Record<string, unknown>);
for (const d of docsResult.issues) {
issues.push({ severity: d.severity, rule: d.rule, message: d.message, path: d.path });
}

// ── Translation coverage ──
if (!flags['skip-i18n']) {
const coverage = computeI18nCoverage(normalized, {
Expand Down
128 changes: 128 additions & 0 deletions packages/cli/src/utils/collect-docs.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import fs from 'fs';
import os from 'os';
import path from 'path';
import { collectDocsFromSrc, lintDocs, collectAndLintDocs, type DocItem } from './collect-docs.js';

let tmp: string;
let configPath: string;
let docsDir: string;

beforeEach(() => {
tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'os-docs-'));
configPath = path.join(tmp, 'objectstack.config.ts');
fs.writeFileSync(configPath, '// stub');
docsDir = path.join(tmp, 'src', 'docs');
fs.mkdirSync(docsDir, { recursive: true });
});

afterEach(() => {
fs.rmSync(tmp, { recursive: true, force: true });
});

const write = (name: string, content: string) => fs.writeFileSync(path.join(docsDir, name), content);

describe('collectDocsFromSrc (ADR-0046 §3.2)', () => {
it('compiles each flat .md file into a doc item (stem = name)', () => {
write('crm_index.md', '# CRM Overview\n\nWhat it is.');
write('crm_lead_guide.md', '---\ntitle: Lead Guide\n---\n\nBody here.');
const { docs, issues } = collectDocsFromSrc(configPath);
expect(issues).toHaveLength(0);
expect(docs.map((d) => d.name).sort()).toEqual(['crm_index', 'crm_lead_guide']);
const index = docs.find((d) => d.name === 'crm_index')!;
expect(index.label).toBe('CRM Overview'); // first # heading
const guide = docs.find((d) => d.name === 'crm_lead_guide')!;
expect(guide.label).toBe('Lead Guide'); // frontmatter title wins
expect(guide.content).not.toContain('title:'); // frontmatter stripped
});

it('errors on subdirectories — flatness is the contract', () => {
fs.mkdirSync(path.join(docsDir, 'user'));
write('crm_index.md', '# x');
const { docs, issues } = collectDocsFromSrc(configPath);
expect(issues.some((i) => i.rule === 'docs/flat-directory' && i.severity === 'error')).toBe(true);
expect(docs).toHaveLength(1); // the valid file still collects
});

it('errors on non-snake_case filename stems', () => {
write('Lead-Guide.md', '# x');
const { docs, issues } = collectDocsFromSrc(configPath);
expect(issues.some((i) => i.rule === 'docs/filename')).toBe(true);
expect(docs).toHaveLength(0);
});

it('ignores non-markdown files and returns empty when src/docs is absent', () => {
write('notes.txt', 'not a doc');
expect(collectDocsFromSrc(configPath).docs).toHaveLength(0);
fs.rmSync(docsDir, { recursive: true });
expect(collectDocsFromSrc(configPath).docs).toHaveLength(0);
});
});

describe('lintDocs (ADR-0046 §3.2–§3.4)', () => {
const doc = (name: string, content: string): DocItem => ({ name, content });

it('requires manifest.namespace when docs ship', () => {
const issues = lintDocs([doc('crm_index', 'x')], undefined);
expect(issues.some((i) => i.rule === 'docs/namespace-required')).toBe(true);
});

it('requires the namespace prefix on every doc name', () => {
const issues = lintDocs([doc('lead_guide', 'x')], 'crm');
const hit = issues.find((i) => i.rule === 'docs/namespace-prefix');
expect(hit?.severity).toBe('error');
expect(hit?.message).toContain('crm_lead_guide');
});

it('rejects duplicate names across inline + collected docs', () => {
const issues = lintDocs([doc('crm_index', 'a'), doc('crm_index', 'b')], 'crm');
expect(issues.some((i) => i.rule === 'docs/duplicate-name')).toBe(true);
});

it('bans image references (v1 text-only)', () => {
const issues = lintDocs([doc('crm_index', 'See ![screenshot](https://x/y.png)')], 'crm');
expect(issues.some((i) => i.rule === 'docs/no-images')).toBe(true);
});

it('bans MDX/JSX but tolerates code blocks that mention it', () => {
expect(
lintDocs([doc('crm_a', 'Use <Tabs items={x}> here')], 'crm')
.some((i) => i.rule === 'docs/no-mdx'),
).toBe(true);
expect(
lintDocs([doc('crm_b', 'Example:\n\n```jsx\n<Tabs items={x} />\n```\n\nplain prose')], 'crm')
.some((i) => i.rule === 'docs/no-mdx'),
).toBe(false);
});

it('resolves same-package relative links and flags broken ones', () => {
const docs = [
doc('crm_index', 'See the [guide](./crm_lead_guide.md#start) and [missing](./crm_nope.md).'),
doc('crm_lead_guide', 'Back to [index](crm_index.md).'),
];
const issues = lintDocs(docs, 'crm');
const broken = issues.filter((i) => i.rule === 'docs/broken-link');
expect(broken).toHaveLength(1);
expect(broken[0].message).toContain('crm_nope');
});

it('leaves cross-package links (foreign prefix) to publish-time checks', () => {
const issues = lintDocs([doc('crm_index', 'See [billing](./billing_setup.md).')], 'crm');
expect(issues.some((i) => i.rule === 'docs/broken-link')).toBe(false);
});
});

describe('collectAndLintDocs', () => {
it('merges inline stack docs with collected files and lints the union', () => {
write('crm_admin_setup.md', '# Admin Setup\n\nSee [index](./crm_index.md).');
const stack = {
manifest: { namespace: 'crm' },
docs: [{ name: 'crm_index', content: '# CRM' }],
};
const { docs, issues } = collectAndLintDocs(configPath, stack);
expect(docs.map((d) => d.name).sort()).toEqual(['crm_admin_setup', 'crm_index']);
expect(issues).toHaveLength(0);
});
});
Loading