diff --git a/docs/content/1.guide/16.hub.md b/docs/content/1.guide/16.hub.md
index 34f59658..37311201 100644
--- a/docs/content/1.guide/16.hub.md
+++ b/docs/content/1.guide/16.hub.md
@@ -46,6 +46,23 @@ ctx.commands.register({
`args` takes positional [Standard Schema](https://standardschema.dev/) schemas (a single `v.object(...)` unwraps into the input); omit for zero-arg. `safety` defaults to `'action'`; `when` clauses are unenforced for agent calls.
+## Localized titles
+
+A dock entry, a command, and a launcher each carry a plain `title` and an optional `titleLocales` map of translations by BCP 47 tag. The plain `title` stays the default, so a hub UI that ignores the map still shows it. The reference hub UI picks the translation closest to its own language setting (`zh-HK` falls back to `zh-TW`, `pt-PT` to `pt-BR`, `en-GB` to `en`):
+
+```ts
+ctx.docks.register({
+ type: 'iframe',
+ id: 'app:build',
+ title: 'Build',
+ titleLocales: { 'zh-CN': '构建', 'ja': 'ビルド' },
+ icon: 'ph:hammer-duotone',
+ url: '/__build/',
+})
+```
+
+A devframe localizes the dock a hub synthesizes for it through `dock: { titleLocales }` in its definition. A hub UI provider resolves a title with `resolveTitle(entry, locale)` from `@devframes/hub/client`.
+
## Nested commands
A command's `children` nest arbitrarily deep. The palette drills into each level, and every command in the tree is bindable at any depth: a shortcut assigned to a leaf several levels down fires as directly as one on a top-level command, and each appears as its own row under **Settings → Shortcuts**, indented by nesting level.
diff --git a/docs/content/1.guide/22.build-your-own-hub-ui.md b/docs/content/1.guide/22.build-your-own-hub-ui.md
index 2340fb89..f6523305 100644
--- a/docs/content/1.guide/22.build-your-own-hub-ui.md
+++ b/docs/content/1.guide/22.build-your-own-hub-ui.md
@@ -47,7 +47,9 @@ the [Hub API reference](/references/hub-api#dock-entry-types).
Honor `when` / `visibility`, `category` grouping (order from
`DEFAULT_CATEGORIES_ORDER`, `@devframes/hub/constants`), and the
-`hub:docks:activate` broadcast.
+`hub:docks:activate` broadcast. Show titles through
+`resolveTitle(entry, locale)` so a registrant's `titleLocales` translations
+reach the user.
An `iframe` entry serving a [remote assets package](/guide/client-assets) can
report it unreachable: its fallback page posts a `RemoteAssetsErrorMessage`
diff --git a/docs/content/8.references/6.hub-api.md b/docs/content/8.references/6.hub-api.md
index c6e71a09..8acd106f 100644
--- a/docs/content/8.references/6.hub-api.md
+++ b/docs/content/8.references/6.hub-api.md
@@ -147,7 +147,7 @@ The origin-locked `postMessage` protocol on `devframe:frame-nav`: [Shared-iframe
## Dock entry types
-The built-in variants of the open dock union (`DevframeDockEntryRegistry`, `@devframes/hub/types`) a hub UI provider renders: [Build Your Own Hub UI](/guide/build-your-own-hub-ui). Each entry's `icon` takes any of the [icon values](/references/node-api#icon-values).
+The built-in variants of the open dock union (`DevframeDockEntryRegistry`, `@devframes/hub/types`) a hub UI provider renders: [Build Your Own Hub UI](/guide/build-your-own-hub-ui). Each entry's `icon` takes any of the [icon values](/references/node-api#icon-values). Each entry's `title` has an optional `titleLocales` map of translations, resolved with `resolveTitle(entry, locale)` from `@devframes/hub/client`; commands and launchers carry the same pair.
| Type | The hub UI provider renders |
|---|---|
diff --git a/examples/custom-hub-next/src/client/app/page.tsx b/examples/custom-hub-next/src/client/app/page.tsx
index 33424891..59249c3c 100644
--- a/examples/custom-hub-next/src/client/app/page.tsx
+++ b/examples/custom-hub-next/src/client/app/page.tsx
@@ -11,7 +11,7 @@ import type {
import type { DevframeJsonRenderSpec } from '@devframes/json-render'
import type { DevframeJsonRenderDockEntry } from '@devframes/json-render/hub'
import type { FormEvent } from 'react'
-import { connectDevframe, createDevframeClientRuntime, FRAME_NAV_CHANNEL } from '@devframes/hub/client'
+import { connectDevframe, createDevframeClientRuntime, FRAME_NAV_CHANNEL, resolveTitle } from '@devframes/hub/client'
import { useEffect, useMemo, useRef, useState } from 'react'
import { createReactJsonRenderDockRenderer } from '../json-render/react-renderer'
import { dockIconSvg } from './icons'
@@ -246,7 +246,7 @@ function DockIcon({ entry }: { entry: DevframeDockEntry }) {
const svg = useDockIconSvg(entry.icon)
if (svg)
return
- const initial = (entry.title?.[0] ?? '?').toUpperCase()
+ const initial = (resolveTitle(entry, navigator.language)[0] ?? '?').toUpperCase()
return {initial}
}
@@ -537,7 +537,7 @@ export default function Page() {
let el = pool.get(key)
if (!el) {
el = document.createElement('iframe')
- el.title = selectedDock.title
+ el.title = resolveTitle(selectedDock, navigator.language)
el.className = 'absolute inset-0 block h-full w-full border-0 bg-base'
el.src = selectedDock.url
stage.appendChild(el)
@@ -652,10 +652,10 @@ export default function Page() {
void ctx?.docks.switchEntry(dock.id)
}}
className={`relative inline-flex items-center gap-1.5 max-w-52 px-2 py-1 rounded-md border border-transparent text-sm op-fade select-none cursor-pointer transition hover:op100 hover:bg-active w-full! max-w-none! gap-2.5!${dock.id === selectedDockId ? ' op100! bg-active border-base! color-base' : ''}`}
- title={dock.title}
+ title={resolveTitle(dock, navigator.language)}
>
- {dock.title}
+ {resolveTitle(dock, navigator.language)}
{dock.badge && {dock.badge} }
@@ -709,7 +709,7 @@ export default function Page() {
?
Waiting for snapshot…
: commands.map(command => (
- {command.title}
+ {resolveTitle(command, navigator.language)}
{' '}
{command.id}
diff --git a/examples/custom-hub-vite/src/client/main.ts b/examples/custom-hub-vite/src/client/main.ts
index 0ffc08d4..d8cd1545 100644
--- a/examples/custom-hub-vite/src/client/main.ts
+++ b/examples/custom-hub-vite/src/client/main.ts
@@ -7,7 +7,7 @@ import type {
} from '@devframes/hub/types'
import type { DevframeJsonRenderSpec } from '@devframes/json-render'
import type { DevframeJsonRenderDockEntry } from '@devframes/json-render/hub'
-import { connectDevframe, createDevframeClientRuntime, FRAME_NAV_CHANNEL } from '@devframes/hub/client'
+import { connectDevframe, createDevframeClientRuntime, FRAME_NAV_CHANNEL, resolveTitle } from '@devframes/hub/client'
import { dockIconSvg } from './icons'
import 'virtual:uno.css'
import '@antfu/design/styles.css'
@@ -85,15 +85,17 @@ function iconName(icon: DevframeDockEntry['icon']): string | undefined {
// real SVG once `paintDockIcons` resolves it), the title, and an optional badge.
function dockButton(entry: DevframeDockEntry, selectedId: string | null): string {
const active = entry.id === selectedId
- const initial = (entry.title?.[0] ?? '?').toUpperCase()
+ // `titleLocales` carries a registrant's translations; the browser language picks one.
+ const title = resolveTitle(entry, navigator.language)
+ const initial = (title[0] ?? '?').toUpperCase()
const badge = entry.badge
? `${entry.badge} `
: ''
return `
-
${initial}
- ${entry.title} ${badge}
+ ${title} ${badge}
`
}
@@ -379,7 +381,7 @@ function wireDockRail(host: Awaited>): Pro
// Commands - read straight from `devframe:commands` shared state.
const commands = await rpc.sharedState.get('devframe:commands', { initialValue: [] })
const renderCommands = (): void => renderList(el.commands, commands.value() ?? [], c =>
- `${c.title} ${c.id} `)
+ `${resolveTitle(c, navigator.language)} ${c.id} `)
commands.on('updated', renderCommands)
renderCommands()
diff --git a/packages/devframe/src/types/devframe.ts b/packages/devframe/src/types/devframe.ts
index a2a7fea8..f1a17371 100644
--- a/packages/devframe/src/types/devframe.ts
+++ b/packages/devframe/src/types/devframe.ts
@@ -267,6 +267,8 @@ export interface DevframeCliOptions {
export interface DevframeDockDefaults {
/** Dock entry title. Defaults to the definition's `name`. */
title?: string
+ /** Translations of `title` by BCP 47 tag; see `@devframes/hub`'s `resolveTitle()`. */
+ titleLocales?: Record
/** Dock entry icon. Defaults to the definition's `icon`. */
icon?: string | { light: string, dark: string }
/**
diff --git a/packages/hub-ui/playground/seed.ts b/packages/hub-ui/playground/seed.ts
index a93adaec..3633e13e 100644
--- a/packages/hub-ui/playground/seed.ts
+++ b/packages/hub-ui/playground/seed.ts
@@ -37,6 +37,8 @@ const playgroundGroup: DevframeViewGroup = {
type: 'group',
id: PLAYGROUND_GROUP_ID,
title: 'Playground Tools',
+ // Exercises `titleLocales`: switch the language in Settings → Appearance.
+ titleLocales: { 'zh-CN': '演练场工具', 'ja': 'プレイグラウンドツール' },
icon: 'ph:flask-duotone',
category: 'app',
// No `defaultChildId`, so clicking reveals the member popover instead of
@@ -72,6 +74,7 @@ export async function seedPlayground(ctx: DevframeHubContext): Promise {
ctx.commands.register({
id: 'playground:say-hello',
title: 'Playground · Say Hello',
+ titleLocales: { 'zh-CN': '演练场 · 打个招呼', 'ja': 'プレイグラウンド · あいさつ' },
icon: 'ph:hand-waving-duotone',
category: 'playground',
handler: () => 'Hello from the hub-ui playground!',
diff --git a/packages/hub-ui/src/client/components/views-builtin/SettingsAppearance.vue b/packages/hub-ui/src/client/components/views-builtin/SettingsAppearance.vue
index d00922cf..4fae661c 100644
--- a/packages/hub-ui/src/client/components/views-builtin/SettingsAppearance.vue
+++ b/packages/hub-ui/src/client/components/views-builtin/SettingsAppearance.vue
@@ -2,8 +2,8 @@
import type { DocksContext } from '@devframes/hub/client'
import FormSelect from '@antfu/design/components/Form/FormSelect.vue'
import { computed } from 'vue'
-import { HUB_UI_LOCALES, matchLocale } from '../../../locales'
-import { localePreference, setLocalePreference, t } from '../../i18n'
+import { HUB_UI_LOCALES } from '../../../locales'
+import { localePreference, matchLocale, setLocalePreference, t } from '../../i18n'
import { useBranding } from '../../state/branding'
import { colorSchemePreference, setColorSchemePreference } from '../../state/color-mode'
import { isDockPopupSupported, requestDockPopupOpen, useIsDockPopupOpen } from '../../state/popup'
diff --git a/packages/hub-ui/src/client/components/views/ViewLauncher.vue b/packages/hub-ui/src/client/components/views/ViewLauncher.vue
index 4684d1cf..eb349884 100644
--- a/packages/hub-ui/src/client/components/views/ViewLauncher.vue
+++ b/packages/hub-ui/src/client/components/views/ViewLauncher.vue
@@ -7,6 +7,7 @@ import { HUB_EVENTS } from '@devframes/hub/constants'
import { computed, ref, watch } from 'vue'
import { TERMINALS_DOCK_ID } from '../../constants'
import { t } from '../../i18n'
+import { localizeTitle } from '../../state/locale'
import DockIcon from '../dock/DockIcon.vue'
const props = defineProps<{
@@ -92,7 +93,7 @@ const canLaunch = computed(() => status.value === 'idle' || status.value === 'er
- {{ entry.launcher.title }}
+ {{ localizeTitle(entry.launcher).title }}
{{ entry.launcher.description }}
diff --git a/packages/hub-ui/src/client/i18n/i18n.test.ts b/packages/hub-ui/src/client/i18n/i18n.test.ts
index 835a4023..85f8f097 100644
--- a/packages/hub-ui/src/client/i18n/i18n.test.ts
+++ b/packages/hub-ui/src/client/i18n/i18n.test.ts
@@ -1,6 +1,6 @@
import { afterEach, beforeAll, describe, expect, it } from 'vitest'
-import { HUB_UI_LOCALES, matchLocale } from '../../locales'
-import { loadLocale, locale, setHostLocale, setLocalePreference, t, tAround } from './index'
+import { HUB_UI_LOCALES } from '../../locales'
+import { loadLocale, locale, matchLocale, setHostLocale, setLocalePreference, t, tAround } from './index'
import en from './locales/en.json'
const files = import.meta.glob
>('./locales/*.json', { import: 'default' })
diff --git a/packages/hub-ui/src/client/i18n/index.ts b/packages/hub-ui/src/client/i18n/index.ts
index 2ec45380..27dfd7cc 100644
--- a/packages/hub-ui/src/client/i18n/index.ts
+++ b/packages/hub-ui/src/client/i18n/index.ts
@@ -1,7 +1,8 @@
import type { HubUiLocale } from '../../locales'
+import { matchLocale as matchHubLocale } from '@devframes/hub/client'
import { usePreferredLanguages, useStorage } from '@vueuse/core'
import { computed, ref, shallowReactive, watch } from 'vue'
-import { DEFAULT_LOCALE, matchLocale } from '../../locales'
+import { DEFAULT_LOCALE, HUB_UI_LOCALES } from '../../locales'
import en from './locales/en.json'
/** Every UI string has a key in `en.json`; the other files translate them. */
@@ -49,6 +50,12 @@ export function loadLocale(code: HubUiLocale): Promise {
export type LocalePreference = 'auto' | HubUiLocale
+/** Narrow a BCP 47 tag to a shipped locale (`zh-HK` → `zh-TW`, `pt-PT` → `pt-BR`, `en-GB` → `en`). */
+export function matchLocale(tag: string | undefined): HubUiLocale | undefined {
+ // `matchHubLocale` only returns members of the list it was given.
+ return matchHubLocale(tag, Object.keys(HUB_UI_LOCALES)) as HubUiLocale | undefined
+}
+
/**
* The visitor's pick, per browser like the color scheme. `auto` follows the
* host default (`createUi({ locale })`), then the browser language.
diff --git a/packages/hub-ui/src/client/state/commands.ts b/packages/hub-ui/src/client/state/commands.ts
index 5400bc3d..a764d447 100644
--- a/packages/hub-ui/src/client/state/commands.ts
+++ b/packages/hub-ui/src/client/state/commands.ts
@@ -7,6 +7,7 @@ import { evaluateWhen } from 'devframe/utils/when'
import { computed, markRaw, reactive, ref, watch } from 'vue'
import { sharedStateToRef } from './docks'
import { collectAllKeybindings, filterCommandsByWhen, findCommandDeep, normalizeKeyEvent } from './keybindings'
+import { localizeCommand } from './locale'
import { useDockPopupWindow, useIsDockPopupOpen } from './popup'
const commandsContextByRpc = new WeakMap()
@@ -54,7 +55,7 @@ export async function createCommandsContext(
const commands = computed(() => [
...serverCommands.value,
...Array.from(clientCommands.values()),
- ])
+ ].map(command => localizeCommand(command)))
const paletteCommands = computed(() => {
const ctx = getWhenContext()
diff --git a/packages/hub-ui/src/client/state/context.ts b/packages/hub-ui/src/client/state/context.ts
index 87feea09..3f9cc002 100644
--- a/packages/hub-ui/src/client/state/context.ts
+++ b/packages/hub-ui/src/client/state/context.ts
@@ -15,6 +15,7 @@ import { useBranding } from './branding'
import { createCommandsContext } from './commands'
import { docksGroupByCategories, getGroupMembers, getRegisteredGroupIds, resolveCommandIcon, resolveGroupDefaultChild, resolveGroupPreferredChild } from './dock-settings'
import { createDockEntryState, DEFAULT_DOCK_PANEL_STORE, DEFAULT_DOCK_SESSION_STORE, sharedStateToRef, useDocksEntries, waitForInitialSharedStateSync } from './docks'
+import { localizeTitle } from './locale'
import { createClientMessagesClient } from './messages-client'
import { dockCommandId } from './palette'
import { registerMainFrameDockActionHandler, triggerMainFrameDockAction, useIsDockPopupOpen } from './popup'
@@ -77,9 +78,10 @@ export async function createDocksContext(
// hub-ui owns the built-in Settings tab so it's always reachable without a
// host registering `~settings`; a host that registered its own wins, so add
// ours only when the merged list has none.
- if (base.some(entry => entry.id === BUILTIN_ENTRY_SETTINGS.id))
- return base
- return [...base, { ...BUILTIN_ENTRY_SETTINGS, title: t('dock.settings') }]
+ const localized = base.map(localizeTitle)
+ if (localized.some(entry => entry.id === BUILTIN_ENTRY_SETTINGS.id))
+ return localized
+ return [...localized, { ...BUILTIN_ENTRY_SETTINGS, title: t('dock.settings') }]
})
// Per-tab session UI state (open/selectedId/route). A caller (the embedded and
diff --git a/packages/hub-ui/src/client/state/locale.test.ts b/packages/hub-ui/src/client/state/locale.test.ts
index 7a2a96c3..892eb1e6 100644
--- a/packages/hub-ui/src/client/state/locale.test.ts
+++ b/packages/hub-ui/src/client/state/locale.test.ts
@@ -4,7 +4,7 @@ import { getDevframeConnection } from '@devframes/hub/client'
import { afterEach, expect, it } from 'vitest'
import { nextTick } from 'vue'
import { setHostLocale, setLocalePreference } from '../i18n'
-import { setupLocale } from './locale'
+import { localizeCommand, setupLocale } from './locale'
function createRpc(connectionMeta: ConnectionMeta): DevframeRpcClient {
// Only the connection surface `setupLocale` touches.
@@ -35,3 +35,17 @@ it('publishes the fallback when the host sets no locale', () => {
expect(rpc.connectionMeta.configs?.ui?.locale).toBe('en')
})
+
+it('localizes a command tree in the current UI language', () => {
+ setLocalePreference('ja')
+ const command = localizeCommand({
+ id: 'build',
+ source: 'client',
+ title: 'Build',
+ titleLocales: { ja: 'ビルド' },
+ children: [{ id: 'build:clean', source: 'client', title: 'Clean', titleLocales: { ja: 'クリーン' } }],
+ })
+
+ expect(command.title).toBe('ビルド')
+ expect(command.children?.[0]?.title).toBe('クリーン')
+})
diff --git a/packages/hub-ui/src/client/state/locale.ts b/packages/hub-ui/src/client/state/locale.ts
index cff13dee..d64b2096 100644
--- a/packages/hub-ui/src/client/state/locale.ts
+++ b/packages/hub-ui/src/client/state/locale.ts
@@ -1,5 +1,6 @@
+import type { DevframeCommandEntry, DevframeTitled } from '@devframes/hub'
import type { DevframeRpcClient } from '@devframes/hub/client'
-import { storeConnection } from '@devframes/hub/client'
+import { resolveTitle, storeConnection } from '@devframes/hub/client'
import { watch } from 'vue'
import { locale, setHostLocale } from '../i18n'
@@ -17,3 +18,14 @@ export function setupLocale(rpc: DevframeRpcClient): void {
storeConnection(rpc.connection)
}, { immediate: true })
}
+
+/** A copy of a dock entry, command or launcher with `title` in the current UI language. */
+export function localizeTitle(titled: T): T {
+ return { ...titled, title: resolveTitle(titled, locale.value) }
+}
+
+/** `localizeTitle` for a command and its children, so palette and shortcut rows follow the language. */
+export function localizeCommand(command: T): T {
+ const children = command.children?.map(child => localizeCommand(child))
+ return { ...localizeTitle(command), ...(children ? { children } : {}) }
+}
diff --git a/packages/hub-ui/src/locales.ts b/packages/hub-ui/src/locales.ts
index 922981ab..0e8823f7 100644
--- a/packages/hub-ui/src/locales.ts
+++ b/packages/hub-ui/src/locales.ts
@@ -21,20 +21,3 @@ export const HUB_UI_LOCALES = {
export type HubUiLocale = keyof typeof HUB_UI_LOCALES
export const DEFAULT_LOCALE: HubUiLocale = 'en'
-
-/**
- * Narrow a BCP 47 tag to a shipped locale: exact match first, then the
- * closest by language (`zh-HK` → `zh-TW`, `pt-PT` → `pt-BR`, `en-GB` → `en`).
- */
-export function matchLocale(tag: string | undefined): HubUiLocale | undefined {
- if (!tag)
- return undefined
- const supported = Object.keys(HUB_UI_LOCALES) as HubUiLocale[]
- const exact = supported.find(code => code.toLowerCase() === tag.toLowerCase())
- if (exact)
- return exact
- const [language, ...rest] = tag.toLowerCase().split('-')
- if (language === 'zh' && rest.some(part => part === 'hant' || part === 'hk' || part === 'mo' || part === 'tw'))
- return 'zh-TW'
- return supported.find(code => code.toLowerCase().split('-')[0] === language)
-}
diff --git a/packages/hub/src/client/__tests__/host.test.ts b/packages/hub/src/client/__tests__/host.test.ts
index 2cd9fced..563ccb6d 100644
--- a/packages/hub/src/client/__tests__/host.test.ts
+++ b/packages/hub/src/client/__tests__/host.test.ts
@@ -242,6 +242,33 @@ describe('createDevframeClientRuntime', () => {
host.dispose()
})
+ it('keeps the latest selection when an earlier switch is still loading its page script', async () => {
+ const { rpc, states } = createStubRpc()
+ const host = await createDevframeClientRuntime({ rpc })
+ const fixture = globalThis as typeof globalThis & { __DF_SLOW_SCRIPT__?: Promise }
+ let finishScript!: () => void
+ fixture.__DF_SLOW_SCRIPT__ = new Promise((resolve) => {
+ finishScript = resolve
+ })
+ try {
+ states.get('devframe:docks')!.push([
+ iframeEntry('slow', { clientScript: { importFrom: 'data:text/javascript,export default () => globalThis.__DF_SLOW_SCRIPT__' } }),
+ iframeEntry('fast'),
+ ])
+
+ const slow = host.context.docks.switchEntry('slow')
+ expect(await host.context.docks.switchEntry('fast')).toBe(true)
+ finishScript()
+
+ expect(await slow).toBe(false)
+ expect(host.context.docks.selected?.id).toBe('fast')
+ }
+ finally {
+ host.dispose()
+ delete fixture.__DF_SLOW_SCRIPT__
+ }
+ })
+
it('switches the active dock when the hub broadcasts devframe:docks:activate', async () => {
const { rpc, states, definitions } = createStubRpc()
const host = await createDevframeClientRuntime({ rpc })
diff --git a/packages/hub/src/client/host.ts b/packages/hub/src/client/host.ts
index 872acdbd..a87f43a0 100644
--- a/packages/hub/src/client/host.ts
+++ b/packages/hub/src/client/host.ts
@@ -140,6 +140,8 @@ export async function createDevframeClientRuntime(
])
let selectedId: string | null = null
+ /** Bumped by every `switchEntry` call, so a stale one can tell it was superseded. */
+ let switchRequest = 0
const entryToStateMap = new Map()
// Docks registered live in this page via `docks.register()`. They never flow
// into the `devframe:docks` shared state (client-only), and are merged with
@@ -450,8 +452,13 @@ export async function createDevframeClientRuntime(
const entry = entryToStateMap.get(next ?? '')?.entryMeta
if (entry && loadScriptsEnabled && !rpc.isTrusted)
return false
+ // A switch that waits on a page script must not override a newer one
+ // that committed meanwhile (a click while the previous dock still loads).
+ const request = ++switchRequest
if (entry?.type === 'iframe' && entry.clientScript && loadScriptsEnabled && !await preparePageScript(entry))
return false
+ if (request !== switchRequest)
+ return false
const previous = selectedId
selectedId = next
diff --git a/packages/hub/src/client/index.ts b/packages/hub/src/client/index.ts
index 78c06ac6..1d78db0a 100644
--- a/packages/hub/src/client/index.ts
+++ b/packages/hub/src/client/index.ts
@@ -1,4 +1,5 @@
export { clientScriptFailureHint, resolveClientModuleSpecifier } from '../client-modules'
+export * from '../locale'
export * from './client-script'
export * from './context'
export * from './dock-resources'
diff --git a/packages/hub/src/index.ts b/packages/hub/src/index.ts
index fca59c2b..c49c02ce 100644
--- a/packages/hub/src/index.ts
+++ b/packages/hub/src/index.ts
@@ -1,2 +1,3 @@
export * from './define'
+export * from './locale'
export type * from './types'
diff --git a/packages/hub/src/locale.test.ts b/packages/hub/src/locale.test.ts
new file mode 100644
index 00000000..c11e5513
--- /dev/null
+++ b/packages/hub/src/locale.test.ts
@@ -0,0 +1,33 @@
+import { describe, expect, it } from 'vitest'
+import { matchLocale, resolveTitle } from './locale'
+
+describe('matchLocale', () => {
+ const available = ['en', 'zh-CN', 'zh-TW', 'pt-BR']
+
+ it.each([
+ ['en-US', 'en'],
+ ['ZH-cn', 'zh-CN'],
+ ['zh', 'zh-CN'],
+ ['zh-Hant', 'zh-TW'],
+ ['zh-HK', 'zh-TW'],
+ ['pt-PT', 'pt-BR'],
+ ['it', undefined],
+ [undefined, undefined],
+ ])('narrows %s to %s', (tag, expected) => {
+ expect(matchLocale(tag, available)).toBe(expected)
+ })
+})
+
+describe('resolveTitle', () => {
+ const entry = { title: 'Build', titleLocales: { 'zh-CN': '构建', 'ja': 'ビルド' } }
+
+ it('picks the closest translation', () => {
+ expect(resolveTitle(entry, 'zh-Hans-CN')).toBe('构建')
+ expect(resolveTitle(entry, 'ja-JP')).toBe('ビルド')
+ })
+
+ it('falls back to the default title', () => {
+ expect(resolveTitle(entry, 'fr')).toBe('Build')
+ expect(resolveTitle({ title: 'Build' }, 'ja')).toBe('Build')
+ })
+})
diff --git a/packages/hub/src/locale.ts b/packages/hub/src/locale.ts
new file mode 100644
index 00000000..95740f4a
--- /dev/null
+++ b/packages/hub/src/locale.ts
@@ -0,0 +1,32 @@
+import type { DevframeTitled } from './types/locale'
+
+/**
+ * Pick the tag in `available` closest to `tag`: exact match first, then
+ * the Traditional Chinese family (`zh-Hant`, `zh-HK`, `zh-MO` → `zh-TW`),
+ * then the first tag sharing the language (`pt-PT` → `pt-BR`, `en-GB` → `en`).
+ */
+export function matchLocale(tag: string | undefined, available: Iterable): string | undefined {
+ if (!tag)
+ return undefined
+ const tags = [...available]
+ const wanted = tag.toLowerCase()
+ const exact = tags.find(code => code.toLowerCase() === wanted)
+ if (exact)
+ return exact
+ const [language, ...rest] = wanted.split('-')
+ if (language === 'zh' && rest.some(part => part === 'hant' || part === 'hk' || part === 'mo' || part === 'tw')) {
+ const traditional = tags.find(code => code.toLowerCase() === 'zh-tw')
+ if (traditional)
+ return traditional
+ }
+ return tags.find(code => code.toLowerCase().split('-')[0] === language)
+}
+
+/** The `title` of a dock entry, command or launcher in `locale`, else its default `title`. */
+export function resolveTitle(titled: DevframeTitled, locale: string | undefined): string {
+ const locales = titled.titleLocales
+ if (!locales)
+ return titled.title
+ const matched = matchLocale(locale, Object.keys(locales))
+ return matched ? locales[matched]! : titled.title
+}
diff --git a/packages/hub/src/types/commands.ts b/packages/hub/src/types/commands.ts
index 30938fca..85cff80b 100644
--- a/packages/hub/src/types/commands.ts
+++ b/packages/hub/src/types/commands.ts
@@ -1,6 +1,7 @@
import type { StandardSchemaV1 } from '@standard-schema/spec'
import type { EventEmitter } from 'devframe/types'
import type { DevframeDockEntryIcon } from './docks'
+import type { DevframeTitleLocales } from './locale'
export interface DevframeCommandKeybinding {
/**
@@ -17,6 +18,8 @@ export interface DevframeCommandBase {
*/
id: string
title: string
+ /** Translations of `title` by BCP 47 tag; see `resolveTitle()` from `@devframes/hub`. */
+ titleLocales?: DevframeTitleLocales
description?: string
/**
* Icon for the command. Either an Iconify icon string (e.g. "ph:pencil-duotone")
diff --git a/packages/hub/src/types/docks.ts b/packages/hub/src/types/docks.ts
index 513a28ed..e023fc89 100644
--- a/packages/hub/src/types/docks.ts
+++ b/packages/hub/src/types/docks.ts
@@ -1,4 +1,5 @@
import type { ConnectionMeta, EventEmitter } from 'devframe/types'
+import type { DevframeTitleLocales } from './locale'
export interface DevframeDocksHost {
readonly views: Map
@@ -83,6 +84,8 @@ export type DevframeDockBadgeVariant = 'default' | 'info' | 'success' | 'warning
export interface DevframeDockEntryBase {
id: string
title: string
+ /** Translations of `title` by BCP 47 tag; see `resolveTitle()` from `@devframes/hub`. */
+ titleLocales?: DevframeTitleLocales
icon: DevframeDockEntryIcon
/**
* The default order of the entry in the dock.
@@ -351,6 +354,7 @@ export interface DevframeViewLauncher extends DevframeDockEntryBase {
launcher: {
icon?: DevframeDockEntryIcon
title: string
+ titleLocales?: DevframeTitleLocales
status?: DevframeViewLauncherStatus
error?: string
description?: string
diff --git a/packages/hub/src/types/index.ts b/packages/hub/src/types/index.ts
index 11e2585c..7e56343f 100644
--- a/packages/hub/src/types/index.ts
+++ b/packages/hub/src/types/index.ts
@@ -4,6 +4,7 @@ export type { CreateHubContextOptions, DevframeHubContext } from '../node/contex
export * from './commands'
export * from './docks'
+export * from './locale'
export * from './messages'
export * from './settings'
export * from './terminals'
diff --git a/packages/hub/src/types/locale.ts b/packages/hub/src/types/locale.ts
new file mode 100644
index 00000000..dde4bb0c
--- /dev/null
+++ b/packages/hub/src/types/locale.ts
@@ -0,0 +1,13 @@
+/**
+ * Translations of a `title`, keyed by BCP 47 tag (`'zh-CN'`, `'ja'`). The
+ * plain `title` stays the default and is what a reader shows when no tag
+ * matches, so a registrant adds languages without breaking a reader that
+ * ignores this field. Resolve with `resolveTitle()` from `@devframes/hub`.
+ */
+export type DevframeTitleLocales = Record
+
+/** Anything carrying a `title` with optional translations: a dock entry, a command, a launcher. */
+export interface DevframeTitled {
+ title: string
+ titleLocales?: DevframeTitleLocales
+}
diff --git a/playwright.config.ts b/playwright.config.ts
index 8c3f05f8..2e64d8e8 100644
--- a/playwright.config.ts
+++ b/playwright.config.ts
@@ -92,7 +92,12 @@ export default defineConfig({
stderr: 'pipe',
},
{
- command: 'pnpm exec next dev src/client -p 9878',
+ /**
+ * Not `pnpm exec`: pnpm 12.6 starts the child in its own process group,
+ * so Playwright's teardown kills only the shell, the orphaned Next server
+ * keeps the stdout pipe open, and the run never exits.
+ */
+ command: 'node node_modules/next/dist/bin/next dev src/client -p 9878',
cwd: 'examples/custom-hub-next',
env: { PORT: '9878', DEVFRAME_INSTANCES_DIR: nextHubRegistry },
url: 'http://localhost:9878/',
diff --git a/tests/__snapshots__/tsnapi/@devframes/hub/client.snapshot.d.ts b/tests/__snapshots__/tsnapi/@devframes/hub/client.snapshot.d.ts
index d3648d3a..3b7451f9 100644
--- a/tests/__snapshots__/tsnapi/@devframes/hub/client.snapshot.d.ts
+++ b/tests/__snapshots__/tsnapi/@devframes/hub/client.snapshot.d.ts
@@ -274,5 +274,7 @@ export * from "devframe/client";
// #region Other
export { DevframeClientRpcHost }
+export { matchLocale }
+export { resolveTitle }
export { RpcClientEvents }
// #endregion
\ No newline at end of file
diff --git a/tests/__snapshots__/tsnapi/@devframes/hub/client.snapshot.js b/tests/__snapshots__/tsnapi/@devframes/hub/client.snapshot.js
index 73a7ec35..9888bb35 100644
--- a/tests/__snapshots__/tsnapi/@devframes/hub/client.snapshot.js
+++ b/tests/__snapshots__/tsnapi/@devframes/hub/client.snapshot.js
@@ -28,6 +28,8 @@ export * from "devframe/client";
// #region Other
export { clientScriptFailureHint }
+export { matchLocale }
export { resolveClientModuleSpecifier }
+export { resolveTitle }
export { stripRemoteConnectionFromUrl }
// #endregion
\ No newline at end of file
diff --git a/tests/__snapshots__/tsnapi/@devframes/hub/index.snapshot.d.ts b/tests/__snapshots__/tsnapi/@devframes/hub/index.snapshot.d.ts
index c3e70df8..f2cfb8a3 100644
--- a/tests/__snapshots__/tsnapi/@devframes/hub/index.snapshot.d.ts
+++ b/tests/__snapshots__/tsnapi/@devframes/hub/index.snapshot.d.ts
@@ -48,6 +48,7 @@ export interface DevframeCommandAgentOptions {
export interface DevframeCommandBase {
id: string;
title: string;
+ titleLocales?: DevframeTitleLocales;
description?: string;
icon?: DevframeDockEntryIcon;
category?: string;
@@ -85,6 +86,7 @@ export interface DevframeDockActivation {
export interface DevframeDockEntryBase {
id: string;
title: string;
+ titleLocales?: DevframeTitleLocales;
icon: DevframeDockEntryIcon;
defaultOrder?: number;
category?: DevframeDockEntryCategory;
@@ -289,6 +291,10 @@ export interface DevframeTerminalsHost {
startChildProcess: (_: DevframeChildProcessExecuteOptions, _: Omit) => Promise;
startPtySession: (_: DevframePtyExecuteOptions, _: Omit) => Promise;
}
+export interface DevframeTitled {
+ title: string;
+ titleLocales?: DevframeTitleLocales;
+}
export interface DevframeViewAction extends DevframeDockEntryBase {
type: 'action';
action: ClientScriptEntry;
@@ -326,6 +332,7 @@ export interface DevframeViewLauncher extends DevframeDockEntryBase {
launcher: {
icon?: DevframeDockEntryIcon;
title: string;
+ titleLocales?: DevframeTitleLocales;
status?: DevframeViewLauncherStatus;
error?: string;
description?: string;
@@ -378,6 +385,7 @@ export type DevframeMessageEntryInput = Omit;
export type DevframeTerminalStatus = 'running' | 'stopped' | 'error';
+export type DevframeTitleLocales = Record;
export type DevframeViewLauncherStatus = 'idle' | 'loading' | 'success' | 'error';
// #endregion
@@ -389,6 +397,8 @@ export declare function defineDockEntry;
visibility?: WhenExpression;
}): T;
+export declare function matchLocale(_: string | undefined, _: Iterable): string | undefined;
+export declare function resolveTitle(_: DevframeTitled, _: string | undefined): string;
// #endregion
// #region Variables
diff --git a/tests/__snapshots__/tsnapi/@devframes/hub/index.snapshot.js b/tests/__snapshots__/tsnapi/@devframes/hub/index.snapshot.js
index 79dff312..14e4d832 100644
--- a/tests/__snapshots__/tsnapi/@devframes/hub/index.snapshot.js
+++ b/tests/__snapshots__/tsnapi/@devframes/hub/index.snapshot.js
@@ -4,6 +4,8 @@
// #region Functions
export function defineCommand(_) {}
export function defineDockEntry(_) {}
+export function matchLocale(_, _) {}
+export function resolveTitle(_, _) {}
// #endregion
// #region Variables
diff --git a/tests/__snapshots__/tsnapi/@devframes/hub/types.snapshot.d.ts b/tests/__snapshots__/tsnapi/@devframes/hub/types.snapshot.d.ts
index bb53a6dc..0ba378d1 100644
--- a/tests/__snapshots__/tsnapi/@devframes/hub/types.snapshot.d.ts
+++ b/tests/__snapshots__/tsnapi/@devframes/hub/types.snapshot.d.ts
@@ -65,6 +65,8 @@ export { DevframeTerminalSession }
export { DevframeTerminalSessionBase }
export { DevframeTerminalsHost }
export { DevframeTerminalStatus }
+export { DevframeTitled }
+export { DevframeTitleLocales }
export { DevframeViewAction }
export { DevframeViewBuiltin }
export { DevframeViewCustomRender }
diff --git a/tests/__snapshots__/tsnapi/devframe/index.snapshot.d.ts b/tests/__snapshots__/tsnapi/devframe/index.snapshot.d.ts
index f2bda763..9d0ebbbf 100644
--- a/tests/__snapshots__/tsnapi/devframe/index.snapshot.d.ts
+++ b/tests/__snapshots__/tsnapi/devframe/index.snapshot.d.ts
@@ -174,6 +174,7 @@ export interface DevframeDiagnosticsHost {
}
export interface DevframeDockDefaults {
title?: string;
+ titleLocales?: Record;
icon?: string | {
light: string;
dark: string;