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