mirror of
https://github.com/coder/coder.git
synced 2026-06-02 20:48:20 +00:00
fix: parse skill frontmatter as YAML (#25610)
This commit is contained in:
@@ -5,6 +5,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"golang.org/x/xerrors"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// SkillNameRegex is the regular expression used to validate kebab-case skill names.
|
||||
@@ -24,32 +25,21 @@ var markdownCommentRe = regexp.MustCompile(`<!--[\s\S]*?-->`)
|
||||
// the frontmatter is missing a required name field.
|
||||
var ErrFrontmatterNameRequired = xerrors.New("frontmatter missing required 'name' field")
|
||||
|
||||
func unquoteFrontmatterScalar(value string) string {
|
||||
if len(value) < 2 {
|
||||
return value
|
||||
func frontmatterStringField(frontmatter map[string]any, key string) (string, bool, error) {
|
||||
value, ok := frontmatter[key]
|
||||
if !ok {
|
||||
return "", false, nil
|
||||
}
|
||||
|
||||
quote := value[0]
|
||||
if quote != value[len(value)-1] {
|
||||
return value
|
||||
}
|
||||
|
||||
inner := value[1 : len(value)-1]
|
||||
switch quote {
|
||||
case '"':
|
||||
// This parser supports a small SKILL.md scalar subset, not full
|
||||
// YAML. Double quotes only unescape quoted text and Windows paths.
|
||||
return strings.NewReplacer(`\"`, `"`, `\\`, `\`).Replace(inner)
|
||||
case '\'':
|
||||
return inner
|
||||
default:
|
||||
return value
|
||||
stringValue, ok := value.(string)
|
||||
if !ok {
|
||||
return "", true, xerrors.Errorf("frontmatter field %q must be a string", key)
|
||||
}
|
||||
return strings.TrimRight(stringValue, "\r\n"), true, nil
|
||||
}
|
||||
|
||||
// ParseSkillFrontmatter extracts name, description, and the
|
||||
// remaining body from a skill meta file. The expected format is
|
||||
// YAML-ish frontmatter delimited by "---" lines:
|
||||
// YAML frontmatter delimited by "---" lines:
|
||||
//
|
||||
// ---
|
||||
// name: my-skill
|
||||
@@ -78,25 +68,23 @@ func ParseSkillFrontmatter(content string) (name, description, body string, err
|
||||
)
|
||||
}
|
||||
|
||||
for _, line := range lines[1:closingIdx] {
|
||||
key, value, ok := strings.Cut(line, ":")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
key = strings.TrimSpace(key)
|
||||
value = strings.TrimSpace(value)
|
||||
value = unquoteFrontmatterScalar(value)
|
||||
switch strings.ToLower(key) {
|
||||
case "name":
|
||||
name = value
|
||||
case "description":
|
||||
description = value
|
||||
}
|
||||
frontmatterContent := strings.Join(lines[1:closingIdx], "\n")
|
||||
var frontmatter map[string]any
|
||||
if err := yaml.Unmarshal([]byte(frontmatterContent), &frontmatter); err != nil {
|
||||
return "", "", "", xerrors.Errorf("parse frontmatter YAML: %w", err)
|
||||
}
|
||||
|
||||
if name == "" {
|
||||
name, ok, err := frontmatterStringField(frontmatter, "name")
|
||||
if err != nil {
|
||||
return "", "", "", xerrors.Errorf("%w: %v", ErrFrontmatterNameRequired, err)
|
||||
}
|
||||
if !ok || name == "" {
|
||||
return "", "", "", xerrors.Errorf("%w", ErrFrontmatterNameRequired)
|
||||
}
|
||||
description, _, err = frontmatterStringField(frontmatter, "description")
|
||||
if err != nil {
|
||||
return "", "", "", err
|
||||
}
|
||||
|
||||
// Everything after the closing delimiter is the body.
|
||||
body = strings.Join(lines[closingIdx+1:], "\n")
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package workspacesdk_test
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
@@ -41,13 +42,48 @@ func TestParseSkillFrontmatter(t *testing.T) {
|
||||
require.Equal(t, "Review \"critical\" C:\\paths.", desc)
|
||||
})
|
||||
|
||||
t.Run("PlainHashValue", func(t *testing.T) {
|
||||
t.Run("FoldedDescription", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
name, desc, body, err := workspacesdk.ParseSkillFrontmatter(
|
||||
strings.Join([]string{
|
||||
"---",
|
||||
"name: brainstorming",
|
||||
"description: >",
|
||||
" Use before any creative work: features, components, functionality changes,",
|
||||
" or behavior modifications. Turns ideas into approved designs through",
|
||||
" collaborative dialog. Hard gate: no implementation action until the",
|
||||
" design is presented and approved.",
|
||||
"",
|
||||
"---",
|
||||
"Use this skill.",
|
||||
}, "\n"),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "brainstorming", name)
|
||||
require.Equal(t, strings.Join([]string{
|
||||
"Use before any creative work: features, components, functionality changes,",
|
||||
"or behavior modifications. Turns ideas into approved designs through",
|
||||
"collaborative dialog. Hard gate: no implementation action until the",
|
||||
"design is presented and approved.",
|
||||
}, " "), desc)
|
||||
require.Equal(t, "Use this skill.", body)
|
||||
})
|
||||
|
||||
t.Run("YAMLComments", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, desc, _, err := workspacesdk.ParseSkillFrontmatter(
|
||||
"---\nname: plain-hash\ndescription: Build # test\n---\nBody\n",
|
||||
)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "Build # test", desc)
|
||||
require.Equal(t, "Build", desc)
|
||||
})
|
||||
|
||||
t.Run("ErrorNullDescription", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, _, _, err := workspacesdk.ParseSkillFrontmatter(
|
||||
"---\nname: null-description\ndescription: null\n---\nBody\n",
|
||||
)
|
||||
require.ErrorContains(t, err, `frontmatter field "description" must be a string`)
|
||||
})
|
||||
|
||||
t.Run("NoDescription", func(t *testing.T) {
|
||||
@@ -99,14 +135,12 @@ func TestParseSkillFrontmatter(t *testing.T) {
|
||||
require.Empty(t, body)
|
||||
})
|
||||
|
||||
t.Run("CaseInsensitiveKeys", func(t *testing.T) {
|
||||
t.Run("YAMLKeysAreCaseSensitive", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
name, desc, _, err := workspacesdk.ParseSkillFrontmatter(
|
||||
_, _, _, err := workspacesdk.ParseSkillFrontmatter(
|
||||
"---\nName: upper\nDescription: Also upper\n---\n",
|
||||
)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "upper", name)
|
||||
require.Equal(t, "Also upper", desc)
|
||||
require.ErrorIs(t, err, workspacesdk.ErrFrontmatterNameRequired)
|
||||
})
|
||||
|
||||
t.Run("UnknownKeysIgnored", func(t *testing.T) {
|
||||
@@ -139,6 +173,15 @@ func TestParseSkillFrontmatter(t *testing.T) {
|
||||
require.ErrorContains(t, err, "frontmatter missing required 'name' field")
|
||||
})
|
||||
|
||||
t.Run("ErrorNullName", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, _, _, err := workspacesdk.ParseSkillFrontmatter(
|
||||
"---\nname: null\n---\nBody\n",
|
||||
)
|
||||
require.ErrorIs(t, err, workspacesdk.ErrFrontmatterNameRequired)
|
||||
require.ErrorContains(t, err, `frontmatter field "name" must be a string`)
|
||||
})
|
||||
|
||||
t.Run("WhitespaceAroundDelimiters", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
name, _, _, err := workspacesdk.ParseSkillFrontmatter(
|
||||
|
||||
Reference in New Issue
Block a user