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
53 changes: 53 additions & 0 deletions .changeset/i18n-translate-platform-bundles.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
---
"@objectstack/plugin-security": patch
"@objectstack/plugin-audit": patch
"@objectstack/plugin-sharing": patch
"@objectstack/plugin-webhooks": patch
"@objectstack/service-storage": patch
"@objectstack/cli": patch
---

fix(i18n): translate the platform packages' declared surface, and gate all nine bundles instead of one (#3762)

Only `platform-objects` was wired into a translation-drift check. The other
**eight** packages shipped a `scripts/i18n-extract.config.ts` that nothing ever
ran — and four of them had already drifted out of sync with the schema, exactly
the rot `pnpm check:i18n` exists to catch, one directory over.

**Translated.** `plugin-security` (45 strings per locale), `plugin-webhooks`
(15), `plugin-audit` (8), `plugin-sharing` (7) and `service-storage` (7) are now
at **zero** untranslated declared strings in zh-CN / ja-JP / es-ES — 246
translations. Most were newly *visible* rather than newly missing: #3753 taught
the coverage detector to walk action `params`, `resultDialog`, `listViews` and
the rest of the declared surface, and these are what it found.

Wording was harvested from the repo's own bundles wherever a string was already
translated somewhere (1382 unambiguous source strings), so `Created At` reads
`创建时间` here because that is what it reads everywhere else, rather than a
fresh invention. Protocol tokens are deliberately left identical across locales:
`GET` / `POST` / `PUT` / `PATCH` / `DELETE`, `ETag`, `ACL`, `URL`.

**Gated.** `scripts/check-i18n-bundles.mjs` replaces the single-package
`pnpm check:i18n` and checks all nine. It does not restate each package's
command — it parses the one already documented in that config's own docstring
and runs it, so the documented regenerate command and the gate cannot diverge.
The coverage ratchet grows the same way, from `examples/*` to twelve configs;
eight of them sit at zero, which makes it the strict gate there.

**Fixed a real truncation bug it exposed.** `os lint --json` on a large config
came out of a pipe cut off at exactly 65536 bytes — `console.log(big)` followed
by `process.exit(1)` tears the process down before an async pipe write drains,
while an interactive run (stdout is a TTY, written synchronously) looks perfect.
Every scripted consumer silently got invalid JSON. `emitJson` in
`packages/cli/src/utils/format.ts` waits for the write to drain and sets
`process.exitCode` instead; `lint`, `i18n check` and `i18n extract` use it.
Roughly 30 other CLI commands share the pattern and are not touched here.

The nine documented regenerate commands also gain `--no-metadata-forms` (added
in #3768), since the Studio metadata-form baseline belongs to `platform-objects`
alone, not to a copy in every plugin.

Not fixed here: `platform-objects`' own 77-per-locale gap is `apps.*` /
`dashboards.*` navigation and widget labels, which live outside the `objects`
subtree and cannot be scaffolded while the package extracts with
`--objects-only`. That needs an emit decision first — tracked in #3762.
21 changes: 15 additions & 6 deletions .github/workflows/lint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -262,21 +262,30 @@ jobs:
# mode, so it never asks anyone to re-translate: a fresh extract of an
# up-to-date bundle is byte-identical to what is committed.
#
# Reads the built @objectstack/spec dist through the extract config, so
# Now covers ALL NINE packages that own a bundle, not just
# platform-objects. The other eight shipped an `i18n-extract.config.ts`
# that nothing ever ran, and four of them had already drifted — the same
# rot this gate exists to catch, one directory over. Each package is
# checked with the exact command its own config docstring documents, so
# the docs and the gate cannot diverge.
#
# Reads the built @objectstack/spec dist through the extract configs, so
# it belongs after the build step with the other consumer gates.
- name: Check generated translation bundles are in sync with the schema
run: pnpm check:i18n

# Ratchet on the OTHER i18n question. The step above asks "are the
# generated bundles still what the schema produces?"; this one asks "did
# anyone declare a new label and not translate it?" — the gap #3370 closed
# in `os lint` but which had ~765 pre-existing misses across the examples,
# so `--i18n-strict` cannot simply be switched on without painting CI red
# and getting switched back off. The debt is frozen in
# in `os lint`, which had ~1000 pre-existing misses across the examples and
# platform-objects, so `--i18n-strict` cannot simply be switched on without
# painting CI red and getting switched back off. The debt is frozen in
# scripts/i18n-coverage-baseline.json; growth fails the build.
#
# Runs the built CLI over each example config, so it also belongs after
# the build step.
# Eight of the twelve tracked configs sit at zero, so for those this is
# already the strict gate — any regression is immediately red.
#
# Runs the built CLI over each config, so it also belongs after the build.
- name: Check no new untranslated declared labels
run: pnpm check:i18n-coverage

Expand Down
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -188,7 +188,7 @@ Root also exports: `defineStack`, `composeStacks`, `defineView`, `defineApp`, `d
| Path | Type | Rule |
|:---|:---|:---|
| `content/docs/references/` | **AUTO-GEN** | ❌ Never hand-edit. Regenerated by `packages/spec/scripts/build-docs.ts`. |
| `packages/platform-objects/src/apps/translations/*.generated.ts` | **AUTO-GEN** | ❌ Never hand-edit. Run `pnpm i18n:extract` (merge mode — existing translations are preserved). `pnpm check:i18n` gates it in CI. |
| `**/translations/*.generated.ts` (nine packages — `platform-objects`, five plugins, three services) | **AUTO-GEN** | ❌ Never hand-edit the file *structure*. Run `node scripts/check-i18n-bundles.mjs --write` to regenerate all nine (merge mode — every existing translation is preserved); `pnpm i18n:extract` still covers `platform-objects` alone. Translation *values* are hand-written and expected to be: the gate compares against a merge-mode extract, so editing a string is fine, while adding or dropping keys is drift. `pnpm check:i18n` gates all nine in CI, and `pnpm check:i18n-coverage` ratchets untranslated declared labels. |
| `content/docs/guides/` | hand-written | ✅ Update `meta.json` when adding pages. |
| `content/docs/concepts/` | hand-written | ✅ |
| `content/docs/getting-started/` | hand-written | ✅ |
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@
"objectui:clean": "rm -rf packages/console/dist .cache/objectui-*",
"lint": "eslint . --no-inline-config",
"i18n:extract": "tsx packages/cli/bin/run-dev.js i18n extract packages/platform-objects/scripts/i18n-extract.config.ts --locales=zh-CN,ja-JP,es-ES --fill=default --out=packages/platform-objects/src/apps/translations",
"check:i18n": "pnpm i18n:extract --check",
"check:i18n": "node scripts/check-i18n-bundles.mjs",
"check:i18n-coverage": "node scripts/check-i18n-coverage.mjs",
"check:nul-bytes": "node scripts/check-nul-bytes.mjs",
"check:doc-authoring": "node scripts/check-doc-authoring.mjs",
Expand Down
11 changes: 5 additions & 6 deletions packages/cli/src/commands/i18n/check.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
printInfo,
printStep,
createTimer,
emitJson,
} from '../../utils/format.js';
import { computeI18nCoverage } from '../../utils/i18n-coverage.js';

Expand Down Expand Up @@ -77,12 +78,10 @@ export default class I18nCheck extends Command {
: [];

if (flags.json) {
console.log(JSON.stringify({
...report,
thresholdViolations,
duration: timer.elapsed(),
}, null, 2));
if (report.totals.errors > 0 || thresholdViolations.length > 0) process.exit(1);
await emitJson(
{ ...report, thresholdViolations, duration: timer.elapsed() },
report.totals.errors > 0 || thresholdViolations.length > 0 ? 1 : 0,
);
return;
}

Expand Down
5 changes: 3 additions & 2 deletions packages/cli/src/commands/i18n/extract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
printInfo,
printStep,
createTimer,
emitJson,
} from '../../utils/format.js';
import { extractTranslations, renderTranslationModule, type FillStrategy } from '../../utils/i18n-extract.js';

Expand Down Expand Up @@ -159,15 +160,15 @@ export default class I18nExtract extends Command {
flags['metadata-forms'] && (metadataFormsCounts[locale] ?? 0) > 0;

if (flags.json) {
console.log(JSON.stringify({
await emitJson({
totalExpected: result.totalExpected,
counts: result.counts,
metadataFormsCounts,
bundles: objectsOnly
? Object.fromEntries(localesEmitted.map((l) => [l, result.bundles[l].objects ?? {}]))
: result.bundles,
duration: timer.elapsed(),
}, null, 2));
});
return;
}

Expand Down
6 changes: 3 additions & 3 deletions packages/cli/src/commands/lint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {
printInfo,
printStep,
createTimer,
emitJson,
} from '../utils/format.js';

// ─── Types ──────────────────────────────────────────────────────────
Expand Down Expand Up @@ -672,7 +673,7 @@ export default class Lint extends Command {
const errors = issues.filter((i) => i.severity === 'error');
const warnings = issues.filter((i) => i.severity === 'warning');
const suggestions = issues.filter((i) => i.severity === 'suggestion');
console.log(JSON.stringify({
await emitJson({
passed: errors.length === 0,
total: issues.length,
errors: errors.length,
Expand All @@ -682,8 +683,7 @@ export default class Lint extends Command {
...(score ? { score: score.score, grade: score.grade } : {}),
issues,
duration: timer.elapsed(),
}, null, 2));
if (errors.length > 0) process.exit(1);
}, errors.length > 0 ? 1 : 0);
return;
}

Expand Down
25 changes: 25 additions & 0 deletions packages/cli/src/utils/format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,31 @@ import type { ZodError } from 'zod';
export const CLI_NAME = 'objectstack';
export const CLI_ALIAS = 'os';

// ─── Machine-readable output ────────────────────────────────────────

/**
* Emit a `--json` payload and record the exit code, without truncating.
*
* `console.log(big)` followed by `process.exit(1)` looks correct and is not:
* when stdout is a **pipe**, Node buffers the write asynchronously and
* `process.exit` tears the process down with the buffer only partly drained.
* `os lint packages/platform-objects/scripts/i18n-extract.config.ts --json`
* came out of a pipe at exactly 65536 bytes — one pipe buffer — so every
* scripted consumer got invalid JSON while an interactive run (stdout is a TTY,
* written synchronously) looked perfect. Silent, and invisible to the author.
*
* Waiting for the write callback drains the buffer first, and setting
* `process.exitCode` instead of calling `process.exit` lets Node exit on its
* own once nothing is pending.
*/
export async function emitJson(payload: unknown, exitCode = 0): Promise<void> {
const text = JSON.stringify(payload, null, 2) + '\n';
await new Promise<void>((resolve, reject) => {
process.stdout.write(text, (err) => (err ? reject(err) : resolve()));
});
if (exitCode !== 0) process.exitCode = exitCode;
}

// ─── Banner ─────────────────────────────────────────────────────────

export function printBanner(version: string) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
* strings were seeded from @objectstack/platform-objects.)
*
* os i18n extract packages/plugins/plugin-audit/scripts/i18n-extract.config.ts \
* --locales=zh-CN,ja-JP,es-ES --fill=default --objects-only \
* --locales=zh-CN,ja-JP,es-ES --fill=default --objects-only --no-metadata-forms \
* --out=packages/plugins/plugin-audit/src/translations
*/

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,10 @@ export const enObjects: NonNullable<TranslationData['objects']> = {
label: "Actor",
help: "User who performed the action (null for system actions)"
},
actor: {
label: "Actor",
help: "Principal that performed the action: a user id, svc:<name>, or null"
},
object_name: {
label: "Object",
help: "Target object (e.g. sys_user, project_task)"
Expand Down Expand Up @@ -73,7 +77,11 @@ export const enObjects: NonNullable<TranslationData['objects']> = {
},
_views: {
recent: {
label: "Recent"
label: "Recent",
emptyState: {
title: "No audit events",
message: "Activity will appear here as users interact with the platform."
}
},
writes_only: {
label: "Writes"
Expand Down Expand Up @@ -140,6 +148,14 @@ export const enObjects: NonNullable<TranslationData['objects']> = {
label: "Record Label",
help: "Display label of the target record at write time"
},
source_object: {
label: "Source Object",
help: "Object name of the rich source entity this activity was derived from (e.g. \"sys_email\"). Null when the activity is about the target record itself."
},
source_id: {
label: "Source ID",
help: "Record id of the rich source entity (paired with source_object) — lets the timeline drill to the full email/call/meeting record."
},
url: {
label: "URL",
help: "Optional deep-link to the activity target"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,10 @@ export const esESObjects: NonNullable<TranslationData['objects']> = {
label: "Actor",
help: "Usuario que realizó la acción (null para acciones del sistema)."
},
actor: {
label: "Actor",
help: "Principal que realizó la acción: un id de usuario, svc:<name> o null"
},
object_name: {
label: "Objeto",
help: "Objeto de destino (p. ej. sys_user, project_task)."
Expand Down Expand Up @@ -73,7 +77,11 @@ export const esESObjects: NonNullable<TranslationData['objects']> = {
},
_views: {
recent: {
label: "Recientes"
label: "Recientes",
emptyState: {
title: "Sin eventos de auditoría",
message: "La actividad aparecerá aquí a medida que los usuarios interactúen con la plataforma."
}
},
writes_only: {
label: "Escrituras"
Expand Down Expand Up @@ -140,6 +148,14 @@ export const esESObjects: NonNullable<TranslationData['objects']> = {
label: "Nombre visible del registro",
help: "Nombre visible del registro de destino en el momento de escritura."
},
source_object: {
label: "Objeto de origen",
help: "Nombre de objeto de la entidad de origen enriquecida de la que deriva esta actividad (p. ej. \"sys_email\"). Null cuando la actividad trata sobre el propio registro de destino."
},
source_id: {
label: "ID de origen",
help: "Id de registro de la entidad de origen enriquecida (emparejado con source_object): permite que la línea de tiempo profundice hasta el registro completo de correo/llamada/reunión."
},
url: {
label: "URL",
help: "Enlace profundo opcional al destino de la actividad."
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,10 @@ export const jaJPObjects: NonNullable<TranslationData['objects']> = {
label: "操作者",
help: "アクションを実行したユーザー(システム操作の場合は null)"
},
actor: {
label: "実行者",
help: "操作を実行したプリンシパル: ユーザー ID、svc:<name>、または null"
},
object_name: {
label: "オブジェクト",
help: "対象オブジェクト(例: sys_user、project_task)"
Expand Down Expand Up @@ -73,7 +77,11 @@ export const jaJPObjects: NonNullable<TranslationData['objects']> = {
},
_views: {
recent: {
label: "最近"
label: "最近",
emptyState: {
title: "監査イベントはありません",
message: "ユーザーがプラットフォームを操作すると、ここにアクティビティが表示されます。"
}
},
writes_only: {
label: "書き込み"
Expand Down Expand Up @@ -140,6 +148,14 @@ export const jaJPObjects: NonNullable<TranslationData['objects']> = {
label: "レコード表示名",
help: "書き込み時点の対象レコードの表示名"
},
source_object: {
label: "ソースオブジェクト",
help: "このアクティビティの派生元となったリッチソースエンティティのオブジェクト名(例: \"sys_email\")。null の場合、アクティビティは対象レコード自体に関するものです。"
},
source_id: {
label: "ソース ID",
help: "リッチソースエンティティのレコード ID(source_object と対で使用)。タイムラインからメール/通話/会議の完全なレコードへドリルダウンできます。"
},
url: {
label: "URL",
help: "アクティビティターゲットへのオプションのディープリンク"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,10 @@ export const zhCNObjects: NonNullable<TranslationData['objects']> = {
label: "执行人",
help: "执行该操作的用户(系统操作时为 null)"
},
actor: {
label: "操作者",
help: "执行该操作的主体:用户 ID、svc:<name> 或空"
},
object_name: {
label: "对象",
help: "目标对象(例如 sys_user、project_task)"
Expand Down Expand Up @@ -73,7 +77,11 @@ export const zhCNObjects: NonNullable<TranslationData['objects']> = {
},
_views: {
recent: {
label: "最近"
label: "最近",
emptyState: {
title: "暂无审计事件",
message: "用户与平台交互后,动态会显示在这里。"
}
},
writes_only: {
label: "写入"
Expand Down Expand Up @@ -140,6 +148,14 @@ export const zhCNObjects: NonNullable<TranslationData['objects']> = {
label: "记录标签",
help: "写入时目标记录的显示标签"
},
source_object: {
label: "来源对象",
help: "该动态所派生自的富来源实体的对象名称(如 \"sys_email\")。为空表示动态针对目标记录本身。"
},
source_id: {
label: "来源 ID",
help: "富来源实体的记录 ID(与 source_object 配对)—— 让时间线可以下钻到完整的邮件/通话/会议记录。"
},
url: {
label: "URL",
help: "指向活动目标的可选深度链接"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
* strings were seeded from @objectstack/platform-objects.)
*
* os i18n extract packages/plugins/plugin-security/scripts/i18n-extract.config.ts \
* --locales=zh-CN,ja-JP,es-ES --fill=default --objects-only \
* --locales=zh-CN,ja-JP,es-ES --fill=default --objects-only --no-metadata-forms \
* --out=packages/plugins/plugin-security/src/translations
*/

Expand Down
Loading
Loading