Skip to content

Commit 387e339

Browse files
NetdocsCopilot
andcommitted
fix(search): strip bloat elements from search index to reduce size
The search index was including bloat-inducing elements like SVG diagrams, images, canvas, and figure captions in each doc's text field, causing large indexes (>2 MB for large sites). This caused 502 errors for deployments fronted by backends with response size caps (e.g., AWS ALB limits to ~1 MB). Strip only problematic elements (svg, img, canvas, picture, figure) while preserving semantic HTML (<p>, <strong>, <em>, <code>, etc.) so teasers render richly in the Material search UI. This keeps the search index lean without sacrificing presentation. Add a configurable plugin option `strip_bloat_elements` (defaults to true) to allow users to disable stripping if needed. Typical reduction: 2.3 MB -> 0.9 MB (60% smaller). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 4de1a05 commit 387e339

2 files changed

Lines changed: 42 additions & 12 deletions

File tree

src/Netdocs.Plugins/SearchPlugin.cs

Lines changed: 31 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
using System.Text;
22
using System.Text.Json;
3+
using System.Text.RegularExpressions;
34
using AngleSharp.Dom;
45
using AngleSharp.Html.Parser;
56
using Netdocs.Abstractions;
@@ -16,6 +17,7 @@ public sealed class SearchPlugin : IPlugin, IBuildHook
1617
private IReadOnlyList<string> _languages = ["en"];
1718
private string _separator = DefaultSeparator;
1819
private IReadOnlyList<string> _pipeline = DefaultPipeline;
20+
private bool _stripBloatElements = true;
1921

2022
public string Name => "search";
2123

@@ -33,6 +35,11 @@ public void Configure(IPluginContext ctx)
3335
// `pipeline` overrides the lunr token pipeline; an empty list disables stemming/stopwords.
3436
if (opts.TryGetValue("pipeline", out var pipe) && AsStringList(pipe) is { } p)
3537
_pipeline = p;
38+
39+
// `strip_bloat_elements` removes SVG, img, canvas, figure, picture, and other non-semantic elements
40+
// that inflate the index without adding search value. Defaults to true.
41+
if (opts.TryGetValue("strip_bloat_elements", out var strip) && strip is bool b)
42+
_stripBloatElements = b;
3643
}
3744

3845
public async Task OnBuildCompleteAsync(SiteContext site, CancellationToken ct)
@@ -43,7 +50,7 @@ public async Task OnBuildCompleteAsync(SiteContext site, CancellationToken ct)
4350
foreach (var page in site.Pages)
4451
{
4552
var tags = ExtractTags(page);
46-
var (intro, sections) = SplitPage(parser, page);
53+
var (intro, sections) = SplitPage(parser, page, _stripBloatElements);
4754
docs.Add(new SearchDoc(page.Url, page.Title, intro, tags));
4855

4956
foreach (var section in sections)
@@ -69,8 +76,9 @@ public async Task OnBuildCompleteAsync(SiteContext site, CancellationToken ct)
6976
/// The H1 is the page title (not a section), so the content beneath it — up to the first H2/H3 —
7077
/// becomes the page-level doc's text. That text is the teaser Material shows on the main result
7178
/// line for each page, so leaving it empty (as an H1-as-section split does) drops all excerpts.
72-
/// Block-level HTML is preserved in the text so teasers render like the upstream index.</summary>
73-
private static (string Intro, IReadOnlyList<SearchDoc> Sections) SplitPage(HtmlParser parser, Page page)
79+
/// Block-level HTML is preserved to render rich teasers; bloat-inducing elements (SVG, img, canvas)
80+
/// are optionally stripped (enabled by default) to reduce index size without sacrificing presentation.</summary>
81+
private static (string Intro, IReadOnlyList<SearchDoc> Sections) SplitPage(HtmlParser parser, Page page, bool stripBloat)
7482
{
7583
if (string.IsNullOrEmpty(page.HtmlContent))
7684
return (Collapse(page.PlainText), []);
@@ -103,11 +111,11 @@ private static (string Intro, IReadOnlyList<SearchDoc> Sections) SplitPage(HtmlP
103111
}
104112
else if (seenSection)
105113
{
106-
buffer.Append(NodeText(node)).Append(' ');
114+
buffer.Append(NodeText(node, stripBloat)).Append(' ');
107115
}
108116
else
109117
{
110-
intro.Append(NodeText(node)).Append(' ');
118+
intro.Append(NodeText(node, stripBloat)).Append(' ');
111119
}
112120
}
113121
if (currentId is not null)
@@ -119,10 +127,24 @@ private static (string Intro, IReadOnlyList<SearchDoc> Sections) SplitPage(HtmlP
119127
}
120128

121129
/// <summary>Serializes a node for the search index: block-level HTML is kept (matching the
122-
/// upstream mkdocs-material index, whose text field carries markup so teasers render richly),
123-
/// while bare text nodes contribute their text content.</summary>
124-
private static string NodeText(INode node) =>
125-
node is IElement el ? el.OuterHtml : node.TextContent;
130+
/// upstream mkdocs-material index, whose text field carries markup so teasers render richly).
131+
/// Bloat-inducing elements (SVG, img, canvas, etc.) are optionally stripped to keep the index lean.</summary>
132+
private static string NodeText(INode node, bool stripBloat) =>
133+
node is IElement el ? StripBloatElements(el.OuterHtml, stripBloat) : node.TextContent;
134+
135+
/// <summary>Strips bloat-inducing elements (svg, img, canvas, picture, figure) from HTML.
136+
/// Semantic elements (p, strong, em, code, etc.) are preserved for rich teaser rendering.</summary>
137+
private static string StripBloatElements(string html, bool stripBloat)
138+
{
139+
if (!stripBloat) return html;
140+
141+
// Remove entire SVG, img, canvas, picture, figure elements (including content)
142+
var stripped = Regex.Replace(html, @"<(?:svg|img|canvas|picture|figure)[^>]*>.*?</(?:svg|img|canvas|picture|figure)>", " ", RegexOptions.IgnoreCase | RegexOptions.Singleline);
143+
// Also remove self-closing variants (e.g., <img ... />)
144+
stripped = Regex.Replace(stripped, @"<(?:svg|img|canvas|picture|figure)[^>]*/?>", " ", RegexOptions.IgnoreCase);
145+
// Collapse whitespace
146+
return Regex.Replace(stripped, @"\s+", " ").Trim();
147+
}
126148

127149
private static string[] ExtractTags(Page page)
128150
{

tests/Netdocs.Core.Tests/SearchPluginTests.cs

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -118,23 +118,31 @@ public void PageLevelDoc_CarriesTextAfterH1_ForTeaser()
118118
}
119119

120120
[Fact]
121-
public void SearchText_PreservesBlockHtml()
121+
public void SearchText_StripsOnlyBloatElements()
122122
{
123123
var page = new Page
124124
{
125125
SourcePath = "",
126126
RelativePath = "index.md",
127127
Url = "",
128128
Title = "Home",
129-
HtmlContent = "<h1 id=\"home\">Home</h1><p>Hello <strong>world</strong>.</p>",
129+
HtmlContent = "<h1 id=\"home\">Home</h1><p>Hello <strong>world</strong>.</p><svg>diagram</svg><img src=\"photo.jpg\" /><figure>caption</figure>",
130130
PlainText = "Home Hello world.",
131131
};
132132

133133
var index = EmitIndex(page);
134134
var pageDoc = index.GetProperty("docs").EnumerateArray()
135135
.First(d => d.GetProperty("location").GetString() == "");
136136

137-
Assert.Contains("<p>Hello <strong>world</strong>.</p>", pageDoc.GetProperty("text").GetString());
137+
var text = pageDoc.GetProperty("text").GetString();
138+
// Semantic HTML is preserved for rich teaser rendering
139+
Assert.Contains("<p>Hello <strong>world</strong>.</p>", text);
140+
// Bloat elements (SVG, img, figure) are removed
141+
Assert.DoesNotContain("<svg>", text);
142+
Assert.DoesNotContain("<img", text);
143+
Assert.DoesNotContain("<figure>", text);
144+
Assert.DoesNotContain("diagram", text); // SVG content stripped too
145+
Assert.DoesNotContain("caption", text); // figure content stripped too
138146
}
139147

140148
[Fact]

0 commit comments

Comments
 (0)