Skip to content

Commit 1e74f19

Browse files
committed
feat(hub): localize dock, command and launcher titles with titleLocales
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
1 parent 5130ccc commit 1e74f19

29 files changed

Lines changed: 194 additions & 41 deletions

File tree

‎docs/content/1.guide/16.hub.md‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,23 @@ ctx.commands.register({
4646

4747
`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.
4848

49+
## Localized titles
50+
51+
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`):
52+
53+
```ts
54+
ctx.docks.register({
55+
type: 'iframe',
56+
id: 'app:build',
57+
title: 'Build',
58+
titleLocales: { 'zh-CN': '构建', 'ja': 'ビルド' },
59+
icon: 'ph:hammer-duotone',
60+
url: '/__build/',
61+
})
62+
```
63+
64+
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`.
65+
4966
## Nested commands
5067

5168
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.

‎docs/content/1.guide/22.build-your-own-hub-ui.md‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,9 @@ the [Hub API reference](/references/hub-api#dock-entry-types).
4747

4848
Honor `when` / `visibility`, `category` grouping (order from
4949
`DEFAULT_CATEGORIES_ORDER`, `@devframes/hub/constants`), and the
50-
`hub:docks:activate` broadcast.
50+
`hub:docks:activate` broadcast. Show titles through
51+
`resolveTitle(entry, locale)` so a registrant's `titleLocales` translations
52+
reach the user.
5153

5254
An `iframe` entry serving a [remote assets package](/guide/client-assets) can
5355
report it unreachable: its fallback page posts a `RemoteAssetsErrorMessage`

‎docs/content/8.references/6.hub-api.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -147,7 +147,7 @@ The origin-locked `postMessage` protocol on `devframe:frame-nav`: [Shared-iframe
147147

148148
## Dock entry types
149149

150-
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).
150+
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.
151151

152152
| Type | The hub UI provider renders |
153153
|---|---|

‎examples/custom-hub-next/src/client/app/page.tsx‎

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import type {
1111
import type { DevframeJsonRenderSpec } from '@devframes/json-render'
1212
import type { DevframeJsonRenderDockEntry } from '@devframes/json-render/hub'
1313
import type { FormEvent } from 'react'
14-
import { connectDevframe, createDevframeClientRuntime, FRAME_NAV_CHANNEL } from '@devframes/hub/client'
14+
import { connectDevframe, createDevframeClientRuntime, FRAME_NAV_CHANNEL, resolveTitle } from '@devframes/hub/client'
1515
import { useEffect, useMemo, useRef, useState } from 'react'
1616
import { createReactJsonRenderDockRenderer } from '../json-render/react-renderer'
1717
import { dockIconSvg } from './icons'
@@ -246,7 +246,7 @@ function DockIcon({ entry }: { entry: DevframeDockEntry }) {
246246
const svg = useDockIconSvg(entry.icon)
247247
if (svg)
248248
return <span className="h-5 w-5 shrink-0 text-lg" dangerouslySetInnerHTML={{ __html: svg }} />
249-
const initial = (entry.title?.[0] ?? '?').toUpperCase()
249+
const initial = (resolveTitle(entry, navigator.language)[0] ?? '?').toUpperCase()
250250
return <span className="grid h-5 w-5 shrink-0 place-items-center rounded bg-active text-[0.7rem] font-bold">{initial}</span>
251251
}
252252

@@ -537,7 +537,7 @@ export default function Page() {
537537
let el = pool.get(key)
538538
if (!el) {
539539
el = document.createElement('iframe')
540-
el.title = selectedDock.title
540+
el.title = resolveTitle(selectedDock, navigator.language)
541541
el.className = 'absolute inset-0 block h-full w-full border-0 bg-base'
542542
el.src = selectedDock.url
543543
stage.appendChild(el)
@@ -652,10 +652,10 @@ export default function Page() {
652652
void ctx?.docks.switchEntry(dock.id)
653653
}}
654654
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' : ''}`}
655-
title={dock.title}
655+
title={resolveTitle(dock, navigator.language)}
656656
>
657657
<DockIcon entry={dock} />
658-
<span className="truncate">{dock.title}</span>
658+
<span className="truncate">{resolveTitle(dock, navigator.language)}</span>
659659
{dock.badge && <span className="ml-auto shrink-0 rounded bg-active px1 py0.5 text-[0.6rem] font-mono color-base">{dock.badge}</span>}
660660
</button>
661661
</li>
@@ -709,7 +709,7 @@ export default function Page() {
709709
? <li className="rounded-lg border border-base bg-base border-dashed px2.5 py1.5 text-xs font-mono op-mute">Waiting for snapshot…</li>
710710
: commands.map(command => (
711711
<li key={command.id} className={rowClass}>
712-
{command.title}
712+
{resolveTitle(command, navigator.language)}
713713
{' '}
714714
<code className="op-fade">{command.id}</code>
715715
</li>

‎examples/custom-hub-vite/src/client/main.ts‎

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import type {
77
} from '@devframes/hub/types'
88
import type { DevframeJsonRenderSpec } from '@devframes/json-render'
99
import type { DevframeJsonRenderDockEntry } from '@devframes/json-render/hub'
10-
import { connectDevframe, createDevframeClientRuntime, FRAME_NAV_CHANNEL } from '@devframes/hub/client'
10+
import { connectDevframe, createDevframeClientRuntime, FRAME_NAV_CHANNEL, resolveTitle } from '@devframes/hub/client'
1111
import { dockIconSvg } from './icons'
1212
import 'virtual:uno.css'
1313
import '@antfu/design/styles.css'
@@ -85,15 +85,17 @@ function iconName(icon: DevframeDockEntry['icon']): string | undefined {
8585
// real SVG once `paintDockIcons` resolves it), the title, and an optional badge.
8686
function dockButton(entry: DevframeDockEntry, selectedId: string | null): string {
8787
const active = entry.id === selectedId
88-
const initial = (entry.title?.[0] ?? '?').toUpperCase()
88+
// `titleLocales` carries a registrant's translations; the browser language picks one.
89+
const title = resolveTitle(entry, navigator.language)
90+
const initial = (title[0] ?? '?').toUpperCase()
8991
const badge = entry.badge
9092
? `<span class="ml-auto shrink-0 rounded bg-active px1 py0.5 text-[0.6rem] font-mono color-base">${entry.badge}</span>`
9193
: ''
9294
return `<li>
93-
<button type="button" data-dock-id="${entry.id}" title="${entry.title}"
95+
<button type="button" data-dock-id="${entry.id}" title="${title}"
9496
class="relative flex w-full items-center gap-2.5 px-2 py-1 rounded-md border border-transparent text-sm select-none cursor-pointer transition hover:op100 hover:bg-active${active ? ' op100 bg-active border-base! color-base' : ' op-fade'}">
9597
<span class="grid h-5 w-5 shrink-0 place-items-center rounded bg-active text-[0.7rem] font-bold" data-dock-icon="${entry.id}">${initial}</span>
96-
<span class="truncate">${entry.title}</span>${badge}
98+
<span class="truncate">${title}</span>${badge}
9799
</button>
98100
</li>`
99101
}
@@ -379,7 +381,7 @@ function wireDockRail(host: Awaited<ReturnType<typeof createDevframeClientRuntim
379381
let frame = iframes.get(key)
380382
if (!frame) {
381383
frame = document.createElement('iframe')
382-
frame.title = entry.title
384+
frame.title = resolveTitle(entry, navigator.language)
383385
frame.className = 'absolute inset-0 block h-full w-full border-0 bg-base'
384386
frame.hidden = true
385387
frame.src = entry.url
@@ -508,7 +510,7 @@ async function wireDrawer(rpc: Awaited<ReturnType<typeof connectDevframe>>): Pro
508510
// Commands - read straight from `devframe:commands` shared state.
509511
const commands = await rpc.sharedState.get<DevframeCommandEntry[]>('devframe:commands', { initialValue: [] })
510512
const renderCommands = (): void => renderList(el.commands, commands.value() ?? [], c =>
511-
`<li class="rounded-lg border border-base bg-base px2.5 py1.5 text-xs font-mono">${c.title} <code class="op-fade">${c.id}</code></li>`)
513+
`<li class="rounded-lg border border-base bg-base px2.5 py1.5 text-xs font-mono">${resolveTitle(c, navigator.language)} <code class="op-fade">${c.id}</code></li>`)
512514
commands.on('updated', renderCommands)
513515
renderCommands()
514516

‎packages/devframe/src/types/devframe.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -267,6 +267,8 @@ export interface DevframeCliOptions {
267267
export interface DevframeDockDefaults {
268268
/** Dock entry title. Defaults to the definition's `name`. */
269269
title?: string
270+
/** Translations of `title` by BCP 47 tag; see `@devframes/hub`'s `resolveTitle()`. */
271+
titleLocales?: Record<string, string>
270272
/** Dock entry icon. Defaults to the definition's `icon`. */
271273
icon?: string | { light: string, dark: string }
272274
/**

‎packages/hub-ui/playground/seed.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,8 @@ const playgroundGroup: DevframeViewGroup = {
3737
type: 'group',
3838
id: PLAYGROUND_GROUP_ID,
3939
title: 'Playground Tools',
40+
// Exercises `titleLocales`: switch the language in Settings → Appearance.
41+
titleLocales: { 'zh-CN': '演练场工具', 'ja': 'プレイグラウンドツール' },
4042
icon: 'ph:flask-duotone',
4143
category: 'app',
4244
// No `defaultChildId`, so clicking reveals the member popover instead of
@@ -72,6 +74,7 @@ export async function seedPlayground(ctx: DevframeHubContext): Promise<void> {
7274
ctx.commands.register({
7375
id: 'playground:say-hello',
7476
title: 'Playground · Say Hello',
77+
titleLocales: { 'zh-CN': '演练场 · 打个招呼', 'ja': 'プレイグラウンド · あいさつ' },
7578
icon: 'ph:hand-waving-duotone',
7679
category: 'playground',
7780
handler: () => 'Hello from the hub-ui playground!',

‎packages/hub-ui/src/client/components/views/ViewLauncher.vue‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { HUB_EVENTS } from '@devframes/hub/constants'
77
import { computed, ref, watch } from 'vue'
88
import { TERMINALS_DOCK_ID } from '../../constants'
99
import { t } from '../../i18n'
10+
import { localizeTitle } from '../../state/locale'
1011
import DockIcon from '../dock/DockIcon.vue'
1112
1213
const props = defineProps<{
@@ -92,7 +93,7 @@ const canLaunch = computed(() => status.value === 'idle' || status.value === 'er
9293
<div class="flex flex-col gap-4 items-center justify-center h-full relative">
9394
<DockIcon :icon="entry.launcher.icon || entry.icon" class="w-10 h-10" />
9495
<h1 class="text-2xl font-bold">
95-
{{ entry.launcher.title }}
96+
{{ localizeTitle(entry.launcher).title }}
9697
</h1>
9798
<p>{{ entry.launcher.description }}</p>
9899

‎packages/hub-ui/src/client/i18n/i18n.test.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { afterEach, beforeAll, describe, expect, it } from 'vitest'
2-
import { HUB_UI_LOCALES, matchLocale } from '../../locales'
3-
import { loadLocale, locale, setHostLocale, setLocalePreference, t, tAround } from './index'
2+
import { HUB_UI_LOCALES } from '../../locales'
3+
import { loadLocale, locale, matchLocale, setHostLocale, setLocalePreference, t, tAround } from './index'
44
import en from './locales/en.json'
55

66
const files = import.meta.glob<Record<string, string>>('./locales/*.json', { import: 'default' })

‎packages/hub-ui/src/client/i18n/index.ts‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import type { HubUiLocale } from '../../locales'
2+
import { matchLocale as matchHubLocale } from '@devframes/hub/client'
23
import { usePreferredLanguages, useStorage } from '@vueuse/core'
34
import { computed, ref, shallowReactive, watch } from 'vue'
4-
import { DEFAULT_LOCALE, matchLocale } from '../../locales'
5+
import { DEFAULT_LOCALE, HUB_UI_LOCALES } from '../../locales'
56
import en from './locales/en.json'
67

78
/** Every UI string has a key in `en.json`; the other files translate them. */
@@ -49,6 +50,12 @@ export function loadLocale(code: HubUiLocale): Promise<void> {
4950

5051
export type LocalePreference = 'auto' | HubUiLocale
5152

53+
/** Narrow a BCP 47 tag to a shipped locale (`zh-HK` → `zh-TW`, `pt-PT` → `pt-BR`, `en-GB` → `en`). */
54+
export function matchLocale(tag: string | undefined): HubUiLocale | undefined {
55+
// `matchHubLocale` only returns members of the list it was given.
56+
return matchHubLocale(tag, Object.keys(HUB_UI_LOCALES)) as HubUiLocale | undefined
57+
}
58+
5259
/**
5360
* The visitor's pick, per browser like the color scheme. `auto` follows the
5461
* host default (`createUi({ locale })`), then the browser language.

0 commit comments

Comments
 (0)