Skip to content

Commit 818b89e

Browse files
committed
wip: tab item key — schema, resolver, lint, extractor
Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D6F9axfoPzR7t8G1J7Ytzz
1 parent 757aadb commit 818b89e

4 files changed

Lines changed: 323 additions & 18 deletions

File tree

‎packages/cli/src/utils/i18n-extract.ts‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,7 @@
103103
* pages.<page>.label / .description
104104
* pages.<page>.title / .subtitle (from the page's `page:header` component)
105105
* pages.<page>.components.<id>.<key> (per-component copy, #6080)
106+
* pages.<page>.components.<tabsId>.items.<value>.label (a `page:tabs` item label)
106107
* flows.<flow>.label
107108
* flows.<flow>.screens.<node_id>.title (#7646 / #11287)
108109
* flows.<flow>.screens.<node_id>.description (#22507)
@@ -137,7 +138,9 @@ import {
137138
FLOW_SCREEN_COPY_KEYS,
138139
FLOW_SCREEN_FIELD_COPY_KEYS,
139140
FLOW_TERMINAL_MESSAGE_KEYS,
141+
PAGE_TABS_COMPONENT_TYPE,
140142
globalFilterKey,
143+
pageTabItemKey,
141144
walkAddressedPageComponents,
142145
} from '@objectstack/spec/system';
143146
import { FLOW_REGION_SLOTS_BY_TYPE, flowScreenFieldOptionKey } from '@objectstack/spec/automation';
@@ -1169,6 +1172,22 @@ function emitPageComponentCopy(out: ExpectedEntry[], page: any, name: string): v
11691172
// records nothing (#14749).
11701173
pushEntry(out, ['pages', name, 'components', id as string, key], value, 'page');
11711174
}
1175+
// A `page:tabs` component's tab labels, keyed by each item's `value`
1176+
// under the tabs component's id — the key `translatePage` reads. An
1177+
// item without a `value` gets no slot: its renderer token is
1178+
// index-derived, and an array index is never a bundle key (#14749).
1179+
if (component.type === PAGE_TABS_COMPONENT_TYPE && Array.isArray(props.items)) {
1180+
for (const item of props.items as unknown[]) {
1181+
const value = pageTabItemKey(item);
1182+
if (value === undefined) continue;
1183+
pushEntry(
1184+
out,
1185+
['pages', name, 'components', id as string, 'items', value, 'label'],
1186+
(item as Record<string, unknown>).label,
1187+
'page',
1188+
);
1189+
}
1190+
}
11721191
}
11731192
return component;
11741193
});

‎packages/lint/src/validate-translation-references.ts‎

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,8 +209,11 @@ import { expandViewContainer } from '@objectstack/spec';
209209
import { APPROVAL_NODE_TYPE, flowScreenFieldOptionKey } from '@objectstack/spec/automation';
210210
import {
211211
FLOW_TERMINAL_MESSAGE_KEYS,
212+
PAGE_TABS_COMPONENT_TYPE,
212213
hasPlatformObjectPrefix,
213214
isPlatformProvidedObjectName,
215+
pageTabItemKey,
216+
walkAddressedPageComponents,
214217
} from '@objectstack/spec/system';
215218
import { walkFlowNodes } from './flow-walk.js';
216219
import { packagesOf, recordsOf, suggestName } from './object-graph.js';
@@ -236,6 +239,15 @@ const SCREEN_NODE_TYPE = 'screen';
236239
* `'refused'` a member of the schema's `outcome` enum, neither exported by name.
237240
*/
238241
const END_NODE_TYPE = 'end';
242+
243+
/**
244+
* The component a misplaced `components.<id>.items` key most often sits on: a
245+
* `page:accordion` declares `items[].label` too, but its panels carry no
246+
* author-settable identity (the renderer derives `panel-<index>`), so the
247+
* verdict points at the inline locale map rather than at a key. A local
248+
* const for {@link SCREEN_NODE_TYPE}'s reason.
249+
*/
250+
const PAGE_ACCORDION_COMPONENT_TYPE = 'page:accordion';
239251
const REFUSED_OUTCOME = 'refused';
240252

241253
export const TRANSLATION_TARGET_UNKNOWN = 'translation-target-unknown';
@@ -461,6 +473,22 @@ interface Universe {
461473
* rung 2b of the object ladder applied to a list (#23566).
462474
*/
463475
extendedPicklists: Set<string>;
476+
/**
477+
* Page name (`Page.name`) → every component id the page ADDRESSES
478+
* (`pages.<page>.components.<id>`), with that component's type and, for a
479+
* `page:tabs`, the item `value`s a `components.<id>.items.<value>` key may
480+
* name. Built on spec's `walkAddressedPageComponents`, the walk
481+
* `translatePage` itself runs, so an id here is the component the resolver
482+
* reads the entry for.
483+
*/
484+
pages: Map<string, Map<string, PageComponentFacts>>;
485+
}
486+
487+
/** What the `items` check knows about one addressed page component. */
488+
interface PageComponentFacts {
489+
type: string | undefined;
490+
/** The `page:tabs` item `value`s (`pageTabItemKey`); empty for any other type. */
491+
tabValues: Set<string>;
464492
}
465493

466494
function emptyFacts(): ObjectFacts {
@@ -1239,6 +1267,38 @@ function buildUniverse(stack: AnyRec): Universe {
12391267
for (const page of recordsOf(stack.pages)) collectPageRecord(page);
12401268
for (const page of artifactProvidedRecords(stack, 'pages')) collectPageRecord(page);
12411269

1270+
// ── Pages: the components `pages.<page>.components.<id>` addresses, for the
1271+
// `page:tabs` item key (`components.<id>.items.<value>.label`) ──
1272+
//
1273+
// Walked with spec's `walkAddressedPageComponents`, NOT lint's
1274+
// `walkPageComponents`: the key is read by `translatePage`, which runs that
1275+
// walk (roots, descent, collision arbitration), so the component an id
1276+
// names here is the one the resolver applies the entry to. Where both this
1277+
// stack and a sibling package declare a page name, the declaration in hand
1278+
// keeps the slot — the dashboards rung's `ownDeclaration` decision.
1279+
const pages = new Map<string, Map<string, PageComponentFacts>>();
1280+
const collectPageComponents = (page: AnyRec, { ownDeclaration }: { ownDeclaration: boolean }) => {
1281+
const pageName = strName(page.name);
1282+
if (!pageName) return;
1283+
if (!ownDeclaration && pages.has(pageName)) return;
1284+
const components = new Map<string, PageComponentFacts>();
1285+
walkAddressedPageComponents(page as Parameters<typeof walkAddressedPageComponents>[0], (component, { id, addressed }) => {
1286+
if (!addressed || id === undefined) return component;
1287+
const tabValues = new Set<string>();
1288+
if (component.type === PAGE_TABS_COMPONENT_TYPE && isRec(component.properties)) {
1289+
for (const item of Array.isArray(component.properties.items) ? component.properties.items : []) {
1290+
const value = pageTabItemKey(item);
1291+
if (value !== undefined) tabValues.add(value);
1292+
}
1293+
}
1294+
components.set(id, { type: strName(component.type), tabValues });
1295+
return component;
1296+
});
1297+
pages.set(pageName, components);
1298+
};
1299+
for (const page of recordsOf(stack.pages)) collectPageComponents(page, { ownDeclaration: true });
1300+
for (const page of artifactProvidedRecords(stack, 'pages')) collectPageComponents(page, { ownDeclaration: false });
1301+
12421302
// ── Actions: object-bound ones join their object; the rest are global ──
12431303
//
12441304
// …from the stack in hand and from the artifact's other entries (#19349).
@@ -1539,6 +1599,7 @@ function buildUniverse(stack: AnyRec): Universe {
15391599
actionOwners,
15401600
picklists,
15411601
extendedPicklists,
1602+
pages,
15421603
};
15431604
}
15441605

@@ -1873,6 +1934,62 @@ export function validateTranslationReferences(stack: AnyRec): TranslationRefFind
18731934
}
18741935
}
18751936

1937+
// ── pages.<name>.components.<id>.items.<value> ────────────────────
1938+
//
1939+
// Only the `items` member is judged here: it is the one page key whose
1940+
// target this check can see whole — a declared tabs component's item
1941+
// `value`s. A page this stack does not declare is skipped (no rung
1942+
// judges `pages.<name>` itself yet), and so is every other member.
1943+
for (const [pageName, rawPage] of Object.entries(asRecord(rawData.pages))) {
1944+
const page = universe.pages.get(pageName);
1945+
if (!page || !isRec(rawPage)) continue;
1946+
const pagePath = `${base}.pages.${pageName}`;
1947+
for (const [componentId, rawComponent] of Object.entries(asRecord(rawPage.components))) {
1948+
if (!isRec(rawComponent) || rawComponent.items === undefined) continue;
1949+
const itemsPath = `${pagePath}.components.${componentId}.items`;
1950+
const where = `${inLocale} · page "${pageName}" · component "${componentId}"`;
1951+
const tabsHint = 'Tab-item translations go under the `id` of a `page:tabs` component.';
1952+
const component = page.get(componentId);
1953+
if (!component) {
1954+
orphan(
1955+
where,
1956+
itemsPath,
1957+
`Translations carry tab items for "${componentId}", a component id page "${pageName}" does not ` +
1958+
`declare, so nothing resolves them.` +
1959+
suggest(componentId, [...page].filter(([, c]) => c.type === PAGE_TABS_COMPONENT_TYPE).map(([id]) => id)),
1960+
tabsHint,
1961+
);
1962+
continue;
1963+
}
1964+
if (component.type !== PAGE_TABS_COMPONENT_TYPE) {
1965+
orphan(
1966+
where,
1967+
itemsPath,
1968+
component.type === PAGE_ACCORDION_COMPONENT_TYPE
1969+
? `Translations carry tab items for "${componentId}", which page "${pageName}" declares as a ` +
1970+
`\`page:accordion\`, whose panels have no stable key, so nothing resolves them.`
1971+
: `Translations carry tab items for "${componentId}", which page "${pageName}" declares as a ` +
1972+
`\`${component.type ?? 'component'}\`, not a \`page:tabs\`, so nothing resolves them.`,
1973+
component.type === PAGE_ACCORDION_COMPONENT_TYPE
1974+
? 'Localize an accordion panel label in place, as an inline locale map on `items[].label`.'
1975+
: tabsHint,
1976+
);
1977+
continue;
1978+
}
1979+
for (const value of Object.keys(asRecord(rawComponent.items))) {
1980+
if (component.tabValues.has(value)) continue;
1981+
orphan(
1982+
`${where} · tab "${value}"`,
1983+
`${itemsPath}.${value}`,
1984+
`Translations are keyed to tab "${value}", which \`page:tabs\` "${componentId}" on page ` +
1985+
`"${pageName}" gives no item as its \`value\`, so the tab keeps its source-locale label.` +
1986+
suggest(value, component.tabValues),
1987+
"Tab-item translations are keyed by the item's `value`; give the item a `value` if it has none.",
1988+
);
1989+
}
1990+
}
1991+
}
1992+
18761993
// ── picklists.<name>[.options.<value>] (#23566) ───────────────────
18771994
for (const [listName, rawList] of Object.entries(asRecord(rawData.picklists))) {
18781995
const listPath = `${base}.picklists.${listName}`;

0 commit comments

Comments
 (0)