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/adr0046-doc-i18n.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
---
"@objectstack/spec": minor
"@objectstack/cli": minor
"@objectstack/rest": minor
"@objectstack/setup": minor
"@objectstack/studio": minor
---

feat(ADR-0046): per-locale documentation content (doc i18n)

Docs can now ship localized bodies. Authors add sibling locale-variant files
`src/docs/<name>.<locale>.md` (e.g. `crm_lead_guide.zh.md`, `..pt-BR.md`) next
to the base `<name>.md`; the base stays the default and the fallback. Flatness is
preserved — variants are flat siblings, not subdirectories.

- **spec**: `DocSchema` gains an optional `translations` map
(`locale → {label?, description?, content}`) plus `resolveDocLocale(doc, locale)`,
which collapses a doc to the best-matching locale (exact → primary subtag
`zh-CN`→`zh` → base) with per-field fallback and strips the `translations` map.
- **cli (collect-docs)**: variant files are folded into the base doc's
`translations`; orphan/duplicate variants and the v1 MDX/image bans are linted
on variant content too.
- **rest**: `/meta/doc` (list + single) resolves the request locale from the
existing `Accept-Language` / `?locale` negotiation, returns one localized body,
and never ships the `translations` map. Doc detail bypasses the response cache
so a language switch can't return a stale-locale body.
- **setup / studio**: the built-in overview docs now ship `zh` translations
(TS-first inline `translations`), so a Chinese console renders Chinese docs.

The console already sends the active UI language as `Accept-Language`, so doc
content localizes on a language switch with no client change.
16 changes: 16 additions & 0 deletions examples/app-showcase/src/docs/showcase_index.zh.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
title: Showcase 示例
description: showcase 包概览,以及它演示的"文档即元数据"特性。
---

# Showcase 示例

ObjectStack 协议的活体一致性夹具:每种字段类型、视图类型、图表类型、报表类型和
动作位置都至少出现一次;当平台新增了本包尚未演示的特性时,覆盖率测试就会失败。

这份手册本身就演示了其中一个特性——**包文档即元数据**(ADR-0046)。扁平
`src/docs/` 目录里的每个 Markdown 文件都会在构建期编译成一条 `doc` 元数据、随包
产物一起发布,并在控制台 `/docs/<name>` 渲染。

本页自己必须遵守的撰写规则,见
[文档撰写指南](./showcase_docs_guide.md)。
34 changes: 34 additions & 0 deletions packages/apps/setup/src/setup-overview.doc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ import type { Doc } from '@objectstack/spec/system';
*
* Principle (from the HotCRM reference docs): document the *invisible* concepts,
* not what the Setup UI already shows on screen.
*
* `translations` carries per-locale variants (ADR-0046 i18n); the REST layer
* collapses the doc to the request's `Accept-Language` and serves one body.
*/
export const SETUP_OVERVIEW_DOC: Doc = {
name: 'setup_overview',
Expand Down Expand Up @@ -52,4 +55,35 @@ object permissions.

See <https://docs.objectstack.ai> for the full security model.
`,
translations: {
zh: {
label: 'Setup 概览',
description: '管理员入门:用户、角色与权限、记录可见性。',
content: `# Setup 概览

Setup 是平台的管理员应用。它的界面大多一目了然——本页讲的是界面背后、UI 没有
明说的概念。完整参考见 <https://docs.objectstack.ai>。

## 用户与认证

每个登录的人都是一条 \`sys_user\` 记录。认证(密码、SSO、API Key、会话)由平台
的认证层负责,所以在这里创建用户给的是*身份*,不是权限——他能做什么,完全由分配
给他的角色和权限决定。停用用户会收回登录权,但不删除其记录,从而保留归属与历史。

## 角色与权限

权限集定义用户*能做什么*(可读写哪些对象和字段、能打开哪些应用);角色把用户放进
组织层级,决定他*能触及哪些记录*。用户的最终访问权是其所有权限集的并集——权限是
叠加的,你是在授予能力,而不是在收回能力。

## 记录可见性(共享)

对象级权限决定用户能否操作某*类*记录;共享决定他实际能看到该类记录中的*哪些行*。
可见性从组织级默认(私有或公开)出发,再由角色层级和显式共享规则*放宽*——绝不会被
悄悄收窄。当用户"看不到本该看到的记录"时,原因几乎总是共享,而不是对象权限。

完整安全模型见 <https://docs.objectstack.ai>。
`,
},
},
};
37 changes: 37 additions & 0 deletions packages/apps/studio/src/studio-overview.doc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ import type { Doc } from '@objectstack/spec/system';
*
* Principle (from the HotCRM reference docs): document the *invisible*
* business logic, not what the Studio UI already shows on screen.
*
* `translations` carries per-locale variants (ADR-0046 i18n); the REST layer
* collapses the doc to the request's `Accept-Language` and serves one body.
*/
export const STUDIO_OVERVIEW_DOC: Doc = {
name: 'studio_overview',
Expand Down Expand Up @@ -55,4 +58,38 @@ safely in one environment before shipping.

See <https://docs.objectstack.ai> for drafts, overlays, and deployment in depth.
`,
translations: {
zh: {
label: 'Studio 概览',
description: '搭建者入门:元数据优先模型、覆盖层优先级、发布与部署。',
content: `# Studio 概览

Studio 是搭建者应用——塑造平台*元数据*的工作台:对象、字段、视图、流程、智能体等。
它的大多数界面一目了然;本页讲的是那条界面上看不见、却支配你在这里一切操作的规则。
完整参考见 <https://docs.objectstack.ai>。

## 元数据优先

在 Studio 里你编辑的不是运行中的数据库,而是*定义*。每个对象、字段、视图都是一条
元数据记录,运行的应用由这些元数据生成。这就是为什么 Studio 里的一处改动能同时重塑
UI 和 API:你改的是模型,而不是修补某个界面。

## 编辑即覆盖层(看不见的规则)

你的改动不会原地修改某个包发布的元数据。Studio 在基础定义之上写一层**覆盖层**,
运行时按优先级解析两者:未发布的**草稿**在你编辑时对你生效,已发布的**租户覆盖层**
优先于包的基线,包基线则是兜底(ADR-0005、ADR-0033)。实际后果:基础定义永不被
销毁,所以覆盖层总能回退以恢复原样——而一个"改不动"的字段,通常是被更高优先级的
层遮住了。

## 发布与部署

草稿只对你自己可见,直到你**发布**它,把覆盖层提升给整个租户看到。在环境之间搬运
改动(例如开发 → 生产)是单独的**部署**步骤,不是发布的自动副作用——把两者分开,
才能让你在一个环境里安全搭建、再上线。

草稿、覆盖层与部署的细节见 <https://docs.objectstack.ai>。
`,
},
},
};
33 changes: 33 additions & 0 deletions packages/cli/src/utils/collect-docs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,3 +138,36 @@ describe('collectAndLintDocs', () => {
expect(issues).toHaveLength(0);
});
});

describe('locale variants (ADR-0046 i18n)', () => {
it('folds `<base>.<locale>.md` into the base doc translations', () => {
write('crm_index.md', '# Overview\n\nEnglish body.'); // no frontmatter → exact content
write('crm_index.zh.md', '---\ntitle: 概览\ndescription: 从这里开始。\n---\n\n# 概览\n\n中文正文。');
write('crm_index.pt-BR.md', '# Visão geral\n\nCorpo PT.');
const { docs, issues } = collectDocsFromSrc(configPath);
expect(issues).toHaveLength(0);
expect(docs).toHaveLength(1); // variants do NOT become separate docs
const doc = docs[0];
expect(doc.name).toBe('crm_index');
expect(doc.content).toBe('# Overview\n\nEnglish body.'); // base = default
expect(doc.translations?.zh?.label).toBe('概览'); // frontmatter title
expect(doc.translations?.zh?.description).toBe('从这里开始。');
expect(doc.translations?.zh?.content).toContain('中文正文。'); // frontmatter stripped
expect(doc.translations?.zh?.content).not.toContain('title:');
expect(doc.translations?.['pt-BR']?.content).toBe('# Visão geral\n\nCorpo PT.');
});

it('errors on an orphan variant with no base file', () => {
write('crm_index.zh.md', '# 概览');
const { docs, issues } = collectDocsFromSrc(configPath);
expect(docs).toHaveLength(0);
expect(issues.some((i) => i.rule === 'docs/orphan-translation' && i.severity === 'error')).toBe(true);
});

it('applies the v1 MDX/image bans to variant content too', () => {
write('crm_index.md', '# ok');
write('crm_index.zh.md', '# 概览\n\n![img](./x.png)');
const issues = lintDocs(collectDocsFromSrc(configPath).docs, 'crm');
expect(issues.some((i) => i.rule === 'docs/no-images' && i.path.endsWith('.zh'))).toBe(true);
});
});
99 changes: 92 additions & 7 deletions packages/cli/src/utils/collect-docs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,11 +26,22 @@
import fs from 'fs';
import path from 'path';

export interface DocTranslationItem {
label?: string;
description?: string;
content: string;
}

export interface DocItem {
name: string;
label?: string;
description?: string;
content: string;
/**
* Per-locale variants (ADR-0046 i18n), compiled from sibling
* `<name>.<locale>.md` files. The base file is the default + fallback.
*/
translations?: Record<string, DocTranslationItem>;
}

export interface DocIssue {
Expand All @@ -41,6 +52,11 @@ export interface DocIssue {
}

const DOC_NAME_RE = /^[a-z][a-z0-9_]*$/;
// A locale-variant file is `<base>.<locale>.md` (ADR-0046 i18n), e.g.
// `crm_lead_guide.zh.md` or `crm_lead_guide.pt-BR.md`. The base stem must be a
// valid doc name; the locale is a BCP-47-ish tag (primary subtag + optional
// region). Flatness is unchanged — variants are still flat siblings.
const DOC_VARIANT_RE = /^([a-z][a-z0-9_]*)\.([A-Za-z]{2,3}(?:-[A-Za-z0-9]{2,8})?)$/;

/** Extract a single-line scalar `key: value` from a frontmatter block. */
function frontmatterScalar(block: string, key: string): string | undefined {
Expand Down Expand Up @@ -89,9 +105,11 @@ function firstHeading(markdown: string): string | undefined {
*/
export function collectDocsFromSrc(configPath: string): { docs: DocItem[]; issues: DocIssue[] } {
const docsDir = path.join(path.dirname(configPath), 'src', 'docs');
const docs: DocItem[] = [];
const issues: DocIssue[] = [];
if (!fs.existsSync(docsDir)) return { docs, issues };
if (!fs.existsSync(docsDir)) return { docs: [], issues };

const baseByName = new Map<string, DocItem>();
const variants: Array<{ base: string; locale: string; item: DocTranslationItem; rel: string }> = [];

for (const entry of fs.readdirSync(docsDir, { withFileTypes: true })) {
const rel = `src/docs/${entry.name}`;
Expand All @@ -107,26 +125,67 @@ export function collectDocsFromSrc(configPath: string): { docs: DocItem[]; issue
if (!entry.isFile() || !entry.name.endsWith('.md')) continue;

const stem = entry.name.slice(0, -3);
const raw = fs.readFileSync(path.join(docsDir, entry.name), 'utf-8');
const { title, description, body } = parseFrontmatter(raw);

// Locale variant `<base>.<locale>.md` (ADR-0046 i18n) — checked before the
// bare-name rule, since a variant stem legitimately contains a dot.
const variantMatch = stem.match(DOC_VARIANT_RE);
if (variantMatch) {
variants.push({
base: variantMatch[1],
locale: variantMatch[2],
item: { ...(title ? { label: title } : {}), ...(description ? { description } : {}), content: body },
rel,
});
continue;
}

if (!DOC_NAME_RE.test(stem)) {
issues.push({
severity: 'error',
rule: 'docs/filename',
message: `Doc filename "${entry.name}" must be snake_case (e.g. crm_lead_guide.md); the stem becomes the doc name.`,
message: `Doc filename "${entry.name}" must be snake_case (e.g. crm_lead_guide.md), optionally with a locale suffix (crm_lead_guide.zh.md); the stem becomes the doc name.`,
path: rel,
});
continue;
}

const raw = fs.readFileSync(path.join(docsDir, entry.name), 'utf-8');
const { title, description, body } = parseFrontmatter(raw);
docs.push({
baseByName.set(stem, {
name: stem,
label: title ?? firstHeading(body),
...(description ? { description } : {}),
content: body,
});
}
return { docs, issues };

// Fold variants into their base doc. An orphan variant (no base file) is an
// error: it would silently never render (resolution always starts from the
// base doc).
for (const v of variants) {
const base = baseByName.get(v.base);
if (!base) {
issues.push({
severity: 'error',
rule: 'docs/orphan-translation',
message: `Locale variant "${path.basename(v.rel)}" has no base doc "${v.base}.md" in this package — add the base file or remove the variant.`,
path: v.rel,
});
continue;
}
if (base.translations?.[v.locale]) {
issues.push({
severity: 'error',
rule: 'docs/duplicate-translation',
message: `Duplicate "${v.locale}" variant for doc "${v.base}".`,
path: v.rel,
});
continue;
}
(base.translations ??= {})[v.locale] = v.item;
}

return { docs: [...baseByName.values()], issues };
}

/**
Expand Down Expand Up @@ -199,6 +258,32 @@ export function lintDocs(docs: DocItem[], namespace: string | undefined): DocIss
path: where,
});
}

// Locale variants get the same v1 content bans — a translated body must
// not smuggle in MDX/images that the base file forbids.
for (const [locale, variant] of Object.entries(doc.translations ?? {})) {
const vScannable = stripCode(variant.content);
if (/!\[[^\]]*\]\(/.test(vScannable) || /<img[\s>]/i.test(vScannable)) {
issues.push({
severity: 'error',
rule: 'docs/no-images',
message: `Doc "${doc.name}" (${locale}) contains an image reference — not allowed in v1 (ADR-0046 §3.4).`,
path: `${where}.${locale}`,
});
}
if (
/^\s*import\s+.+\s+from\s+['"]/m.test(vScannable) ||
/^\s*export\s+(default|const|function)\s/m.test(vScannable) ||
/<[A-Z][A-Za-z0-9]*[\s/>]/.test(vScannable)
) {
issues.push({
severity: 'error',
rule: 'docs/no-mdx',
message: `Doc "${doc.name}" (${locale}) appears to contain MDX/JSX — only CommonMark + GFM Markdown is allowed (ADR-0046 §3.3/§3.4).`,
path: `${where}.${locale}`,
});
}
}
}

// Same-package link resolution: `[text](./<name>.md#anchor)` where the
Expand Down
32 changes: 31 additions & 1 deletion packages/rest/src/rest-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1832,6 +1832,25 @@ export class RestServer {
}
}

// ADR-0046 i18n: collapse each doc to the request
// locale (localized label/description, `translations`
// map dropped) before the content-strip step below.
if (req.params.type === 'doc') {
const locale = this.extractLocale(req);
const { resolveDocLocale } = await import('@objectstack/spec/system');
const raw = visible as unknown;
const list: any[] | null = Array.isArray(raw)
? (raw as any[])
: (raw && typeof raw === 'object' && Array.isArray((raw as any).items))
? ((raw as any).items as any[])
: null;
if (list) {
const localized = list.map((it: any) =>
it && typeof it === 'object' ? resolveDocLocale(it as any, locale) : it);
visible = Array.isArray(raw) ? localized : { ...(raw as any), items: localized };
}
}

// ADR-0046: `doc` list responses omit `content` by
// default — manuals are the one metadata payload that
// grows unbounded, and the list surface only needs
Expand Down Expand Up @@ -1958,7 +1977,7 @@ export class RestServer {
// `getMetaItem(type, name, packageId)` path runs.
const packageScoped = typeof req.query?.package === 'string'
&& req.query.package.length > 0;
if (metadata.enableCache && p.getMetaItemCached && !isAppType && !isDraftRead && !previewDrafts && !packageScoped) {
if (metadata.enableCache && p.getMetaItemCached && !isAppType && !isDraftRead && !previewDrafts && !packageScoped && req.params.type !== 'doc') {
const cacheRequest = {
ifNoneMatch: req.headers['if-none-match'] as string,
ifModifiedSince: req.headers['if-modified-since'] as string,
Expand Down Expand Up @@ -2041,6 +2060,17 @@ export class RestServer {
}
}

// ADR-0046 i18n: collapse the doc to the request
// locale (label/description/content) and drop the
// `translations` map so consumers get one body.
if (req.params.type === 'doc' && visible) {
const locale = this.extractLocale(req);
const { resolveDocLocale } = await import('@objectstack/spec/system');
visible = isMetaEnvelope(visible)
? { ...visible, item: resolveDocLocale(visible.item as any, locale) }
: resolveDocLocale(visible as any, locale);
}

res.header('Vary', 'Accept-Language');
res.json(await this.translateMetaItem(req, req.params.type, environmentId, visible));
}
Expand Down
Loading