Add glossary implementation

Glossary entries can be defined with `~KEY` for the
short form and `~~KEY` for the long form.
If a glossary entry is referenced for the first time it will always be
rendered as the long form.
Glossary entries can be defined in a toml file (default is glossary.toml)
similar to bibliography.

Signed-off-by: trivernis <trivernis@protonmail.com>
feature/epub-rendering
trivernis 4 years ago
parent 9424d04c37
commit e8cdbc3b06
Signed by: Trivernis
GPG Key ID: DFFFCC2C7A02DB45

2
Cargo.lock generated

@ -1277,7 +1277,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
[[package]]
name = "snekdown"
version = "0.27.2"
version = "0.28.0"
dependencies = [
"asciimath-rs 0.5.7 (registry+https://github.com/rust-lang/crates.io-index)",
"base64 0.12.3 (registry+https://github.com/rust-lang/crates.io-index)",

@ -1,6 +1,6 @@
[package]
name = "snekdown"
version = "0.27.2"
version = "0.28.0"
authors = ["trivernis <trivernis@protonmail.com>"]
edition = "2018"
license-file = "LICENSE"

@ -157,7 +157,8 @@ test-key = ["test value", "test value 2"]
ignored-imports = ["style.css"] # those files won't get imported
included-stylesheets = ["style2.css"] # stylesheets that should be included
included-configs = [] # other metadata files that should be included
included-bibliography = ["nextbib.toml"]# bibliography that should be included
included-bibliography = ["mybib.toml"] # bibliography that should be included
included-glossary = ["myglossary.toml"] #glossary that sould be included
```
The `[Section]` keys are not relevant as the structure gets flattened before the values are read.
@ -239,6 +240,36 @@ Bibliography entries are not rendered. To render a list of used bibliography ins
`bib` placeholder at the place you want it to be rendered.
## Glossary
Glossary entries are to be defined in a `glossary.toml` file or any other toml file
that is imported as type `glossary`.
The definition of glossary entries has to follow the following structure
```toml
[SHORT]
long = "Long Form"
description = "The description of the entry"
# Example
[HTML]
long = "Hypertext Markup Language"
description = "The markup language of the web"
```
Those glossary entries can be referenced in the snekdown file as follows:
```md
~HTML is widely used for websites.
The format ~HTML is not considered a programming language by some definitions.
~~HTML
```
The first occurence of the glossary entry (`~HTML`) always uses the long form.
The second will always be the short form. The long form can be enforced by using two
(`~~HTML`) tildes.
## Math
Snekdown allows the embedding of [AsciiMath](http://asciimath.org/):

@ -2,6 +2,7 @@ pub mod tokens;
use crate::format::PlaceholderTemplate;
use crate::references::configuration::{ConfigRefEntry, Configuration, Value};
use crate::references::glossary::{GlossaryManager, GlossaryReference};
use crate::references::placeholders::ProcessPlaceholders;
use crate::references::templates::{Template, TemplateVariable};
use crate::utils::downloads::{DownloadManager, PendingDownload};
@ -73,6 +74,7 @@ pub struct Document {
pub bibliography: BibManager,
pub downloads: Arc<Mutex<DownloadManager>>,
pub stylesheets: Vec<Arc<Mutex<PendingDownload>>>,
pub glossary: Arc<Mutex<GlossaryManager>>,
}
#[derive(Clone, Debug)]
@ -177,6 +179,7 @@ pub enum Inline {
Colored(Colored),
Math(Math),
BibReference(Arc<RwLock<BibReference>>),
GlossaryReference(Arc<Mutex<GlossaryReference>>),
TemplateVar(Arc<RwLock<TemplateVariable>>),
CharacterCode(CharacterCode),
LineBreak,
@ -300,6 +303,7 @@ impl Document {
bibliography: BibManager::new(),
stylesheets: Vec::new(),
downloads: Arc::new(Mutex::new(DownloadManager::new())),
glossary: Arc::new(Mutex::new(GlossaryManager::new())),
}
}
@ -314,6 +318,7 @@ impl Document {
bibliography: self.bibliography.create_child(),
stylesheets: Vec::new(),
downloads: Arc::clone(&self.downloads),
glossary: Arc::clone(&self.glossary),
}
}
@ -411,6 +416,7 @@ impl Document {
if self.is_root {
self.process_definitions();
self.bibliography.assign_entries_to_references();
self.glossary.lock().unwrap().assign_entries_to_references();
self.process_placeholders();
}
}

@ -69,16 +69,18 @@ pub(crate) const TEMPLATE: char = PERCENT;
pub(crate) const ITALIC: char = ASTERISK;
pub(crate) const MONOSPACE: char = BACKTICK;
pub(crate) const STRIKED: char = TILDE;
pub(crate) const STRIKED: &'static [char] = &[TILDE, TILDE];
pub(crate) const UNDERLINED: char = UNDERSCR;
pub(crate) const SUPER: char = UP;
pub(crate) const EMOJI: char = COLON;
pub(crate) const MATH_INLINE: &'static [char] = &[MATH, MATH];
pub(crate) const BOLD: [char; 2] = [ASTERISK, ASTERISK];
pub(crate) const BOLD: &'static [char] = &[ASTERISK, ASTERISK];
pub(crate) const CHARACTER_START: char = AMPERSAND;
pub(crate) const CHARACTER_STOP: char = SEMICOLON;
pub(crate) const GLOSSARY_REF_START: char = TILDE;
// groups
pub(crate) const QUOTES: [char; 2] = [SINGLE_QUOTE, DOUBLE_QUOTE];

@ -127,4 +127,10 @@ blockquote {
.centered {
text-align: center;
}
.glossaryReference {
text-decoration: none;
color: inherit;
border-bottom: 1px dotted #000;
}

@ -3,6 +3,7 @@ use crate::format::html::html_writer::HTMLWriter;
use crate::format::PlaceholderTemplate;
use crate::references::configuration::keys::{EMBED_EXTERNAL, META_LANG};
use crate::references::configuration::Value;
use crate::references::glossary::{GlossaryDisplay, GlossaryReference};
use crate::references::templates::{Template, TemplateVariable};
use asciimath_rs::format::mathml::ToMathML;
use htmlescape::encode_attribute;
@ -63,6 +64,7 @@ impl ToHtml for Inline {
Inline::Math(m) => m.to_html(writer),
Inline::LineBreak => writer.write("<br>".to_string()),
Inline::CharacterCode(code) => code.to_html(writer),
Inline::GlossaryReference(gloss) => gloss.lock().unwrap().to_html(writer),
}
}
}
@ -665,3 +667,23 @@ impl ToHtml for Anchor {
writer.write("</div>".to_string())
}
}
impl ToHtml for GlossaryReference {
fn to_html(&self, writer: &mut HTMLWriter) -> io::Result<()> {
if let Some(entry) = &self.entry {
let entry = entry.lock().unwrap();
writer.write("<a class=\"glossaryReference\" href=\"#".to_string())?;
writer.write_attribute(self.short.clone())?;
writer.write("\">".to_string())?;
match self.display {
GlossaryDisplay::Short => writer.write_escaped(entry.short.clone())?,
GlossaryDisplay::Long => writer.write_escaped(entry.long.clone())?,
}
writer.write("</a>".to_string())?;
} else {
writer.write_escaped(format!("~{}", self.short.clone()))?;
}
Ok(())
}
}

@ -4,11 +4,13 @@ use crate::elements::BibReference;
use crate::elements::*;
use crate::parser::block::ParseBlock;
use crate::references::configuration::keys::BIB_REF_DISPLAY;
use crate::references::glossary::GlossaryDisplay;
use crate::references::glossary::GlossaryReference;
use crate::references::templates::{GetTemplateVariables, Template, TemplateVariable};
use crate::Parser;
use bibliographix::references::bib_reference::BibRef;
use std::collections::HashMap;
use std::sync::{Arc, RwLock};
use std::sync::{Arc, Mutex, RwLock};
pub(crate) trait ParseInline {
fn parse_surrounded(&mut self, surrounding: &char) -> ParseResult<Vec<Inline>>;
@ -27,6 +29,7 @@ pub(crate) trait ParseInline {
fn parse_colored(&mut self) -> ParseResult<Colored>;
fn parse_bibref(&mut self) -> ParseResult<Arc<RwLock<BibReference>>>;
fn parse_template_variable(&mut self) -> ParseResult<Arc<RwLock<TemplateVariable>>>;
fn parse_glossary_reference(&mut self) -> ParseResult<Arc<Mutex<GlossaryReference>>>;
fn parse_plain(&mut self) -> ParseResult<PlainText>;
fn parse_inline_metadata(&mut self) -> ParseResult<InlineMetadata>;
fn parse_metadata_pair(&mut self) -> ParseResult<(String, MetadataValue)>;
@ -70,13 +73,13 @@ impl ParseInline for Parser {
log::trace!("EOF");
Err(self.ctm.err())
} else if let Ok(image) = self.parse_image() {
log::trace!("Inline::Image");
log::trace!("Inline::Image {:?}", image);
Ok(Inline::Image(image))
} else if let Ok(url) = self.parse_url(false) {
log::trace!("Inline::Url");
log::trace!("Inline::Url {:?}", url);
Ok(Inline::Url(url))
} else if let Ok(pholder) = self.parse_placeholder() {
log::trace!("Inline::Placeholder");
log::trace!("Inline::Placeholder {:?}", pholder);
Ok(Inline::Placeholder(pholder))
} else if let Ok(bold) = self.parse_bold() {
log::trace!("Inline::Bold");
@ -88,8 +91,11 @@ impl ParseInline for Parser {
log::trace!("Inline::Underlined");
Ok(Inline::Underlined(under))
} else if let Ok(mono) = self.parse_monospace() {
log::trace!("Inline::Monospace");
log::trace!("Inline::Monospace {}", mono.value);
Ok(Inline::Monospace(mono))
} else if let Ok(gloss) = self.parse_glossary_reference() {
log::trace!("Inline::GlossaryReference {}", gloss.lock().unwrap().short);
Ok(Inline::GlossaryReference(gloss))
} else if let Ok(striked) = self.parse_striked() {
log::trace!("Inline::Striked");
Ok(Inline::Striked(striked))
@ -97,26 +103,27 @@ impl ParseInline for Parser {
log::trace!("Inline::Superscript");
Ok(Inline::Superscript(superscript))
} else if let Ok(checkbox) = self.parse_checkbox() {
log::trace!("Inline::Checkbox");
log::trace!("Inline::Checkbox {}", checkbox.value);
Ok(Inline::Checkbox(checkbox))
} else if let Ok(emoji) = self.parse_emoji() {
log::trace!("Inline::Emoji");
log::trace!("Inline::Emoji {} -> {}", emoji.name, emoji.value);
Ok(Inline::Emoji(emoji))
} else if let Ok(colored) = self.parse_colored() {
log::trace!("Inline::Colored");
Ok(Inline::Colored(colored))
} else if let Ok(bibref) = self.parse_bibref() {
log::trace!("Inline::BibReference");
log::trace!("Inline::BibReference {:?}", bibref);
Ok(Inline::BibReference(bibref))
} else if let Ok(math) = self.parse_math() {
log::trace!("Inline::Math");
Ok(Inline::Math(math))
} else if let Ok(char_code) = self.parse_character_code() {
log::trace!("Inline::Character Code");
log::trace!("Inline::CharacterCode {}", char_code.code);
Ok(Inline::CharacterCode(char_code))
} else {
log::trace!("Inline::Plain");
Ok(Inline::Plain(self.parse_plain()?))
let plain = self.parse_plain()?;
log::trace!("Inline::Plain {}", plain.value);
Ok(Inline::Plain(plain))
}
}
@ -237,9 +244,19 @@ impl ParseInline for Parser {
}
fn parse_striked(&mut self) -> ParseResult<StrikedText> {
Ok(StrikedText {
value: self.parse_surrounded(&STRIKED)?,
})
let start_index = self.ctm.get_index();
self.ctm.assert_sequence(&STRIKED, Some(start_index))?;
self.ctm.seek_one()?;
let mut inline = vec![self.parse_inline()?];
while !self.ctm.check_sequence(&STRIKED) {
if let Ok(result) = self.parse_inline() {
inline.push(result);
} else {
return Err(self.ctm.rewind_with_error(start_index));
}
}
self.ctm.seek_one()?;
Ok(StrikedText { value: inline })
}
fn parse_math(&mut self) -> ParseResult<Math> {
@ -373,6 +390,39 @@ impl ParseInline for Parser {
})))
}
/// Parses a reference to a glossary entry
fn parse_glossary_reference(&mut self) -> ParseResult<Arc<Mutex<GlossaryReference>>> {
self.ctm.assert_char(&GLOSSARY_REF_START, None)?;
let start_index = self.ctm.get_index();
self.ctm.seek_one()?;
let display = if self.ctm.check_char(&GLOSSARY_REF_START) {
self.ctm.seek_one()?;
GlossaryDisplay::Long
} else {
GlossaryDisplay::Short
};
let mut key =
self.ctm
.get_string_until_any_or_rewind(&WHITESPACE, &[TILDE], start_index)?;
if key.len() == 0 {
return Err(self.ctm.rewind_with_error(start_index));
}
if !key.chars().last().unwrap().is_alphabetic() {
self.ctm.rewind(self.ctm.get_index() - 1);
key = key[..key.len() - 1].to_string();
}
let reference = GlossaryReference::with_display(key, display);
Ok(self
.options
.document
.glossary
.lock()
.unwrap()
.add_reference(reference))
}
/// parses plain text as a string until it encounters an unescaped special inline char
fn parse_plain(&mut self) -> ParseResult<PlainText> {
if self.ctm.check_char(&LB) {
@ -499,13 +549,11 @@ impl ParseInline for Parser {
} else {
return Err(self.ctm.rewind_with_error(start_index));
};
self.ctm.seek_one()?;
if !self.ctm.check_eof() {
self.ctm.seek_one()?;
}
let metadata = if let Ok(meta) = self.parse_inline_metadata() {
Some(meta)
} else {
None
};
let metadata = self.parse_inline_metadata().ok();
let placeholder = Arc::new(RwLock::new(Placeholder::new(name, metadata)));
self.options

@ -6,7 +6,7 @@ use self::block::ParseBlock;
use crate::elements::tokens::LB;
use crate::elements::{Document, ImportAnchor};
use crate::references::configuration::keys::{
IMP_BIBLIOGRAPHY, IMP_CONFIGS, IMP_IGNORE, IMP_STYLESHEETS,
IMP_BIBLIOGRAPHY, IMP_CONFIGS, IMP_GLOSSARY, IMP_IGNORE, IMP_STYLESHEETS,
};
use crate::references::configuration::Value;
use charred::tapemachine::{CharTapeMachine, TapeError, TapeResult};
@ -27,6 +27,7 @@ const DEFAULT_IMPORTS: &'static [(&str, &str)] = &[
("manifest.toml", "manifest"),
("bibliography.toml", "bibliography"),
("bibliography2.bib.toml", "bibliography"),
("glossary.toml", "glossary"),
("style.css", "stylesheet"),
];
@ -225,6 +226,23 @@ impl Parser {
Ok(())
}
/// Imports a glossary
fn import_glossary(&self, path: PathBuf) -> ParseResult<()> {
let contents = self.import_text_file(path)?;
let value = contents
.parse::<toml::Value>()
.map_err(|_| self.ctm.err())?;
self.options
.document
.glossary
.lock()
.unwrap()
.assign_from_toml(value)
.unwrap_or_else(|e| log::error!("{}", e));
Ok(())
}
/// Imports a path
fn import(&mut self, path: String, args: &HashMap<String, Value>) -> ImportType {
log::debug!(
@ -274,6 +292,9 @@ impl Parser {
Some(s) if s == "manifest".to_string() || s == "config" => {
ImportType::Manifest(self.import_manifest(path))
}
Some(s) if s == "glossary".to_string() => {
ImportType::Glossary(self.import_glossary(path))
}
_ => {
lazy_static::lazy_static! {
static ref BIB_NAME: Regex = Regex::new(r".*\.bib\.toml$").unwrap();
@ -379,6 +400,20 @@ impl Parser {
self.import(s, &args);
}
}
if let Some(Value::Array(mut imp)) = self
.options
.document
.config
.get_entry(IMP_GLOSSARY)
.and_then(|e| Some(e.get().clone()))
{
let args =
maplit::hashmap! {"type".to_string() => Value::String("glossary".to_string())};
while let Some(Value::String(s)) = imp.pop() {
self.import(s, &args);
}
}
}
}
@ -387,5 +422,6 @@ pub(crate) enum ImportType {
Stylesheet(ParseResult<()>),
Bibliography(ParseResult<()>),
Manifest(ParseResult<()>),
Glossary(ParseResult<()>),
None,
}

@ -17,51 +17,12 @@ use bibliographix::bibliography::bibliography_entry::{
BibliographyEntry, BibliographyEntryReference,
};
macro_rules! plain_text {
($e:expr) => {
Inline::Plain(PlainText { value: $e })
};
}
macro_rules! bold_text {
($e:expr) => {
Inline::Bold(BoldText {
value: vec![Inline::Plain(PlainText { value: $e })],
})
};
}
macro_rules! italic_text {
($e:expr) => {
Inline::Italic(ItalicText {
value: vec![Inline::Plain(PlainText { value: $e })],
})
};
}
macro_rules! url_text {
($e:expr) => {
Inline::Url(Url {
url: $e,
description: None,
})
};
}
macro_rules! list_item {
($e:expr, $k:expr) => {
ListItem::new(
Line::Anchor(Anchor {
inner: Box::new(Line::Text($e)),
key: $k,
}),
0,
true,
)
};
}
const DATE_FORMAT: &str = "%d.%m.%Y";
use crate::bold_text;
use crate::italic_text;
use crate::list_item;
use crate::plain_text;
use crate::url_text;
/// Creates a list from a list of bib items
pub fn create_bib_list(entries: Vec<BibliographyEntryReference>) -> List {

@ -5,4 +5,5 @@ pub const IMP_IGNORE: &str = "ignored-imports";
pub const IMP_STYLESHEETS: &str = "included-stylesheets";
pub const IMP_CONFIGS: &str = "included-configs";
pub const IMP_BIBLIOGRAPHY: &str = "included-bibliography";
pub const IMP_GLOSSARY: &str = "included-glossary";
pub const EMBED_EXTERNAL: &str = "embed-external";

@ -0,0 +1,188 @@
use crate::elements::{
Anchor, BoldText, Inline, ItalicText, Line, List, ListItem, PlainText, TextLine,
};
use std::cmp::Ordering;
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use crate::bold_text;
use crate::italic_text;
use crate::plain_text;
const K_LONG: &str = "long";
const K_DESCRIPTION: &str = "description";
/// A glossary manager responsible for handling glossary entries and references to those entries
#[derive(Clone, Debug)]
pub struct GlossaryManager {
entries: HashMap<String, Arc<Mutex<GlossaryEntry>>>,
references: Vec<Arc<Mutex<GlossaryReference>>>,
}
/// A single glossary entry
#[derive(Clone, Debug)]
pub struct GlossaryEntry {
pub short: String,
pub long: String,
pub description: String,
pub is_assigned: bool,
}
/// A single glossary reference
#[derive(Clone, Debug)]
pub struct GlossaryReference {
pub short: String,
pub display: GlossaryDisplay,
pub entry: Option<Arc<Mutex<GlossaryEntry>>>,
}
/// A glossary display value that determines which value
/// of a glossary entry will be rendered
#[derive(Clone, Debug)]
pub enum GlossaryDisplay {
Short,
Long,
}
impl GlossaryManager {
/// Creates a new glossary manager
pub fn new() -> Self {
Self {
entries: HashMap::new(),
references: Vec::new(),
}
}
/// Adds a new glossary entry to the manager
pub fn add_entry(&mut self, entry: GlossaryEntry) -> Arc<Mutex<GlossaryEntry>> {
let key = entry.short.clone();
let entry = Arc::new(Mutex::new(entry));
self.entries.insert(key.clone(), Arc::clone(&entry));
log::debug!("Added glossary entry {}", key);
entry
}
/// Adds a new glossary reference to the manager
pub fn add_reference(&mut self, reference: GlossaryReference) -> Arc<Mutex<GlossaryReference>> {
let reference = Arc::new(Mutex::new(reference));
self.references.push(Arc::clone(&reference));
reference
}
/// Assignes bibliography entries from toml
pub fn assign_from_toml(&mut self, value: toml::Value) -> Result<(), String> {
let table = value.as_table().ok_or("Failed to parse toml".to_string())?;
log::debug!("Assigning glossary entries from toml...");
for (key, value) in table {
let long = value.get(K_LONG).and_then(|l| l.as_str());
let description = value.get(K_DESCRIPTION).and_then(|d| d.as_str());
if let Some(long) = long {
if let Some(description) = description {
let entry = GlossaryEntry {
description: description.to_string(),
long: long.to_string(),
short: key.clone(),
is_assigned: false,
};
self.add_entry(entry);
} else {
log::warn!(
"Failed to parse glossary entry {}: Missing field '{}'",
key,
K_DESCRIPTION
);
}
} else {
log::warn!(
"Failed to parse glossary entry {}: Missing field '{}'",
key,
K_LONG
);
}
}
Ok(())
}
/// Assignes entries to references
pub fn assign_entries_to_references(&self) {
for reference in &self.references {
let mut reference = reference.lock().unwrap();
if let Some(entry) = self.entries.get(&reference.short) {
reference.entry = Some(Arc::clone(entry));
let mut entry = entry.lock().unwrap();
if !entry.is_assigned {
entry.is_assigned = true;
reference.display = GlossaryDisplay::Long;
}
}
}
}
/// Creates a sorted glossary list from the glossary entries
pub fn create_glossary_list(&self) -> List {
let mut list = List::new();
let mut entries = self
.entries
.values()
.filter(|e| e.lock().unwrap().is_assigned)
.cloned()
.collect::<Vec<Arc<Mutex<GlossaryEntry>>>>();
entries.sort_by(|a, b| {
let a = a.lock().unwrap();
let b = b.lock().unwrap();
if a.short > b.short {
Ordering::Greater
} else if a.short < b.short {
Ordering::Less
} else {
Ordering::Equal
}
});
for entry in &entries {
let entry = entry.lock().unwrap();
let mut line = TextLine::new();
line.subtext.push(bold_text!(entry.short.clone()));
line.subtext.push(plain_text!(" - ".to_string()));
line.subtext.push(italic_text!(entry.long.clone()));
line.subtext.push(plain_text!(" - ".to_string()));
line.subtext.push(plain_text!(entry.description.clone()));
list.add_item(ListItem::new(
Line::Anchor(Anchor {
inner: Box::new(Line::Text(line)),
key: entry.short.clone(),
}),
0,
false,
));
}
list
}
}
impl GlossaryReference {
/// Creates a new glossary reference
pub fn new(key: String) -> Self {
Self {
short: key,
display: GlossaryDisplay::Short,
entry: None,
}
}
/// Creates a new glossary reference with a given display parameter
pub fn with_display(key: String, display: GlossaryDisplay) -> Self {
Self {
short: key,
display,
entry: None,
}
}
}

@ -1,4 +1,5 @@
pub mod bibliography;
pub mod configuration;
pub mod glossary;
pub mod placeholders;
pub mod templates;

@ -31,6 +31,7 @@ const S_VALUE: &str = "value";
const P_TOC: &str = "toc";
const P_BIB: &str = "bib";
const P_GLS: &str = "gls";
const P_DATE: &str = "date";
const P_TIME: &str = "time";
const P_DATETIME: &str = "datetime";
@ -52,6 +53,9 @@ impl ProcessPlaceholders for Document {
P_BIB => pholder.set_value(block!(Block::List(create_bib_list(
self.bibliography.get_entry_list_by_occurrence()
)))),
P_GLS => pholder.set_value(block!(Block::List(
self.glossary.lock().unwrap().create_glossary_list()
))),
P_DATE => pholder.set_value(inline!(Inline::Plain(PlainText {
value: get_date_string()
}))),

@ -0,0 +1,48 @@
#[macro_export]
macro_rules! plain_text {
($e:expr) => {
Inline::Plain(PlainText { value: $e })
};
}
#[macro_export]
macro_rules! bold_text {
($e:expr) => {
Inline::Bold(BoldText {
value: vec![Inline::Plain(PlainText { value: $e })],
})
};
}
#[macro_export]
macro_rules! italic_text {
($e:expr) => {
Inline::Italic(ItalicText {
value: vec![Inline::Plain(PlainText { value: $e })],
})
};
}
#[macro_export]
macro_rules! url_text {
($e:expr) => {
Inline::Url(Url {
url: $e,
description: None,
})
};
}
#[macro_export]
macro_rules! list_item {
($e:expr, $k:expr) => {
ListItem::new(
Line::Anchor(Anchor {
inner: Box::new(Line::Text($e)),
key: $k,
}),
0,
true,
)
};
}

@ -1,2 +1,3 @@
pub mod downloads;
pub mod macros;
pub mod parsing;

Loading…
Cancel
Save