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
31 changes: 31 additions & 0 deletions .changeset/docs-gen-same-dir-bare-path.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
---
"@objectstack/spec": patch
---

fix(spec): 参考页里写在同目录的裸源码路径不再以纯文本落地 (#6484)

参考页开篇那段模块描述由 `packages/spec/scripts/lib/file-description.ts` 渲染。它把
JSDoc 里裸写的 `*.zod.ts` 路径改写成站内链接,而这条机制的**两侧**过去都要求路径里
至少有一个目录段:改写正则的 `[\w-]+/` 分组是必需的,`build-docs.ts` 的
`sourcePathToDocsRoute()` 也要求那个斜杠、并把第一段读作分类名。

于是作者按最自然的方式引用邻居 —— 写 `auth.zod.ts` 而不是 `identity/auth.zod.ts` ——
两侧都匹配不上,既没成链接,也没回退成代码段,以**纯文本**发布在四张参考页上,共 9 处:
`api/realtime-shared`、`cloud/package`、`identity/identity`、`system/security-context`。

缺的从来不是正则,而是**上下文**:`build-docs.ts` 按分类遍历,自己知道正在渲染哪个目录,
却只把一个成员交给渲染方。现在 `FileDescriptionContext` 增加 `fromCategory`,由
`build-docs.ts` 传入,裸文件名在渲染方补全成 `<分类>/<文件>` 后再去解析 —— 与
`schemaHrefFrom(fromCategory)` 是同一道缝。补全放在调用方一侧是有意的:裸名不是身份
(#4696),`auth.zod.ts` 在多个分类下都存在,让解析器自己去全分类搜同名文件只会答出
目录遍历最后到达的那一个。

读者可见的变化是这 9 处:**5 处成为可点链接**(`api/realtime`、`api/websocket`、
`cloud/package-version`、`cloud/environment-package`、`system/encryption`),**4 处回退成
代码段**(`auth`、`audit`、`compliance`、`masking` —— 这四个邻居本就不存在,按 #6229
的规矩「目标没有页面就不发链接」)。纯文本是三种结果里唯一错的那种,现在一处不剩。

`sourcePathToDocsRoute()` 同时补上了它文档里一直声明、实现却没做的那一半:分类是真的
不等于页面存在。旧实现只校验分类,这在放宽之前侥幸成立(能匹配上的路径恰好都有页面);
放宽后那 4 个不存在的邻居会各产出一条 404 链接。现在按本次运行真正发出的页面清单判断,
全语料 216 条站内路由、437 个位置,无死链。
4 changes: 2 additions & 2 deletions content/docs/references/api/realtime-shared.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,9 @@ realtime protocol (SSE/Polling/WebSocket) and the WebSocket collaboration protoc
- `realtime.zod.ts` — Transport-layer protocol (Channel, Subscription, Transport selection)
- `websocket.zod.ts` — Collaboration protocol (Cursor, OT editing, Advanced presence)

See also: realtime.zod.ts for transport-layer configuration
See also: [realtime.zod.ts](/docs/references/api/realtime) for transport-layer configuration

See also: websocket.zod.ts for collaborative editing protocol
See also: [websocket.zod.ts](/docs/references/api/websocket) for collaborative editing protocol

<Callout type="info">
**Source:** `packages/spec/src/api/realtime-shared.zod.ts`
Expand Down
4 changes: 2 additions & 2 deletions content/docs/references/cloud/package.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,8 @@ flows, translations, agents — into a named, versioned artifact.

Architecture:
- `sys_package` — identity (one row per logical package)
- `sys_package_version` — immutable release snapshots (see package-version.zod.ts)
- `sys_package_installation` — env ↔ version pairing (see environment-package.zod.ts)
- `sys_package_version` — immutable release snapshots (see [package-version.zod.ts](/docs/references/cloud/package-version))
- `sys_package_installation` — env ↔ version pairing (see [environment-package.zod.ts](/docs/references/cloud/environment-package))

See `docs/adr/0003-package-as-first-class-citizen.md` for the full rationale.

Expand Down
2 changes: 1 addition & 1 deletion content/docs/references/identity/identity.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ Identity & User Model Specification
Defines the standard user, account, and session data models for ObjectStack.
These schemas represent "who is logged in" and their associated data.

This is separate from authentication configuration (auth.zod.ts) which
This is separate from authentication configuration (`auth.zod.ts`) which
defines "how to login".

<Callout type="info">
Expand Down
8 changes: 4 additions & 4 deletions content/docs/references/system/security-context.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,11 @@ Provides a central governance layer that correlates and unifies
the four independent security subsystems it was designed against. Three of
the four have since been REMOVED per ADR-0056 D8 (declared-but-never-enforced;
see system/index.ts notes) — only encryption survives, marked experimental:
- **Audit** (audit.zod.ts — REMOVED): the live audit path is plugin-audit's
- **Audit** (`audit.zod.ts` — REMOVED): the live audit path is plugin-audit's
always-on capture + object/field `trackHistory` + lifecycle `audit` retention
- **Encryption** (encryption.zod.ts): Field-level encryption and key management
- **Compliance** (compliance.zod.ts — REMOVED): GDPR/HIPAA/SOX/PCI-DSS configs
- **Masking** (masking.zod.ts — REMOVED): PII data masking and tokenization
- **Encryption** ([encryption.zod.ts](/docs/references/system/encryption)): Field-level encryption and key management
- **Compliance** (`compliance.zod.ts` — REMOVED): GDPR/HIPAA/SOX/PCI-DSS configs
- **Masking** (`masking.zod.ts` — REMOVED): PII data masking and tokenization

This schema enforces cross-cutting security policies, ensuring compliance
frameworks drive encryption requirements, masking rules respect role-based
Expand Down
112 changes: 83 additions & 29 deletions packages/spec/scripts/build-docs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -280,16 +280,77 @@ function schemaHrefFrom(fromCategory: string): (name: string) => string | null {
}


/**
* Every page this run publishes: `category` -> page slug -> the schemas that
* page documents.
*
* Grouped ONCE and read twice — by §2 below, which emits the pages, and by
* `sourcePathToDocsRoute`, which has to answer "is there a page for this file?"
* while §2 is still part-way through the categories. Neither of the two obvious
* shortcuts can answer it: asking the sink (`wasEmitted`) makes the reply depend
* on which category the walk reached first, and asking the disk makes a run's
* output depend on the previous run's, so a deleted page would keep resolving
* until someone regenerated twice.
*/
function groupSchemasByPage(): Map<string, Map<string, Array<{ name: string; content: any }>>> {
const byCategory = new Map<string, Map<string, Array<{ name: string; content: any }>>>();

for (const category of Object.keys(CATEGORIES)) {
const categorySchemaDir = path.join(SCHEMA_DIR, category);
if (!fs.existsSync(categorySchemaDir)) {
console.log(`Warning: Schema directory ${categorySchemaDir} does not exist`);
continue;
}

const pages = new Map<string, Array<{ name: string; content: any }>>();
for (const file of fs.readdirSync(categorySchemaDir).filter(f => f.endsWith('.json'))) {
const schemaName = file.replace('.json', '');
const content = JSON.parse(fs.readFileSync(path.join(categorySchemaDir, file), 'utf-8'));
// Category-scoped: the page is owned by the file in THIS category that puts
// the name on its export surface — declaration or re-export. `misc` stays
// the catch-all for a published schema no `.zod.ts` here accounts for
// (`security/*` declares two in plain `.ts` files), and it is honest about
// it: `sourcePathFor` finds no file, so the page prints no "Source:" line.
const zodFile = schemaIndex.pageFor(category, schemaName) || 'misc';

if (!pages.has(zodFile)) pages.set(zodFile, []);
pages.get(zodFile)!.push({ name: schemaName, content });
}

byCategory.set(category, pages);
}

return byCategory;
}

/**
* Rewrite a source path referenced from JSDoc (`../automation/sync.zod.ts`) to
* the docs route that renders it. Without this the generated page links to a
* path that only exists in the repo, i.e. a 404 on the site.
*
* Always given a path WITH a category segment: `lib/file-description.ts`
* completes a same-directory spelling from its `fromCategory` before calling in,
* precisely so this stays the `<category>/<file>` lookup #4696 settled on and
* never has to guess which `auth.zod.ts` an author meant.
*/
function sourcePathToDocsRoute(target: string): string | null {
const m = target.match(/(?:^|\/)([\w-]+)\/([\w.-]+)\.zod\.ts$/);
if (!m) return null;
const [, category, zodFile] = m;
if (!CATEGORIES[category]) return null;
// A real category is not yet a page. This used to be the whole test, which
// was survivable only because every path the old regex could match happened
// to name a file with a page behind it. #6484 widened what reaches here to
// include same-directory spellings, and FOUR of the nine name a neighbour
// that does not exist at all — `identity/auth`, `system/audit`,
// `system/compliance`, `system/masking`, all four long since removed. Under
// the old test each would have become a confident link to a 404 (measured:
// deleting this line puts exactly those four dead routes into the artifact).
//
// File existence is not the test either: seven `.zod.ts` sources publish no
// page at all, their schemas being unrepresentable in JSON Schema. The test
// is whether THIS run emits the page, which is what the map knows.
if (!PAGES_BY_CATEGORY.get(category)?.has(zodFile)) return null;
return `/docs/references/${category}/${zodFile}`;
}

Expand Down Expand Up @@ -420,7 +481,14 @@ function generateZodFileMarkdown(zodFile: string, schemas: Array<{name: string,
const sourcePath = sourceRel ? path.join(REPO_ROOT, sourceRel) : undefined;
let fileDesc = '';
if (sourcePath && fs.existsSync(sourcePath)) {
fileDesc = renderFileDescription(fs.readFileSync(sourcePath, 'utf-8'), { sourcePathToDocsRoute });
// `category` is what a path written relative to the module's own
// directory is relative TO — without it the renderer cannot tell which
// `auth.zod.ts` a neighbour reference means, and until #6484 it was never
// told, so those references shipped as plain prose.
fileDesc = renderFileDescription(fs.readFileSync(sourcePath, 'utf-8'), {
fromCategory: category,
sourcePathToDocsRoute,
});
}

let md = `---\n`;
Expand Down Expand Up @@ -699,6 +767,15 @@ function deadDocLinks(mdx: string): string[] {

console.log('Building documentation...');

/**
* The page inventory, built before anything is rendered.
*
* It has to exist before the first `renderFileDescription` call, because that
* is where `sourcePathToDocsRoute` is asked whether a referenced neighbour has
* a page — an answer no partially-filled sink could give.
*/
const PAGES_BY_CATEGORY = groupSchemasByPage();

/** Categories that had schemas to regenerate from — drives the flush() guard. */
let managedCount = 0;

Expand Down Expand Up @@ -734,34 +811,11 @@ if (fs.existsSync(DOCS_ROOT)) {
// But verify we don't kill the manual files.
}

Object.keys(CATEGORIES).forEach(category => {
const categorySchemaDir = path.join(SCHEMA_DIR, category);

if (!fs.existsSync(categorySchemaDir)) {
console.log(`Warning: Schema directory ${categorySchemaDir} does not exist`);
return;
}

const files = fs.readdirSync(categorySchemaDir).filter(f => f.endsWith('.json'));
const zodFileSchemas = new Map<string, Array<{name: string, content: any}>>();

files.forEach(file => {
const schemaName = file.replace('.json', '');
const schemaPath = path.join(categorySchemaDir, file);
const content = JSON.parse(fs.readFileSync(schemaPath, 'utf-8'));
// Category-scoped: the page is owned by the file in THIS category that puts
// the name on its export surface — declaration or re-export. `misc` stays
// the catch-all for a published schema no `.zod.ts` here accounts for
// (`security/*` declares two in plain `.ts` files), and it is honest about
// it: `sourcePathFor` finds no file, so the page prints no "Source:" line.
const zodFile = schemaIndex.pageFor(category, schemaName) || 'misc';

if (!zodFileSchemas.has(zodFile)) {
zodFileSchemas.set(zodFile, []);
}
zodFileSchemas.get(zodFile)!.push({ name: schemaName, content });
});

// The grouping is `PAGES_BY_CATEGORY`'s, not a second one computed here: the
// page a schema lands on decides both what this loop writes and what
// `sourcePathToDocsRoute` calls a live route, and two enumerations of that could
// disagree — the same discipline §2.6 already applies to the root index.
PAGES_BY_CATEGORY.forEach((zodFileSchemas, category) => {
const categoryDir = path.join(DOCS_ROOT, category);

// Generate file
Expand Down
Loading
Loading