@ti-engine/web-framework 1.20.0 → 1.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1301,6 +1301,320 @@ document.addEventListener( "htmx:responseError", ( event ) => {
1301
1301
  }
1302
1302
  } );
1303
1303
 
1304
+ /* ============================================================================================================
1305
+ Read-only "facts" screens — Profile and About.
1306
+
1307
+ Both render the same descriptor shape: an identity header plus an ordered list of titled sections, each
1308
+ holding label/value items. The server decides the content (see TiWebAppManager#getProfileInfo /
1309
+ #getApplicationInfo); everything below only shapes it for the template, so the fragments stay free of
1310
+ formatting logic — which is also what keeps them within Alpine's CSP expression subset.
1311
+ ============================================================================================================ */
1312
+
1313
+ /**
1314
+ * Rendered in place of an item value the server left empty. Keeping it here rather than in each fragment means a
1315
+ * section never has to be conditionally assembled just to avoid a blank cell.
1316
+ *
1317
+ * @type {string}
1318
+ */
1319
+ const INFO_VALUE_PLACEHOLDER = "—";
1320
+
1321
+ /**
1322
+ * URL schemes an info item may link to. Descriptor content is server-authored, but an `href` becomes a live
1323
+ * navigation target, so the set is an allowlist rather than a `javascript:` denylist.
1324
+ *
1325
+ * @type {string[]}
1326
+ */
1327
+ const INFO_LINK_SCHEMES = [ "http:", "https:", "mailto:" ];
1328
+
1329
+ /**
1330
+ * Returns the href for an info item, or an empty string when it has none or names a scheme that is not in
1331
+ * {@link INFO_LINK_SCHEMES}. A rejected href degrades the item to plain text rather than dropping it.
1332
+ *
1333
+ * @method
1334
+ * @param {Object} item
1335
+ * @returns {string}
1336
+ * @private
1337
+ */
1338
+ const sanitizeInfoHref = ( item ) => {
1339
+ const href = String( ( item && item.href ) || "" ).trim();
1340
+ if ( !href ) {
1341
+ return "";
1342
+ }
1343
+ try {
1344
+ return INFO_LINK_SCHEMES.includes( new URL( href, window.location.origin ).protocol ) ? href : "";
1345
+ } catch {
1346
+ return "";
1347
+ }
1348
+ };
1349
+
1350
+ /**
1351
+ * Normalizes one label/value item: fills in the placeholder for an absent value, collapses the presentational
1352
+ * flags into a single class string, and vets any link target.
1353
+ *
1354
+ * @method
1355
+ * @param {Object} item
1356
+ * @returns {Object}
1357
+ * @private
1358
+ */
1359
+ const normalizeInfoItem = ( item ) => {
1360
+ const source = ( item && typeof item === "object" ) ? item : {};
1361
+ const value = String( source.value === undefined || source.value === null ? "" : source.value ).trim();
1362
+ const classes = [];
1363
+ if ( source.mono ) classes.push( "mono" );
1364
+ if ( source.muted || !value ) classes.push( "muted" );
1365
+
1366
+ return {
1367
+ label: String( source.label || "" ),
1368
+ value: value || INFO_VALUE_PLACEHOLDER,
1369
+ valueClass: classes.join( " " ),
1370
+ wide: source.wide === true,
1371
+ href: value ? sanitizeInfoHref( source ) : ""
1372
+ };
1373
+ };
1374
+
1375
+ /**
1376
+ * Normalizes a descriptor's section list so the template can iterate it without guarding for missing keys.
1377
+ * A section carrying no items at all is dropped — an empty panel is noise, not information.
1378
+ *
1379
+ * @method
1380
+ * @param {Array} sections
1381
+ * @returns {Array}
1382
+ * @private
1383
+ */
1384
+ const normalizeInfoSections = ( sections ) => {
1385
+ return ( Array.isArray( sections ) ? sections : [] )
1386
+ .filter( ( section ) => section && Array.isArray( section.items ) && section.items.length > 0 )
1387
+ .map( ( section ) => ( {
1388
+ title: String( section.title || "" ),
1389
+ description: String( section.description || "" ),
1390
+ icon: String( section.icon || "info-circle" ),
1391
+ wide: section.wide === true,
1392
+ items: section.items.map( normalizeInfoItem )
1393
+ } ) );
1394
+ };
1395
+
1396
+ /**
1397
+ * Normalizes an identity tag into a ready-to-bind pill.
1398
+ *
1399
+ * @method
1400
+ * @param {Object} tag
1401
+ * @returns {Object}
1402
+ * @private
1403
+ */
1404
+ const normalizeInfoTag = ( tag ) => {
1405
+ const source = ( tag && typeof tag === "object" ) ? tag : {};
1406
+ const classes = [ source.mono ? "ti-tag mono" : "ti-status-pill" ];
1407
+ if ( source.tone ) classes.push( String( source.tone ) );
1408
+ return {
1409
+ text: String( source.text || "" ),
1410
+ dot: source.dot === true && !source.mono,
1411
+ pillClass: classes.join( " " )
1412
+ };
1413
+ };
1414
+
1415
+ /**
1416
+ * Runs `load` once the application store has finished initializing — the descriptor endpoints are session-scoped,
1417
+ * so requesting them before `/app/config` has resolved would race the session bootstrap.
1418
+ *
1419
+ * @method
1420
+ * @param {Object} component The Alpine component (for `$watch`).
1421
+ * @param {Function} load
1422
+ * @private
1423
+ */
1424
+ const loadWhenInitialized = ( component, load ) => {
1425
+ const tiApplication = Alpine.store( "tiApplication" );
1426
+ if ( tiApplication.isInitialized ) {
1427
+ load();
1428
+ } else {
1429
+ component.$watch( () => tiApplication.isInitialized, ( isInitialized ) => {
1430
+ if ( isInitialized ) {
1431
+ load();
1432
+ }
1433
+ } );
1434
+ }
1435
+ };
1436
+
1437
+ /**
1438
+ * Returns a configuration object for the Profile screen "frame-profile.html".
1439
+ * <br/>
1440
+ * The descriptor comes from `GET /app/profile` (JSON), which the application's web app manager builds — this
1441
+ * component only shapes it for the template and never decides what a profile contains.
1442
+ *
1443
+ * @method
1444
+ * @returns {Object}
1445
+ * @public
1446
+ */
1447
+ const configureScreenProfile = () => {
1448
+ const tiToolbox = Alpine.store( "tiToolbox" );
1449
+ const tiApplication = Alpine.store( "tiApplication" );
1450
+
1451
+ return {
1452
+
1453
+ profile: null,
1454
+ busy: true,
1455
+
1456
+ init() {
1457
+ loadWhenInitialized( this, () => this.load() );
1458
+ },
1459
+
1460
+ load() {
1461
+ this.busy = true;
1462
+ tiApplication.sendRequest( "/app/profile" ).then( ( result ) => {
1463
+ const data = ( result && result.data && typeof result.data === "object" ) ? result.data : {};
1464
+ const identity = ( data.identity && typeof data.identity === "object" ) ? data.identity : {};
1465
+ this.profile = {
1466
+ identity: {
1467
+ name: String( identity.name || "" ),
1468
+ subtitle: String( identity.subtitle || "" ),
1469
+ caption: String( identity.caption || "" ),
1470
+ avatarSeed: String( identity.avatarSeed || identity.name || "" ),
1471
+ badge: identity.badge ? { text: String( identity.badge.text || "" ), tone: String( identity.badge.tone || "" ) } : null,
1472
+ tags: ( Array.isArray( identity.tags ) ? identity.tags : [] ).map( normalizeInfoTag )
1473
+ },
1474
+ sections: normalizeInfoSections( data.sections )
1475
+ };
1476
+ this.busy = false;
1477
+ } ).catch( ( error ) => {
1478
+ this.busy = false;
1479
+ if ( error && ( error.name === "AbortError" || error.isAborted ) ) return;
1480
+ tiApplication.notify( tiApplication.formatException( error ) );
1481
+ } );
1482
+ },
1483
+
1484
+ avatarStyle() {
1485
+ const identity = this.profile ? this.profile.identity : {};
1486
+ return tiToolbox.generateAvatarStyle( identity.avatarSeed, identity.name );
1487
+ },
1488
+
1489
+ avatarInitial() {
1490
+ const identity = this.profile ? this.profile.identity : {};
1491
+ return String( identity.name || "?" ).charAt( 0 ).toUpperCase();
1492
+ }
1493
+
1494
+ };
1495
+ };
1496
+
1497
+ /**
1498
+ * Returns a configuration object for the About screen "frame-about.html".
1499
+ * <br/>
1500
+ * The server descriptor carries the application's identity as flat fields; the release, component and runtime
1501
+ * sections are assembled here rather than server-side, because their labels are fixed framework chrome and the
1502
+ * client's `getLabel` takes a fallback — which is what keeps the screen readable inside a consuming application
1503
+ * that loads only its own label catalogue.
1504
+ *
1505
+ * @method
1506
+ * @returns {Object}
1507
+ * @public
1508
+ */
1509
+ const configureScreenAbout = () => {
1510
+ const tiToolbox = Alpine.store( "tiToolbox" );
1511
+ const tiApplication = Alpine.store( "tiApplication" );
1512
+
1513
+ // Runtime keys the framework itself supplies; anything else an application adds falls back to its own key.
1514
+ const RUNTIME_LABELS = {
1515
+ node: [ "interface.about.runtime-node", "Node.js" ],
1516
+ platform: [ "interface.about.runtime-platform", "Platform" ],
1517
+ application: [ "interface.about.runtime-application", "Application ID" ]
1518
+ };
1519
+
1520
+ const label = ( key, fallback ) => tiApplication.getLabel( key, fallback );
1521
+
1522
+ return {
1523
+
1524
+ application: null,
1525
+ sections: [],
1526
+ busy: true,
1527
+
1528
+ init() {
1529
+ loadWhenInitialized( this, () => this.load() );
1530
+ },
1531
+
1532
+ load() {
1533
+ this.busy = true;
1534
+ tiApplication.sendRequest( "/app/about" ).then( ( result ) => {
1535
+ const data = ( result && result.data && typeof result.data === "object" ) ? result.data : {};
1536
+ this.application = {
1537
+ name: String( data.name || "" ),
1538
+ version: String( data.version || "" ),
1539
+ releaseDate: String( data.releaseDate || "" ),
1540
+ description: String( data.description || "" )
1541
+ };
1542
+ this.sections = normalizeInfoSections( [
1543
+ this._releaseSection( data ),
1544
+ this._componentsSection( data ),
1545
+ this._runtimeSection( data )
1546
+ ].concat( Array.isArray( data.sections ) ? data.sections : [] ) );
1547
+ this.busy = false;
1548
+ } ).catch( ( error ) => {
1549
+ this.busy = false;
1550
+ if ( error && ( error.name === "AbortError" || error.isAborted ) ) return;
1551
+ tiApplication.notify( tiApplication.formatException( error ) );
1552
+ } );
1553
+ },
1554
+
1555
+ applicationInitial() {
1556
+ return String( ( this.application && this.application.name ) || "?" ).charAt( 0 ).toUpperCase();
1557
+ },
1558
+
1559
+ versionTag() {
1560
+ return "v" + ( ( this.application && this.application.version ) || "" );
1561
+ },
1562
+
1563
+ releaseDateText() {
1564
+ const released = label( "interface.about.released", "Released" );
1565
+ return released + " " + tiToolbox.formatDate( ( this.application && this.application.releaseDate ) || "" );
1566
+ },
1567
+
1568
+ _releaseSection( data ) {
1569
+ return {
1570
+ title: label( "interface.about.section-release", "Release" ),
1571
+ icon: "info-circle",
1572
+ items: [
1573
+ { label: label( "interface.about.field-version", "Version" ), value: data.version, mono: true },
1574
+ { label: label( "interface.about.field-release-date", "Release date" ), value: tiToolbox.formatDate( data.releaseDate ) },
1575
+ { label: label( "interface.about.field-license", "License" ), value: data.license },
1576
+ { label: label( "interface.about.field-author", "Author" ), value: data.author },
1577
+ { label: label( "interface.about.field-package", "Package" ), value: data.packageName, mono: true, wide: true },
1578
+ { label: label( "interface.about.field-homepage", "Homepage" ), value: data.homepage, href: data.homepage, wide: true }
1579
+ ]
1580
+ };
1581
+ },
1582
+
1583
+ _componentsSection( data ) {
1584
+ const components = Array.isArray( data.components ) ? data.components : [];
1585
+ return {
1586
+ title: label( "interface.about.section-components", "Framework components" ),
1587
+ description: label( "interface.about.section-components-desc", "The ti-engine packages this application is built on." ),
1588
+ icon: "folder",
1589
+ items: components.map( ( component ) => ( {
1590
+ label: String( component.name || "" ),
1591
+ value: String( component.version || "" ),
1592
+ mono: true
1593
+ } ) )
1594
+ };
1595
+ },
1596
+
1597
+ _runtimeSection( data ) {
1598
+ // Present only for an admin session — the server withholds `runtime` from everyone else.
1599
+ const runtime = ( data.runtime && typeof data.runtime === "object" ) ? data.runtime : {};
1600
+ return {
1601
+ title: label( "interface.about.section-runtime", "Runtime" ),
1602
+ description: label( "interface.about.section-runtime-desc", "Visible to administrators only." ),
1603
+ icon: "settings",
1604
+ items: Object.keys( runtime ).map( ( key ) => {
1605
+ const known = RUNTIME_LABELS[ key ];
1606
+ return {
1607
+ label: known ? label( known[ 0 ], known[ 1 ] ) : key,
1608
+ value: String( runtime[ key ] || "" ),
1609
+ mono: true
1610
+ };
1611
+ } )
1612
+ };
1613
+ }
1614
+
1615
+ };
1616
+ };
1617
+
1304
1618
  /**
1305
1619
  * Returns a configuration object for the login screen test user pill panel.
1306
1620
  * <br/>
@@ -1424,4 +1738,6 @@ document.addEventListener( "alpine:init", () => {
1424
1738
  Alpine.data( "tiComponentNotificationBar", configureComponentNotificationBar );
1425
1739
  Alpine.data( "tiComponentTooltip", configureComponentTooltip );
1426
1740
  Alpine.data( "tiLoginTestUserPanel", configureLoginTestUserPanel );
1741
+ Alpine.data( "tiScreenProfile", configureScreenProfile );
1742
+ Alpine.data( "tiScreenAbout", configureScreenAbout );
1427
1743
  } );
@@ -14,8 +14,9 @@ const fs = require( "node:fs" );
14
14
  const configRegistry = require( "#config-registry" );
15
15
  const configService = require( "#config-service" );
16
16
  const authorization = require( "#authorization" );
17
+ const applicationInfo = require( "#application-info" );
17
18
 
18
- /** @import { TiSession } from "#definitions" */
19
+ /** @import { TiApplicationInfo, TiInfoSection, TiProfileInfo, TiSession } from "#definitions" */
19
20
 
20
21
  const RE_NONCE_ATTR = /\{ti-nonce-placeholder}/g;
21
22
  const RE_CSRF_ATTR = /\{ti-csrf-placeholder}/g;
@@ -124,6 +125,7 @@ class TiWebAppManager {
124
125
  #staticFileCache = {};
125
126
  #staticFileCacheEnabled;
126
127
  #enabledAuthMethods = [];
128
+ #baseApplicationInfo = null;
127
129
 
128
130
  /**
129
131
  * @constructor
@@ -165,6 +167,10 @@ class TiWebAppManager {
165
167
  title: "Profile",
166
168
  path: "fragments/frame-profile.html"
167
169
  };
170
+ this.#fragments[ 'about' ] = {
171
+ title: "About",
172
+ path: "fragments/frame-about.html"
173
+ };
168
174
  this.#fragments[ 'not-found' ] = {
169
175
  title: "Not Found",
170
176
  path: "fragments/frame-not-found.html"
@@ -393,13 +399,20 @@ class TiWebAppManager {
393
399
  * @public
394
400
  */
395
401
  processDataRequest( session, view, options = {} ) {
402
+ if ( view === "profile" ) {
403
+ return this.getProfileInfo( session );
404
+ }
405
+ if ( view === "about" ) {
406
+ return this.getApplicationInfo( session );
407
+ }
396
408
  return new Promise( ( resolve, reject ) => {
397
409
  if ( view === "config" ) {
398
410
  resolve( {
399
411
  labels: localization.getAllLabels( session?.language ),
400
412
  auth: {
401
413
  isAuthenticated: Boolean( session && session.user )
402
- }
414
+ },
415
+ componentsConfig: this.buildComponentsConfig( session )
403
416
  } );
404
417
  } else {
405
418
  reject( exceptions.raise( exceptions.exceptionCode.E_WEB_INVALID_REQUEST_URI, { view: view } ) );
@@ -407,6 +420,198 @@ class TiWebAppManager {
407
420
  } );
408
421
  }
409
422
 
423
+ /**
424
+ * Returns the configuration for the shared UI components the application shell renders — currently the sidebar
425
+ * user flyout menu. Shipped as part of the `config` data payload and merged into the `tiComponentsConfig`
426
+ * Alpine store on the client.
427
+ * <br/>
428
+ * The default menu links the two screens the framework itself provides (Profile and About) plus sign-out, so a
429
+ * consuming application gets a working user menu without configuring one. Override to replace it; a subclass
430
+ * that supplies its own `componentsConfig` naturally supersedes this.
431
+ *
432
+ * @method
433
+ * @param {TiSession} session
434
+ * @returns {Object}
435
+ * @virtual
436
+ * @public
437
+ */
438
+ buildComponentsConfig( session ) {
439
+ const language = session && session.language;
440
+ return {
441
+ userProfileMenu: {
442
+ menuTitle: localization.getLabel( "interface.topbar.user-profile", language, "Your profile" ),
443
+ placement: "right-end",
444
+ offset: 0,
445
+ buttonConfigs: [ {
446
+ title: localization.getLabel( "interface.user-menu.profile", language, "Your profile" ),
447
+ icon: "user-profile",
448
+ action: { href: "/app/profile", target: "#ti-content", swap: "innerHTML" }
449
+ }, {
450
+ title: localization.getLabel( "interface.user-menu.about", language, "About" ),
451
+ icon: "info-circle",
452
+ action: { href: "/app/about", target: "#ti-content", swap: "innerHTML" }
453
+ }, {
454
+ title: localization.getLabel( "interface.user-menu.logout", language, "Logout" ),
455
+ icon: "logout",
456
+ action: { href: "/logout", method: "post", target: "body", swap: "outerHTML" }
457
+ } ]
458
+ }
459
+ };
460
+ }
461
+
462
+ /**
463
+ * Returns the descriptor rendered by the "Profile" screen — the identity header plus an ordered list of titled
464
+ * label/value sections. Every string in it is display-ready: the server resolves labels and formats values,
465
+ * because this is where the session language and the label catalogue are (see {@link resolveLabel}).
466
+ * <br/>
467
+ * The default implementation reports what the framework itself knows about the session user — name, username,
468
+ * e-mail, language and roles. Override in subclasses to show application-owned data instead; the screen, its
469
+ * Alpine component and its styling are inherited unchanged, so an override only decides the content.
470
+ * <br/>
471
+ * NOTE: The descriptor is always about the SESSION user. There is deliberately no "whose profile" parameter —
472
+ * viewing another person's record belongs to an application screen that carries its own scoping rules.
473
+ *
474
+ * @method
475
+ * @param {TiSession} session
476
+ * @returns {Promise<TiProfileInfo>}
477
+ * @exception {TiException.E_SEC_UNAUTHORIZED_ACCESS} (401) When the session carries no user.
478
+ * @virtual
479
+ * @public
480
+ */
481
+ getProfileInfo( session ) {
482
+ return new Promise( ( resolve, reject ) => {
483
+ const user = session && session.user;
484
+ if ( !user ) {
485
+ return reject( exceptions.raise( exceptions.exceptionCode.E_SEC_UNAUTHORIZED_ACCESS, null, exceptions.httpCode.C_401 ) );
486
+ }
487
+ resolve( {
488
+ identity: this.buildSessionIdentity( session ),
489
+ sections: this.buildAccountSections( session )
490
+ } );
491
+ } );
492
+ }
493
+
494
+ /**
495
+ * Returns the descriptor rendered by the "About" screen — the application's own identity (name, version,
496
+ * release date, description, license, homepage) plus the ti-engine component versions it runs on, and any
497
+ * extra sections the application contributes.
498
+ * <br/>
499
+ * The baseline is resolved once from the consuming application's `package.json`, overridable through
500
+ * `TI_WEB_APP_NAME` / `TI_WEB_APP_VERSION` / `TI_WEB_APP_RELEASE_DATE` (see `#application-info`), and cached —
501
+ * the manifest cannot change while the process runs.
502
+ * <br/>
503
+ * NOTE: Runtime facts (node version, platform, instance identity) are attached only for an `admin` session.
504
+ * They are operational detail that helps support and means nothing to an ordinary user, so they are not handed
505
+ * to every signed-in visitor. Override in subclasses to append application-specific sections; call `super` and
506
+ * extend the result rather than rebuilding it.
507
+ *
508
+ * @method
509
+ * @param {TiSession} session
510
+ * @returns {Promise<TiApplicationInfo>}
511
+ * @virtual
512
+ * @public
513
+ */
514
+ getApplicationInfo( session ) {
515
+ return new Promise( ( resolve ) => {
516
+ if ( !this.#baseApplicationInfo ) {
517
+ this.#baseApplicationInfo = applicationInfo.buildApplicationInfo( {
518
+ manifest: applicationInfo.readApplicationManifest(),
519
+ env: process.env,
520
+ components: TiWebAppManager.resolveFrameworkComponents()
521
+ } );
522
+ }
523
+
524
+ const info = structuredClone( this.#baseApplicationInfo );
525
+ if ( authorization.hasAnyRole( session, [ authorization.ADMIN_ROLE ] ) ) {
526
+ info.runtime = {
527
+ node: process.version,
528
+ platform: `${ process.platform } · ${ process.arch }`,
529
+ application: this.#webAppIdentifier
530
+ };
531
+ }
532
+ resolve( info );
533
+ } );
534
+ }
535
+
536
+ /**
537
+ * Builds the identity header of the Profile screen from the session user. Kept separate from
538
+ * {@link TiWebAppManager#getProfileInfo} so a subclass that replaces the sections can still reuse — or fall
539
+ * back to — the framework's identity block when the application has no richer identity to show.
540
+ *
541
+ * @method
542
+ * @param {TiSession} session
543
+ * @returns {Object}
544
+ * @public
545
+ */
546
+ buildSessionIdentity( session ) {
547
+ const user = ( session && session.user ) || {};
548
+ return {
549
+ name: String( user.name || user.username || user.userID || "" ),
550
+ subtitle: "",
551
+ caption: String( user.email || "" ),
552
+ avatarSeed: String( user.userID || user.username || "" ),
553
+ // No pills by default: the framework knows roles only as opaque codes, and a stack of pills reading
554
+ // "1" / "2" beside the name is noise. The Access section lists them, and an application that has
555
+ // meaningful status to show (employment state, an ID, a badge) supplies its own.
556
+ tags: []
557
+ };
558
+ }
559
+
560
+ /**
561
+ * Builds the framework's account-level Profile sections from the session user. A subclass showing richer
562
+ * application data can append these so the account facts remain visible alongside it.
563
+ *
564
+ * @method
565
+ * @param {TiSession} session
566
+ * @returns {TiInfoSection[]}
567
+ * @public
568
+ */
569
+ buildAccountSections( session ) {
570
+ const user = ( session && session.user ) || {};
571
+ const language = session && session.language;
572
+ const roles = Array.isArray( user.roles ) ? user.roles : [];
573
+
574
+ return [ {
575
+ title: localization.getLabel( "interface.profile.section-account", language, "Account" ),
576
+ icon: "user",
577
+ items: [
578
+ { label: localization.getLabel( "interface.profile.field-name", language, "Full name" ), value: String( user.name || "" ) },
579
+ { label: localization.getLabel( "interface.profile.field-username", language, "Username" ), value: String( user.username || "" ) },
580
+ { label: localization.getLabel( "interface.profile.field-email", language, "E-mail" ), value: String( user.email || "" ), wide: true },
581
+ { label: localization.getLabel( "interface.profile.field-user-id", language, "User ID" ), value: String( user.userID || "" ), mono: true },
582
+ { label: localization.getLabel( "interface.profile.field-language", language, "Language" ), value: String( user.language || language || "" ) }
583
+ ]
584
+ }, {
585
+ title: localization.getLabel( "interface.profile.section-access", language, "Access" ),
586
+ icon: "check-circle",
587
+ items: [
588
+ { label: localization.getLabel( "interface.profile.field-roles", language, "Roles" ), value: roles.join( " · " ), wide: true }
589
+ ]
590
+ } ];
591
+ }
592
+
593
+ /**
594
+ * Resolves the versions of the ti-engine packages the running application is built on, for the About screen.
595
+ * <br/>
596
+ * NOTE: `@ti-engine/core` does not expose `./package.json` through its exports map, so its manifest is located
597
+ * by walking up from a module it *does* export. A package that cannot be resolved is simply omitted — an
598
+ * informational screen must never be the reason a request fails.
599
+ *
600
+ * @method
601
+ * @static
602
+ * @returns {Array<{name: string, version: string}>}
603
+ * @public
604
+ */
605
+ static resolveFrameworkComponents() {
606
+ const manifests = [
607
+ applicationInfo.readApplicationManifest( path.join( __dirname, ".." ) ),
608
+ TiWebAppManager.#readManifestForModule( "@ti-engine/core/tools" )
609
+ ];
610
+ return manifests
611
+ .filter( ( manifest ) => manifest && manifest.name && manifest.version )
612
+ .map( ( manifest ) => ( { name: manifest.name, version: manifest.version } ) );
613
+ }
614
+
410
615
  /**
411
616
  * Used to process an application service request.
412
617
  *
@@ -452,6 +657,34 @@ class TiWebAppManager {
452
657
 
453
658
  /* Private interface */
454
659
 
660
+ /**
661
+ * Locates the `package.json` owning a resolvable module specifier by walking up from the resolved file.
662
+ *
663
+ * @method
664
+ * @static
665
+ * @param {string} moduleSpecifier
666
+ * @returns {Object} The manifest, or an empty object when it cannot be located.
667
+ */
668
+ static #readManifestForModule( moduleSpecifier ) {
669
+ try {
670
+ let directory = path.dirname( require.resolve( moduleSpecifier ) );
671
+ for ( let depth = 0; depth < 8; depth++ ) {
672
+ const manifest = applicationInfo.readApplicationManifest( directory );
673
+ if ( manifest && manifest.name ) {
674
+ return manifest;
675
+ }
676
+ const parent = path.dirname( directory );
677
+ if ( parent === directory ) {
678
+ break;
679
+ }
680
+ directory = parent;
681
+ }
682
+ } catch {
683
+ // An unresolvable package simply does not appear in the component list.
684
+ }
685
+ return {};
686
+ }
687
+
455
688
  /**
456
689
  * Returns the HTML fragment for the requested route.
457
690
  *