mirror of
https://github.com/NanmiCoder/claude-code-haha.git
synced 2026-10-10 11:53:10 +08:00
f0d740cef2
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.
157 lines
5.7 KiB
TypeScript
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([])
|
|
})
|
|
})
|