/// 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",