From 3f34f95d964a1635bd17ec117e9c57cae2f3f1ef Mon Sep 17 00:00:00 2001 From: Martijn Laarman Date: Tue, 18 Aug 2026 13:01:44 +0200 Subject: [PATCH] Source search-labs content from Contentstack instead of crawling search-labs (blog, tutorials, notebooks, integrations) is now published through Contentstack, so essc unblocks the relevant content types, adds rich-text (ProseMirror JSON) body/description mapping for them, and stops crawling search-labs in the labs pipeline. Also retries Contentstack's transient 422s, which previously aborted production sync runs before finalize. Co-Authored-By: Claude Sonnet 5 (1M context) Co-authored-by: Cursor --- docs/development/essc.md | 15 ++- src/tooling/essc/Commands/LabsCommands.cs | 7 +- .../essc/ContentStack/ContentStackMapper.cs | 102 ++++++++++++++++++ .../essc/ContentStack/SourcingState.cs | 18 ++-- .../essc/LabsCrawl/LabsSiteCrawlPlanner.cs | 3 +- src/tooling/essc/Program.cs | 7 ++ .../ContentStackMappingTests.cs | 99 +++++++++++++++++ .../ContentStack/description_only.json | 12 +++ .../Fixtures/ContentStack/landing_page.json | 27 +++++ .../ContentStack/rich_text_blog_v3.json | 32 ++++++ .../Fixtures/ContentStack/rich_text_page.json | 62 +++++++++++ .../Fixtures/ContentStack/schema-groups.json | 20 ++++ 12 files changed, 384 insertions(+), 20 deletions(-) create mode 100644 tests/Elastic.SiteSearch.Tests/Fixtures/ContentStack/description_only.json create mode 100644 tests/Elastic.SiteSearch.Tests/Fixtures/ContentStack/landing_page.json create mode 100644 tests/Elastic.SiteSearch.Tests/Fixtures/ContentStack/rich_text_blog_v3.json create mode 100644 tests/Elastic.SiteSearch.Tests/Fixtures/ContentStack/rich_text_page.json diff --git a/docs/development/essc.md b/docs/development/essc.md index e7e98b788c..84245db58a 100644 --- a/docs/development/essc.md +++ b/docs/development/essc.md @@ -17,11 +17,16 @@ existing interface. Current sources: -| Source | Namespace | Description | -| ------------ | ------------------- | ---------------------------------------------------- | -| Contentstack | `contentstack` | elastic.co marketing, blog, product, and event pages | -| Labs | `labs` | Search, security, and observability labs properties | -| Legacy docs | `guide` _(planned)_ | `/guide` legacy documentation | +| Source | Namespace | Description | +| ------------ | ------------------- | ------------------------------------------------------------------------ | +| Contentstack | `contentstack` | elastic.co marketing, blog, product, event, and Search Labs pages | +| Labs | `labs` | Security and observability labs properties | +| Legacy docs | `guide` _(planned)_ | `/guide` legacy documentation | + +Search Labs (`/search-labs/*` — blog, tutorials, notebooks, integrations) is sourced from +Contentstack rather than crawled: it moved off the `labs` HTML crawler and is now indexed by +`contentstack sync` alongside the rest of the marketing site. Security Labs and Observability Labs +are still crawled by `labs sync`. ## Installation diff --git a/src/tooling/essc/Commands/LabsCommands.cs b/src/tooling/essc/Commands/LabsCommands.cs index df011d7b7c..b106352ff6 100644 --- a/src/tooling/essc/Commands/LabsCommands.cs +++ b/src/tooling/essc/Commands/LabsCommands.cs @@ -70,10 +70,11 @@ internal sealed class LabsSyncOptions } /// -/// Crawl elastic.co labs properties (search, security, observability) into labs-* Elasticsearch indices. +/// Crawl elastic.co labs properties (security, observability) into labs-* Elasticsearch indices. /// /// -/// Independent of Contentstack contentstack commands targeting site-* indices. +/// Independent of Contentstack contentstack commands targeting site-* indices. Search Labs +/// is sourced from Contentstack (see essc contentstack sync) and is not crawled here. /// Discovery starts from published labs sitemap URLs; use --dry-run to validate discovery only. /// internal sealed class LabsCommands( @@ -134,7 +135,7 @@ public async Task Sync([AsParameters] LabsSyncOptions options, Cancel ct = defau var env = config.ElasticsearchEnvironment; var indexAlias = LabsSiteCrawlPlanner.ResolveLexicalReadAlias(buildType, env); - AnsiConsole.MarkupLine("[aqua bold]Labs crawl[/] — [dim]search-labs, security-labs, observability-labs[/]"); + AnsiConsole.MarkupLine("[aqua bold]Labs crawl[/] — [dim]security-labs, observability-labs[/]"); AnsiConsole.MarkupLine($"[dim]Elasticsearch:{Markup.Escape(cfg.Uri.ToString())}[/]"); AnsiConsole.MarkupLine($"[dim]Incremental cache alias:[/] [white]{Markup.Escape(indexAlias)}[/]"); if (force) diff --git a/src/tooling/essc/ContentStack/ContentStackMapper.cs b/src/tooling/essc/ContentStack/ContentStackMapper.cs index 4a99be34e3..20ef897a2e 100644 --- a/src/tooling/essc/ContentStack/ContentStackMapper.cs +++ b/src/tooling/essc/ContentStack/ContentStackMapper.cs @@ -2,6 +2,7 @@ // Elasticsearch B.V licenses this file to you under the Apache 2.0 License. // See the LICENSE file in the project root for more information +using System.Linq; using System.Security.Cryptography; using System.Text; using System.Text.Json; @@ -139,9 +140,61 @@ internal static partial class ContentStackMapper if (!string.IsNullOrWhiteSpace(v5Notes)) return v5Notes; + // Strategy 7: main_content.content_l10n (tutorial, tutorial_page, tutorial_chapter, + // labs_integration) or main_content.body.content_l10n (blog_v3) — a rich-text JSON AST + // (ProseMirror-style { type, children, text }), not an HTML string like the strategies above. + var richTextBody = ExtractRichTextBody(data); + if (!string.IsNullOrWhiteSpace(richTextBody)) + return richTextBody; + + return null; + } + + private static string? ExtractRichTextBody(JsonElement data) + { + if (!data.TryGetProperty("main_content", out var mainContent) || mainContent.ValueKind != JsonValueKind.Object) + return null; + + if (mainContent.TryGetProperty("content_l10n", out var direct) && direct.ValueKind == JsonValueKind.Object) + return RenderRichTextRoot(direct); + + if (mainContent.TryGetProperty("body", out var body) && body.ValueKind == JsonValueKind.Object + && body.TryGetProperty("content_l10n", out var nested) && nested.ValueKind == JsonValueKind.Object) + return RenderRichTextRoot(nested); + return null; } + /// + /// Renders a ContentStack rich-text (ProseMirror-style) JSON AST — { type, children: [...], text? } + /// — into a lightweight HTML-ish string so it flows through the same / + /// regex pipeline as the legacy HTML-string strategies above, instead of + /// needing a parallel JSON-aware text/heading extractor. + /// + private static string RenderRichText(JsonElement node) + { + if (node.TryGetProperty("text", out var textProp) && textProp.ValueKind == JsonValueKind.String) + return textProp.GetString() ?? ""; + + if (!node.TryGetProperty("children", out var children) || children.ValueKind != JsonValueKind.Array) + return ""; + + var inner = string.Concat(children.EnumerateArray().Select(RenderRichText)); + var type = GetString(node, "type"); + + return type switch + { + "h1" or "h2" or "h3" or "h4" or "h5" or "h6" or "p" or "li" or "ul" or "ol" => $"<{type}>{inner}", + _ => inner + }; + } + + private static string? RenderRichTextRoot(JsonElement root) + { + var rendered = RenderRichText(root); + return string.IsNullOrWhiteSpace(StripHtml(rendered)) ? null : rendered; + } + private static string? ExtractModularBlocks(JsonElement data) { if (!data.TryGetProperty("modular_blocks", out var blocks) || blocks.ValueKind != JsonValueKind.Array) @@ -232,6 +285,36 @@ internal static partial class ContentStackMapper if (!string.IsNullOrWhiteSpace(intro)) return StripHtml(intro); + // description_l10n (notebook, series, labs_category, labs_homepage, glossary, examples_landing) — + // a plain string on most content types, but a nested rich-text object on labs_integration, so this + // simply returns null there (GetString ignores non-string values) and falls through below. + var descriptionL10n = GetString(data, "description_l10n"); + if (!string.IsNullOrWhiteSpace(descriptionL10n)) + return descriptionL10n; + + // description_l10n.content_simple_l10n (labs_integration) + var richDescription = GetNestedRichText(data, "description_l10n", "content_simple_l10n"); + if (!string.IsNullOrWhiteSpace(richDescription)) + return StripHtml(richDescription); + + // page_info.subheading_l10n (tutorials_landing, integrations_landing, blog_landing) + var subheading = GetNestedString(data, "page_info", "subheading_l10n"); + if (!string.IsNullOrWhiteSpace(subheading)) + return subheading; + + // page_info.description.content_simple_l10n (tutorials_landing; often empty on other landing pages) + if (data.TryGetProperty("page_info", out var pageInfo) && pageInfo.ValueKind == JsonValueKind.Object) + { + var pageInfoRichDescription = GetNestedRichText(pageInfo, "description", "content_simple_l10n"); + if (!string.IsNullOrWhiteSpace(pageInfoRichDescription)) + return StripHtml(pageInfoRichDescription); + } + + // summary_l10n (blog_v3) + var summary = GetString(data, "summary_l10n"); + if (!string.IsNullOrWhiteSpace(summary)) + return summary; + // SEO description fallback return GetSeoString(data, "seo_description_l10n") ?? GetSeoString(data, "seo_description"); } @@ -354,6 +437,12 @@ private static int ComputeNavigationDepth(string url) internal static string GetNavigationSection(string url, string? contentTypeUid = null) { + // Search Labs is sourced from Contentstack; classify consistently with the label + // LabsHtmlExtractor.GetNavigationSection assigns to the same URLs when crawled. + if (url.Contains("/search-labs", StringComparison.OrdinalIgnoreCase)) + return "search-labs"; + if (url.Contains("/glossary", StringComparison.OrdinalIgnoreCase)) + return "glossary"; if (url.Contains("/blog/", StringComparison.OrdinalIgnoreCase)) return "blog"; if (url.Contains("/what-is/", StringComparison.OrdinalIgnoreCase)) @@ -465,6 +554,19 @@ private static string ComputeHash(string content) return null; } + /// + /// Reads a rich-text (ProseMirror-style) JSON AST nested at el.{parent}.{child} — e.g. + /// description_l10n.content_simple_l10n — and renders it via . + /// + private static string? GetNestedRichText(JsonElement el, string parent, string child) + { + if (!el.TryGetProperty(parent, out var p) || p.ValueKind != JsonValueKind.Object) + return null; + if (!p.TryGetProperty(child, out var richTextRoot) || richTextRoot.ValueKind != JsonValueKind.Object) + return null; + return RenderRichTextRoot(richTextRoot); + } + private static string? GetSeoString(JsonElement data, string field) { if (data.TryGetProperty("seo", out var seo) && seo.ValueKind == JsonValueKind.Object) diff --git a/src/tooling/essc/ContentStack/SourcingState.cs b/src/tooling/essc/ContentStack/SourcingState.cs index e155ca4eca..4097cb7ba9 100644 --- a/src/tooling/essc/ContentStack/SourcingState.cs +++ b/src/tooling/essc/ContentStack/SourcingState.cs @@ -125,17 +125,8 @@ internal static class PageContentTypes "demo_gallery_overview", "timeline", "events_overview", - "blog_archive_overview" - ]; - - /// - /// Content types that exist in Contentstack but must never be synced or indexed — e.g. content - /// types still being authored that aren't ready to appear in search yet. - /// - public static readonly string[] Blocked = - [ + "blog_archive_overview", "blog_landing", - "example_link", "glossary", "examples_landing", "integrations_landing", @@ -151,6 +142,12 @@ internal static class PageContentTypes "blog_v3" ]; + /// + /// Content types that exist in Contentstack but must never be synced or indexed — e.g. content + /// types still being authored that aren't ready to appear in search yet. + /// + public static readonly string[] Blocked = []; + /// /// Content types that are never expected to represent a standalone page — reusable components, /// taxonomy/tags, navigation config, redirects, etc. Unlike , these aren't @@ -187,6 +184,7 @@ internal static class PageContentTypes "customer_industry", "customer_use_case", "date_field", + "example_link", "featured_split_listing", "features", "footer", diff --git a/src/tooling/essc/LabsCrawl/LabsSiteCrawlPlanner.cs b/src/tooling/essc/LabsCrawl/LabsSiteCrawlPlanner.cs index f74d8ea487..e6a0c0bb9b 100644 --- a/src/tooling/essc/LabsCrawl/LabsSiteCrawlPlanner.cs +++ b/src/tooling/essc/LabsCrawl/LabsSiteCrawlPlanner.cs @@ -10,9 +10,9 @@ namespace Elastic.SiteSearch.Cli.LabsCrawl; /// Labs-only sitemap URLs and crawl planning (vendored from site crawler logic). public static class LabsSiteCrawlPlanner { + // search-labs is sourced from Contentstack (see essc contentstack sync) and is no longer crawled here. public static readonly string[] LabsSitemapUrls = [ - "https://www.elastic.co/search-labs/sitemap.xml", "https://www.elastic.co/security-labs/sitemap.xml", "https://www.elastic.co/observability-labs/sitemap.xml" ]; @@ -32,7 +32,6 @@ public sealed record LabsSitemapDiscoveryResult( private static readonly (string Pattern, string Label)[] PathEntries = [ - ("/search-labs/", "/search-labs/"), ("/security-labs/", "/security-labs/"), ("/observability-labs/", "/observability-labs/") ]; diff --git a/src/tooling/essc/Program.cs b/src/tooling/essc/Program.cs index 47cd5b072a..4bbf3d5f08 100644 --- a/src/tooling/essc/Program.cs +++ b/src/tooling/essc/Program.cs @@ -11,6 +11,7 @@ using Elastic.SiteSearch.Cli.Logging; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Http.Resilience; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Console; using Nullean.Argh.Hosting; @@ -56,6 +57,12 @@ o.Retry.UseJitter = true; o.Retry.BackoffType = Polly.DelayBackoffType.Exponential; o.Retry.Delay = TimeSpan.FromSeconds(2); + // Contentstack's sync endpoint intermittently returns a transient 422 (Unprocessable Entity) + // under load — not handled by the default transient-status predicate — which otherwise aborts + // the whole sync run before FinalizeAsync. See production run 32113750025. + o.Retry.ShouldHandle = args => ValueTask.FromResult( + HttpClientResiliencePredicates.IsTransient(args.Outcome) || + args.Outcome.Result?.StatusCode == HttpStatusCode.UnprocessableEntity); }); csHttpClient.AddHttpMessageHandler(() => RateLimitingHandler.CreateForContentStack()); diff --git a/tests/Elastic.SiteSearch.Tests/ContentStackMappingTests.cs b/tests/Elastic.SiteSearch.Tests/ContentStackMappingTests.cs index 731d0b9031..d3a1c71609 100644 --- a/tests/Elastic.SiteSearch.Tests/ContentStackMappingTests.cs +++ b/tests/Elastic.SiteSearch.Tests/ContentStackMappingTests.cs @@ -250,6 +250,102 @@ public void MinimalPage_Maps_TitleUrlSeo() doc.Hash.Should().NotBeNullOrEmpty(); } + /// Covers: tutorial, tutorial_page, tutorial_chapter, labs_integration (rich-text JSON AST body + description) + [Fact] + public void RichTextPage_Maps_ProseMirrorBodyAndDescription() + { + var item = LoadFixture("rich_text_page.json", "labs_integration"); + var doc = ContentStackMapper.ToSiteDocument(item); + + doc.Should().NotBeNull(); + doc.Path.Should().Be("/search-labs/integrations/amazon-bedrock"); + doc.Section.Should().Be("search-labs"); + doc.Body.Should().Contain("Amazon Bedrock makes leading foundation models available"); + doc.Body.Should().Contain("RAG using Bedrock"); + doc.Body.Should().NotContain("

"); + doc.Body.Should().NotContain("

"); + doc.Headings.Should().Contain("Blogs to get started"); + doc.Description.Should().Contain("fully managed service offering foundation models"); + doc.Hash.Should().NotBeNullOrEmpty(); + } + + /// Covers: blog_v3 (rich-text body nested under main_content.body.content_l10n + summary_l10n description) + [Fact] + public void BlogV3_Maps_NestedRichTextBodyAndSummary() + { + var item = LoadFixture("rich_text_blog_v3.json", "blog_v3"); + var doc = ContentStackMapper.ToSiteDocument(item); + + doc.Should().NotBeNull(); + doc.Path.Should().Be("/search-labs/blog/hybrid-search-with-elasticsearch"); + doc.Section.Should().Be("search-labs"); + doc.Body.Should().Contain("Hybrid search combines BM25 lexical scoring"); + doc.Body.Should().NotContain("

"); + doc.Headings.Should().Contain("Why hybrid search"); + doc.Description.Should().Be("Combine lexical and semantic search for better relevance."); + doc.PublishedDate.Should().NotBeNull(); + } + + ///

Covers: tutorials_landing, integrations_landing, blog_landing (page_info.subheading_l10n description, no body) + [Fact] + public void LandingPage_Maps_SubheadingDescription_NoBody() + { + var item = LoadFixture("landing_page.json", "tutorials_landing"); + var doc = ContentStackMapper.ToSiteDocument(item); + + doc.Should().NotBeNull(); + doc.Path.Should().Be("/search-labs/tutorials"); + doc.Section.Should().Be("search-labs"); + doc.Description.Should().Be("Long-form guides to get you started"); + // No main_content on landing pages — body falls back to the description. + doc.Body.Should().Be(doc.Description); + } + + /// Covers: notebook, series, labs_category, labs_homepage, glossary, examples_landing (plain description_l10n, no body) + [Fact] + public void DescriptionOnly_Maps_PlainDescription_NoBody() + { + var item = LoadFixture("description_only.json", "notebook"); + var doc = ContentStackMapper.ToSiteDocument(item); + + doc.Should().NotBeNull(); + doc.Section.Should().Be("search-labs"); + doc.Description.Should().Contain("Learn how to build a RAG system"); + doc.Body.Should().Be(doc.Description); + doc.Headings.Should().BeEmpty(); + } + + /// Empty page_info.description.content_simple_l10n must not win over the SEO fallback. + [Fact] + public void ToSiteDocument_LandingPage_WithEmptyRichDescription_FallsBackToSeo() + { + var item = LoadFromJson(/*lang=json,strict*/ """ + { "title": "Integrations", "url": "/search-labs/integrations", "locale": "en-us", + "page_info": { "description": { "content_simple_l10n": { "type": "doc", "children": [] } } }, + "seo": { "seo_description_l10n": "Browse Search Labs integrations." } } + """, "integrations_landing"); + var doc = ContentStackMapper.ToSiteDocument(item); + + doc.Should().NotBeNull(); + doc.Description.Should().Be("Browse Search Labs integrations."); + } + + /// page_info.description.content_simple_l10n is used when there's no subheading_l10n. + [Fact] + public void ToSiteDocument_GlossaryPage_UsesPageInfoRichTextDescription_WhenNoSubheading() + { + var item = LoadFromJson(/*lang=json,strict*/ """ + { "title": "Glossary", "url": "/glossary", "locale": "en-us", + "page_info": { "description": { "content_simple_l10n": { "type": "doc", + "children": [ { "type": "p", "children": [ { "text": "All the terms, concepts, and abbreviations." } ] } ] } } } } + """, "glossary"); + var doc = ContentStackMapper.ToSiteDocument(item); + + doc.Should().NotBeNull(); + doc.Description.Should().Contain("terms, concepts, and abbreviations"); + doc.Section.Should().Be("glossary"); + } + // --- Body projection helper tests --- [Fact] @@ -292,6 +388,9 @@ public void ExtractHeadings_Returns_Empty_For_No_Headings() [Fact] public void GetNavigationSection_Classifies_Known_Paths() { + ContentStackMapper.GetNavigationSection("/search-labs").Should().Be("search-labs"); + ContentStackMapper.GetNavigationSection("/search-labs/blog/some-post").Should().Be("search-labs"); + ContentStackMapper.GetNavigationSection("/glossary").Should().Be("glossary"); ContentStackMapper.GetNavigationSection("/blog/some-post").Should().Be("blog"); ContentStackMapper.GetNavigationSection("/what-is/elasticsearch").Should().Be("concept"); ContentStackMapper.GetNavigationSection("/webinars/live-event").Should().Be("webinar"); diff --git a/tests/Elastic.SiteSearch.Tests/Fixtures/ContentStack/description_only.json b/tests/Elastic.SiteSearch.Tests/Fixtures/ContentStack/description_only.json new file mode 100644 index 0000000000..ec38f30f04 --- /dev/null +++ b/tests/Elastic.SiteSearch.Tests/Fixtures/ContentStack/description_only.json @@ -0,0 +1,12 @@ +{ + "title": "Using Cohere for RAG and Rerank in Elasticsearch", + "title_l10n": "Using Cohere for RAG and Rerank in Elasticsearch", + "url": "/search-labs/tutorials/examples/cohere-rag-rerank-elasticsearch-tutorial", + "locale": "en-us", + "description_l10n": "Learn how to build a RAG system using the Cohere Inference API and Elasticsearch with embeddings, hybrid search, and advanced reranking.", + "seo": { + "seo_title_l10n": "Cohere RAG and Rerank with Elasticsearch", + "seo_description_l10n": "Learn how to build a RAG system using the Cohere Inference API." + }, + "publish_details": { "locale": "en-us" } +} diff --git a/tests/Elastic.SiteSearch.Tests/Fixtures/ContentStack/landing_page.json b/tests/Elastic.SiteSearch.Tests/Fixtures/ContentStack/landing_page.json new file mode 100644 index 0000000000..6b41b15fe2 --- /dev/null +++ b/tests/Elastic.SiteSearch.Tests/Fixtures/ContentStack/landing_page.json @@ -0,0 +1,27 @@ +{ + "title": "Tutorials", + "title_l10n": "Tutorials", + "url": "/search-labs/tutorials", + "locale": "en-us", + "page_info": { + "subheading_l10n": "Long-form guides to get you started", + "description": { + "content_simple_l10n": { + "type": "doc", + "children": [ + { + "type": "p", + "children": [ + { "text": "Get started combining Elastic's search capabilities with machine learning." } + ] + } + ] + } + } + }, + "seo": { + "seo_title_l10n": "Elasticsearch tutorials", + "seo_description_l10n": "" + }, + "publish_details": { "locale": "en-us" } +} diff --git a/tests/Elastic.SiteSearch.Tests/Fixtures/ContentStack/rich_text_blog_v3.json b/tests/Elastic.SiteSearch.Tests/Fixtures/ContentStack/rich_text_blog_v3.json new file mode 100644 index 0000000000..5c855a0675 --- /dev/null +++ b/tests/Elastic.SiteSearch.Tests/Fixtures/ContentStack/rich_text_blog_v3.json @@ -0,0 +1,32 @@ +{ + "title": "Hybrid Search With Elasticsearch", + "title_l10n": "Hybrid Search With Elasticsearch", + "url": "/search-labs/blog/hybrid-search-with-elasticsearch", + "locale": "en-us", + "summary_l10n": "Combine lexical and semantic search for better relevance.", + "main_content": { + "body": { + "content_l10n": { + "type": "doc", + "children": [ + { + "type": "h2", + "children": [ { "text": "Why hybrid search" } ] + }, + { + "type": "p", + "children": [ + { "text": "Hybrid search combines BM25 lexical scoring with dense vector search to improve relevance." } + ] + } + ] + } + } + }, + "seo": { + "seo_title_l10n": "Hybrid Search With Elasticsearch", + "seo_description_l10n": "" + }, + "publish_date": "2026-06-01T00:00:00.000Z", + "publish_details": { "locale": "en-us" } +} diff --git a/tests/Elastic.SiteSearch.Tests/Fixtures/ContentStack/rich_text_page.json b/tests/Elastic.SiteSearch.Tests/Fixtures/ContentStack/rich_text_page.json new file mode 100644 index 0000000000..e6d3c95a64 --- /dev/null +++ b/tests/Elastic.SiteSearch.Tests/Fixtures/ContentStack/rich_text_page.json @@ -0,0 +1,62 @@ +{ + "title": "Amazon Bedrock", + "title_l10n": "Amazon Bedrock", + "url": "/search-labs/integrations/amazon-bedrock", + "locale": "en-us", + "description_l10n": { + "content_simple_l10n": { + "type": "doc", + "children": [ + { + "type": "p", + "children": [ + { "text": "Amazon Bedrock is a fully managed service offering foundation models from leading AI companies." } + ] + } + ] + } + }, + "main_content": { + "content_l10n": { + "type": "doc", + "children": [ + { + "type": "p", + "children": [ + { "text": "Amazon Bedrock makes leading foundation models available through a single API." } + ] + }, + { + "type": "h3", + "children": [ { "text": "Blogs to get started" } ] + }, + { + "type": "ul", + "children": [ + { + "type": "li", + "children": [ + { + "type": "p", + "children": [ + { + "type": "a", + "attrs": { "url": "https://www.elastic.co/search-labs/blog/example", "target": "_blank" }, + "children": [ { "text": "RAG using Bedrock" } ] + } + ] + } + ] + } + ] + } + ] + } + }, + "seo": { + "seo_title_l10n": "Amazon Bedrock Integration", + "seo_description_l10n": "Connect Amazon Bedrock to Elasticsearch." + }, + "updated_at": "2026-07-01T00:00:00.000Z", + "publish_details": { "locale": "en-us" } +} diff --git a/tests/Elastic.SiteSearch.Tests/Fixtures/ContentStack/schema-groups.json b/tests/Elastic.SiteSearch.Tests/Fixtures/ContentStack/schema-groups.json index a40859d3f8..620da6f8b2 100644 --- a/tests/Elastic.SiteSearch.Tests/Fixtures/ContentStack/schema-groups.json +++ b/tests/Elastic.SiteSearch.Tests/Fixtures/ContentStack/schema-groups.json @@ -51,6 +51,26 @@ "body_strategy": "paragraph_l10n (short text)", "content_types": ["customer_tile"] }, + "rich_text_page": { + "fixture": "rich_text_page.json", + "body_strategy": "main_content.content_l10n (ProseMirror-style JSON AST) + description_l10n.content_simple_l10n (rich-text description)", + "content_types": ["tutorial", "tutorial_page", "tutorial_chapter", "labs_integration"] + }, + "rich_text_blog_v3": { + "fixture": "rich_text_blog_v3.json", + "body_strategy": "main_content.body.content_l10n (rich-text nested one level deeper than other rich-text types) + summary_l10n description", + "content_types": ["blog_v3"] + }, + "landing_page": { + "fixture": "landing_page.json", + "body_strategy": "No body content - page_info.subheading_l10n / page_info.description.content_simple_l10n description fallback only", + "content_types": ["tutorials_landing", "integrations_landing", "blog_landing"] + }, + "description_only": { + "fixture": "description_only.json", + "body_strategy": "description_l10n (plain string) - no body content, falls back to description", + "content_types": ["notebook", "series", "labs_category", "labs_homepage", "glossary", "examples_landing"] + }, "minimal_page": { "fixture": "minimal_page.json", "body_strategy": "No body content - title/url/seo only",