@@ -209,8 +209,11 @@ import { expandViewContainer } from '@objectstack/spec';
209209import { APPROVAL_NODE_TYPE , flowScreenFieldOptionKey } from '@objectstack/spec/automation' ;
210210import {
211211 FLOW_TERMINAL_MESSAGE_KEYS ,
212+ PAGE_TABS_COMPONENT_TYPE ,
212213 hasPlatformObjectPrefix ,
213214 isPlatformProvidedObjectName ,
215+ pageTabItemKey ,
216+ walkAddressedPageComponents ,
214217} from '@objectstack/spec/system' ;
215218import { walkFlowNodes } from './flow-walk.js' ;
216219import { 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 */
238241const 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' ;
239251const REFUSED_OUTCOME = 'refused' ;
240252
241253export 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
466494function 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