From 1e74f194a67123dc6f292f82a99c2179f33d0f33 Mon Sep 17 00:00:00 2001 From: "Anthony Fu (via agent)" Date: Thu, 1 Oct 2026 00:58:29 +0000 Subject: [PATCH 1/4] feat(hub): localize dock, command and launcher titles with titleLocales MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A registrant adds translations next to its plain title: `titleLocales: { 'zh-CN': '构建', ja: 'ビルド' }`. The plain title stays the default, so a reader that ignores the map keeps working. resolveTitle() and matchLocale() from @devframes/hub pick the closest tag; the reference hub UI resolves every entry and command against its own language setting, and the example hosts resolve against the browser language. refs #398 --- docs/content/1.guide/16.hub.md | 17 ++++++++++ .../1.guide/22.build-your-own-hub-ui.md | 4 ++- docs/content/8.references/6.hub-api.md | 2 +- .../custom-hub-next/src/client/app/page.tsx | 12 +++---- examples/custom-hub-vite/src/client/main.ts | 14 ++++---- packages/devframe/src/types/devframe.ts | 2 ++ packages/hub-ui/playground/seed.ts | 3 ++ .../client/components/views/ViewLauncher.vue | 3 +- packages/hub-ui/src/client/i18n/i18n.test.ts | 4 +-- packages/hub-ui/src/client/i18n/index.ts | 9 ++++- packages/hub-ui/src/client/state/commands.ts | 3 +- packages/hub-ui/src/client/state/context.ts | 8 +++-- .../hub-ui/src/client/state/locale.test.ts | 16 ++++++++- packages/hub-ui/src/client/state/locale.ts | 14 +++++++- packages/hub-ui/src/locales.ts | 17 ---------- packages/hub/src/client/index.ts | 1 + packages/hub/src/index.ts | 1 + packages/hub/src/locale.test.ts | 33 +++++++++++++++++++ packages/hub/src/locale.ts | 32 ++++++++++++++++++ packages/hub/src/types/commands.ts | 3 ++ packages/hub/src/types/docks.ts | 4 +++ packages/hub/src/types/index.ts | 1 + packages/hub/src/types/locale.ts | 13 ++++++++ .../@devframes/hub/client.snapshot.d.ts | 2 ++ .../tsnapi/@devframes/hub/client.snapshot.js | 2 ++ .../tsnapi/@devframes/hub/index.snapshot.d.ts | 10 ++++++ .../tsnapi/@devframes/hub/index.snapshot.js | 2 ++ .../tsnapi/@devframes/hub/types.snapshot.d.ts | 2 ++ .../tsnapi/devframe/index.snapshot.d.ts | 1 + 29 files changed, 194 insertions(+), 41 deletions(-) create mode 100644 packages/hub/src/locale.test.ts create mode 100644 packages/hub/src/locale.ts create mode 100644 packages/hub/src/types/locale.ts diff --git a/docs/content/1.guide/16.hub.md b/docs/content/1.guide/16.hub.md index 34f59658b..37311201e 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 2340fb89a..f65233056 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 c6e71a09f..8acd106ff 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 334248917..59249c3cf 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 0ffc08d44..d8cd15450 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 `
  • -
  • ` } @@ -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 a2a7fea83..f1a173713 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 a93adaece..3633e13e3 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/ViewLauncher.vue b/packages/hub-ui/src/client/components/views/ViewLauncher.vue index 4684d1cfb..eb349884a 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 835a40232..85f8f097f 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 2ec453807..27dfd7cc9 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 5400bc3db..a764d4472 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 87feea092..3f9cc002e 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 7a2a96c38..892eb1e66 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 cff13deea..d64b2096e 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 922981ab4..0e8823f7b 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/index.ts b/packages/hub/src/client/index.ts index 78c06ac6e..1d78db0ad 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 fca59c2b5..c49c02ceb 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 000000000..c11e5513d --- /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 000000000..95740f4ac --- /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 30938fca4..85cff80b9 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 513a28ed8..e023fc893 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 11e2585c8..7e56343ff 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 000000000..dde4bb0cf --- /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/tests/__snapshots__/tsnapi/@devframes/hub/client.snapshot.d.ts b/tests/__snapshots__/tsnapi/@devframes/hub/client.snapshot.d.ts index d3648d3ac..3b7451f9c 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 73a7ec354..9888bb354 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 c3e70df81..f2cfb8a36 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 79dff312e..14e4d8323 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 bb53a6dc5..0ba378d15 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 f2bda7638..9d0ebbbfd 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; From 53880fc4e1f6d54286eac07c39683e0aff21055e Mon Sep 17 00:00:00 2001 From: "Anthony Fu (via agent)" Date: Fri, 2 Oct 2026 02:22:29 +0000 Subject: [PATCH 2/4] fix(hub-ui): import matchLocale from its new home in the language setting --- .../client/components/views-builtin/SettingsAppearance.vue | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 d00922cf0..4fae661cf 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' From b14e2cbf88d3a0bd760e5b9c36a7d51f5394cda2 Mon Sep 17 00:00:00 2001 From: "Anthony Fu (via agent)" Date: Fri, 2 Oct 2026 02:22:29 +0000 Subject: [PATCH 3/4] fix(hub): keep the latest dock selection while a page script loads A dock switch that waits on a page script no longer overrides a newer switch that committed meanwhile, so clicking a dock while the previous one still loads its script keeps the click. --- .../hub/src/client/__tests__/host.test.ts | 27 +++++++++++++++++++ packages/hub/src/client/host.ts | 7 +++++ 2 files changed, 34 insertions(+) diff --git a/packages/hub/src/client/__tests__/host.test.ts b/packages/hub/src/client/__tests__/host.test.ts index 2cd9fced8..563ccb6dc 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 872acdbd8..a87f43a0f 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 From 8a9a4cf7176902e3e7010c3275452c7bdfcc31ed Mon Sep 17 00:00:00 2001 From: "Anthony Fu (via agent)" Date: Fri, 2 Oct 2026 02:22:29 +0000 Subject: [PATCH 4/4] test(e2e): start the Next hub server without pnpm exec pnpm 12.6 starts an exec child in its own process group, so Playwright's teardown killed only the shell and the orphaned Next server held the stdout pipe open: every e2e run hung until the 15-minute job limit. --- playwright.config.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/playwright.config.ts b/playwright.config.ts index 8c3f05f86..2e64d8e89 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/',