Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions docs/content/1.guide/16.hub.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 3 additions & 1 deletion docs/content/1.guide/22.build-your-own-hub-ui.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
2 changes: 1 addition & 1 deletion docs/content/8.references/6.hub-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
|---|---|
Expand Down
12 changes: 6 additions & 6 deletions examples/custom-hub-next/src/client/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -246,7 +246,7 @@ function DockIcon({ entry }: { entry: DevframeDockEntry }) {
const svg = useDockIconSvg(entry.icon)
if (svg)
return <span className="h-5 w-5 shrink-0 text-lg" dangerouslySetInnerHTML={{ __html: svg }} />
const initial = (entry.title?.[0] ?? '?').toUpperCase()
const initial = (resolveTitle(entry, navigator.language)[0] ?? '?').toUpperCase()
return <span className="grid h-5 w-5 shrink-0 place-items-center rounded bg-active text-[0.7rem] font-bold">{initial}</span>
}

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)}
>
<DockIcon entry={dock} />
<span className="truncate">{dock.title}</span>
<span className="truncate">{resolveTitle(dock, navigator.language)}</span>
{dock.badge && <span className="ml-auto shrink-0 rounded bg-active px1 py0.5 text-[0.6rem] font-mono color-base">{dock.badge}</span>}
</button>
</li>
Expand Down Expand Up @@ -709,7 +709,7 @@ export default function Page() {
? <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>
: commands.map(command => (
<li key={command.id} className={rowClass}>
{command.title}
{resolveTitle(command, navigator.language)}
{' '}
<code className="op-fade">{command.id}</code>
</li>
Expand Down
14 changes: 8 additions & 6 deletions examples/custom-hub-vite/src/client/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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
? `<span class="ml-auto shrink-0 rounded bg-active px1 py0.5 text-[0.6rem] font-mono color-base">${entry.badge}</span>`
: ''
return `<li>
<button type="button" data-dock-id="${entry.id}" title="${entry.title}"
<button type="button" data-dock-id="${entry.id}" title="${title}"
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'}">
<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>
<span class="truncate">${entry.title}</span>${badge}
<span class="truncate">${title}</span>${badge}
</button>
</li>`
}
Expand Down Expand Up @@ -379,7 +381,7 @@ function wireDockRail(host: Awaited<ReturnType<typeof createDevframeClientRuntim
let frame = iframes.get(key)
if (!frame) {
frame = document.createElement('iframe')
frame.title = entry.title
frame.title = resolveTitle(entry, navigator.language)
frame.className = 'absolute inset-0 block h-full w-full border-0 bg-base'
frame.hidden = true
frame.src = entry.url
Expand Down Expand Up @@ -508,7 +510,7 @@ async function wireDrawer(rpc: Awaited<ReturnType<typeof connectDevframe>>): Pro
// Commands - read straight from `devframe:commands` shared state.
const commands = await rpc.sharedState.get<DevframeCommandEntry[]>('devframe:commands', { initialValue: [] })
const renderCommands = (): void => renderList(el.commands, commands.value() ?? [], c =>
`<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>`)
`<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>`)
commands.on('updated', renderCommands)
renderCommands()

Expand Down
2 changes: 2 additions & 0 deletions packages/devframe/src/types/devframe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>
/** Dock entry icon. Defaults to the definition's `icon`. */
icon?: string | { light: string, dark: string }
/**
Expand Down
3 changes: 3 additions & 0 deletions packages/hub-ui/playground/seed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -72,6 +74,7 @@ export async function seedPlayground(ctx: DevframeHubContext): Promise<void> {
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!',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
3 changes: 2 additions & 1 deletion packages/hub-ui/src/client/components/views/ViewLauncher.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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<{
Expand Down Expand Up @@ -92,7 +93,7 @@ const canLaunch = computed(() => status.value === 'idle' || status.value === 'er
<div class="flex flex-col gap-4 items-center justify-center h-full relative">
<DockIcon :icon="entry.launcher.icon || entry.icon" class="w-10 h-10" />
<h1 class="text-2xl font-bold">
{{ entry.launcher.title }}
{{ localizeTitle(entry.launcher).title }}
</h1>
<p>{{ entry.launcher.description }}</p>

Expand Down
4 changes: 2 additions & 2 deletions packages/hub-ui/src/client/i18n/i18n.test.ts
Original file line number Diff line number Diff line change
@@ -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<Record<string, string>>('./locales/*.json', { import: 'default' })
Expand Down
9 changes: 8 additions & 1 deletion packages/hub-ui/src/client/i18n/index.ts
Original file line number Diff line number Diff line change
@@ -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. */
Expand Down Expand Up @@ -49,6 +50,12 @@ export function loadLocale(code: HubUiLocale): Promise<void> {

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.
Expand Down
3 changes: 2 additions & 1 deletion packages/hub-ui/src/client/state/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<DevframeRpcClient, CommandsContext>()
Expand Down Expand Up @@ -54,7 +55,7 @@ export async function createCommandsContext(
const commands = computed<DevframeCommandEntry[]>(() => [
...serverCommands.value,
...Array.from(clientCommands.values()),
])
].map(command => localizeCommand(command)))

const paletteCommands = computed<DevframeCommandEntry[]>(() => {
const ctx = getWhenContext()
Expand Down
8 changes: 5 additions & 3 deletions packages/hub-ui/src/client/state/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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
Expand Down
16 changes: 15 additions & 1 deletion packages/hub-ui/src/client/state/locale.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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('クリーン')
})
14 changes: 13 additions & 1 deletion packages/hub-ui/src/client/state/locale.ts
Original file line number Diff line number Diff line change
@@ -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'

Expand All @@ -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<T extends DevframeTitled>(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<T extends DevframeCommandEntry>(command: T): T {
const children = command.children?.map(child => localizeCommand(child))
return { ...localizeTitle(command), ...(children ? { children } : {}) }
}
17 changes: 0 additions & 17 deletions packages/hub-ui/src/locales.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
27 changes: 27 additions & 0 deletions packages/hub/src/client/__tests__/host.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> }
let finishScript!: () => void
fixture.__DF_SLOW_SCRIPT__ = new Promise<void>((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 })
Expand Down
Loading
Loading