diff --git a/CLAUDE.md b/CLAUDE.md index 876c475..31017d2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -62,6 +62,7 @@ search enhancements. │ ├── ContentSearchService.java # read facade: multiSearch + Highlighter (+ result records) │ ├── ContentMcpToolProvider.java # McpToolProvider: the 4 tools on /mcp │ ├── RobotsPolicy.java / HttpRobotsPolicy.java # robots.txt allow/disallow + Crawl-delay +│ └── GitSourceStrategy.java / GitConfig.java # type: git — GitHub Markdown source (spec 016) ├── src/main/resources/application.yaml # datasource, JPA, OAuth2, MCP, Meilisearch, content config ├── src/test/java/com/openelements/content/ # behavior tests (context, MCP enabled/disabled, search-down, jsoup) └── docs/ @@ -106,8 +107,12 @@ categories/preview image/locale) from OpenGraph/Article ``, JSON-LD, and ` `ContentSourceStrategy` is the per-source-type seam that composes these stages: `WebsiteSourceStrategy` wires crawler → fetcher → extractor and maps the fetch result to a `FetchOutcome` (`INDEX`/`UNCHANGED`/`DELETE`/`SKIP`); `SourceStrategyRegistry` selects the strategy by -`ContentSource.type()`, so a future `git` strategy (spec 016) plugs in as a bean with no indexer -changes. `ContentIndexer` is the orchestration engine: discover → diff discovered `lastmod` against +`ContentSource.type()`, so the `git` strategy (spec 016) plugs in as a bean with no indexer +changes. `GitSourceStrategy` (`SourceType.GIT`, `GitConfig`) indexes Markdown from GitHub repos: +Trees-API discovery filtered by `paths` globs (blob SHA = change marker), raw-content fetch with a +server-side bearer token, YAML-frontmatter + Markdown extraction (Hugo shortcodes cleaned), path → +canonical-URL mapping, and locale from the filename suffix — producing the same `ContentDocument` as +websites, so downstream is unchanged. `ContentIndexer` is the orchestration engine: discover → diff discovered `lastmod` against the state read from the index (`ContentIndexStore`; the index *is* the state) → fetch only new/changed items via the strategy → batch-upsert, and delete documents that 404 or vanished from discovery. `MeilisearchContentIndexStore` implements the store over `MeilisearchClient` (paged `multiSearch` to diff --git a/docs/specs/016-git-markdown-source/steps.md b/docs/specs/016-git-markdown-source/steps.md new file mode 100644 index 0000000..8c926f9 --- /dev/null +++ b/docs/specs/016-git-markdown-source/steps.md @@ -0,0 +1,51 @@ +# Implementation Steps: Git Markdown Source + +## Step 1: Config model + +- [x] `GitConfig` record (`provider`, `repo`, `ref`, `paths`, `token`) with `hasToken()` +- [x] Add optional `git` field to `ContentSource` (present only for `type: git`); existing call sites pass `null` + +**Related behaviors:** private-repo token; config binding + +--- + +## Step 2: `GitSourceStrategy` + +- [x] `@Component implements ContentSourceStrategy` for `SourceType.GIT` (auto-routed by `SourceStrategyRegistry`) +- [x] `discover`: GitHub Trees API (`/repos/{repo}/git/trees/{ref}?recursive=1`), keep blobs matching `paths` globs (`AntPathMatcher`), SHA as change marker; a hard failure (auth) throws so the source is isolated (not mass-deleted) +- [x] `fetch`: raw content (`raw.githubusercontent.com`), split YAML frontmatter from Markdown body, clean Hugo shortcodes, map path → canonical URL, derive locale from filename suffix, build `ContentDocument` (SHA = `lastmod`) +- [x] Secrets: bearer token applied server-side only; never logged; empty-default env placeholder + +**Related behaviors:** discovery/filter; incremental SHA; frontmatter/shortcode/URL/locale extraction; token handling + +--- + +## Step 3: Config + tests + +- [x] `application.yaml`: example `oe-website-markdown` git source (disabled by default; token via `GITHUB_TOKEN_OE_WEBSITE`, empty default) +- [x] `GitSourceStrategyTest` (MockRestServiceServer): discovery+filter+SHA, bearer token, discovery-failure, fetch extraction (frontmatter/shortcode/URL/locale), fetch-failure skip, and pure `mapToUrl`/`localeFromPath`/`parseMarkdown` +- [x] `ConfiguredSourcesTest`: the git source binds as `SourceType.GIT` + +**Acceptance criteria:** +- [x] All tests pass (`mvn test`); build green + +--- + +## Behavior Coverage + +| Scenario | Layer | Covered in Step | +|----------|-------|-----------------| +| Files discovered via the trees API and filtered by paths | Backend | Step 3 (`discoveryFiltersByPaths`) | +| Non-matching files excluded | Backend | Step 3 (`discoveryFiltersByPaths` — README/png excluded) | +| Unchanged file (same SHA) is skipped | Backend | SHA returned as `lastmod` (`discoveryFiltersByPaths`); the unchanged/changed diff is `ContentIndexer`'s (spec 008 tests) | +| Changed file (new SHA) is re-indexed | Backend | As above — diff handled by `ContentIndexer`; strategy supplies the SHA | +| Frontmatter becomes metadata | Backend | Step 3 (`fetchExtractsFrontmatterAndBody`) | +| Hugo shortcodes are cleaned | Backend | Step 3 (`fetchExtractsFrontmatterAndBody` — no `{{<`) | +| Path maps to canonical URL | Backend | Step 3 (`datedFilenameMapsToCanonicalUrl`, `fetchExtractsFrontmatterAndBody`) | +| Locale from filename suffix | Backend | Step 3 (`localeFromFilenameSuffix`) | +| Private repo accessed with token | Backend | Step 3 (`tokenIsSentForPrivateRepo`) | +| Token is never logged or served | Backend | Design: token applied only as a request header, never logged (auth logs use `repo`, not token); MCP serves only indexed docs | +| Missing token for private repo fails clearly | Backend | Step 3 (`missingTokenFailsClearly`) | +| Git-sourced docs behave like website docs | Backend | Downstream unchanged — same `ContentDocument`/indexer/tools (specs 008/011/012) | + +All scenarios are backend; there is no frontend in this spec. diff --git a/docs/specs/INDEX.md b/docs/specs/INDEX.md index bf29b31..fe9d4db 100644 --- a/docs/specs/INDEX.md +++ b/docs/specs/INDEX.md @@ -21,5 +21,5 @@ roadmap step, to be implemented sequentially (each builds on the previous). | 013 | 013-scoped-search-key | Scoped search key | backend, search, security | Read-only scoped Meilisearch key for the content index | #25 | done | | 014 | 014-ops-robustness | Ops & robustness | backend, infrastructure, observability | robots.txt handling, fault tolerance, logging/metrics | #27 | done | | 015 | 015-additional-web-sources | Additional web sources | backend, configuration | hiero.org/blog + Support & Care as config-only sources | #29 | done | -| 016 | 016-git-markdown-source | Git markdown source | backend, architecture, security | `type: git` source + `GitSourceStrategy` (GitHub Markdown) | — | open | +| 016 | 016-git-markdown-source | Git markdown source | backend, architecture, security | `type: git` source + `GitSourceStrategy` (GitHub Markdown) | #31 | done | | 017 | 017-search-enhancements | Search enhancements | backend, search | Facets, synonyms/stop words, optional semantic search | — | open | diff --git a/src/main/java/com/openelements/content/ContentSource.java b/src/main/java/com/openelements/content/ContentSource.java index f60fcf8..21d310d 100644 --- a/src/main/java/com/openelements/content/ContentSource.java +++ b/src/main/java/com/openelements/content/ContentSource.java @@ -24,6 +24,7 @@ * @param contentSelector CSS selector for the main content element (applied in spec 006) * @param contentExclude CSS selectors removed before text extraction (applied in spec 006) * @param enabled whether the source is active; consumers in later specs skip disabled sources + * @param git Git configuration; present only for {@link SourceType#GIT} sources (spec 016) */ public record ContentSource( String id, @@ -34,7 +35,8 @@ public record ContentSource( List urlExclude, String contentSelector, List contentExclude, - boolean enabled + boolean enabled, + GitConfig git ) { /** The include pattern applied when a source declares no {@code urlInclude}: match everything. */ diff --git a/src/main/java/com/openelements/content/GitConfig.java b/src/main/java/com/openelements/content/GitConfig.java new file mode 100644 index 0000000..3653495 --- /dev/null +++ b/src/main/java/com/openelements/content/GitConfig.java @@ -0,0 +1,31 @@ +package com.openelements.content; + +import java.util.List; + +/** + * Configuration for a {@link SourceType#GIT} source, bound from the {@code git:} sub-object of a + * content source. Present only when {@code type: git}. + * + * @param provider the git provider ({@code github}); only GitHub is supported for now + * @param repo the repository in {@code owner/name} form + * @param ref the branch, tag, or commit to read (e.g. {@code main}) + * @param paths Ant-glob patterns selecting which files to index (e.g. {@code content/posts/**​/*.md}) + * @param token an access token for private repos; supplied via the environment, never in plaintext YAML + */ +public record GitConfig( + String provider, + String repo, + String ref, + List paths, + String token +) { + + public GitConfig { + paths = paths == null ? List.of() : List.copyOf(paths); + } + + /** @return {@code true} if an access token is configured (private repo) */ + public boolean hasToken() { + return token != null && !token.isBlank(); + } +} diff --git a/src/main/java/com/openelements/content/GitSourceStrategy.java b/src/main/java/com/openelements/content/GitSourceStrategy.java new file mode 100644 index 0000000..6de351d --- /dev/null +++ b/src/main/java/com/openelements/content/GitSourceStrategy.java @@ -0,0 +1,269 @@ +package com.openelements.content; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.dataformat.yaml.YAMLMapper; +import java.net.URI; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.regex.Pattern; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.HttpHeaders; +import org.springframework.stereotype.Component; +import org.springframework.util.AntPathMatcher; +import org.springframework.web.client.RestClient; + +/** + * {@link ContentSourceStrategy} for {@link SourceType#GIT}: indexes Markdown files directly from a + * GitHub repository. + * + *

Discovery lists the repo tree (GitHub Git Trees API) and keeps blob paths matching the source's + * {@code paths} globs, using each file's blob SHA as the change marker. Fetch reads the raw file, + * splits YAML frontmatter from the Markdown body (cleaning Hugo shortcodes), maps the file path to a + * canonical website URL, and derives the locale from the filename suffix. Private repos are accessed + * with a bearer token that is used server-side only and never logged. + * + *

A hard discovery failure (e.g. auth error) is surfaced as an exception so the caller (bootstrap + * step / refresh scheduler) isolates the source rather than treating an empty listing as "everything + * was deleted". + */ +@Component +public class GitSourceStrategy implements ContentSourceStrategy { + + private static final Logger log = LoggerFactory.getLogger(GitSourceStrategy.class); + private static final Pattern SHORTCODE = Pattern.compile("\\{\\{[<%].*?[%>]\\}\\}", Pattern.DOTALL); + private static final Pattern DATED_FILENAME = Pattern.compile("^(\\d{4})-(\\d{2})-(\\d{2})-(.+)$"); + + private final RestClient restClient; + private final YAMLMapper yamlMapper = new YAMLMapper(); + private final AntPathMatcher pathMatcher = new AntPathMatcher(); + + public GitSourceStrategy(RestClient.Builder restClientBuilder) { + this.restClient = restClientBuilder.build(); + } + + @Override + public SourceType type() { + return SourceType.GIT; + } + + @Override + public List discover(ContentSource source) { + GitConfig git = source.git(); + if (git == null || git.repo() == null || git.repo().isBlank()) { + throw new IllegalStateException("Git source " + source.id() + " is missing its git configuration"); + } + JsonNode tree; + try { + tree = restClient.get() + .uri(URI.create("https://api.github.com/repos/" + git.repo() + "/git/trees/" + git.ref() + "?recursive=1")) + .headers(headers -> applyGitHubHeaders(headers, git)) + .retrieve() + .body(JsonNode.class); + } catch (Exception e) { + // Surface as a failure so the source is isolated (not mass-deleted). Never log the token. + log.warn("Git discovery failed for source {} (repo {}): {}", source.id(), git.repo(), e.toString()); + throw new IllegalStateException("Git discovery failed for " + git.repo(), e); + } + + List items = new ArrayList<>(); + if (tree != null) { + for (JsonNode node : tree.path("tree")) { + if (!"blob".equals(node.path("type").asText())) { + continue; + } + String path = node.path("path").asText(""); + if (!path.isEmpty() && matchesAnyGlob(path, git.paths())) { + items.add(new DiscoveredItem(path, node.path("sha").asText(null))); + } + } + } + return items; + } + + @Override + public FetchOutcome fetch(ContentSource source, DiscoveredItem item) { + GitConfig git = source.git(); + String content; + try { + content = restClient.get() + .uri(URI.create("https://raw.githubusercontent.com/" + git.repo() + "/" + git.ref() + "/" + item.url())) + .headers(headers -> applyGitHubHeaders(headers, git)) + .retrieve() + .body(String.class); + } catch (Exception e) { + log.warn("Skipping {} in {}: fetch failed ({})", item.url(), git.repo(), e.toString()); + return FetchOutcome.skip(); + } + if (content == null || content.isBlank()) { + return FetchOutcome.skip(); + } + + ParsedMarkdown parsed = parseMarkdown(content); + if (parsed.body().isBlank()) { + log.warn("Skipping {} in {}: no content after frontmatter", item.url(), git.repo()); + return FetchOutcome.skip(); + } + Map frontmatter = parsed.frontmatter(); + ContentDocument document = new ContentDocument( + ContentDocument.id(source.id(), item.url()), + source.id(), + localeFromPath(item.url()), + mapToUrl(source, item.url(), frontmatter), + firstNonBlank(string(frontmatter, "title"), slugOf(item.url())), + firstNonBlank(string(frontmatter, "excerpt"), string(frontmatter, "description")), + parsed.body(), + string(frontmatter, "author"), + categories(frontmatter), + firstNonBlank(string(frontmatter, "date"), string(frontmatter, "publishedDate")), + item.lastmod(), + firstNonBlank(string(frontmatter, "previewImage"), string(frontmatter, "image"))); + return FetchOutcome.index(document); + } + + private static void applyGitHubHeaders(HttpHeaders headers, GitConfig git) { + headers.set(HttpHeaders.ACCEPT, "application/vnd.github+json"); + if (git.hasToken()) { + headers.setBearerAuth(git.token()); + } + } + + private boolean matchesAnyGlob(String path, List globs) { + return globs.stream().anyMatch(glob -> pathMatcher.match(glob, path)); + } + + // ---- markdown / frontmatter ---- + + ParsedMarkdown parseMarkdown(String content) { + Map frontmatter = Map.of(); + String body = content; + if (content.startsWith("---")) { + int firstNewline = content.indexOf('\n'); + int closing = firstNewline < 0 ? -1 : content.indexOf("\n---", firstNewline); + if (firstNewline > 0 && closing > firstNewline) { + String block = content.substring(firstNewline + 1, closing); + int bodyStart = content.indexOf('\n', closing + 1); + body = bodyStart >= 0 ? content.substring(bodyStart + 1) : ""; + frontmatter = parseYaml(block); + } + } + return new ParsedMarkdown(frontmatter, SHORTCODE.matcher(body).replaceAll("").strip()); + } + + @SuppressWarnings("unchecked") + private Map parseYaml(String block) { + try { + Map map = yamlMapper.readValue(block, Map.class); + return map == null ? Map.of() : map; + } catch (Exception e) { + log.debug("Ignoring unparseable frontmatter: {}", e.toString()); + return Map.of(); + } + } + + // ---- path -> url / locale ---- + + String mapToUrl(ContentSource source, String path, Map frontmatter) { + String base = trimTrailingSlash(source.baseUrl()); + String fileName = stripMarkdownExtension(lastSegment(path)); + String withoutLocale = stripLocaleSuffix(fileName); + var matcher = DATED_FILENAME.matcher(withoutLocale); + if (matcher.matches()) { + return base + "/posts/" + matcher.group(1) + "/" + matcher.group(2) + "/" + matcher.group(3) + + "/" + matcher.group(4); + } + String slug = string(frontmatter, "slug"); + if (slug != null) { + return base + "/posts/" + slug; + } + return base + "/" + stripMarkdownExtension(path); + } + + String localeFromPath(String path) { + String fileName = stripMarkdownExtension(lastSegment(path)); + int dot = fileName.lastIndexOf('.'); + if (dot > 0) { + String suffix = fileName.substring(dot + 1); + if (suffix.length() == 2 && suffix.chars().allMatch(Character::isLetter)) { + return suffix.toLowerCase(Locale.ROOT); + } + } + return "en"; + } + + private static String slugOf(String path) { + return stripLocaleSuffix(stripMarkdownExtension(lastSegment(path))); + } + + private static String lastSegment(String path) { + int slash = path.lastIndexOf('/'); + return slash >= 0 ? path.substring(slash + 1) : path; + } + + private static String stripMarkdownExtension(String value) { + if (value.endsWith(".md")) { + return value.substring(0, value.length() - 3); + } + if (value.endsWith(".mdx")) { + return value.substring(0, value.length() - 4); + } + return value; + } + + private static String stripLocaleSuffix(String fileName) { + int dot = fileName.lastIndexOf('.'); + if (dot > 0) { + String suffix = fileName.substring(dot + 1); + if (suffix.length() == 2 && suffix.chars().allMatch(Character::isLetter)) { + return fileName.substring(0, dot); + } + } + return fileName; + } + + private static String trimTrailingSlash(String url) { + return url.endsWith("/") ? url.substring(0, url.length() - 1) : url; + } + + // ---- frontmatter helpers ---- + + @SuppressWarnings("unchecked") + private static List categories(Map frontmatter) { + Object value = frontmatter.containsKey("categories") ? frontmatter.get("categories") : frontmatter.get("tags"); + List categories = new ArrayList<>(); + if (value instanceof List list) { + list.forEach(item -> categories.add(String.valueOf(item))); + } else if (value instanceof String text) { + for (String part : text.split(",")) { + if (!part.isBlank()) { + categories.add(part.strip()); + } + } + } + return categories; + } + + private static String string(Map frontmatter, String key) { + Object value = frontmatter.get(key); + if (value == null) { + return null; + } + String text = String.valueOf(value).strip(); + return text.isEmpty() ? null : text; + } + + private static String firstNonBlank(String... values) { + for (String value : values) { + if (value != null && !value.isBlank()) { + return value; + } + } + return null; + } + + /** The frontmatter map and the cleaned Markdown body. */ + record ParsedMarkdown(Map frontmatter, String body) { + } +} diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index e41c4d4..1844658 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -82,3 +82,16 @@ open-elements: content-selector: "body" content-exclude: [ "nav", "header", "footer", ".cookie-banner", "aside" ] enabled: true + # Example Git/Markdown source (spec 016). Disabled by default: enable it and supply a read-only, + # repo-scoped token via GITHUB_TOKEN_OE_WEBSITE (never commit a token). Higher-fidelity content + # than HTML scraping for repos we own. + - id: oe-website-markdown + type: git + base-url: https://open-elements.com + enabled: false + git: + provider: github + repo: OpenElements/open-elements-website + ref: main + paths: [ "content/posts/**/*.md", "content/posts/**/*.mdx" ] + token: ${GITHUB_TOKEN_OE_WEBSITE:} diff --git a/src/test/java/com/openelements/content/ConfiguredSourcesTest.java b/src/test/java/com/openelements/content/ConfiguredSourcesTest.java index 2dbbeda..28183ad 100644 --- a/src/test/java/com/openelements/content/ConfiguredSourcesTest.java +++ b/src/test/java/com/openelements/content/ConfiguredSourcesTest.java @@ -56,4 +56,16 @@ void supportAndCareSourceIsConfigured() { assertThat(support.contentSelector()).isEqualTo("body"); assertThat(support.contentExclude()).contains("nav", "header", "footer", ".cookie-banner"); } + + @Test + @DisplayName("the example git source binds as a GIT source (disabled by default)") + void gitSourceIsConfigured() { + ContentSource git = sourcesById().get("oe-website-markdown"); + assertThat(git.type()).isEqualTo(SourceType.GIT); + assertThat(git.enabled()).isFalse(); + assertThat(git.git()).isNotNull(); + assertThat(git.git().repo()).isEqualTo("OpenElements/open-elements-website"); + assertThat(git.git().ref()).isEqualTo("main"); + assertThat(git.git().paths()).contains("content/posts/**/*.md"); + } } diff --git a/src/test/java/com/openelements/content/ContentBootstrapStepTest.java b/src/test/java/com/openelements/content/ContentBootstrapStepTest.java index 175d4d8..53b0f99 100644 --- a/src/test/java/com/openelements/content/ContentBootstrapStepTest.java +++ b/src/test/java/com/openelements/content/ContentBootstrapStepTest.java @@ -32,7 +32,7 @@ class ContentBootstrapStepTest { private static ContentSource source(String id, boolean enabled) { return new ContentSource( - id, SourceType.WEBSITE, "https://ex.com", List.of(), List.of("/**"), List.of(), "article", List.of(), enabled); + id, SourceType.WEBSITE, "https://ex.com", List.of(), List.of("/**"), List.of(), "article", List.of(), enabled, null); } private static ContentSourceProperties properties(ContentSource... sources) { diff --git a/src/test/java/com/openelements/content/ContentExtractorTest.java b/src/test/java/com/openelements/content/ContentExtractorTest.java index 010b89e..3763e04 100644 --- a/src/test/java/com/openelements/content/ContentExtractorTest.java +++ b/src/test/java/com/openelements/content/ContentExtractorTest.java @@ -19,7 +19,7 @@ class ContentExtractorTest { private static ContentSource source(String contentSelector, List contentExclude) { return new ContentSource( "test", SourceType.WEBSITE, "https://ex.com", - List.of(), List.of("/**"), List.of(), contentSelector, contentExclude, true); + List.of(), List.of("/**"), List.of(), contentSelector, contentExclude, true, null); } private ExtractedContent extract(String contentSelector, List exclude, String html) { diff --git a/src/test/java/com/openelements/content/ContentIndexerTest.java b/src/test/java/com/openelements/content/ContentIndexerTest.java index a33b645..91a4509 100644 --- a/src/test/java/com/openelements/content/ContentIndexerTest.java +++ b/src/test/java/com/openelements/content/ContentIndexerTest.java @@ -19,7 +19,7 @@ class ContentIndexerTest { private static final ContentSource SOURCE = new ContentSource( - "oe", SourceType.WEBSITE, "https://ex.com", List.of(), List.of("/**"), List.of(), "article", List.of(), true); + "oe", SourceType.WEBSITE, "https://ex.com", List.of(), List.of("/**"), List.of(), "article", List.of(), true, null); private final InMemoryContentIndexStore store = new InMemoryContentIndexStore(); private final StubStrategy strategy = new StubStrategy(); diff --git a/src/test/java/com/openelements/content/ContentRefreshSchedulerTest.java b/src/test/java/com/openelements/content/ContentRefreshSchedulerTest.java index df01fb9..4deb9b1 100644 --- a/src/test/java/com/openelements/content/ContentRefreshSchedulerTest.java +++ b/src/test/java/com/openelements/content/ContentRefreshSchedulerTest.java @@ -41,7 +41,7 @@ void markBootstrapComplete() { private static ContentSource source(String id, boolean enabled) { return new ContentSource( - id, SourceType.WEBSITE, "https://ex.com", List.of(), List.of("/**"), List.of(), "article", List.of(), enabled); + id, SourceType.WEBSITE, "https://ex.com", List.of(), List.of("/**"), List.of(), "article", List.of(), enabled, null); } private static ContentSourceProperties properties(boolean enabled, ContentSource... sources) { diff --git a/src/test/java/com/openelements/content/GitSourceStrategyTest.java b/src/test/java/com/openelements/content/GitSourceStrategyTest.java new file mode 100644 index 0000000..f7733c1 --- /dev/null +++ b/src/test/java/com/openelements/content/GitSourceStrategyTest.java @@ -0,0 +1,160 @@ +package com.openelements.content; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.header; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo; +import static org.springframework.test.web.client.response.MockRestResponseCreators.withStatus; +import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess; + +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.test.web.client.MockRestServiceServer; +import org.springframework.web.client.RestClient; + +/** + * Tests for {@link GitSourceStrategy} using {@link MockRestServiceServer} to stub the GitHub trees + * and raw-content endpoints (no real network), plus direct unit tests of the pure mapping helpers. + */ +@DisplayName("Git source strategy") +class GitSourceStrategyTest { + + private static final String TREES_URL = + "https://api.github.com/repos/OpenElements/website/git/trees/main?recursive=1"; + + private MockRestServiceServer server; + private GitSourceStrategy strategy; + + @BeforeEach + void setUp() { + RestClient.Builder builder = RestClient.builder(); + server = MockRestServiceServer.bindTo(builder).ignoreExpectOrder(true).build(); + strategy = new GitSourceStrategy(builder); + } + + private static ContentSource gitSource(String token, String... paths) { + return new ContentSource("oe-md", SourceType.GIT, "https://open-elements.com", + List.of(), List.of("/**"), List.of(), null, List.of(), true, + new GitConfig("github", "OpenElements/website", "main", List.of(paths), token)); + } + + private static String rawUrl(String path) { + return "https://raw.githubusercontent.com/OpenElements/website/main/" + path; + } + + @Test + @DisplayName("handles the GIT source type") + void handlesGitType() { + assertThat(strategy.type()).isEqualTo(SourceType.GIT); + } + + @Test + @DisplayName("discovery lists tree blobs filtered by the paths glob, with their SHAs") + void discoveryFiltersByPaths() { + server.expect(requestTo(TREES_URL)).andRespond(withSuccess(""" + {"tree":[ + {"path":"README.md","type":"blob","sha":"r1"}, + {"path":"content/posts/2026-03-12-slug.md","type":"blob","sha":"s1"}, + {"path":"content/posts/image.png","type":"blob","sha":"i1"}, + {"path":"content/posts","type":"tree","sha":"t1"} + ]}""", MediaType.APPLICATION_JSON)); + + List items = strategy.discover(gitSource(null, "content/posts/**/*.md")); + + assertThat(items).containsExactly(new DiscoveredItem("content/posts/2026-03-12-slug.md", "s1")); + } + + @Test + @DisplayName("a configured token is sent as a bearer credential") + void tokenIsSentForPrivateRepo() { + server.expect(requestTo(TREES_URL)) + .andExpect(header("Authorization", "Bearer secret-token")) + .andRespond(withSuccess("{\"tree\":[]}", MediaType.APPLICATION_JSON)); + + strategy.discover(gitSource("secret-token", "content/posts/**/*.md")); + + server.verify(); + } + + @Test + @DisplayName("a discovery auth failure is surfaced so the source is isolated") + void missingTokenFailsClearly() { + server.expect(requestTo(TREES_URL)).andRespond(withStatus(HttpStatus.NOT_FOUND)); + + assertThatThrownBy(() -> strategy.discover(gitSource(null, "content/posts/**/*.md"))) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("OpenElements/website"); + } + + @Test + @DisplayName("fetch parses frontmatter, cleans shortcodes, maps the URL and derives the locale") + void fetchExtractsFrontmatterAndBody() { + String path = "content/posts/2026-03-12-slug.md"; + server.expect(requestTo(rawUrl(path))).andRespond(withSuccess(""" + --- + title: "Agentic Wallets" + date: "2026-03-12" + author: "hendrik" + categories: ["ai", "web3"] + --- + Body text about {{< figure src="x.png" >}} wallets. + """, MediaType.TEXT_PLAIN)); + + FetchOutcome outcome = strategy.fetch(gitSource(null, "content/posts/**/*.md"), + new DiscoveredItem(path, "s1")); + + assertThat(outcome.result()).isEqualTo(FetchOutcome.Result.INDEX); + ContentDocument document = outcome.document(); + assertThat(document.title()).isEqualTo("Agentic Wallets"); + assertThat(document.author()).isEqualTo("hendrik"); + assertThat(document.categories()).containsExactly("ai", "web3"); + assertThat(document.publishedDate()).isEqualTo("2026-03-12"); + assertThat(document.url()).isEqualTo("https://open-elements.com/posts/2026/03/12/slug"); + assertThat(document.locale()).isEqualTo("en"); + assertThat(document.lastmod()).isEqualTo("s1"); + assertThat(document.body()).contains("Body text about", "wallets."); + assertThat(document.body()).doesNotContain("{{<", "title:"); + } + + @Test + @DisplayName("a fetch failure skips the file without failing the batch") + void fetchFailureIsSkipped() { + String path = "content/posts/gone.md"; + server.expect(requestTo(rawUrl(path))).andRespond(withStatus(HttpStatus.NOT_FOUND)); + + FetchOutcome outcome = strategy.fetch(gitSource(null, "content/posts/**/*.md"), + new DiscoveredItem(path, "s1")); + + assertThat(outcome.result()).isEqualTo(FetchOutcome.Result.SKIP); + } + + // ---- pure mapping helpers ---- + + @Test + @DisplayName("a dated filename maps to the canonical /posts/YYYY/MM/DD/slug URL") + void datedFilenameMapsToCanonicalUrl() { + String url = strategy.mapToUrl( + gitSource(null, "content/posts/**/*.md"), "content/posts/2026-03-12-my-post.md", Map.of()); + assertThat(url).isEqualTo("https://open-elements.com/posts/2026/03/12/my-post"); + } + + @Test + @DisplayName("the locale is derived from a filename language suffix") + void localeFromFilenameSuffix() { + assertThat(strategy.localeFromPath("content/posts/2026-03-12-slug.de.md")).isEqualTo("de"); + assertThat(strategy.localeFromPath("content/posts/2026-03-12-slug.md")).isEqualTo("en"); + } + + @Test + @DisplayName("a file without frontmatter yields the whole content as the body") + void parsesBodyWithoutFrontmatter() { + GitSourceStrategy.ParsedMarkdown parsed = strategy.parseMarkdown("Just body, no frontmatter."); + assertThat(parsed.frontmatter()).isEmpty(); + assertThat(parsed.body()).isEqualTo("Just body, no frontmatter."); + } +} diff --git a/src/test/java/com/openelements/content/SitemapCrawlerTest.java b/src/test/java/com/openelements/content/SitemapCrawlerTest.java index 1efeb6d..29c0a07 100644 --- a/src/test/java/com/openelements/content/SitemapCrawlerTest.java +++ b/src/test/java/com/openelements/content/SitemapCrawlerTest.java @@ -41,7 +41,7 @@ private static ContentSource source(List sitemaps, List include, private static ContentSource source(String baseUrl, List sitemaps, List include, List exclude) { return new ContentSource( - "test", SourceType.WEBSITE, baseUrl, sitemaps, include, exclude, "article", List.of(), true); + "test", SourceType.WEBSITE, baseUrl, sitemaps, include, exclude, "article", List.of(), true, null); } private void stubXml(String url, String body) { diff --git a/src/test/java/com/openelements/content/SourceStrategyRegistryTest.java b/src/test/java/com/openelements/content/SourceStrategyRegistryTest.java index 262e155..856b4a6 100644 --- a/src/test/java/com/openelements/content/SourceStrategyRegistryTest.java +++ b/src/test/java/com/openelements/content/SourceStrategyRegistryTest.java @@ -15,7 +15,7 @@ class SourceStrategyRegistryTest { private static ContentSource source(SourceType type) { return new ContentSource( - "s", type, "https://ex.com", List.of(), List.of("/**"), List.of(), "article", List.of(), true); + "s", type, "https://ex.com", List.of(), List.of("/**"), List.of(), "article", List.of(), true, null); } private static ContentSourceStrategy strategyFor(SourceType type) { diff --git a/src/test/java/com/openelements/content/UrlMatcherTest.java b/src/test/java/com/openelements/content/UrlMatcherTest.java index 2d7aeea..315497d 100644 --- a/src/test/java/com/openelements/content/UrlMatcherTest.java +++ b/src/test/java/com/openelements/content/UrlMatcherTest.java @@ -18,7 +18,7 @@ class UrlMatcherTest { private static ContentSource source(List include, List exclude) { return new ContentSource( "test", SourceType.WEBSITE, "https://example.com", - List.of(), include, exclude, "article", List.of(), true); + List.of(), include, exclude, "article", List.of(), true, null); } @Test @@ -57,7 +57,7 @@ void defaultIncludeMatchesEverything() { // urlInclude omitted -> ContentSource defaults it to ["/**"]. ContentSource source = new ContentSource( "test", SourceType.WEBSITE, "https://example.com", - List.of(), null, List.of("/private/**"), "article", List.of(), true); + List.of(), null, List.of("/private/**"), "article", List.of(), true, null); assertThat(matcher.matches(source, "/anything/at/all")).isTrue(); assertThat(matcher.matches(source, "/private/secret")).isFalse(); diff --git a/src/test/java/com/openelements/content/WebsiteSourceStrategyTest.java b/src/test/java/com/openelements/content/WebsiteSourceStrategyTest.java index d5eaa0e..08b68bc 100644 --- a/src/test/java/com/openelements/content/WebsiteSourceStrategyTest.java +++ b/src/test/java/com/openelements/content/WebsiteSourceStrategyTest.java @@ -53,7 +53,7 @@ void setUp() { private static ContentSource source(List sitemaps) { return new ContentSource( "open-elements", SourceType.WEBSITE, "https://ex.com", - sitemaps, List.of("/**"), List.of(), "article", List.of(), true); + sitemaps, List.of("/**"), List.of(), "article", List.of(), true, null); } private static DiscoveredItem item(String lastmod) {