mirror of
https://github.com/NanmiCoder/claude-code-haha.git
synced 2026-10-10 11:53:10 +08:00
fix(desktop): render skill frontmatter as metadata, not markdown
SKILL.md opens with a YAML frontmatter block. Passing it straight to the markdown renderer made the closing `---` a setext heading marker, so the whole block rendered as one giant bold <h2> above the document. Split the block out before rendering and lay it out as structured rows. Where it goes differs by context: - The detail sidebar, next to the market metadata. `slug`, `license` and `allowed-tools` are the same kind of data as `author` and `category`, just from a different source, and it is reference material -- it should never precede the document that explains what the skill does. Short scalars mirror the meta rows above them; arrays and long text stack, because 300px cannot do both on one line. - The file preview, as the file's header block. There the frontmatter genuinely is the top of the file being read, so removing it would misrepresent the file; it is styled as a header rather than a card so it does not compete with the body. This also covers SkillHub, whose `description` is the raw SKILL.md -- so the overview tab hit the same wall of bold text -- and puts ClawHub's already-parsed `descriptionFrontmatter` to use for the first time. Local installed skills previously flattened frontmatter into sidebar rows as comma-joined strings; they now share the same panel. The parser is a deliberately small YAML subset (scalars, inline and block sequences, block scalars) rather than a new dependency, since skill frontmatter is flat by convention. It never throws: a malformed or unterminated block returns the document untouched, and nested mappings are kept as raw YAML so nothing is silently dropped. Separately, the files tab was capped at a hard-coded 520px, leaving the bottom of the page empty. `min-h-full` on the flex column was not enough: a container sized by min-height keeps `height: auto`, so `flex-1`'s `flex-basis: 0%` cannot resolve and the panel falls back to content height -- measured 1215px of content inside a 720px viewport. A definite `lg:h-full` makes the panel claim the leftover space. Below lg the page scrolls as before. Walked through in a browser at three widths in both themes. check:desktop is green: 227 files, 2562 passing.
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { act, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { act, fireEvent, render, screen, within } from '@testing-library/react'
|
||||
import '@testing-library/jest-dom'
|
||||
|
||||
import { Settings } from '../pages/Settings'
|
||||
@@ -253,10 +253,15 @@ describe('Settings > Skills tab', () => {
|
||||
|
||||
expect(screen.getByText('Alpha Skill')).toBeInTheDocument()
|
||||
expect(screen.getByText('First skill description')).toBeInTheDocument()
|
||||
expect(screen.getByText('Read, Edit')).toBeInTheDocument()
|
||||
expect(screen.getByText('sonnet')).toBeInTheDocument()
|
||||
expect(screen.getByText('Hello')).toBeInTheDocument()
|
||||
expect(screen.queryByText(/^---$/)).not.toBeInTheDocument()
|
||||
|
||||
// Frontmatter renders as a structured panel: list fields become individual
|
||||
// chips rather than one comma-joined string squeezed into the sidebar.
|
||||
const frontmatter = screen.getByTestId('skill-frontmatter-panel')
|
||||
expect(within(frontmatter).getByText('Read')).toBeInTheDocument()
|
||||
expect(within(frontmatter).getByText('Edit')).toBeInTheDocument()
|
||||
expect(within(frontmatter).getByText('sonnet')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('returns to plugins tab when skill detail was opened from plugins', async () => {
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { CodeViewer } from '../chat/CodeViewer'
|
||||
import { MarkdownRenderer } from '../markdown/MarkdownRenderer'
|
||||
import { splitFrontmatter } from '../../lib/skillFrontmatter'
|
||||
import { FrontmatterPanel } from './FrontmatterPanel'
|
||||
|
||||
export type PreviewFile = {
|
||||
path: string
|
||||
@@ -41,6 +43,21 @@ type LoadState =
|
||||
| { kind: 'error'; message: string }
|
||||
| { kind: 'loaded'; file: PreviewFileContent }
|
||||
|
||||
/**
|
||||
* Markdown body plus its structured frontmatter. SKILL.md leads with a YAML
|
||||
* block that markdown would otherwise turn into a giant setext heading.
|
||||
*/
|
||||
function MarkdownFilePreview({ content }: { content: string }) {
|
||||
const { frontmatter, body } = useMemo(() => splitFrontmatter(content), [content])
|
||||
|
||||
return (
|
||||
<>
|
||||
{frontmatter && <FrontmatterPanel frontmatter={frontmatter} className="mb-5" />}
|
||||
<MarkdownRenderer content={body} variant="document" />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Two-pane file preview: file list on the left, rendered content on the
|
||||
* right. Content is fetched lazily via `loadFile` and cached per path for
|
||||
@@ -93,7 +110,7 @@ export function FilePreview({
|
||||
|
||||
if (files.length === 0) {
|
||||
return (
|
||||
<div className="rounded-2xl border border-dashed border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-6 py-12 text-center">
|
||||
<div className="flex min-h-0 flex-1 flex-col items-center justify-center rounded-2xl border border-dashed border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-6 py-12 text-center">
|
||||
<span className="material-symbols-outlined mb-2 block text-[32px] text-[var(--color-text-tertiary)]">folder_off</span>
|
||||
<p className="text-sm text-[var(--color-text-tertiary)]">{t('market.file.noFiles')}</p>
|
||||
</div>
|
||||
@@ -103,8 +120,11 @@ export function FilePreview({
|
||||
const activeFile = files.find((f) => f.path === activePath)
|
||||
|
||||
return (
|
||||
<div className="grid min-w-0 gap-4 lg:grid-cols-[240px_minmax(0,1fr)]" data-testid="market-file-preview">
|
||||
<div className="flex max-h-[520px] flex-col gap-0.5 overflow-y-auto rounded-xl border border-[var(--color-border)] bg-[var(--color-surface)] p-1.5">
|
||||
<div
|
||||
className="grid min-h-0 min-w-0 flex-1 gap-4 lg:h-full lg:grid-cols-[minmax(200px,240px)_minmax(0,1fr)]"
|
||||
data-testid="market-file-preview"
|
||||
>
|
||||
<div className="flex max-h-[40vh] min-h-0 flex-col gap-0.5 overflow-y-auto rounded-xl border border-[var(--color-border)] bg-[var(--color-surface)] p-1.5 lg:max-h-none">
|
||||
{files.map((file) => {
|
||||
const active = file.path === activePath
|
||||
return (
|
||||
@@ -133,9 +153,9 @@ export function FilePreview({
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 overflow-hidden rounded-xl border border-[var(--color-border)] bg-[var(--color-surface)]">
|
||||
<div className="flex min-h-0 min-w-0 flex-col overflow-hidden rounded-xl border border-[var(--color-border)] bg-[var(--color-surface)]">
|
||||
{activeFile && (
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 border-b border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-4 py-2.5 text-[11px] text-[var(--color-text-tertiary)]">
|
||||
<div className="flex flex-shrink-0 flex-wrap items-center gap-x-3 gap-y-1 border-b border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-4 py-2.5 text-[11px] text-[var(--color-text-tertiary)]">
|
||||
<span className="font-mono font-medium text-[var(--color-text-secondary)]">{activeFile.path}</span>
|
||||
<span>{activeFile.language}</span>
|
||||
<span>{formatSize(activeFile.size)}</span>
|
||||
@@ -148,7 +168,7 @@ export function FilePreview({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="max-h-[480px] overflow-y-auto p-4">
|
||||
<div className="min-h-0 flex-1 overflow-y-auto p-4 max-lg:max-h-[70vh]">
|
||||
{state.kind === 'loading' && (
|
||||
<div className="flex justify-center py-10" data-testid="market-file-loading">
|
||||
<div className="h-5 w-5 animate-spin rounded-full border-2 border-[var(--color-brand)] border-t-transparent" />
|
||||
@@ -174,7 +194,7 @@ export function FilePreview({
|
||||
)}
|
||||
{state.kind === 'loaded' &&
|
||||
(state.file.language === 'markdown' ? (
|
||||
<MarkdownRenderer content={state.file.content} variant="document" />
|
||||
<MarkdownFilePreview content={state.file.content} />
|
||||
) : (
|
||||
<CodeViewer
|
||||
code={state.file.content}
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import { render, screen, fireEvent, within } from '@testing-library/react'
|
||||
import '@testing-library/jest-dom'
|
||||
|
||||
import { FrontmatterPanel } from './FrontmatterPanel'
|
||||
import { useSettingsStore } from '../../stores/settingsStore'
|
||||
|
||||
beforeEach(() => {
|
||||
useSettingsStore.setState({ locale: 'en' })
|
||||
})
|
||||
|
||||
describe('FrontmatterPanel', () => {
|
||||
it('renders each key next to its value', () => {
|
||||
render(<FrontmatterPanel frontmatter={{ model: 'opus', 'allowed-tools': 'Read, Write' }} />)
|
||||
|
||||
const row = screen.getByTestId('skill-frontmatter-row-model')
|
||||
expect(within(row).getByText('model')).toBeInTheDocument()
|
||||
expect(within(row).getByText('opus')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('skill-frontmatter-row-allowed-tools')).toHaveTextContent('Read, Write')
|
||||
})
|
||||
|
||||
it('renders sequences as individual chips rather than one joined string', () => {
|
||||
render(<FrontmatterPanel frontmatter={{ xiaping_tags: ['AI工具', '技能发现', 'WorkBuddy'] }} />)
|
||||
|
||||
const row = screen.getByTestId('skill-frontmatter-row-xiaping_tags')
|
||||
expect(within(row).getByText('AI工具')).toBeInTheDocument()
|
||||
expect(within(row).getByText('技能发现')).toBeInTheDocument()
|
||||
expect(within(row).getByText('WorkBuddy')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders booleans as a chip', () => {
|
||||
render(<FrontmatterPanel frontmatter={{ agent_created: true }} />)
|
||||
|
||||
expect(screen.getByTestId('skill-frontmatter-row-agent_created')).toHaveTextContent('true')
|
||||
})
|
||||
|
||||
it('renders multi-line values in a scrollable pre block', () => {
|
||||
const { container } = render(<FrontmatterPanel frontmatter={{ hooks: 'PreToolUse:\n - matcher: Bash' }} />)
|
||||
|
||||
const pre = container.querySelector('pre')
|
||||
expect(pre).toBeInTheDocument()
|
||||
expect(pre).toHaveTextContent('PreToolUse:')
|
||||
})
|
||||
|
||||
it('omits keys already shown in the detail header', () => {
|
||||
render(<FrontmatterPanel frontmatter={{ name: 'find-skills', version: '1.7.0', model: 'opus' }} />)
|
||||
|
||||
expect(screen.queryByTestId('skill-frontmatter-row-name')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('skill-frontmatter-row-version')).not.toBeInTheDocument()
|
||||
expect(screen.getByTestId('skill-frontmatter-row-model')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders nothing when every field is filtered out', () => {
|
||||
render(<FrontmatterPanel frontmatter={{ name: 'find-skills', description: 'x' }} />)
|
||||
|
||||
expect(screen.queryByTestId('skill-frontmatter-panel')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders nothing for absent frontmatter', () => {
|
||||
render(<FrontmatterPanel frontmatter={null} />)
|
||||
|
||||
expect(screen.queryByTestId('skill-frontmatter-panel')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('offers no toggle for a short block', () => {
|
||||
render(<FrontmatterPanel frontmatter={{ model: 'opus', effort: 'high' }} />)
|
||||
|
||||
expect(screen.queryByTestId('skill-frontmatter-toggle')).not.toBeInTheDocument()
|
||||
expect(screen.getByTestId('skill-frontmatter-row-model')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('stacks arrays but keeps short scalars opposite their label in the sidebar', () => {
|
||||
render(<FrontmatterPanel variant="sidebar" frontmatter={{ model: 'opus', tags: ['a', 'b'] }} />)
|
||||
|
||||
// Short scalars mirror the market meta rows above them (label left, value
|
||||
// right); arrays need the full width, so they drop to their own line.
|
||||
expect(screen.getByTestId('skill-frontmatter-row-model').className).toContain('justify-between')
|
||||
expect(screen.getByTestId('skill-frontmatter-row-tags').className).toContain('flex-col')
|
||||
})
|
||||
|
||||
it('renders the same fields in either variant', () => {
|
||||
const frontmatter = { model: 'opus', tags: ['a', 'b'], license: 'MIT' }
|
||||
const grid = render(<FrontmatterPanel variant="grid" frontmatter={frontmatter} />)
|
||||
const gridKeys = [...grid.container.querySelectorAll('dt')].map((n) => n.textContent)
|
||||
grid.unmount()
|
||||
|
||||
const sidebar = render(<FrontmatterPanel variant="sidebar" frontmatter={frontmatter} />)
|
||||
const sidebarKeys = [...sidebar.container.querySelectorAll('dt')].map((n) => n.textContent)
|
||||
|
||||
expect(sidebarKeys).toEqual(gridKeys)
|
||||
})
|
||||
|
||||
it('collapses and expands a long block', () => {
|
||||
const frontmatter = Object.fromEntries(
|
||||
Array.from({ length: 12 }, (_, i) => [`field_${i}`, `value ${i}`]),
|
||||
)
|
||||
render(<FrontmatterPanel frontmatter={frontmatter} />)
|
||||
|
||||
const toggle = screen.getByTestId('skill-frontmatter-toggle')
|
||||
expect(toggle).toHaveAttribute('aria-expanded', 'true')
|
||||
expect(screen.getByTestId('skill-frontmatter-row-field_0')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(toggle)
|
||||
expect(toggle).toHaveAttribute('aria-expanded', 'false')
|
||||
expect(screen.queryByTestId('skill-frontmatter-row-field_0')).not.toBeInTheDocument()
|
||||
|
||||
fireEvent.click(toggle)
|
||||
expect(screen.getByTestId('skill-frontmatter-row-field_0')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,203 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { ChevronDown } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import {
|
||||
toFrontmatterEntries,
|
||||
type FrontmatterEntry,
|
||||
type FrontmatterValue,
|
||||
type SkillFrontmatter,
|
||||
} from '../../lib/skillFrontmatter'
|
||||
|
||||
/** Below this, the list always renders expanded — a toggle would be noise. */
|
||||
const COLLAPSIBLE_THRESHOLD = 8
|
||||
|
||||
function Chip({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<span className="inline-flex max-w-full items-center rounded-md bg-[var(--color-surface-container-high)] px-2 py-0.5 text-[11px] leading-5 text-[var(--color-text-secondary)] break-words">
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function ValueCell({ value }: { value: FrontmatterValue }) {
|
||||
if (Array.isArray(value)) {
|
||||
return (
|
||||
<span className="flex flex-wrap gap-1">
|
||||
{value.map((item, i) => (
|
||||
<Chip key={`${String(item)}-${i}`}>{String(item)}</Chip>
|
||||
))}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
if (typeof value === 'boolean') {
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex items-center gap-1 rounded-md px-2 py-0.5 text-[11px] leading-5 ${
|
||||
value
|
||||
? 'bg-[var(--color-success-container)]/45 text-[var(--color-success)]'
|
||||
: 'bg-[var(--color-surface-container-high)] text-[var(--color-text-tertiary)]'
|
||||
}`}
|
||||
>
|
||||
{value ? 'true' : 'false'}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
if (typeof value === 'string' && value.includes('\n')) {
|
||||
return (
|
||||
<pre className="max-h-56 overflow-auto whitespace-pre-wrap break-words rounded-lg bg-[var(--color-surface-container-high)]/60 px-3 py-2 font-[var(--font-mono)] text-[11.5px] leading-5 text-[var(--color-text-secondary)]">
|
||||
{value}
|
||||
</pre>
|
||||
)
|
||||
}
|
||||
|
||||
return <span className="break-words [overflow-wrap:anywhere]">{String(value)}</span>
|
||||
}
|
||||
|
||||
function CollapseToggle({ open, onToggle }: { open: boolean; onToggle: () => void }) {
|
||||
const t = useTranslation()
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="skill-frontmatter-toggle"
|
||||
aria-expanded={open}
|
||||
onClick={onToggle}
|
||||
className="-mr-1 inline-flex min-h-6 items-center gap-1 rounded-md px-1.5 text-[11px] font-normal normal-case tracking-normal text-[var(--color-text-secondary)] transition-colors hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-primary)] focus-visible:outline-none focus-visible:shadow-[var(--shadow-focus-ring)]"
|
||||
>
|
||||
{open ? t('market.detail.metadataCollapse') : t('market.detail.metadataExpand')}
|
||||
<ChevronDown className={`h-3.5 w-3.5 transition-transform ${open ? 'rotate-180' : ''}`} strokeWidth={2} aria-hidden="true" />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Sidebar form: rows that match the market meta list sitting above it, so a
|
||||
* skill's own declared attributes read as part of the same attribute block
|
||||
* rather than a separate widget. Short values sit opposite their label; arrays
|
||||
* and long text stack, because 300px cannot do both on one line.
|
||||
*/
|
||||
function SidebarRows({ entries }: { entries: FrontmatterEntry[] }) {
|
||||
return (
|
||||
<dl>
|
||||
{entries.map((entry) => {
|
||||
const stacked = entry.block || Array.isArray(entry.value)
|
||||
return (
|
||||
<div
|
||||
key={entry.key}
|
||||
data-testid={`skill-frontmatter-row-${entry.key}`}
|
||||
className={`min-w-0 border-b border-[var(--color-border)]/65 px-4 py-2.5 last:border-b-0 ${
|
||||
stacked ? 'flex flex-col gap-1.5' : 'flex items-start justify-between gap-4'
|
||||
}`}
|
||||
>
|
||||
<dt
|
||||
className="min-w-0 font-[var(--font-mono)] text-[10.5px] leading-5 text-[var(--color-text-tertiary)] [overflow-wrap:anywhere]"
|
||||
title={entry.key}
|
||||
>
|
||||
{entry.key}
|
||||
</dt>
|
||||
<dd
|
||||
className={`min-w-0 text-[12px] leading-5 text-[var(--color-text-primary)] ${
|
||||
stacked ? '' : 'max-w-[62%] text-right font-medium'
|
||||
}`}
|
||||
>
|
||||
<ValueCell value={entry.value} />
|
||||
</dd>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</dl>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Wide form: an auto-fitting grid that keeps a dozen short fields to three or
|
||||
* four rows. Used inside the file preview, where the frontmatter genuinely is
|
||||
* the top of the file being read.
|
||||
*/
|
||||
function GridRows({ entries }: { entries: FrontmatterEntry[] }) {
|
||||
return (
|
||||
<dl className="grid grid-cols-[repeat(auto-fill,minmax(min(100%,13rem),1fr))] gap-x-6 gap-y-3 pb-3.5">
|
||||
{entries.map((entry) => (
|
||||
<div
|
||||
key={entry.key}
|
||||
data-testid={`skill-frontmatter-row-${entry.key}`}
|
||||
className={`flex min-w-0 flex-col gap-1 ${entry.block ? 'col-span-full' : ''}`}
|
||||
>
|
||||
<dt className="truncate font-[var(--font-mono)] text-[10.5px] leading-4 text-[var(--color-text-tertiary)]" title={entry.key}>
|
||||
{entry.key}
|
||||
</dt>
|
||||
<dd className="min-w-0 text-[12.5px] leading-5 text-[var(--color-text-primary)]">
|
||||
<ValueCell value={entry.value} />
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Structured view of a skill's YAML frontmatter.
|
||||
*
|
||||
* SKILL.md metadata is data, not prose — rendering it through the markdown
|
||||
* pipeline produced a wall of bold setext-heading text. It is also *reference*
|
||||
* material: it never precedes the document that explains what the skill does.
|
||||
* In `sidebar` it joins the attribute rail; in `grid` it heads the file preview.
|
||||
*/
|
||||
export function FrontmatterPanel({
|
||||
frontmatter,
|
||||
variant = 'grid',
|
||||
skipKeys,
|
||||
className,
|
||||
defaultOpen = true,
|
||||
}: {
|
||||
frontmatter: SkillFrontmatter | null | undefined
|
||||
variant?: 'grid' | 'sidebar'
|
||||
/** Extra keys to hide, on top of the ones the detail header already shows. */
|
||||
skipKeys?: string[]
|
||||
className?: string
|
||||
defaultOpen?: boolean
|
||||
}) {
|
||||
const t = useTranslation()
|
||||
const entries = useMemo(() => toFrontmatterEntries(frontmatter, { skipKeys }), [frontmatter, skipKeys])
|
||||
const [open, setOpen] = useState(defaultOpen)
|
||||
|
||||
if (entries.length === 0) return null
|
||||
|
||||
const collapsible = entries.length > COLLAPSIBLE_THRESHOLD
|
||||
const toggle = collapsible ? <CollapseToggle open={open} onToggle={() => setOpen((prev) => !prev)} /> : null
|
||||
|
||||
if (variant === 'sidebar') {
|
||||
return (
|
||||
<section data-testid="skill-frontmatter-panel" className={className}>
|
||||
{/* Sticky: the rail scrolls on its own, and a bare list of `slug` /
|
||||
`xiaping_*` rows is meaningless without the heading in view. */}
|
||||
<div className="sticky top-0 z-10 flex items-center gap-2 border-b border-[var(--color-border)]/65 bg-[var(--color-surface-container)] px-4 py-2">
|
||||
<h3 className="flex-1 text-[10px] font-semibold uppercase tracking-[0.08em] text-[var(--color-text-tertiary)]">
|
||||
{t('market.detail.metadata')}
|
||||
</h3>
|
||||
{toggle}
|
||||
</div>
|
||||
{open && <SidebarRows entries={entries} />}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
// Reads as the file's header block, not a card floating inside the preview
|
||||
// card — the frontmatter is part of the document being viewed.
|
||||
return (
|
||||
<section
|
||||
data-testid="skill-frontmatter-panel"
|
||||
className={`border-b border-[var(--color-border)] ${className ?? ''}`}
|
||||
>
|
||||
<div className="flex items-center gap-2 py-2">
|
||||
<h3 className="flex-1 text-[10px] font-semibold uppercase tracking-[0.08em] text-[var(--color-text-tertiary)]">
|
||||
{t('market.detail.metadata')}
|
||||
</h3>
|
||||
<span className="text-[11px] tabular-nums text-[var(--color-text-tertiary)]">{entries.length}</span>
|
||||
{toggle}
|
||||
</div>
|
||||
{open && <GridRows entries={entries} />}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -221,6 +221,7 @@ export function MarketSkillDetail({
|
||||
banner={banner}
|
||||
meta={meta}
|
||||
description={detail.description}
|
||||
descriptionFrontmatter={detail.descriptionFrontmatter}
|
||||
files={detail.files.map((f) => ({ path: f.path, size: f.size, language: f.language, tooBig: f.tooBig }))}
|
||||
loadFile={loadFile}
|
||||
onBack={backToList}
|
||||
|
||||
@@ -118,4 +118,101 @@ describe('SkillDetailView', () => {
|
||||
expect(within(decisionSidebar).getByText('Author')).toBeInTheDocument()
|
||||
expect(within(decisionSidebar).getByText('Alice')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('lifts SKILL.md frontmatter out of the overview markdown into the sidebar rail', () => {
|
||||
renderView({
|
||||
description: ['---', 'name: find-skills', 'model: opus', 'tags: ["a","b"]', '---', '', '# Body'].join('\n'),
|
||||
})
|
||||
|
||||
// The YAML must not reach the renderer — markdown turns it into a heading.
|
||||
expect(screen.getByTestId('markdown-renderer')).toHaveAttribute('data-content', '# Body')
|
||||
|
||||
// Metadata is reference material: it sits with the other attributes, and
|
||||
// never ahead of the document that says what the skill does.
|
||||
const overview = screen.getByTestId('skill-detail-overview')
|
||||
expect(within(overview).queryByTestId('skill-frontmatter-panel')).not.toBeInTheDocument()
|
||||
|
||||
const panel = within(screen.getByTestId('skill-detail-sidebar')).getByTestId('skill-frontmatter-panel')
|
||||
expect(within(panel).getByText('model')).toBeInTheDocument()
|
||||
expect(within(panel).getByText('opus')).toBeInTheDocument()
|
||||
// `name` duplicates the header, so it is filtered out.
|
||||
expect(within(panel).queryByText('name')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('keeps the metadata rail below the market meta it belongs with', () => {
|
||||
renderView({
|
||||
description: '---\nmodel: opus\n---\n# Body',
|
||||
meta: [{ label: 'Author', value: 'Alice' }],
|
||||
})
|
||||
|
||||
const sidebar = screen.getByTestId('skill-detail-sidebar')
|
||||
const author = within(sidebar).getByText('Author')
|
||||
const panel = within(sidebar).getByTestId('skill-frontmatter-panel')
|
||||
|
||||
expect(author.compareDocumentPosition(panel) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
|
||||
})
|
||||
|
||||
it('shows frontmatter the caller parsed upstream when the description has none', () => {
|
||||
renderView({ description: '# Body', descriptionFrontmatter: { model: 'opus' } })
|
||||
|
||||
expect(screen.getByTestId('skill-frontmatter-panel')).toHaveTextContent('opus')
|
||||
expect(screen.getByTestId('markdown-renderer')).toHaveAttribute('data-content', '# Body')
|
||||
})
|
||||
|
||||
it('prefers frontmatter embedded in the description over the caller-supplied copy', () => {
|
||||
renderView({
|
||||
description: '---\nmodel: sonnet\n---\n# Body',
|
||||
descriptionFrontmatter: { model: 'opus' },
|
||||
})
|
||||
|
||||
const panel = screen.getByTestId('skill-frontmatter-panel')
|
||||
expect(panel).toHaveTextContent('sonnet')
|
||||
expect(panel).not.toHaveTextContent('opus')
|
||||
})
|
||||
|
||||
it('omits the metadata panel when there is no frontmatter at all', () => {
|
||||
renderView({ description: '# Body' })
|
||||
|
||||
expect(screen.queryByTestId('skill-frontmatter-panel')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('still shows the empty-description notice when only frontmatter is present', () => {
|
||||
renderView({ description: '---\nmodel: opus\n---\n' })
|
||||
|
||||
expect(screen.getByText('This skill provides no description.')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('skill-frontmatter-panel')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('splits frontmatter out of markdown files in the files tab too', async () => {
|
||||
const loadFile = (path: string) =>
|
||||
Promise.resolve({
|
||||
path,
|
||||
content: '---\nmodel: opus\n---\n# Skill body',
|
||||
language: 'markdown',
|
||||
size: 42,
|
||||
truncated: false,
|
||||
})
|
||||
// No sidebar copy here, so the only panel found must be the file's own.
|
||||
renderView({ loadFile, description: '# Overview', meta: [] })
|
||||
|
||||
fireEvent.click(screen.getByTestId('skill-detail-tab-files'))
|
||||
|
||||
const preview = await screen.findByTestId('market-file-preview')
|
||||
expect(await within(preview).findByTestId('skill-frontmatter-panel')).toHaveTextContent('opus')
|
||||
const rendered = await screen.findAllByTestId('markdown-renderer')
|
||||
expect(rendered.some((node) => node.getAttribute('data-content') === '# Skill body')).toBe(true)
|
||||
})
|
||||
|
||||
it('lets the files tab fill the available height instead of a fixed-height box', async () => {
|
||||
renderView()
|
||||
|
||||
fireEvent.click(screen.getByTestId('skill-detail-tab-files'))
|
||||
|
||||
// A hard-coded max-height left the lower half of the page empty; the panel
|
||||
// now grows with its flex parent.
|
||||
const preview = await screen.findByTestId('market-file-preview')
|
||||
expect(screen.getByTestId('skill-detail-files').className).toContain('flex-1')
|
||||
expect(preview.className).toContain('flex-1')
|
||||
expect(preview.className).not.toContain('max-h-[520px]')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useRef, useState, type ReactNode } from 'react'
|
||||
import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
|
||||
import { ArrowLeft, CircleSlash2, FileText, Folder } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import type {
|
||||
@@ -10,8 +10,10 @@ import type {
|
||||
import { InstallStateBadge } from './InstallStateBadge'
|
||||
import { SecurityBadge } from './SecurityBadge'
|
||||
import { FilePreview, type PreviewFile, type PreviewFileContent } from './FilePreview'
|
||||
import { FrontmatterPanel } from './FrontmatterPanel'
|
||||
import { MarkdownRenderer } from '../markdown/MarkdownRenderer'
|
||||
import { SkillAvatar } from './SkillAvatar'
|
||||
import { splitFrontmatter, type SkillFrontmatter } from '../../lib/skillFrontmatter'
|
||||
|
||||
export type SkillDetailMetaItem = {
|
||||
label: string
|
||||
@@ -34,6 +36,11 @@ export type SkillDetailViewProps = {
|
||||
banner?: ReactNode
|
||||
meta: SkillDetailMetaItem[]
|
||||
description: string
|
||||
/**
|
||||
* Frontmatter the caller already parsed. Used when `description` arrives with
|
||||
* its YAML block stripped upstream, so the overview can still show it.
|
||||
*/
|
||||
descriptionFrontmatter?: SkillFrontmatter
|
||||
files: PreviewFile[]
|
||||
loadFile: (path: string) => Promise<PreviewFileContent>
|
||||
onBack: () => void
|
||||
@@ -54,12 +61,24 @@ export function SkillDetailView(props: SkillDetailViewProps) {
|
||||
headingRef.current?.focus()
|
||||
}, [])
|
||||
|
||||
// Some sources hand us the raw SKILL.md (frontmatter included), others strip
|
||||
// it upstream and pass the parsed block separately. Handle both.
|
||||
const overview = useMemo(() => splitFrontmatter(props.description), [props.description])
|
||||
const skillFrontmatter = overview.frontmatter ?? props.descriptionFrontmatter
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex min-h-0 flex-1 flex-col overflow-y-auto bg-[var(--color-surface-container-lowest)]"
|
||||
className="min-h-0 flex-1 overflow-y-auto bg-[var(--color-surface-container-lowest)]"
|
||||
data-testid="skill-detail-view"
|
||||
>
|
||||
<div className="mx-auto w-full max-w-[1320px] px-6 py-6 lg:px-8">
|
||||
{/*
|
||||
`lg:h-full` (not `min-h-full`) on purpose: a flex column sized by
|
||||
min-height stays `height: auto`, so `flex-1`'s `flex-basis: 0%` cannot
|
||||
resolve and the panel below falls back to content height — which is what
|
||||
left the bottom of the page empty. A definite height makes the tab panel
|
||||
claim the leftover space. Narrow layouts keep the page scrolling.
|
||||
*/}
|
||||
<div className="mx-auto flex w-full max-w-[1320px] flex-col px-6 py-6 lg:h-full lg:px-8">
|
||||
<button
|
||||
type="button"
|
||||
onClick={props.onBack}
|
||||
@@ -69,7 +88,7 @@ export function SkillDetailView(props: SkillDetailViewProps) {
|
||||
{props.backLabel}
|
||||
</button>
|
||||
|
||||
<header className="mt-5 border-b border-[var(--color-border)]/70 pb-6">
|
||||
<header className="mt-5 flex-shrink-0 border-b border-[var(--color-border)]/70 pb-6">
|
||||
<div className="flex min-w-0 items-start gap-4 sm:gap-5">
|
||||
<SkillAvatar skill={{ name: props.name, iconUrl: props.iconUrl }} size={64} />
|
||||
<div className="min-w-0 flex-1 pt-0.5">
|
||||
@@ -145,12 +164,12 @@ export function SkillDetailView(props: SkillDetailViewProps) {
|
||||
{props.banner}
|
||||
</header>
|
||||
|
||||
<div className="mt-6 grid gap-6 lg:grid-cols-[minmax(0,1fr)_280px] lg:items-start">
|
||||
<main className="min-w-0">
|
||||
<div className="mt-6 grid gap-6 lg:min-h-0 lg:flex-1 lg:grid-cols-[minmax(0,1fr)_300px] lg:items-stretch">
|
||||
<main className="flex min-w-0 flex-col lg:min-h-0">
|
||||
<div
|
||||
role="tablist"
|
||||
aria-label={props.name}
|
||||
className="flex items-center gap-1 border-b border-[var(--color-border)]"
|
||||
className="flex flex-shrink-0 items-center gap-1 border-b border-[var(--color-border)]"
|
||||
>
|
||||
{(['overview', 'files'] as const).map((key) => {
|
||||
const active = tab === key
|
||||
@@ -188,11 +207,11 @@ export function SkillDetailView(props: SkillDetailViewProps) {
|
||||
id="skill-detail-overview-panel"
|
||||
role="tabpanel"
|
||||
aria-labelledby="skill-detail-tab-overview-trigger"
|
||||
className="mt-5 rounded-xl border border-[var(--color-border)]/70 bg-[var(--color-surface)] px-6 py-6 sm:px-8 sm:py-7"
|
||||
className="mt-5 rounded-xl border border-[var(--color-border)]/70 bg-[var(--color-surface)] px-6 py-6 sm:px-8 sm:py-7 lg:min-h-0 lg:flex-1 lg:overflow-y-auto"
|
||||
data-testid="skill-detail-overview"
|
||||
>
|
||||
{props.description.trim() ? (
|
||||
<MarkdownRenderer content={props.description} variant="document" className="mx-auto max-w-[72ch]" />
|
||||
{overview.body.trim() ? (
|
||||
<MarkdownRenderer content={overview.body} variant="document" className="mx-auto max-w-[72ch]" />
|
||||
) : (
|
||||
<p className="py-6 text-center text-sm text-[var(--color-text-tertiary)]">{t('market.detail.noDescription')}</p>
|
||||
)}
|
||||
@@ -204,7 +223,8 @@ export function SkillDetailView(props: SkillDetailViewProps) {
|
||||
id="skill-detail-files-panel"
|
||||
role="tabpanel"
|
||||
aria-labelledby="skill-detail-tab-files-trigger"
|
||||
className="mt-5"
|
||||
className="mt-5 flex min-h-[22rem] flex-col lg:min-h-0 lg:flex-1"
|
||||
data-testid="skill-detail-files"
|
||||
>
|
||||
<FilePreview files={props.files} loadFile={props.loadFile} />
|
||||
</section>
|
||||
@@ -213,7 +233,7 @@ export function SkillDetailView(props: SkillDetailViewProps) {
|
||||
|
||||
<aside
|
||||
data-testid="skill-detail-sidebar"
|
||||
className="order-first min-w-0 lg:order-none lg:sticky lg:top-5"
|
||||
className="order-first min-w-0 lg:order-none lg:max-h-full lg:self-start lg:overflow-y-auto"
|
||||
>
|
||||
<div className="overflow-hidden rounded-xl border border-[var(--color-border)] bg-[var(--color-surface-container-low)] shadow-[0_1px_2px_rgba(27,28,26,0.05)]">
|
||||
{props.actions && (
|
||||
@@ -238,6 +258,17 @@ export function SkillDetailView(props: SkillDetailViewProps) {
|
||||
))}
|
||||
</dl>
|
||||
)}
|
||||
{/*
|
||||
The skill's own declared attributes belong with the market ones
|
||||
above — same kind of data, different source. Keeping them here
|
||||
leaves the overview tab free to answer "what is this skill?"
|
||||
first, which is what a reader opens the page for.
|
||||
*/}
|
||||
<FrontmatterPanel
|
||||
frontmatter={skillFrontmatter}
|
||||
variant="sidebar"
|
||||
className={props.actions || props.meta.length > 0 ? 'border-t border-[var(--color-border)]' : ''}
|
||||
/>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
@@ -8,29 +8,6 @@ import { SkillDetailView, type SkillDetailMetaItem } from '../market/SkillDetail
|
||||
import type { PreviewFileContent } from '../market/FilePreview'
|
||||
import { ConfirmDialog } from '../shared/ConfirmDialog'
|
||||
|
||||
const META_PRIORITY = [
|
||||
'when_to_use',
|
||||
'argument-hint',
|
||||
'model',
|
||||
'effort',
|
||||
'allowed-tools',
|
||||
'paths',
|
||||
'agent',
|
||||
'context',
|
||||
'user-invocable',
|
||||
] as const
|
||||
|
||||
function formatMetaKey(key: string) {
|
||||
return key.replace(/[-_]/g, ' ')
|
||||
}
|
||||
|
||||
function formatMetaValue(value: unknown): string {
|
||||
if (Array.isArray(value)) return value.map((item) => String(item)).join(', ')
|
||||
if (typeof value === 'boolean') return value ? 'true' : 'false'
|
||||
if (typeof value === 'object' && value !== null) return JSON.stringify(value)
|
||||
return String(value)
|
||||
}
|
||||
|
||||
export function SkillDetail() {
|
||||
const { selectedSkill, selectedSkillReturnTab, isDetailLoading, clearSelection, fetchSkills } = useSkillStore()
|
||||
const t = useTranslation()
|
||||
@@ -47,14 +24,15 @@ export function SkillDetail() {
|
||||
|
||||
const files = selectedSkill?.files ?? []
|
||||
|
||||
// The files tab shows the file as it is on disk — frontmatter included. The
|
||||
// preview splits the YAML block out and renders it as structured metadata.
|
||||
const loadFile = useCallback(
|
||||
(path: string): Promise<PreviewFileContent> => {
|
||||
const file = files.find((f) => f.path === path)
|
||||
if (!file) return Promise.reject(new Error(`File not found: ${path}`))
|
||||
const content = file.language === 'markdown' ? (file.body ?? file.content) : file.content
|
||||
return Promise.resolve({
|
||||
path: file.path,
|
||||
content,
|
||||
content: file.content,
|
||||
language: file.language,
|
||||
size: file.content.length,
|
||||
truncated: false,
|
||||
@@ -82,28 +60,9 @@ export function SkillDetail() {
|
||||
value: new Date(selectedSkill.marketMeta.installedAt).toLocaleDateString(),
|
||||
})
|
||||
}
|
||||
const entry = selectedSkill.files.find((f) => f.isEntry)
|
||||
const frontmatter = entry?.frontmatter
|
||||
if (frontmatter) {
|
||||
const entries = Object.entries(frontmatter)
|
||||
.filter(([key, value]) => {
|
||||
if (key === 'name' || key === 'description' || key === 'version') return false
|
||||
if (value == null) return false
|
||||
if (typeof value === 'string') return value.trim().length > 0
|
||||
if (Array.isArray(value)) return value.length > 0
|
||||
return true
|
||||
})
|
||||
.sort((a, b) => {
|
||||
const aIndex = META_PRIORITY.indexOf(a[0] as (typeof META_PRIORITY)[number])
|
||||
const bIndex = META_PRIORITY.indexOf(b[0] as (typeof META_PRIORITY)[number])
|
||||
const normalizedA = aIndex === -1 ? Number.MAX_SAFE_INTEGER : aIndex
|
||||
const normalizedB = bIndex === -1 ? Number.MAX_SAFE_INTEGER : bIndex
|
||||
return normalizedA - normalizedB || a[0].localeCompare(b[0])
|
||||
})
|
||||
for (const [key, value] of entries) {
|
||||
items.push({ label: formatMetaKey(key), value: formatMetaValue(value) })
|
||||
}
|
||||
}
|
||||
// SKILL.md frontmatter used to be flattened into this sidebar, where long
|
||||
// values and list fields were unreadable. It now renders as a structured
|
||||
// panel in the overview tab instead.
|
||||
return items
|
||||
}, [selectedSkill, t])
|
||||
|
||||
@@ -121,6 +80,7 @@ export function SkillDetail() {
|
||||
const marketMeta = selectedSkill.marketMeta
|
||||
const entryFile = selectedSkill.files.find((f) => f.isEntry)
|
||||
const description = entryFile ? (entryFile.body ?? entryFile.content) : ''
|
||||
const descriptionFrontmatter = entryFile?.frontmatter
|
||||
|
||||
const runUninstall = async () => {
|
||||
if (!marketMeta) return
|
||||
@@ -184,6 +144,7 @@ export function SkillDetail() {
|
||||
actions={actions}
|
||||
meta={meta}
|
||||
description={description}
|
||||
descriptionFrontmatter={descriptionFrontmatter}
|
||||
files={selectedSkill.files.map((f) => ({
|
||||
path: f.path,
|
||||
size: f.content.length,
|
||||
|
||||
@@ -2429,6 +2429,9 @@ export const en = {
|
||||
'market.detail.requiresApiKey': 'Requires API key',
|
||||
'market.detail.loadError': 'Failed to load skill details',
|
||||
'market.detail.noDescription': 'This skill provides no description.',
|
||||
'market.detail.metadata': 'Metadata',
|
||||
'market.detail.metadataCollapse': 'Collapse',
|
||||
'market.detail.metadataExpand': 'Expand',
|
||||
'market.file.empty': 'Select a file on the left to preview it.',
|
||||
'market.file.truncated': 'Preview truncated — the file is larger than the preview limit.',
|
||||
'market.file.loadError': 'Failed to load this file',
|
||||
|
||||
@@ -2431,6 +2431,9 @@ export const jp: Record<TranslationKey, string> = {
|
||||
'market.detail.requiresApiKey': 'API キーが必要',
|
||||
'market.detail.loadError': 'スキル詳細の読み込みに失敗しました',
|
||||
'market.detail.noDescription': 'このスキルには説明がありません。',
|
||||
'market.detail.metadata': 'メタ情報',
|
||||
'market.detail.metadataCollapse': '折りたたむ',
|
||||
'market.detail.metadataExpand': '展開',
|
||||
'market.file.empty': '左側のファイルを選択するとプレビューできます。',
|
||||
'market.file.truncated': 'プレビューは切り詰められています。ファイルがプレビュー上限を超えています。',
|
||||
'market.file.loadError': 'ファイルの読み込みに失敗しました',
|
||||
|
||||
@@ -2431,6 +2431,9 @@ export const kr: Record<TranslationKey, string> = {
|
||||
'market.detail.requiresApiKey': 'API 키 필요',
|
||||
'market.detail.loadError': '스킬 상세 정보를 불러오지 못했습니다',
|
||||
'market.detail.noDescription': '이 스킬에는 설명이 없습니다.',
|
||||
'market.detail.metadata': '메타 정보',
|
||||
'market.detail.metadataCollapse': '접기',
|
||||
'market.detail.metadataExpand': '펼치기',
|
||||
'market.file.empty': '왼쪽에서 파일을 선택하면 미리 볼 수 있습니다.',
|
||||
'market.file.truncated': '미리보기가 잘렸습니다. 파일이 미리보기 제한을 초과합니다.',
|
||||
'market.file.loadError': '파일을 불러오지 못했습니다',
|
||||
|
||||
@@ -2431,6 +2431,9 @@ export const zh: Record<TranslationKey, string> = {
|
||||
'market.detail.requiresApiKey': '需要 API Key',
|
||||
'market.detail.loadError': '技能詳情載入失敗',
|
||||
'market.detail.noDescription': '該技能未提供說明。',
|
||||
'market.detail.metadata': '中繼資訊',
|
||||
'market.detail.metadataCollapse': '收合',
|
||||
'market.detail.metadataExpand': '展開',
|
||||
'market.file.empty': '在左側選擇一個檔案進行預覽。',
|
||||
'market.file.truncated': '預覽已截斷——檔案超出預覽大小限制。',
|
||||
'market.file.loadError': '檔案載入失敗',
|
||||
|
||||
@@ -2431,6 +2431,9 @@ export const zh: Record<TranslationKey, string> = {
|
||||
'market.detail.requiresApiKey': '需要 API Key',
|
||||
'market.detail.loadError': '技能详情加载失败',
|
||||
'market.detail.noDescription': '该技能未提供说明。',
|
||||
'market.detail.metadata': '元信息',
|
||||
'market.detail.metadataCollapse': '收起',
|
||||
'market.detail.metadataExpand': '展开',
|
||||
'market.file.empty': '在左侧选择一个文件进行预览。',
|
||||
'market.file.truncated': '预览已截断——文件超出预览大小限制。',
|
||||
'market.file.loadError': '文件加载失败',
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { splitFrontmatter, toFrontmatterEntries } from './skillFrontmatter'
|
||||
|
||||
describe('splitFrontmatter', () => {
|
||||
it('splits a SKILL.md frontmatter block off the body', () => {
|
||||
const md = ['---', 'name: find-skills', 'slug: guipi-find-skills', '---', '', '# Heading', 'body text'].join('\n')
|
||||
|
||||
const { frontmatter, body } = splitFrontmatter(md)
|
||||
|
||||
expect(frontmatter).toEqual({ name: 'find-skills', slug: 'guipi-find-skills' })
|
||||
expect(body).toBe('# Heading\nbody text')
|
||||
})
|
||||
|
||||
it('keeps quoted versions as strings so 1.7.0 does not become a number', () => {
|
||||
const { frontmatter } = splitFrontmatter('---\nversion: "1.7.0"\nweight: 3\n---\n')
|
||||
|
||||
expect(frontmatter).toEqual({ version: '1.7.0', weight: 3 })
|
||||
})
|
||||
|
||||
it('parses inline sequences, including quoted items containing commas', () => {
|
||||
const md = '---\nxiaping_tags: ["AI工具","技能发现","a, b"]\n---\n'
|
||||
|
||||
expect(splitFrontmatter(md).frontmatter).toEqual({ xiaping_tags: ['AI工具', '技能发现', 'a, b'] })
|
||||
})
|
||||
|
||||
it('parses block sequences', () => {
|
||||
const md = ['---', 'paths:', ' - src/**', ' - tests/**', 'model: opus', '---', 'body'].join('\n')
|
||||
|
||||
expect(splitFrontmatter(md).frontmatter).toEqual({ paths: ['src/**', 'tests/**'], model: 'opus' })
|
||||
})
|
||||
|
||||
it('parses booleans and nulls', () => {
|
||||
const { frontmatter } = splitFrontmatter('---\nagent_created: true\nuser-invocable: false\nagent: null\n---\n')
|
||||
|
||||
expect(frontmatter).toEqual({ agent_created: true, 'user-invocable': false, agent: null })
|
||||
})
|
||||
|
||||
it('keeps literal block scalars line-broken and folds folded ones', () => {
|
||||
const md = ['---', 'literal: |', ' line one', ' line two', 'folded: >', ' line one', ' line two', '---'].join('\n')
|
||||
|
||||
expect(splitFrontmatter(md).frontmatter).toEqual({
|
||||
literal: 'line one\nline two',
|
||||
folded: 'line one line two',
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps nested mappings as raw YAML instead of dropping them', () => {
|
||||
const md = ['---', 'hooks:', ' PreToolUse:', ' - matcher: Bash', 'model: opus', '---'].join('\n')
|
||||
|
||||
const { frontmatter } = splitFrontmatter(md)
|
||||
|
||||
expect(frontmatter?.model).toBe('opus')
|
||||
expect(frontmatter?.hooks).toBe('PreToolUse:\n - matcher: Bash')
|
||||
})
|
||||
|
||||
it('ignores a colon inside a description value', () => {
|
||||
const md = '---\ndescription: Use this when: you want X\n---\nbody'
|
||||
|
||||
expect(splitFrontmatter(md).frontmatter).toEqual({ description: 'Use this when: you want X' })
|
||||
})
|
||||
|
||||
it('strips trailing comments but leaves # inside quotes alone', () => {
|
||||
const { frontmatter } = splitFrontmatter('---\nmodel: opus # the good one\ntag: "a#b"\n---\n')
|
||||
|
||||
expect(frontmatter).toEqual({ model: 'opus', tag: 'a#b' })
|
||||
})
|
||||
|
||||
it('returns the document untouched when there is no frontmatter', () => {
|
||||
const md = '# Just a doc\n\nwith --- inside\n'
|
||||
|
||||
expect(splitFrontmatter(md)).toEqual({ frontmatter: null, body: md })
|
||||
})
|
||||
|
||||
it('does not eat the document when the fence is never closed', () => {
|
||||
const md = '---\nname: broken\n\n# Heading'
|
||||
|
||||
expect(splitFrontmatter(md)).toEqual({ frontmatter: null, body: md })
|
||||
})
|
||||
|
||||
it('treats a leading horizontal rule as body, not as an open fence', () => {
|
||||
const md = '---\n\n# Heading\n\nsome text\n'
|
||||
|
||||
expect(splitFrontmatter(md).frontmatter).toBeNull()
|
||||
})
|
||||
|
||||
it('tolerates CRLF line endings', () => {
|
||||
const { frontmatter, body } = splitFrontmatter('---\r\nname: crlf\r\n---\r\n# Heading\r\n')
|
||||
|
||||
expect(frontmatter).toEqual({ name: 'crlf' })
|
||||
expect(body).toBe('# Heading\r\n')
|
||||
})
|
||||
|
||||
it('handles an empty document', () => {
|
||||
expect(splitFrontmatter('')).toEqual({ frontmatter: null, body: '' })
|
||||
})
|
||||
|
||||
it('drops an empty frontmatter block from the body without reporting metadata', () => {
|
||||
expect(splitFrontmatter('---\n---\n# Heading')).toEqual({ frontmatter: null, body: '# Heading' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('toFrontmatterEntries', () => {
|
||||
it('hides keys the detail header already shows', () => {
|
||||
const entries = toFrontmatterEntries({
|
||||
name: 'find-skills',
|
||||
displayName: 'Find Skills',
|
||||
description: 'long text',
|
||||
version: '1.7.0',
|
||||
model: 'opus',
|
||||
})
|
||||
|
||||
expect(entries.map((e) => e.key)).toEqual(['model'])
|
||||
})
|
||||
|
||||
it('drops empty values', () => {
|
||||
const entries = toFrontmatterEntries({ a: '', b: ' ', c: null, d: [], e: 'kept', f: false })
|
||||
|
||||
expect(entries.map((e) => e.key)).toEqual(['e', 'f'])
|
||||
})
|
||||
|
||||
it('sorts well-known operational keys first, then alphabetically', () => {
|
||||
const entries = toFrontmatterEntries({ zeta: '1', 'allowed-tools': 'Read', alpha: '2', model: 'opus' })
|
||||
|
||||
expect(entries.map((e) => e.key)).toEqual(['allowed-tools', 'model', 'alpha', 'zeta'])
|
||||
})
|
||||
|
||||
it('flags long and multi-line strings as block values', () => {
|
||||
const entries = toFrontmatterEntries({
|
||||
short: 'opus',
|
||||
long: 'x'.repeat(60),
|
||||
multiline: 'a\nb',
|
||||
})
|
||||
|
||||
expect(entries.find((e) => e.key === 'short')?.block).toBe(false)
|
||||
expect(entries.find((e) => e.key === 'long')?.block).toBe(true)
|
||||
expect(entries.find((e) => e.key === 'multiline')?.block).toBe(true)
|
||||
})
|
||||
|
||||
it('renders nested objects from a server-side YAML parse as pretty JSON', () => {
|
||||
const entries = toFrontmatterEntries({ hooks: { PreToolUse: [{ matcher: 'Bash' }] } })
|
||||
|
||||
expect(entries[0]?.value).toContain('"PreToolUse"')
|
||||
expect(entries[0]?.block).toBe(true)
|
||||
})
|
||||
|
||||
it('honours caller-supplied skip keys case-insensitively', () => {
|
||||
const entries = toFrontmatterEntries({ slug: 'a', model: 'opus' }, { skipKeys: ['SLUG'] })
|
||||
|
||||
expect(entries.map((e) => e.key)).toEqual(['model'])
|
||||
})
|
||||
|
||||
it('returns nothing for null or empty frontmatter', () => {
|
||||
expect(toFrontmatterEntries(null)).toEqual([])
|
||||
expect(toFrontmatterEntries({})).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,371 @@
|
||||
/**
|
||||
* Frontmatter splitting for skill markdown shown in the desktop UI.
|
||||
*
|
||||
* SKILL.md carries its metadata as YAML frontmatter. Feeding that raw text to
|
||||
* the markdown renderer turns `key: value` lines into a setext heading (the
|
||||
* closing `---` promotes everything above it to an `<h2>`), which is why the
|
||||
* detail view used to open with a wall of bold text. We split the block out
|
||||
* here so the UI can render it as structured metadata instead.
|
||||
*
|
||||
* This is a deliberately small YAML subset — scalars, inline/block sequences,
|
||||
* and block scalars — because skill frontmatter is flat by convention. Anything
|
||||
* more nested is kept as its raw YAML text so no information is lost.
|
||||
*/
|
||||
|
||||
export type FrontmatterScalar = string | number | boolean | null
|
||||
export type FrontmatterValue = FrontmatterScalar | FrontmatterScalar[]
|
||||
/**
|
||||
* Widened to `unknown` on purpose: the same panel also renders frontmatter that
|
||||
* the server already parsed with a full YAML library, which can hold nested
|
||||
* objects. `toFrontmatterEntries` normalizes whatever comes in.
|
||||
*/
|
||||
export type SkillFrontmatter = Record<string, unknown>
|
||||
|
||||
export type SplitMarkdown = {
|
||||
/** Parsed frontmatter, or null when the document has none. */
|
||||
frontmatter: SkillFrontmatter | null
|
||||
/** The document with its frontmatter block removed. */
|
||||
body: string
|
||||
}
|
||||
|
||||
/** A `---` (or `...`) line that closes the block. Must sit at column zero. */
|
||||
const CLOSING_DELIMITER = /^(?:---|\.\.\.)[\t ]*$/
|
||||
/** Top-level `key:` or `key: value`. Indented lines belong to the value above. */
|
||||
const KEY_LINE = /^([A-Za-z0-9_$][\w.$-]*)[\t ]*:(?:[\t ]+(.*))?$/
|
||||
const BLOCK_SCALAR = /^([|>])([-+]?)(\d*)$/
|
||||
|
||||
function stripQuotes(raw: string): { value: string; quoted: boolean } {
|
||||
if (raw.length >= 2) {
|
||||
const first = raw[0]
|
||||
const last = raw[raw.length - 1]
|
||||
if ((first === '"' && last === '"') || (first === "'" && last === "'")) {
|
||||
const inner = raw.slice(1, -1)
|
||||
return {
|
||||
value: first === '"' ? inner.replace(/\\"/g, '"').replace(/\\\\/g, '\\') : inner.replace(/''/g, "'"),
|
||||
quoted: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
return { value: raw, quoted: false }
|
||||
}
|
||||
|
||||
/** Drop a trailing ` # comment`, but only when it is outside quotes. */
|
||||
function stripComment(raw: string): string {
|
||||
let quote: string | null = null
|
||||
for (let i = 0; i < raw.length; i++) {
|
||||
const char = raw[i]
|
||||
if (quote) {
|
||||
if (char === quote) quote = null
|
||||
continue
|
||||
}
|
||||
if (char === '"' || char === "'") {
|
||||
quote = char
|
||||
continue
|
||||
}
|
||||
if (char === '#' && (i === 0 || raw[i - 1] === ' ' || raw[i - 1] === '\t')) {
|
||||
return raw.slice(0, i)
|
||||
}
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
function coerceScalar(raw: string): FrontmatterScalar {
|
||||
const trimmed = raw.trim()
|
||||
if (trimmed === '') return ''
|
||||
|
||||
const { value, quoted } = stripQuotes(trimmed)
|
||||
// Quoted values stay strings — `version: "1.7.0"` must not become 1.7.
|
||||
if (quoted) return value
|
||||
|
||||
const lower = value.toLowerCase()
|
||||
if (lower === 'true') return true
|
||||
if (lower === 'false') return false
|
||||
if (lower === 'null' || lower === '~') return null
|
||||
// Plain integers/decimals only. Leading zeros stay strings so ids survive.
|
||||
if (/^-?(?:0|[1-9]\d*)(?:\.\d+)?$/.test(value)) return Number(value)
|
||||
return value
|
||||
}
|
||||
|
||||
/** Split `[a, "b, c", d]` on top-level commas only. */
|
||||
function splitInlineSequence(inner: string): string[] {
|
||||
const parts: string[] = []
|
||||
let current = ''
|
||||
let quote: string | null = null
|
||||
let depth = 0
|
||||
|
||||
for (const char of inner) {
|
||||
if (quote) {
|
||||
current += char
|
||||
if (char === quote) quote = null
|
||||
continue
|
||||
}
|
||||
if (char === '"' || char === "'") {
|
||||
quote = char
|
||||
current += char
|
||||
continue
|
||||
}
|
||||
if (char === '[' || char === '{') depth++
|
||||
if (char === ']' || char === '}') depth--
|
||||
if (char === ',' && depth === 0) {
|
||||
parts.push(current)
|
||||
current = ''
|
||||
continue
|
||||
}
|
||||
current += char
|
||||
}
|
||||
parts.push(current)
|
||||
|
||||
return parts.map((part) => part.trim()).filter((part) => part.length > 0)
|
||||
}
|
||||
|
||||
function indentWidth(line: string): number {
|
||||
let width = 0
|
||||
for (const char of line) {
|
||||
if (char === ' ') width++
|
||||
else if (char === '\t') width += 2
|
||||
else break
|
||||
}
|
||||
return width
|
||||
}
|
||||
|
||||
function isBlank(line: string): boolean {
|
||||
return line.trim().length === 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the YAML block between the leading `---` and its closing delimiter.
|
||||
* Returns null when the document does not open with a frontmatter fence.
|
||||
*/
|
||||
function extractBlock(markdown: string): { yaml: string; body: string } | null {
|
||||
// A BOM ahead of the fence is common in files authored on Windows.
|
||||
const source = markdown.charCodeAt(0) === 0xfeff ? markdown.slice(1) : markdown
|
||||
const lines = source.split('\n')
|
||||
const first = lines[0]
|
||||
if (first === undefined || !/^---[\t ]*\r?$|^---[\t ]*$/.test(first)) return null
|
||||
|
||||
for (let i = 1; i < lines.length; i++) {
|
||||
const line = lines[i]!.replace(/\r$/, '')
|
||||
if (CLOSING_DELIMITER.test(line)) {
|
||||
return {
|
||||
yaml: lines
|
||||
.slice(1, i)
|
||||
.map((l) => l.replace(/\r$/, ''))
|
||||
.join('\n'),
|
||||
body: lines
|
||||
.slice(i + 1)
|
||||
.join('\n')
|
||||
.replace(/^\n+/, ''),
|
||||
}
|
||||
}
|
||||
}
|
||||
// Unterminated fence — treat the whole document as body rather than eating it.
|
||||
return null
|
||||
}
|
||||
|
||||
function parseYamlSubset(yaml: string): Record<string, FrontmatterValue> {
|
||||
const lines = yaml.split('\n')
|
||||
const result: Record<string, FrontmatterValue> = {}
|
||||
let index = 0
|
||||
|
||||
while (index < lines.length) {
|
||||
const line = lines[index]!
|
||||
if (isBlank(line) || line.trimStart().startsWith('#') || indentWidth(line) > 0) {
|
||||
index++
|
||||
continue
|
||||
}
|
||||
|
||||
const match = KEY_LINE.exec(line)
|
||||
if (!match) {
|
||||
index++
|
||||
continue
|
||||
}
|
||||
|
||||
const key = match[1]!
|
||||
const inlineRaw = match[2] ?? ''
|
||||
index++
|
||||
|
||||
const blockScalar = BLOCK_SCALAR.exec(inlineRaw.trim())
|
||||
if (blockScalar) {
|
||||
const collected: string[] = []
|
||||
while (index < lines.length) {
|
||||
const next = lines[index]!
|
||||
if (!isBlank(next) && indentWidth(next) === 0) break
|
||||
collected.push(next)
|
||||
index++
|
||||
}
|
||||
while (collected.length > 0 && isBlank(collected[collected.length - 1]!)) collected.pop()
|
||||
const margin = collected.reduce(
|
||||
(min, l) => (isBlank(l) ? min : Math.min(min, indentWidth(l))),
|
||||
Number.MAX_SAFE_INTEGER,
|
||||
)
|
||||
const dedented = collected.map((l) => (isBlank(l) ? '' : l.slice(margin === Number.MAX_SAFE_INTEGER ? 0 : margin)))
|
||||
// `>` folds lines into a paragraph; `|` keeps them.
|
||||
result[key] = blockScalar[1] === '>' ? dedented.join(' ').replace(/\s+/g, ' ').trim() : dedented.join('\n')
|
||||
continue
|
||||
}
|
||||
|
||||
const inline = stripComment(inlineRaw).trim()
|
||||
if (inline !== '') {
|
||||
if (inline.startsWith('[') && inline.endsWith(']')) {
|
||||
result[key] = splitInlineSequence(inline.slice(1, -1)).map(coerceScalar)
|
||||
} else {
|
||||
result[key] = coerceScalar(inline)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Empty inline value: the payload is either an indented sequence, an
|
||||
// indented mapping, or nothing at all.
|
||||
const childLines: string[] = []
|
||||
while (index < lines.length) {
|
||||
const next = lines[index]!
|
||||
if (!isBlank(next) && indentWidth(next) === 0) break
|
||||
childLines.push(next)
|
||||
index++
|
||||
}
|
||||
while (childLines.length > 0 && isBlank(childLines[childLines.length - 1]!)) childLines.pop()
|
||||
|
||||
const meaningful = childLines.filter((l) => !isBlank(l) && !l.trimStart().startsWith('#'))
|
||||
if (meaningful.length === 0) {
|
||||
result[key] = null
|
||||
continue
|
||||
}
|
||||
|
||||
if (meaningful.every((l) => l.trimStart().startsWith('- '))) {
|
||||
result[key] = meaningful.map((l) => coerceScalar(stripComment(l.trimStart().slice(2))))
|
||||
continue
|
||||
}
|
||||
|
||||
// Nested mapping — keep the raw YAML so nothing is silently dropped.
|
||||
const margin = meaningful.reduce((min, l) => Math.min(min, indentWidth(l)), Number.MAX_SAFE_INTEGER)
|
||||
result[key] = childLines
|
||||
.map((l) => (isBlank(l) ? '' : l.slice(margin)))
|
||||
.join('\n')
|
||||
.trim()
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Split YAML frontmatter off a markdown document.
|
||||
*
|
||||
* Never throws: a malformed or unterminated block yields
|
||||
* `{ frontmatter: null, body: markdown }` so the caller can still render.
|
||||
*/
|
||||
export function splitFrontmatter(markdown: string): SplitMarkdown {
|
||||
if (!markdown) return { frontmatter: null, body: markdown ?? '' }
|
||||
|
||||
const block = extractBlock(markdown)
|
||||
if (!block) return { frontmatter: null, body: markdown }
|
||||
|
||||
let frontmatter: Record<string, FrontmatterValue>
|
||||
try {
|
||||
frontmatter = parseYamlSubset(block.yaml)
|
||||
} catch {
|
||||
return { frontmatter: null, body: markdown }
|
||||
}
|
||||
|
||||
if (Object.keys(frontmatter).length === 0) {
|
||||
// An empty block carries nothing worth showing; just drop it from the body.
|
||||
return { frontmatter: null, body: block.body }
|
||||
}
|
||||
|
||||
return { frontmatter, body: block.body }
|
||||
}
|
||||
|
||||
/** Keys already shown in the detail header — repeating them is noise. */
|
||||
const HEADER_KEYS = new Set(['name', 'displayname', 'display_name', 'description', 'version', 'title'])
|
||||
|
||||
export type FrontmatterEntry = {
|
||||
key: string
|
||||
value: FrontmatterValue
|
||||
/** Long text or multi-line values render stacked instead of inline. */
|
||||
block: boolean
|
||||
}
|
||||
|
||||
/** Order the well-known operational fields first, then everything else A→Z. */
|
||||
const KEY_PRIORITY = [
|
||||
'when_to_use',
|
||||
'when-to-use',
|
||||
'argument-hint',
|
||||
'allowed-tools',
|
||||
'model',
|
||||
'effort',
|
||||
'context',
|
||||
'agent',
|
||||
'paths',
|
||||
'user-invocable',
|
||||
'license',
|
||||
'author',
|
||||
'homepage',
|
||||
'repository',
|
||||
'tags',
|
||||
'keywords',
|
||||
'category',
|
||||
]
|
||||
|
||||
const INLINE_VALUE_MAX = 48
|
||||
|
||||
function isEmptyValue(value: FrontmatterValue): boolean {
|
||||
if (value === null || value === undefined) return true
|
||||
if (typeof value === 'string') return value.trim().length === 0
|
||||
if (Array.isArray(value)) return value.length === 0
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Coerce an arbitrary parsed-YAML value into something the panel can render.
|
||||
* Nested structures become pretty-printed JSON rather than `[object Object]`.
|
||||
*/
|
||||
function normalizeValue(value: unknown): FrontmatterValue {
|
||||
if (value === null || value === undefined) return null
|
||||
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') return value
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((item) =>
|
||||
item === null || typeof item === 'string' || typeof item === 'number' || typeof item === 'boolean'
|
||||
? (item as FrontmatterScalar)
|
||||
: safeStringify(item),
|
||||
)
|
||||
}
|
||||
return safeStringify(value)
|
||||
}
|
||||
|
||||
function safeStringify(value: unknown): string {
|
||||
try {
|
||||
return JSON.stringify(value, null, 2) ?? String(value)
|
||||
} catch {
|
||||
return String(value)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn parsed frontmatter into display entries: drop what the header already
|
||||
* shows, drop empties, sort by usefulness, and flag values that need their own
|
||||
* row instead of sitting next to the label.
|
||||
*/
|
||||
export function toFrontmatterEntries(
|
||||
frontmatter: SkillFrontmatter | null | undefined,
|
||||
options: { skipKeys?: Iterable<string> } = {},
|
||||
): FrontmatterEntry[] {
|
||||
if (!frontmatter) return []
|
||||
|
||||
const skip = new Set(HEADER_KEYS)
|
||||
for (const key of options.skipKeys ?? []) skip.add(key.toLowerCase())
|
||||
|
||||
return Object.entries(frontmatter)
|
||||
.map(([key, value]) => [key, normalizeValue(value)] as const)
|
||||
.filter(([key, value]) => !skip.has(key.toLowerCase()) && !isEmptyValue(value))
|
||||
.sort(([a], [b]) => {
|
||||
const aIndex = KEY_PRIORITY.indexOf(a.toLowerCase())
|
||||
const bIndex = KEY_PRIORITY.indexOf(b.toLowerCase())
|
||||
const normalizedA = aIndex === -1 ? Number.MAX_SAFE_INTEGER : aIndex
|
||||
const normalizedB = bIndex === -1 ? Number.MAX_SAFE_INTEGER : bIndex
|
||||
return normalizedA - normalizedB || a.localeCompare(b)
|
||||
})
|
||||
.map(([key, value]) => ({
|
||||
key,
|
||||
value,
|
||||
block: typeof value === 'string' && (value.includes('\n') || value.length > INLINE_VALUE_MAX),
|
||||
}))
|
||||
}
|
||||
Reference in New Issue
Block a user