@@ -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>
2842public 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 {
0 commit comments