From 645dfa1d36206bad9ddb0f9d1d30bc9db22c4065 Mon Sep 17 00:00:00 2001 From: Neil MacDougall Date: Fri, 17 Jul 2026 22:45:14 +0100 Subject: [PATCH 1/5] Add support for l10n globals and use for Harvester product name --- pkg/harvester-manager/index.ts | 7 ++ pkg/harvester-manager/l10n/en-us.yaml | 38 +++++------ pkg/harvester-manager/l10n/zh-hans.yaml | 10 +-- shell/assets/translations/en-us.yaml | 52 +++++++-------- shell/assets/translations/zh-hans.yaml | 48 +++++++------- shell/core/plugin.ts | 12 +++- shell/core/types.ts | 12 +++- shell/store/__tests__/i18n.test.ts | 86 +++++++++++++++++++++++++ shell/store/i18n.js | 48 ++++++++++++-- shell/types/extension-manager.ts | 4 +- 10 files changed, 232 insertions(+), 85 deletions(-) diff --git a/pkg/harvester-manager/index.ts b/pkg/harvester-manager/index.ts index 498debb388e..2e1dcbaddfb 100644 --- a/pkg/harvester-manager/index.ts +++ b/pkg/harvester-manager/index.ts @@ -14,6 +14,13 @@ export default function(plugin: IPlugin) { plugin.addProduct(require('./config/harvester-manager')); + if (plugin.environment.isPrime) { + plugin.register('l10n-global', 'Harvester', 'SUSE Virtualization'); + plugin.register('l10n', 'en-us', () => { + return { product: { harvesterManager: 'SUSE Virtualization' } }; + }); + } + plugin.addTab( TabLocation.RESOURCE_CREATE_PAGE, { diff --git a/pkg/harvester-manager/l10n/en-us.yaml b/pkg/harvester-manager/l10n/en-us.yaml index 5c73450d06e..697edeacbbc 100644 --- a/pkg/harvester-manager/l10n/en-us.yaml +++ b/pkg/harvester-manager/l10n/en-us.yaml @@ -2,45 +2,45 @@ harvesterManager: manage: Manage tableHeaders: kubernetesVersion: Kubernetes Version - harvesterVersion: Harvester Version + harvesterVersion: '[[Harvester]] Version' cluster: - label: Harvester Clusters - none: There are no Harvester Clusters - learnMore: Learn more about Harvester from the Harvester Web Site or read the Harvester Docs - description: Harvester is a modern Hyperconverged infrastructure (HCI) solution built for bare metal servers using enterprise-grade open source technologies including Kubernetes, Kubevirt and Longhorn. - supportMessage: The Harvester UI Extension only supports Harvester v1.3.0 and later versions + label: '[[Harvester]] Clusters' + none: There are no [[Harvester]] Clusters + learnMore: Learn more about [[Harvester]] from the [[Harvester]] Web Site or read the [[Harvester]] Docs + description: '[[Harvester]] is a modern Hyperconverged infrastructure (HCI) solution built for bare metal servers using enterprise-grade open source technologies including Kubernetes, Kubevirt and Longhorn.' + supportMessage: The [[Harvester]] UI Extension only supports [[Harvester]] v1.3.0 and later versions plugins: loadError: Error loading harvester plugin extension: install: - warning: "The Harvester UI Extension is not installed" - prompt: "UI Integration for Harvester is now provided via the new Harvester UI Extension, which needs to be installed." + warning: "The [[Harvester]] UI Extension is not installed" + prompt: "UI Integration for [[Harvester]] is now provided via the new [[Harvester]] UI Extension, which needs to be installed." prompt-standard-user: Please contact your system administrator steps: repo: 1: Go to the 2: Apps Repositories - 3: page and add the Harvester repository + 3: page and add the [[Harvester]] repository ui: 1: Go to the 2: Extensions - 3: page and install the Harvester Extension + 3: page and install the [[Harvester]] Extension update: - warning: "Your current Harvester UI Extension ({currentVersion}) is not the latest." - prompt: "A new version ({ newVersion }) is available, please click on Update button to install the latest Harvester UI Extension" + warning: "Your current [[Harvester]] UI Extension ({currentVersion}) is not the latest." + prompt: "A new version ({ newVersion }) is available, please click on Update button to install the latest [[Harvester]] UI Extension" prompt-standard-user: Please contact your system administrator to install the latest version missingRepo: - warning: "The Harvester UI Extension repository is missing" - prompt: "Please click on Update button to add the Harvester repository and install the latest Harvester UI Extension" - prompt-standard-user: Please contact your system administrator to install the latest Harvester UI Extension + warning: "The [[Harvester]] UI Extension repository is missing" + prompt: "Please click on Update button to add the [[Harvester]] repository and install the latest [[Harvester]] UI Extension" + prompt-standard-user: Please contact your system administrator to install the latest [[Harvester]] UI Extension missingVersion: warning: "Could not find a compatible version" - prompt: "Please update Rancher to get the latest compatible version of the Harvester UI extension or try to install it manually" + prompt: "Please update Rancher to get the latest compatible version of the [[Harvester]] UI extension or try to install it manually" prompt-standard-user: Please contact your system administrator error: - warning: "Warning, Harvester UI extension automatic installation failed" - prompt: "Please refresh the page and try again or install the Harvester UI extension manually" - prompt-standard-user: Please contact your system administrator to install or update the Harvester extension + warning: "Warning, [[Harvester]] UI extension automatic installation failed" + prompt: "Please refresh the page and try again or install the [[Harvester]] UI extension manually" + prompt-standard-user: Please contact your system administrator to install or update the [[Harvester]] extension rke: templateError: Incorrect template format affinity: diff --git a/pkg/harvester-manager/l10n/zh-hans.yaml b/pkg/harvester-manager/l10n/zh-hans.yaml index c91f94b001d..63d633664d7 100644 --- a/pkg/harvester-manager/l10n/zh-hans.yaml +++ b/pkg/harvester-manager/l10n/zh-hans.yaml @@ -1,12 +1,12 @@ harvesterManager: manage: 管理 cluster: - label: Harvester 集群 - none: 不存在 Harvester 集群 - learnMore: 访问 Harvester 网站 或阅读 Harvester 官方文档,以了解更多关于 Harvester 的信息。 - description: Harvester 是一个采用企业级开源技术,包括Kubernetes、Kubevirt和Longhorn,为裸金属服务器打造的现代超融合基础设施(HCI)解决方案。 + label: '[[Harvester]] 集群' + none: 不存在 [[Harvester]] 集群 + learnMore: 访问 [[Harvester]] 网站 或阅读 [[Harvester]] 官方文档,以了解更多关于 [[Harvester]] 的信息。 + description: '[[Harvester]] 是一个采用企业级开源技术,包括Kubernetes、Kubevirt和Longhorn,为裸金属服务器打造的现代超融合基础设施(HCI)解决方案。' plugins: - loadError: 加载 Harvester 插件时出错 + loadError: 加载 [[Harvester]] 插件时出错 rke: templateError: 模版格式不正确 cpuModel: diff --git a/shell/assets/translations/en-us.yaml b/shell/assets/translations/en-us.yaml index c8228348863..ea2ef02c28a 100644 --- a/shell/assets/translations/en-us.yaml +++ b/shell/assets/translations/en-us.yaml @@ -197,7 +197,7 @@ nav: sideNav: Secondary menu homePage: Home page navigation menu cluster: Cluster menu item - harvesterCluster: Harvester cluster menu item + harvesterCluster: '[[Harvester]] cluster menu item' seeAll: See all clusters menu item multiClusterApps: Main menu multi cluster app menu item configurationApps: Main menu configuration app menu item @@ -213,7 +213,7 @@ nav: mainMenuRancherLogo: Main menu Rancher logo userAvatar: User avatar image expandCollapseAppBar: Expand/Collapse the Application Bar - harvesterDashboard: Harvester Dashboard + harvesterDashboard: '[[Harvester]] Dashboard' backToRancher: Cluster Manager tools: Tools clusterTools: Cluster Tools @@ -1640,7 +1640,7 @@ cluster: label: vSphere note: 'Important: Configure the vSphere Cloud Provider and Storage Provider options in the Add-on Configuration tab.' harvester: - label: Harvester + label: '[[Harvester]]' copyConfig: Copy KubeConfig to Clipboard copiedConfig: Copied KubeConfig to Clipboard custom: @@ -1819,7 +1819,7 @@ cluster: kubeconfigContent: label: KubeconfigContent placeholder: 'Namespace/Name' - cluster: Imported Harvester Cluster + cluster: Imported [[Harvester]] Cluster enableTpm: Enable TPM enableCpuPinning: Enable CPU Pinning cpuPinningTooltip: Enable CPU Pinning brings better performance and reduce latency for the harvester virtual machine @@ -1827,25 +1827,25 @@ cluster: secureBoot: Secure Boot enableEfi: Booting in EFI mode installGuestAgent: Install guest agent - tokenExpirationWarning: 'Warning: Harvester Cloud Credentials use an underlying authentication token that may have an expiry time - please see the following knowledge base article for possible implications on management operations.' + tokenExpirationWarning: 'Warning: [[Harvester]] Cloud Credentials use an underlying authentication token that may have an expiry time - please see the following knowledge base article for possible implications on management operations.' description: label: Cluster Description placeholder: Any text you want that better describes this cluster harvester: - importNotice: Import Harvester Clusters via + importNotice: Import [[Harvester]] Clusters via warning: - label: This is a Harvester Cluster - enable the Harvester feature flag to manage it + label: This is a [[Harvester]] Cluster - enable the [[Harvester]] feature flag to manage it state: Warning cloudProvider: incompatible: - You cannot select the Harvester cloud provider as the current Harvester version is not compatible with the selected RKE2 Kubernetes version, click here to see which Kubernetes versions are supported. + You cannot select the [[Harvester]] cloud provider as the current [[Harvester]] version is not compatible with the selected RKE2 Kubernetes version, click here to see which Kubernetes versions are supported. clusterWarning: |- There {count, plural, - =1 {is 1 Harvester cluster that is not shown} - other {are # Harvester clusters that are not shown} + =1 {is 1 [[Harvester]] cluster that is not shown} + other {are # [[Harvester]] clusters that are not shown} } registration: - step1: "1. Go to the Advanced / Settings page of the target Harvester's UI." + step1: "1. Go to the Advanced / Settings page of the target [[Harvester]]'s UI." step2: '2. Find the cluster-registration-url setting and click the -> Edit Setting button.' step3: "3. Input the following registration URL and click the Save button." step4: "Registration URL" @@ -1869,7 +1869,7 @@ cluster: toolTip: SSH user to login with the selected OS image. kubeconfigSecret: nameRequired: Cluster name is required. - error: 'Error generating Harvester kubeconfig secret: {err}' + error: 'Error generating [[Harvester]] kubeconfig secret: {err}' haveOneOwner: There must be at least one member with the Owner role. import: warningBanner: 'You should not import a cluster which has already been connected to another instance of Rancher as it will lead to data corruption.' @@ -2475,7 +2475,7 @@ cluster: googlegke: Google GKE gke: GKE googlekubernetesengine: Google GKE (Unsupported Kev1) - harvester: Harvester + harvester: '[[Harvester]]' huaweicce: Huawei CCE import: Generic imported: Imported @@ -3146,21 +3146,21 @@ fleet: clusters: harvester: |- There {count, plural, - =1 {is 1 Harvester cluster that is hidden as this can not be used with Continuous Delivery} - other {are # Harvester clusters that are hidden as they can not be used with Continuous Delivery} + =1 {is 1 [[Harvester]] cluster that is hidden as this can not be used with Continuous Delivery} + other {are # [[Harvester]] clusters that are hidden as they can not be used with Continuous Delivery} } tokens: harvester: |- {count, plural, - =1 {1 token is hidden as it belongs to a Harvester cluster that can not be used with Continuous Delivery} - other {# tokens are hidden as they belong to Harvester clusters that can not be used with Continuous Delivery} + =1 {1 token is hidden as it belongs to a [[Harvester]] cluster that can not be used with Continuous Delivery} + other {# tokens are hidden as they belong to [[Harvester]] clusters that can not be used with Continuous Delivery} } bundles: resources: Resources harvester: |- {count, plural, - =1 {1 bundle is hidden as it belongs to a Harvester cluster that can not be used with Continuous Delivery} - other {# bundles are hidden as they belong to Harvester clusters that can not be used with Continuous Delivery} + =1 {1 bundle is hidden as it belongs to a [[Harvester]] cluster that can not be used with Continuous Delivery} + other {# bundles are hidden as they belong to [[Harvester]] clusters that can not be used with Continuous Delivery} } fleetSummary: noClusters: @@ -5296,7 +5296,7 @@ persistentVolume: disk-csi-azure-com: Azure Disk (CSI) file-csi-azure-com: Azure File (CSI) driver-longhorn-io: Longhorn (CSI) - driver-harvesterhci-io: Harvester (CSI) + driver-harvesterhci-io: '[[Harvester]] (CSI)' lvm-driver-harvesterhci-io: LVM nfs-csi-k8s-io: NFS (CSI) ebs-csi-aws-com: AWS Elastic Block Store (CSI) @@ -7004,13 +7004,13 @@ storageClass: title: (Deprecated) warning: 'The {provisioner} in-tree plugin is deprecated: Find a CSI driver here.' harvesterhci: - title: Harvester (CSI) + title: '[[Harvester]] (CSI)' warning: - unSatisfiesVersion: Please upgrade your Harvester CSI driver version to use this feature. (csi-driver >= v0.1.15) + unSatisfiesVersion: Please upgrade your [[Harvester]] CSI driver version to use this feature. (csi-driver >= v0.1.15) hostStorageClass: label: Host Storage Class - placeholder: Storage class name on the host Harvester cluster - tooltip: By default the default storage class on the host Harvester cluster is used. + placeholder: Storage class name on the host [[Harvester]] cluster + tooltip: By default the default storage class on the host [[Harvester]] cluster is used. tableHeaders: assuredConcurrencyShares: Assured Concurrency Shares @@ -8634,8 +8634,8 @@ typeLabel: } harvesterhci.io.management.cluster: |- {count, plural, - one { Harvester Cluster } - other { Harvester Clusters } + one { [[Harvester]] Cluster } + other { [[Harvester]] Clusters } } harvesterhci.io.cloudtemplate: |- {count, plural, diff --git a/shell/assets/translations/zh-hans.yaml b/shell/assets/translations/zh-hans.yaml index 6dfde4ae893..5dc831bb99d 100644 --- a/shell/assets/translations/zh-hans.yaml +++ b/shell/assets/translations/zh-hans.yaml @@ -122,7 +122,7 @@ locale: none: (None) nav: - harvesterDashboard: Harvester 仪表板 + harvesterDashboard: '[[Harvester]] 仪表板' backToRancher: Cluster Manager clusterTools: 集群工具 kubeconfig: @@ -1142,7 +1142,7 @@ cluster: label: vSphere note: '重要:在附加配置标签中配置 vSphere 云提供商和存储提供商选项。' harvester: - label: Harvester + label: '[[Harvester]]' copyConfig: 将 KubeConfig 复制到剪切板 copiedConfig: 已将 KubeConfig 复制到剪切板 custom: @@ -1301,26 +1301,26 @@ cluster: kubeconfigContent: label: KubeconfigContent placeholder: '命名空间/名称' - cluster: 导入的 Harvester 集群 + cluster: 导入的 [[Harvester]] 集群 installGuestAgent: 安装访客代理 description: label: 集群描述 placeholder: 输入可以描述该集群的文本 harvester: - importNotice: 导入 Harvester 集群的方式: + importNotice: 导入 [[Harvester]] 集群的方式: warning: - label: 这是一个 Harvester 集群 - 请启用 Harvester 功能开关以对其进行管理。 + label: 这是一个 [[Harvester]] 集群 - 请启用 [[Harvester]] 功能开关以对其进行管理。 state: 警告 cloudProvider: incompatible: - 由于当前的 Harvester 版本与所选的 RKE2 Kubernetes 版本不兼容,因此你无法选择 Harvester cloud provider。请单击 此处查看支持的 Kubernetes 版本。 + 由于当前的 [[Harvester]] 版本与所选的 RKE2 Kubernetes 版本不兼容,因此你无法选择 [[Harvester]] cloud provider。请单击 此处查看支持的 Kubernetes 版本。 clusterWarning: |- 还有 {count, plural, - =1 {1 个 Harvester 集群未显示} - other { # 个 Harvester 集群未显示} + =1 {1 个 [[Harvester]] 集群未显示} + other { # 个 [[Harvester]] 集群未显示} } registration: - step1: "1. 前往目标 Harvester UI 的高级设置页面。" + step1: "1. 前往目标 [[Harvester]] UI 的高级设置页面。" step2: '2. 找到cluster-registration-url设置项,并点击 -> 编辑设置按钮。' step3: "3. 输入以下注册 URL,并点击保存。" step4: "注册 URL" @@ -1789,7 +1789,7 @@ cluster: google: Google GCE googlegke: Google GKE gke: Google GKE - harvester: Harvester + harvester: '[[Harvester]]' huaweicce: 华为 CCE import: 通用 imported: 已导入 @@ -2116,21 +2116,21 @@ fleet: clusters: harvester: |- 还有 {count, plural, - =1 {1个 Harvester 集群未显示,因为它不能与持续交付一起使用。} - other { #个 Harvester 集群未显示,因为它们不能与持续交付一起使用。} + =1 {1个 [[Harvester]] 集群未显示,因为它不能与持续交付一起使用。} + other { #个 [[Harvester]] 集群未显示,因为它们不能与持续交付一起使用。} } tokens: harvester: |- {count, plural, - =1 {1个 Token 未显示,因为它属于不能与持续交付一起使用的 Harvester 集群。} - other {#个 Token 未显示,因为它属于不能与持续交付一起使用的 Harvester 集群。} + =1 {1个 Token 未显示,因为它属于不能与持续交付一起使用的 [[Harvester]] 集群。} + other {#个 Token 未显示,因为它属于不能与持续交付一起使用的 [[Harvester]] 集群。} } bundles: resources: 资源 harvester: |- {count, plural, - =1 {1 个 Bundle 未显示,因为它属于不能与持续交付一起使用的 Harvester 集群。} - other {# 个 Bundle 未显示,因为它属于不能与持续交付一起使用的 Harvester 集群。} + =1 {1 个 Bundle 未显示,因为它属于不能与持续交付一起使用的 [[Harvester]] 集群。} + other {# 个 Bundle 未显示,因为它属于不能与持续交付一起使用的 [[Harvester]] 集群。} } fleetSummary: noClusters: @@ -3738,7 +3738,7 @@ persistentVolume: disk-csi-azure-com: Azure 磁盘 (CSI) file-csi-azure-com: Azure 文件 (CSI) driver-longhorn-io: Longhorn (CSI) - driver-harvesterhci-io: Harvester (CSI) + driver-harvesterhci-io: '[[Harvester]] (CSI)' nfs-csi-k8s-io: NFS (CSI) ebs-csi-aws-com: AWS Elastic Block Store (CSI) rbd-csi-ceph-com: Ceph RBD (CSI) @@ -5283,13 +5283,13 @@ storageClass: title: (已弃用) warning: '{provisioner} 树内插件已弃用。你可以在这里查找 CSI 驱动。' harvesterhci: - title: Harvester (CSI) + title: '[[Harvester]] (CSI)' warning: - unSatisfiesVersion: 请升级 Harvester CSI 驱动版本来使用此功能(csi-driver >= v0.1.15) + unSatisfiesVersion: 请升级 [[Harvester]] CSI 驱动版本来使用此功能(csi-driver >= v0.1.15) hostStorageClass: label: 主机存储类 - placeholder: 主机 Harvester 集群上的存储类名称 - tooltip: 默认使用主机 Harvester 集群上的默认存储类。 + placeholder: 主机 [[Harvester]] 集群上的存储类名称 + tooltip: 默认使用主机 [[Harvester]] 集群上的默认存储类。 tableHeaders: assuredConcurrencyShares: 确保并发份额 @@ -6696,8 +6696,8 @@ typeLabel: } harvesterhci.io.management.cluster: |- {count, plural, - one { Harvester 集群 } - other { Harvester 集群 } + one { [[Harvester]] 集群 } + other { [[Harvester]] 集群 } } harvesterhci.io.cloudtemplate: |- {count, plural, @@ -7044,7 +7044,7 @@ featureFlags: title: 等待重启 wait: 重启需要数分钟。 description: - harvester: 接入 Harvester,以导入和管理 Harvester 集群 + harvester: 接入 [[Harvester]],以导入和管理 [[Harvester]] 集群 unsupported-storage-drivers: 允许使用非默认启用的存储提供商和卷插件 legacy: 启用旧版功能 istio-virtual-service-ui: 暴露了一个用户界面,使用户能够创建、读取、更新和删除虚拟服务和目标规则,这是 Istio 的流量管理功能。 diff --git a/shell/core/plugin.ts b/shell/core/plugin.ts index 34144449105..90a0715b894 100644 --- a/shell/core/plugin.ts +++ b/shell/core/plugin.ts @@ -41,6 +41,12 @@ export const EXT_IDS = { * Extension can provide resources that use server-side-pagination */ SERVER_SIDE_PAGINATION_RESOURCES: 'server-side-pagination', + /** + * Extensions can provide values for l10n global tokens ([[name]]) referenced + * from translation strings. Values may be a string or a function returning a + * string. If a global is not registered, the token name is used as the value. + */ + I18N_GLOBAL: 'l10n-global', } as const; export type EXT_IDS_VALUES = (typeof EXT_IDS)[keyof typeof EXT_IDS]; @@ -431,7 +437,7 @@ export class Plugin implements IPlugin { await this._onLogOut(store); } - public register(type: string, name: string, fn: Function) { + public register(type: string, name: string, fn: Function | String) { const allowPaths = ['models', 'image']; const nparts = name.split('/'); @@ -450,14 +456,14 @@ export class Plugin implements IPlugin { this.l10n[name] = []; } - this.l10n[name].push(fn); + this.l10n[name].push(fn as Function); // Accumulate model extensions } else if (type === EXT_IDS.MODEL_EXTENSION) { if (!this.modelExtensions[name]) { this.modelExtensions[name] = []; } - this.modelExtensions[name].push(fn); + this.modelExtensions[name].push(fn as Function); } else { if (!this.types[type]) { this.types[type] = {}; diff --git a/shell/core/types.ts b/shell/core/types.ts index 4cb1682a6bf..01e9c06790d 100644 --- a/shell/core/types.ts +++ b/shell/core/types.ts @@ -686,11 +686,19 @@ export interface IExtension extends IExtensionProducts { /** * Register 'something' that can be dynamically loaded - e.g. model, edit, create, list, i18n + * + * A special type `'l10n-global'` can be used to register a value that will be + * substituted for `[[name]]` tokens in translation strings, allowing shared + * terms (e.g. product names) to be defined once and referenced across + * localisations. The value can be a string or a function returning a string. + * If no global is registered for a given name, the token's name is used as + * the value. + * * @param {String} type type of thing to register, e.g. 'edit' * @param {String} name unique name of 'something' - * @param {Function} fn function that dynamically loads the module for the thing being registered + * @param {Function|String|Boolean} fn function that dynamically loads the module for the thing being registered, or (for `l10n-global`) the value itself */ - register(type: string, name: string, fn: Function | Boolean): void; + register(type: string, name: string, fn: Function | Boolean | String): void; /** * Will return all of the configuration functions used for creating a new product. diff --git a/shell/store/__tests__/i18n.test.ts b/shell/store/__tests__/i18n.test.ts index 0594a419df4..97c0fb18d2e 100644 --- a/shell/store/__tests__/i18n.test.ts +++ b/shell/store/__tests__/i18n.test.ts @@ -5,6 +5,8 @@ import { getters, mutations, actions, + substituteGlobals, + I18N_GLOBAL_TYPE, } from '@shell/store/i18n'; jest.mock('@shell/assets/translations/en-us.yaml', () => ({})); @@ -206,6 +208,90 @@ describe('i18n store', () => { expect(translate('greeting', {}, 'en-us')).toStrictEqual('Hello'); }); + + describe('i18n globals ([[name]] substitution)', () => { + // The formatter cache in i18n.js is keyed by locale/key; use unique keys + // per test so results do not leak between cases. + function stateWithGlobalMsg(key: string, msg: string) { + const s = makeState({ selected: 'en-us' }); + + (s.translations as any)['en-us'][key] = msg; + + return s; + } + + function extensionWith(globals: Record) { + return { + getDynamic: (type: string, name: string) => { + return type === I18N_GLOBAL_TYPE ? globals[name] : undefined; + } + }; + } + + it('substitutes a [[name]] token with the registered global value', () => { + const s = stateWithGlobalMsg('i18nGlobal.registered', 'Manage your [[Harvester]] cluster'); + const rootState = { $extension: extensionWith({ Harvester: 'Rancher Virtualization' }) }; + const translate = getters.t(s, undefined, rootState); + + expect(translate('i18nGlobal.registered')).toStrictEqual('Manage your Rancher Virtualization cluster'); + }); + + it('falls back to the token name when the global is not registered', () => { + const s = stateWithGlobalMsg('i18nGlobal.unregistered', 'Manage your [[Harvester]] cluster'); + const rootState = { $extension: extensionWith({}) }; + const translate = getters.t(s, undefined, rootState); + + expect(translate('i18nGlobal.unregistered')).toStrictEqual('Manage your Harvester cluster'); + }); + + it('evaluates function-valued globals when substituting', () => { + const s = stateWithGlobalMsg('i18nGlobal.function', 'Welcome to [[Product]]'); + const rootState = { $extension: extensionWith({ Product: () => 'Fabric' }) }; + const translate = getters.t(s, undefined, rootState); + + expect(translate('i18nGlobal.function')).toStrictEqual('Welcome to Fabric'); + }); + + it('preserves the literal [[name]] when the token is escaped with a backslash', () => { + const s = stateWithGlobalMsg('i18nGlobal.escaped', 'Use \\[[Harvester]] to reference the global'); + const rootState = { $extension: extensionWith({ Harvester: 'Rancher Virtualization' }) }; + const translate = getters.t(s, undefined, rootState); + + expect(translate('i18nGlobal.escaped')).toStrictEqual('Use [[Harvester]] to reference the global'); + }); + + it('substitutes multiple different tokens in the same message', () => { + const s = stateWithGlobalMsg('i18nGlobal.multiple', '[[Product]] on [[Platform]]'); + const rootState = { $extension: extensionWith({ Product: 'Fabric', Platform: 'Kubernetes' }) }; + const translate = getters.t(s, undefined, rootState); + + expect(translate('i18nGlobal.multiple')).toStrictEqual('Fabric on Kubernetes'); + }); + + it('substitutes before IntlMessageFormat args are interpolated', () => { + const s = stateWithGlobalMsg('i18nGlobal.withArgs', 'Found {count} [[Harvester]] cluster(s)'); + const rootState = { $extension: extensionWith({ Harvester: 'Rancher Virtualization' }) }; + const translate = getters.t(s, undefined, rootState); + + expect(translate('i18nGlobal.withArgs', { count: 3 })).toStrictEqual('Found 3 Rancher Virtualization cluster(s)'); + }); + + it('leaves messages without [[ tokens unchanged', () => { + const s = stateWithGlobalMsg('i18nGlobal.noToken', 'Nothing to substitute here'); + const rootState = { $extension: extensionWith({ Harvester: 'Rancher Virtualization' }) }; + const translate = getters.t(s, undefined, rootState); + + expect(translate('i18nGlobal.noToken')).toStrictEqual('Nothing to substitute here'); + }); + + it('does not crash when rootState.$extension is unavailable', () => { + const s = stateWithGlobalMsg('i18nGlobal.noExtension', 'Manage your [[Harvester]] cluster'); + const translate = getters.t(s, undefined, {}); + + // With no extension registry available, the token name is used as the value + expect(translate('i18nGlobal.noExtension')).toStrictEqual('Manage your Harvester cluster'); + }); + }); }); describe('exists', () => { diff --git a/shell/store/i18n.js b/shell/store/i18n.js index bbea6f619d1..a6a5a041422 100644 --- a/shell/store/i18n.js +++ b/shell/store/i18n.js @@ -8,6 +8,42 @@ import { loadTranslation } from '@shell/utils/dynamic-importer'; const NONE = 'none'; const DEFAULT_LOCALE = 'en-us'; +// Extension type used to register i18n global values, referenced in translation +// strings using [[name]] notation. See `substituteGlobals` below. +export const I18N_GLOBAL_TYPE = 'l10n-global'; + +// Matches [[name]] where name is any character except ']' and where the opening +// `[[` is NOT preceded by a backslash (which is the escape sequence). +const GLOBAL_PATTERN = /(\\?)\[\[([^\]]+)\]\]/g; + +/** + * Replace [[name]] tokens with values registered as i18n globals via + * `extension.register('i18n-global', name, value)`. If the token is not + * registered, the name itself is used as the value. Use `\[[` to include a + * literal `[[` in a translation string. + * + * @param {string} msg + * @param {any} $extension + * @returns {string} + */ +export function substituteGlobals(msg, $extension) { + if (typeof msg !== 'string' || !msg.includes('[[')) { + return msg; + } + + return msg.replace(GLOBAL_PATTERN, (_match, escape, name) => { + if (escape) { + // Preserve the literal token, stripping only the escape character + return `[[${ name }]]`; + } + + const registered = $extension?.getDynamic?.(I18N_GLOBAL_TYPE, name); + const value = typeof registered === 'function' ? registered() : registered; + + return value !== undefined && value !== null ? String(value) : name; + }); +} + // Formatters can't be serialized into state const intlCache = {}; @@ -62,7 +98,7 @@ export const getters = { return state.available.length > 1; }, - t: (state) => (key, args, language) => { + t: (state, _getters, rootState) => (key, args, language) => { if (state.selected === NONE && !language) { return `%${ key }%`; } @@ -88,10 +124,14 @@ export const getters = { return undefined; } - if ( msg?.includes('{')) { - formatter = new IntlMessageFormat(msg, locale); + // Substitute [[name]] tokens with values registered as i18n globals. + const hasGlobal = msg.includes('[['); + const substituted = hasGlobal ? substituteGlobals(msg, rootState?.$extension) : msg; + + if ( substituted?.includes('{')) { + formatter = new IntlMessageFormat(substituted, locale); } else { - formatter = msg; + formatter = substituted; } intlCache[cacheKey] = formatter; diff --git a/shell/types/extension-manager.ts b/shell/types/extension-manager.ts index 22a54d0a048..25785b5f0be 100644 --- a/shell/types/extension-manager.ts +++ b/shell/types/extension-manager.ts @@ -1,7 +1,7 @@ import { EXT_IDS_VALUES } from '@shell/core/plugin'; import { ClusterProvisionerContext } from '@shell/core/types'; -type ExtensionManagerType = { [name: string]: Function, } +type ExtensionManagerType = { [name: string]: Function | String } export type ExtensionManagerTypes = { [type in EXT_IDS_VALUES]?: ExtensionManagerType } & // eslint-disable-line no-unused-vars { [name: string]: ExtensionManagerType } @@ -17,7 +17,7 @@ export type ExtensionManager = { removePlugin(name: string): Promise; removeTypeFromStore(store: any, storeName: string, types: string[]): any[]; applyPlugin(plugin: any): void; - register(type: string, name: string, fn: Function): void; + register(type: string, name: string, fn: Function | String): void; unregister(type: string, name: string, fn: Function): void; getAll(): ExtensionManagerTypes; getPlugins(): any; From b8f7589c80fb6588c1c6a02703f8f98ece817080 Mon Sep 17 00:00:00 2001 From: Neil MacDougall Date: Wed, 5 Aug 2026 10:08:30 +0100 Subject: [PATCH 2/5] Remove unused import in i18n test file Removed unused import of substituteGlobals from i18n. --- shell/store/__tests__/i18n.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/shell/store/__tests__/i18n.test.ts b/shell/store/__tests__/i18n.test.ts index 97c0fb18d2e..9a83fa549a3 100644 --- a/shell/store/__tests__/i18n.test.ts +++ b/shell/store/__tests__/i18n.test.ts @@ -5,7 +5,6 @@ import { getters, mutations, actions, - substituteGlobals, I18N_GLOBAL_TYPE, } from '@shell/store/i18n'; From 08df8de16e47dbe289468e79a6cd3fb808a37c52 Mon Sep 17 00:00:00 2001 From: Neil MacDougall Date: Wed, 5 Aug 2026 10:57:58 +0100 Subject: [PATCH 3/5] Fix lint issues --- shell/store/__tests__/i18n.test.ts | 15 ++++++++++++--- shell/utils/pagination-utils.ts | 2 +- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/shell/store/__tests__/i18n.test.ts b/shell/store/__tests__/i18n.test.ts index 9a83fa549a3..69e9b05b262 100644 --- a/shell/store/__tests__/i18n.test.ts +++ b/shell/store/__tests__/i18n.test.ts @@ -33,8 +33,17 @@ function makeTranslations() { }; } -function makeState(overrides?: Partial>): ReturnType { - const base = state(); +type I18nState = ReturnType; +type TestI18nState = Omit & { + selected: string | null; + previous: string | null; + default: string; + available: string[]; + translations: Record; +}; + +function makeState(overrides?: Partial): TestI18nState { + const base = state() as TestI18nState; base.translations = makeTranslations() as any; base.selected = 'en-us'; @@ -46,7 +55,7 @@ function makeState(overrides?: Partial>): ReturnType) { +function makeMockGetters(st: TestI18nState) { const g: Record = {}; g.t = getters.t(st); diff --git a/shell/utils/pagination-utils.ts b/shell/utils/pagination-utils.ts index ccd9bc709e6..6e06bbb923c 100644 --- a/shell/utils/pagination-utils.ts +++ b/shell/utils/pagination-utils.ts @@ -181,7 +181,7 @@ class PaginationUtils { if (paginationExtensionPoints) { const allowed = Object.entries(paginationExtensionPoints).find(([_, settingsFn]) => { - if (!settingsFn) { + if (typeof settingsFn !== 'function') { return false; } From f407bcf701236824e1e324728152566421d088f0 Mon Sep 17 00:00:00 2001 From: Neil MacDougall Date: Thu, 6 Aug 2026 16:28:38 +0100 Subject: [PATCH 4/5] Fix unit tests --- shell/store/__tests__/i18n.test.ts | 36 ++++++++++++++++-------------- 1 file changed, 19 insertions(+), 17 deletions(-) diff --git a/shell/store/__tests__/i18n.test.ts b/shell/store/__tests__/i18n.test.ts index 69e9b05b262..a348be0572b 100644 --- a/shell/store/__tests__/i18n.test.ts +++ b/shell/store/__tests__/i18n.test.ts @@ -219,7 +219,9 @@ describe('i18n store', () => { describe('i18n globals ([[name]] substitution)', () => { // The formatter cache in i18n.js is keyed by locale/key; use unique keys - // per test so results do not leak between cases. + // per test so results do not leak between cases. Keys must not contain + // dots — the store looks them up with lodash `get`, which treats dots + // as nested-path navigation. function stateWithGlobalMsg(key: string, msg: string) { const s = makeState({ selected: 'en-us' }); @@ -237,67 +239,67 @@ describe('i18n store', () => { } it('substitutes a [[name]] token with the registered global value', () => { - const s = stateWithGlobalMsg('i18nGlobal.registered', 'Manage your [[Harvester]] cluster'); + const s = stateWithGlobalMsg('i18nGlobalRegistered', 'Manage your [[Harvester]] cluster'); const rootState = { $extension: extensionWith({ Harvester: 'Rancher Virtualization' }) }; const translate = getters.t(s, undefined, rootState); - expect(translate('i18nGlobal.registered')).toStrictEqual('Manage your Rancher Virtualization cluster'); + expect(translate('i18nGlobalRegistered')).toStrictEqual('Manage your Rancher Virtualization cluster'); }); it('falls back to the token name when the global is not registered', () => { - const s = stateWithGlobalMsg('i18nGlobal.unregistered', 'Manage your [[Harvester]] cluster'); + const s = stateWithGlobalMsg('i18nGlobalUnregistered', 'Manage your [[Harvester]] cluster'); const rootState = { $extension: extensionWith({}) }; const translate = getters.t(s, undefined, rootState); - expect(translate('i18nGlobal.unregistered')).toStrictEqual('Manage your Harvester cluster'); + expect(translate('i18nGlobalUnregistered')).toStrictEqual('Manage your Harvester cluster'); }); it('evaluates function-valued globals when substituting', () => { - const s = stateWithGlobalMsg('i18nGlobal.function', 'Welcome to [[Product]]'); + const s = stateWithGlobalMsg('i18nGlobalFunction', 'Welcome to [[Product]]'); const rootState = { $extension: extensionWith({ Product: () => 'Fabric' }) }; const translate = getters.t(s, undefined, rootState); - expect(translate('i18nGlobal.function')).toStrictEqual('Welcome to Fabric'); + expect(translate('i18nGlobalFunction')).toStrictEqual('Welcome to Fabric'); }); it('preserves the literal [[name]] when the token is escaped with a backslash', () => { - const s = stateWithGlobalMsg('i18nGlobal.escaped', 'Use \\[[Harvester]] to reference the global'); + const s = stateWithGlobalMsg('i18nGlobalEscaped', 'Use \\[[Harvester]] to reference the global'); const rootState = { $extension: extensionWith({ Harvester: 'Rancher Virtualization' }) }; const translate = getters.t(s, undefined, rootState); - expect(translate('i18nGlobal.escaped')).toStrictEqual('Use [[Harvester]] to reference the global'); + expect(translate('i18nGlobalEscaped')).toStrictEqual('Use [[Harvester]] to reference the global'); }); it('substitutes multiple different tokens in the same message', () => { - const s = stateWithGlobalMsg('i18nGlobal.multiple', '[[Product]] on [[Platform]]'); + const s = stateWithGlobalMsg('i18nGlobalMultiple', '[[Product]] on [[Platform]]'); const rootState = { $extension: extensionWith({ Product: 'Fabric', Platform: 'Kubernetes' }) }; const translate = getters.t(s, undefined, rootState); - expect(translate('i18nGlobal.multiple')).toStrictEqual('Fabric on Kubernetes'); + expect(translate('i18nGlobalMultiple')).toStrictEqual('Fabric on Kubernetes'); }); it('substitutes before IntlMessageFormat args are interpolated', () => { - const s = stateWithGlobalMsg('i18nGlobal.withArgs', 'Found {count} [[Harvester]] cluster(s)'); + const s = stateWithGlobalMsg('i18nGlobalWithArgs', 'Found {count} [[Harvester]] cluster(s)'); const rootState = { $extension: extensionWith({ Harvester: 'Rancher Virtualization' }) }; const translate = getters.t(s, undefined, rootState); - expect(translate('i18nGlobal.withArgs', { count: 3 })).toStrictEqual('Found 3 Rancher Virtualization cluster(s)'); + expect(translate('i18nGlobalWithArgs', { count: 3 })).toStrictEqual('Found 3 Rancher Virtualization cluster(s)'); }); it('leaves messages without [[ tokens unchanged', () => { - const s = stateWithGlobalMsg('i18nGlobal.noToken', 'Nothing to substitute here'); + const s = stateWithGlobalMsg('i18nGlobalNoToken', 'Nothing to substitute here'); const rootState = { $extension: extensionWith({ Harvester: 'Rancher Virtualization' }) }; const translate = getters.t(s, undefined, rootState); - expect(translate('i18nGlobal.noToken')).toStrictEqual('Nothing to substitute here'); + expect(translate('i18nGlobalNoToken')).toStrictEqual('Nothing to substitute here'); }); it('does not crash when rootState.$extension is unavailable', () => { - const s = stateWithGlobalMsg('i18nGlobal.noExtension', 'Manage your [[Harvester]] cluster'); + const s = stateWithGlobalMsg('i18nGlobalNoExtension', 'Manage your [[Harvester]] cluster'); const translate = getters.t(s, undefined, {}); // With no extension registry available, the token name is used as the value - expect(translate('i18nGlobal.noExtension')).toStrictEqual('Manage your Harvester cluster'); + expect(translate('i18nGlobalNoExtension')).toStrictEqual('Manage your Harvester cluster'); }); }); }); From 363d47cd21a846388cd34162015a562183bbf7fe Mon Sep 17 00:00:00 2001 From: Neil MacDougall Date: Wed, 5 Aug 2026 11:21:52 +0100 Subject: [PATCH 5/5] Switch logo depending on whether Harvester or SUSE Virtualisation --- .../harvesterhci.io.management.cluster.vue | 19 +++++++++++--- .../images/pl/dark/suse-virtualization.svg | 1 + .../assets/images/pl/suse-virtualization.svg | 1 + shell/store/i18n.js | 25 ++++++++++++++++--- 4 files changed, 40 insertions(+), 6 deletions(-) create mode 100644 shell/assets/images/pl/dark/suse-virtualization.svg create mode 100644 shell/assets/images/pl/suse-virtualization.svg diff --git a/pkg/harvester-manager/list/harvesterhci.io.management.cluster.vue b/pkg/harvester-manager/list/harvesterhci.io.management.cluster.vue index 769ce2c2faf..d4eb8ae922b 100644 --- a/pkg/harvester-manager/list/harvesterhci.io.management.cluster.vue +++ b/pkg/harvester-manager/list/harvesterhci.io.management.cluster.vue @@ -240,7 +240,20 @@ export default { // No custom rows limit; 'Table Rows Per Page' preference will be used. return null; - } + }, + + // Return the logo information based on whether is is Harvester or SUSE Virtualization. + logo() { + const isSuseVirtualization = this.$store.getters['i18n/global']('Harvester') === 'SUSE Virtualization'; + + return !isSuseVirtualization ? { + name: 'harvester.png', + height: 64 + } : { + name: 'suse-virtualization.svg', + height: 36 + } + }, }, methods: { @@ -455,8 +468,8 @@ export default {