mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-22 06:56:35 +00:00
feat(sdk): support for plugin icons
This commit is contained in:
@@ -4,9 +4,10 @@ import {
|
||||
Blocks, AlertTriangle, PackageOpen, RefreshCw, Trash2, Download, Bug, X, ShieldCheck, UploadCloud,
|
||||
ArrowUpCircle, Github, ExternalLink, ChevronDown, Check, Lock, Search, Link2, KeyRound, ShieldAlert,
|
||||
SlidersHorizontal, ArrowUpDown, CircleDot, MoreHorizontal, RotateCw, ArrowRight, Database, Users, LayoutDashboard,
|
||||
Radio, Luggage, Plane, Globe, Image, CalendarDays, Map, Bell, Cloud, Camera, Compass,
|
||||
BookOpen, Wallet, Puzzle, MapPin, ListChecks, Pencil, Tag, FileText,
|
||||
Radio, Luggage, Globe, Image, CalendarDays, Bell,
|
||||
Wallet, Puzzle, MapPin, ListChecks, Pencil, Tag, FileText,
|
||||
} from 'lucide-react'
|
||||
import PluginIcon from '../shared/PluginIcon'
|
||||
import { adminApi } from '../../api/client'
|
||||
import { usePluginStore } from '../../store/pluginStore'
|
||||
import { useTranslation } from '../../i18n'
|
||||
@@ -70,6 +71,8 @@ interface RegistryItem {
|
||||
repo: string
|
||||
homepage?: string | null
|
||||
type: string
|
||||
/** Lucide icon name from the registry entry; absent → Blocks. */
|
||||
icon?: string | null
|
||||
latest: string | null
|
||||
minTrekVersion: string | null
|
||||
/** The latest version's declared TREK range; null on a legacy registry entry. */
|
||||
@@ -171,11 +174,6 @@ const HEALTH: Record<string, string> = {
|
||||
incompatible: 'bg-warning',
|
||||
}
|
||||
|
||||
// Manifest `icon` is a lucide name; map the common ones, fall back to Blocks.
|
||||
const ICON_MAP: Record<string, React.ComponentType<{ size?: number; className?: string }>> = {
|
||||
Luggage, Plane, Globe, Image, CalendarDays, Map, Bell, Cloud, Camera, Compass, BookOpen, Wallet, Puzzle, Blocks,
|
||||
}
|
||||
|
||||
// Known permissions → human-readable i18n key; unknown ones render as raw code.
|
||||
const PERM_KEYS = [
|
||||
'db:own', 'db:read:trips', 'db:read:users', 'db:read:costs', 'db:read:packing', 'db:read:files', 'db:read:files:content',
|
||||
@@ -297,11 +295,6 @@ function installOffer(item: RegistryItem, t: T): { blocked: boolean; version?: s
|
||||
return { blocked: true, label: t('admin.plugins.incompatible'), title }
|
||||
}
|
||||
|
||||
function PluginIcon({ name, size = 20, className }: { name: string | null; size?: number; className?: string }) {
|
||||
const Icon = (name && Object.prototype.hasOwnProperty.call(ICON_MAP, name) && ICON_MAP[name]) || Blocks
|
||||
return <Icon size={size} className={className} />
|
||||
}
|
||||
|
||||
function ReviewedBadge({ t, compact }: { t: T; compact?: boolean }) {
|
||||
if (compact) return <ShieldCheck size={13} className="text-success shrink-0" aria-label={t('admin.plugins.reviewed')} />
|
||||
return (
|
||||
@@ -1340,7 +1333,7 @@ function RegistryGrid({ items, onInstall, onOpenDetail, busy, t, installedIds, f
|
||||
</span>
|
||||
)}
|
||||
<div className="absolute left-3 -bottom-4 w-11 h-11 rounded-xl bg-surface-card border border-edge grid place-items-center shadow-card z-[1]">
|
||||
<PluginIcon name={item.type === 'widget' ? 'Blocks' : null} size={22} className="text-content-secondary" />
|
||||
<PluginIcon name={item.icon} size={22} className="text-content-secondary" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="pt-6 px-3.5 pb-3.5 flex flex-col flex-1">
|
||||
|
||||
@@ -21,6 +21,7 @@ import userEvent from '@testing-library/user-event';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import { useSettingsStore } from '../../store/settingsStore';
|
||||
import { useAddonStore } from '../../store/addonStore';
|
||||
import { usePluginStore } from '../../store/pluginStore';
|
||||
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
|
||||
import { buildUser, buildSettings } from '../../../tests/helpers/factories';
|
||||
import BottomNav from './BottomNav';
|
||||
@@ -113,4 +114,21 @@ describe('BottomNav', () => {
|
||||
await user.click(screen.getByRole('button', { name: 'Add expense' }));
|
||||
expect(mockNavigate).toHaveBeenCalledWith('/trips/42?create=expense');
|
||||
});
|
||||
|
||||
it('FE-COMP-BOTTOMNAV-011: page plugin renders the icon its manifest declares', () => {
|
||||
seedStore(usePluginStore, {
|
||||
plugins: [{ id: 'trip-doctor', name: 'Trip Doctor', type: 'page', icon: 'Stethoscope' }],
|
||||
});
|
||||
const { container } = render(<BottomNav />);
|
||||
expect(screen.getByText('Trip Doctor')).toBeInTheDocument();
|
||||
expect(container.querySelector('.lucide-stethoscope')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('FE-COMP-BOTTOMNAV-012: page plugin with an unknown icon falls back to Blocks', () => {
|
||||
seedStore(usePluginStore, {
|
||||
plugins: [{ id: 'bogus', name: 'Bogus', type: 'page', icon: 'NotAnIcon' }],
|
||||
});
|
||||
const { container } = render(<BottomNav />);
|
||||
expect(container.querySelector('.lucide-blocks')).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,8 +3,9 @@ import { useAddonStore } from '../../store/addonStore'
|
||||
import { usePluginStore } from '../../store/pluginStore'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { LayoutGrid, CalendarDays, Globe, Compass, Bookmark, Plus, Blocks } from 'lucide-react'
|
||||
import { LayoutGrid, CalendarDays, Globe, Compass, Bookmark, Plus } from 'lucide-react'
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
import { resolvePluginIcon } from '../shared/pluginIcon'
|
||||
|
||||
const ADDON_NAV: Record<string, { icon: LucideIcon; labelKey: string }> = {
|
||||
vacay: { icon: CalendarDays, labelKey: 'admin.addons.catalog.vacay.name' },
|
||||
@@ -65,7 +66,7 @@ export default function BottomNav() {
|
||||
const nav = ADDON_NAV[addon.id]
|
||||
return nav ? [{ to: `/${addon.id}`, label: t(nav.labelKey), icon: nav.icon }] : []
|
||||
}),
|
||||
...pagePlugins.map(p => ({ to: `/plugins/${p.id}`, label: p.name, icon: Blocks })),
|
||||
...pagePlugins.map(p => ({ to: `/plugins/${p.id}`, label: p.name, icon: resolvePluginIcon(p.icon) })),
|
||||
]
|
||||
// Split the items so the raised "+" sits dead centre.
|
||||
const splitAt = Math.ceil(items.length / 2)
|
||||
|
||||
@@ -6,6 +6,7 @@ import { server } from '../../../tests/helpers/msw/server';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import { useSettingsStore } from '../../store/settingsStore';
|
||||
import { useAddonStore } from '../../store/addonStore';
|
||||
import { usePluginStore } from '../../store/pluginStore';
|
||||
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
|
||||
import { buildUser, buildSettings } from '../../../tests/helpers/factories';
|
||||
import Navbar from './Navbar';
|
||||
@@ -304,4 +305,21 @@ describe('Navbar', () => {
|
||||
await user.click(screen.getByText('adminuser'));
|
||||
expect(screen.getByText('Administrator')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-NAVBAR-034: page plugin renders the icon its manifest declares', () => {
|
||||
seedStore(usePluginStore, {
|
||||
plugins: [{ id: 'trip-doctor', name: 'Trip Doctor', type: 'page', icon: 'Stethoscope' }],
|
||||
});
|
||||
const { container } = render(<Navbar />);
|
||||
expect(screen.getByRole('link', { name: /trip doctor/i })).toBeInTheDocument();
|
||||
expect(container.querySelector('.lucide-stethoscope')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('FE-COMP-NAVBAR-035: page plugin with an unknown icon falls back to Blocks', () => {
|
||||
seedStore(usePluginStore, {
|
||||
plugins: [{ id: 'bogus', name: 'Bogus', type: 'page', icon: 'NotAnIcon' }],
|
||||
});
|
||||
const { container } = render(<Navbar />);
|
||||
expect(container.querySelector('.lucide-blocks')).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,9 +6,10 @@ import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { useAddonStore } from '../../store/addonStore'
|
||||
import { usePluginStore } from '../../store/pluginStore'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { Plane, LogOut, Settings, ChevronDown, Shield, ArrowLeft, Users, Moon, Sun, Monitor, CalendarDays, Briefcase, Globe, Compass, BookOpen, Bookmark, Blocks } from 'lucide-react'
|
||||
import { Plane, LogOut, Settings, ChevronDown, Shield, ArrowLeft, Users, Moon, Sun, Monitor, CalendarDays, Briefcase, Globe, Compass, BookOpen, Bookmark } from 'lucide-react'
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
import InAppNotificationBell from './InAppNotificationBell.tsx'
|
||||
import { resolvePluginIcon } from '../shared/pluginIcon'
|
||||
|
||||
const ADDON_ICONS: Record<string, LucideIcon> = { CalendarDays, Briefcase, Globe, Compass, Bookmark }
|
||||
|
||||
@@ -150,7 +151,7 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }:
|
||||
>
|
||||
{[{ id: '__trips', path: '/dashboard', label: t('nav.myTrips'), Icon: Briefcase },
|
||||
...globalAddons.map(a => ({ id: a.id, path: `/${a.id}`, label: getAddonName(a), Icon: ADDON_ICONS[a.icon] || CalendarDays })),
|
||||
...pagePlugins.map(p => ({ id: `plugin:${p.id}`, path: `/plugins/${p.id}`, label: p.name, Icon: Blocks }))
|
||||
...pagePlugins.map(p => ({ id: `plugin:${p.id}`, path: `/plugins/${p.id}`, label: p.name, Icon: resolvePluginIcon(p.icon) }))
|
||||
].map(tab => {
|
||||
const isActive = location.pathname === tab.path
|
||||
return (
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Blocks } from 'lucide-react'
|
||||
import PluginIcon from '../shared/PluginIcon'
|
||||
import PluginFrame from './PluginFrame'
|
||||
import type { ActivePlugin } from '../../store/pluginStore'
|
||||
|
||||
@@ -35,7 +35,7 @@ export default function PluginWidgets({ plugins, tripId = null }: { plugins: Act
|
||||
color: 'var(--ink-3)',
|
||||
}}
|
||||
>
|
||||
<Blocks size={14} style={{ flexShrink: 0 }} />
|
||||
<PluginIcon name={p.icon} size={14} style={{ flexShrink: 0 }} />
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{p.name}</span>
|
||||
</div>
|
||||
{/* min-height is just a pre-resize floor; trek:resize drives the real height. */}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Blocks, Save, Loader2, Link2, Unlink, CheckCircle } from 'lucide-react'
|
||||
import { Save, Loader2, Link2, Unlink, CheckCircle } from 'lucide-react'
|
||||
import PluginIcon from '../shared/PluginIcon'
|
||||
import { pluginsApi, type PluginUserSettingField, type PluginAction } from '../../api/client'
|
||||
import { usePluginStore } from '../../store/pluginStore'
|
||||
import { useToast } from '../shared/Toast'
|
||||
@@ -57,7 +58,7 @@ const SECRET_MASK = '••••••••'
|
||||
* here; the field list is trusted, validated manifest data. Secrets stay write-only
|
||||
* (masked, never echoed back). One form per active plugin that declares user fields.
|
||||
*/
|
||||
function PluginSettingsForm({ id, name }: { id: string; name: string }) {
|
||||
function PluginSettingsForm({ id, name, icon }: { id: string; name: string; icon: string | null }) {
|
||||
const { t } = useTranslation()
|
||||
const toast = useToast()
|
||||
const [fields, setFields] = useState<PluginUserSettingField[] | null>(null)
|
||||
@@ -134,7 +135,7 @@ function PluginSettingsForm({ id, name }: { id: string; name: string }) {
|
||||
return (
|
||||
<div className="rounded-xl border border-border bg-surface p-5">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Blocks className="w-4 h-4 text-content-secondary" />
|
||||
<PluginIcon name={icon} className="w-4 h-4 text-content-secondary" />
|
||||
<h3 className="text-sm font-semibold text-content">{name}</h3>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
@@ -232,7 +233,7 @@ export default function PluginSettingsTab() {
|
||||
</div>
|
||||
{plugins.length === 0
|
||||
? <p className="text-sm text-content-muted">{t('settings.plugins.empty')}</p>
|
||||
: plugins.map(p => <PluginSettingsForm key={p.id} id={p.id} name={p.name} />)}
|
||||
: plugins.map(p => <PluginSettingsForm key={p.id} id={p.id} name={p.name} icon={p.icon} />)}
|
||||
<PluginActivityPanel />
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { render } from '@testing-library/react'
|
||||
import { Blocks, Stethoscope } from 'lucide-react'
|
||||
import { resolvePluginIcon } from './pluginIcon'
|
||||
import PluginIcon from './PluginIcon'
|
||||
|
||||
describe('resolvePluginIcon', () => {
|
||||
it('resolves a lucide name outside the old 14-icon allowlist', () => {
|
||||
expect(resolvePluginIcon('Stethoscope')).toBe(Stethoscope)
|
||||
})
|
||||
|
||||
it('falls back to Blocks for a missing or unknown name', () => {
|
||||
expect(resolvePluginIcon(null)).toBe(Blocks)
|
||||
expect(resolvePluginIcon(undefined)).toBe(Blocks)
|
||||
expect(resolvePluginIcon('')).toBe(Blocks)
|
||||
expect(resolvePluginIcon('Stethscope')).toBe(Blocks)
|
||||
})
|
||||
|
||||
// A manifest must not be able to reach lucide's non-icon exports.
|
||||
it.each(['icons', 'createLucideIcon', 'default', 'module.exports'])(
|
||||
'falls back to Blocks for the non-icon export %s',
|
||||
(name) => expect(resolvePluginIcon(name)).toBe(Blocks),
|
||||
)
|
||||
|
||||
it('returns a stable reference so nav arrays do not remount the icon', () => {
|
||||
expect(resolvePluginIcon('Stethoscope')).toBe(resolvePluginIcon('Stethoscope'))
|
||||
})
|
||||
})
|
||||
|
||||
describe('PluginIcon', () => {
|
||||
it('renders the plugin-declared icon', () => {
|
||||
const { container } = render(<PluginIcon name="Stethoscope" />)
|
||||
expect(container.querySelector('.lucide-stethoscope')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('renders Blocks when the plugin declares nothing', () => {
|
||||
const { container } = render(<PluginIcon name={null} />)
|
||||
expect(container.querySelector('.lucide-blocks')).not.toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { CSSProperties } from 'react'
|
||||
import { resolvePluginIcon } from './pluginIcon'
|
||||
|
||||
/** Renders the lucide icon a plugin declares in its manifest (Blocks if unknown). */
|
||||
export default function PluginIcon({ name, size = 20, className, style }: {
|
||||
name: string | null | undefined
|
||||
size?: number
|
||||
className?: string
|
||||
style?: CSSProperties
|
||||
}) {
|
||||
const Icon = resolvePluginIcon(name)
|
||||
return <Icon size={size} className={className} style={style} />
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import * as LucideIcons from 'lucide-react'
|
||||
import { Blocks, type LucideIcon } from 'lucide-react'
|
||||
|
||||
// Lucide names are PascalCase. Restricting the lookup to that shape keeps a manifest
|
||||
// from reaching lucide's non-icon exports (`createLucideIcon`, `icons`, `default`,
|
||||
// `module.exports`), which are all lowercase-initial.
|
||||
const LUCIDE_ICON_NAME = /^[A-Z][A-Za-z0-9]*$/
|
||||
|
||||
/**
|
||||
* Resolves a plugin manifest's `icon` (a lucide icon name, e.g. "Stethoscope") to the
|
||||
* matching lucide component, falling back to Blocks for a missing or unknown name.
|
||||
*
|
||||
* Note lucide icons are forwardRef objects, not functions — never narrow this with
|
||||
* `typeof x === 'function'`. The lookup returns a module-level reference, so the
|
||||
* component identity is stable across renders and is safe to call inline while
|
||||
* building nav/tab arrays.
|
||||
*/
|
||||
export function resolvePluginIcon(name: string | null | undefined): LucideIcon {
|
||||
if (!name || !LUCIDE_ICON_NAME.test(name)) return Blocks
|
||||
const icon = (LucideIcons as unknown as Record<string, LucideIcon | undefined>)[name]
|
||||
return icon ?? Blocks
|
||||
}
|
||||
@@ -5,7 +5,8 @@ import { useCanDo } from '../../store/permissionsStore'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { getCached, fetchPhoto } from '../../services/photoService'
|
||||
import { useToast } from '../../components/shared/Toast'
|
||||
import { Map, Ticket, PackageCheck, Wallet, FolderOpen, Users, Train, Blocks } from 'lucide-react'
|
||||
import { Map, Ticket, PackageCheck, Wallet, FolderOpen, Users, Train } from 'lucide-react'
|
||||
import { resolvePluginIcon } from '../../components/shared/pluginIcon'
|
||||
import { useTranslation, translateApiError } from '../../i18n'
|
||||
import { addonsApi, accommodationsApi, authApi, tripsApi, assignmentsApi, healthApi, airtrailApi, mapsApi, placesApi } from '../../api/client'
|
||||
import { parsedItemToDraft, isTransportItem, type BookingReviewDraft } from '../../components/Planner/parsedItemToDraft'
|
||||
@@ -127,8 +128,8 @@ export function useTripPlanner() {
|
||||
// Positioned plugin tabs splice in ascending order so two positions stay stable;
|
||||
// the rest append, exactly as before this capability existed.
|
||||
const positioned = tripPagePlugins.filter(p => p.tripPage?.position != null).sort((a, b) => (a.tripPage!.position! - b.tripPage!.position!))
|
||||
for (const p of positioned) TRIP_TABS.splice(Math.min(p.tripPage!.position!, TRIP_TABS.length), 0, { id: `plugin:${p.id}`, label: p.name, icon: Blocks })
|
||||
for (const p of tripPagePlugins.filter(p => p.tripPage?.position == null)) TRIP_TABS.push({ id: `plugin:${p.id}`, label: p.name, icon: Blocks })
|
||||
for (const p of positioned) TRIP_TABS.splice(Math.min(p.tripPage!.position!, TRIP_TABS.length), 0, { id: `plugin:${p.id}`, label: p.name, icon: resolvePluginIcon(p.icon) })
|
||||
for (const p of tripPagePlugins.filter(p => p.tripPage?.position == null)) TRIP_TABS.push({ id: `plugin:${p.id}`, label: p.name, icon: resolvePluginIcon(p.icon) })
|
||||
|
||||
const [activeTab, setActiveTab] = useState<string>(() => {
|
||||
const saved = sessionStorage.getItem(`trip-tab-${tripId}`)
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
// Regenerates src/lucide-icon-names.ts from the lucide-react version installed at
|
||||
// the repo root. Run from plugin-sdk/: `node scripts/gen-lucide-icon-names.mjs`.
|
||||
//
|
||||
// The SDK ships no lucide dependency of its own (it is a dependency-light CLI), so
|
||||
// the name list is a checked-in snapshot rather than a runtime lookup — same shape
|
||||
// as KNOWN_ADDONS in src/manifest.ts.
|
||||
import { writeFileSync } from 'node:fs';
|
||||
import { createRequire } from 'node:module';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname, join } from 'node:path';
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const require = createRequire(join(here, '..', '..', 'package.json'));
|
||||
|
||||
const dynamicIconImports = require('lucide-react/dynamicIconImports.js');
|
||||
const iconsByKebabName = dynamicIconImports.default ?? dynamicIconImports;
|
||||
const { version } = require('lucide-react/package.json');
|
||||
|
||||
const toPascalCase = (kebab) =>
|
||||
kebab.split('-').map((part) => part[0].toUpperCase() + part.slice(1)).join('');
|
||||
|
||||
const names = Object.keys(iconsByKebabName).map(toPascalCase).sort();
|
||||
|
||||
const source = `// GENERATED — do not edit by hand.
|
||||
// Snapshot of every lucide icon name (lucide-react ${version}), used by
|
||||
// \`trek-plugin validate\` to WARN on a manifest \`icon\` lucide doesn't know —
|
||||
// never a hard error (the host falls back to Blocks, and a plugin built for a
|
||||
// newer TREK may name an icon this SDK predates). Regenerate with
|
||||
// \`node scripts/gen-lucide-icon-names.mjs\`.
|
||||
export const LUCIDE_ICON_NAMES: ReadonlySet<string> = new Set([
|
||||
${names.map((n) => ' ' + JSON.stringify(n) + ',').join('\n')}
|
||||
]);
|
||||
`;
|
||||
|
||||
writeFileSync(join(here, '..', 'src', 'lucide-icon-names.ts'), source);
|
||||
console.log(`wrote ${names.length} icon names from lucide-react ${version}`);
|
||||
@@ -36,7 +36,7 @@ interface Version {
|
||||
}
|
||||
interface Entry {
|
||||
id: string; name: string; author: string; description: string; repo: string;
|
||||
homepage?: string; tags?: string[]; type: string; authorPublicKey?: string; versions: Version[];
|
||||
homepage?: string; tags?: string[]; type: string; icon?: string; authorPublicKey?: string; versions: Version[];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -114,7 +114,11 @@ export function buildEntry(opts: {
|
||||
throw new Error('this plugin was published signed — sign the update too (pass --sign) or TREK will refuse it.');
|
||||
}
|
||||
const versions = [version, ...existing.versions.filter((v) => v.version !== version.version)];
|
||||
return { ...existing, authorPublicKey: authorPublicKey ?? existing.authorPublicKey, versions };
|
||||
// Refresh the icon from the manifest when this release declares one (an author who adds
|
||||
// or changes it should see it in the store), but never wipe an icon the entry already
|
||||
// carries just because the manifest omits it.
|
||||
const icon = typeof manifest.icon === 'string' && manifest.icon ? manifest.icon : existing.icon;
|
||||
return { ...existing, icon, authorPublicKey: authorPublicKey ?? existing.authorPublicKey, versions };
|
||||
}
|
||||
|
||||
const entry: Entry = {
|
||||
@@ -126,6 +130,7 @@ export function buildEntry(opts: {
|
||||
type: String(manifest.type),
|
||||
versions: [version],
|
||||
};
|
||||
if (typeof manifest.icon === 'string' && manifest.icon) entry.icon = manifest.icon;
|
||||
if (typeof manifest.homepage === 'string') entry.homepage = manifest.homepage;
|
||||
if (Array.isArray(manifest.tags)) entry.tags = manifest.tags.map(String).slice(0, 8);
|
||||
if (authorPublicKey) entry.authorPublicKey = authorPublicKey;
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { validateManifest, isUnboundedRange, KNOWN_ADDONS } from '../manifest.js';
|
||||
import { LUCIDE_ICON_NAMES } from '../lucide-icon-names.js';
|
||||
import { readJsonFile } from './json.js';
|
||||
|
||||
export interface ValidateReport {
|
||||
@@ -45,6 +46,14 @@ export function validatePluginDir(dir: string): ValidateReport {
|
||||
for (const a of res.manifest?.requiredAddons ?? []) {
|
||||
if (!KNOWN_ADDONS.includes(a)) warnings.push(`requiredAddons: "${a}" is not a known TREK addon on this SDK version`);
|
||||
}
|
||||
// Same deal for `icon`: TREK resolves it against lucide at render time and falls
|
||||
// back to Blocks on a name it can't find, so a typo is silent in the UI. Warn
|
||||
// rather than error — the list is a snapshot, and a newer lucide may have names
|
||||
// this SDK predates.
|
||||
const icon = typeof raw.icon === 'string' ? raw.icon : '';
|
||||
if (icon && !LUCIDE_ICON_NAMES.has(icon)) {
|
||||
warnings.push(`icon: "${icon}" is not a known lucide icon name on this SDK version — TREK will fall back to Blocks`);
|
||||
}
|
||||
// TREK renders its UI with lucide icons only. It STRIPS emojis from the declarative
|
||||
// text your hooks return (badges, columns, warnings, PDF sections, map/calendar/photo
|
||||
// labels) and from notifications, so emojis in that text simply vanish at render.
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -409,6 +409,32 @@ describe('scaffold + validate CLIs', () => {
|
||||
expect(r.warnings.some((w) => /placeholder|screenshot/.test(w))).toBe(true); // README is the unfilled template
|
||||
});
|
||||
|
||||
// TREK resolves `icon` against lucide at render time and falls back to Blocks on a
|
||||
// name it can't find, so a typo is invisible in the UI — validate has to surface it.
|
||||
it('warns on an icon lucide does not know, without failing validation', () => {
|
||||
scaffold('icon-plug', 'widget', tmp);
|
||||
const dir = path.join(tmp, 'icon-plug');
|
||||
const file = path.join(dir, 'trek-plugin.json');
|
||||
const m = JSON.parse(fs.readFileSync(file, 'utf8'));
|
||||
fs.writeFileSync(file, JSON.stringify({ ...m, icon: 'Stethscope' }, null, 2));
|
||||
|
||||
const r = validatePluginDir(dir);
|
||||
expect(r.ok).toBe(true); // a bad icon is never a hard error
|
||||
expect(r.warnings.some((w) => /icon: "Stethscope" is not a known lucide icon name/.test(w))).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts any real lucide icon name, not just a curated few', () => {
|
||||
scaffold('icon-plug', 'widget', tmp);
|
||||
const dir = path.join(tmp, 'icon-plug');
|
||||
const file = path.join(dir, 'trek-plugin.json');
|
||||
const m = JSON.parse(fs.readFileSync(file, 'utf8'));
|
||||
fs.writeFileSync(file, JSON.stringify({ ...m, icon: 'Stethoscope' }, null, 2));
|
||||
|
||||
const r = validatePluginDir(dir);
|
||||
expect(r.ok).toBe(true);
|
||||
expect(r.warnings.some((w) => /lucide icon name/.test(w))).toBe(false);
|
||||
});
|
||||
|
||||
it('scaffolds a client that opts into the design kit via the marker (source stays one line)', () => {
|
||||
scaffold('kit-plug', 'widget', tmp);
|
||||
const html = fs.readFileSync(path.join(tmp, 'kit-plug', 'client', 'index.html'), 'utf8');
|
||||
@@ -618,6 +644,49 @@ describe('pack + entry (publishing automation)', () => {
|
||||
expect(v.nativeModules).toBe(false);
|
||||
});
|
||||
|
||||
// The store tile reads `icon` off the registry ENTRY (the index has no manifest to consult),
|
||||
// so if the entry generator drops it, every plugin in the store renders as a generic Blocks.
|
||||
it('carries the manifest icon onto the registry entry', () => {
|
||||
const out = path.join(tmp, 'plugin.zip');
|
||||
packPluginDir(koffi, out);
|
||||
const entry = buildEntry({
|
||||
dir: koffi, repo: 'mauriceboe/trek-plugin-koffi', tag: 'v1.0.0', zipPath: out,
|
||||
commit: 'a'.repeat(40), now: '2026-07-04T00:00:00.000Z',
|
||||
});
|
||||
expect(entry.icon).toBe('Luggage'); // koffi's trek-plugin.json declares it
|
||||
});
|
||||
|
||||
it('--merge refreshes the icon from the manifest, but never wipes one the entry already carries', () => {
|
||||
const out = path.join(tmp, 'plugin.zip');
|
||||
packPluginDir(koffi, out);
|
||||
const prior = (icon: string) => {
|
||||
const p = path.join(tmp, `prior-${icon}.json`);
|
||||
fs.writeFileSync(p, JSON.stringify({
|
||||
id: 'koffi', name: 'Koffi', author: 'TREK', description: 'x',
|
||||
repo: 'mauriceboe/trek-plugin-koffi', type: 'widget', icon,
|
||||
versions: [{ version: '0.9.0', gitTag: 'v0.9.0', commitSha: 'b'.repeat(40), downloadUrl: 'https://x/y.zip', sha256: 'c'.repeat(64), size: 10, apiVersion: 1, nativeModules: false, publishedAt: '2026-01-01T00:00:00.000Z' }],
|
||||
}));
|
||||
return p;
|
||||
};
|
||||
|
||||
// koffi's manifest declares Luggage → an author who rebrands sees it in the store
|
||||
const rebranded = buildEntry({
|
||||
dir: koffi, repo: 'mauriceboe/trek-plugin-koffi', tag: 'v1.0.0', zipPath: out,
|
||||
commit: 'a'.repeat(40), now: '2026-07-04T00:00:00.000Z', mergePath: prior('Coffee'),
|
||||
});
|
||||
expect(rebranded.icon).toBe('Luggage');
|
||||
|
||||
// a manifest that declares NO icon leaves the entry's existing one alone
|
||||
const noIconOpts = entryOptsFor('no-icon', '>=3.2.0 <4.0.0');
|
||||
const keepPath = path.join(tmp, 'keep.json');
|
||||
fs.writeFileSync(keepPath, JSON.stringify({
|
||||
id: 'no-icon', name: 'No Icon', author: 'TREK', description: 'x', repo: 'a/b',
|
||||
type: 'integration', icon: 'Coffee', versions: [],
|
||||
}));
|
||||
const kept = buildEntry({ ...noIconOpts, now: '2026-07-04T00:00:00.000Z', mergePath: keepPath });
|
||||
expect(kept.icon).toBe('Coffee');
|
||||
});
|
||||
|
||||
it('publishes an upper-bound-only range verbatim, with no floor to get wrong', () => {
|
||||
// This is why the derived floor is gone. "<4.0.0" has a first X.Y.Z of 4.0.0 but a lower
|
||||
// bound of 0.0.0, and the old regex published 4.0.0 as the MINIMUM — advertising a plugin
|
||||
|
||||
@@ -68,6 +68,8 @@ export interface RegistryEntry {
|
||||
homepage?: string;
|
||||
tags?: string[];
|
||||
type: string;
|
||||
/** A lucide icon name for the store tile; absent means the client's default (Blocks). */
|
||||
icon?: string;
|
||||
reviewedAt?: string | null;
|
||||
/** base64 minisign author public key — stable across versions, TOFU-pinned. */
|
||||
authorPublicKey?: string;
|
||||
@@ -176,7 +178,7 @@ export class PluginRegistryService {
|
||||
const latest = p.versions[0] ?? null;
|
||||
return {
|
||||
id: p.id, name: p.name, author: p.author, description: p.description, repo: p.repo,
|
||||
homepage: p.homepage, tags: p.tags, type: p.type, reviewedAt: p.reviewedAt ?? null,
|
||||
homepage: p.homepage, tags: p.tags, type: p.type, icon: p.icon, reviewedAt: p.reviewedAt ?? null,
|
||||
downloadCount: p.downloadCount ?? null,
|
||||
latest: latest?.version ?? null, minTrekVersion: latest?.minTrekVersion ?? null,
|
||||
requiredAddons: latest?.requiredAddons ?? [], pluginDependencies: latest?.pluginDependencies ?? [],
|
||||
|
||||
@@ -68,7 +68,7 @@ const REGISTRY = {
|
||||
plugins: [
|
||||
{
|
||||
id: 'flight-tracker', name: 'Flight', author: 'Acme', description: 'flights', repo: 'acme/trek-flight',
|
||||
type: 'widget', reviewedAt: '2026-06-20',
|
||||
type: 'widget', icon: 'Plane', reviewedAt: '2026-06-20',
|
||||
versions: [{ version: '1.0.0', gitTag: 'v1.0.0', commitSha: 'a'.repeat(40), downloadUrl: 'https://codeload.github.com/acme/trek-flight/tar.gz/aaaa', sha256: '', minTrekVersion: '3.2.0' }],
|
||||
},
|
||||
],
|
||||
@@ -124,6 +124,13 @@ describe('PluginRegistryService', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
// The store tile has no manifest to read (the aggregated index carries no manifests), so
|
||||
// the entry's icon is the only thing standing between it and a generic Blocks glyph.
|
||||
it('browse carries the entry icon through to the store tile', async () => {
|
||||
const [entry] = await svc.browse();
|
||||
expect(entry.icon).toBe('Plane');
|
||||
});
|
||||
|
||||
it('browse exposes a screenshot url pinned at the reviewed commit', async () => {
|
||||
const list = await svc.browse();
|
||||
expect(list[0].screenshotUrl).toBe(`https://raw.githubusercontent.com/acme/trek-flight/${'a'.repeat(40)}/docs/screenshot.png`);
|
||||
|
||||
Reference in New Issue
Block a user