Skip to content

Commit 3a1f80d

Browse files
NetdocsCopilot
andcommitted
Gate .mkdocsignore on file-filter enabled state
ContentDiscovery applied `.mkdocsignore` unconditionally, so dev-only sections (e.g. listed for production exclusion) were hidden on every build, including development and serve. This mirrors the mkdocs behaviour where `.mkdocsignore` is owned by the file-filter plugin and only prunes content when the filter is enabled. - Add FileFilterSettings: a shared loader for `.file-filter.yml` that resolves enabled/enabled_on_serve (with !ENV support) plus the mkdocsignore + label-filter options, exposing IsActive, AppliesMkdocsIgnore and AppliesLabelFilter. - IgnoreRules.Load now takes includeIgnoreFile / ignoreFileName so the ignore file can be skipped while config `exclude` globs stay always-on. - ContentDiscovery takes BuildOptions and only applies the ignore file when the filter is active for the current mode. No `.file-filter.yml` keeps the ignore file always-on (backward compatible, gitignore-like). - FileFilterPlugin reuses FileFilterSettings so label pruning and path pruning share one enabled gate. - Add FileFilterTests covering settings resolution and a dev-vs-prod discovery integration test. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 08268eb commit 3a1f80d

6 files changed

Lines changed: 255 additions & 29 deletions

File tree

src/Netdocs.Core/BuildEngine.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ public async Task<SiteContext> BuildAsync(CancellationToken ct = default)
5555
List<Page> pages;
5656
using (Measure("1. content discovery"))
5757
{
58-
var discovery = new ContentDiscovery(config, loggerFactory.CreateLogger<ContentDiscovery>());
58+
var discovery = new ContentDiscovery(config, options, loggerFactory.CreateLogger<ContentDiscovery>());
5959
pages = discovery.Discover().ToList();
6060
}
6161

src/Netdocs.Core/Content/ContentDiscovery.cs

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
namespace Netdocs.Core.Content;
55

66
/// <summary>Walks the docs directory and produces <see cref="Page"/> instances.</summary>
7-
public sealed class ContentDiscovery(SiteConfig config, ILogger<ContentDiscovery> logger)
7+
public sealed class ContentDiscovery(SiteConfig config, BuildOptions options, ILogger<ContentDiscovery> logger)
88
{
99
private static readonly string[] MarkdownExtensions = [".md", ".markdown"];
1010

@@ -14,7 +14,11 @@ public IReadOnlyList<Page> Discover()
1414
if (!Directory.Exists(docsDir))
1515
throw new DirectoryNotFoundException($"docs_dir not found: {docsDir}");
1616

17-
var ignore = IgnoreRules.Load(config.ProjectRoot, docsDir, config.Exclude);
17+
// `.mkdocsignore` only prunes content when the file-filter is active for this build mode
18+
// (production, by default). On dev / serve builds the listed dev-only sections stay in.
19+
var filter = FileFilterSettings.Load(config.ProjectRoot);
20+
var applyIgnoreFile = filter.AppliesMkdocsIgnore(options.IsServe);
21+
var ignore = IgnoreRules.Load(config.ProjectRoot, docsDir, config.Exclude, applyIgnoreFile, filter.MkdocsignoreFile);
1822
var pages = new List<Page>();
1923

2024
foreach (var file in Directory.EnumerateFiles(docsDir, "*", SearchOption.AllDirectories))
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
using Netdocs.Core.Configuration;
2+
3+
namespace Netdocs.Core.Content;
4+
5+
/// <summary>
6+
/// Reads <c>.file-filter.yml</c> (mkdocs-file-filter compatible) from the project root and
7+
/// resolves whether content filtering is active for the current build mode.
8+
///
9+
/// The whole filter — front-matter label pruning AND <c>.mkdocsignore</c> path exclusion — is
10+
/// gated behind <c>enabled</c> / <c>enabled_on_serve</c> (both support <c>!ENV</c> lookups, which
11+
/// <see cref="YamlTree"/> resolves at parse time). This lets a development or <c>serve</c> build
12+
/// keep draft / dev-only content that a production build hides, matching the upstream plugin.
13+
///
14+
/// When no <c>.file-filter.yml</c> exists, a <c>.mkdocsignore</c> is treated as an always-on
15+
/// ignore file (like <c>.gitignore</c>) so simple projects that never opted into the filter keep
16+
/// their previous behaviour.
17+
/// </summary>
18+
public sealed class FileFilterSettings
19+
{
20+
public bool Exists { get; private init; }
21+
public bool Enabled { get; private init; } = true;
22+
public bool EnabledOnServe { get; private init; } = true;
23+
public bool Mkdocsignore { get; private init; } = true;
24+
public string MkdocsignoreFile { get; private init; } = ".mkdocsignore";
25+
public string MetadataProperty { get; private init; } = "labels";
26+
public IReadOnlySet<string> ExcludeTags { get; private init; } = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
27+
public IReadOnlySet<string> IncludeTags { get; private init; } = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
28+
29+
public static FileFilterSettings Load(string projectRoot)
30+
{
31+
var path = Path.Combine(projectRoot, ".file-filter.yml");
32+
if (!File.Exists(path))
33+
return new FileFilterSettings { Exists = false };
34+
35+
var root = YamlTree.Parse(File.ReadAllText(path)).AsMap();
36+
37+
var excludeTags = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
38+
foreach (var t in root.Get("exclude_tag").AsList())
39+
if (t.AsString() is { Length: > 0 } s) excludeTags.Add(s);
40+
41+
var includeTags = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
42+
foreach (var t in root.Get("include_tag").AsList())
43+
if (t.AsString() is { Length: > 0 } s) includeTags.Add(s);
44+
45+
var file = root.Get("mkdocsignore_file").AsString();
46+
var prop = root.Get("metadata_property").AsString();
47+
48+
return new FileFilterSettings
49+
{
50+
Exists = true,
51+
Enabled = root.Get("enabled").AsBool(true),
52+
EnabledOnServe = root.TryGetValue("enabled_on_serve", out var eos) ? eos.AsBool(true) : true,
53+
Mkdocsignore = root.Get("mkdocsignore").AsBool(true),
54+
MkdocsignoreFile = string.IsNullOrWhiteSpace(file) ? ".mkdocsignore" : file!,
55+
MetadataProperty = prop is { Length: > 0 } ? prop : "labels",
56+
ExcludeTags = excludeTags,
57+
IncludeTags = includeTags,
58+
};
59+
}
60+
61+
/// <summary>Whether the filter runs at all for this build (the <c>enabled</c> gate).</summary>
62+
public bool IsActive(bool isServe) =>
63+
Exists && (isServe ? Enabled && EnabledOnServe : Enabled);
64+
65+
/// <summary>Whether label-based pruning runs (the gate AND at least one <c>exclude_tag</c>).</summary>
66+
public bool AppliesLabelFilter(bool isServe) => IsActive(isServe) && ExcludeTags.Count > 0;
67+
68+
/// <summary>
69+
/// Whether <c>.mkdocsignore</c> path rules should be applied for this build. With no filter
70+
/// config the ignore file is always honoured; otherwise it follows the <c>enabled</c> gate so
71+
/// dev-only sections listed there reappear on non-production builds.
72+
/// </summary>
73+
public bool AppliesMkdocsIgnore(bool isServe) => !Exists || (Mkdocsignore && IsActive(isServe));
74+
}

src/Netdocs.Core/Content/IgnoreRules.cs

Lines changed: 19 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,24 @@ public sealed partial class IgnoreRules
99

1010
private IgnoreRules(List<Regex> patterns) => _patterns = patterns;
1111

12-
public static IgnoreRules Load(string projectRoot, string docsDir)
12+
/// <param name="includeIgnoreFile">
13+
/// When true, patterns from the <paramref name="ignoreFileName"/> file (default
14+
/// <c>.mkdocsignore</c>) are loaded. Callers pass false to skip it — e.g. a development build
15+
/// where the file-filter is disabled — so dev-only sections listed there stay in the site.
16+
/// </param>
17+
/// <param name="ignoreFileName">Name of the ignore file to read (from docs dir, then project root).</param>
18+
public static IgnoreRules Load(
19+
string projectRoot,
20+
string docsDir,
21+
IEnumerable<string> extraPatterns,
22+
bool includeIgnoreFile = true,
23+
string ignoreFileName = ".mkdocsignore")
1324
{
1425
var patterns = new List<Regex>();
15-
foreach (var name in new[] { ".mkdocsignore" })
26+
27+
if (includeIgnoreFile)
1628
{
17-
foreach (var path in new[] { Path.Combine(docsDir, name), Path.Combine(projectRoot, name) })
29+
foreach (var path in new[] { Path.Combine(docsDir, ignoreFileName), Path.Combine(projectRoot, ignoreFileName) })
1830
{
1931
if (!File.Exists(path)) continue;
2032
foreach (var line in File.ReadAllLines(path))
@@ -25,18 +37,15 @@ public static IgnoreRules Load(string projectRoot, string docsDir)
2537
}
2638
}
2739
}
28-
return new IgnoreRules(patterns);
29-
}
3040

31-
public static IgnoreRules Load(string projectRoot, string docsDir, IEnumerable<string> extraPatterns)
32-
{
33-
var rules = Load(projectRoot, docsDir);
41+
// Explicit config `exclude` globs are always honoured, independent of the ignore file.
3442
foreach (var pattern in extraPatterns)
3543
{
3644
var trimmed = pattern.Trim();
37-
if (trimmed.Length > 0) rules._patterns.Add(GlobToRegex(trimmed));
45+
if (trimmed.Length > 0) patterns.Add(GlobToRegex(trimmed));
3846
}
39-
return rules;
47+
48+
return new IgnoreRules(patterns);
4049
}
4150

4251
public bool IsIgnored(string relativePath)

src/Netdocs.Plugins/StubPlugins.cs

Lines changed: 10 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
using Markdig;
22
using Netdocs.Abstractions;
33
using Netdocs.Core.Configuration;
4+
using Netdocs.Core.Content;
45

56
namespace Netdocs.Plugins;
67

@@ -21,22 +22,15 @@ public sealed class FileFilterPlugin : IPlugin, INavigationFilter
2122

2223
public void Configure(IPluginContext ctx)
2324
{
24-
var path = Path.Combine(ctx.Config.ProjectRoot, ".file-filter.yml");
25-
if (!File.Exists(path)) { _active = false; return; }
26-
27-
var root = YamlTree.Parse(File.ReadAllText(path)).AsMap();
28-
29-
var enabled = root.Get("enabled").AsBool(true);
30-
var enabledOnServe = root.TryGetValue("enabled_on_serve", out var eos) ? eos.AsBool(true) : true;
31-
_active = ctx.Options.IsServe ? enabled && enabledOnServe : enabled;
32-
33-
if (root.Get("metadata_property").AsString() is { Length: > 0 } prop) _metadataProperty = prop;
34-
foreach (var t in root.Get("exclude_tag").AsList())
35-
if (t.AsString() is { Length: > 0 } s) _excludeTags.Add(s);
36-
foreach (var t in root.Get("include_tag").AsList())
37-
if (t.AsString() is { Length: > 0 } s) _includeTags.Add(s);
38-
39-
if (_excludeTags.Count == 0) _active = false;
25+
// Path-based `.mkdocsignore` pruning is handled during content discovery; this plugin owns
26+
// the front-matter label filter. Both share the same enabled gate via FileFilterSettings.
27+
var settings = FileFilterSettings.Load(ctx.Config.ProjectRoot);
28+
_active = settings.AppliesLabelFilter(ctx.Options.IsServe);
29+
if (!_active) return;
30+
31+
_metadataProperty = settings.MetadataProperty;
32+
foreach (var t in settings.ExcludeTags) _excludeTags.Add(t);
33+
foreach (var t in settings.IncludeTags) _includeTags.Add(t);
4034
}
4135

4236
public bool ShouldInclude(Page page, SiteContext site)
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
using Microsoft.Extensions.Logging.Abstractions;
2+
using Netdocs.Abstractions;
3+
using Netdocs.Core.Content;
4+
using Xunit;
5+
6+
namespace Netdocs.Core.Tests;
7+
8+
/// <summary>
9+
/// Covers <see cref="FileFilterSettings"/> resolution and the gating of <c>.mkdocsignore</c> in
10+
/// <see cref="ContentDiscovery"/>. The key behaviour: dev-only sections listed in
11+
/// <c>.mkdocsignore</c> stay in non-production builds and disappear only when the file-filter is
12+
/// enabled (production).
13+
/// </summary>
14+
public class FileFilterTests : IDisposable
15+
{
16+
private readonly string _root;
17+
18+
public FileFilterTests()
19+
{
20+
_root = Path.Combine(Path.GetTempPath(), "ndfilter-" + Guid.NewGuid().ToString("N"));
21+
Directory.CreateDirectory(Path.Combine(_root, "docs"));
22+
}
23+
24+
public void Dispose()
25+
{
26+
try { Directory.Delete(_root, recursive: true); } catch { /* best effort */ }
27+
}
28+
29+
private void WriteFilter(string yaml) => File.WriteAllText(Path.Combine(_root, ".file-filter.yml"), yaml);
30+
31+
// ---- FileFilterSettings unit behaviour -------------------------------------------------
32+
33+
[Fact]
34+
public void NoConfig_IgnoreFileAlwaysApplies_FilterInactive()
35+
{
36+
var s = FileFilterSettings.Load(_root);
37+
38+
Assert.False(s.Exists);
39+
Assert.False(s.IsActive(isServe: false));
40+
Assert.True(s.AppliesMkdocsIgnore(isServe: false)); // backward compatible: honored
41+
Assert.True(s.AppliesMkdocsIgnore(isServe: true));
42+
Assert.False(s.AppliesLabelFilter(isServe: false));
43+
}
44+
45+
[Fact]
46+
public void EnabledFalse_IgnoreFileSkipped()
47+
{
48+
WriteFilter("enabled: false\nmkdocsignore: true\n");
49+
var s = FileFilterSettings.Load(_root);
50+
51+
Assert.True(s.Exists);
52+
Assert.False(s.IsActive(isServe: false));
53+
Assert.False(s.AppliesMkdocsIgnore(isServe: false));
54+
}
55+
56+
[Fact]
57+
public void EnabledTrue_IgnoreFileApplied_OnBuild_ButNotServeWhenServeDisabled()
58+
{
59+
WriteFilter("enabled: true\nenabled_on_serve: false\nmkdocsignore: true\n");
60+
var s = FileFilterSettings.Load(_root);
61+
62+
Assert.True(s.IsActive(isServe: false)); // production build
63+
Assert.True(s.AppliesMkdocsIgnore(isServe: false));
64+
Assert.False(s.IsActive(isServe: true)); // serve stays off
65+
Assert.False(s.AppliesMkdocsIgnore(isServe: true));
66+
}
67+
68+
[Fact]
69+
public void EnvResolution_ProdBuildFlag_TogglesFilter()
70+
{
71+
WriteFilter("enabled: !ENV [MKDOCS_PROD_BUILD, false]\nmkdocsignore: true\n");
72+
73+
Environment.SetEnvironmentVariable("MKDOCS_PROD_BUILD", "false");
74+
Assert.False(FileFilterSettings.Load(_root).AppliesMkdocsIgnore(isServe: false));
75+
76+
Environment.SetEnvironmentVariable("MKDOCS_PROD_BUILD", "true");
77+
Assert.True(FileFilterSettings.Load(_root).AppliesMkdocsIgnore(isServe: false));
78+
79+
Environment.SetEnvironmentVariable("MKDOCS_PROD_BUILD", null);
80+
}
81+
82+
[Fact]
83+
public void LabelFilter_RequiresEnabledAndExcludeTags()
84+
{
85+
WriteFilter("enabled: true\nexclude_tag:\n - draft\n");
86+
Assert.True(FileFilterSettings.Load(_root).AppliesLabelFilter(isServe: false));
87+
88+
WriteFilter("enabled: true\n"); // no exclude tags
89+
Assert.False(FileFilterSettings.Load(_root).AppliesLabelFilter(isServe: false));
90+
91+
WriteFilter("enabled: false\nexclude_tag:\n - draft\n");
92+
Assert.False(FileFilterSettings.Load(_root).AppliesLabelFilter(isServe: false));
93+
}
94+
95+
// ---- ContentDiscovery integration ------------------------------------------------------
96+
97+
private void Seed()
98+
{
99+
var docs = Path.Combine(_root, "docs");
100+
File.WriteAllText(Path.Combine(docs, "index.md"), "# Home\n");
101+
Directory.CreateDirectory(Path.Combine(docs, "teams"));
102+
File.WriteAllText(Path.Combine(docs, "teams", "index.md"), "# Teams\n");
103+
File.WriteAllText(Path.Combine(_root, ".mkdocsignore"), "teams/\n");
104+
}
105+
106+
private IReadOnlyList<Page> Discover(bool isProduction)
107+
{
108+
var config = new SiteConfig { ProjectRoot = _root, DocsDir = "docs" };
109+
var options = new BuildOptions { IsProduction = isProduction, IsServe = false };
110+
return new ContentDiscovery(config, options, NullLogger<ContentDiscovery>.Instance).Discover();
111+
}
112+
113+
[Fact]
114+
public void DevBuild_KeepsMkdocsIgnoredSection_WhenFilterDisabled()
115+
{
116+
Seed();
117+
WriteFilter("enabled: false\nmkdocsignore: true\n");
118+
119+
var pages = Discover(isProduction: false);
120+
121+
Assert.Contains(pages, p => p.RelativePath.Replace('\\', '/') == "teams/index.md");
122+
}
123+
124+
[Fact]
125+
public void ProdBuild_HidesMkdocsIgnoredSection_WhenFilterEnabled()
126+
{
127+
Seed();
128+
WriteFilter("enabled: true\nmkdocsignore: true\n");
129+
130+
var pages = Discover(isProduction: true);
131+
132+
Assert.DoesNotContain(pages, p => p.RelativePath.Replace('\\', '/') == "teams/index.md");
133+
Assert.Contains(pages, p => p.RelativePath.Replace('\\', '/') == "index.md");
134+
}
135+
136+
[Fact]
137+
public void NoFilterConfig_HonorsMkdocsIgnore_LikeGitignore()
138+
{
139+
Seed(); // no .file-filter.yml
140+
141+
var pages = Discover(isProduction: false);
142+
143+
Assert.DoesNotContain(pages, p => p.RelativePath.Replace('\\', '/') == "teams/index.md");
144+
}
145+
}

0 commit comments

Comments
 (0)