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/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 {
diff --git a/shell/assets/images/pl/dark/suse-virtualization.svg b/shell/assets/images/pl/dark/suse-virtualization.svg
new file mode 100644
index 00000000000..3d6b2784cc6
--- /dev/null
+++ b/shell/assets/images/pl/dark/suse-virtualization.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/shell/assets/images/pl/suse-virtualization.svg b/shell/assets/images/pl/suse-virtualization.svg
new file mode 100644
index 00000000000..a1565a6f39a
--- /dev/null
+++ b/shell/assets/images/pl/suse-virtualization.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
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..a348be0572b 100644
--- a/shell/store/__tests__/i18n.test.ts
+++ b/shell/store/__tests__/i18n.test.ts
@@ -5,6 +5,7 @@ import {
getters,
mutations,
actions,
+ I18N_GLOBAL_TYPE,
} from '@shell/store/i18n';
jest.mock('@shell/assets/translations/en-us.yaml', () => ({}));
@@ -32,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';
@@ -45,7 +55,7 @@ function makeState(overrides?: Partial>): ReturnType) {
+function makeMockGetters(st: TestI18nState) {
const g: Record = {};
g.t = getters.t(st);
@@ -206,6 +216,92 @@ 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. 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' });
+
+ (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('i18nGlobalRegistered', 'Manage your [[Harvester]] cluster');
+ const rootState = { $extension: extensionWith({ Harvester: 'Rancher Virtualization' }) };
+ const translate = getters.t(s, undefined, rootState);
+
+ 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('i18nGlobalUnregistered', 'Manage your [[Harvester]] cluster');
+ const rootState = { $extension: extensionWith({}) };
+ const translate = getters.t(s, undefined, rootState);
+
+ expect(translate('i18nGlobalUnregistered')).toStrictEqual('Manage your Harvester cluster');
+ });
+
+ it('evaluates function-valued globals when substituting', () => {
+ const s = stateWithGlobalMsg('i18nGlobalFunction', 'Welcome to [[Product]]');
+ const rootState = { $extension: extensionWith({ Product: () => 'Fabric' }) };
+ const translate = getters.t(s, undefined, rootState);
+
+ expect(translate('i18nGlobalFunction')).toStrictEqual('Welcome to Fabric');
+ });
+
+ it('preserves the literal [[name]] when the token is escaped with a backslash', () => {
+ 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('i18nGlobalEscaped')).toStrictEqual('Use [[Harvester]] to reference the global');
+ });
+
+ it('substitutes multiple different tokens in the same message', () => {
+ const s = stateWithGlobalMsg('i18nGlobalMultiple', '[[Product]] on [[Platform]]');
+ const rootState = { $extension: extensionWith({ Product: 'Fabric', Platform: 'Kubernetes' }) };
+ const translate = getters.t(s, undefined, rootState);
+
+ expect(translate('i18nGlobalMultiple')).toStrictEqual('Fabric on Kubernetes');
+ });
+
+ it('substitutes before IntlMessageFormat args are interpolated', () => {
+ 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('i18nGlobalWithArgs', { count: 3 })).toStrictEqual('Found 3 Rancher Virtualization cluster(s)');
+ });
+
+ it('leaves messages without [[ tokens unchanged', () => {
+ const s = stateWithGlobalMsg('i18nGlobalNoToken', 'Nothing to substitute here');
+ const rootState = { $extension: extensionWith({ Harvester: 'Rancher Virtualization' }) };
+ const translate = getters.t(s, undefined, rootState);
+
+ expect(translate('i18nGlobalNoToken')).toStrictEqual('Nothing to substitute here');
+ });
+
+ it('does not crash when rootState.$extension is unavailable', () => {
+ 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('i18nGlobalNoExtension')).toStrictEqual('Manage your Harvester cluster');
+ });
+ });
});
describe('exists', () => {
diff --git a/shell/store/i18n.js b/shell/store/i18n.js
index bbea6f619d1..d39aea542af 100644
--- a/shell/store/i18n.js
+++ b/shell/store/i18n.js
@@ -8,6 +8,57 @@ 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;
+
+/**
+ * Look up an i18n global value registered via
+ * `extension.register('l10n-global', name, value)`. Values may be registered as
+ * a function, in which case they are invoked to produce the current value.
+ *
+ * @param {string} name
+ * @param {any} $extension
+ * @returns {string | undefined}
+ */
+export function lookupGlobal(name, $extension) {
+ const registered = $extension?.getDynamic?.(I18N_GLOBAL_TYPE, name);
+ const value = typeof registered === 'function' ? registered() : registered;
+
+ return value !== undefined && value !== null ? String(value) : undefined;
+}
+
+/**
+ * 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 value = lookupGlobal(name, $extension);
+
+ return value !== undefined ? value : name;
+ });
+}
+
// Formatters can't be serialized into state
const intlCache = {};
@@ -62,7 +113,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 +139,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;
@@ -114,6 +169,10 @@ export const getters = {
}
},
+ global: (_state, _getters, rootState) => (name) => {
+ return lookupGlobal(name, rootState?.$extension);
+ },
+
exists: (state) => (key, language) => {
const locale = language || state.selected;
const cacheKey = `${ locale }/${ key }`;
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;
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;
}