Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 2 additions & 12 deletions Cargo.lock

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

3 changes: 1 addition & 2 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,9 @@ napi-derive = { version = "=3.5.6", features = ["type-def"] }
serde = { version = "=1.0.228", features = ["derive"] }
serde_json = "=1.0.150"
htmlescape = "=0.3.1"
rust-norg = { git = "https://github.com/nvim-neorg/rust-norg", rev = "79673015447b62d021d57b92f80be1454fe5cf83" }
rust-norg = { git = "https://github.com/nvim-neorg/rust-norg", rev = "8e40d2443c39b4719e1c6637b93007fa64353e92" }
arborium = { version = "=2.17.0", features = ["all-languages"] }
textwrap = "=0.16.2"
itertools = "=0.14.0"

[dev-dependencies]
insta = { version = "=1.47.2", features = ["yaml"] }
Expand Down
68 changes: 42 additions & 26 deletions src/parser/ast_handlers/nestable.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,66 +2,82 @@ use crate::segments::convert_segments;
use crate::utils::into_slug;
use htmlescape::encode_minimal;
use rust_norg::{DetachedModifierExtension, NorgASTFlat, TodoStatus};
use std::fmt::Write;

pub fn nestable_modifier(
text: &NorgASTFlat,
extensions: &[DetachedModifierExtension],
children_html: &str,
) -> Option<String> {
match text {
NorgASTFlat::Paragraph(segments) => {
let content = convert_segments(segments);
(!content.trim().is_empty()).then(|| format_nestable(&content, extensions))
let content = match text {
NorgASTFlat::Paragraph(segments) => convert_segments(segments),
_ => {
debug_assert!(false, "non-Paragraph text in nestable modifier");
String::new()
}
_ => None,
};
if content.trim().is_empty() && children_html.trim().is_empty() && extensions.is_empty() {
return None;
}
Some(list_item(&content, extensions, children_html))
}

fn format_nestable(content: &str, extensions: &[DetachedModifierExtension]) -> String {
let mut classes: Vec<String> = Vec::new();
let mut attrs: Vec<String> = Vec::new();
let mut prefix: Vec<&str> = Vec::new();
fn list_item(
content: &str,
extensions: &[DetachedModifierExtension],
children_html: &str,
) -> String {
let mut classes = String::new();
let mut attrs = String::new();
let mut prefix = String::new();

for extension in extensions {
match extension {
DetachedModifierExtension::Todo(status) => {
if matches!(status, TodoStatus::Recurring(_)) {
classes.push("todo-recurring".into());
push_space_separated(&mut classes, "todo-recurring");
}
prefix.push(todo_html(status));
push_space_separated(&mut prefix, todo_html(status));
}
DetachedModifierExtension::Priority(priority) => {
classes.push(format!("priority-{}", into_slug(priority)));
attrs.push(format!(r#"data-priority="{}""#, encode_minimal(priority)));
push_space_separated(&mut classes, &format!("priority-{}", into_slug(priority)));
push_attr(&mut attrs, "data-priority", priority);
}
DetachedModifierExtension::Timestamp(timestamp) => {
attrs.push(format!(r#"data-timestamp="{}""#, encode_minimal(timestamp)));
push_attr(&mut attrs, "data-timestamp", timestamp);
}
DetachedModifierExtension::DueDate(date) => {
attrs.push(format!(r#"data-due="{}""#, encode_minimal(date)));
push_attr(&mut attrs, "data-due", date);
}
DetachedModifierExtension::StartDate(date) => {
attrs.push(format!(r#"data-start="{}""#, encode_minimal(date)));
push_attr(&mut attrs, "data-start", date);
}
}
}

let class_attr = if classes.is_empty() {
String::new()
} else {
format!(r#" class="{}""#, classes.join(" "))
};
let data_attrs = if attrs.is_empty() {
String::new()
} else {
format!(" {}", attrs.join(" "))
format!(r#" class="{classes}""#)
};
let prefix_html = if prefix.is_empty() {
String::new()
let separator = if prefix.is_empty() || content.trim().is_empty() {
""
} else {
format!("{} ", prefix.join(" "))
" "
};

format!("<li{class_attr}{data_attrs}>{prefix_html}{content}</li>")
format!("<li{class_attr}{attrs}>{prefix}{separator}{content}{children_html}</li>")
}

fn push_space_separated(buf: &mut String, value: &str) {
if !buf.is_empty() {
buf.push(' ');
}
buf.push_str(value);
}

fn push_attr(buf: &mut String, name: &str, value: &str) {
let _ = write!(buf, r#" {name}="{}""#, encode_minimal(value));
}

fn todo_html(status: &TodoStatus) -> &'static str {
Expand Down
118 changes: 66 additions & 52 deletions src/parser/ast_handlers/verbatim.rs
Original file line number Diff line number Diff line change
@@ -1,14 +1,13 @@
use super::error::EmbedParseError;
use crate::types::{EmbedComponent, OutputMode};
use crate::types::OutputMode;
use arborium::Highlighter;
use htmlescape::encode_minimal;
use itertools::Itertools;
use textwrap::dedent;

pub enum VerbatimTagResult {
Html(String),
Css(String),
Embed(EmbedComponent),
Embed { mode: String, code: String },
}

pub enum VerbatimTag {
Expand Down Expand Up @@ -40,31 +39,33 @@ impl VerbatimTag {
highlighter: &mut Highlighter,
embed_index: usize,
) -> Result<Option<VerbatimTagResult>, EmbedParseError> {
let first_param = || {
parameters
.first()
.filter(|s| !s.is_empty())
.map(String::as_str)
};

match self {
Self::Code => {
let code = dedent(content);
let lang = parameters
.first()
.filter(|s| !s.is_empty())
.map(String::as_str)
.unwrap_or("text");

let highlighted = highlighter.highlight(lang, &code);
let html = match highlighted {
Ok(h) => format!(
let lang = first_param().unwrap_or("text");
let body = match highlighter.highlight(lang, &code) {
Ok(highlighted) => format!(
r#"<pre class="arborium lang-{lang}"><code>{}</code></pre>"#,
wrap_lines(&h)
wrap_lines(&highlighted)
),
Err(_) => format!(
r#"<pre><code>{}</code></pre>"#,
wrap_lines(&encode_minimal(&code))
),
};
Ok(Some(VerbatimTagResult::Html(html)))
Ok(Some(VerbatimTagResult::Html(body)))
}
Self::Image => Ok(parameters.first().filter(|s| !s.is_empty()).map(|path| {

Self::Image => Ok(first_param().map(|path| {
let src = if path.starts_with('/') || path.starts_with("http") {
path.clone()
path.to_string()
} else {
format!("./{path}")
};
Expand All @@ -74,40 +75,11 @@ impl VerbatimTag {
encode_minimal(content.trim())
))
})),
Self::Embed => {
let embed_lang = parameters
.first()
.filter(|s| !s.is_empty())
.map(String::as_str);

match embed_lang {
Some("css") => Ok(Some(VerbatimTagResult::Css(content.to_string()))),
None => Err(EmbedParseError::MissingLanguage { index: embed_index }),
Some(lang) => {
let embed_mode = lang.parse::<OutputMode>().map_err(|_| {
EmbedParseError::InvalidLanguage {
index: embed_index,
language: lang.to_string(),
}
})?;
Self::Embed => render_embed(first_param(), content, mode, embed_index),

match mode {
None => Ok(None),
Some(m) if m != embed_mode => Err(EmbedParseError::LanguageMismatch {
index: embed_index,
language: lang.to_string(),
mode: m,
}),
Some(_) => Ok(Some(VerbatimTagResult::Embed(EmbedComponent {
index: 0,
mode: embed_mode.to_string(),
code: content.to_string(),
}))),
}
}
}
}
Self::DocumentMeta => Ok(None),

Self::Unknown => Ok(Some(VerbatimTagResult::Html(format!(
r#"<div class="verbatim">{}</div>"#,
encode_minimal(content)
Expand All @@ -116,10 +88,52 @@ impl VerbatimTag {
}
}

/// Wraps each of highlighted HTML in `<span class="line">`
/// This enables per-line styling such as line numbers or highlighting specific lines
fn render_embed(
lang: Option<&str>,
content: &str,
mode: Option<OutputMode>,
index: usize,
) -> Result<Option<VerbatimTagResult>, EmbedParseError> {
let Some(lang) = lang else {
return Err(EmbedParseError::MissingLanguage { index });
};

if lang == "css" {
return Ok(Some(VerbatimTagResult::Css(content.to_string())));
}

let embed_mode = lang
.parse::<OutputMode>()
.map_err(|_| EmbedParseError::InvalidLanguage {
index,
language: lang.to_string(),
})?;

match mode {
None => Ok(None),
Some(m) if m != embed_mode => Err(EmbedParseError::LanguageMismatch {
index,
language: lang.to_string(),
mode: m,
}),
Some(_) => Ok(Some(VerbatimTagResult::Embed {
mode: embed_mode.to_string(),
code: content.to_string(),
})),
}
}

/// Wraps each line of highlighted HTML in `<span class="line">` so consumers
/// can attach per-line styling (line numbers, highlights, etc.).
fn wrap_lines(html: &str) -> String {
html.lines()
.map(|line| format!(r#"<span class="line">{line}</span>"#))
.join("\n")
let mut out = String::with_capacity(html.len() + 64);
for (i, line) in html.lines().enumerate() {
if i > 0 {
out.push('\n');
}
out.push_str(r#"<span class="line">"#);
out.push_str(line);
out.push_str("</span>");
}
out
}
Loading