Skip to content

Commit fa95a05

Browse files
os-zhuangclaude
andcommitted
feat(ADR-0046): per-locale documentation content (doc i18n)
Docs can ship localized bodies via sibling `src/docs/<name>.<locale>.md` variant files (flat siblings; base `<name>.md` is the default + fallback), or inline `translations` for TS-first code packages. - spec: DocSchema.translations + resolveDocLocale (exact → primary subtag → base, per-field fallback, strips the map) - cli/collect-docs: fold variants into the base doc; lint orphan/duplicate variants + apply the v1 MDX/image bans to variant content - rest: /meta/doc (list + single) collapses to the request's Accept-Language/?locale and returns one body; doc detail bypasses cache - setup/studio: built-in overview docs ship zh translations (inline) - showcase: showcase_index.zh.md demonstrates the flat-file variant path Console already sends the UI language as Accept-Language, so doc content localizes on a language switch with no client change. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 08a11f7 commit fa95a05

9 files changed

Lines changed: 356 additions & 9 deletions

File tree

.changeset/adr0046-doc-i18n.md

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/cli": minor
4+
"@objectstack/rest": minor
5+
"@objectstack/setup": minor
6+
"@objectstack/studio": minor
7+
---
8+
9+
feat(ADR-0046): per-locale documentation content (doc i18n)
10+
11+
Docs can now ship localized bodies. Authors add sibling locale-variant files
12+
`src/docs/<name>.<locale>.md` (e.g. `crm_lead_guide.zh.md`, `..pt-BR.md`) next
13+
to the base `<name>.md`; the base stays the default and the fallback. Flatness is
14+
preserved — variants are flat siblings, not subdirectories.
15+
16+
- **spec**: `DocSchema` gains an optional `translations` map
17+
(`locale → {label?, description?, content}`) plus `resolveDocLocale(doc, locale)`,
18+
which collapses a doc to the best-matching locale (exact → primary subtag
19+
`zh-CN``zh` → base) with per-field fallback and strips the `translations` map.
20+
- **cli (collect-docs)**: variant files are folded into the base doc's
21+
`translations`; orphan/duplicate variants and the v1 MDX/image bans are linted
22+
on variant content too.
23+
- **rest**: `/meta/doc` (list + single) resolves the request locale from the
24+
existing `Accept-Language` / `?locale` negotiation, returns one localized body,
25+
and never ships the `translations` map. Doc detail bypasses the response cache
26+
so a language switch can't return a stale-locale body.
27+
- **setup / studio**: the built-in overview docs now ship `zh` translations
28+
(TS-first inline `translations`), so a Chinese console renders Chinese docs.
29+
30+
The console already sends the active UI language as `Accept-Language`, so doc
31+
content localizes on a language switch with no client change.
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
title: Showcase 示例
3+
description: showcase 包概览,以及它演示的"文档即元数据"特性。
4+
---
5+
6+
# Showcase 示例
7+
8+
ObjectStack 协议的活体一致性夹具:每种字段类型、视图类型、图表类型、报表类型和
9+
动作位置都至少出现一次;当平台新增了本包尚未演示的特性时,覆盖率测试就会失败。
10+
11+
这份手册本身就演示了其中一个特性——**包文档即元数据**(ADR-0046)。扁平
12+
`src/docs/` 目录里的每个 Markdown 文件都会在构建期编译成一条 `doc` 元数据、随包
13+
产物一起发布,并在控制台 `/docs/<name>` 渲染。
14+
15+
本页自己必须遵守的撰写规则,见
16+
[文档撰写指南](./showcase_docs_guide.md)

packages/apps/setup/src/setup-overview.doc.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,9 @@ import type { Doc } from '@objectstack/spec/system';
1414
*
1515
* Principle (from the HotCRM reference docs): document the *invisible* concepts,
1616
* not what the Setup UI already shows on screen.
17+
*
18+
* `translations` carries per-locale variants (ADR-0046 i18n); the REST layer
19+
* collapses the doc to the request's `Accept-Language` and serves one body.
1720
*/
1821
export const SETUP_OVERVIEW_DOC: Doc = {
1922
name: 'setup_overview',
@@ -52,4 +55,35 @@ object permissions.
5255
5356
See <https://docs.objectstack.ai> for the full security model.
5457
`,
58+
translations: {
59+
zh: {
60+
label: 'Setup 概览',
61+
description: '管理员入门:用户、角色与权限、记录可见性。',
62+
content: `# Setup 概览
63+
64+
Setup 是平台的管理员应用。它的界面大多一目了然——本页讲的是界面背后、UI 没有
65+
明说的概念。完整参考见 <https://docs.objectstack.ai>。
66+
67+
## 用户与认证
68+
69+
每个登录的人都是一条 \`sys_user\` 记录。认证(密码、SSO、API Key、会话)由平台
70+
的认证层负责,所以在这里创建用户给的是*身份*,不是权限——他能做什么,完全由分配
71+
给他的角色和权限决定。停用用户会收回登录权,但不删除其记录,从而保留归属与历史。
72+
73+
## 角色与权限
74+
75+
权限集定义用户*能做什么*(可读写哪些对象和字段、能打开哪些应用);角色把用户放进
76+
组织层级,决定他*能触及哪些记录*。用户的最终访问权是其所有权限集的并集——权限是
77+
叠加的,你是在授予能力,而不是在收回能力。
78+
79+
## 记录可见性(共享)
80+
81+
对象级权限决定用户能否操作某*类*记录;共享决定他实际能看到该类记录中的*哪些行*。
82+
可见性从组织级默认(私有或公开)出发,再由角色层级和显式共享规则*放宽*——绝不会被
83+
悄悄收窄。当用户"看不到本该看到的记录"时,原因几乎总是共享,而不是对象权限。
84+
85+
完整安全模型见 <https://docs.objectstack.ai>。
86+
`,
87+
},
88+
},
5589
};

packages/apps/studio/src/studio-overview.doc.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,9 @@ import type { Doc } from '@objectstack/spec/system';
1414
*
1515
* Principle (from the HotCRM reference docs): document the *invisible*
1616
* business logic, not what the Studio UI already shows on screen.
17+
*
18+
* `translations` carries per-locale variants (ADR-0046 i18n); the REST layer
19+
* collapses the doc to the request's `Accept-Language` and serves one body.
1720
*/
1821
export const STUDIO_OVERVIEW_DOC: Doc = {
1922
name: 'studio_overview',
@@ -55,4 +58,38 @@ safely in one environment before shipping.
5558
5659
See <https://docs.objectstack.ai> for drafts, overlays, and deployment in depth.
5760
`,
61+
translations: {
62+
zh: {
63+
label: 'Studio 概览',
64+
description: '搭建者入门:元数据优先模型、覆盖层优先级、发布与部署。',
65+
content: `# Studio 概览
66+
67+
Studio 是搭建者应用——塑造平台*元数据*的工作台:对象、字段、视图、流程、智能体等。
68+
它的大多数界面一目了然;本页讲的是那条界面上看不见、却支配你在这里一切操作的规则。
69+
完整参考见 <https://docs.objectstack.ai>。
70+
71+
## 元数据优先
72+
73+
在 Studio 里你编辑的不是运行中的数据库,而是*定义*。每个对象、字段、视图都是一条
74+
元数据记录,运行的应用由这些元数据生成。这就是为什么 Studio 里的一处改动能同时重塑
75+
UI 和 API:你改的是模型,而不是修补某个界面。
76+
77+
## 编辑即覆盖层(看不见的规则)
78+
79+
你的改动不会原地修改某个包发布的元数据。Studio 在基础定义之上写一层**覆盖层**,
80+
运行时按优先级解析两者:未发布的**草稿**在你编辑时对你生效,已发布的**租户覆盖层**
81+
优先于包的基线,包基线则是兜底(ADR-0005、ADR-0033)。实际后果:基础定义永不被
82+
销毁,所以覆盖层总能回退以恢复原样——而一个"改不动"的字段,通常是被更高优先级的
83+
层遮住了。
84+
85+
## 发布与部署
86+
87+
草稿只对你自己可见,直到你**发布**它,把覆盖层提升给整个租户看到。在环境之间搬运
88+
改动(例如开发 → 生产)是单独的**部署**步骤,不是发布的自动副作用——把两者分开,
89+
才能让你在一个环境里安全搭建、再上线。
90+
91+
草稿、覆盖层与部署的细节见 <https://docs.objectstack.ai>。
92+
`,
93+
},
94+
},
5895
};

packages/cli/src/utils/collect-docs.test.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,3 +138,36 @@ describe('collectAndLintDocs', () => {
138138
expect(issues).toHaveLength(0);
139139
});
140140
});
141+
142+
describe('locale variants (ADR-0046 i18n)', () => {
143+
it('folds `<base>.<locale>.md` into the base doc translations', () => {
144+
write('crm_index.md', '# Overview\n\nEnglish body.'); // no frontmatter → exact content
145+
write('crm_index.zh.md', '---\ntitle: 概览\ndescription: 从这里开始。\n---\n\n# 概览\n\n中文正文。');
146+
write('crm_index.pt-BR.md', '# Visão geral\n\nCorpo PT.');
147+
const { docs, issues } = collectDocsFromSrc(configPath);
148+
expect(issues).toHaveLength(0);
149+
expect(docs).toHaveLength(1); // variants do NOT become separate docs
150+
const doc = docs[0];
151+
expect(doc.name).toBe('crm_index');
152+
expect(doc.content).toBe('# Overview\n\nEnglish body.'); // base = default
153+
expect(doc.translations?.zh?.label).toBe('概览'); // frontmatter title
154+
expect(doc.translations?.zh?.description).toBe('从这里开始。');
155+
expect(doc.translations?.zh?.content).toContain('中文正文。'); // frontmatter stripped
156+
expect(doc.translations?.zh?.content).not.toContain('title:');
157+
expect(doc.translations?.['pt-BR']?.content).toBe('# Visão geral\n\nCorpo PT.');
158+
});
159+
160+
it('errors on an orphan variant with no base file', () => {
161+
write('crm_index.zh.md', '# 概览');
162+
const { docs, issues } = collectDocsFromSrc(configPath);
163+
expect(docs).toHaveLength(0);
164+
expect(issues.some((i) => i.rule === 'docs/orphan-translation' && i.severity === 'error')).toBe(true);
165+
});
166+
167+
it('applies the v1 MDX/image bans to variant content too', () => {
168+
write('crm_index.md', '# ok');
169+
write('crm_index.zh.md', '# 概览\n\n![img](./x.png)');
170+
const issues = lintDocs(collectDocsFromSrc(configPath).docs, 'crm');
171+
expect(issues.some((i) => i.rule === 'docs/no-images' && i.path.endsWith('.zh'))).toBe(true);
172+
});
173+
});

packages/cli/src/utils/collect-docs.ts

Lines changed: 92 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -26,11 +26,22 @@
2626
import fs from 'fs';
2727
import path from 'path';
2828

29+
export interface DocTranslationItem {
30+
label?: string;
31+
description?: string;
32+
content: string;
33+
}
34+
2935
export interface DocItem {
3036
name: string;
3137
label?: string;
3238
description?: string;
3339
content: string;
40+
/**
41+
* Per-locale variants (ADR-0046 i18n), compiled from sibling
42+
* `<name>.<locale>.md` files. The base file is the default + fallback.
43+
*/
44+
translations?: Record<string, DocTranslationItem>;
3445
}
3546

3647
export interface DocIssue {
@@ -41,6 +52,11 @@ export interface DocIssue {
4152
}
4253

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

4561
/** Extract a single-line scalar `key: value` from a frontmatter block. */
4662
function frontmatterScalar(block: string, key: string): string | undefined {
@@ -89,9 +105,11 @@ function firstHeading(markdown: string): string | undefined {
89105
*/
90106
export function collectDocsFromSrc(configPath: string): { docs: DocItem[]; issues: DocIssue[] } {
91107
const docsDir = path.join(path.dirname(configPath), 'src', 'docs');
92-
const docs: DocItem[] = [];
93108
const issues: DocIssue[] = [];
94-
if (!fs.existsSync(docsDir)) return { docs, issues };
109+
if (!fs.existsSync(docsDir)) return { docs: [], issues };
110+
111+
const baseByName = new Map<string, DocItem>();
112+
const variants: Array<{ base: string; locale: string; item: DocTranslationItem; rel: string }> = [];
95113

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

109127
const stem = entry.name.slice(0, -3);
128+
const raw = fs.readFileSync(path.join(docsDir, entry.name), 'utf-8');
129+
const { title, description, body } = parseFrontmatter(raw);
130+
131+
// Locale variant `<base>.<locale>.md` (ADR-0046 i18n) — checked before the
132+
// bare-name rule, since a variant stem legitimately contains a dot.
133+
const variantMatch = stem.match(DOC_VARIANT_RE);
134+
if (variantMatch) {
135+
variants.push({
136+
base: variantMatch[1],
137+
locale: variantMatch[2],
138+
item: { ...(title ? { label: title } : {}), ...(description ? { description } : {}), content: body },
139+
rel,
140+
});
141+
continue;
142+
}
143+
110144
if (!DOC_NAME_RE.test(stem)) {
111145
issues.push({
112146
severity: 'error',
113147
rule: 'docs/filename',
114-
message: `Doc filename "${entry.name}" must be snake_case (e.g. crm_lead_guide.md); the stem becomes the doc name.`,
148+
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.`,
115149
path: rel,
116150
});
117151
continue;
118152
}
119153

120-
const raw = fs.readFileSync(path.join(docsDir, entry.name), 'utf-8');
121-
const { title, description, body } = parseFrontmatter(raw);
122-
docs.push({
154+
baseByName.set(stem, {
123155
name: stem,
124156
label: title ?? firstHeading(body),
125157
...(description ? { description } : {}),
126158
content: body,
127159
});
128160
}
129-
return { docs, issues };
161+
162+
// Fold variants into their base doc. An orphan variant (no base file) is an
163+
// error: it would silently never render (resolution always starts from the
164+
// base doc).
165+
for (const v of variants) {
166+
const base = baseByName.get(v.base);
167+
if (!base) {
168+
issues.push({
169+
severity: 'error',
170+
rule: 'docs/orphan-translation',
171+
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.`,
172+
path: v.rel,
173+
});
174+
continue;
175+
}
176+
if (base.translations?.[v.locale]) {
177+
issues.push({
178+
severity: 'error',
179+
rule: 'docs/duplicate-translation',
180+
message: `Duplicate "${v.locale}" variant for doc "${v.base}".`,
181+
path: v.rel,
182+
});
183+
continue;
184+
}
185+
(base.translations ??= {})[v.locale] = v.item;
186+
}
187+
188+
return { docs: [...baseByName.values()], issues };
130189
}
131190

132191
/**
@@ -199,6 +258,32 @@ export function lintDocs(docs: DocItem[], namespace: string | undefined): DocIss
199258
path: where,
200259
});
201260
}
261+
262+
// Locale variants get the same v1 content bans — a translated body must
263+
// not smuggle in MDX/images that the base file forbids.
264+
for (const [locale, variant] of Object.entries(doc.translations ?? {})) {
265+
const vScannable = stripCode(variant.content);
266+
if (/!\[[^\]]*\]\(/.test(vScannable) || /<img[\s>]/i.test(vScannable)) {
267+
issues.push({
268+
severity: 'error',
269+
rule: 'docs/no-images',
270+
message: `Doc "${doc.name}" (${locale}) contains an image reference — not allowed in v1 (ADR-0046 §3.4).`,
271+
path: `${where}.${locale}`,
272+
});
273+
}
274+
if (
275+
/^\s*import\s+.+\s+from\s+['"]/m.test(vScannable) ||
276+
/^\s*export\s+(default|const|function)\s/m.test(vScannable) ||
277+
/<[A-Z][A-Za-z0-9]*[\s/>]/.test(vScannable)
278+
) {
279+
issues.push({
280+
severity: 'error',
281+
rule: 'docs/no-mdx',
282+
message: `Doc "${doc.name}" (${locale}) appears to contain MDX/JSX — only CommonMark + GFM Markdown is allowed (ADR-0046 §3.3/§3.4).`,
283+
path: `${where}.${locale}`,
284+
});
285+
}
286+
}
202287
}
203288

204289
// Same-package link resolution: `[text](./<name>.md#anchor)` where the

packages/rest/src/rest-server.ts

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1832,6 +1832,25 @@ export class RestServer {
18321832
}
18331833
}
18341834

1835+
// ADR-0046 i18n: collapse each doc to the request
1836+
// locale (localized label/description, `translations`
1837+
// map dropped) before the content-strip step below.
1838+
if (req.params.type === 'doc') {
1839+
const locale = this.extractLocale(req);
1840+
const { resolveDocLocale } = await import('@objectstack/spec/system');
1841+
const raw = visible as unknown;
1842+
const list: any[] | null = Array.isArray(raw)
1843+
? (raw as any[])
1844+
: (raw && typeof raw === 'object' && Array.isArray((raw as any).items))
1845+
? ((raw as any).items as any[])
1846+
: null;
1847+
if (list) {
1848+
const localized = list.map((it: any) =>
1849+
it && typeof it === 'object' ? resolveDocLocale(it as any, locale) : it);
1850+
visible = Array.isArray(raw) ? localized : { ...(raw as any), items: localized };
1851+
}
1852+
}
1853+
18351854
// ADR-0046: `doc` list responses omit `content` by
18361855
// default — manuals are the one metadata payload that
18371856
// grows unbounded, and the list surface only needs
@@ -1958,7 +1977,7 @@ export class RestServer {
19581977
// `getMetaItem(type, name, packageId)` path runs.
19591978
const packageScoped = typeof req.query?.package === 'string'
19601979
&& req.query.package.length > 0;
1961-
if (metadata.enableCache && p.getMetaItemCached && !isAppType && !isDraftRead && !previewDrafts && !packageScoped) {
1980+
if (metadata.enableCache && p.getMetaItemCached && !isAppType && !isDraftRead && !previewDrafts && !packageScoped && req.params.type !== 'doc') {
19621981
const cacheRequest = {
19631982
ifNoneMatch: req.headers['if-none-match'] as string,
19641983
ifModifiedSince: req.headers['if-modified-since'] as string,
@@ -2041,6 +2060,17 @@ export class RestServer {
20412060
}
20422061
}
20432062

2063+
// ADR-0046 i18n: collapse the doc to the request
2064+
// locale (label/description/content) and drop the
2065+
// `translations` map so consumers get one body.
2066+
if (req.params.type === 'doc' && visible) {
2067+
const locale = this.extractLocale(req);
2068+
const { resolveDocLocale } = await import('@objectstack/spec/system');
2069+
visible = isMetaEnvelope(visible)
2070+
? { ...visible, item: resolveDocLocale(visible.item as any, locale) }
2071+
: resolveDocLocale(visible as any, locale);
2072+
}
2073+
20442074
res.header('Vary', 'Accept-Language');
20452075
res.json(await this.translateMetaItem(req, req.params.type, environmentId, visible));
20462076
}

0 commit comments

Comments
 (0)