Skip to content

Commit 6d32137

Browse files
NetdocsCopilot
andcommitted
feat(redirects): generate redirects automatically on a slugify change
Add a `slugify_redirects` option to the redirects plugin holding one or more previous slugify configurations. At build completion every page URL that the current slugify config would produce is re-slugified under each old config; where the old slug differs, a redirect from the old URL to the current URL is generated. This means changing a slugify parameter (e.g. the separator) no longer silently breaks existing links. - Generic across all URL-producing plugins (blog, tags, content) with no duplication of per-plugin slug logic; keyed only off the final URL. - Only URLs the current config would itself produce are considered, so hand-authored non-slugified paths are left untouched. - Never emits a stub at a path occupied by a real page. - Precedence: generated slugify redirects (lowest), then per-page redirect_from, then configured redirect_maps/redirect_files (highest). Adds 5 tests and documents the option with its separator/case coverage and the ascii-folding limitation. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent e2f89ff commit 6d32137

3 files changed

Lines changed: 267 additions & 6 deletions

File tree

docs-site/docs/plugins/redirects.md

Lines changed: 58 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,15 +7,18 @@ title: redirects
77
Emits tiny client-side redirect pages that forward old URLs to new destinations. Useful
88
for vanity links and for preserving inbound links after restructuring.
99

10-
Redirects can be declared in three ways, from most to least local:
10+
Redirects can be declared in four ways, from most to least local:
1111

1212
1. **Per-page front matter** (`redirect_from`) — recommended for content that moved or was
1313
renamed, because the old URLs live next to the page and travel with it.
1414
2. **`redirect_maps`** — a small inline table in the config.
1515
3. **`redirect_files`** — one or more external JSON files for large bulk tables.
16+
4. **`slugify_redirects`** — generated automatically when the site's `slugify` settings change,
17+
so old slugified URLs keep resolving.
1618

17-
All three are merged. On a conflicting source, an explicitly configured `redirect_maps`/
18-
`redirect_files` entry wins over a per-page `redirect_from`.
19+
All are merged. Precedence, from lowest to highest: generated `slugify_redirects`, then per-page
20+
`redirect_from`, then explicitly configured `redirect_maps`/`redirect_files`. So an explicit
21+
redirect always overrides an automatically generated one for the same source.
1922

2023
## Per-page redirects (`redirect_from`)
2124

@@ -40,6 +43,7 @@ No plugin options are required for this; just enable the `redirects` plugin.
4043
|---|---|---|---|
4144
| `redirect_maps` | object || Inline map of source path → destination URL. |
4245
| `redirect_files` | string \| array || One or more JSON files holding bulk redirects. |
46+
| `slugify_redirects` | object \| array || One or more *previous* slugify configs; old URLs are regenerated and redirected to current ones. |
4347

4448
```json
4549
{
@@ -99,6 +103,57 @@ with the same source. Sources with a leading slash are normalized relative to th
99103
directory. A missing or malformed file logs a warning and is skipped rather than failing the
100104
build.
101105

106+
## Automatic redirects on a slugify change (`slugify_redirects`)
107+
108+
URLs for blog posts, categories, authors, tags and (when `slugify.urls` is on) content pages are
109+
derived from the site's `slugify` settings — the casing, word separator and ASCII folding. If you
110+
later change any of those settings, every affected URL changes too, silently breaking existing
111+
inbound links and bookmarks.
112+
113+
`slugify_redirects` fixes that. List the **previous** slugify configuration(s) and, on the next
114+
build, every current URL is re-slugified under each old configuration; wherever the old slug
115+
differs from the current one, a redirect from the old URL to the current URL is generated
116+
automatically. You do not have to enumerate individual pages.
117+
118+
```json
119+
{
120+
"name": "redirects",
121+
"options": {
122+
"slugify_redirects": { "case": "lower", "separator": "_" }
123+
}
124+
}
125+
```
126+
127+
The example above says "we used to slugify with an underscore separator." After switching the
128+
site's `slugify.separator` to `-`, a post now at `/blog/2018/ls-how-to/` also answers at its old
129+
`/blog/2018/ls_how_to/` URL.
130+
131+
Provide an array to record several historical changes at once:
132+
133+
```json
134+
{
135+
"name": "redirects",
136+
"options": {
137+
"slugify_redirects": [
138+
{ "separator": "_" },
139+
{ "case": "upper", "separator": "-" }
140+
]
141+
}
142+
}
143+
```
144+
145+
Each entry accepts the same keys as the site-level `slugify` block: `case` (`lower` \| `upper` \|
146+
`none`, default `lower`), `separator` (default `-`), and `ascii` (default `false`).
147+
148+
**Notes and limits**
149+
150+
- Only URLs that the **current** slugify config would itself produce are considered, so
151+
hand-authored, non-slugified paths are left untouched.
152+
- A generated redirect is never written at a path already occupied by a real page.
153+
- Old URLs are reconstructed by re-slugifying the current URL, which captures `separator` and
154+
`case` changes. A change to `ascii` folding cannot be reconstructed — the dropped characters are
155+
already gone from the current URL — so handle those with an explicit `redirect_from` on the page.
156+
102157
## Attribution
103158

104159
Behavior is modeled on [mkdocs-redirects](https://github.com/mkdocs/mkdocs-redirects) (MIT). See [Attributions](../about/attributions.md).

src/Netdocs.Plugins/RedirectsPlugin.cs

Lines changed: 110 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,18 +21,36 @@ namespace Netdocs.Plugins;
2121
/// or an array of objects <c>[{ "source": "old/path", "target": "new/url", "status": 308 }]</c>.
2222
/// Paths are resolved relative to the project root, then the docs directory, then treated as
2323
/// absolute. This lets large migration redirect tables live outside the config file.</item>
24+
/// <item><c>slugify_redirects</c> — one or more <em>previous</em> slugify configurations
25+
/// (each an object of <c>{ "case", "separator", "ascii" }</c>, or an array of them). When the
26+
/// site's <c>slugify</c> settings change, every page's URL is re-slugified under each old
27+
/// configuration; wherever the old slug differs from the current one, a redirect from the old
28+
/// URL to the current URL is generated automatically. This means changing a slugify parameter
29+
/// (for example the separator from <c>_</c> to <c>-</c>) does not silently break every existing
30+
/// link — the old URLs keep resolving. Only URLs that the <em>current</em> slugify config would
31+
/// itself produce are considered (so hand-authored, non-slugified paths are left untouched), and
32+
/// a generated redirect is never emitted at a path already occupied by a real page.
33+
/// Note: this reconstructs old URLs by re-slugifying the current URL, so it captures
34+
/// <c>separator</c> and <c>case</c> changes; a change to <c>ascii</c> folding cannot be
35+
/// reconstructed (the dropped characters are already gone) and should be handled with an explicit
36+
/// <c>redirect_from</c>.</item>
2437
/// </list>
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.
38+
/// Merge order (later wins): generated <c>slugify_redirects</c>, then per-page <c>redirect_from</c>,
39+
/// then explicitly configured <c>redirect_maps</c>/<c>redirect_files</c>. So an explicit redirect
40+
/// always overrides an automatically generated one for the same source.
2741
/// </summary>
2842
public sealed class RedirectsPlugin : IPlugin, IBuildHook
2943
{
3044
private readonly Dictionary<string, string> _maps = new(StringComparer.OrdinalIgnoreCase);
45+
private readonly List<SlugifyConfig> _slugifyOldConfigs = [];
3146

3247
public string Name => "redirects";
3348

3449
public void Configure(IPluginContext ctx)
3550
{
51+
// Previous slugify configuration(s), used to regenerate old URLs after a slugify change.
52+
_slugifyOldConfigs.AddRange(ParseSlugifyConfigs(ctx.PluginOptions));
53+
3654
// 1) External JSON file(s) first, so inline entries can override them.
3755
foreach (var file in FileList(ctx.PluginOptions))
3856
{
@@ -62,8 +80,10 @@ public void Configure(IPluginContext ctx)
6280

6381
public async Task OnBuildCompleteAsync(SiteContext site, CancellationToken ct)
6482
{
65-
// Merge sources: per-page front matter first, then explicitly configured maps (which win).
83+
// Merge sources (lowest precedence first): generated slugify redirects, then per-page
84+
// front matter, then explicitly configured maps (which win).
6685
var redirects = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
86+
CollectSlugifyRedirects(site, redirects);
6787
CollectFrontMatterRedirects(site, redirects);
6888
foreach (var (source, target) in _maps)
6989
redirects[source] = target;
@@ -124,6 +144,93 @@ private static IEnumerable<string> AsStrings(object? value)
124144
}
125145
}
126146

147+
/// <summary>Generates redirects for a slugify-configuration change: every page URL that the
148+
/// current slugify config would produce is re-slugified under each previously configured
149+
/// slugify config; where the old slug differs, a redirect from the old URL to the current URL
150+
/// is added. Sources that collide with a real page URL are skipped so a stub never clobbers an
151+
/// actual page.</summary>
152+
private void CollectSlugifyRedirects(SiteContext site, Dictionary<string, string> into)
153+
{
154+
if (_slugifyOldConfigs.Count == 0) return;
155+
156+
var current = site.Config.Slugify;
157+
var pageUrls = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
158+
foreach (var page in site.Pages)
159+
if (!string.IsNullOrWhiteSpace(page.Url))
160+
pageUrls.Add(page.Url.Trim().Trim('/'));
161+
162+
foreach (var page in site.Pages)
163+
{
164+
if (string.IsNullOrWhiteSpace(page.Url)) continue;
165+
var cur = page.Url.Trim().TrimStart('/');
166+
167+
// Only act on URLs the current slugify config would itself produce; this leaves
168+
// hand-authored, non-slugified paths (e.g. when slugify.urls is off) untouched.
169+
if (!string.Equals(ReSlugifyUrl(cur, current), EnsureTrailingSlash(cur), StringComparison.Ordinal))
170+
continue;
171+
172+
var target = "/" + cur.TrimStart('/');
173+
foreach (var old in _slugifyOldConfigs)
174+
{
175+
var oldUrl = ReSlugifyUrl(cur, old);
176+
if (string.Equals(oldUrl, EnsureTrailingSlash(cur), StringComparison.Ordinal)) continue; // unchanged
177+
if (pageUrls.Contains(oldUrl.Trim('/'))) continue; // would clobber a real page
178+
into[oldUrl] = target;
179+
}
180+
}
181+
}
182+
183+
/// <summary>Re-slugifies each path segment of a site-relative URL under the given config,
184+
/// preserving <c>.</c> (matching content-URL slugification), and returns it with a trailing
185+
/// slash. An empty URL (site root) maps to the empty string.</summary>
186+
private static string ReSlugifyUrl(string url, SlugifyConfig config)
187+
{
188+
var trimmed = url.Trim().Trim('/');
189+
if (trimmed.Length == 0) return "";
190+
var segments = trimmed.Split('/');
191+
return string.Join('/', segments.Select(s => Slug.Make(s, config, "."))) + "/";
192+
}
193+
194+
private static string EnsureTrailingSlash(string url)
195+
{
196+
var trimmed = url.Trim().Trim('/');
197+
return trimmed.Length == 0 ? "" : trimmed + "/";
198+
}
199+
200+
/// <summary>Parses the <c>slugify_redirects</c> option, which may be a single object
201+
/// (<c>{ case, separator, ascii }</c>) or an array of such objects.</summary>
202+
private static IEnumerable<SlugifyConfig> ParseSlugifyConfigs(IReadOnlyDictionary<string, object?> options)
203+
{
204+
if (!options.TryGetValue("slugify_redirects", out var value) || value is null)
205+
yield break;
206+
207+
switch (value)
208+
{
209+
case IReadOnlyDictionary<string, object?> single:
210+
yield return ToSlugifyConfig(single);
211+
break;
212+
case IEnumerable<object?> list:
213+
foreach (var item in list)
214+
if (item is IReadOnlyDictionary<string, object?> m)
215+
yield return ToSlugifyConfig(m);
216+
break;
217+
}
218+
}
219+
220+
private static SlugifyConfig ToSlugifyConfig(IReadOnlyDictionary<string, object?> m) => new()
221+
{
222+
Case = m.TryGetValue("case", out var c) && c?.ToString() is { Length: > 0 } cs ? cs : "lower",
223+
Separator = m.TryGetValue("separator", out var s) && s?.ToString() is { } ss ? ss : "-",
224+
Ascii = m.TryGetValue("ascii", out var a) && ToBool(a),
225+
};
226+
227+
private static bool ToBool(object? value) => value switch
228+
{
229+
bool b => b,
230+
string s => string.Equals(s.Trim(), "true", StringComparison.OrdinalIgnoreCase),
231+
_ => false,
232+
};
233+
127234
/// <summary>Maps a redirect source path to the relative output HTML file it is written to.</summary>
128235
private static string OutputPathFor(string source)
129236
{

tests/Netdocs.Core.Tests/RedirectsPluginTests.cs

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,105 @@ public async Task ConfiguredMap_OverridesFrontMatterForSameSource()
202202
Assert.Contains("url=/from-config/", ReadStub(site, "dup/index.html"));
203203
}
204204

205+
[Fact]
206+
public async Task SlugifyRedirect_SeparatorChange_EmitsRedirectToCurrentUrl()
207+
{
208+
var site = Site();
209+
site.Pages.Add(new Page { SourcePath = "x", RelativePath = "x.md", Url = "blog/2018/ls-how-to-turn-on/" });
210+
var plugin = new RedirectsPlugin();
211+
plugin.Configure(new FakeContext(
212+
new Dictionary<string, object?>
213+
{
214+
["slugify_redirects"] = new Dictionary<string, object?> { ["separator"] = "_" },
215+
},
216+
site.Config));
217+
await plugin.OnBuildCompleteAsync(site, default);
218+
219+
var html = ReadStub(site, "blog/2018/ls_how_to_turn_on/index.html");
220+
Assert.Contains("url=/blog/2018/ls-how-to-turn-on/", html);
221+
}
222+
223+
[Fact]
224+
public async Task SlugifyRedirect_NoConfigChange_EmitsNothing()
225+
{
226+
var site = Site();
227+
site.Pages.Add(new Page { SourcePath = "x", RelativePath = "x.md", Url = "blog/ls-how-to/" });
228+
var plugin = new RedirectsPlugin();
229+
plugin.Configure(new FakeContext(
230+
new Dictionary<string, object?>
231+
{
232+
// Same as the current default slugify (lower / "-"), so no URL changes.
233+
["slugify_redirects"] = new Dictionary<string, object?> { ["separator"] = "-", ["case"] = "lower" },
234+
},
235+
site.Config));
236+
await plugin.OnBuildCompleteAsync(site, default);
237+
238+
Assert.False(Directory.Exists(site.Config.AbsoluteSiteDir) &&
239+
Directory.EnumerateFiles(site.Config.AbsoluteSiteDir, "*", SearchOption.AllDirectories).Any());
240+
}
241+
242+
[Fact]
243+
public async Task SlugifyRedirect_ArrayOfConfigs_AllApplied()
244+
{
245+
var site = Site();
246+
site.Pages.Add(new Page { SourcePath = "x", RelativePath = "x.md", Url = "posts/my-post/" });
247+
var plugin = new RedirectsPlugin();
248+
plugin.Configure(new FakeContext(
249+
new Dictionary<string, object?>
250+
{
251+
["slugify_redirects"] = new List<object?>
252+
{
253+
new Dictionary<string, object?> { ["separator"] = "_" },
254+
new Dictionary<string, object?> { ["separator"] = "+" },
255+
},
256+
},
257+
site.Config));
258+
await plugin.OnBuildCompleteAsync(site, default);
259+
260+
Assert.Contains("url=/posts/my-post/", ReadStub(site, "posts/my_post/index.html"));
261+
Assert.Contains("url=/posts/my-post/", ReadStub(site, "posts/my+post/index.html"));
262+
}
263+
264+
[Fact]
265+
public async Task SlugifyRedirect_DoesNotClobberExistingPage()
266+
{
267+
var site = Site();
268+
// Current page whose old-separator slug would collide with a real page below.
269+
site.Pages.Add(new Page { SourcePath = "a", RelativePath = "a.md", Url = "a-b/" });
270+
// A real page already living at the would-be old URL.
271+
site.Pages.Add(new Page { SourcePath = "b", RelativePath = "b.md", Url = "a_b/" });
272+
var plugin = new RedirectsPlugin();
273+
plugin.Configure(new FakeContext(
274+
new Dictionary<string, object?>
275+
{
276+
["slugify_redirects"] = new Dictionary<string, object?> { ["separator"] = "_" },
277+
},
278+
site.Config));
279+
await plugin.OnBuildCompleteAsync(site, default);
280+
281+
// No stub should be written over the real /a_b/ page.
282+
Assert.False(File.Exists(Path.Combine(site.Config.AbsoluteSiteDir, "a_b", "index.html")));
283+
}
284+
285+
[Fact]
286+
public async Task SlugifyRedirect_ConfiguredMapOverridesGeneratedSource()
287+
{
288+
var site = Site();
289+
site.Pages.Add(new Page { SourcePath = "x", RelativePath = "x.md", Url = "blog/ls-how-to/" });
290+
var plugin = new RedirectsPlugin();
291+
plugin.Configure(new FakeContext(
292+
new Dictionary<string, object?>
293+
{
294+
["slugify_redirects"] = new Dictionary<string, object?> { ["separator"] = "_" },
295+
// An explicit map for the same generated source must win.
296+
["redirect_maps"] = new Dictionary<string, object?> { ["blog/ls_how_to/"] = "/somewhere-else/" },
297+
},
298+
site.Config));
299+
await plugin.OnBuildCompleteAsync(site, default);
300+
301+
Assert.Contains("url=/somewhere-else/", ReadStub(site, "blog/ls_how_to/index.html"));
302+
}
303+
205304
private sealed class FakeContext(IReadOnlyDictionary<string, object?> options, SiteConfig config) : IPluginContext
206305
{
207306
public SiteConfig Config { get; } = config;

0 commit comments

Comments
 (0)