Files
claude-code-haha/desktop/src/lib/skillFrontmatter.test.ts
T
程序员阿江(Relakkes) f0d740cef2 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.
2026-07-26 02:12:29 +08:00

157 lines
5.7 KiB
TypeScript

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([])
})
})