From 9fa46ac6f3d7f09f8917588e01a8fa59677b98dd Mon Sep 17 00:00:00 2001 From: Florian Dieminger Date: Wed, 22 Jan 2025 21:39:27 +0100 Subject: [PATCH 1/2] feat(issues): improve flaw compatibility --- crates/rari-doc/src/html/fix_link.rs | 51 +++++---- crates/rari-doc/src/html/links.rs | 6 +- crates/rari-doc/src/issues.rs | 158 +++++++++++++-------------- crates/rari-doc/src/templ/api.rs | 12 +- 4 files changed, 121 insertions(+), 106 deletions(-) diff --git a/crates/rari-doc/src/html/fix_link.rs b/crates/rari-doc/src/html/fix_link.rs index e8373e66..c84f6a73 100644 --- a/crates/rari-doc/src/html/fix_link.rs +++ b/crates/rari-doc/src/html/fix_link.rs @@ -135,7 +135,7 @@ pub fn handle_internal_link( } else { resolved_href.as_ref() }; - if original_href != resolved_href { + if original_href != resolved_href || remove_href { if !en_us_fallback { if let Some(pos) = el.get_attribute("data-sourcepos") { if let Some((start, _)) = pos.split_once('-') { @@ -147,14 +147,24 @@ pub fn handle_internal_link( .unwrap_or(-1); let col = col.parse::().ok().unwrap_or(0); let ic = get_issue_counter(); - tracing::warn!( - source = "redirected-link", - ic = ic, - line = line, - col = col, - url = original_href, - redirect = resolved_href - ); + if remove_href { + tracing::warn!( + source = "broken-link", + ic = ic, + line = line, + col = col, + url = original_href, + ); + } else { + tracing::warn!( + source = "redirected-link", + ic = ic, + line = line, + col = col, + url = original_href, + redirect = resolved_href + ); + } if data_issues { el.set_attribute("data-flaw", &ic.to_string())?; } @@ -162,24 +172,27 @@ pub fn handle_internal_link( } } else { let ic = get_issue_counter(); - tracing::warn!( - source = "redirected-link", - ic = ic, - url = original_href, - redirect = resolved_href - ); + if remove_href { + tracing::warn!(source = "broken-link", ic = ic, url = original_href,); + } else { + tracing::warn!( + source = "redirected-link", + ic = ic, + url = original_href, + redirect = resolved_href + ); + } if data_issues { el.set_attribute("data-flaw", &ic.to_string())?; } } } - if !remove_href { + if remove_href { + el.remove_attribute("href"); + } else { el.set_attribute("href", resolved_href)?; } } - if remove_href { - el.remove_attribute("href"); - } Ok(()) } diff --git a/crates/rari-doc/src/html/links.rs b/crates/rari-doc/src/html/links.rs index 72a014a0..5182a221 100644 --- a/crates/rari-doc/src/html/links.rs +++ b/crates/rari-doc/src/html/links.rs @@ -129,11 +129,7 @@ pub fn render_link_via_page( } Err(e) => { if !Page::ignore_link_check(url) { - warn!( - source = "invalid-link", - url = url, - "Link via page not found: {e}" - ) + warn!(source = "broken-link", url = url,) } } } diff --git a/crates/rari-doc/src/issues.rs b/crates/rari-doc/src/issues.rs index 3537d706..7d13d252 100644 --- a/crates/rari-doc/src/issues.rs +++ b/crates/rari-doc/src/issues.rs @@ -42,64 +42,6 @@ pub struct IssueEntries { entries: Vec<(&'static str, String)>, } -#[derive(Clone, Debug, Serialize)] -pub struct Issues<'a> { - pub templ: BTreeMap<&'a str, Vec>>, - pub other: BTreeMap<&'a str, Vec>>, - pub no_pos: BTreeMap<&'a str, Vec>>, -} - -#[derive(Clone, Debug, Serialize)] -pub struct TemplIssue<'a> { - pub req: u64, - pub ic: i64, - pub source: &'a str, - pub file: &'a str, - pub slug: &'a str, - pub locale: &'a str, - pub line: i64, - pub col: i64, - pub tail: Vec<(&'static str, &'a str)>, -} - -static UNKNOWN: &str = "unknown"; -static DEFAULT_TEMPL_ISSUE: TemplIssue<'static> = TemplIssue { - req: 0, - ic: -1, - source: UNKNOWN, - file: UNKNOWN, - slug: UNKNOWN, - locale: UNKNOWN, - line: -1, - col: -1, - tail: vec![], -}; - -impl<'a> From<&'a Issue> for TemplIssue<'a> { - fn from(value: &'a Issue) -> Self { - let mut tissue = DEFAULT_TEMPL_ISSUE.clone(); - for (key, value) in value.spans.iter().chain(value.fields.iter()) { - match *key { - "slug" => { - tissue.slug = value.as_str(); - } - "locale" => { - tissue.locale = value.as_str(); - } - "source" => { - tissue.source = value.as_str(); - } - "message" => {} - _ => tissue.tail.push((key, value.as_str())), - } - } - tissue.col = value.col; - tissue.line = value.line; - tissue.file = value.file.as_str(); - tissue - } -} - #[derive(Clone, Default, Debug)] pub struct InMemoryLayer { events: Arc>>, @@ -247,18 +189,37 @@ pub struct DisplayIssue { pub suggestion: Option, pub fixable: Option, pub fixed: bool, - pub name: String, pub line: Option, pub col: Option, pub source_context: Option, pub filepath: Option, - #[serde(flatten)] - pub additional: Additional, } -pub type DisplayIssues = BTreeMap<&'static str, Vec>; +#[derive(Serialize, Debug, Clone, JsonSchema)] +#[serde(tag = "name")] +#[serde(rename_all = "snake_case")] +pub enum DIssue { + BrokenLink { + #[serde(flatten)] + display_issue: DisplayIssue, + href: Option, + }, + Macros { + #[serde(flatten)] + display_issue: DisplayIssue, + #[serde(rename = "macroName")] + macro_name: Option, + href: Option, + }, + Unknown { + #[serde(flatten)] + display_issue: DisplayIssue, + }, +} + +pub type DisplayIssues = BTreeMap<&'static str, Vec>; -impl DisplayIssue { +impl DIssue { fn from_issue_with_id(issue: Issue, page: &Page, id: usize) -> Self { let mut di = DisplayIssue { id: id as i64, @@ -294,13 +255,13 @@ impl DisplayIssue { di.filepath = Some(page.full_path().to_string_lossy().into_owned()); + let mut name = "Unknown".to_string(); let mut additional = HashMap::new(); for (key, value) in issue.spans.into_iter().chain(issue.fields.into_iter()) { match key { "source" => { - di.name = value; + name = value; } - "message" => di.explanation = Some(value), "redirect" => di.suggestion = Some(value), _ => { @@ -308,42 +269,79 @@ impl DisplayIssue { } } } - let additional = match di.name.as_str() { + match name.as_str() { "redirected-link" => { di.fixed = false; di.fixable = Some(true); - Additional::BrokenLink { - href: additional.remove("url").unwrap_or_default(), + di.explanation = Some(format!( + "{} is a redirect", + additional.get("url").map(|s| s.as_str()).unwrap_or("?") + )); + DIssue::BrokenLink { + display_issue: di, + href: additional.remove("url"), + } + } + "broken-link" => { + di.fixed = false; + di.fixable = Some(false); + di.explanation = Some(format!( + "Can't resolve {}", + additional.get("url").map(|s| s.as_str()).unwrap_or("?") + )); + DIssue::BrokenLink { + display_issue: di, + href: None, + } + } + "templ-broken-link" => { + di.fixed = false; + di.fixable = Some(false); + di.explanation = Some(format!( + "Can't resolve {}", + additional.get("url").map(|s| s.as_str()).unwrap_or("?") + )); + DIssue::Macros { + display_issue: di, + macro_name: additional.remove("templ"), + href: None, } } - "macro-redirected-link" => { + "templ-redirected-link" => { di.fixed = false; di.fixable = Some(true); - Additional::MacroBrokenLink { - href: additional.remove("url").unwrap_or_default(), + di.explanation = Some(format!( + "Macro produces link {} which is a redirect", + additional.get("url").map(|s| s.as_str()).unwrap_or("?") + )); + DIssue::Macros { + display_issue: di, + macro_name: additional.remove("templ"), + href: additional.remove("url"), } } - _ => Additional::None, - }; - di.additional = additional; - di + _ => { + di.explanation = additional.remove("message"); + DIssue::Unknown { display_issue: di } + } + } } } pub fn to_display_issues(issues: Vec, page: &Page) -> DisplayIssues { let mut map = BTreeMap::new(); for (id, issue) in issues.into_iter().enumerate() { - let di = DisplayIssue::from_issue_with_id(issue, page, id); - match &di.additional { - Additional::BrokenLink { .. } => { + let di = DIssue::from_issue_with_id(issue, page, id); + match di { + DIssue::BrokenLink { .. } => { let entry: &mut Vec<_> = map.entry("broken_links").or_default(); entry.push(di); } - Additional::MacroBrokenLink { .. } => { + DIssue::Macros { .. } => { let entry: &mut Vec<_> = map.entry("macros").or_default(); entry.push(di); } - Additional::None => { + DIssue::Unknown { .. } => { let entry: &mut Vec<_> = map.entry("unknown").or_default(); entry.push(di); } diff --git a/crates/rari-doc/src/templ/api.rs b/crates/rari-doc/src/templ/api.rs index 9bc6b7a0..044f16ae 100644 --- a/crates/rari-doc/src/templ/api.rs +++ b/crates/rari-doc/src/templ/api.rs @@ -36,7 +36,7 @@ impl RariApi { if warn { let ic = get_issue_counter(); tracing::warn!( - source = "macro-redirected-link", + source = "templ-redirected-link", ic = ic, url = url, href = redirect.as_ref() @@ -53,7 +53,15 @@ impl RariApi { } None => url, }; - Page::from_url_with_fallback(url).map_err(Into::into) + Page::from_url_with_fallback(url).map_err(|e| { + if let DocError::PageNotFound(_, _) = e { + if warn { + let ic = get_issue_counter(); + tracing::warn!(source = "templ-broken-link", ic = ic, url = url); + } + } + e + }) } pub fn decode_uri_component(component: &str) -> String { From aa2ea9d0ba7d0fe085f37af072aa99d07a2386b4 Mon Sep 17 00:00:00 2001 From: Florian Dieminger Date: Fri, 24 Jan 2025 10:53:14 +0100 Subject: [PATCH 2/2] fix and refactor --- crates/rari-doc/src/helpers/subpages.rs | 2 + crates/rari-doc/src/html/fix_link.rs | 121 +++++++++--------- crates/rari-doc/src/html/links.rs | 87 ++++++------- crates/rari-doc/src/html/rewriter.rs | 3 +- crates/rari-doc/src/issues.rs | 81 +++++++++--- .../templ/templs/list_subpages_for_sidebar.rs | 1 + .../src/templ/templs/previous_menu_next.rs | 2 +- 7 files changed, 174 insertions(+), 123 deletions(-) diff --git a/crates/rari-doc/src/helpers/subpages.rs b/crates/rari-doc/src/helpers/subpages.rs index 9b06a044..d01b94b9 100644 --- a/crates/rari-doc/src/helpers/subpages.rs +++ b/crates/rari-doc/src/helpers/subpages.rs @@ -84,6 +84,7 @@ pub fn write_li_with_badges( code, only_en_us: locale_page.locale() != locale, }, + true, )?; if closed { write!(out, "")?; @@ -106,6 +107,7 @@ pub fn write_parent_li(out: &mut String, page: &Page, locale: Locale) -> Result< code: false, only_en_us: page.locale() != locale, }, + true, )?; out.push_str(""); Ok(()) diff --git a/crates/rari-doc/src/html/fix_link.rs b/crates/rari-doc/src/html/fix_link.rs index c84f6a73..cc8bd12e 100644 --- a/crates/rari-doc/src/html/fix_link.rs +++ b/crates/rari-doc/src/html/fix_link.rs @@ -17,11 +17,15 @@ pub fn check_and_fix_link( page: &impl PageLike, data_issues: bool, ) -> HandlerResult { + let templ_link = el.has_attribute("data-templ-link"); + if templ_link { + el.remove_attribute("data-templ-link"); + } let original_href = el.get_attribute("href").expect("href was required"); if original_href.starts_with('/') || original_href.starts_with("https://developer.mozilla.org") { - handle_internal_link(&original_href, el, page, data_issues) + handle_internal_link(&original_href, el, page, data_issues, templ_link) } else if original_href.starts_with("http:") || original_href.starts_with("https:") { handle_external_link(el) } else { @@ -48,6 +52,7 @@ pub fn handle_internal_link( el: &mut Element, page: &impl PageLike, data_issues: bool, + templ_link: bool, ) -> HandlerResult { // Strip prefix for curriculum links. let original_href = if page.page_type() == PageType::Curriculum { @@ -130,68 +135,70 @@ pub fn handle_internal_link( } } - let resolved_href = if no_locale { - strip_locale_from_url(&resolved_href).1 - } else { - resolved_href.as_ref() - }; - if original_href != resolved_href || remove_href { - if !en_us_fallback { - if let Some(pos) = el.get_attribute("data-sourcepos") { - if let Some((start, _)) = pos.split_once('-') { - if let Some((line, col)) = start.split_once(':') { - let line = line - .parse::() - .map(|l| l + i64::try_from(page.fm_offset()).unwrap_or(l - 1)) - .ok() - .unwrap_or(-1); - let col = col.parse::().ok().unwrap_or(0); - let ic = get_issue_counter(); - if remove_href { - tracing::warn!( - source = "broken-link", - ic = ic, - line = line, - col = col, - url = original_href, - ); - } else { - tracing::warn!( - source = "redirected-link", - ic = ic, - line = line, - col = col, - url = original_href, - redirect = resolved_href - ); - } - if data_issues { - el.set_attribute("data-flaw", &ic.to_string())?; + if !templ_link { + let resolved_href = if no_locale { + strip_locale_from_url(&resolved_href).1 + } else { + resolved_href.as_ref() + }; + if original_href != resolved_href || remove_href { + if !en_us_fallback { + if let Some(pos) = el.get_attribute("data-sourcepos") { + if let Some((start, _)) = pos.split_once('-') { + if let Some((line, col)) = start.split_once(':') { + let line = line + .parse::() + .map(|l| l + i64::try_from(page.fm_offset()).unwrap_or(l - 1)) + .ok() + .unwrap_or(-1); + let col = col.parse::().ok().unwrap_or(0); + let ic = get_issue_counter(); + if remove_href { + tracing::warn!( + source = "broken-link", + ic = ic, + line = line, + col = col, + url = original_href, + ); + } else { + tracing::warn!( + source = "redirected-link", + ic = ic, + line = line, + col = col, + url = original_href, + redirect = resolved_href + ); + } + if data_issues { + el.set_attribute("data-flaw", &ic.to_string())?; + } } } - } - } else { - let ic = get_issue_counter(); - if remove_href { - tracing::warn!(source = "broken-link", ic = ic, url = original_href,); } else { - tracing::warn!( - source = "redirected-link", - ic = ic, - url = original_href, - redirect = resolved_href - ); - } - if data_issues { - el.set_attribute("data-flaw", &ic.to_string())?; + let ic = get_issue_counter(); + if remove_href { + tracing::warn!(source = "broken-link", ic = ic, url = original_href); + } else { + tracing::warn!( + source = "redirected-link", + ic = ic, + url = original_href, + redirect = resolved_href + ); + } + if data_issues { + el.set_attribute("data-flaw", &ic.to_string())?; + } } } - } - if remove_href { - el.remove_attribute("href"); - } else { - el.set_attribute("href", resolved_href)?; + if remove_href { + el.remove_attribute("href"); + } else { + el.set_attribute("href", resolved_href)?; + } } } Ok(()) diff --git a/crates/rari-doc/src/html/links.rs b/crates/rari-doc/src/html/links.rs index 5182a221..37eca43e 100644 --- a/crates/rari-doc/src/html/links.rs +++ b/crates/rari-doc/src/html/links.rs @@ -4,7 +4,6 @@ use rari_md::anchor::anchorize; use rari_types::fm_types::FeatureStatus; use rari_types::locale::Locale; use rari_utils::concat_strs; -use tracing::warn; use crate::error::DocError; use crate::pages::page::{Page, PageLike}; @@ -26,6 +25,7 @@ pub fn render_internal_link( content: &str, title: Option<&str>, modifier: &LinkModifier, + checked: bool, ) -> Result<(), DocError> { out.push_str(""); + if checked { + out.push_str(" data-templ-link"); + } + out.push('>'); if modifier.code { out.push_str(""); } @@ -76,7 +83,7 @@ pub fn render_link_from_page( } else { Cow::Borrowed(content) }; - render_internal_link(out, page.url(), None, &content, None, modifier) + render_internal_link(out, page.url(), None, &content, None, modifier, true) } pub fn render_link_via_page( @@ -94,48 +101,42 @@ pub fn render_link_via_page( url = Cow::Owned(concat_strs!("/", locale.as_url_str(), "/docs/", link)); } let (url, anchor) = url.split_once('#').unwrap_or((&url, "")); - match RariApi::get_page(url) { - Ok(page) => { - let url = page.url(); - let content = if let Some(content) = content { - Cow::Borrowed(content) + if let Ok(page) = RariApi::get_page(url) { + let url = page.url(); + let content = if let Some(content) = content { + Cow::Borrowed(content) + } else { + let content = page.short_title().unwrap_or(page.title()); + let decoded_content = html_escape::decode_html_entities(content); + let encoded_content = html_escape::encode_safe(&decoded_content); + if content != encoded_content { + Cow::Owned(encoded_content.into_owned()) } else { - let content = page.short_title().unwrap_or(page.title()); - let decoded_content = html_escape::decode_html_entities(content); - let encoded_content = html_escape::encode_safe(&decoded_content); - if content != encoded_content { - Cow::Owned(encoded_content.into_owned()) - } else { - Cow::Borrowed(content) - } - }; - return render_internal_link( - out, - url, - if anchor.is_empty() { - None - } else { - Some(anchor) - }, - &content, - title, - &LinkModifier { - badges: if with_badges { page.status() } else { &[] }, - badge_locale: locale, - code, - only_en_us: page.locale() == Locale::EnUs && locale != Locale::EnUs, - }, - ); - } - Err(e) => { - if !Page::ignore_link_check(url) { - warn!(source = "broken-link", url = url,) + Cow::Borrowed(content) } - } + }; + return render_internal_link( + out, + url, + if anchor.is_empty() { + None + } else { + Some(anchor) + }, + &content, + title, + &LinkModifier { + badges: if with_badges { page.status() } else { &[] }, + badge_locale: locale, + code, + only_en_us: page.locale() == Locale::EnUs && locale != Locale::EnUs, + }, + true, + ); } } - out.push_str(" { let decoded_content = html_escape::decode_html_entities(content); diff --git a/crates/rari-doc/src/html/rewriter.rs b/crates/rari-doc/src/html/rewriter.rs index 14e63579..15646a3e 100644 --- a/crates/rari-doc/src/html/rewriter.rs +++ b/crates/rari-doc/src/html/rewriter.rs @@ -99,7 +99,8 @@ pub fn post_process_html( Ok(()) }), element!("a[href]", |el| { - check_and_fix_link(el, page, data_issues) + check_and_fix_link(el, page, data_issues)?; + Ok(()) }), element!("pre:not(.notranslate)", |el| { let mut class = el.get_attribute("class").unwrap_or_default(); diff --git a/crates/rari-doc/src/issues.rs b/crates/rari-doc/src/issues.rs index 7d13d252..48e52df9 100644 --- a/crates/rari-doc/src/issues.rs +++ b/crates/rari-doc/src/issues.rs @@ -1,10 +1,11 @@ use std::collections::{BTreeMap, HashMap}; -use std::fmt; +use std::convert::Infallible; +use std::str::FromStr; use std::sync::atomic::AtomicI64; use std::sync::{Arc, LazyLock}; +use std::{fmt, iter}; use dashmap::DashMap; -use itertools::Itertools; use schemars::JsonSchema; use serde::Serialize; use tracing::field::{Field, Visit}; @@ -190,14 +191,39 @@ pub struct DisplayIssue { pub fixable: Option, pub fixed: bool, pub line: Option, - pub col: Option, + pub column: Option, pub source_context: Option, pub filepath: Option, + pub name: IssueType, +} + +#[derive(Serialize, Debug, Default, Clone, JsonSchema)] +#[serde(rename_all = "PascalCase")] +pub enum IssueType { + TemplRedirectedLink, + TemplBrokenLink, + RedirectedLink, + BrokenLink, + #[default] + Unknown, +} + +impl FromStr for IssueType { + type Err = Infallible; + + fn from_str(s: &str) -> Result { + Ok(match s { + "templ-redirected-link" => Self::TemplRedirectedLink, + "templ-broken-link" => Self::TemplBrokenLink, + "redirected-link" => Self::RedirectedLink, + "broken-link" => Self::BrokenLink, + _ => Self::Unknown, + }) + } } #[derive(Serialize, Debug, Clone, JsonSchema)] -#[serde(tag = "name")] -#[serde(rename_all = "snake_case")] +#[serde(rename_all = "camelCase", tag = "type")] pub enum DIssue { BrokenLink { #[serde(flatten)] @@ -223,7 +249,7 @@ impl DIssue { fn from_issue_with_id(issue: Issue, page: &Page, id: usize) -> Self { let mut di = DisplayIssue { id: id as i64, - col: if issue.col == 0 { + column: if issue.col == 0 { None } else { Some(issue.col) @@ -235,32 +261,45 @@ impl DIssue { }, ..Default::default() }; - if let (Some(_col), Some(line)) = (di.col, di.line) { + if let (Some(col), Some(line)) = (di.column, di.line) { let line = line - page.fm_offset() as i64; // take surrounding +- 3 lines (7 in total) - let (skip, take) = if line < 4 { - (0, 7 - (4 - line)) + let (skip, take, highlight) = if line < 4 { + (0, 7 - (4 - line), (line - 1) as usize) } else { - (line - 4, 7) + (line - 4, 7, 3) }; let context = page .content() .lines() .skip(skip as usize) .take(take as usize) - .join("\n"); + .collect::>(); + + let source_context = + context + .iter() + .enumerate() + .fold(String::new(), |mut acc, (i, line)| { + acc.push_str(line); + acc.push('\n'); + if i == highlight { + acc.extend(iter::repeat_n('-', (col - 1) as usize)); + acc.push_str("^\n"); + } + acc + }); - di.source_context = Some(context); + di.source_context = Some(source_context); } di.filepath = Some(page.full_path().to_string_lossy().into_owned()); - let mut name = "Unknown".to_string(); let mut additional = HashMap::new(); for (key, value) in issue.spans.into_iter().chain(issue.fields.into_iter()) { match key { "source" => { - name = value; + di.name = IssueType::from_str(&value).unwrap(); } "redirect" => di.suggestion = Some(value), @@ -269,8 +308,8 @@ impl DIssue { } } } - match name.as_str() { - "redirected-link" => { + match di.name { + IssueType::RedirectedLink => { di.fixed = false; di.fixable = Some(true); di.explanation = Some(format!( @@ -282,7 +321,7 @@ impl DIssue { href: additional.remove("url"), } } - "broken-link" => { + IssueType::BrokenLink => { di.fixed = false; di.fixable = Some(false); di.explanation = Some(format!( @@ -291,10 +330,10 @@ impl DIssue { )); DIssue::BrokenLink { display_issue: di, - href: None, + href: additional.remove("url"), } } - "templ-broken-link" => { + IssueType::TemplBrokenLink => { di.fixed = false; di.fixable = Some(false); di.explanation = Some(format!( @@ -304,10 +343,10 @@ impl DIssue { DIssue::Macros { display_issue: di, macro_name: additional.remove("templ"), - href: None, + href: additional.remove("url"), } } - "templ-redirected-link" => { + IssueType::TemplRedirectedLink => { di.fixed = false; di.fixable = Some(true); di.explanation = Some(format!( diff --git a/crates/rari-doc/src/templ/templs/list_subpages_for_sidebar.rs b/crates/rari-doc/src/templ/templs/list_subpages_for_sidebar.rs index 2da5d9bf..a8b35047 100644 --- a/crates/rari-doc/src/templ/templs/list_subpages_for_sidebar.rs +++ b/crates/rari-doc/src/templ/templs/list_subpages_for_sidebar.rs @@ -50,6 +50,7 @@ pub fn list_subpages_for_sidebar( code, only_en_us: locale_page.locale() != env.locale, }, + true, )?; out.push_str(""); } diff --git a/crates/rari-doc/src/templ/templs/previous_menu_next.rs b/crates/rari-doc/src/templ/templs/previous_menu_next.rs index ac5e42d9..1fdd895b 100644 --- a/crates/rari-doc/src/templ/templs/previous_menu_next.rs +++ b/crates/rari-doc/src/templ/templs/previous_menu_next.rs @@ -99,7 +99,7 @@ fn generate_link( title: &str, ) -> Result<(), DocError> { out.extend([ - r#"