createSchemalessDocumentsFromXml(String xml) thro
return xmlDocumentCreator.create(xml);
}
+
+ /**
+ * Creates a list of schema-less SolrInputDocument objects from a markdown
+ * string.
+ *
+ *
+ * This method delegates markdown processing to the MarkdownDocumentCreator
+ * utility class.
+ *
+ * @param markdown
+ * markdown string containing document content, optionally starting
+ * with YAML front matter
+ * @return list of SolrInputDocument objects ready for indexing
+ * @throws DocumentProcessingException
+ * if markdown parsing fails or input validation fails
+ * @see MarkdownDocumentCreator
+ */
+ public List createSchemalessDocumentsFromMarkdown(String markdown)
+ throws DocumentProcessingException {
+
+ // Input validation
+ if (markdown == null || markdown.trim().isEmpty()) {
+ throw new DocumentProcessingException("Markdown input cannot be null or empty");
+ }
+
+ return markdownDocumentCreator.create(markdown);
+ }
}
diff --git a/src/main/java/org/apache/solr/mcp/server/indexing/documentcreator/MarkdownDocumentCreator.java b/src/main/java/org/apache/solr/mcp/server/indexing/documentcreator/MarkdownDocumentCreator.java
new file mode 100644
index 00000000..db560202
--- /dev/null
+++ b/src/main/java/org/apache/solr/mcp/server/indexing/documentcreator/MarkdownDocumentCreator.java
@@ -0,0 +1,281 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.solr.mcp.server.indexing.documentcreator;
+
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.util.ArrayList;
+import java.util.HexFormat;
+import java.util.List;
+import org.apache.solr.common.SolrInputDocument;
+import org.commonmark.Extension;
+import org.commonmark.ext.front.matter.YamlFrontMatterExtension;
+import org.commonmark.ext.front.matter.YamlFrontMatterVisitor;
+import org.commonmark.node.AbstractVisitor;
+import org.commonmark.node.Code;
+import org.commonmark.node.CustomBlock;
+import org.commonmark.node.Heading;
+import org.commonmark.node.Node;
+import org.commonmark.node.Text;
+import org.commonmark.parser.Parser;
+import org.commonmark.renderer.text.TextContentRenderer;
+import org.jspecify.annotations.Nullable;
+import org.springframework.stereotype.Component;
+
+/**
+ * Utility class for processing markdown documents and converting them to
+ * SolrInputDocument objects.
+ *
+ *
+ * Unlike the structured formats (JSON, CSV, XML), markdown is a prose format,
+ * so this creator extracts searchable structure from the document rather than
+ * mapping fields one-to-one. Parsing is performed with the CommonMark library,
+ * which is lightweight and reflection-free (GraalVM native-image safe).
+ *
+ *
+ * Field Extraction Rules:
+ *
+ *
+ * - YAML Front Matter: Each front matter entry becomes a
+ * document field with a sanitized name. Entries with multiple values become
+ * multi-valued fields.
+ *
- id: Taken from the {@code id} front matter entry when
+ * present, otherwise derived deterministically from a SHA-256 hash of the input
+ * so that re-indexing the same markdown overwrites the same document (keeping
+ * the operation idempotent).
+ *
- title: Taken from the {@code title} front matter entry
+ * when present, otherwise from the first level-1 heading.
+ *
- headings: Multi-valued field containing the text of
+ * every heading, preserving the document outline for searching.
+ *
- content: The plain text of the document body (front
+ * matter excluded), suitable for full-text search.
+ *
+ *
+ *
+ * Example Transformation:
+ *
+ *
{@code
+ * Input markdown:
+ * ---
+ * author: Jane Doe
+ * tags: [search, solr]
+ * ---
+ * # Getting Started
+ * ## Installation
+ * Run the installer.
+ *
+ * Output document:
+ * {author:"Jane Doe", tags:["search","solr"], title:"Getting Started",
+ * headings:["Getting Started","Installation"], content:"Getting Started\nInstallation\nRun the installer."}
+ * }
+ *
+ * @see SolrInputDocument
+ * @see FieldNameSanitizer#sanitizeFieldName(String)
+ */
+@Component
+public class MarkdownDocumentCreator implements SolrDocumentCreator {
+
+ private static final int MAX_INPUT_SIZE_BYTES = 10 * 1024 * 1024;
+
+ /** Solr field holding the document's unique key. */
+ public static final String FIELD_ID = "id";
+
+ /** Solr field holding the document title. */
+ public static final String FIELD_TITLE = "title";
+
+ /** Multi-valued Solr field holding the text of every heading. */
+ public static final String FIELD_HEADINGS = "headings";
+
+ /** Solr field holding the plain text body of the document. */
+ public static final String FIELD_CONTENT = "content";
+
+ private final Parser parser;
+
+ private final TextContentRenderer textContentRenderer;
+
+ public MarkdownDocumentCreator() {
+ List extensions = List.of(YamlFrontMatterExtension.create());
+ this.parser = Parser.builder().extensions(extensions).build();
+ this.textContentRenderer = TextContentRenderer.builder().build();
+ }
+
+ /**
+ * Creates a SolrInputDocument from a markdown string.
+ *
+ *
+ * The whole input is treated as a single document: front matter entries map to
+ * fields, the title is resolved from front matter or the first level-1 heading,
+ * all heading texts are collected into a multi-valued {@code headings} field,
+ * and the plain text body is stored in {@code content}.
+ *
+ * @param markdown
+ * markdown string, optionally starting with YAML front matter
+ * @return a single-element list containing the created document, or an empty
+ * list if the input is blank
+ * @throws DocumentProcessingException
+ * if the input exceeds the size limit or parsing fails
+ */
+ @Override
+ public List create(String markdown) throws DocumentProcessingException {
+ if (markdown.getBytes(StandardCharsets.UTF_8).length > MAX_INPUT_SIZE_BYTES) {
+ throw new DocumentProcessingException(
+ "Input too large: exceeds maximum size of " + MAX_INPUT_SIZE_BYTES + " bytes");
+ }
+
+ if (markdown.trim().isEmpty()) {
+ return List.of();
+ }
+
+ Node document;
+ try {
+ document = parser.parse(markdown);
+ } catch (RuntimeException e) {
+ throw new DocumentProcessingException("Failed to parse markdown document", e);
+ }
+
+ SolrInputDocument doc = new SolrInputDocument();
+
+ addFrontMatterFields(document, doc);
+
+ // Solr's default schema requires a unique key. A content-derived id keeps
+ // re-indexing of the same markdown idempotent (same input, same document)
+ if (doc.getFieldValue(FIELD_ID) == null) {
+ doc.addField(FIELD_ID, contentHash(markdown));
+ }
+
+ HeadingCollector headingCollector = new HeadingCollector();
+ document.accept(headingCollector);
+ for (String heading : headingCollector.headings) {
+ doc.addField(FIELD_HEADINGS, heading);
+ }
+
+ // Front matter title wins; otherwise fall back to the first level-1 heading
+ if (doc.getFieldValue(FIELD_TITLE) == null && headingCollector.firstTopLevelHeading != null) {
+ doc.addField(FIELD_TITLE, headingCollector.firstTopLevelHeading);
+ }
+
+ String content = textContentRenderer.render(document).trim();
+ if (!content.isEmpty()) {
+ doc.addField(FIELD_CONTENT, content);
+ }
+
+ return List.of(doc);
+ }
+
+ /**
+ * Extracts YAML front matter entries into document fields and unlinks the front
+ * matter block so it is excluded from the rendered body content.
+ */
+ private void addFrontMatterFields(Node document, SolrInputDocument doc) {
+ YamlFrontMatterVisitor frontMatterVisitor = new YamlFrontMatterVisitor();
+ document.accept(frontMatterVisitor);
+
+ frontMatterVisitor.getData().forEach((key, values) -> {
+ String fieldName = FieldNameSanitizer.sanitizeFieldName(key);
+ for (String value : flattenFlowSequences(values)) {
+ if (!value.isEmpty()) {
+ doc.addField(fieldName, value);
+ }
+ }
+ });
+
+ // The front matter block is metadata, not body text: remove it so the
+ // TextContentRenderer output contains only the document body
+ Node firstChild = document.getFirstChild();
+ if (firstChild instanceof CustomBlock) {
+ firstChild.unlink();
+ }
+ }
+
+ /**
+ * Expands simple YAML flow sequences into individual values.
+ *
+ *
+ * The CommonMark front matter extension parses block-style lists
+ * ({@code - item}) into multiple values but passes flow-style lists
+ * ({@code [a, b, c]}) through as a single literal string. Flow style is common
+ * for tags in real-world markdown (Jekyll, Hugo), so split it here to produce
+ * the same multi-valued field either way. Values containing commas inside
+ * quotes are not supported and are kept as-is.
+ */
+ private static List flattenFlowSequences(List values) {
+ List result = new ArrayList<>(values.size());
+ for (String value : values) {
+ String trimmed = value.trim();
+ if (trimmed.length() >= 2 && trimmed.startsWith("[") && trimmed.endsWith("]") && !trimmed.contains("\"")
+ && !trimmed.contains("'")) {
+ for (String element : trimmed.substring(1, trimmed.length() - 1).split(",")) {
+ result.add(element.trim());
+ }
+ } else {
+ result.add(value);
+ }
+ }
+ return result;
+ }
+
+ private static String contentHash(String markdown) {
+ try {
+ MessageDigest digest = MessageDigest.getInstance("SHA-256");
+ return HexFormat.of().formatHex(digest.digest(markdown.getBytes(StandardCharsets.UTF_8)));
+ } catch (NoSuchAlgorithmException e) {
+ // SHA-256 is guaranteed to be available on every Java platform
+ throw new IllegalStateException("SHA-256 MessageDigest not available", e);
+ }
+ }
+
+ /**
+ * AST visitor collecting heading texts and the first level-1 heading for use as
+ * a title fallback.
+ */
+ private static final class HeadingCollector extends AbstractVisitor {
+
+ private final List headings = new ArrayList<>();
+
+ @Nullable private String firstTopLevelHeading;
+
+ @Override
+ public void visit(Heading heading) {
+ String text = collectText(heading).trim();
+ if (!text.isEmpty()) {
+ headings.add(text);
+ if (firstTopLevelHeading == null && heading.getLevel() == 1) {
+ firstTopLevelHeading = text;
+ }
+ }
+ visitChildren(heading);
+ }
+
+ private static String collectText(Node node) {
+ StringBuilder builder = new StringBuilder();
+ appendText(node, builder);
+ return builder.toString();
+ }
+
+ private static void appendText(Node node, StringBuilder builder) {
+ if (node instanceof Text text) {
+ builder.append(text.getLiteral());
+ } else if (node instanceof Code code) {
+ builder.append(code.getLiteral());
+ }
+ for (Node child = node.getFirstChild(); child != null; child = child.getNext()) {
+ appendText(child, builder);
+ }
+ }
+ }
+}
diff --git a/src/test/java/org/apache/solr/mcp/server/McpClientIntegrationTestBase.java b/src/test/java/org/apache/solr/mcp/server/McpClientIntegrationTestBase.java
index 2f617ec1..56d5e909 100644
--- a/src/test/java/org/apache/solr/mcp/server/McpClientIntegrationTestBase.java
+++ b/src/test/java/org/apache/solr/mcp/server/McpClientIntegrationTestBase.java
@@ -104,6 +104,7 @@ void listToolsReturnsExpectedTools() {
assertTrue(toolNames.contains("create-collection"), "Should have create-collection tool");
assertTrue(toolNames.contains("index-json-documents"), "Should have index-json-documents tool");
+ assertTrue(toolNames.contains("index-markdown-documents"), "Should have index-markdown-documents tool");
assertTrue(toolNames.contains("search"), "Should have search tool");
assertTrue(toolNames.contains("list-collections"), "Should have list-collections tool");
assertTrue(toolNames.contains("check-health"), "Should have check-health tool");
@@ -135,6 +136,7 @@ void toolsExposeBehaviorHints() {
assertHint(tools, "index-json-documents", false, true, true);
assertHint(tools, "index-csv-documents", false, true, true);
assertHint(tools, "index-xml-documents", false, true, true);
+ assertHint(tools, "index-markdown-documents", false, true, true);
}
private static void assertReadOnly(Map tools, String name) {
@@ -398,6 +400,38 @@ void searchWithNewFieldFilters() throws Exception {
assertEquals(1, getNumFound(r2), "Multi-valued 'genres' should match on 'crime'");
}
+ // Self-contained markdown round-trip: independent of the other order-18 test
+ // (the markdown document carries none of the fields its filter queries touch).
+ @Test
+ @Order(18)
+ void indexMarkdownDocumentAndFindItById() throws Exception {
+ String markdown = """
+ ---
+ id: md-doc-1
+ author: Markdown Author
+ ---
+ # Markdown Indexing Guide
+
+ ## Installation
+
+ Index markdown documents through the MCP server.
+ """;
+
+ CallToolResult indexResult = mcpClient.callTool(new CallToolRequest("index-markdown-documents",
+ Map.of("collection", COLLECTION, "markdown", markdown)));
+
+ assertNotNull(indexResult);
+ assertNotError(indexResult);
+ assertTrue(extractText(indexResult).contains("Successfully indexed 1"),
+ "Markdown indexing should report one indexed document: " + extractText(indexResult));
+
+ CallToolResult searchResult = mcpClient
+ .callTool(new CallToolRequest("search", Map.of("collection", COLLECTION, "query", "id:md-doc-1")));
+ Map response = OBJECT_MAPPER.readValue(extractText(searchResult), new TypeReference<>() {
+ });
+ assertEquals(1, getNumFound(response), "Should find the markdown document by its front matter id");
+ }
+
// ===== End-to-end shows workflow (orders 19–27) =====
// Exercises the canonical "set up a new collection with a defined schema, index
// real data, then search and introspect" workflow purely through MCP tool
diff --git a/src/test/java/org/apache/solr/mcp/server/indexing/IndexingServiceIntegrationTest.java b/src/test/java/org/apache/solr/mcp/server/indexing/IndexingServiceIntegrationTest.java
index 933e668a..725848da 100644
--- a/src/test/java/org/apache/solr/mcp/server/indexing/IndexingServiceIntegrationTest.java
+++ b/src/test/java/org/apache/solr/mcp/server/indexing/IndexingServiceIntegrationTest.java
@@ -27,6 +27,7 @@
import org.apache.solr.mcp.server.indexing.documentcreator.CsvDocumentCreator;
import org.apache.solr.mcp.server.indexing.documentcreator.IndexingDocumentCreator;
import org.apache.solr.mcp.server.indexing.documentcreator.JsonDocumentCreator;
+import org.apache.solr.mcp.server.indexing.documentcreator.MarkdownDocumentCreator;
import org.apache.solr.mcp.server.indexing.documentcreator.XmlDocumentCreator;
import org.apache.solr.mcp.server.search.SearchResponse;
import org.apache.solr.mcp.server.search.SearchService;
@@ -72,9 +73,10 @@ void setUp() throws Exception {
CsvDocumentCreator csvDocumentCreator = new CsvDocumentCreator();
JsonDocumentCreator jsonDocumentCreator = new JsonDocumentCreator(
new com.fasterxml.jackson.databind.ObjectMapper());
+ MarkdownDocumentCreator markdownDocumentCreator = new MarkdownDocumentCreator();
indexingDocumentCreator = new IndexingDocumentCreator(xmlDocumentCreator, csvDocumentCreator,
- jsonDocumentCreator);
+ jsonDocumentCreator, markdownDocumentCreator);
indexingService = new IndexingService(solrClient, indexingDocumentCreator);
searchService = new SearchService(solrClient);
diff --git a/src/test/java/org/apache/solr/mcp/server/indexing/IndexingServiceTest.java b/src/test/java/org/apache/solr/mcp/server/indexing/IndexingServiceTest.java
index 88c749ed..b1491d16 100644
--- a/src/test/java/org/apache/solr/mcp/server/indexing/IndexingServiceTest.java
+++ b/src/test/java/org/apache/solr/mcp/server/indexing/IndexingServiceTest.java
@@ -354,6 +354,22 @@ void indexDataPrompt_xmlPath_referencesIndexXmlDocuments() {
assertTrue(body.contains("index-xml-documents"), "XML path should reference index-xml-documents tool");
}
+ @Test
+ void indexDataPrompt_markdownPath_referencesIndexMarkdownDocuments() {
+ String body = indexingService.indexDataPrompt("library", "markdown", null);
+
+ assertTrue(body.contains("index-markdown-documents"),
+ "Markdown path should reference index-markdown-documents tool");
+ }
+
+ @Test
+ void indexDataPrompt_mdAliasResolvesToMarkdownTool() {
+ String body = indexingService.indexDataPrompt("library", "md", null);
+
+ assertTrue(body.contains("index-markdown-documents"),
+ "'md' alias should reference index-markdown-documents tool");
+ }
+
@Test
void indexDataPrompt_unknownFormat_throwsIllegalArgumentException() {
IllegalArgumentException ex = assertThrows(IllegalArgumentException.class,
diff --git a/src/test/java/org/apache/solr/mcp/server/indexing/MarkdownIndexingTest.java b/src/test/java/org/apache/solr/mcp/server/indexing/MarkdownIndexingTest.java
new file mode 100644
index 00000000..2ec3a7c5
--- /dev/null
+++ b/src/test/java/org/apache/solr/mcp/server/indexing/MarkdownIndexingTest.java
@@ -0,0 +1,287 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.solr.mcp.server.indexing;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+import java.util.List;
+import org.apache.solr.common.SolrInputDocument;
+import org.apache.solr.mcp.server.indexing.documentcreator.DocumentProcessingException;
+import org.apache.solr.mcp.server.indexing.documentcreator.IndexingDocumentCreator;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.test.context.TestPropertySource;
+
+/**
+ * Test class for markdown indexing functionality in IndexingService.
+ *
+ *
+ * This test verifies that the IndexingService can correctly parse markdown
+ * content — including YAML front matter, headings, and body text — and convert
+ * it into SolrInputDocument objects using the schema-less approach.
+ */
+@SpringBootTest
+@TestPropertySource(locations = "classpath:application.properties")
+class MarkdownIndexingTest {
+
+ @Autowired
+ private IndexingDocumentCreator indexingDocumentCreator;
+
+ @Test
+ void testCreateSchemalessDocumentsFromMarkdownWithFrontMatter() throws Exception {
+ // Given
+ String markdown = """
+ ---
+ author: George R.R. Martin
+ genre: fantasy
+ published: 1996
+ ---
+ # A Game of Thrones
+
+ ## Synopsis
+
+ The first book in A Song of Ice and Fire.
+
+ ## Reception
+
+ Widely acclaimed.
+ """;
+
+ // When
+ List documents = indexingDocumentCreator.createSchemalessDocumentsFromMarkdown(markdown);
+
+ // Then
+ assertThat(documents).hasSize(1);
+
+ SolrInputDocument doc = documents.getFirst();
+ assertThat(doc.getFieldValue("author")).isEqualTo("George R.R. Martin");
+ assertThat(doc.getFieldValue("genre")).isEqualTo("fantasy");
+ assertThat(doc.getFieldValue("published")).isEqualTo("1996");
+
+ // Title comes from the first level-1 heading (no front matter title)
+ assertThat(doc.getFieldValue("title")).isEqualTo("A Game of Thrones");
+
+ // All heading texts are collected into a multi-valued field
+ assertThat(doc.getFieldValues("headings")).containsExactly("A Game of Thrones", "Synopsis", "Reception");
+
+ // Body text is searchable and excludes front matter
+ String content = (String) doc.getFieldValue("content");
+ assertThat(content).contains("The first book in A Song of Ice and Fire.");
+ assertThat(content).contains("Widely acclaimed.");
+ assertThat(content).doesNotContain("George R.R. Martin");
+ }
+
+ @Test
+ void testFrontMatterTitleWinsOverFirstHeading() throws Exception {
+ // Given
+ String markdown = """
+ ---
+ title: Front Matter Title
+ ---
+ # Heading Title
+
+ Some body text.
+ """;
+
+ // When
+ List documents = indexingDocumentCreator.createSchemalessDocumentsFromMarkdown(markdown);
+
+ // Then
+ assertThat(documents).hasSize(1);
+ assertThat(documents.getFirst().getFieldValue("title")).isEqualTo("Front Matter Title");
+ }
+
+ @Test
+ void testCreateSchemalessDocumentsFromMarkdownWithoutFrontMatter() throws Exception {
+ // Given
+ String markdown = """
+ # Getting Started
+
+ Install the package and run the server.
+
+ ## Configuration
+
+ Set the `SOLR_URL` environment variable.
+ """;
+
+ // When
+ List documents = indexingDocumentCreator.createSchemalessDocumentsFromMarkdown(markdown);
+
+ // Then
+ assertThat(documents).hasSize(1);
+
+ SolrInputDocument doc = documents.getFirst();
+ assertThat(doc.getFieldValue("title")).isEqualTo("Getting Started");
+ assertThat(doc.getFieldValues("headings")).containsExactly("Getting Started", "Configuration");
+
+ String content = (String) doc.getFieldValue("content");
+ assertThat(content).contains("Install the package and run the server.");
+ assertThat(content).contains("SOLR_URL");
+ }
+
+ @Test
+ void testPlainTextMarkdownWithoutHeadings() throws Exception {
+ // Given
+ String markdown = "Just a plain paragraph of text without any structure.";
+
+ // When
+ List documents = indexingDocumentCreator.createSchemalessDocumentsFromMarkdown(markdown);
+
+ // Then
+ assertThat(documents).hasSize(1);
+
+ SolrInputDocument doc = documents.getFirst();
+ assertThat(doc.getFieldValue("title")).isNull();
+ assertThat(doc.getFieldValues("headings")).isNull();
+ assertThat(doc.getFieldValue("content")).isEqualTo("Just a plain paragraph of text without any structure.");
+ }
+
+ @Test
+ void testFrontMatterFieldNamesAreSanitized() throws Exception {
+ // Given
+ String markdown = """
+ ---
+ Created-By: Jane Doe
+ last.updated: 2026-01-01
+ ---
+ # Doc
+
+ Body.
+ """;
+
+ // When
+ List documents = indexingDocumentCreator.createSchemalessDocumentsFromMarkdown(markdown);
+
+ // Then
+ assertThat(documents).hasSize(1);
+
+ SolrInputDocument doc = documents.getFirst();
+ assertThat(doc.getFieldValue("created_by")).isEqualTo("Jane Doe");
+ assertThat(doc.getFieldValue("last_updated")).isEqualTo("2026-01-01");
+ }
+
+ @Test
+ void testFrontMatterListValuesBecomeMultiValuedFields() throws Exception {
+ // Given
+ String markdown = """
+ ---
+ tags: [search, solr, mcp]
+ ---
+ # Tagged Document
+
+ Body.
+ """;
+
+ // When
+ List documents = indexingDocumentCreator.createSchemalessDocumentsFromMarkdown(markdown);
+
+ // Then
+ assertThat(documents).hasSize(1);
+ assertThat(documents.getFirst().getFieldValues("tags")).containsExactly("search", "solr", "mcp");
+ }
+
+ @Test
+ void testFrontMatterBlockListValuesBecomeMultiValuedFields() throws Exception {
+ // Given
+ String markdown = """
+ ---
+ tags:
+ - search
+ - solr
+ ---
+ # Tagged Document
+
+ Body.
+ """;
+
+ // When
+ List documents = indexingDocumentCreator.createSchemalessDocumentsFromMarkdown(markdown);
+
+ // Then
+ assertThat(documents).hasSize(1);
+ assertThat(documents.getFirst().getFieldValues("tags")).containsExactly("search", "solr");
+ }
+
+ @Test
+ void testFrontMatterIdIsUsedAsDocumentId() throws Exception {
+ // Given
+ String markdown = """
+ ---
+ id: doc-42
+ ---
+ # Identified Document
+
+ Body.
+ """;
+
+ // When
+ List documents = indexingDocumentCreator.createSchemalessDocumentsFromMarkdown(markdown);
+
+ // Then
+ assertThat(documents).hasSize(1);
+ assertThat(documents.getFirst().getFieldValue("id")).isEqualTo("doc-42");
+ }
+
+ @Test
+ void testGeneratedIdIsStableForSameContent() throws Exception {
+ // Given
+ String markdown = "# Stable\n\nSame content, same id.";
+
+ // When
+ List first = indexingDocumentCreator.createSchemalessDocumentsFromMarkdown(markdown);
+ List second = indexingDocumentCreator.createSchemalessDocumentsFromMarkdown(markdown);
+ List other = indexingDocumentCreator
+ .createSchemalessDocumentsFromMarkdown("# Different\n\nOther content.");
+
+ // Then: re-indexing the same markdown must overwrite the same document
+ Object firstId = first.getFirst().getFieldValue("id");
+ assertThat(firstId).isNotNull();
+ assertThat(firstId).isEqualTo(second.getFirst().getFieldValue("id"));
+ assertThat(firstId).isNotEqualTo(other.getFirst().getFieldValue("id"));
+ }
+
+ @Test
+ void testEmptyMarkdownThrowsException() {
+ assertThatThrownBy(() -> indexingDocumentCreator.createSchemalessDocumentsFromMarkdown(""))
+ .isInstanceOf(DocumentProcessingException.class).hasMessageContaining("cannot be null or empty");
+ }
+
+ @Test
+ void testMarkdownFormattingIsStrippedFromContent() throws Exception {
+ // Given
+ String markdown = """
+ # Formatted
+
+ Some **bold** and *italic* text with a [link](https://solr.apache.org) and `inline code`.
+ """;
+
+ // When
+ List documents = indexingDocumentCreator.createSchemalessDocumentsFromMarkdown(markdown);
+
+ // Then
+ assertThat(documents).hasSize(1);
+
+ String content = (String) documents.getFirst().getFieldValue("content");
+ assertThat(content).contains("bold");
+ assertThat(content).contains("italic");
+ assertThat(content).contains("inline code");
+ assertThat(content).doesNotContain("**");
+ assertThat(content).doesNotContain("](");
+ }
+}