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
45 changes: 45 additions & 0 deletions design/dock-icon.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
// Framework-neutral port of @antfu/design's `DisplayIconifyRemoteIcon`:
// https://github.com/antfu/design/blob/main/packages/design/components/Display/DisplayIconifyRemoteIcon.vue
//
// Resolves a devframe dock `icon` (an Iconify `collection:icon` id, e.g.
// `ph:git-branch-duotone`) to its live, sanitized SVG markup, fetched from the
// public `api.iconify.design` CDN. Unlike a UnoCSS `preset-icons` class, this
// needs no `@iconify-json/*` collection installed and no hand-maintained
// id -> class table — any Iconify id just works, at the cost of a network
// round-trip on first render. We reuse @antfu/design's own fetcher, cache and
// sanitizer (`utils/iconify.ts`) rather than reimplementing them; only the id
// parsing and light/dark selection below are devframe-specific, mirroring the
// upstream Vue component's own `icon` prop parsing. Vue surfaces should render
// `DisplayIconifyRemoteIcon` directly instead of using this port.
import { getIconifySvg } from '@antfu/design/utils/iconify'

// Mirrors DisplayIconifyRemoteIcon.vue's own `collection:icon` parse (with an
// optional `i-` prefix tolerated so a UnoCSS-style id also works).
const ICONIFY_ID = /^(?:i-)?([\w-]+):([\w-]+)$/

/**
* Resolve a dock icon (a `collection:icon` string, or a `{ light, dark }`
* pair — the `light` variant is fetched) to its sanitized SVG markup.
*
* Returns `undefined` when the id doesn't parse or the fetch fails, so the
* caller can fall back to a text initial.
*
* @example
* await dockIconSvg('ph:git-branch-duotone') // → '<svg ...>...</svg>'
*/
export async function dockIconSvg(name: string | { light: string, dark: string } | undefined): Promise<string | undefined> {
const id = typeof name === 'string' ? name : name?.light
if (!id)
return undefined
const match = id.match(ICONIFY_ID)
if (!match)
return undefined
try {
return await getIconifySvg(match[1]!, match[2]!)
}
catch {
// A failed fetch (offline / flaky CDN) degrades to the text-initial
// fallback, not a thrown error out of a render path.
return undefined
}
}
1 change: 1 addition & 0 deletions examples/minimal-next-devframe-hub/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
"@json-render/react": "catalog:frontend",
"colorjs.io": "catalog:frontend",
"devframe": "workspace:*",
"dompurify": "catalog:frontend",
"minimal-json-render": "workspace:*",
"next": "catalog:frontend",
"react": "catalog:frontend",
Expand Down
40 changes: 5 additions & 35 deletions examples/minimal-next-devframe-hub/src/client/app/icons.ts
Original file line number Diff line number Diff line change
@@ -1,35 +1,5 @@
// @unocss-include
// Map a devframe dock `icon` (e.g. `ph:git-branch-duotone`) to a UnoCSS
// `preset-icons` class. Keeping the class strings literal lets UnoCSS
// statically extract them and inline only these glyphs from `@iconify-json/ph`
// at build time — the same icon strategy vite-devtools uses. Add a row here to
// support another dock icon.
const ICON_CLASS: Record<string, string> = {
'ph:git-branch-duotone': 'i-ph-git-branch-duotone',
'ph:terminal-window-duotone': 'i-ph-terminal-window-duotone',
'ph:code-duotone': 'i-ph-code-duotone',
'ph:stethoscope-duotone': 'i-ph-stethoscope-duotone',
'ph:wheelchair-duotone': 'i-ph-wheelchair-duotone',
'ph:rocket-duotone': 'i-ph-rocket-duotone',
'ph:wrench-duotone': 'i-ph-wrench-duotone',
'ph:plug-duotone': 'i-ph-plug-duotone',
'ph:layout-duotone': 'i-ph-layout-duotone',
'ph:note-pencil-duotone': 'i-ph-note-pencil-duotone',
'ph:squares-four-duotone': 'i-ph-squares-four-duotone',
'ph:house-duotone': 'i-ph-house-duotone',
'ph:puzzle-piece-duotone': 'i-ph-puzzle-piece-duotone',
'ph:clock-duotone': 'i-ph-clock-duotone',
'ph:gear-duotone': 'i-ph-gear-duotone',
'ph:sliders-horizontal-duotone': 'i-ph-sliders-horizontal-duotone',
}

/**
* Resolve a dock icon to its UnoCSS class, or an empty string when the icon
* isn't mapped (the caller falls back to a text initial).
*/
export function iconClass(name: string | { light: string, dark: string } | undefined): string {
if (!name)
return ''
const id = typeof name === 'string' ? name : name.light
return ICON_CLASS[id] ?? ''
}
// Re-exports the shared devframe dock-icon resolver (see
// `design/dock-icon.ts`, a port of @antfu/design's `DisplayIconifyRemoteIcon`)
// so this surface stays in lockstep with every other hub shell instead of
// hand-maintaining its own icon table.
export { dockIconSvg } from '../../../../../design/dock-icon'
31 changes: 26 additions & 5 deletions examples/minimal-next-devframe-hub/src/client/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import type { DevframeJsonRenderDockEntry } from '@devframes/json-render/hub'
import { connectDevframe, createDevframeClientHost, FRAME_NAV_CHANNEL } from '@devframes/hub/client'
import { useEffect, useMemo, useRef, useState } from 'react'
import { createReactJsonRenderDockRenderer } from '../json-render/dock-renderer'
import { iconClass } from './icons'
import { dockIconSvg } from './icons'

const HUB_BASE = '/__hub/'

Expand Down Expand Up @@ -138,11 +138,32 @@ function createClientPlaygroundSpec(clientType: string): DevframeJsonRenderSpec
}
}

/** Render a dock icon, falling back to the title's initial when unmapped. */
/** Fetches (and caches, for the component's lifetime) a dock icon's sanitized SVG. */
function useDockIconSvg(icon: DevframeDockEntry['icon']): string | undefined {
const [svg, setSvg] = useState<string | undefined>(undefined)
const key = typeof icon === 'string' ? icon : icon?.light

useEffect(() => {
let cancelled = false
setSvg(undefined)
void dockIconSvg(icon).then((resolved) => {
if (!cancelled)
setSvg(resolved)
})
return () => {
cancelled = true
}
// Re-fetch only when the icon id itself changes, not on every `icon` object identity.
}, [key])

return svg
}

/** Render a dock icon, falling back to the title's initial while it loads or when unmapped. */
function DockIcon({ entry }: { entry: DevframeDockEntry }) {
const cls = iconClass(entry.icon)
if (cls)
return <span className={`${cls} shrink-0 text-lg`} />
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()
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
6 changes: 6 additions & 0 deletions examples/minimal-next-devframe-hub/src/client/next.config.mjs
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
images: { unoptimized: true },
// @antfu/design ships raw, uncompiled `.ts`/`.vue` source (see its README —
// "no bundling, your build compiles it"). `dockIconSvg` (design/dock-icon.ts)
// imports its `utils/iconify.ts` directly, so Next/Turbopack — which
// otherwise treats node_modules as pre-built and has no loader for a bare
// `.ts` file there — needs to run this package through its own transform.
transpilePackages: ['@antfu/design'],
// The workspace typecheck owns source-level project references.
typescript: { ignoreBuildErrors: true },
// Mounted devframe SPAs are served at `/__<id>/` and reference their assets
Expand Down
1 change: 1 addition & 0 deletions examples/minimal-vite-devframe-hub/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
"@devframes/plugin-terminals": "workspace:*",
"colorjs.io": "catalog:frontend",
"devframe": "workspace:*",
"dompurify": "catalog:frontend",
"minimal-json-render": "workspace:*",
"vue": "catalog:frontend"
},
Expand Down
40 changes: 5 additions & 35 deletions examples/minimal-vite-devframe-hub/src/client/icons.ts
Original file line number Diff line number Diff line change
@@ -1,35 +1,5 @@
// @unocss-include
// Map a devframe dock `icon` (e.g. `ph:git-branch-duotone`) to a UnoCSS
// `preset-icons` class. Keeping the class strings literal lets UnoCSS
// statically extract them and inline only these glyphs from `@iconify-json/ph`
// at build time — the same icon strategy vite-devtools uses. Add a row here to
// support another dock icon.
const ICON_CLASS: Record<string, string> = {
'ph:git-branch-duotone': 'i-ph-git-branch-duotone',
'ph:terminal-window-duotone': 'i-ph-terminal-window-duotone',
'ph:code-duotone': 'i-ph-code-duotone',
'ph:stethoscope-duotone': 'i-ph-stethoscope-duotone',
'ph:wheelchair-duotone': 'i-ph-wheelchair-duotone',
'ph:rocket-duotone': 'i-ph-rocket-duotone',
'ph:wrench-duotone': 'i-ph-wrench-duotone',
'ph:plug-duotone': 'i-ph-plug-duotone',
'ph:layout-duotone': 'i-ph-layout-duotone',
'ph:note-pencil-duotone': 'i-ph-note-pencil-duotone',
'ph:squares-four-duotone': 'i-ph-squares-four-duotone',
'ph:house-duotone': 'i-ph-house-duotone',
'ph:puzzle-piece-duotone': 'i-ph-puzzle-piece-duotone',
'ph:clock-duotone': 'i-ph-clock-duotone',
'ph:gear-duotone': 'i-ph-gear-duotone',
'ph:sliders-horizontal-duotone': 'i-ph-sliders-horizontal-duotone',
}

/**
* Resolve a dock icon to its UnoCSS class, or an empty string when the icon
* isn't mapped (the caller falls back to a text initial).
*/
export function iconClass(name: string | { light: string, dark: string } | undefined): string {
if (!name)
return ''
const id = typeof name === 'string' ? name : name.light
return ICON_CLASS[id] ?? ''
}
// Re-exports the shared devframe dock-icon resolver (see
// `design/dock-icon.ts`, a port of @antfu/design's `DisplayIconifyRemoteIcon`)
// so this surface stays in lockstep with every other hub shell instead of
// hand-maintaining its own icon table.
export { dockIconSvg } from '../../../../design/dock-icon'
48 changes: 42 additions & 6 deletions examples/minimal-vite-devframe-hub/src/client/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import type { DevframeJsonRenderSpec } from '@devframes/json-render'
import type { DevframeJsonRenderDockEntry } from '@devframes/json-render/hub'
import { connectDevframe, createDevframeClientHost, FRAME_NAV_CHANNEL } from '@devframes/hub/client'
import { createJsonRenderDockRenderer } from '@devframes/json-render-ui'
import { iconClass } from './icons'
import { dockIconSvg } from './icons'
import 'virtual:uno.css'
import '@antfu/design/styles.css'

Expand Down Expand Up @@ -40,13 +40,48 @@ function renderList<T>(host: HTMLElement, items: readonly T[], render: (item: T)
host.innerHTML = items.map(render).join('')
}

/** Render a dock icon, falling back to the title's initial when unknown. */
// Session-lifetime cache of resolved dock-icon SVGs, keyed by the icon id
// (e.g. `ph:git-branch-duotone`) — `dockIconSvg` itself caches per fetch, this
// just lets a re-render (e.g. a badge update) paint an already-resolved icon
// synchronously instead of flashing the fallback initial again.
const dockIconCache = new Map<string, string | null>()

function dockIconKey(icon: DevframeDockEntry['icon']): string | undefined {
return typeof icon === 'string' ? icon : icon?.light
}

/** Render a dock icon's placeholder markup: a resolved SVG if cached, else the title's initial. */
function dockIcon(entry: DevframeDockEntry): string {
const cls = iconClass(entry.icon)
if (cls)
return `<span class="${cls} shrink-0 text-lg"></span>`
const key = entry.icon ? dockIconKey(entry.icon) : undefined
const cached = key ? dockIconCache.get(key) : undefined
if (cached)
return `<span class="h-5 w-5 shrink-0 text-lg" data-dock-icon="${entry.id}">${cached}</span>`
const initial = (entry.title?.[0] ?? '?').toUpperCase()
return `<span class="grid h-5 w-5 shrink-0 place-items-center rounded bg-active text-[0.7rem] font-bold">${initial}</span>`
return `<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>`
}

/**
* Resolve (and cache) each unresolved dock's icon SVG, then patch just that
* dock's `[data-dock-icon]` element in place — no full re-render, since the
* fetch is async and the dock list may already be showing by the time it
* settles. A dock with no icon or an unparsable/failed fetch keeps its
* fallback initial.
*/
async function hydrateDockIcons(list: readonly DevframeDockEntry[]): Promise<void> {
await Promise.all(list.map(async (entry) => {
const key = entry.icon ? dockIconKey(entry.icon) : undefined
if (!key || dockIconCache.has(key))
return
const svg = await dockIconSvg(entry.icon) ?? null
dockIconCache.set(key, svg)
if (!svg)
return
const el = docksEl.querySelector<HTMLElement>(`[data-dock-icon="${entry.id}"]`)
if (el) {
el.className = 'h-5 w-5 shrink-0 text-lg'
el.innerHTML = svg
}
}))
}

function isIframeDock(d: DevframeDockEntry): d is DevframeDockEntry & { type: 'iframe', url: string } {
Expand Down Expand Up @@ -315,6 +350,7 @@ async function main() {

renderList(docksEl, list, d =>
`<li><button type="button" data-dock-id="${d.id}" class="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!${d.id === docksCtx.selectedId ? ' op100! bg-active border-base! color-base' : ''}" title="${d.title}">${dockIcon(d)}<span class="truncate">${d.title}</span>${d.badge ? `<span class="ml-auto shrink-0 rounded bg-active px1 py0.5 text-[0.6rem] font-mono color-base">${d.badge}</span>` : ''}</button></li>`)
void hydrateDockIcons(list)

void showSelection(list)
}
Expand Down
6 changes: 6 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading