diff --git a/.changeset/cli-i18n-flow-screen-bucket.md b/.changeset/cli-i18n-flow-screen-bucket.md new file mode 100644 index 0000000000..b4ee112ede --- /dev/null +++ b/.changeset/cli-i18n-flow-screen-bucket.md @@ -0,0 +1,44 @@ +--- +'@objectstack/cli': minor +--- + +`os lint` and `os i18n extract` gain the flow/screen bucket — a screen-flow +copy gap is reportable, and the `flows..screens..` skeleton is scaffoldable, +for the first time + +#11287 gave the bundle a `flows` group and a resolver that applies it. Nothing +on the CLI side walked it: `COVERAGE_SOURCE` had no flow bucket, so the +`i18n/missing-*` family could not report a screen-flow copy gap **at all**, and +`os i18n extract` never wrote the keys, so an author had no way to discover the +vocabulary. Measured on #11287: HotCRM reported **0 `i18n/missing-*` issues** on +a tree whose six screen dialogs rendered English in all four locales. An app +whose i18n gate is green is green because the surface is invisible to it. + +The shared walker (`collectExpectedEntries` — one definition of what is +translatable, feeding both the gate and the extractor) now harvests, per flow: + +``` +flows..label +flows..screens..title +flows..screens..fields..label +flows..screens..fields..placeholder +``` + +Screens are keyed by `FlowNode.id` and fields by `ScreenFieldConfig.name` — the +identifiers the runner already holds at render time, not a second naming +scheme. Missing keys report as `i18n/missing-flow`, per locale, with the same +opt-in rule as every other bucket: a project that declares no locales and ships +no bundle still reports nothing. + +The copy keys are **imported** from `@objectstack/spec/system` +(`FLOW_SCREEN_COPY_KEYS` / `FLOW_SCREEN_FIELD_COPY_KEYS`), never restated. They +are exported precisely so the extractor and the resolver cannot drift; the +schema-to-list agreement is pinned spec-side in `translation.test.ts`, and the +list-to-walker agreement is pinned here. + +Two seeding rules follow what the reader actually sees rather than which key +the author filled in. A screen's `title` falls back to the node `label`, +because the executor builds the wire title as `config.title ?? node.label` and +one bundle key covers whichever the runner draws. A field's `label` falls back +to its `name` as a *derived* seed: the skeleton stays usable while the gate +demands no translation for a string nobody authored. diff --git a/packages/cli/src/utils/i18n-coverage.ts b/packages/cli/src/utils/i18n-coverage.ts index cf3d94e7e5..d94828ff0b 100644 --- a/packages/cli/src/utils/i18n-coverage.ts +++ b/packages/cli/src/utils/i18n-coverage.ts @@ -56,6 +56,7 @@ export interface CoverageIssue { | 'dashboard' | 'widget' | 'page' + | 'flow' | 'metadataForm'; /** Human-readable explanation. */ message: string; @@ -205,6 +206,14 @@ const COVERAGE_SOURCE: Record dashboard: 'dashboard', widget: 'widget', page: 'page', + // Screen-flow copy (`flows..label`, `flows..screens..title`, and + // the per-field `label` / `placeholder`) — the author's own wizard text, so + // it keeps its own bucket and reports as `i18n/missing-flow` rather than + // folding away with `--include-platform`. Until this bucket existed the + // family could not report a screen-flow gap at all: HotCRM measured + // `0 i18n/missing-*` while six screen dialogs rendered English in all four + // locales (#11485). + flow: 'flow', metadataType: 'metadataForm', metadataFormSection: 'metadataForm', metadataFormField: 'metadataForm', @@ -223,13 +232,14 @@ const SOURCE_NOUN: Record = { dashboard: 'Dashboard', widget: 'Widget', page: 'Page', + flow: 'Flow', metadataForm: 'Metadata form', }; /** Subject line for the "missing translation" message. */ function describeEntry(entry: ExpectedEntry, source: CoverageIssue['source']): string { const noun = SOURCE_NOUN[source]; - const owner = entry.objectName ?? entry.appName ?? entry.metadataType; + const owner = entry.objectName ?? entry.appName ?? entry.metadataType ?? entry.flowName; // Everything past the owning collection and its name reads as the attribute // path: `objects.account.fields.name.label` → `fields.name.label`. const attribute = entry.path.slice(2).join('.'); diff --git a/packages/cli/src/utils/i18n-extract.ts b/packages/cli/src/utils/i18n-extract.ts index e66ac3d593..42422c3163 100644 --- a/packages/cli/src/utils/i18n-extract.ts +++ b/packages/cli/src/utils/i18n-extract.ts @@ -51,6 +51,10 @@ * pages..label / .description * pages..title / .subtitle (from the page's `page:header` component) * pages..components.. (per-component copy, #6080) + * flows..label + * flows..screens..title (#7646 / #11287) + * flows..screens..fields..label + * flows..screens..fields..placeholder * metadataForms..label / .description * metadataForms..sections.
.label / .description * metadataForms..fields..label / .helpText / .placeholder @@ -65,7 +69,12 @@ */ import type { TranslationBundle, TranslationData } from '@objectstack/spec/system'; -import { METADATA_FORM_REGISTRY, PAGE_COMPONENT_COPY_KEYS } from '@objectstack/spec/system'; +import { + METADATA_FORM_REGISTRY, + PAGE_COMPONENT_COPY_KEYS, + FLOW_SCREEN_COPY_KEYS, + FLOW_SCREEN_FIELD_COPY_KEYS, +} from '@objectstack/spec/system'; import { DEFAULT_METADATA_TYPE_REGISTRY } from '@objectstack/spec/kernel'; import { deriveFieldGroupLayout } from '@objectstack/spec/data'; import { expandViewContainer } from '@objectstack/spec/ui'; @@ -110,6 +119,7 @@ export interface ExpectedEntry { | 'dashboard' | 'widget' | 'page' + | 'flow' | 'metadataType' | 'metadataFormSection' | 'metadataFormField'; @@ -119,6 +129,8 @@ export interface ExpectedEntry { appName?: string; /** Metadata type name when applicable (for `--filter` matching). */ metadataType?: string; + /** Flow name when applicable (for `--filter` matching). */ + flowName?: string; } export type FillStrategy = 'empty' | 'default' | 'todo'; @@ -249,7 +261,7 @@ function pushViewEmptyState(out: ExpectedEntry[], viewPath: string[], view: any, } } -type EntryScope = Pick; +type EntryScope = Pick; /** Narrow to a usable source string; an empty string is not authored text. */ function inlineText(value: unknown): string | undefined { @@ -873,6 +885,9 @@ export function collectExpectedEntries(config: any): ExpectedEntry[] { } } + // ── Screen flows (`flows..screens..…`, #7646 / #11287) ─ + walkScreenFlows(config, out); + // ── Object sections (fieldGroups + authored form/page sections) ─── // Deliberately a pass of its own: the two authoring surfaces live in // `objects`, `views` and `pages`, and one section may be declared by more @@ -893,6 +908,105 @@ export function collectExpectedEntries(config: any): ExpectedEntry[] { return out; } +// ─── Screen flows (`flows..screens..…`) ───────────────── + +/** + * The one flow-node type whose copy the bundle addresses. Spelled once here + * rather than at each guard; the resolver's own constant is module-private, + * and `translateScreenNode` filters on exactly this value. + */ +const SCREEN_NODE_TYPE = 'screen'; + +/** + * Emit the screen-flow copy surface (#7646, resolver landed in #11287). + * + * **The hole this closes.** A `type: 'screen'` flow is a wizard the user + * reads — a heading and a list of labelled inputs — and this walker had no + * pass for it, so `os lint` could not report a screen-flow copy gap and + * `os i18n extract` never scaffolded the keys. HotCRM measured + * `0 i18n/missing-*` on a tree whose six screen dialogs rendered English in + * all four locales: the gate was green because the surface was invisible to + * it, not because the app was translated. + * + * **The key face is IMPORTED, never restated.** {@link FLOW_SCREEN_COPY_KEYS} + * and {@link FLOW_SCREEN_FIELD_COPY_KEYS} are exported by + * `@objectstack/spec/system` precisely so this scaffolder and the resolver + * that reads the bundle cannot drift — a local copy, however correct on the + * day it is written, is the drift the export exists to make impossible. The + * schema↔list agreement is pinned spec-side in `translation.test.ts`. + * + * Addressing (`translation.zod.ts`, `flows`): flow by `Flow.name`, screen by + * `FlowNode.id` (the client's `ScreenSpec.nodeId`), field by + * `ScreenFieldConfig.name` — every level an identifier some consumer already + * holds at render time. + * + * Two seeding rules worth stating, both measured against what the reader sees + * rather than against which key the author happened to fill in: + * + * - **A screen's `title` falls back to the node `label`.** The executor builds + * the wire title as `config.title ?? node.label` (`ScreenSpec.title`), and + * `translateFlow` overlays the bundle onto `config.title` for that reason — + * one key covers whichever of the two the runner draws. So the seed, and the + * `inline` the coverage gate judges, is that same pair: a screen with only a + * canvas label still shows English text a translator owes a translation for. + * - **A field's `label` falls back to its `name`.** `ScreenFieldConfig.label` + * is optional and forwarded as-is (`ScreenFieldSpec.label`), so the runner + * renders the field name when the author wrote no label. That is a derived + * fallback nobody authored — {@link pushDerived}, so the skeleton stays + * usable while the gate demands no translation of a string that does not + * exist. + * + * A screen node whose `waitForInput` is `false` is deliberately NOT skipped: + * `translateFlow` overlays every screen node, and a walker that skipped one + * would re-open the extractable-but-ungated gap in miniature. + */ +function walkScreenFlows(config: any, out: ExpectedEntry[]): void { + const flows: any[] = Array.isArray(config?.flows) ? config.flows : []; + for (const flow of flows) { + const flowName = typeof flow?.name === 'string' && flow.name.length > 0 ? flow.name : undefined; + if (!flowName) continue; + const scope: EntryScope = { flowName }; + + // `flows..label` — `lookupFlowLabel`'s key. `Flow.label` is required + // by the schema, so this is authored text in practice; `pushOptional` + // keeps a label-less flow from seeding an empty string anyway. + pushOptional(out, ['flows', flowName, 'label'], flow.label, 'flow', scope); + + const nodes: any[] = Array.isArray(flow.nodes) ? flow.nodes : []; + for (const node of nodes) { + if (!node || typeof node !== 'object' || node.type !== SCREEN_NODE_TYPE) continue; + const nodeId = typeof node.id === 'string' && node.id.length > 0 ? node.id : undefined; + // No id, no key: `translateScreenNode` cannot address the node either. + if (!nodeId) continue; + const cfg = node.config && typeof node.config === 'object' ? node.config : {}; + const screenRoot = ['flows', flowName, 'screens', nodeId]; + + for (const key of FLOW_SCREEN_COPY_KEYS) { + const authored = key === 'title' + ? (inlineText(cfg[key]) ?? inlineText(node.label)) + : inlineText(cfg[key]); + pushOptional(out, [...screenRoot, key], authored, 'flow', scope); + } + + const fields: any[] = Array.isArray(cfg.fields) ? cfg.fields : []; + for (const field of fields) { + const fieldName = typeof field?.name === 'string' && field.name.length > 0 ? field.name : undefined; + // An item with an empty name is dropped by the runner too. + if (!fieldName) continue; + const fieldRoot = [...screenRoot, 'fields', fieldName]; + for (const key of FLOW_SCREEN_FIELD_COPY_KEYS) { + const authored = inlineText(field[key]); + if (key === 'label') { + pushDerived(out, [...fieldRoot, key], authored ?? fieldName, authored, 'flow', scope); + } else { + pushOptional(out, [...fieldRoot, key], authored, 'flow', scope); + } + } + } + } + } +} + /** * Iterate the canonical metadata form registry and emit translation entries * for every metadata type's display label/description, plus the section and @@ -1006,6 +1120,7 @@ function passesFilter(entry: ExpectedEntry, filter?: RegExp): boolean { if (entry.objectName && filter.test(entry.objectName)) return true; if (entry.appName && filter.test(entry.appName)) return true; if (entry.metadataType && filter.test(entry.metadataType)) return true; + if (entry.flowName && filter.test(entry.flowName)) return true; // Allow matching against the joined path so users can target e.g. ^dashboards\.system_ return filter.test(entry.path.join('.')); } diff --git a/packages/cli/test/i18n-flow-screen-coverage.test.ts b/packages/cli/test/i18n-flow-screen-coverage.test.ts new file mode 100644 index 0000000000..f4479015e5 --- /dev/null +++ b/packages/cli/test/i18n-flow-screen-coverage.test.ts @@ -0,0 +1,280 @@ +// Copyright (c) 2026 ObjectStack contributors. Apache-2.0 license. +// +// objectstack#11485 — the `flows` bucket the coverage taxonomy never had. +// +// ## The defect these pins aim at +// +// A `type: 'screen'` flow is a wizard the user reads. #11287 gave the bundle a +// group for its copy and a resolver that applies it; nothing on the CLI side +// walked it. So `COVERAGE_SOURCE` had no `flow` bucket, `os lint`'s +// `i18n/missing-*` family could not report a screen-flow gap at ALL, and +// `os i18n extract` never scaffolded the keys — an author had no way to even +// discover the vocabulary. +// +// Measured on #11287: HotCRM reported **0 `i18n/missing-*` issues** on a tree +// whose six screen dialogs rendered English in all four locales. An app whose +// i18n gate is green is green because the surface is invisible to it. +// +// So the pin that matters is the FIRST one below — a tree whose object surface +// is fully translated and whose wizard is not must report the wizard. A test +// that merely proved the bucket exists would reproduce the defect one level up. +// Its twin is the second: a fully translated tree reports NOTHING, or the new +// bucket is a noise generator and authors learn to ignore the family. +// +// ## The list is imported, never restated +// +// `FLOW_SCREEN_COPY_KEYS` / `FLOW_SCREEN_FIELD_COPY_KEYS` are exported by +// `@objectstack/spec/system` precisely so the extractor and the resolver cannot +// drift. `translation.test.ts` pins list↔schema spec-side; the `emits exactly +// the spec-exported key face` test below pins list↔walker on this side, so a +// hand-copied list here fails rather than silently offering a key nothing reads. + +import { describe, it, expect } from 'vitest'; +import { collectExpectedEntries, extractTranslations } from '../src/utils/i18n-extract.js'; +import { computeI18nCoverage, type CoverageIssue, type CoverageReport } from '../src/utils/i18n-coverage.js'; +import { + FLOW_SCREEN_COPY_KEYS, + FLOW_SCREEN_FIELD_COPY_KEYS, + TranslationDataSchema, +} from '@objectstack/spec/system'; + +/** + * The lead-conversion wizard, in the shape the schema's own `flows` note cites + * as the #7646 report ("Conversion Details / Create Opportunity? / Opportunity + * Name" rendered in English on a zh-CN console). + */ +const leadConversion = { + name: 'lead_conversion', + label: 'Convert Lead', + type: 'screen', + nodes: [ + { id: 'start', type: 'start', label: 'Start' }, + { + id: 'conversion_details', + type: 'screen', + label: 'Conversion Details Step', + config: { + title: 'Conversion Details', + // Body text is guidance-REFUSED by the schema: it must not become a key. + description: 'Choose what this lead becomes.', + fields: [ + { name: 'create_opportunity', label: 'Create Opportunity?', type: 'boolean' }, + { name: 'opportunity_name', label: 'Opportunity Name', placeholder: 'Acme - Q3 renewal' }, + ], + }, + }, + // No `config.title`: the executor draws the node label, so one key covers both. + { id: 'summary', type: 'screen', label: 'Summary', config: { waitForInput: true } }, + ], + edges: [], +}; + +/** A tree whose OBJECT surface is completely translated — HotCRM's state. */ +const hotCrmLike = (flowTranslations?: Record) => ({ + i18n: { defaultLocale: 'en', supportedLocales: ['en', 'zh-CN'] }, + objects: [{ name: 'crm_lead', label: 'Lead', fields: { company: { label: 'Company' } } }], + flows: [leadConversion], + translations: [ + { + 'zh-CN': { + objects: { crm_lead: { label: '线索', fields: { company: { label: '公司' } } } }, + ...(flowTranslations ? { flows: flowTranslations } : {}), + }, + }, + ], +}); + +/** Only the user's own metadata; `os lint` folds the platform baseline away. */ +const userIssues = (report: CoverageReport): CoverageIssue[] => + report.issues.filter((i) => i.source !== 'metadataForm'); + +const flowKeys = (config: any) => + collectExpectedEntries(config) + .filter((e) => e.path[0] === 'flows') + .map((e) => e.path.join('.')); + +describe('the screen-flow gap a green i18n gate could not see (#11485)', () => { + it('reports every untranslated wizard string on a tree whose objects are done', () => { + // `userIssues` first: the ~850-key Studio metadata-form baseline is + // platform noise `os lint` folds away, and it is present in every report. + const zh = userIssues(computeI18nCoverage(hotCrmLike())).filter((i) => i.locale === 'zh-CN'); + const keys = zh.map((i) => i.key); + + // The exact strings the console renders in English. + expect(keys).toContain('flows.lead_conversion.label'); + expect(keys).toContain('flows.lead_conversion.screens.conversion_details.title'); + expect(keys).toContain('flows.lead_conversion.screens.conversion_details.fields.create_opportunity.label'); + expect(keys).toContain('flows.lead_conversion.screens.conversion_details.fields.opportunity_name.label'); + expect(keys).toContain('flows.lead_conversion.screens.conversion_details.fields.opportunity_name.placeholder'); + // The object surface IS translated, so nothing else is reported: the whole + // report is the wizard. Before this bucket the same tree reported zero. + expect(zh.length).toBeGreaterThan(0); + expect(keys.filter((k) => !k.startsWith('flows.'))).toEqual([]); + }); + + it('attributes them to the flow bucket so `os lint` renders `i18n/missing-flow`', () => { + const zh = computeI18nCoverage(hotCrmLike()).issues.filter( + (i) => i.locale === 'zh-CN' && i.key.startsWith('flows.'), + ); + + expect(zh.length).toBeGreaterThan(0); + for (const issue of zh) expect(issue.source).toBe('flow'); + // The message names the flow, not a bare dot-path. + expect(zh.map((i) => i.message)).toContain( + 'Flow "lead_conversion" screens.conversion_details.title missing translation for locale "zh-CN"', + ); + }); + + it('goes quiet once the wizard is translated — the bucket is not a noise generator', () => { + const report = computeI18nCoverage( + hotCrmLike({ + lead_conversion: { + label: '线索转化', + screens: { + conversion_details: { + title: '转化详情', + fields: { + create_opportunity: { label: '创建商机?' }, + opportunity_name: { label: '商机名称', placeholder: 'Acme - 第三季度续约' }, + }, + }, + summary: { title: '完成' }, + }, + }, + }), + ); + + expect(userIssues(report)).toEqual([]); + }); + + it('stays silent for a monolingual project that never opted into i18n', () => { + // The opt-in half of the contract: no `i18n` block, no bundle, so a project + // that does not translate must not start failing lint for a flow it wrote. + const report = computeI18nCoverage({ flows: [leadConversion] }); + + expect(report.locales).toEqual(['en']); + expect(userIssues(report)).toEqual([]); + }); +}); + +describe('what the walker harvests from a screen flow', () => { + it('keys screens by `FlowNode.id` and fields by `ScreenFieldConfig.name`', () => { + expect(flowKeys({ flows: [leadConversion] }).sort()).toEqual([ + 'flows.lead_conversion.label', + 'flows.lead_conversion.screens.conversion_details.fields.create_opportunity.label', + 'flows.lead_conversion.screens.conversion_details.fields.create_opportunity.placeholder', + 'flows.lead_conversion.screens.conversion_details.fields.opportunity_name.label', + 'flows.lead_conversion.screens.conversion_details.fields.opportunity_name.placeholder', + 'flows.lead_conversion.screens.conversion_details.title', + 'flows.lead_conversion.screens.summary.title', + ]); + }); + + it('falls the screen title back to the node label, the way the executor does', () => { + // `ScreenSpec.title` is `config.title ?? node.label`, so a screen with only + // a canvas label still shows English text somebody owes a translation for. + const summary = collectExpectedEntries({ flows: [leadConversion] }).find( + (e) => e.path.join('.') === 'flows.lead_conversion.screens.summary.title', + ); + + expect(summary).toMatchObject({ sourceValue: 'Summary', inline: 'Summary', source: 'flow' }); + }); + + it('seeds an unlabelled field from its name without demanding a translation for it', () => { + // `ScreenFieldConfig.label` is optional and forwarded as-is, so the runner + // renders the name. The skeleton stays usable (`sourceValue`) while the gate + // demands nothing (`inline` unset) — a missing label is `required/label`'s + // finding, not an i18n gap. + const config = { + i18n: { defaultLocale: 'en', supportedLocales: ['en', 'zh-CN'] }, + flows: [ + { + name: 'quick_capture', + label: 'Quick Capture', + type: 'screen', + nodes: [{ id: 'ask', type: 'screen', label: 'Ask', config: { fields: [{ name: 'amount' }] } }], + }, + ], + }; + const entry = collectExpectedEntries(config).find( + (e) => e.path.join('.') === 'flows.quick_capture.screens.ask.fields.amount.label', + ); + const gated = computeI18nCoverage(config).issues.map((i) => i.key); + + expect(entry).toMatchObject({ sourceValue: 'amount', inline: undefined }); + expect(gated).not.toContain('flows.quick_capture.screens.ask.fields.amount.label'); + }); + + it('harvests nothing from non-screen nodes, id-less screens or nameless flows', () => { + expect( + flowKeys({ + flows: [ + { + name: 'housekeeping', + label: 'Housekeeping', + nodes: [ + { id: 'start', type: 'start', label: 'Start' }, + { id: 'notify', type: 'send_email', label: 'Notify', config: { title: 'Not a screen' } }, + { type: 'screen', label: 'Anonymous', config: { title: 'Unaddressable' } }, + ], + }, + { label: 'No name at all', nodes: [{ id: 's', type: 'screen', label: 'Orphan' }] }, + ], + }), + ).toEqual(['flows.housekeeping.label']); + }); + + it('never emits the keys the schema refuses by name', () => { + const keys = flowKeys({ flows: [leadConversion] }); + + // `description` is a screen's body text — guidance-refused spec-side. + expect(keys.filter((k) => k.endsWith('.description'))).toEqual([]); + // Runner chrome and per-field `help` / `options` are refused too. + expect(keys.filter((k) => /\.(help|helpText|options|successMessage|errorMessage)$/.test(k))).toEqual([]); + }); +}); + +describe('`os i18n extract` scaffolds the flows skeleton', () => { + const result = extractTranslations({ flows: [leadConversion] }, { locales: ['en', 'zh-CN'] }); + const en = result.bundles.en as any; + const zh = result.bundles['zh-CN'] as any; + + it('writes the whole `flows..screens..` drill for every requested locale', () => { + expect(en.flows.lead_conversion.label).toBe('Convert Lead'); + expect(en.flows.lead_conversion.screens.conversion_details.title).toBe('Conversion Details'); + expect(en.flows.lead_conversion.screens.conversion_details.fields.opportunity_name).toEqual({ + label: 'Opportunity Name', + placeholder: 'Acme - Q3 renewal', + }); + // The translator's empty slots — the vocabulary an author had no way to + // discover before this pass existed. + expect(zh.flows.lead_conversion.screens.conversion_details.title).toBe(''); + expect(zh.flows.lead_conversion.screens.summary.title).toBe(''); + }); + + it('emits exactly the spec-exported key face — the import that stops the drift', () => { + const screen = en.flows.lead_conversion.screens.conversion_details; + + expect(Object.keys(screen).filter((k) => k !== 'fields').sort()).toEqual([...FLOW_SCREEN_COPY_KEYS].sort()); + expect(Object.keys(screen.fields.opportunity_name).sort()).toEqual([...FLOW_SCREEN_FIELD_COPY_KEYS].sort()); + }); + + it('scaffolds a bundle the strict schema accepts', () => { + // A skeleton the schema refuses is worse than none: the author pastes it in + // and the whole bundle stops parsing. `strictObject` means an unrecognised + // key here would be a hard rejection, so this parse is the key-face check. + const parsed = TranslationDataSchema.safeParse(en); + + expect(parsed.success ? [] : parsed.error.issues).toEqual([]); + }); + + it('matches `--filter` against the flow name', () => { + const filtered = extractTranslations( + { flows: [leadConversion], objects: [{ name: 'crm_lead', label: 'Lead' }] }, + { filter: /^lead_conversion$/ }, + ); + + expect((filtered.bundles.en as any).flows.lead_conversion.label).toBe('Convert Lead'); + expect((filtered.bundles.en as any).objects).toBeUndefined(); + }); +}); diff --git a/scripts/i18n-coverage-baseline.json b/scripts/i18n-coverage-baseline.json index 566be636bb..9705563407 100644 --- a/scripts/i18n-coverage-baseline.json +++ b/scripts/i18n-coverage-baseline.json @@ -1,7 +1,7 @@ { - "examples/app-crm/objectstack.config.ts": 89, - "examples/app-showcase/objectstack.config.ts": 393, - "examples/app-todo/objectstack.config.ts": 120, + "examples/app-crm/objectstack.config.ts": 93, + "examples/app-showcase/objectstack.config.ts": 424, + "examples/app-todo/objectstack.config.ts": 140, "packages/platform-objects/scripts/i18n-extract.config.ts": 0, "packages/plugins/plugin-approvals/scripts/i18n-extract.config.ts": 0, "packages/plugins/plugin-audit/scripts/i18n-extract.config.ts": 0,