diff --git a/pgml-dashboard/src/api/cms.rs b/pgml-dashboard/src/api/cms.rs index 756b6514f..f40d4204f 100644 --- a/pgml-dashboard/src/api/cms.rs +++ b/pgml-dashboard/src/api/cms.rs @@ -557,7 +557,7 @@ This is the end of the markdown #[sqlx::test] async fn render_blogs_test() { let client = Client::tracked(rocket().await).await.unwrap(); - let blog: Collection = Collection::new("Blog", true); + let blog: Collection = Collection::new("Blog", true, HashMap::new()); for path in blog.index { let req = client.get(path.clone().href); @@ -579,7 +579,7 @@ This is the end of the markdown #[sqlx::test] async fn render_guides_test() { let client = Client::tracked(rocket().await).await.unwrap(); - let docs: Collection = Collection::new("Docs", true); + let docs: Collection = Collection::new("Docs", true, HashMap::new()); for path in docs.index { let req = client.get(path.clone().href); diff --git a/pgml-dashboard/src/templates/docs.rs b/pgml-dashboard/src/templates/docs.rs index ad18d2215..8c735c237 100644 --- a/pgml-dashboard/src/templates/docs.rs +++ b/pgml-dashboard/src/templates/docs.rs @@ -1,5 +1,9 @@ +use convert_case; +use lazy_static::lazy_static; use sailfish::TemplateOnce; use serde::{Deserialize, Serialize}; +use std::collections::hash_map::DefaultHasher; +use std::hash::{Hash, Hasher}; use crate::utils::markdown::SearchResult; @@ -11,6 +15,26 @@ pub struct Search { pub results: Vec, } +lazy_static! { + static ref CMS_IDENTIFIER: CmsIdentifier = CmsIdentifier::new(); +} + +// Prevent css collisions in cms header ids. +pub struct CmsIdentifier { + pub id: String, +} + +impl CmsIdentifier { + pub fn new() -> CmsIdentifier { + let mut s = DefaultHasher::new(); + "cms header".hash(&mut s); + + CmsIdentifier { + id: s.finish().to_string(), + } + } +} + /// Table of contents link. #[derive(Clone, Debug, Serialize, Deserialize)] pub struct TocLink { @@ -25,9 +49,23 @@ impl TocLink { /// # Arguments /// /// * `title` - The title of the link. + /// * `counter` - The number of times that header is in the document /// pub fn new(title: &str, counter: usize) -> TocLink { - let id = format!("header-{}", counter); + let conv = convert_case::Converter::new().to_case(convert_case::Case::Kebab); + let id = conv.convert(title.to_string()); + + // gitbook style id's + let id = format!( + "{}{}-{}", + id, + if counter > 0 { + format!("-{counter}") + } else { + String::new() + }, + CMS_IDENTIFIER.id + ); TocLink { title: title.to_string(), @@ -43,11 +81,20 @@ impl TocLink { self.level = level; self } -} -/// Table of contents template. -#[derive(TemplateOnce)] -#[template(path = "components/toc.html")] -pub struct Toc { - pub links: Vec, + /// Converts gitbook link fragment to toc header + pub fn from_fragment(link: String) -> TocLink { + match link.is_empty() { + true => TocLink { + title: String::new(), + id: String::new(), + level: 0, + }, + _ => TocLink { + title: link.clone(), + id: format!("#{}-{}", link.clone(), CMS_IDENTIFIER.id), + level: 0, + }, + } + } } diff --git a/pgml-dashboard/src/utils/markdown.rs b/pgml-dashboard/src/utils/markdown.rs index 0cf172289..949bf7b17 100644 --- a/pgml-dashboard/src/utils/markdown.rs +++ b/pgml-dashboard/src/utils/markdown.rs @@ -3,10 +3,7 @@ use crate::{templates::docs::TocLink, utils::config}; use std::cell::RefCell; use std::collections::{HashMap, HashSet}; use std::path::{Path, PathBuf}; -use std::sync::{ - atomic::{AtomicUsize, Ordering}, - Arc, -}; +use std::sync::Arc; use anyhow::Result; use comrak::{ @@ -15,6 +12,7 @@ use comrak::{ nodes::{Ast, AstNode, NodeValue}, parse_document, Arena, ComrakExtensionOptions, ComrakOptions, ComrakRenderOptions, }; +use convert_case; use itertools::Itertools; use regex::Regex; use tantivy::collector::TopDocs; @@ -22,18 +20,19 @@ use tantivy::query::{QueryParser, RegexQuery}; use tantivy::schema::*; use tantivy::tokenizer::{LowerCaser, NgramTokenizer, TextAnalyzer}; use tantivy::{Index, IndexReader, SnippetGenerator}; -use url::Url; + +use std::sync::Mutex; use std::fmt; pub struct MarkdownHeadings { - counter: Arc, + header_map: Arc>>, } impl Default for MarkdownHeadings { fn default() -> Self { Self { - counter: Arc::new(AtomicUsize::new(0)), + header_map: Arc::new(Mutex::new(HashMap::new())), } } } @@ -44,31 +43,42 @@ impl MarkdownHeadings { } } +/// Sets the document headers +/// +/// uses toclink to ensure header id matches what the TOC expects +/// impl HeadingAdapter for MarkdownHeadings { fn enter(&self, meta: &HeadingMeta) -> String { - // let id = meta.content.to_case(convert_case::Case::Kebab); - let id = self.counter.fetch_add(1, Ordering::SeqCst); - let id = format!("header-{}", id); + let conv = convert_case::Converter::new().to_case(convert_case::Case::Kebab); + let id = conv.convert(meta.content.to_string()); + + let index = match self.header_map.lock().unwrap().get(&id) { + Some(value) => value + 1, + _ => 0, + }; + self.header_map.lock().unwrap().insert(id.clone(), index); + + let id = TocLink::new(&id, index).id; match meta.level { - 1 => format!(r#"

"#), - 2 => format!(r#"

"#), - 3 => format!(r#"

"#), - 4 => format!(r#"

"#), - 5 => format!(r#"

"#), - 6 => format!(r#"
"#), + 1 => format!(r##"

"##), + 2 => format!(r##"

"##), + 3 => format!(r##"

"##), + 4 => format!(r##"

"##), + 5 => format!(r##"
"##), + 6 => format!(r##"
"##), _ => unreachable!(), } } fn exit(&self, meta: &HeadingMeta) -> String { match meta.level { - 1 => r#"
"#, - 2 => r#"
"#, - 3 => r#"

"#, - 4 => r#"

"#, - 5 => r#"

"#, - 6 => r#"

"#, + 1 => r#""#, + 2 => r#""#, + 3 => r#""#, + 4 => r#""#, + 5 => r#""#, + 6 => r#""#, _ => unreachable!(), } .into() @@ -335,38 +345,6 @@ where Ok(()) } -pub fn nest_relative_links(node: &mut markdown::mdast::Node, path: &PathBuf) { - let _ = iter_mut_all(node, &mut |node| { - if let markdown::mdast::Node::Link(ref mut link) = node { - match Url::parse(&link.url) { - Ok(url) => { - if !url.has_host() { - let mut url_path = url.path().to_string(); - let url_path_path = Path::new(&url_path); - match url_path_path.extension() { - Some(ext) => { - if ext.to_str() == Some(".md") { - let base = url_path_path.with_extension(""); - url_path = base.into_os_string().into_string().unwrap(); - } - } - _ => { - warn!("not markdown path: {:?}", path) - } - } - link.url = path.join(url_path).into_os_string().into_string().unwrap(); - } - } - Err(e) => { - warn!("could not parse url in markdown: {}", e) - } - } - } - - Ok(()) - }); -} - /// Get the title of the article. /// /// # Arguments @@ -462,11 +440,10 @@ pub fn wrap_tables<'a>(root: &'a AstNode<'a>, arena: &'a Arena>) -> /// pub fn get_toc<'a>(root: &'a AstNode<'a>) -> anyhow::Result> { let mut links = Vec::new(); - let mut header_counter = 0; + let mut header_count: HashMap = HashMap::new(); iter_nodes(root, &mut |node| { if let NodeValue::Heading(header) = &node.data.borrow().value { - header_counter += 1; if header.level != 1 { let sibling = match node.first_child() { Some(child) => child, @@ -476,7 +453,14 @@ pub fn get_toc<'a>(root: &'a AstNode<'a>) -> anyhow::Result> { } }; if let NodeValue::Text(text) = &sibling.data.borrow().value { - links.push(TocLink::new(text, header_counter - 1).level(header.level)); + let index = match header_count.get(text) { + Some(index) => index + 1, + _ => 0, + }; + + header_count.insert(text.clone(), index); + + links.push(TocLink::new(text, index).level(header.level)); return Ok(false); } } @@ -753,11 +737,25 @@ pub fn mkdocs<'a>(root: &'a AstNode<'a>, arena: &'a Arena>) -> anyho let path = Path::new(link.url.as_str()); if path.is_relative() { + let fragment = match link.url.find("#") { + Some(index) => link.url[index + 1..link.url.len()].to_string(), + _ => "".to_string(), + }; + + for _ in 0..fragment.len() + 1 { + link.url.pop(); + } + if link.url.ends_with(".md") { for _ in 0..".md".len() { link.url.pop(); } } + + let header_id = TocLink::from_fragment(fragment).id; + for c in header_id.chars() { + link.url.push(c) + } } Ok(true) diff --git a/pgml-dashboard/static/css/scss/pages/_docs.scss b/pgml-dashboard/static/css/scss/pages/_docs.scss index 4fca4c7ae..e7890ada0 100644 --- a/pgml-dashboard/static/css/scss/pages/_docs.scss +++ b/pgml-dashboard/static/css/scss/pages/_docs.scss @@ -206,5 +206,21 @@ display: contents !important; } } + + h1, h2, h3, h4, h5, h6 { + scroll-margin-top: 108px; + + &:hover { + &:after { + content: '#'; + margin-left: 0.2em; + position: absolute; + } + } + + a { + color: inherit !important; + } + } } diff --git a/pgml-dashboard/static/js/docs-toc.js b/pgml-dashboard/static/js/docs-toc.js index 25d83c382..496278ae4 100644 --- a/pgml-dashboard/static/js/docs-toc.js +++ b/pgml-dashboard/static/js/docs-toc.js @@ -15,4 +15,16 @@ export default class extends Controller { threshold: [1], }) } + + setUrlFragment(e) { + let href = e.target.attributes.href.nodeValue; + if (href) { + if (href.startsWith("#")) { + let hash = href.slice(1); + if (window.location.hash != hash) { + window.location.hash = hash + } + } + } + } } diff --git a/pgml-dashboard/templates/components/toc.html b/pgml-dashboard/templates/components/toc.html deleted file mode 100644 index 88dbb9d89..000000000 --- a/pgml-dashboard/templates/components/toc.html +++ /dev/null @@ -1,18 +0,0 @@ - -<% if !links.is_empty() { %> -
Table of Contents
- - -
- <% for link in links.iter() { %> - - <% } %> -
-<% } %> diff --git a/pgml-dashboard/templates/layout/nav/toc.html b/pgml-dashboard/templates/layout/nav/toc.html index 65d7ebe0c..6c851deee 100644 --- a/pgml-dashboard/templates/layout/nav/toc.html +++ b/pgml-dashboard/templates/layout/nav/toc.html @@ -10,7 +10,7 @@
Table of Contents
<% for link in toc_links.iter() { %>