@@ -111,6 +111,14 @@ impl HtmlHandlebars {
111111 . insert ( "section" . to_owned ( ) , json ! ( section. to_string( ) ) ) ;
112112 }
113113
114+ let redirects = collect_redirects_for_path ( & filepath, & ctx. html_config . redirect ) ?;
115+ if !redirects. is_empty ( ) {
116+ ctx. data . insert (
117+ "fragment_map" . to_owned ( ) ,
118+ json ! ( serde_json:: to_string( & redirects) ?) ,
119+ ) ;
120+ }
121+
114122 // Render the handlebars template with the data
115123 debug ! ( "Render template" ) ;
116124 let rendered = ctx. handlebars . render ( "index" , & ctx. data ) ?;
@@ -266,15 +274,27 @@ impl HtmlHandlebars {
266274 }
267275
268276 log:: debug!( "Emitting redirects" ) ;
277+ let redirects = combine_fragment_redirects ( redirects) ;
269278
270- for ( original, new) in redirects {
271- log:: debug!( "Redirecting \" {}\" → \" {}\" " , original, new) ;
279+ for ( original, ( dest, fragment_map) ) in redirects {
272280 // Note: all paths are relative to the build directory, so the
273281 // leading slash in an absolute path means nothing (and would mess
274282 // up `root.join(original)`).
275283 let original = original. trim_start_matches ( '/' ) ;
276284 let filename = root. join ( original) ;
277- self . emit_redirect ( handlebars, & filename, new) ?;
285+ if filename. exists ( ) {
286+ // This redirect is handled by the in-page fragment mapper.
287+ continue ;
288+ }
289+ if dest. is_empty ( ) {
290+ bail ! (
291+ "redirect entry for `{original}` only has source paths with `#` fragments\n \
292+ There must be an entry without the `#` fragment to determine the default \
293+ destination."
294+ ) ;
295+ }
296+ log:: debug!( "Redirecting \" {}\" → \" {}\" " , original, dest) ;
297+ self . emit_redirect ( handlebars, & filename, & dest, & fragment_map) ?;
278298 }
279299
280300 Ok ( ( ) )
@@ -285,23 +305,17 @@ impl HtmlHandlebars {
285305 handlebars : & Handlebars < ' _ > ,
286306 original : & Path ,
287307 destination : & str ,
308+ fragment_map : & BTreeMap < String , String > ,
288309 ) -> Result < ( ) > {
289- if original. exists ( ) {
290- // sanity check to avoid accidentally overwriting a real file.
291- let msg = format ! (
292- "Not redirecting \" {}\" to \" {}\" because it already exists. Are you sure it needs to be redirected?" ,
293- original. display( ) ,
294- destination,
295- ) ;
296- return Err ( Error :: msg ( msg) ) ;
297- }
298-
299310 if let Some ( parent) = original. parent ( ) {
300311 std:: fs:: create_dir_all ( parent)
301312 . with_context ( || format ! ( "Unable to ensure \" {}\" exists" , parent. display( ) ) ) ?;
302313 }
303314
315+ let js_map = serde_json:: to_string ( fragment_map) ?;
316+
304317 let ctx = json ! ( {
318+ "fragment_map" : js_map,
305319 "url" : destination,
306320 } ) ;
307321 let f = File :: create ( original) ?;
@@ -934,6 +948,62 @@ struct RenderItemContext<'a> {
934948 chapter_titles : & ' a HashMap < PathBuf , String > ,
935949}
936950
951+ /// Redirect mapping.
952+ ///
953+ /// The key is the source path (like `foo/bar.html`). The value is a tuple
954+ /// `(destination_path, fragment_map)`. The `destination_path` is the page to
955+ /// redirect to. `fragment_map` is the map of fragments that override the
956+ /// destination. For example, a fragment `#foo` could redirect to any other
957+ /// page or site.
958+ type CombinedRedirects = BTreeMap < String , ( String , BTreeMap < String , String > ) > ;
959+ fn combine_fragment_redirects ( redirects : & HashMap < String , String > ) -> CombinedRedirects {
960+ let mut combined: CombinedRedirects = BTreeMap :: new ( ) ;
961+ // This needs to extract the fragments to generate the fragment map.
962+ for ( original, new) in redirects {
963+ if let Some ( ( source_path, source_fragment) ) = original. rsplit_once ( '#' ) {
964+ let e = combined. entry ( source_path. to_string ( ) ) . or_default ( ) ;
965+ if let Some ( old) = e. 1 . insert ( format ! ( "#{source_fragment}" ) , new. clone ( ) ) {
966+ log:: error!(
967+ "internal error: found duplicate fragment redirect \
968+ {old} for {source_path}#{source_fragment}"
969+ ) ;
970+ }
971+ } else {
972+ let e = combined. entry ( original. to_string ( ) ) . or_default ( ) ;
973+ e. 0 = new. clone ( ) ;
974+ }
975+ }
976+ combined
977+ }
978+
979+ /// Collects fragment redirects for an existing page.
980+ ///
981+ /// The returned map has keys like `#foo` and the value is the new destination
982+ /// path or URL.
983+ fn collect_redirects_for_path (
984+ path : & Path ,
985+ redirects : & HashMap < String , String > ,
986+ ) -> Result < BTreeMap < String , String > > {
987+ let path = format ! ( "/{}" , path. display( ) . to_string( ) . replace( '\\' , "/" ) ) ;
988+ if redirects. contains_key ( & path) {
989+ bail ! (
990+ "redirect found for existing chapter at `{path}`\n \
991+ Either delete the redirect or remove the chapter."
992+ ) ;
993+ }
994+
995+ let key_prefix = format ! ( "{path}#" ) ;
996+ let map = redirects
997+ . iter ( )
998+ . filter_map ( |( source, dest) | {
999+ source
1000+ . strip_prefix ( & key_prefix)
1001+ . map ( |fragment| ( format ! ( "#{fragment}" ) , dest. to_string ( ) ) )
1002+ } )
1003+ . collect ( ) ;
1004+ Ok ( map)
1005+ }
1006+
9371007#[ cfg( test) ]
9381008mod tests {
9391009 use crate :: config:: TextDirection ;
0 commit comments