Skip to content

Commit

Permalink
feat: add Github like TOC
Browse files Browse the repository at this point in the history
  • Loading branch information
vjousse committed Jul 14, 2024
1 parent 822725a commit e6a1584
Show file tree
Hide file tree
Showing 6 changed files with 183 additions and 9 deletions.
14 changes: 14 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -36,3 +36,6 @@ xml = "0.8.20"
slug = "0.1.5"
clap = { version = "4.5.8", features = ["derive"] }
strip_markdown = "0.2.0"
pulldown-cmark-toc = "0.5.0"
regex = "1.10.5"
once_cell = "1.19.0"
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ date: "2024-07-13 09:33:20+01:00"
slug: configurer-neovim-comme-ide-a-partir-de-zero-tutoriel-guide
tags: neovim, tutoriel, lua, vim
status: draft
toc: false
toc: true
---

Vous avez envie d'utiliser [_Neovim_](https://neovim.io/) mais ne savez pas par où commencer ? Vous voulez comprendre ce que vous faites au lieu d'utiliser des configurations déjà toutes prêtes ? Vous n'avez aucune idée de comment faire du _Lua_ ou ne savez même pas pourquoi vous devriez ? Cet article est fait pour vous !
Expand Down
165 changes: 157 additions & 8 deletions src/content.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,20 +9,98 @@ use anyhow::Result;
use form_urlencoded::byte_serialize;
use gray_matter::engine::YAML;
use gray_matter::Matter;
use pulldown_cmark as cmark;
use once_cell::sync::Lazy;
use pulldown_cmark::{self as cmark};
use pulldown_cmark_toc::TableOfContents;
use regex::Regex;
use slug::slugify;
use std::collections::HashMap;
use std::error::Error;
use std::fs;
use std::fs::File;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::slice::Iter;
use strip_markdown::strip_markdown;
use tera::{Context, Tera};
use walkdir::WalkDir;

type FilePath = String;

/// Represents a heading.
#[derive(Debug, Clone)]
pub struct CustomHeading<'a> {
/// The Markdown events between the heading tags.
events: Vec<Event<'a>>,
/// The heading level.
tag: Tag<'a>,
}

impl CustomHeading<'_> {
/// The raw events contained between the heading tags.
pub fn events(&self) -> Iter<Event> {
self.events.iter()
}

/// The heading level.
pub fn tag(&self) -> Tag {
self.tag.clone()
}

/// The heading text with all Markdown code stripped out.
///
/// The output of this this function can be used to generate an anchor.
pub fn text(&self) -> String {
let mut buf = String::new();
for event in self.events() {
if let Event::Text(s) | Event::Code(s) = event {
buf.push_str(s);
}
}

buf
}
}

/// A trait to specify the anchor calculation.
pub trait Slugify {
fn slugify(&mut self, str: String) -> String;
}

/// A slugifier that attempts to mimic GitHub's behavior.
///
/// Unfortunately GitHub's behavior is not documented anywhere by GitHub.
/// This should really be part of the [GitHub Flavored Markdown Spec][gfm]
/// but alas it's not. And there also does not appear to be a public issue
/// tracker for the spec where that issue could be raised.
///
/// [gfm]: https://github.github.com/gfm/
#[derive(Default)]
pub struct GitHubSlugifier {
counts: HashMap<String, i32>,
}

impl Slugify for GitHubSlugifier {
fn slugify(&mut self, str: String) -> String {
static RE: Lazy<Regex> = Lazy::new(|| Regex::new(r"[^\w\- ]").unwrap());
let anchor = RE
.replace_all(&str.to_lowercase().replace(' ', "-"), "")
.into_owned();

let i = self
.counts
.entry(anchor.clone())
.and_modify(|i| *i += 1)
.or_insert(0);

match *i {
0 => anchor,
i => format!("{}-{}", anchor, i),
}
.into()

Check warning on line 100 in src/content.rs

View workflow job for this annotation

GitHub Actions / clippy

useless conversion to the same type: `std::string::String`

warning: useless conversion to the same type: `std::string::String` --> src/content.rs:96:9 | 96 | / match *i { 97 | | 0 => anchor, 98 | | i => format!("{}-{}", anchor, i), 99 | | } 100 | | .into() | |_______________^ | = help: for further information visit https://rust-lang.github.io/rust-clippy/master/index.html#useless_conversion = note: `#[warn(clippy::useless_conversion)]` on by default help: consider removing `.into()` | 96 ~ match *i { 97 + 0 => anchor, 98 + i => format!("{}-{}", anchor, i), 99 + } |
}
}

pub fn create_content(site: &Site, publish_drafts: bool) -> Result<()> {
// Get the list of files
let files_to_parse: Vec<FilePath> = get_files_for_directory(&site.settings.posts_path);
Expand Down Expand Up @@ -83,22 +161,74 @@ pub fn create_content(site: &Site, publish_drafts: bool) -> Result<()> {
pub fn convert_md_to_html(md_content: &str, settings: &Settings, path: Option<&str>) -> String {
let mut options = Options::empty();
options.insert(Options::ENABLE_STRIKETHROUGH);
//let parser = Parser::new_ext(md_content, options);
options.insert(Options::ENABLE_HEADING_ATTRIBUTES);
options.insert(Options::ENABLE_GFM);

let mut events = Vec::new();
let mut code_block: Option<CodeBlock> = None;

let mut current: Option<CustomHeading> = None;
let mut slugifier = GitHubSlugifier::default();

for (event, mut _range) in Parser::new_ext(md_content, options).into_offset_iter() {
match event {
Event::Text(text) => {
if let Some(ref mut code_block) = code_block {
let html = code_block.highlight(&text);
events.push(Event::Html(html.into()));
if let Some(heading) = current.as_mut() {
heading.events.push(Event::Text(text));
} else {
events.push(Event::Text(text));
continue;
if let Some(ref mut code_block) = code_block {
let html = code_block.highlight(&text);
events.push(Event::Html(html.into()));
} else {
events.push(Event::Text(text));
continue;
}
}

Check warning on line 186 in src/content.rs

View workflow job for this annotation

GitHub Actions / clippy

this `else { if .. }` block can be collapsed

warning: this `else { if .. }` block can be collapsed --> src/content.rs:178:24 | 178 | } else { | ________________________^ 179 | | if let Some(ref mut code_block) = code_block { 180 | | let html = code_block.highlight(&text); 181 | | events.push(Event::Html(html.into())); ... | 185 | | } 186 | | } | |_________________^ | = help: for further information visit https://rust-lang.github.io/rust-clippy/master/index.html#collapsible_else_if = note: `#[warn(clippy::collapsible_else_if)]` on by default help: collapse nested if block | 178 ~ } else if let Some(ref mut code_block) = code_block { 179 + let html = code_block.highlight(&text); 180 + events.push(Event::Html(html.into())); 181 + } else { 182 + events.push(Event::Text(text)); 183 + continue; 184 + } |
}

Event::Start(Tag::Heading {
level,
ref id,
ref classes,
ref attrs,
}) => {
current = Some(CustomHeading {
events: Vec::new(),
tag: Tag::Heading {
level,
id: id.clone(),
classes: classes.to_vec(),
attrs: attrs.to_vec(),
},
});
}
Event::End(TagEnd::Heading(_level)) => {
let heading = current.take().unwrap();

if let Tag::Heading {
level,
ref id,
ref classes,
ref attrs,
} = heading.tag
{
let text = heading.text();
let string_text = text.to_string();
let slug = slugifier.slugify(string_text);
events.push(Event::Start(Tag::Heading {
level,
id: id.clone().or(Some(slug.clone().into())).into(),

Check warning on line 220 in src/content.rs

View workflow job for this annotation

GitHub Actions / clippy

useless conversion to the same type: `std::option::Option<pulldown_cmark::CowStr<'_>>`

warning: useless conversion to the same type: `std::option::Option<pulldown_cmark::CowStr<'_>>` --> src/content.rs:220:29 | 220 | id: id.clone().or(Some(slug.clone().into())).into(), | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ help: consider removing `.into()`: `id.clone().or(Some(slug.clone().into()))` | = help: for further information visit https://rust-lang.github.io/rust-clippy/master/index.html#useless_conversion
classes: classes.to_vec(),
attrs: attrs.to_vec(),
}));
let heading_events = heading.events.clone();
for e in heading_events {
events.push(e);
}

events.push(Event::End(TagEnd::Heading(level)));
};
}
Event::Start(Tag::CodeBlock(ref kind)) => {
let fence = match kind {
cmark::CodeBlockKind::Fenced(fence_info) => FenceSettings::new(fence_info),
Expand All @@ -113,7 +243,13 @@ pub fn convert_md_to_html(md_content: &str, settings: &Settings, path: Option<&s
code_block = None;
events.push(Event::Html("</code></pre>\n".into()));
}
_ => events.push(event),
event => {
if let Some(heading) = current.as_mut() {
heading.events.push(event.clone());
} else {
events.push(event);
}
}
}
}

Expand Down Expand Up @@ -216,6 +352,19 @@ pub fn write_posts_html(posts: &[Post], site: &Site) {

context.insert("categories", &post.ancestor_directories_names);

let toc = &post.front_matter.toc.unwrap_or(false);

if *toc {
let table_of_contents = TableOfContents::new(&post.content).to_cmark();

let html_toc = convert_md_to_html(
table_of_contents.as_str(),
&site.settings,
Some(&post.path[..]),
);
context.insert("toc", &html_toc);
}

context.insert(
"description",
&post
Expand Down
1 change: 1 addition & 0 deletions src/post.rs
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,7 @@ pub struct FrontMatter {
#[serde(default)]
#[serde(with = "optional_custom_date_format")]
pub updated_at: Option<DateTime<FixedOffset>>,
pub toc: Option<bool>,
}

#[derive(Clone, Debug, Serialize, Eq, Ord, PartialEq, PartialOrd)]
Expand Down
7 changes: 7 additions & 0 deletions templates/blog/post.html
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,13 @@
{%endif%}
<h1>{{ title }}</h1>
</header>
{% if toc %}
<h2>Table des matières</h2>
<div id="toc">
{{ toc }}
</div>
<hr />
{% endif %}
{{ post_content }}
</article>
{% endblock content %}

0 comments on commit e6a1584

Please sign in to comment.