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:
程序员阿江(Relakkes)
2026-07-26 02:11:50 +08:00
parent 0dac038249
commit f0d740cef2
15 changed files with 1040 additions and 70 deletions
@@ -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 () => {
+28 -8
View File
@@ -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 -47
View File
@@ -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,
+3
View File
@@ -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',
+3
View 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': 'ファイルの読み込みに失敗しました',
+3
View File
@@ -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': '파일을 불러오지 못했습니다',
+3
View File
@@ -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': '檔案載入失敗',
+3
View File
@@ -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': '文件加载失败',
+156
View File
@@ -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([])
})
})
+371
View File
@@ -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),
}))
}