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 `
  • -
  • ` } @@ -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;