@@ -70,6 +70,10 @@ import {
7070 type ObjectSchemaMaskPosture ,
7171} from '@objectstack/metadata-core' ;
7272import { ANONYMOUS_DENY_CODE , ANONYMOUS_DENY_MESSAGE , ANONYMOUS_DENY_STATUS } from '@objectstack/core' ;
73+ // [#22614] The registry's one spelling of a container's binding, through its
74+ // import-free leaf entry (that module's header measures why the leaf exists).
75+ import { deriveViewContainerObject } from '@objectstack/metadata/view-container' ;
76+ import type { ExpandedViewItem } from '@objectstack/spec/ui' ;
7377import { isEndpointMatchAuthority , selectServedEndpoints } from './served-endpoints.js' ;
7478import { logError , logWarn } from './log.js' ;
7579
@@ -2233,6 +2237,13 @@ export async function translateMetaList(
22332237 * Moved here, unchanged, from `RestServer.translateMetaItem` (which now
22342238 * delegates), so the runtime dispatcher's item read translates with the same
22352239 * function instead of serving the authored labels.
2240+ *
2241+ * [#22614] A `view` document is classified ONCE, here, before any translator
2242+ * reads it: a view CONTAINER goes to {@link translateMetaViewContainer}, a
2243+ * single view to the spec's view translator. Both shapes are served under the
2244+ * type `view` — the registry files a `defineView` container under the object
2245+ * it binds and serves it on that key's by-name read — and the spec translator
2246+ * the table dispatches `view` to reads a single view's `name`.
22362247 */
22372248export async function translateMetaDocument (
22382249 sources : MetaListTranslationSources ,
@@ -2245,6 +2256,17 @@ export async function translateMetaDocument(
22452256 const bundle = translationBundleOf ( i18n ) ;
22462257 const locale = sources . requestLocale ( i18n ) ;
22472258 if ( ! locale ) return document ;
2259+ if ( metaType === 'view' ) {
2260+ const { isAggregatedViewContainer } = await import ( '@objectstack/spec/ui' ) ;
2261+ if ( isAggregatedViewContainer ( document ) ) {
2262+ return translateMetaViewContainer (
2263+ sources ,
2264+ document as Record < string , unknown > ,
2265+ bundle ,
2266+ metaTranslateOptions ( i18n , locale ) ,
2267+ ) ;
2268+ }
2269+ }
22482270 const { translateMetadataDocument } = await import ( '@objectstack/spec/system' ) ;
22492271 const packagedBase = PACKAGED_BASE_ACCESSORS . has ( metaType )
22502272 ? packagedObjectBaseOf ( await sources . resolveProtocol ( ) , metaType , ( document as any ) ?. name )
@@ -2255,6 +2277,160 @@ export async function translateMetaDocument(
22552277 } ) ;
22562278}
22572279
2280+ /**
2281+ * [#22614] Translate a served view CONTAINER as a container — the by-name read
2282+ * of a `defineView` document, which the registry files under the object it
2283+ * binds ({@link deriveViewContainerObject}).
2284+ *
2285+ * The spec's `translateView` is the translator for ONE view, and it keys the
2286+ * catalog on that view's `name`. A container has no `name` of its own when a
2287+ * compiled artifact ships it (only the config boot's registrar stamps one), so
2288+ * handing the container to it threw on the artifact boot, and on the config
2289+ * boot it served the container as one view keyed by the object: a fabricated
2290+ * `label` equal to the object name, and every view inside left untranslated.
2291+ * ⛔ Guarding the `name` read instead would leave the container untranslated
2292+ * in silence.
2293+ *
2294+ * So a container is translated in two parts, each through `translateView`
2295+ * under its own key rule:
2296+ *
2297+ * - **each view it expands to**, under the name the composer gives it
2298+ * (`expandViewContainer`, the one producer of a view's runtime identity, the
2299+ * same expansion the registrars run), with that view's packaged base looked
2300+ * up by that name — exactly what the by-name read of the expanded view
2301+ * (`<object>.<key>`) is translated with, so the two reads cannot disagree.
2302+ * The result lands back in the slot the view came from;
2303+ * - **the container's own `label` and `description`**, keyed by the object it
2304+ * binds, with the packaged container as the base.
2305+ *
2306+ * A string the catalog does not carry is never invented: the translator falls
2307+ * back to a view's `name` for a missing label, and that fallback is not a
2308+ * translation ({@link projectViewTranslation}). With no bundle, nothing
2309+ * changes and the SAME container is returned.
2310+ *
2311+ * Known limit: a container saved at runtime on ANOTHER package's object is
2312+ * expanded by the metadata protocol under its own name (`<object>.<name>.<key>`),
2313+ * which this function does not reproduce; its views are translated under the
2314+ * composer's names.
2315+ */
2316+ async function translateMetaViewContainer (
2317+ sources : MetaListTranslationSources ,
2318+ container : Record < string , unknown > ,
2319+ bundle : unknown ,
2320+ options : ReturnType < typeof metaTranslateOptions > ,
2321+ ) : Promise < Record < string , unknown > > {
2322+ const object = deriveViewContainerObject ( container ) ;
2323+ // The registrars file nothing they cannot bind, so no read serves such a container.
2324+ if ( object === undefined ) return container ;
2325+ const [ { translateView } , { expandViewContainer } ] = await Promise . all ( [
2326+ import ( '@objectstack/spec/system' ) ,
2327+ import ( '@objectstack/spec/ui' ) ,
2328+ ] ) ;
2329+ const protocol = await sources . resolveProtocol ( ) ;
2330+ const translate = ( view : Record < string , unknown > , baseName : unknown ) : Record < string , unknown > =>
2331+ translateView ( view as any , bundle as any , {
2332+ ...options ,
2333+ packagedBase : packagedObjectBaseOf ( protocol , 'view' , baseName ) ,
2334+ } ) as unknown as Record < string , unknown > ;
2335+
2336+ // The container's own strings. The key the registry files it under is its
2337+ // `name` when it carries one (a source registrar refuses a `name` that is
2338+ // not the object, a save stamps the save name), else the object.
2339+ const ownName = typeof container . name === 'string' && container . name !== '' ? container . name : object ;
2340+ const ownInput = { ...container , name : object , object } ;
2341+ let next = projectViewTranslation ( container , ownInput , translate ( ownInput , ownName ) ) ;
2342+
2343+ // Each view, by the item the composer expands its slot into.
2344+ const items = expandViewContainer ( object , container ) as ExpandedViewItem [ ] ;
2345+ const lists = items . filter ( ( item ) => item . viewKind === 'list' ) ;
2346+ const forms = items . filter ( ( item ) => item . viewKind === 'form' ) ;
2347+ const slotTranslation = ( slot : unknown , item : ExpandedViewItem | undefined ) : unknown => {
2348+ if ( item === undefined || ! slot || typeof slot !== 'object' || Array . isArray ( slot ) ) return slot ;
2349+ const input = item as unknown as Record < string , unknown > ;
2350+ return projectViewTranslation ( slot as Record < string , unknown > , input , translate ( input , item . name ) ) ;
2351+ } ;
2352+ const put = ( key : string , value : unknown ) : void => {
2353+ if ( value === next [ key ] ) return ;
2354+ if ( next === container ) next = { ...container } ;
2355+ next [ key ] = value ;
2356+ } ;
2357+ const slotRecord = ( key : string , itemFor : ( slotKey : string , slot : unknown ) => ExpandedViewItem | undefined ) : void => {
2358+ const record = next [ key ] ;
2359+ if ( ! record || typeof record !== 'object' || Array . isArray ( record ) ) return ;
2360+ let out = record as Record < string , unknown > ;
2361+ for ( const [ slotKey , slot ] of Object . entries ( record ) ) {
2362+ const translated = slotTranslation ( slot , itemFor ( slotKey , slot ) ) ;
2363+ if ( translated === slot ) continue ;
2364+ if ( out === record ) out = { ...( record as Record < string , unknown > ) } ;
2365+ out [ slotKey ] = translated ;
2366+ }
2367+ put ( key , out ) ;
2368+ } ;
2369+ // The composer's order, from its docblock: each family's NAMED views first,
2370+ // in entry order, then the default. A `listViews` entry is the first claim
2371+ // on its `<object>.<key>`, so it is never renamed and is found by name. The
2372+ // default `list` and the default `form` are the items the composer flags
2373+ // `isDefault` (a `list` restating a named list collapses into that item; a
2374+ // named list is flagged only when there is no `list`). A `formViews` entry
2375+ // can be renamed by a list that took its name, so it is found by position
2376+ // among the named forms — entries that are not objects expand to nothing.
2377+ slotRecord ( 'listViews' , ( slotKey ) => lists . find ( ( item ) => item . name === `${ object } .${ slotKey } ` ) ) ;
2378+ if ( next . list && typeof next . list === 'object' ) {
2379+ put ( 'list' , slotTranslation ( next . list , lists . find ( ( item ) => item . isDefault ) ) ) ;
2380+ }
2381+ const namedForms = forms . filter ( ( item ) => ! item . isDefault ) ;
2382+ let formPosition = 0 ;
2383+ slotRecord ( 'formViews' , ( _slotKey , slot ) =>
2384+ slot && typeof slot === 'object' ? namedForms [ formPosition ++ ] : undefined ) ;
2385+ if ( next . form && typeof next . form === 'object' ) {
2386+ put ( 'form' , slotTranslation ( next . form , forms . find ( ( item ) => item . isDefault ) ) ) ;
2387+ }
2388+ return next ;
2389+ }
2390+
2391+ /**
2392+ * [#22614] Land what `translateView` changed on `input` onto `target` — the
2393+ * container itself, or the slot `input` was expanded from — copy on write,
2394+ * `target` itself when nothing changed.
2395+ *
2396+ * Every top-level string the translator overlays (`label`, `description`)
2397+ * lands on `target`, and so does every key it replaced in the view's `config`
2398+ * (`bulkActionDefs`): an expanded view nests its whole list or form body under
2399+ * `config`, and the slot IS that body. The expansion's own identity keys are
2400+ * never copied. A `label` that equals `input.name` where `input` had none is
2401+ * the translator's fallback, not a translation, and is dropped.
2402+ */
2403+ function projectViewTranslation (
2404+ target : Record < string , unknown > ,
2405+ input : Record < string , unknown > ,
2406+ translated : Record < string , unknown > ,
2407+ ) : Record < string , unknown > {
2408+ let out = target ;
2409+ const set = ( key : string , value : unknown ) : void => {
2410+ if ( value === out [ key ] ) return ;
2411+ if ( out === target ) out = { ...target } ;
2412+ out [ key ] = value ;
2413+ } ;
2414+ for ( const [ key , value ] of Object . entries ( translated ) ) {
2415+ if ( key === 'config' || VIEW_EXPANSION_IDENTITY_KEYS . has ( key ) || value === input [ key ] ) continue ;
2416+ if ( key === 'label' && input . label == null && value === input . name ) continue ;
2417+ set ( key , value ) ;
2418+ }
2419+ const config = translated . config ;
2420+ const inputConfig = input . config ;
2421+ if ( config !== inputConfig && config && typeof config === 'object' && inputConfig && typeof inputConfig === 'object' ) {
2422+ for ( const [ key , value ] of Object . entries ( config ) ) {
2423+ if ( value !== ( inputConfig as Record < string , unknown > ) [ key ] ) set ( key , value ) ;
2424+ }
2425+ }
2426+ return out ;
2427+ }
2428+
2429+ /** The keys `expandViewContainer` stamps on an item — its identity, never the view's own content. */
2430+ const VIEW_EXPANSION_IDENTITY_KEYS : ReadonlySet < string > = new Set ( [
2431+ 'name' , 'object' , 'viewKind' , 'order' , 'scope' , 'isDefault' , '_diagnostics' ,
2432+ ] ) ;
2433+
22582434/**
22592435 * [#5563 · #10235 · #20408] The one place a single-item read rebuilds its
22602436 * response body around the document it serves: `envelope` supplies the
0 commit comments