Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

/**
* Utility for parsing and generating Markdown files with YAML frontmatter.
Expand Down Expand Up @@ -54,6 +56,8 @@
*/
public class MarkdownSkillParser {

private static final Logger logger = LoggerFactory.getLogger(MarkdownSkillParser.class);

/**
* Private constructor to prevent instantiation.
*/
Expand Down Expand Up @@ -81,7 +85,6 @@ private MarkdownSkillParser() {}
*
* @param markdown Markdown content (may or may not have frontmatter)
* @return ParsedMarkdown containing metadata and content
* @throws IllegalArgumentException if YAML syntax is invalid
*/
public static ParsedMarkdown parse(String markdown) {
if (markdown == null || markdown.isEmpty()) {
Expand All @@ -102,14 +105,8 @@ public static ParsedMarkdown parse(String markdown) {
return new ParsedMarkdown(Map.of(), markdownContent);
}

try {
Map<String, String> metadata = SimpleYamlParser.parse(yamlContent);
return new ParsedMarkdown(metadata, markdownContent);
} catch (IllegalArgumentException e) {
throw e;
} catch (RuntimeException e) {
throw new IllegalArgumentException("Invalid YAML frontmatter syntax", e);
}
Map<String, String> metadata = SimpleYamlParser.parse(yamlContent);
return new ParsedMarkdown(metadata, markdownContent);
}

/**
Expand Down Expand Up @@ -158,9 +155,14 @@ private static class SimpleYamlParser {
/**
* Parse YAML string into a map of key-value pairs.
*
* <p>This is a simplified parser designed for flat string-to-string mappings.
* Block-style complex YAML structures (such as multi-line lists or indented
* nested objects) are not supported and will be gracefully skipped.
* However, flow-style inline structures (e.g., single-line JSON strings)
* are treated as standard scalar values and will be parsed as raw strings.
*
* @param yaml YAML content to parse
* @return Map of key-value pairs
* @throws IllegalArgumentException if YAML syntax is invalid
*/
static Map<String, String> parse(String yaml) {
Map<String, String> result = new LinkedHashMap<>();
Expand All @@ -184,19 +186,44 @@ static Map<String, String> parse(String yaml) {

Matcher matcher = KEY_VALUE_PATTERN.matcher(line.trim());
if (!matcher.matches()) {
throw new IllegalArgumentException(
"Invalid YAML line (expected 'key: value' format): " + line);
logger.debug(
"Skipping unsupported YAML line (expected 'key: value' format): {}",
line);
continue;
}

String key = matcher.group(1);
String value = parseValue(matcher.group(2));
String rawValue = matcher.group(2);

result.put(key, value);
if (isBlockScalarModifier(rawValue)) {
logger.debug(
"Skipping key '{}': block-style values ('{}') are unsupported",
key,
rawValue.trim());
continue;
}

result.put(key, parseValue(rawValue));
}

return result;
}

/**
* Check if the raw value is a YAML block scalar modifier ('|' or '>').
*
* @param rawValue The raw string captured after the colon
* @return true if it is a block scalar modifier
*/
private static boolean isBlockScalarModifier(String rawValue) {
if (rawValue == null) {
return false;
}

String trimmed = rawValue.trim();
return "|".equals(trimmed) || ">".equals(trimmed);
}

/**
* Parse a YAML value, handling quoted strings.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;

import io.agentscope.core.skill.util.MarkdownSkillParser.ParsedMarkdown;
Expand Down Expand Up @@ -283,28 +282,103 @@ void testParseUnicodeCharacters() {
class ErrorHandlingTests {

@Test
@DisplayName("Should throw exception for invalid YAML")
@DisplayName("Should gracefully ignore invalid YAML lines instead of throwing exception")
void testInvalidYaml() {
String markdown = "---\nname: test\nthis is not a valid line\n---\nContent";

IllegalArgumentException exception =
assertThrows(
IllegalArgumentException.class,
() -> MarkdownSkillParser.parse(markdown));
assertTrue(exception.getMessage().contains("Invalid YAML line"));
assertTrue(exception.getMessage().contains("expected 'key: value' format"));
MarkdownSkillParser.ParsedMarkdown parsed = MarkdownSkillParser.parse(markdown);
Map<String, String> metadata = parsed.getMetadata();

assertEquals("test", metadata.get("name"));
assertFalse(metadata.containsKey("this is not a valid line"));
assertEquals("Content", parsed.getContent());
}

@Test
@DisplayName("Should throw exception for list format")
@DisplayName("Should gracefully ignore list format instead of throwing exception")
void testListFormat() {
String markdown = "---\n- item1\n- item2\n---\nContent";
String markdown = "---\nname: test_skill\n- item1\n- item2\n---\nContent";

MarkdownSkillParser.ParsedMarkdown parsed = MarkdownSkillParser.parse(markdown);
Map<String, String> metadata = parsed.getMetadata();

assertEquals("test_skill", metadata.get("name"));
assertFalse(metadata.containsKey("- item1"));
assertFalse(metadata.containsKey("- item2"));
}

@Test
@DisplayName(
"Should parse basic scalars and gracefully ignore complex YAML structures like"
+ " lists or JSON")
void testParseAndIgnoreComplexMetadata() {
String markdown =
"""
---
name: Agent Browser
description: A fast Rust-based headless browser automation CLI
read_when:
- Automating web interactions
- Extracting structured data from pages
metadata: {"clawdbot":{"emoji":"🌐"}}
allowed-tools: Bash(agent-browser:*)
---

# Content
This is the content.\
""";

MarkdownSkillParser.ParsedMarkdown parsed = MarkdownSkillParser.parse(markdown);
Map<String, String> metadata = parsed.getMetadata();

assertEquals("Agent Browser", metadata.get("name"));
assertEquals(
"A fast Rust-based headless browser automation CLI",
metadata.get("description"));
assertEquals("Bash(agent-browser:*)", metadata.get("allowed-tools"));

assertEquals("{\"clawdbot\":{\"emoji\":\"🌐\"}}", metadata.get("metadata"));

assertEquals("", metadata.get("read_when"));
assertNull(metadata.get("- Automating web interactions"));

assertTrue(parsed.getContent().contains("# Content"));
}

@Test
@DisplayName(
"Should gracefully skip keys with block-style modifiers (| or >) instead of"
+ " recording them as literal values")
void testSkipBlockStyleModifiers() {
String markdown =
"""
---
name: test_skill
description: |
This is a multi-line description.
It should be ignored by the simple parser.
summary: >
This is a folded multi-line summary.
It should also be ignored.
version: "1.0"
---
Content\
""";

MarkdownSkillParser.ParsedMarkdown parsed = MarkdownSkillParser.parse(markdown);
Map<String, String> metadata = parsed.getMetadata();

assertEquals("test_skill", metadata.get("name"));
assertEquals("1.0", metadata.get("version"));

assertNull(
metadata.get("description"),
"Block scalar modifier '|' should not be parsed as a literal value");
assertNull(
metadata.get("summary"),
"Block scalar modifier '>' should not be parsed as a literal value");

IllegalArgumentException exception =
assertThrows(
IllegalArgumentException.class,
() -> MarkdownSkillParser.parse(markdown));
assertTrue(exception.getMessage().contains("Invalid YAML line"));
assertFalse(metadata.containsKey(" This is a multi-line description."));
}
}

Expand Down
Loading