static_serve_macro/
lib.rs

1//! Proc macro crate for compressing and embedding static assets
2//! in a web server
3
4use std::{
5    convert::Into,
6    fs,
7    io::{self, Write},
8    path::{Path, PathBuf},
9};
10
11use display_full_error::DisplayFullError;
12use flate2::write::GzEncoder;
13use glob::glob;
14use proc_macro2::{Span, TokenStream};
15use quote::{quote, ToTokens};
16use sha1::{Digest as _, Sha1};
17use syn::{
18    bracketed,
19    parse::{Parse, ParseStream},
20    parse_macro_input, Ident, LitBool, LitByteStr, LitStr, Token,
21};
22
23mod error;
24use error::{Error, GzipType, ZstdType};
25
26#[proc_macro]
27/// Embed and optionally compress static assets for a web server
28pub fn embed_assets(input: proc_macro::TokenStream) -> proc_macro::TokenStream {
29    let parsed = parse_macro_input!(input as EmbedAssets);
30    quote! { #parsed }.into()
31}
32
33#[proc_macro]
34/// Embed and optionally compress a single static asset for a web server
35pub fn embed_asset(input: proc_macro::TokenStream) -> proc_macro::TokenStream {
36    let parsed = parse_macro_input!(input as EmbedAsset);
37    quote! { #parsed }.into()
38}
39
40struct EmbedAsset {
41    asset_file: AssetFile,
42    should_compress: ShouldCompress,
43    cache_busted: IsCacheBusted,
44}
45
46struct AssetFile(LitStr);
47
48impl Parse for EmbedAsset {
49    fn parse(input: ParseStream) -> syn::Result<Self> {
50        let asset_file: AssetFile = input.parse()?;
51
52        // Default to no compression, no cache-busting
53        let mut maybe_should_compress = None;
54        let mut maybe_is_cache_busted = None;
55
56        while !input.is_empty() {
57            input.parse::<Token![,]>()?;
58            let key: Ident = input.parse()?;
59            input.parse::<Token![=]>()?;
60
61            match key.to_string().as_str() {
62                "compress" => {
63                    let value = input.parse()?;
64                    maybe_should_compress = Some(value);
65                }
66                "cache_bust" => {
67                    let value = input.parse()?;
68                    maybe_is_cache_busted = Some(value);
69                }
70                _ => {
71                    return Err(syn::Error::new(
72                    key.span(),
73                    format!(
74                        "Unknown key in `embed_asset!` macro. Expected `compress` or `cache_bust` but got {key}"
75                    ),
76                ));
77                }
78            }
79        }
80        let should_compress = maybe_should_compress.unwrap_or_else(|| {
81            ShouldCompress(LitBool {
82                value: false,
83                span: Span::call_site(),
84            })
85        });
86        let cache_busted = maybe_is_cache_busted.unwrap_or_else(|| {
87            IsCacheBusted(LitBool {
88                value: false,
89                span: Span::call_site(),
90            })
91        });
92
93        Ok(Self {
94            asset_file,
95            should_compress,
96            cache_busted,
97        })
98    }
99}
100
101impl Parse for AssetFile {
102    fn parse(input: ParseStream) -> syn::Result<Self> {
103        let input_span = input.span();
104        let asset_file: LitStr = input.parse()?;
105        let literal = asset_file.value();
106        let path = Path::new(&literal);
107        let metadata = match fs::metadata(path) {
108            Ok(meta) => meta,
109            Err(e) if matches!(e.kind(), std::io::ErrorKind::NotFound) => {
110                return Err(syn::Error::new(
111                    input_span,
112                    format!("The specified asset file ({literal}) does not exist."),
113                ));
114            }
115            Err(e) => {
116                return Err(syn::Error::new(
117                    input_span,
118                    format!("Error reading file {literal}: {}", DisplayFullError(&e)),
119                ));
120            }
121        };
122
123        if metadata.is_dir() {
124            return Err(syn::Error::new(
125                input_span,
126                "The specified asset is a directory, not a file. Did you mean to call `embed_assets!` instead?",
127            ));
128        }
129
130        Ok(AssetFile(asset_file))
131    }
132}
133
134impl ToTokens for EmbedAsset {
135    fn to_tokens(&self, tokens: &mut TokenStream) {
136        let AssetFile(asset_file) = &self.asset_file;
137        let ShouldCompress(should_compress) = &self.should_compress;
138        let IsCacheBusted(cache_busted) = &self.cache_busted;
139
140        let result = generate_static_handler(asset_file, should_compress, cache_busted);
141
142        match result {
143            Ok(value) => {
144                tokens.extend(quote! {
145                    #value
146                });
147            }
148            Err(err_message) => {
149                let error = syn::Error::new(Span::call_site(), err_message);
150                tokens.extend(error.to_compile_error());
151            }
152        }
153    }
154}
155
156struct EmbedAssets {
157    assets_dir: AssetsDir,
158    validated_ignore_paths: IgnorePaths,
159    should_compress: ShouldCompress,
160    should_strip_html_ext: ShouldStripHtmlExt,
161    cache_busted_paths: CacheBustedPaths,
162}
163
164impl Parse for EmbedAssets {
165    fn parse(input: ParseStream) -> syn::Result<Self> {
166        let assets_dir: AssetsDir = input.parse()?;
167
168        // Default to no compression
169        let mut maybe_should_compress = None;
170        let mut maybe_ignore_paths = None;
171        let mut maybe_should_strip_html_ext = None;
172        let mut maybe_cache_busted_paths = None;
173
174        while !input.is_empty() {
175            input.parse::<Token![,]>()?;
176            let key: Ident = input.parse()?;
177            input.parse::<Token![=]>()?;
178
179            match key.to_string().as_str() {
180                "compress" => {
181                    let value = input.parse()?;
182                    maybe_should_compress = Some(value);
183                }
184                "ignore_paths" => {
185                    let value = input.parse()?;
186                    maybe_ignore_paths = Some(value);
187                }
188                "strip_html_ext" => {
189                    let value = input.parse()?;
190                    maybe_should_strip_html_ext = Some(value);
191                }
192                "cache_busted_paths" => {
193                    let value = input.parse()?;
194                    maybe_cache_busted_paths = Some(value);
195                }
196                _ => {
197                    return Err(syn::Error::new(
198                        key.span(),
199                        "Unknown key in embed_assets! macro. Expected `compress`, `ignore_paths`, `strip_html_ext`, or `cache_busted_paths`",
200                    ));
201                }
202            }
203        }
204
205        let should_compress = maybe_should_compress.unwrap_or_else(|| {
206            ShouldCompress(LitBool {
207                value: false,
208                span: Span::call_site(),
209            })
210        });
211
212        let should_strip_html_ext = maybe_should_strip_html_ext.unwrap_or_else(|| {
213            ShouldStripHtmlExt(LitBool {
214                value: false,
215                span: Span::call_site(),
216            })
217        });
218
219        let ignore_paths_with_span = maybe_ignore_paths.unwrap_or(IgnorePathsWithSpan(vec![]));
220        let validated_ignore_paths = validate_ignore_paths(ignore_paths_with_span, &assets_dir.0)?;
221
222        let maybe_cache_busted_paths =
223            maybe_cache_busted_paths.unwrap_or(CacheBustedPathsWithSpan(vec![]));
224        let cache_busted_paths =
225            validate_cache_busted_paths(maybe_cache_busted_paths, &assets_dir.0)?;
226
227        Ok(Self {
228            assets_dir,
229            validated_ignore_paths,
230            should_compress,
231            should_strip_html_ext,
232            cache_busted_paths,
233        })
234    }
235}
236
237impl ToTokens for EmbedAssets {
238    fn to_tokens(&self, tokens: &mut TokenStream) {
239        let AssetsDir(assets_dir) = &self.assets_dir;
240        let ignore_paths = &self.validated_ignore_paths;
241        let ShouldCompress(should_compress) = &self.should_compress;
242        let ShouldStripHtmlExt(should_strip_html_ext) = &self.should_strip_html_ext;
243        let cache_busted_paths = &self.cache_busted_paths;
244
245        let result = generate_static_routes(
246            assets_dir,
247            ignore_paths,
248            should_compress,
249            should_strip_html_ext,
250            cache_busted_paths,
251        );
252
253        match result {
254            Ok(value) => {
255                tokens.extend(quote! {
256                    #value
257                });
258            }
259            Err(err_message) => {
260                let error = syn::Error::new(Span::call_site(), err_message);
261                tokens.extend(error.to_compile_error());
262            }
263        }
264    }
265}
266
267struct AssetsDir(LitStr);
268
269impl Parse for AssetsDir {
270    fn parse(input: ParseStream) -> syn::Result<Self> {
271        let input_span = input.span();
272        let assets_dir: LitStr = input.parse()?;
273        let literal = assets_dir.value();
274        let path = Path::new(&literal);
275        let metadata = match fs::metadata(path) {
276            Ok(meta) => meta,
277            Err(e) if matches!(e.kind(), std::io::ErrorKind::NotFound) => {
278                return Err(syn::Error::new(
279                    input_span,
280                    "The specified assets directory does not exist",
281                ));
282            }
283            Err(e) => {
284                return Err(syn::Error::new(
285                    input_span,
286                    format!(
287                        "Error reading directory {literal}: {}",
288                        DisplayFullError(&e)
289                    ),
290                ));
291            }
292        };
293
294        if !metadata.is_dir() {
295            return Err(syn::Error::new(
296                input_span,
297                "The specified assets directory is not a directory",
298            ));
299        }
300
301        Ok(AssetsDir(assets_dir))
302    }
303}
304
305struct IgnorePaths(Vec<PathBuf>);
306
307struct IgnorePathsWithSpan(Vec<(PathBuf, Span)>);
308
309impl Parse for IgnorePathsWithSpan {
310    fn parse(input: ParseStream) -> syn::Result<Self> {
311        let dirs = parse_dirs(input)?;
312
313        Ok(IgnorePathsWithSpan(dirs))
314    }
315}
316
317fn validate_ignore_paths(
318    ignore_paths: IgnorePathsWithSpan,
319    assets_dir: &LitStr,
320) -> syn::Result<IgnorePaths> {
321    let mut valid_ignore_paths = Vec::new();
322    for (dir, span) in ignore_paths.0 {
323        let full_path = PathBuf::from(assets_dir.value()).join(&dir);
324        match fs::metadata(&full_path) {
325            Ok(_) => valid_ignore_paths.push(full_path),
326            Err(e) if matches!(e.kind(), std::io::ErrorKind::NotFound) => {
327                return Err(syn::Error::new(
328                    span,
329                    "The specified ignored path does not exist",
330                ))
331            }
332            Err(e) => {
333                return Err(syn::Error::new(
334                    span,
335                    format!(
336                        "Error reading ignored path {}: {}",
337                        dir.to_string_lossy(),
338                        DisplayFullError(&e)
339                    ),
340                ))
341            }
342        }
343    }
344    Ok(IgnorePaths(valid_ignore_paths))
345}
346
347struct ShouldCompress(LitBool);
348
349impl Parse for ShouldCompress {
350    fn parse(input: ParseStream) -> syn::Result<Self> {
351        let lit = input.parse()?;
352        Ok(ShouldCompress(lit))
353    }
354}
355
356struct ShouldStripHtmlExt(LitBool);
357
358impl Parse for ShouldStripHtmlExt {
359    fn parse(input: ParseStream) -> syn::Result<Self> {
360        let lit = input.parse()?;
361        Ok(ShouldStripHtmlExt(lit))
362    }
363}
364
365struct IsCacheBusted(LitBool);
366
367impl Parse for IsCacheBusted {
368    fn parse(input: ParseStream) -> syn::Result<Self> {
369        let lit = input.parse()?;
370        Ok(IsCacheBusted(lit))
371    }
372}
373
374struct CacheBustedPaths {
375    dirs: Vec<PathBuf>,
376    files: Vec<PathBuf>,
377}
378struct CacheBustedPathsWithSpan(Vec<(PathBuf, Span)>);
379
380impl Parse for CacheBustedPathsWithSpan {
381    fn parse(input: ParseStream) -> syn::Result<Self> {
382        let dirs = parse_dirs(input)?;
383        Ok(CacheBustedPathsWithSpan(dirs))
384    }
385}
386
387fn validate_cache_busted_paths(
388    tuples: CacheBustedPathsWithSpan,
389    assets_dir: &LitStr,
390) -> syn::Result<CacheBustedPaths> {
391    let mut valid_dirs = Vec::new();
392    let mut valid_files = Vec::new();
393    for (dir, span) in tuples.0 {
394        let full_path = PathBuf::from(assets_dir.value()).join(&dir);
395        match fs::metadata(&full_path) {
396            Ok(meta) => {
397                if meta.is_dir() {
398                    valid_dirs.push(full_path);
399                } else {
400                    valid_files.push(full_path);
401                }
402            }
403            Err(e) if matches!(e.kind(), std::io::ErrorKind::NotFound) => {
404                return Err(syn::Error::new(
405                    span,
406                    "The specified directory for cache busting does not exist",
407                ))
408            }
409            Err(e) => {
410                return Err(syn::Error::new(
411                    span,
412                    format!(
413                        "Error reading path {}: {}",
414                        dir.to_string_lossy(),
415                        DisplayFullError(&e)
416                    ),
417                ))
418            }
419        }
420    }
421    Ok(CacheBustedPaths {
422        dirs: valid_dirs,
423        files: valid_files,
424    })
425}
426
427/// Helper function for turning an array of strs representing paths into
428/// a `Vec` containing tuples of each `PathBuf` and its `Span` in the `ParseStream`
429fn parse_dirs(input: ParseStream) -> syn::Result<Vec<(PathBuf, Span)>> {
430    let inner_content;
431    bracketed!(inner_content in input);
432
433    let mut dirs = Vec::new();
434    while !inner_content.is_empty() {
435        let directory_span = inner_content.span();
436        let directory_str = inner_content.parse::<LitStr>()?;
437        let path = PathBuf::from(directory_str.value());
438        dirs.push((path, directory_span));
439
440        if !inner_content.is_empty() {
441            inner_content.parse::<Token![,]>()?;
442        }
443    }
444    Ok(dirs)
445}
446
447fn generate_static_routes(
448    assets_dir: &LitStr,
449    ignore_paths: &IgnorePaths,
450    should_compress: &LitBool,
451    should_strip_html_ext: &LitBool,
452    cache_busted_paths: &CacheBustedPaths,
453) -> Result<TokenStream, error::Error> {
454    let assets_dir_abs = Path::new(&assets_dir.value())
455        .canonicalize()
456        .map_err(Error::CannotCanonicalizeDirectory)?;
457    let assets_dir_abs_str = assets_dir_abs
458        .to_str()
459        .ok_or(Error::InvalidUnicodeInDirectoryName)?;
460    let canon_ignore_paths = ignore_paths
461        .0
462        .iter()
463        .map(|d| {
464            d.canonicalize()
465                .map_err(Error::CannotCanonicalizeIgnorePath)
466        })
467        .collect::<Result<Vec<_>, _>>()?;
468    let canon_cache_busted_dirs = cache_busted_paths
469        .dirs
470        .iter()
471        .map(|d| {
472            d.canonicalize()
473                .map_err(Error::CannotCanonicalizeCacheBustedDir)
474        })
475        .collect::<Result<Vec<_>, _>>()?;
476    let canon_cache_busted_files = cache_busted_paths
477        .files
478        .iter()
479        .map(|file| file.canonicalize().map_err(Error::CannotCanonicalizeFile))
480        .collect::<Result<Vec<_>, _>>()?;
481
482    let mut routes = Vec::new();
483    for entry in glob(&format!("{assets_dir_abs_str}/**/*")).map_err(Error::Pattern)? {
484        let entry = entry.map_err(Error::Glob)?;
485        let metadata = entry.metadata().map_err(Error::CannotGetMetadata)?;
486        if metadata.is_dir() {
487            continue;
488        }
489
490        // Skip `entry`s which are located in ignored paths
491        if canon_ignore_paths
492            .iter()
493            .any(|ignore_path| entry.starts_with(ignore_path))
494        {
495            continue;
496        }
497
498        let mut is_entry_cache_busted = false;
499        if canon_cache_busted_dirs
500            .iter()
501            .any(|dir| entry.starts_with(dir))
502            || canon_cache_busted_files.contains(&entry)
503        {
504            is_entry_cache_busted = true;
505        }
506
507        let entry = entry
508            .canonicalize()
509            .map_err(Error::CannotCanonicalizeFile)?;
510        let entry_str = entry.to_str().ok_or(Error::FilePathIsNotUtf8)?;
511        let EmbeddedFileInfo {
512            entry_path,
513            content_type,
514            etag_str,
515            lit_byte_str_contents,
516            maybe_gzip,
517            maybe_zstd,
518            cache_busted,
519        } = EmbeddedFileInfo::from_path(
520            &entry,
521            Some(assets_dir_abs_str),
522            should_compress,
523            should_strip_html_ext,
524            is_entry_cache_busted,
525        )?;
526
527        routes.push(quote! {
528            router = ::static_serve::static_route(
529                router,
530                #entry_path,
531                #content_type,
532                #etag_str,
533                {
534                    // Poor man's `tracked_path`
535                    // https://github.com/rust-lang/rust/issues/99515
536                    const _: &[u8] = include_bytes!(#entry_str);
537                        #lit_byte_str_contents
538                },
539                #maybe_gzip,
540                #maybe_zstd,
541                #cache_busted
542            );
543        });
544    }
545
546    Ok(quote! {
547    pub fn static_router<S>() -> ::axum::Router<S>
548        where S: ::std::clone::Clone + ::std::marker::Send + ::std::marker::Sync + 'static {
549            let mut router = ::axum::Router::<S>::new();
550            #(#routes)*
551            router
552        }
553    })
554}
555
556fn generate_static_handler(
557    asset_file: &LitStr,
558    should_compress: &LitBool,
559    cache_busted: &LitBool,
560) -> Result<TokenStream, error::Error> {
561    let asset_file_abs = Path::new(&asset_file.value())
562        .canonicalize()
563        .map_err(Error::CannotCanonicalizeFile)?;
564    let asset_file_abs_str = asset_file_abs.to_str().ok_or(Error::FilePathIsNotUtf8)?;
565
566    let EmbeddedFileInfo {
567        entry_path: _,
568        content_type,
569        etag_str,
570        lit_byte_str_contents,
571        maybe_gzip,
572        maybe_zstd,
573        cache_busted,
574    } = EmbeddedFileInfo::from_path(
575        &asset_file_abs,
576        None,
577        should_compress,
578        &LitBool {
579            value: false,
580            span: Span::call_site(),
581        },
582        cache_busted.value(),
583    )?;
584
585    let route = quote! {
586        ::static_serve::static_method_router(
587            #content_type,
588            #etag_str,
589            {
590                // Poor man's `tracked_path`
591                // https://github.com/rust-lang/rust/issues/99515
592                const _: &[u8] = include_bytes!(#asset_file_abs_str);
593                #lit_byte_str_contents
594            },
595            #maybe_gzip,
596            #maybe_zstd,
597            #cache_busted
598        )
599    };
600
601    Ok(route)
602}
603
604struct OptionBytesSlice(Option<LitByteStr>);
605impl ToTokens for OptionBytesSlice {
606    fn to_tokens(&self, tokens: &mut TokenStream) {
607        tokens.extend(if let Some(inner) = &self.0.as_ref() {
608            quote! { ::std::option::Option::Some(#inner) }
609        } else {
610            quote! { ::std::option::Option::None }
611        });
612    }
613}
614
615struct EmbeddedFileInfo<'a> {
616    /// When creating a `Router`, we need the API path/route to the
617    /// target file. If creating a `Handler`, this is not needed since
618    /// the router is responsible for the file's path on the server.
619    entry_path: Option<&'a str>,
620    content_type: String,
621    etag_str: String,
622    lit_byte_str_contents: LitByteStr,
623    maybe_gzip: OptionBytesSlice,
624    maybe_zstd: OptionBytesSlice,
625    cache_busted: bool,
626}
627
628impl<'a> EmbeddedFileInfo<'a> {
629    fn from_path(
630        pathbuf: &'a PathBuf,
631        assets_dir_abs_str: Option<&str>,
632        should_compress: &LitBool,
633        should_strip_html_ext: &LitBool,
634        cache_busted: bool,
635    ) -> Result<Self, Error> {
636        let contents = fs::read(pathbuf).map_err(Error::CannotReadEntryContents)?;
637
638        // Optionally compress files
639        let (maybe_gzip, maybe_zstd) = if should_compress.value {
640            let gzip = gzip_compress(&contents)?;
641            let zstd = zstd_compress(&contents)?;
642            (gzip, zstd)
643        } else {
644            (None, None)
645        };
646
647        let content_type = file_content_type(pathbuf)?;
648
649        // entry_path is only needed for the router (embed_assets!)
650        let entry_path = if let Some(dir) = assets_dir_abs_str {
651            if should_strip_html_ext.value && content_type == "text/html" {
652                Some(
653                    strip_html_ext(pathbuf)?
654                        .strip_prefix(dir)
655                        .unwrap_or_default(),
656                )
657            } else {
658                pathbuf
659                    .to_str()
660                    .ok_or(Error::InvalidUnicodeInEntryName)?
661                    .strip_prefix(dir)
662            }
663        } else {
664            None
665        };
666
667        let etag_str = etag(&contents);
668        let lit_byte_str_contents = LitByteStr::new(&contents, Span::call_site());
669        let maybe_gzip = OptionBytesSlice(maybe_gzip);
670        let maybe_zstd = OptionBytesSlice(maybe_zstd);
671
672        Ok(Self {
673            entry_path,
674            content_type,
675            etag_str,
676            lit_byte_str_contents,
677            maybe_gzip,
678            maybe_zstd,
679            cache_busted,
680        })
681    }
682}
683
684fn gzip_compress(contents: &[u8]) -> Result<Option<LitByteStr>, Error> {
685    let mut compressor = GzEncoder::new(Vec::new(), flate2::Compression::best());
686    compressor
687        .write_all(contents)
688        .map_err(|e| Error::Gzip(GzipType::CompressorWrite(e)))?;
689    let compressed = compressor
690        .finish()
691        .map_err(|e| Error::Gzip(GzipType::EncoderFinish(e)))?;
692
693    Ok(maybe_get_compressed(&compressed, contents))
694}
695
696fn zstd_compress(contents: &[u8]) -> Result<Option<LitByteStr>, Error> {
697    let level = *zstd::compression_level_range().end();
698    let mut encoder = zstd::Encoder::new(Vec::new(), level).unwrap();
699    write_to_zstd_encoder(&mut encoder, contents)
700        .map_err(|e| Error::Zstd(ZstdType::EncoderWrite(e)))?;
701
702    let compressed = encoder
703        .finish()
704        .map_err(|e| Error::Zstd(ZstdType::EncoderFinish(e)))?;
705
706    Ok(maybe_get_compressed(&compressed, contents))
707}
708
709fn write_to_zstd_encoder(
710    encoder: &mut zstd::Encoder<'static, Vec<u8>>,
711    contents: &[u8],
712) -> io::Result<()> {
713    encoder.set_pledged_src_size(Some(
714        contents
715            .len()
716            .try_into()
717            .expect("contents size should fit into u64"),
718    ))?;
719    encoder.window_log(23)?;
720    encoder.include_checksum(false)?;
721    encoder.include_contentsize(false)?;
722    encoder.long_distance_matching(false)?;
723    encoder.write_all(contents)?;
724
725    Ok(())
726}
727
728fn is_compression_significant(compressed_len: usize, contents_len: usize) -> bool {
729    let ninety_pct_original = contents_len / 10 * 9;
730    compressed_len < ninety_pct_original
731}
732
733fn maybe_get_compressed(compressed: &[u8], contents: &[u8]) -> Option<LitByteStr> {
734    is_compression_significant(compressed.len(), contents.len())
735        .then(|| LitByteStr::new(compressed, Span::call_site()))
736}
737
738/// Use `mime_guess` to get the best guess of the file's MIME type
739/// by looking at its extension, or return an error if unable.
740///
741/// We accept the first guess because [`mime_guess` updates the order
742/// according to the latest IETF RTC](https://docs.rs/mime_guess/2.0.5/mime_guess/struct.MimeGuess.html#note-ordering)
743fn file_content_type(path: &Path) -> Result<String, error::Error> {
744    match path.extension() {
745        Some(ext) => {
746            let guesses = mime_guess::MimeGuess::from_ext(
747                ext.to_str()
748                    .ok_or(error::Error::InvalidFileExtension(path.into()))?,
749            );
750
751            if let Some(guess) = guesses.first_raw() {
752                Ok(guess.to_owned())
753            } else {
754                Err(error::Error::UnknownFileExtension(
755                    path.extension().map(Into::into),
756                ))
757            }
758        }
759        None => Err(error::Error::UnknownFileExtension(None)),
760    }
761}
762
763fn etag(contents: &[u8]) -> String {
764    let sha256 = Sha1::digest(contents);
765    let hash = u64::from_le_bytes(sha256[..8].try_into().unwrap())
766        ^ u64::from_le_bytes(sha256[8..16].try_into().unwrap());
767    format!("\"{hash:016x}\"")
768}
769
770fn strip_html_ext(entry: &Path) -> Result<&str, Error> {
771    let entry_str = entry.to_str().ok_or(Error::InvalidUnicodeInEntryName)?;
772    let mut output = entry_str;
773
774    // Strip the extension
775    if let Some(prefix) = output.strip_suffix(".html") {
776        output = prefix;
777    } else if let Some(prefix) = output.strip_suffix(".htm") {
778        output = prefix;
779    }
780
781    // If it was `/index.html` or `/index.htm`, also remove `index`
782    if output.ends_with("/index") {
783        output = output.strip_suffix("index").unwrap_or("/");
784    }
785
786    Ok(output)
787}