Skip to content

Commit 9926b34

Browse files
NetdocsCopilot
andcommitted
feat(redirects): support per-page redirect_from front matter
Pages can now declare the old URLs they replace directly in front matter under `redirect_from` (or `aliases`), as a string or list. Each emits a client-side redirect to that page's current URL, so a page's historical locations live next to the content and move with it when it is renamed. Front-matter redirects merge with `redirect_maps`/`redirect_files`; an explicitly configured entry still wins on a conflicting source. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 94fcc72 commit 9926b34

2 files changed

Lines changed: 112 additions & 3 deletions

File tree

src/Netdocs.Plugins/RedirectsPlugin.cs

Lines changed: 47 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,17 +9,21 @@ namespace Netdocs.Plugins;
99
/// Emits client-side redirect pages (an <c>index.html</c> with a canonical link plus a
1010
/// <c>meta refresh</c>) for every <c>source → target</c> mapping.
1111
///
12-
/// Mappings come from two places, merged together:
12+
/// Mappings come from three places, merged together:
1313
/// <list type="bullet">
14+
/// <item>Per-page front matter — a page may list the old URLs it replaces under
15+
/// <c>redirect_from</c> (or <c>aliases</c>), as a single string or a list. Each becomes a
16+
/// redirect to that page's current URL. This keeps a page's old locations next to the page
17+
/// itself, so a redirect travels with the content when it is renamed or moved.</item>
1418
/// <item><c>redirect_maps</c> — an inline object of <c>{ "old/path": "new/url" }</c> pairs.</item>
1519
/// <item><c>redirect_files</c> — a single path or an array of paths to JSON file(s) holding
1620
/// bulk redirects. Each file may be either an object map <c>{ "old/path": "new/url" }</c>
1721
/// or an array of objects <c>[{ "source": "old/path", "target": "new/url", "status": 308 }]</c>.
1822
/// Paths are resolved relative to the project root, then the docs directory, then treated as
1923
/// absolute. This lets large migration redirect tables live outside the config file.</item>
2024
/// </list>
21-
/// Later entries win on conflict, so an inline <c>redirect_maps</c> value overrides one loaded
22-
/// from a file with the same source.
25+
/// Later entries win on conflict, so an explicitly configured <c>redirect_maps</c>/<c>redirect_files</c>
26+
/// value overrides a per-page <c>redirect_from</c> for the same source.
2327
/// </summary>
2428
public sealed class RedirectsPlugin : IPlugin, IBuildHook
2529
{
@@ -58,7 +62,13 @@ public void Configure(IPluginContext ctx)
5862

5963
public async Task OnBuildCompleteAsync(SiteContext site, CancellationToken ct)
6064
{
65+
// Merge sources: per-page front matter first, then explicitly configured maps (which win).
66+
var redirects = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
67+
CollectFrontMatterRedirects(site, redirects);
6168
foreach (var (source, target) in _maps)
69+
redirects[source] = target;
70+
71+
foreach (var (source, target) in redirects)
6272
{
6373
var relative = OutputPathFor(source);
6474
var dest = Path.Combine(site.Config.AbsoluteSiteDir, relative.Replace('/', Path.DirectorySeparatorChar));
@@ -80,6 +90,40 @@ public async Task OnBuildCompleteAsync(SiteContext site, CancellationToken ct)
8090
}
8191
}
8292

93+
/// <summary>Collects redirects declared in page front matter (<c>redirect_from</c> or
94+
/// <c>aliases</c>), each pointing at that page's current site-relative URL.</summary>
95+
private static void CollectFrontMatterRedirects(SiteContext site, Dictionary<string, string> into)
96+
{
97+
foreach (var page in site.Pages)
98+
{
99+
if (string.IsNullOrWhiteSpace(page.Url)) continue;
100+
var target = "/" + page.Url.TrimStart('/');
101+
102+
foreach (var key in new[] { "redirect_from", "aliases" })
103+
{
104+
if (!page.FrontMatter.TryGetValue(key, out var value)) continue;
105+
foreach (var source in AsStrings(value))
106+
into[source] = target;
107+
}
108+
}
109+
}
110+
111+
/// <summary>Yields the trimmed string values of a front-matter scalar or list.</summary>
112+
private static IEnumerable<string> AsStrings(object? value)
113+
{
114+
switch (value)
115+
{
116+
case string s when s.Trim().Length > 0:
117+
yield return s.Trim();
118+
break;
119+
case IEnumerable<object?> list:
120+
foreach (var item in list)
121+
if (item?.ToString() is { } s && s.Trim().Length > 0)
122+
yield return s.Trim();
123+
break;
124+
}
125+
}
126+
83127
/// <summary>Maps a redirect source path to the relative output HTML file it is written to.</summary>
84128
private static string OutputPathFor(string source)
85129
{

tests/Netdocs.Core.Tests/RedirectsPluginTests.cs

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,14 @@ private SiteContext Site()
3030
return site;
3131
}
3232

33+
private static Page PageWith(string url, string frontMatterKey, object? value) => new()
34+
{
35+
SourcePath = "x",
36+
RelativePath = url + ".md",
37+
Url = url + "/",
38+
FrontMatter = new Dictionary<string, object?> { [frontMatterKey] = value },
39+
};
40+
3341
private async Task<SiteContext> Run(IReadOnlyDictionary<string, object?> options)
3442
{
3543
var site = Site();
@@ -137,6 +145,63 @@ public async Task MissingFile_DoesNotThrow()
137145
Directory.EnumerateFiles(site.Config.AbsoluteSiteDir).Any());
138146
}
139147

148+
[Fact]
149+
public async Task FrontMatter_RedirectFromString_EmitsStubToPageUrl()
150+
{
151+
var site = Site();
152+
site.Pages.Add(PageWith("blog/2018/ls-how-to-turn-on-the-alternator",
153+
"redirect_from", "blog/2018/ls--how-to-turn-on-the-alternator/"));
154+
var plugin = new RedirectsPlugin();
155+
plugin.Configure(new FakeContext(new Dictionary<string, object?>(), site.Config));
156+
await plugin.OnBuildCompleteAsync(site, default);
157+
158+
var html = ReadStub(site, "blog/2018/ls--how-to-turn-on-the-alternator/index.html");
159+
Assert.Contains("url=/blog/2018/ls-how-to-turn-on-the-alternator/", html);
160+
}
161+
162+
[Fact]
163+
public async Task FrontMatter_RedirectFromList_EmitsStubPerEntry()
164+
{
165+
var site = Site();
166+
site.Pages.Add(PageWith("new/page", "redirect_from",
167+
new List<object?> { "old/a/", "old/b/" }));
168+
var plugin = new RedirectsPlugin();
169+
plugin.Configure(new FakeContext(new Dictionary<string, object?>(), site.Config));
170+
await plugin.OnBuildCompleteAsync(site, default);
171+
172+
Assert.Contains("url=/new/page/", ReadStub(site, "old/a/index.html"));
173+
Assert.Contains("url=/new/page/", ReadStub(site, "old/b/index.html"));
174+
}
175+
176+
[Fact]
177+
public async Task FrontMatter_AliasesKey_AlsoSupported()
178+
{
179+
var site = Site();
180+
site.Pages.Add(PageWith("new/page", "aliases", "legacy/x/"));
181+
var plugin = new RedirectsPlugin();
182+
plugin.Configure(new FakeContext(new Dictionary<string, object?>(), site.Config));
183+
await plugin.OnBuildCompleteAsync(site, default);
184+
185+
Assert.Contains("url=/new/page/", ReadStub(site, "legacy/x/index.html"));
186+
}
187+
188+
[Fact]
189+
public async Task ConfiguredMap_OverridesFrontMatterForSameSource()
190+
{
191+
var site = Site();
192+
site.Pages.Add(PageWith("from-frontmatter", "redirect_from", "dup/"));
193+
var plugin = new RedirectsPlugin();
194+
plugin.Configure(new FakeContext(
195+
new Dictionary<string, object?>
196+
{
197+
["redirect_maps"] = new Dictionary<string, object?> { ["dup/"] = "/from-config/" },
198+
},
199+
site.Config));
200+
await plugin.OnBuildCompleteAsync(site, default);
201+
202+
Assert.Contains("url=/from-config/", ReadStub(site, "dup/index.html"));
203+
}
204+
140205
private sealed class FakeContext(IReadOnlyDictionary<string, object?> options, SiteConfig config) : IPluginContext
141206
{
142207
public SiteConfig Config { get; } = config;

0 commit comments

Comments
 (0)