mirror of
https://github.com/getzola/zola
synced 2024-12-05 01:49:12 +00:00
Add class based syntax higlighting + line numbers (#1531)
* Add class based syntax higlighting + line numbers * Use fork of syntect for now * Fix tests * Fix diff background on inline highlighter Co-authored-by: evan-brass <evan-brass@protonmail.com>
This commit is contained in:
parent
57705aa82e
commit
4a87689cfb
25 changed files with 907 additions and 405 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
@ -3,6 +3,7 @@ target
|
||||||
test_site/public
|
test_site/public
|
||||||
test_site_i18n/public
|
test_site_i18n/public
|
||||||
docs/public
|
docs/public
|
||||||
|
docs/out
|
||||||
|
|
||||||
small-blog
|
small-blog
|
||||||
medium-blog
|
medium-blog
|
||||||
|
|
5
Cargo.lock
generated
5
Cargo.lock
generated
|
@ -2549,9 +2549,8 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "syntect"
|
name = "syntect"
|
||||||
version = "4.5.0"
|
version = "5.0.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "git+https://github.com/Keats/syntect.git?branch=scopestack#6b36f5eb406d57e57ddb6eb51df3a5e36e52c955"
|
||||||
checksum = "2bfac2b23b4d049dc9a89353b4e06bbc85a8f42020cccbe5409a115cf19031e5"
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bincode",
|
"bincode",
|
||||||
"bitflags",
|
"bitflags",
|
||||||
|
|
|
@ -12,7 +12,8 @@ serde_derive = "1"
|
||||||
chrono = "0.4"
|
chrono = "0.4"
|
||||||
globset = "0.4"
|
globset = "0.4"
|
||||||
lazy_static = "1"
|
lazy_static = "1"
|
||||||
syntect = "4.1"
|
# TODO: go back to version 4/5 once https://github.com/trishume/syntect/pull/337 is merged
|
||||||
|
syntect = { git = "https://github.com/Keats/syntect.git", branch = "scopestack" }
|
||||||
unic-langid = "0.9"
|
unic-langid = "0.9"
|
||||||
|
|
||||||
errors = { path = "../errors" }
|
errors = { path = "../errors" }
|
||||||
|
|
|
@ -16,6 +16,9 @@ pub struct LanguageOptions {
|
||||||
pub description: Option<String>,
|
pub description: Option<String>,
|
||||||
/// Whether to generate a feed for that language, defaults to `false`
|
/// Whether to generate a feed for that language, defaults to `false`
|
||||||
pub generate_feed: bool,
|
pub generate_feed: bool,
|
||||||
|
/// The filename to use for feeds. Used to find the template, too.
|
||||||
|
/// Defaults to "atom.xml", with "rss.xml" also having a template provided out of the box.
|
||||||
|
pub feed_filename: String,
|
||||||
pub taxonomies: Vec<taxonomies::Taxonomy>,
|
pub taxonomies: Vec<taxonomies::Taxonomy>,
|
||||||
/// Whether to generate search index for that language, defaults to `false`
|
/// Whether to generate search index for that language, defaults to `false`
|
||||||
pub build_search_index: bool,
|
pub build_search_index: bool,
|
||||||
|
@ -34,6 +37,7 @@ impl Default for LanguageOptions {
|
||||||
title: None,
|
title: None,
|
||||||
description: None,
|
description: None,
|
||||||
generate_feed: false,
|
generate_feed: false,
|
||||||
|
feed_filename: String::new(),
|
||||||
build_search_index: false,
|
build_search_index: false,
|
||||||
taxonomies: Vec::new(),
|
taxonomies: Vec::new(),
|
||||||
search: search::Search::default(),
|
search: search::Search::default(),
|
||||||
|
|
|
@ -7,6 +7,21 @@ use errors::Result;
|
||||||
|
|
||||||
pub const DEFAULT_HIGHLIGHT_THEME: &str = "base16-ocean-dark";
|
pub const DEFAULT_HIGHLIGHT_THEME: &str = "base16-ocean-dark";
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(default)]
|
||||||
|
pub struct ThemeCss {
|
||||||
|
/// Which theme are we generating the CSS from
|
||||||
|
pub theme: String,
|
||||||
|
/// In which file are we going to output the CSS
|
||||||
|
pub filename: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for ThemeCss {
|
||||||
|
fn default() -> ThemeCss {
|
||||||
|
ThemeCss { theme: String::new(), filename: String::new() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub struct Markdown {
|
pub struct Markdown {
|
||||||
|
@ -15,6 +30,8 @@ pub struct Markdown {
|
||||||
/// Which themes to use for code highlighting. See Readme for supported themes
|
/// Which themes to use for code highlighting. See Readme for supported themes
|
||||||
/// Defaults to "base16-ocean-dark"
|
/// Defaults to "base16-ocean-dark"
|
||||||
pub highlight_theme: String,
|
pub highlight_theme: String,
|
||||||
|
/// Generate CSS files for Themes out of syntect
|
||||||
|
pub highlight_themes_css: Vec<ThemeCss>,
|
||||||
/// Whether to render emoji aliases (e.g.: :smile: => 😄) in the markdown files
|
/// Whether to render emoji aliases (e.g.: :smile: => 😄) in the markdown files
|
||||||
pub render_emoji: bool,
|
pub render_emoji: bool,
|
||||||
/// Whether external links are to be opened in a new tab
|
/// Whether external links are to be opened in a new tab
|
||||||
|
@ -87,12 +104,13 @@ impl Default for Markdown {
|
||||||
Markdown {
|
Markdown {
|
||||||
highlight_code: false,
|
highlight_code: false,
|
||||||
highlight_theme: DEFAULT_HIGHLIGHT_THEME.to_owned(),
|
highlight_theme: DEFAULT_HIGHLIGHT_THEME.to_owned(),
|
||||||
|
highlight_themes_css: Vec::new(),
|
||||||
render_emoji: false,
|
render_emoji: false,
|
||||||
external_links_target_blank: false,
|
external_links_target_blank: false,
|
||||||
external_links_no_follow: false,
|
external_links_no_follow: false,
|
||||||
external_links_no_referrer: false,
|
external_links_no_referrer: false,
|
||||||
smart_punctuation: false,
|
smart_punctuation: false,
|
||||||
extra_syntaxes: vec![],
|
extra_syntaxes: Vec::new(),
|
||||||
extra_syntax_set: None,
|
extra_syntax_set: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -98,6 +98,7 @@ pub struct SerializedConfig<'a> {
|
||||||
description: &'a Option<String>,
|
description: &'a Option<String>,
|
||||||
languages: HashMap<&'a String, &'a languages::LanguageOptions>,
|
languages: HashMap<&'a String, &'a languages::LanguageOptions>,
|
||||||
generate_feed: bool,
|
generate_feed: bool,
|
||||||
|
feed_filename: &'a str,
|
||||||
taxonomies: &'a [taxonomies::Taxonomy],
|
taxonomies: &'a [taxonomies::Taxonomy],
|
||||||
build_search_index: bool,
|
build_search_index: bool,
|
||||||
extra: &'a HashMap<String, Toml>,
|
extra: &'a HashMap<String, Toml>,
|
||||||
|
@ -116,12 +117,14 @@ impl Config {
|
||||||
bail!("A base URL is required in config.toml with key `base_url`");
|
bail!("A base URL is required in config.toml with key `base_url`");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if config.markdown.highlight_theme != "css" {
|
||||||
if !THEME_SET.themes.contains_key(&config.markdown.highlight_theme) {
|
if !THEME_SET.themes.contains_key(&config.markdown.highlight_theme) {
|
||||||
bail!(
|
bail!(
|
||||||
"Highlight theme {} defined in config does not exist.",
|
"Highlight theme {} defined in config does not exist.",
|
||||||
config.markdown.highlight_theme
|
config.markdown.highlight_theme
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
languages::validate_code(&config.default_language)?;
|
languages::validate_code(&config.default_language)?;
|
||||||
for code in config.languages.keys() {
|
for code in config.languages.keys() {
|
||||||
|
@ -201,6 +204,7 @@ impl Config {
|
||||||
title: self.title.clone(),
|
title: self.title.clone(),
|
||||||
description: self.description.clone(),
|
description: self.description.clone(),
|
||||||
generate_feed: self.generate_feed,
|
generate_feed: self.generate_feed,
|
||||||
|
feed_filename: self.feed_filename.clone(),
|
||||||
build_search_index: self.build_search_index,
|
build_search_index: self.build_search_index,
|
||||||
taxonomies: self.taxonomies.clone(),
|
taxonomies: self.taxonomies.clone(),
|
||||||
search: self.search.clone(),
|
search: self.search.clone(),
|
||||||
|
@ -288,6 +292,7 @@ impl Config {
|
||||||
description: &options.description,
|
description: &options.description,
|
||||||
languages: self.languages.iter().filter(|(k, _)| k.as_str() != lang).collect(),
|
languages: self.languages.iter().filter(|(k, _)| k.as_str() != lang).collect(),
|
||||||
generate_feed: options.generate_feed,
|
generate_feed: options.generate_feed,
|
||||||
|
feed_filename: &options.feed_filename,
|
||||||
taxonomies: &options.taxonomies,
|
taxonomies: &options.taxonomies,
|
||||||
build_search_index: options.build_search_index,
|
build_search_index: options.build_search_index,
|
||||||
extra: &self.extra,
|
extra: &self.extra,
|
||||||
|
|
|
@ -1,10 +1,10 @@
|
||||||
use lazy_static::lazy_static;
|
use lazy_static::lazy_static;
|
||||||
use syntect::dumps::from_binary;
|
use syntect::dumps::from_binary;
|
||||||
use syntect::easy::HighlightLines;
|
use syntect::highlighting::{Theme, ThemeSet};
|
||||||
use syntect::highlighting::ThemeSet;
|
use syntect::parsing::{SyntaxReference, SyntaxSet};
|
||||||
use syntect::parsing::SyntaxSet;
|
|
||||||
|
|
||||||
use crate::config::Config;
|
use crate::config::Config;
|
||||||
|
use syntect::html::{css_for_theme_with_class_style, ClassStyle};
|
||||||
|
|
||||||
lazy_static! {
|
lazy_static! {
|
||||||
pub static ref SYNTAX_SET: SyntaxSet = {
|
pub static ref SYNTAX_SET: SyntaxSet = {
|
||||||
|
@ -16,24 +16,47 @@ lazy_static! {
|
||||||
from_binary(include_bytes!("../../../sublime/themes/all.themedump"));
|
from_binary(include_bytes!("../../../sublime/themes/all.themedump"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub const CLASS_STYLE: ClassStyle = ClassStyle::SpacedPrefixed { prefix: "z-" };
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
pub enum HighlightSource {
|
pub enum HighlightSource {
|
||||||
Theme,
|
/// One of the built-in Zola syntaxes
|
||||||
|
BuiltIn,
|
||||||
|
/// Found in the extra syntaxes
|
||||||
Extra,
|
Extra,
|
||||||
|
/// No language specified
|
||||||
Plain,
|
Plain,
|
||||||
|
/// We didn't find the language in built-in and extra syntaxes
|
||||||
NotFound,
|
NotFound,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns the highlighter and whether it was found in the extra or not
|
pub struct SyntaxAndTheme<'config> {
|
||||||
pub fn get_highlighter(
|
pub syntax: &'config SyntaxReference,
|
||||||
language: Option<&str>,
|
pub syntax_set: &'config SyntaxSet,
|
||||||
config: &Config,
|
/// None if highlighting via CSS
|
||||||
) -> (HighlightLines<'static>, HighlightSource) {
|
pub theme: Option<&'config Theme>,
|
||||||
let theme = &THEME_SET.themes[&config.markdown.highlight_theme];
|
pub source: HighlightSource,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn resolve_syntax_and_theme<'config>(
|
||||||
|
language: Option<&'_ str>,
|
||||||
|
config: &'config Config,
|
||||||
|
) -> SyntaxAndTheme<'config> {
|
||||||
|
let theme = if config.markdown.highlight_theme != "css" {
|
||||||
|
Some(&THEME_SET.themes[&config.markdown.highlight_theme])
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
if let Some(ref lang) = language {
|
if let Some(ref lang) = language {
|
||||||
if let Some(ref extra_syntaxes) = config.markdown.extra_syntax_set {
|
if let Some(ref extra_syntaxes) = config.markdown.extra_syntax_set {
|
||||||
if let Some(syntax) = extra_syntaxes.find_syntax_by_token(lang) {
|
if let Some(syntax) = extra_syntaxes.find_syntax_by_token(lang) {
|
||||||
return (HighlightLines::new(syntax, theme), HighlightSource::Extra);
|
return SyntaxAndTheme {
|
||||||
|
syntax,
|
||||||
|
syntax_set: extra_syntaxes,
|
||||||
|
theme,
|
||||||
|
source: HighlightSource::Extra,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// The JS syntax hangs a lot... the TS syntax is probably better anyway.
|
// The JS syntax hangs a lot... the TS syntax is probably better anyway.
|
||||||
|
@ -42,14 +65,31 @@ pub fn get_highlighter(
|
||||||
// https://github.com/getzola/zola/issues/1174
|
// https://github.com/getzola/zola/issues/1174
|
||||||
let hacked_lang = if *lang == "js" || *lang == "javascript" { "ts" } else { lang };
|
let hacked_lang = if *lang == "js" || *lang == "javascript" { "ts" } else { lang };
|
||||||
if let Some(syntax) = SYNTAX_SET.find_syntax_by_token(hacked_lang) {
|
if let Some(syntax) = SYNTAX_SET.find_syntax_by_token(hacked_lang) {
|
||||||
(HighlightLines::new(syntax, theme), HighlightSource::Theme)
|
SyntaxAndTheme {
|
||||||
} else {
|
syntax,
|
||||||
(
|
syntax_set: &SYNTAX_SET as &SyntaxSet,
|
||||||
HighlightLines::new(SYNTAX_SET.find_syntax_plain_text(), theme),
|
theme,
|
||||||
HighlightSource::NotFound,
|
source: HighlightSource::BuiltIn,
|
||||||
)
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
(HighlightLines::new(SYNTAX_SET.find_syntax_plain_text(), theme), HighlightSource::Plain)
|
SyntaxAndTheme {
|
||||||
|
syntax: SYNTAX_SET.find_syntax_plain_text(),
|
||||||
|
syntax_set: &SYNTAX_SET as &SyntaxSet,
|
||||||
|
theme,
|
||||||
|
source: HighlightSource::NotFound,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
SyntaxAndTheme {
|
||||||
|
syntax: SYNTAX_SET.find_syntax_plain_text(),
|
||||||
|
syntax_set: &SYNTAX_SET as &SyntaxSet,
|
||||||
|
theme,
|
||||||
|
source: HighlightSource::Plain,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn export_theme_css(theme_name: &str) -> String {
|
||||||
|
let theme = &THEME_SET.themes[theme_name];
|
||||||
|
css_for_theme_with_class_style(theme, CLASS_STYLE)
|
||||||
|
}
|
||||||
|
|
|
@ -8,4 +8,5 @@ edition = "2018"
|
||||||
tera = "1"
|
tera = "1"
|
||||||
toml = "0.5"
|
toml = "0.5"
|
||||||
image = "0.23"
|
image = "0.23"
|
||||||
syntect = "4.4"
|
# TODO: go back to version 4/5 once https://github.com/trishume/syntect/pull/337 is merged
|
||||||
|
syntect = { git = "https://github.com/Keats/syntect.git", branch = "scopestack" }
|
||||||
|
|
|
@ -7,7 +7,8 @@ include = ["src/**/*"]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
tera = { version = "1", features = ["preserve_order"] }
|
tera = { version = "1", features = ["preserve_order"] }
|
||||||
syntect = "4.1"
|
# TODO: go back to version 4/5 once https://github.com/trishume/syntect/pull/337 is merged
|
||||||
|
syntect = { git = "https://github.com/Keats/syntect.git", branch = "scopestack" }
|
||||||
pulldown-cmark = { version = "0.8", default-features = false }
|
pulldown-cmark = { version = "0.8", default-features = false }
|
||||||
serde = "1"
|
serde = "1"
|
||||||
serde_derive = "1"
|
serde_derive = "1"
|
||||||
|
|
|
@ -1,11 +1,6 @@
|
||||||
#[derive(Copy, Clone, Debug)]
|
use std::ops::RangeInclusive;
|
||||||
pub struct Range {
|
|
||||||
pub from: usize,
|
|
||||||
pub to: usize,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Range {
|
fn parse_range(s: &str) -> Option<RangeInclusive<usize>> {
|
||||||
fn parse(s: &str) -> Option<Range> {
|
|
||||||
match s.find('-') {
|
match s.find('-') {
|
||||||
Some(dash) => {
|
Some(dash) => {
|
||||||
let mut from = s[..dash].parse().ok()?;
|
let mut from = s[..dash].parse().ok()?;
|
||||||
|
@ -13,12 +8,11 @@ impl Range {
|
||||||
if to < from {
|
if to < from {
|
||||||
std::mem::swap(&mut from, &mut to);
|
std::mem::swap(&mut from, &mut to);
|
||||||
}
|
}
|
||||||
Some(Range { from, to })
|
Some(from..=to)
|
||||||
}
|
}
|
||||||
None => {
|
None => {
|
||||||
let val = s.parse().ok()?;
|
let val = s.parse().ok()?;
|
||||||
Some(Range { from: val, to: val })
|
Some(val..=val)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -27,14 +21,17 @@ impl Range {
|
||||||
pub struct FenceSettings<'a> {
|
pub struct FenceSettings<'a> {
|
||||||
pub language: Option<&'a str>,
|
pub language: Option<&'a str>,
|
||||||
pub line_numbers: bool,
|
pub line_numbers: bool,
|
||||||
pub highlight_lines: Vec<Range>,
|
pub line_number_start: usize,
|
||||||
pub hide_lines: Vec<Range>,
|
pub highlight_lines: Vec<RangeInclusive<usize>>,
|
||||||
|
pub hide_lines: Vec<RangeInclusive<usize>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<'a> FenceSettings<'a> {
|
impl<'a> FenceSettings<'a> {
|
||||||
pub fn new(fence_info: &'a str) -> Self {
|
pub fn new(fence_info: &'a str) -> Self {
|
||||||
let mut me = Self {
|
let mut me = Self {
|
||||||
language: None,
|
language: None,
|
||||||
line_numbers: false,
|
line_numbers: false,
|
||||||
|
line_number_start: 1,
|
||||||
highlight_lines: Vec::new(),
|
highlight_lines: Vec::new(),
|
||||||
hide_lines: Vec::new(),
|
hide_lines: Vec::new(),
|
||||||
};
|
};
|
||||||
|
@ -43,6 +40,7 @@ impl<'a> FenceSettings<'a> {
|
||||||
match token {
|
match token {
|
||||||
FenceToken::Language(lang) => me.language = Some(lang),
|
FenceToken::Language(lang) => me.language = Some(lang),
|
||||||
FenceToken::EnableLineNumbers => me.line_numbers = true,
|
FenceToken::EnableLineNumbers => me.line_numbers = true,
|
||||||
|
FenceToken::InitialLineNumber(l) => me.line_number_start = l,
|
||||||
FenceToken::HighlightLines(lines) => me.highlight_lines.extend(lines),
|
FenceToken::HighlightLines(lines) => me.highlight_lines.extend(lines),
|
||||||
FenceToken::HideLines(lines) => me.hide_lines.extend(lines),
|
FenceToken::HideLines(lines) => me.hide_lines.extend(lines),
|
||||||
}
|
}
|
||||||
|
@ -56,22 +54,24 @@ impl<'a> FenceSettings<'a> {
|
||||||
enum FenceToken<'a> {
|
enum FenceToken<'a> {
|
||||||
Language(&'a str),
|
Language(&'a str),
|
||||||
EnableLineNumbers,
|
EnableLineNumbers,
|
||||||
HighlightLines(Vec<Range>),
|
InitialLineNumber(usize),
|
||||||
HideLines(Vec<Range>),
|
HighlightLines(Vec<RangeInclusive<usize>>),
|
||||||
|
HideLines(Vec<RangeInclusive<usize>>),
|
||||||
}
|
}
|
||||||
|
|
||||||
struct FenceIter<'a> {
|
struct FenceIter<'a> {
|
||||||
split: std::str::Split<'a, char>,
|
split: std::str::Split<'a, char>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<'a> FenceIter<'a> {
|
impl<'a> FenceIter<'a> {
|
||||||
fn new(fence_info: &'a str) -> Self {
|
fn new(fence_info: &'a str) -> Self {
|
||||||
Self { split: fence_info.split(',') }
|
Self { split: fence_info.split(',') }
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_ranges(token: Option<&str>) -> Vec<Range> {
|
fn parse_ranges(token: Option<&str>) -> Vec<RangeInclusive<usize>> {
|
||||||
let mut ranges = Vec::new();
|
let mut ranges = Vec::new();
|
||||||
for range in token.unwrap_or("").split(' ') {
|
for range in token.unwrap_or("").split(' ') {
|
||||||
if let Some(range) = Range::parse(range) {
|
if let Some(range) = parse_range(range) {
|
||||||
ranges.push(range);
|
ranges.push(range);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -89,6 +89,11 @@ impl<'a> Iterator for FenceIter<'a> {
|
||||||
let mut tok_split = tok.split('=');
|
let mut tok_split = tok.split('=');
|
||||||
match tok_split.next().unwrap_or("").trim() {
|
match tok_split.next().unwrap_or("").trim() {
|
||||||
"" => continue,
|
"" => continue,
|
||||||
|
"linenostart" => {
|
||||||
|
if let Some(l) = tok_split.next().and_then(|s| s.parse().ok()) {
|
||||||
|
return Some(FenceToken::InitialLineNumber(l));
|
||||||
|
}
|
||||||
|
}
|
||||||
"linenos" => return Some(FenceToken::EnableLineNumbers),
|
"linenos" => return Some(FenceToken::EnableLineNumbers),
|
||||||
"hl_lines" => {
|
"hl_lines" => {
|
||||||
let ranges = Self::parse_ranges(tok_split.next());
|
let ranges = Self::parse_ranges(tok_split.next());
|
226
components/rendering/src/codeblock/highlight.rs
Normal file
226
components/rendering/src/codeblock/highlight.rs
Normal file
|
@ -0,0 +1,226 @@
|
||||||
|
use std::fmt::Write;
|
||||||
|
|
||||||
|
use config::highlighting::{SyntaxAndTheme, CLASS_STYLE};
|
||||||
|
use syntect::easy::HighlightLines;
|
||||||
|
use syntect::highlighting::{Color, Theme};
|
||||||
|
use syntect::html::{
|
||||||
|
styled_line_to_highlighted_html, tokens_to_classed_spans, ClassStyle, IncludeBackground,
|
||||||
|
};
|
||||||
|
use syntect::parsing::{ParseState, ScopeStack, SyntaxReference, SyntaxSet};
|
||||||
|
|
||||||
|
/// Not public, but from syntect::html
|
||||||
|
fn write_css_color(s: &mut String, c: Color) {
|
||||||
|
if c.a != 0xFF {
|
||||||
|
write!(s, "#{:02x}{:02x}{:02x}{:02x}", c.r, c.g, c.b, c.a).unwrap();
|
||||||
|
} else {
|
||||||
|
write!(s, "#{:02x}{:02x}{:02x}", c.r, c.g, c.b).unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) struct ClassHighlighter<'config> {
|
||||||
|
syntax_set: &'config SyntaxSet,
|
||||||
|
open_spans: isize,
|
||||||
|
parse_state: ParseState,
|
||||||
|
scope_stack: ScopeStack,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'config> ClassHighlighter<'config> {
|
||||||
|
pub fn new(syntax: &'config SyntaxReference, syntax_set: &'config SyntaxSet) -> Self {
|
||||||
|
let parse_state = ParseState::new(syntax);
|
||||||
|
Self { syntax_set, open_spans: 0, parse_state, scope_stack: ScopeStack::new() }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse the line of code and update the internal HTML buffer with tagged HTML
|
||||||
|
///
|
||||||
|
/// *Note:* This function requires `line` to include a newline at the end and
|
||||||
|
/// also use of the `load_defaults_newlines` version of the syntaxes.
|
||||||
|
pub fn highlight_line(&mut self, line: &str) -> String {
|
||||||
|
debug_assert!(line.ends_with("\n"));
|
||||||
|
let parsed_line = self.parse_state.parse_line(line, &self.syntax_set);
|
||||||
|
let (formatted_line, delta) = tokens_to_classed_spans(
|
||||||
|
line,
|
||||||
|
parsed_line.as_slice(),
|
||||||
|
CLASS_STYLE,
|
||||||
|
&mut self.scope_stack,
|
||||||
|
);
|
||||||
|
self.open_spans += delta;
|
||||||
|
formatted_line
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Close all open `<span>` tags and return the finished HTML string
|
||||||
|
pub fn finalize(&mut self) -> String {
|
||||||
|
let mut html = String::with_capacity((self.open_spans * 7) as usize);
|
||||||
|
for _ in 0..self.open_spans {
|
||||||
|
html.push_str("</span>");
|
||||||
|
}
|
||||||
|
html
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) struct InlineHighlighter<'config> {
|
||||||
|
theme: &'config Theme,
|
||||||
|
fg_color: String,
|
||||||
|
bg_color: Color,
|
||||||
|
syntax_set: &'config SyntaxSet,
|
||||||
|
h: HighlightLines<'config>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'config> InlineHighlighter<'config> {
|
||||||
|
pub fn new(
|
||||||
|
syntax: &'config SyntaxReference,
|
||||||
|
syntax_set: &'config SyntaxSet,
|
||||||
|
theme: &'config Theme,
|
||||||
|
) -> Self {
|
||||||
|
let h = HighlightLines::new(syntax, theme);
|
||||||
|
let mut color = String::new();
|
||||||
|
write_css_color(&mut color, theme.settings.foreground.unwrap_or(Color::BLACK));
|
||||||
|
let fg_color = format!(r#" style="color:{};""#, color);
|
||||||
|
let bg_color = theme.settings.background.unwrap_or(Color::WHITE);
|
||||||
|
Self { theme, fg_color, bg_color, syntax_set, h }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn highlight_line(&mut self, line: &str) -> String {
|
||||||
|
let regions = self.h.highlight(line, &self.syntax_set);
|
||||||
|
// TODO: add a param like `IncludeBackground` for `IncludeForeground` in syntect
|
||||||
|
let highlighted = styled_line_to_highlighted_html(®ions, IncludeBackground::IfDifferent(self.bg_color));
|
||||||
|
highlighted.replace(&self.fg_color, "")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) enum SyntaxHighlighter<'config> {
|
||||||
|
Inlined(InlineHighlighter<'config>),
|
||||||
|
Classed(ClassHighlighter<'config>),
|
||||||
|
/// We might not want highlighting but we want line numbers or to hide some lines
|
||||||
|
NoHighlight,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'config> SyntaxHighlighter<'config> {
|
||||||
|
pub fn new(highlight_code: bool, s: SyntaxAndTheme<'config>) -> Self {
|
||||||
|
if highlight_code {
|
||||||
|
if let Some(theme) = s.theme {
|
||||||
|
SyntaxHighlighter::Inlined(InlineHighlighter::new(s.syntax, s.syntax_set, theme))
|
||||||
|
} else {
|
||||||
|
SyntaxHighlighter::Classed(ClassHighlighter::new(s.syntax, s.syntax_set))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
SyntaxHighlighter::NoHighlight
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn highlight_line(&mut self, line: &str) -> String {
|
||||||
|
use SyntaxHighlighter::*;
|
||||||
|
|
||||||
|
match self {
|
||||||
|
Inlined(h) => h.highlight_line(line),
|
||||||
|
Classed(h) => h.highlight_line(line),
|
||||||
|
NoHighlight => line.to_owned(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn finalize(&mut self) -> Option<String> {
|
||||||
|
use SyntaxHighlighter::*;
|
||||||
|
|
||||||
|
match self {
|
||||||
|
Inlined(_) | NoHighlight => None,
|
||||||
|
Classed(h) => Some(h.finalize()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Inlined needs to set the background/foreground colour on <pre>
|
||||||
|
pub fn pre_style(&self) -> Option<String> {
|
||||||
|
use SyntaxHighlighter::*;
|
||||||
|
|
||||||
|
match self {
|
||||||
|
Classed(_) | NoHighlight => None,
|
||||||
|
Inlined(h) => {
|
||||||
|
let mut styles = String::from("background-color:");
|
||||||
|
write_css_color(&mut styles, h.theme.settings.background.unwrap_or(Color::WHITE));
|
||||||
|
styles.push_str(";color:");
|
||||||
|
write_css_color(&mut styles, h.theme.settings.foreground.unwrap_or(Color::BLACK));
|
||||||
|
styles.push(';');
|
||||||
|
Some(styles)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Classed needs to set a class on the pre
|
||||||
|
pub fn pre_class(&self) -> Option<String> {
|
||||||
|
use SyntaxHighlighter::*;
|
||||||
|
|
||||||
|
match self {
|
||||||
|
Classed(_) => {
|
||||||
|
if let ClassStyle::SpacedPrefixed { prefix } = CLASS_STYLE {
|
||||||
|
Some(format!("{}code", prefix))
|
||||||
|
} else {
|
||||||
|
unreachable!()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Inlined(_) | NoHighlight => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Inlined needs to set the background/foreground colour
|
||||||
|
pub fn mark_style(&self) -> Option<String> {
|
||||||
|
use SyntaxHighlighter::*;
|
||||||
|
|
||||||
|
match self {
|
||||||
|
Classed(_) | NoHighlight => None,
|
||||||
|
Inlined(h) => {
|
||||||
|
let mut styles = String::from("background-color:");
|
||||||
|
write_css_color(
|
||||||
|
&mut styles,
|
||||||
|
h.theme.settings.line_highlight.unwrap_or(Color { r: 255, g: 255, b: 0, a: 0 }),
|
||||||
|
);
|
||||||
|
styles.push_str(";");
|
||||||
|
Some(styles)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use config::highlighting::resolve_syntax_and_theme;
|
||||||
|
use config::Config;
|
||||||
|
use syntect::util::LinesWithEndings;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn can_highlight_with_classes() {
|
||||||
|
let mut config = Config::default();
|
||||||
|
config.markdown.highlight_code = true;
|
||||||
|
let code = "import zen\nz = x + y\nprint('hello')\n";
|
||||||
|
let syntax_and_theme = resolve_syntax_and_theme(Some("py"), &config);
|
||||||
|
let mut highlighter =
|
||||||
|
ClassHighlighter::new(syntax_and_theme.syntax, syntax_and_theme.syntax_set);
|
||||||
|
let mut out = String::new();
|
||||||
|
for line in LinesWithEndings::from(&code) {
|
||||||
|
out.push_str(&highlighter.highlight_line(line));
|
||||||
|
}
|
||||||
|
out.push_str(&highlighter.finalize());
|
||||||
|
|
||||||
|
assert!(out.starts_with("<span class"));
|
||||||
|
assert!(out.ends_with("</span>"));
|
||||||
|
assert!(out.contains("z-"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn can_highlight_inline() {
|
||||||
|
let mut config = Config::default();
|
||||||
|
config.markdown.highlight_code = true;
|
||||||
|
let code = "import zen\nz = x + y\nprint('hello')\n";
|
||||||
|
let syntax_and_theme = resolve_syntax_and_theme(Some("py"), &config);
|
||||||
|
let mut highlighter = InlineHighlighter::new(
|
||||||
|
syntax_and_theme.syntax,
|
||||||
|
syntax_and_theme.syntax_set,
|
||||||
|
syntax_and_theme.theme.unwrap(),
|
||||||
|
);
|
||||||
|
let mut out = String::new();
|
||||||
|
for line in LinesWithEndings::from(&code) {
|
||||||
|
out.push_str(&highlighter.highlight_line(line));
|
||||||
|
}
|
||||||
|
|
||||||
|
assert!(out.starts_with(r#"<span style="color"#));
|
||||||
|
assert!(out.ends_with("</span>"));
|
||||||
|
}
|
||||||
|
}
|
186
components/rendering/src/codeblock/mod.rs
Normal file
186
components/rendering/src/codeblock/mod.rs
Normal file
|
@ -0,0 +1,186 @@
|
||||||
|
mod fence;
|
||||||
|
mod highlight;
|
||||||
|
|
||||||
|
use std::ops::RangeInclusive;
|
||||||
|
|
||||||
|
use syntect::util::LinesWithEndings;
|
||||||
|
|
||||||
|
use crate::codeblock::highlight::SyntaxHighlighter;
|
||||||
|
use config::highlighting::{resolve_syntax_and_theme, HighlightSource};
|
||||||
|
use config::Config;
|
||||||
|
pub(crate) use fence::FenceSettings;
|
||||||
|
|
||||||
|
fn opening_html(
|
||||||
|
language: Option<&str>,
|
||||||
|
pre_style: Option<String>,
|
||||||
|
pre_class: Option<String>,
|
||||||
|
line_numbers: bool,
|
||||||
|
) -> String {
|
||||||
|
let mut html = String::from("<pre");
|
||||||
|
if line_numbers {
|
||||||
|
html.push_str(" data-linenos");
|
||||||
|
}
|
||||||
|
let mut classes = String::new();
|
||||||
|
|
||||||
|
if let Some(lang) = language {
|
||||||
|
classes.push_str("language-");
|
||||||
|
classes.push_str(&lang);
|
||||||
|
classes.push_str(" ");
|
||||||
|
|
||||||
|
html.push_str(" data-lang=\"");
|
||||||
|
html.push_str(lang);
|
||||||
|
html.push('"');
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(styles) = pre_style {
|
||||||
|
html.push_str(" style=\"");
|
||||||
|
html.push_str(styles.as_str());
|
||||||
|
html.push('"');
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(c) = pre_class {
|
||||||
|
classes.push_str(&c);
|
||||||
|
}
|
||||||
|
|
||||||
|
if !classes.is_empty() {
|
||||||
|
html.push_str(" class=\"");
|
||||||
|
html.push_str(&classes);
|
||||||
|
html.push('"');
|
||||||
|
}
|
||||||
|
|
||||||
|
html.push_str("><code");
|
||||||
|
if let Some(lang) = language {
|
||||||
|
html.push_str(" class=\"language-");
|
||||||
|
html.push_str(lang);
|
||||||
|
html.push_str("\" data-lang=\"");
|
||||||
|
html.push_str(lang);
|
||||||
|
html.push('"');
|
||||||
|
}
|
||||||
|
html.push('>');
|
||||||
|
html
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct CodeBlock<'config> {
|
||||||
|
highlighter: SyntaxHighlighter<'config>,
|
||||||
|
// fence options
|
||||||
|
line_numbers: bool,
|
||||||
|
line_number_start: usize,
|
||||||
|
highlight_lines: Vec<RangeInclusive<usize>>,
|
||||||
|
hide_lines: Vec<RangeInclusive<usize>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'config> CodeBlock<'config> {
|
||||||
|
pub fn new<'fence_info>(
|
||||||
|
fence: FenceSettings<'fence_info>,
|
||||||
|
config: &'config Config,
|
||||||
|
// path to the current file if there is one, to point where the error is
|
||||||
|
path: Option<&'config str>,
|
||||||
|
) -> (Self, String) {
|
||||||
|
let syntax_and_theme = resolve_syntax_and_theme(fence.language, config);
|
||||||
|
if syntax_and_theme.source == HighlightSource::NotFound {
|
||||||
|
let lang = fence.language.unwrap();
|
||||||
|
if let Some(p) = path {
|
||||||
|
eprintln!("Warning: Highlight language {} not found in {}", lang, p);
|
||||||
|
} else {
|
||||||
|
eprintln!("Warning: Highlight language {} not found", lang);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let highlighter = SyntaxHighlighter::new(config.markdown.highlight_code, syntax_and_theme);
|
||||||
|
|
||||||
|
let html_start = opening_html(
|
||||||
|
fence.language,
|
||||||
|
highlighter.pre_style(),
|
||||||
|
highlighter.pre_class(),
|
||||||
|
fence.line_numbers,
|
||||||
|
);
|
||||||
|
(
|
||||||
|
Self {
|
||||||
|
highlighter,
|
||||||
|
line_numbers: fence.line_numbers,
|
||||||
|
line_number_start: fence.line_number_start,
|
||||||
|
highlight_lines: fence.highlight_lines,
|
||||||
|
hide_lines: fence.hide_lines,
|
||||||
|
},
|
||||||
|
html_start,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn highlight(&mut self, content: &str) -> String {
|
||||||
|
let mut buffer = String::new();
|
||||||
|
let mark_style = self.highlighter.mark_style();
|
||||||
|
|
||||||
|
if self.line_numbers {
|
||||||
|
buffer.push_str("<table><tbody>");
|
||||||
|
}
|
||||||
|
|
||||||
|
// syntect leaking here in this file
|
||||||
|
for (i, line) in LinesWithEndings::from(&content).enumerate() {
|
||||||
|
let one_indexed = i + 1;
|
||||||
|
// first do we need to skip that line?
|
||||||
|
let mut skip = false;
|
||||||
|
for range in &self.hide_lines {
|
||||||
|
if range.contains(&one_indexed) {
|
||||||
|
skip = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if skip {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Next is it supposed to be higlighted?
|
||||||
|
let mut is_higlighted = false;
|
||||||
|
for range in &self.highlight_lines {
|
||||||
|
if range.contains(&one_indexed) {
|
||||||
|
is_higlighted = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.line_numbers {
|
||||||
|
buffer.push_str("<tr><td>");
|
||||||
|
let num = format!("{}", self.line_number_start + i);
|
||||||
|
if is_higlighted {
|
||||||
|
buffer.push_str("<mark");
|
||||||
|
if let Some(ref s) = mark_style {
|
||||||
|
buffer.push_str(" style=\"");
|
||||||
|
buffer.push_str(&s);
|
||||||
|
buffer.push_str("\">");
|
||||||
|
} else {
|
||||||
|
buffer.push_str(">")
|
||||||
|
}
|
||||||
|
buffer.push_str(&num);
|
||||||
|
buffer.push_str("</mark>");
|
||||||
|
} else {
|
||||||
|
buffer.push_str(&num);
|
||||||
|
}
|
||||||
|
buffer.push_str("</td><td>");
|
||||||
|
}
|
||||||
|
|
||||||
|
let highlighted_line = self.highlighter.highlight_line(line);
|
||||||
|
if is_higlighted {
|
||||||
|
buffer.push_str("<mark");
|
||||||
|
if let Some(ref s) = mark_style {
|
||||||
|
buffer.push_str(" style=\"");
|
||||||
|
buffer.push_str(&s);
|
||||||
|
buffer.push_str("\">");
|
||||||
|
} else {
|
||||||
|
buffer.push_str(">")
|
||||||
|
}
|
||||||
|
buffer.push_str(&highlighted_line);
|
||||||
|
buffer.push_str("</mark>");
|
||||||
|
} else {
|
||||||
|
buffer.push_str(&highlighted_line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(rest) = self.highlighter.finalize() {
|
||||||
|
buffer.push_str(&rest);
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.line_numbers {
|
||||||
|
buffer.push_str("</tr></tbody></table>");
|
||||||
|
}
|
||||||
|
|
||||||
|
buffer
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,3 +1,4 @@
|
||||||
|
mod codeblock;
|
||||||
mod context;
|
mod context;
|
||||||
mod markdown;
|
mod markdown;
|
||||||
mod shortcode;
|
mod shortcode;
|
||||||
|
|
|
@ -1,11 +1,9 @@
|
||||||
use lazy_static::lazy_static;
|
use lazy_static::lazy_static;
|
||||||
use pulldown_cmark as cmark;
|
use pulldown_cmark as cmark;
|
||||||
use regex::Regex;
|
use regex::Regex;
|
||||||
use syntect::html::{start_highlighted_html_snippet, IncludeBackground};
|
|
||||||
|
|
||||||
use crate::context::RenderContext;
|
use crate::context::RenderContext;
|
||||||
use crate::table_of_contents::{make_table_of_contents, Heading};
|
use crate::table_of_contents::{make_table_of_contents, Heading};
|
||||||
use config::highlighting::THEME_SET;
|
|
||||||
use errors::{Error, Result};
|
use errors::{Error, Result};
|
||||||
use front_matter::InsertAnchor;
|
use front_matter::InsertAnchor;
|
||||||
use utils::site::resolve_internal_link;
|
use utils::site::resolve_internal_link;
|
||||||
|
@ -13,10 +11,7 @@ use utils::slugs::slugify_anchors;
|
||||||
use utils::vec::InsertMany;
|
use utils::vec::InsertMany;
|
||||||
|
|
||||||
use self::cmark::{Event, LinkType, Options, Parser, Tag};
|
use self::cmark::{Event, LinkType, Options, Parser, Tag};
|
||||||
|
use crate::codeblock::{CodeBlock, FenceSettings};
|
||||||
mod codeblock;
|
|
||||||
mod fence;
|
|
||||||
use self::codeblock::CodeBlock;
|
|
||||||
|
|
||||||
const CONTINUE_READING: &str = "<span id=\"continue-reading\"></span>";
|
const CONTINUE_READING: &str = "<span id=\"continue-reading\"></span>";
|
||||||
const ANCHOR_LINK_TEMPLATE: &str = "anchor-link.html";
|
const ANCHOR_LINK_TEMPLATE: &str = "anchor-link.html";
|
||||||
|
@ -32,7 +27,7 @@ pub struct Rendered {
|
||||||
pub external_links: Vec<String>,
|
pub external_links: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
// tracks a heading in a slice of pulldown-cmark events
|
/// Tracks a heading in a slice of pulldown-cmark events
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
struct HeadingRef {
|
struct HeadingRef {
|
||||||
start_idx: usize,
|
start_idx: usize,
|
||||||
|
@ -64,13 +59,13 @@ fn find_anchor(anchors: &[String], name: String, level: u16) -> String {
|
||||||
find_anchor(anchors, name, level + 1)
|
find_anchor(anchors, name, level + 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Returns whether the given string starts with a schema.
|
/// Returns whether the given string starts with a schema.
|
||||||
//
|
///
|
||||||
// Although there exists [a list of registered URI schemes][uri-schemes], a link may use arbitrary,
|
/// Although there exists [a list of registered URI schemes][uri-schemes], a link may use arbitrary,
|
||||||
// private schemes. This function checks if the given string starts with something that just looks
|
/// private schemes. This function checks if the given string starts with something that just looks
|
||||||
// like a scheme, i.e., a case-insensitive identifier followed by a colon.
|
/// like a scheme, i.e., a case-insensitive identifier followed by a colon.
|
||||||
//
|
///
|
||||||
// [uri-schemes]: https://www.iana.org/assignments/uri-schemes/uri-schemes.xhtml
|
/// [uri-schemes]: https://www.iana.org/assignments/uri-schemes/uri-schemes.xhtml
|
||||||
fn starts_with_schema(s: &str) -> bool {
|
fn starts_with_schema(s: &str) -> bool {
|
||||||
lazy_static! {
|
lazy_static! {
|
||||||
static ref PATTERN: Regex = Regex::new(r"^[0-9A-Za-z\-]+:").unwrap();
|
static ref PATTERN: Regex = Regex::new(r"^[0-9A-Za-z\-]+:").unwrap();
|
||||||
|
@ -79,14 +74,14 @@ fn starts_with_schema(s: &str) -> bool {
|
||||||
PATTERN.is_match(s)
|
PATTERN.is_match(s)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Colocated asset links refers to the files in the same directory,
|
/// Colocated asset links refers to the files in the same directory,
|
||||||
// there it should be a filename only
|
/// there it should be a filename only
|
||||||
fn is_colocated_asset_link(link: &str) -> bool {
|
fn is_colocated_asset_link(link: &str) -> bool {
|
||||||
!link.contains('/') // http://, ftp://, ../ etc
|
!link.contains('/') // http://, ftp://, ../ etc
|
||||||
&& !starts_with_schema(link)
|
&& !starts_with_schema(link)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Returns whether a link starts with an HTTP(s) scheme.
|
/// Returns whether a link starts with an HTTP(s) scheme.
|
||||||
fn is_external_link(link: &str) -> bool {
|
fn is_external_link(link: &str) -> bool {
|
||||||
link.starts_with("http:") || link.starts_with("https:")
|
link.starts_with("http:") || link.starts_with("https:")
|
||||||
}
|
}
|
||||||
|
@ -165,12 +160,17 @@ pub fn markdown_to_html(content: &str, context: &RenderContext) -> Result<Render
|
||||||
static ref EMOJI_REPLACER: gh_emoji::Replacer = gh_emoji::Replacer::new();
|
static ref EMOJI_REPLACER: gh_emoji::Replacer = gh_emoji::Replacer::new();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let path = context
|
||||||
|
.tera_context
|
||||||
|
.get("page")
|
||||||
|
.or(context.tera_context.get("section"))
|
||||||
|
.map(|x| x.as_object().unwrap().get("relative_path").unwrap().as_str().unwrap());
|
||||||
// the rendered html
|
// the rendered html
|
||||||
let mut html = String::with_capacity(content.len());
|
let mut html = String::with_capacity(content.len());
|
||||||
// Set while parsing
|
// Set while parsing
|
||||||
let mut error = None;
|
let mut error = None;
|
||||||
|
|
||||||
let mut highlighter: Option<CodeBlock> = None;
|
let mut code_block: Option<CodeBlock> = None;
|
||||||
|
|
||||||
let mut inserted_anchors: Vec<String> = vec![];
|
let mut inserted_anchors: Vec<String> = vec![];
|
||||||
let mut headings: Vec<Heading> = vec![];
|
let mut headings: Vec<Heading> = vec![];
|
||||||
|
@ -195,7 +195,7 @@ pub fn markdown_to_html(content: &str, context: &RenderContext) -> Result<Render
|
||||||
match event {
|
match event {
|
||||||
Event::Text(text) => {
|
Event::Text(text) => {
|
||||||
// if we are in the middle of a highlighted code block
|
// if we are in the middle of a highlighted code block
|
||||||
if let Some(ref mut code_block) = highlighter {
|
if let Some(ref mut code_block) = code_block {
|
||||||
let html = code_block.highlight(&text);
|
let html = code_block.highlight(&text);
|
||||||
Event::Html(html.into())
|
Event::Html(html.into())
|
||||||
} else if context.config.markdown.render_emoji {
|
} else if context.config.markdown.render_emoji {
|
||||||
|
@ -207,74 +207,20 @@ pub fn markdown_to_html(content: &str, context: &RenderContext) -> Result<Render
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Event::Start(Tag::CodeBlock(ref kind)) => {
|
Event::Start(Tag::CodeBlock(ref kind)) => {
|
||||||
let language = match kind {
|
let fence = match kind {
|
||||||
cmark::CodeBlockKind::Fenced(fence_info) => {
|
cmark::CodeBlockKind::Fenced(fence_info) => {
|
||||||
let fence_info = fence::FenceSettings::new(fence_info);
|
FenceSettings::new(fence_info)
|
||||||
fence_info.language
|
|
||||||
}
|
}
|
||||||
_ => None,
|
_ => FenceSettings::new(""),
|
||||||
};
|
};
|
||||||
|
let (block, begin) = CodeBlock::new(fence, &context.config, path);
|
||||||
if !context.config.markdown.highlight_code {
|
code_block = Some(block);
|
||||||
if let Some(lang) = language {
|
Event::Html(begin.into())
|
||||||
let html = format!(
|
|
||||||
r#"<pre><code class="language-{}" data-lang="{}">"#,
|
|
||||||
lang, lang
|
|
||||||
);
|
|
||||||
return Event::Html(html.into());
|
|
||||||
}
|
|
||||||
return Event::Html("<pre><code>".into());
|
|
||||||
}
|
|
||||||
|
|
||||||
let theme = &THEME_SET.themes[&context.config.markdown.highlight_theme];
|
|
||||||
match kind {
|
|
||||||
cmark::CodeBlockKind::Indented => (),
|
|
||||||
cmark::CodeBlockKind::Fenced(fence_info) => {
|
|
||||||
// This selects the background color the same way that
|
|
||||||
// start_coloured_html_snippet does
|
|
||||||
let color = theme
|
|
||||||
.settings
|
|
||||||
.background
|
|
||||||
.unwrap_or(::syntect::highlighting::Color::WHITE);
|
|
||||||
|
|
||||||
highlighter = Some(CodeBlock::new(
|
|
||||||
fence_info,
|
|
||||||
&context.config,
|
|
||||||
IncludeBackground::IfDifferent(color),
|
|
||||||
context
|
|
||||||
.tera_context
|
|
||||||
.get("page")
|
|
||||||
.or(context.tera_context.get("section"))
|
|
||||||
.map(|x| {
|
|
||||||
x.as_object()
|
|
||||||
.unwrap()
|
|
||||||
.get("relative_path")
|
|
||||||
.unwrap()
|
|
||||||
.as_str()
|
|
||||||
.unwrap()
|
|
||||||
}),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let snippet = start_highlighted_html_snippet(theme);
|
|
||||||
let mut html = snippet.0;
|
|
||||||
if let Some(lang) = language {
|
|
||||||
html.push_str(&format!(
|
|
||||||
r#"<code class="language-{}" data-lang="{}">"#,
|
|
||||||
lang, lang
|
|
||||||
));
|
|
||||||
} else {
|
|
||||||
html.push_str("<code>");
|
|
||||||
}
|
|
||||||
Event::Html(html.into())
|
|
||||||
}
|
}
|
||||||
Event::End(Tag::CodeBlock(_)) => {
|
Event::End(Tag::CodeBlock(_)) => {
|
||||||
if !context.config.markdown.highlight_code {
|
|
||||||
return Event::Html("</code></pre>\n".into());
|
|
||||||
}
|
|
||||||
// reset highlight and close the code block
|
// reset highlight and close the code block
|
||||||
highlighter = None;
|
code_block = None;
|
||||||
Event::Html("</code></pre>".into())
|
Event::Html("</code></pre>\n".into())
|
||||||
}
|
}
|
||||||
Event::Start(Tag::Image(link_type, src, title)) => {
|
Event::Start(Tag::Image(link_type, src, title)) => {
|
||||||
if is_colocated_asset_link(&src) {
|
if is_colocated_asset_link(&src) {
|
||||||
|
|
|
@ -1,238 +0,0 @@
|
||||||
use config::highlighting::{get_highlighter, HighlightSource, SYNTAX_SET, THEME_SET};
|
|
||||||
use config::Config;
|
|
||||||
use std::cmp::min;
|
|
||||||
use std::collections::HashSet;
|
|
||||||
use syntect::easy::HighlightLines;
|
|
||||||
use syntect::highlighting::{Color, Style, Theme};
|
|
||||||
use syntect::html::{styled_line_to_highlighted_html, IncludeBackground};
|
|
||||||
use syntect::parsing::SyntaxSet;
|
|
||||||
|
|
||||||
use super::fence::{FenceSettings, Range};
|
|
||||||
|
|
||||||
pub struct CodeBlock<'config> {
|
|
||||||
highlighter: HighlightLines<'static>,
|
|
||||||
extra_syntax_set: Option<&'config SyntaxSet>,
|
|
||||||
background: IncludeBackground,
|
|
||||||
theme: &'static Theme,
|
|
||||||
|
|
||||||
/// List of ranges of lines to highlight.
|
|
||||||
highlight_lines: Vec<Range>,
|
|
||||||
/// List of ranges of lines to hide.
|
|
||||||
hide_lines: Vec<Range>,
|
|
||||||
/// The number of lines in the code block being processed.
|
|
||||||
num_lines: usize,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<'config> CodeBlock<'config> {
|
|
||||||
pub fn new(
|
|
||||||
fence_info: &str,
|
|
||||||
config: &'config Config,
|
|
||||||
background: IncludeBackground,
|
|
||||||
path: Option<&'config str>,
|
|
||||||
) -> Self {
|
|
||||||
let fence_info = FenceSettings::new(fence_info);
|
|
||||||
let theme = &THEME_SET.themes[&config.markdown.highlight_theme];
|
|
||||||
let (highlighter, highlight_source) = get_highlighter(fence_info.language, config);
|
|
||||||
let extra_syntax_set = match highlight_source {
|
|
||||||
HighlightSource::Extra => config.markdown.extra_syntax_set.as_ref(),
|
|
||||||
HighlightSource::NotFound => {
|
|
||||||
// Language was not found, so it exists (safe unwrap)
|
|
||||||
let lang = fence_info.language.unwrap();
|
|
||||||
if let Some(path) = path {
|
|
||||||
eprintln!("Warning: Highlight language {} not found in {}", lang, path);
|
|
||||||
} else {
|
|
||||||
eprintln!("Warning: Highlight language {} not found", lang);
|
|
||||||
}
|
|
||||||
None
|
|
||||||
}
|
|
||||||
_ => None,
|
|
||||||
};
|
|
||||||
Self {
|
|
||||||
highlighter,
|
|
||||||
extra_syntax_set,
|
|
||||||
background,
|
|
||||||
theme,
|
|
||||||
|
|
||||||
highlight_lines: fence_info.highlight_lines,
|
|
||||||
hide_lines: fence_info.hide_lines,
|
|
||||||
num_lines: 0,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn highlight(&mut self, text: &str) -> String {
|
|
||||||
let highlighted =
|
|
||||||
self.highlighter.highlight(text, self.extra_syntax_set.unwrap_or(&SYNTAX_SET));
|
|
||||||
let line_boundaries = self.find_line_boundaries(&highlighted);
|
|
||||||
|
|
||||||
// First we make sure that `highlighted` is split at every line
|
|
||||||
// boundary. The `styled_line_to_highlighted_html` function will
|
|
||||||
// merge split items with identical styles, so this is not a
|
|
||||||
// problem.
|
|
||||||
//
|
|
||||||
// Note that this invalidates the values in `line_boundaries`.
|
|
||||||
// The `perform_split` function takes it by value to ensure that
|
|
||||||
// we don't use it later.
|
|
||||||
let mut highlighted = perform_split(&highlighted, line_boundaries);
|
|
||||||
|
|
||||||
let hl_background =
|
|
||||||
self.theme.settings.line_highlight.unwrap_or(Color { r: 255, g: 255, b: 0, a: 0 });
|
|
||||||
|
|
||||||
let hl_lines = self.get_highlighted_lines();
|
|
||||||
color_highlighted_lines(&mut highlighted, &hl_lines, hl_background);
|
|
||||||
|
|
||||||
let hide_lines = self.get_hidden_lines();
|
|
||||||
let highlighted = hide_hidden_lines(highlighted, &hide_lines);
|
|
||||||
|
|
||||||
styled_line_to_highlighted_html(&highlighted, self.background)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn find_line_boundaries(&mut self, styled: &[(Style, &str)]) -> Vec<StyledIdx> {
|
|
||||||
let mut boundaries = Vec::new();
|
|
||||||
for (vec_idx, (_style, s)) in styled.iter().enumerate() {
|
|
||||||
for (str_idx, character) in s.char_indices() {
|
|
||||||
if character == '\n' {
|
|
||||||
boundaries.push(StyledIdx { vec_idx, str_idx });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
self.num_lines = boundaries.len() + 1;
|
|
||||||
boundaries
|
|
||||||
}
|
|
||||||
|
|
||||||
fn get_highlighted_lines(&self) -> HashSet<usize> {
|
|
||||||
self.ranges_to_lines(&self.highlight_lines)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn get_hidden_lines(&self) -> HashSet<usize> {
|
|
||||||
self.ranges_to_lines(&self.hide_lines)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn ranges_to_lines(&self, range: &Vec<Range>) -> HashSet<usize> {
|
|
||||||
let mut lines = HashSet::new();
|
|
||||||
for range in range {
|
|
||||||
for line in range.from..=min(range.to, self.num_lines) {
|
|
||||||
// Ranges are one-indexed
|
|
||||||
lines.insert(line.saturating_sub(1));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
lines
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// This is an index of a character in a `&[(Style, &'b str)]`. The `vec_idx` is the
|
|
||||||
/// index in the slice, and `str_idx` is the byte index of the character in the
|
|
||||||
/// corresponding string slice.
|
|
||||||
///
|
|
||||||
/// The `Ord` impl on this type sorts lexiographically on `vec_idx`, and then `str_idx`.
|
|
||||||
#[derive(Copy, Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
|
|
||||||
struct StyledIdx {
|
|
||||||
vec_idx: usize,
|
|
||||||
str_idx: usize,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// This is a utility used by `perform_split`. If the `vec_idx` in the `StyledIdx` is
|
|
||||||
/// equal to the provided value, return the `str_idx`, otherwise return `None`.
|
|
||||||
fn get_str_idx_if_vec_idx_is(idx: Option<&StyledIdx>, vec_idx: usize) -> Option<usize> {
|
|
||||||
match idx {
|
|
||||||
Some(idx) if idx.vec_idx == vec_idx => Some(idx.str_idx),
|
|
||||||
_ => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// This function assumes that `line_boundaries` is sorted according to the `Ord` impl on
|
|
||||||
/// the `StyledIdx` type.
|
|
||||||
fn perform_split<'b>(
|
|
||||||
split: &[(Style, &'b str)],
|
|
||||||
line_boundaries: Vec<StyledIdx>,
|
|
||||||
) -> Vec<(Style, &'b str)> {
|
|
||||||
let mut result = Vec::new();
|
|
||||||
|
|
||||||
let mut idxs_iter = line_boundaries.into_iter().peekable();
|
|
||||||
|
|
||||||
for (split_idx, item) in split.iter().enumerate() {
|
|
||||||
let mut last_split = 0;
|
|
||||||
|
|
||||||
// Since `line_boundaries` is sorted, we know that any remaining indexes in
|
|
||||||
// `idxs_iter` have `vec_idx >= split_idx`, and that if there are any with
|
|
||||||
// `vec_idx == split_idx`, they will be first.
|
|
||||||
//
|
|
||||||
// Using the `get_str_idx_if_vec_idx_is` utility, this loop will keep consuming
|
|
||||||
// indexes from `idxs_iter` as long as `vec_idx == split_idx` holds. Once
|
|
||||||
// `vec_idx` becomes larger than `split_idx`, the loop will finish without
|
|
||||||
// consuming that index.
|
|
||||||
//
|
|
||||||
// If `idxs_iter` is empty, or there are no indexes with `vec_idx == split_idx`,
|
|
||||||
// the loop does nothing.
|
|
||||||
while let Some(str_idx) = get_str_idx_if_vec_idx_is(idxs_iter.peek(), split_idx) {
|
|
||||||
// Consume the value we just peeked.
|
|
||||||
idxs_iter.next();
|
|
||||||
|
|
||||||
// This consumes the index to split at. We add one to include the newline
|
|
||||||
// together with its own line, rather than as the first character in the next
|
|
||||||
// line.
|
|
||||||
let split_at = min(str_idx + 1, item.1.len());
|
|
||||||
|
|
||||||
// This will fail if `line_boundaries` is not sorted.
|
|
||||||
debug_assert!(split_at >= last_split);
|
|
||||||
|
|
||||||
// Skip splitting if the string slice would be empty.
|
|
||||||
if last_split != split_at {
|
|
||||||
result.push((item.0, &item.1[last_split..split_at]));
|
|
||||||
last_split = split_at;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Now append the remainder. If the current item was not split, this will
|
|
||||||
// append the entire item.
|
|
||||||
if last_split != item.1.len() {
|
|
||||||
result.push((item.0, &item.1[last_split..]));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
result
|
|
||||||
}
|
|
||||||
|
|
||||||
fn color_highlighted_lines(data: &mut [(Style, &str)], lines: &HashSet<usize>, background: Color) {
|
|
||||||
if lines.is_empty() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut current_line = 0;
|
|
||||||
|
|
||||||
for item in data {
|
|
||||||
if lines.contains(¤t_line) {
|
|
||||||
item.0.background = background;
|
|
||||||
}
|
|
||||||
|
|
||||||
// We split the lines such that every newline is at the end of an item.
|
|
||||||
if item.1.ends_with('\n') {
|
|
||||||
current_line += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn hide_hidden_lines<'a>(
|
|
||||||
data: Vec<(Style, &'a str)>,
|
|
||||||
lines: &HashSet<usize>,
|
|
||||||
) -> Vec<(Style, &'a str)> {
|
|
||||||
if lines.is_empty() {
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut current_line = 0;
|
|
||||||
|
|
||||||
let mut to_keep = Vec::new();
|
|
||||||
|
|
||||||
for item in data {
|
|
||||||
if !lines.contains(¤t_line) {
|
|
||||||
to_keep.push(item);
|
|
||||||
}
|
|
||||||
|
|
||||||
// We split the lines such that every newline is at the end of an item.
|
|
||||||
if item.1.ends_with('\n') {
|
|
||||||
current_line += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
to_keep
|
|
||||||
}
|
|
|
@ -8,7 +8,7 @@ use rendering::{render_content, RenderContext};
|
||||||
|
|
||||||
macro_rules! colored_html_line {
|
macro_rules! colored_html_line {
|
||||||
( $s:expr ) => {{
|
( $s:expr ) => {{
|
||||||
let mut result = "<span style=\"color:#c0c5ce;\">".to_string();
|
let mut result = "<span>".to_string();
|
||||||
result.push_str($s);
|
result.push_str($s);
|
||||||
result.push_str("\n</span>");
|
result.push_str("\n</span>");
|
||||||
result
|
result
|
||||||
|
@ -17,11 +17,11 @@ macro_rules! colored_html_line {
|
||||||
|
|
||||||
macro_rules! colored_html {
|
macro_rules! colored_html {
|
||||||
( $($s:expr),* $(,)* ) => {{
|
( $($s:expr),* $(,)* ) => {{
|
||||||
let mut result = "<pre style=\"background-color:#2b303b;\">\n<code>".to_string();
|
let mut result = "<pre style=\"background-color:#2b303b;color:#c0c5ce;\"><code>".to_string();
|
||||||
$(
|
$(
|
||||||
result.push_str(colored_html_line!($s).as_str());
|
result.push_str(colored_html_line!($s).as_str());
|
||||||
)*
|
)*
|
||||||
result.push_str("</code></pre>");
|
result.push_str("</code></pre>\n");
|
||||||
result
|
result
|
||||||
}};
|
}};
|
||||||
}
|
}
|
||||||
|
@ -52,5 +52,5 @@ bat
|
||||||
&context,
|
&context,
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(res.body, colored_html!("foo\nbaz\nbat",));
|
assert_eq!(res.body, colored_html!("foo", "baz", "bat"));
|
||||||
}
|
}
|
||||||
|
|
|
@ -8,26 +8,28 @@ use rendering::{render_content, RenderContext};
|
||||||
|
|
||||||
macro_rules! colored_html_line {
|
macro_rules! colored_html_line {
|
||||||
( @no $s:expr ) => {{
|
( @no $s:expr ) => {{
|
||||||
let mut result = "<span style=\"color:#c0c5ce;\">".to_string();
|
let mut result = "<span>".to_string();
|
||||||
result.push_str($s);
|
result.push_str($s);
|
||||||
result.push_str("\n</span>");
|
result.push_str("\n</span>");
|
||||||
result
|
result
|
||||||
}};
|
}};
|
||||||
( @hl $s:expr ) => {{
|
( @hl $s:expr ) => {{
|
||||||
let mut result = "<span style=\"background-color:#65737e30;color:#c0c5ce;\">".to_string();
|
let mut result = "<mark style=\"background-color:#65737e30;\">".to_string();
|
||||||
|
result.push_str("<span>");
|
||||||
result.push_str($s);
|
result.push_str($s);
|
||||||
result.push_str("\n</span>");
|
result.push_str("\n</span>");
|
||||||
|
result.push_str("</mark>");
|
||||||
result
|
result
|
||||||
}};
|
}};
|
||||||
}
|
}
|
||||||
|
|
||||||
macro_rules! colored_html {
|
macro_rules! colored_html {
|
||||||
( $(@$kind:tt $s:expr),* $(,)* ) => {{
|
( $(@$kind:tt $s:expr),* $(,)* ) => {{
|
||||||
let mut result = "<pre style=\"background-color:#2b303b;\">\n<code>".to_string();
|
let mut result = "<pre style=\"background-color:#2b303b;color:#c0c5ce;\"><code>".to_string();
|
||||||
$(
|
$(
|
||||||
result.push_str(colored_html_line!(@$kind $s).as_str());
|
result.push_str(colored_html_line!(@$kind $s).as_str());
|
||||||
)*
|
)*
|
||||||
result.push_str("</code></pre>");
|
result.push_str("</code></pre>\n");
|
||||||
result
|
result
|
||||||
}};
|
}};
|
||||||
}
|
}
|
||||||
|
@ -63,7 +65,8 @@ baz
|
||||||
colored_html!(
|
colored_html!(
|
||||||
@no "foo",
|
@no "foo",
|
||||||
@hl "bar",
|
@hl "bar",
|
||||||
@no "bar\nbaz",
|
@no "bar",
|
||||||
|
@no "baz",
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -98,7 +101,8 @@ baz
|
||||||
res.body,
|
res.body,
|
||||||
colored_html!(
|
colored_html!(
|
||||||
@no "foo",
|
@no "foo",
|
||||||
@hl "bar\nbar",
|
@hl "bar",
|
||||||
|
@hl "bar",
|
||||||
@no "baz",
|
@no "baz",
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
@ -133,7 +137,10 @@ baz
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
res.body,
|
res.body,
|
||||||
colored_html!(
|
colored_html!(
|
||||||
@hl "foo\nbar\nbar\nbaz",
|
@hl "foo",
|
||||||
|
@hl "bar",
|
||||||
|
@hl "bar",
|
||||||
|
@hl "baz",
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -167,7 +174,9 @@ baz
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
res.body,
|
res.body,
|
||||||
colored_html!(
|
colored_html!(
|
||||||
@hl "foo\nbar\nbar",
|
@hl "foo",
|
||||||
|
@hl "bar",
|
||||||
|
@hl "bar",
|
||||||
@no "baz",
|
@no "baz",
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
@ -202,7 +211,9 @@ baz
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
res.body,
|
res.body,
|
||||||
colored_html!(
|
colored_html!(
|
||||||
@hl "foo\nbar\nbar",
|
@hl "foo",
|
||||||
|
@hl "bar",
|
||||||
|
@hl "bar",
|
||||||
@no "baz",
|
@no "baz",
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
@ -237,8 +248,10 @@ baz
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
res.body,
|
res.body,
|
||||||
colored_html!(
|
colored_html!(
|
||||||
@no "foo\nbar",
|
@no "foo",
|
||||||
@hl "bar\nbaz",
|
@no "bar",
|
||||||
|
@hl "bar",
|
||||||
|
@hl "baz",
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -272,8 +285,10 @@ baz
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
res.body,
|
res.body,
|
||||||
colored_html!(
|
colored_html!(
|
||||||
@no "foo\nbar",
|
@no "foo",
|
||||||
@hl "bar\nbaz",
|
@no "bar",
|
||||||
|
@hl "bar",
|
||||||
|
@hl "baz",
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -307,7 +322,9 @@ baz
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
res.body,
|
res.body,
|
||||||
colored_html!(
|
colored_html!(
|
||||||
@hl "foo\nbar\nbar",
|
@hl "foo",
|
||||||
|
@hl "bar",
|
||||||
|
@hl "bar",
|
||||||
@no "baz",
|
@no "baz",
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
@ -341,7 +358,9 @@ baz
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
res.body,
|
res.body,
|
||||||
colored_html!(
|
colored_html!(
|
||||||
@hl "foo\nbar\nbar",
|
@hl "foo",
|
||||||
|
@hl "bar",
|
||||||
|
@hl "bar",
|
||||||
@no "baz",
|
@no "baz",
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
@ -376,7 +395,9 @@ baz
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
res.body,
|
res.body,
|
||||||
colored_html!(
|
colored_html!(
|
||||||
@hl "foo\nbar\nbar",
|
@hl "foo",
|
||||||
|
@hl "bar",
|
||||||
|
@hl "bar",
|
||||||
@no "baz",
|
@no "baz",
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
@ -413,7 +434,8 @@ baz
|
||||||
colored_html!(
|
colored_html!(
|
||||||
@hl "foo",
|
@hl "foo",
|
||||||
@no "bar",
|
@no "bar",
|
||||||
@hl "bar\nbaz",
|
@hl "bar",
|
||||||
|
@hl "baz",
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -449,7 +471,8 @@ baz
|
||||||
colored_html!(
|
colored_html!(
|
||||||
@no "foo",
|
@no "foo",
|
||||||
@hl "bar",
|
@hl "bar",
|
||||||
@no "bar\nbaz",
|
@no "bar",
|
||||||
|
@no "baz",
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -484,7 +507,8 @@ baz
|
||||||
res.body,
|
res.body,
|
||||||
colored_html!(
|
colored_html!(
|
||||||
@no "foo",
|
@no "foo",
|
||||||
@hl "bar\nbar",
|
@hl "bar",
|
||||||
|
@hl "bar",
|
||||||
@no "baz",
|
@no "baz",
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
97
components/rendering/tests/codeblock_linenos.rs
Normal file
97
components/rendering/tests/codeblock_linenos.rs
Normal file
|
@ -0,0 +1,97 @@
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
use tera::Tera;
|
||||||
|
|
||||||
|
use config::Config;
|
||||||
|
use front_matter::InsertAnchor;
|
||||||
|
use rendering::{render_content, RenderContext};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn can_add_line_numbers() {
|
||||||
|
let tera_ctx = Tera::default();
|
||||||
|
let permalinks_ctx = HashMap::new();
|
||||||
|
let mut config = Config::default_for_test();
|
||||||
|
config.markdown.highlight_code = true;
|
||||||
|
let context = RenderContext::new(
|
||||||
|
&tera_ctx,
|
||||||
|
&config,
|
||||||
|
&config.default_language,
|
||||||
|
"",
|
||||||
|
&permalinks_ctx,
|
||||||
|
InsertAnchor::None,
|
||||||
|
);
|
||||||
|
let res = render_content(
|
||||||
|
r#"
|
||||||
|
```linenos
|
||||||
|
foo
|
||||||
|
bar
|
||||||
|
```
|
||||||
|
"#,
|
||||||
|
&context,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
res.body,
|
||||||
|
"<pre data-linenos style=\"background-color:#2b303b;color:#c0c5ce;\"><code><table><tbody><tr><td>1</td><td><span>foo\n</span><tr><td>2</td><td><span>bar\n</span></tr></tbody></table></code></pre>\n"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn can_add_line_numbers_with_linenostart() {
|
||||||
|
let tera_ctx = Tera::default();
|
||||||
|
let permalinks_ctx = HashMap::new();
|
||||||
|
let mut config = Config::default_for_test();
|
||||||
|
config.markdown.highlight_code = true;
|
||||||
|
let context = RenderContext::new(
|
||||||
|
&tera_ctx,
|
||||||
|
&config,
|
||||||
|
&config.default_language,
|
||||||
|
"",
|
||||||
|
&permalinks_ctx,
|
||||||
|
InsertAnchor::None,
|
||||||
|
);
|
||||||
|
let res = render_content(
|
||||||
|
r#"
|
||||||
|
```linenos, linenostart=40
|
||||||
|
foo
|
||||||
|
bar
|
||||||
|
```
|
||||||
|
"#,
|
||||||
|
&context,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
res.body,
|
||||||
|
"<pre data-linenos style=\"background-color:#2b303b;color:#c0c5ce;\"><code><table><tbody><tr><td>40</td><td><span>foo\n</span><tr><td>41</td><td><span>bar\n</span></tr></tbody></table></code></pre>\n"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn can_add_line_numbers_with_highlight() {
|
||||||
|
let tera_ctx = Tera::default();
|
||||||
|
let permalinks_ctx = HashMap::new();
|
||||||
|
let mut config = Config::default_for_test();
|
||||||
|
config.markdown.highlight_code = true;
|
||||||
|
let context = RenderContext::new(
|
||||||
|
&tera_ctx,
|
||||||
|
&config,
|
||||||
|
&config.default_language,
|
||||||
|
"",
|
||||||
|
&permalinks_ctx,
|
||||||
|
InsertAnchor::None,
|
||||||
|
);
|
||||||
|
let res = render_content(
|
||||||
|
r#"
|
||||||
|
```linenos, hl_lines=2
|
||||||
|
foo
|
||||||
|
bar
|
||||||
|
```
|
||||||
|
"#,
|
||||||
|
&context,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
res.body,
|
||||||
|
"<pre data-linenos style=\"background-color:#2b303b;color:#c0c5ce;\"><code><table><tbody><tr><td>1</td><td><span>foo\n</span><tr><td><mark style=\"background-color:#65737e30;\">2</mark></td><td><mark style=\"background-color:#65737e30;\"><span>bar\n</span></mark></tr></tbody></table></code></pre>\n"
|
||||||
|
);
|
||||||
|
}
|
|
@ -60,7 +60,7 @@ fn can_highlight_code_block_no_lang() {
|
||||||
let res = render_content("```\n$ gutenberg server\n$ ping\n```", &context).unwrap();
|
let res = render_content("```\n$ gutenberg server\n$ ping\n```", &context).unwrap();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
res.body,
|
res.body,
|
||||||
"<pre style=\"background-color:#2b303b;\">\n<code><span style=\"color:#c0c5ce;\">$ gutenberg server\n$ ping\n</span></code></pre>"
|
"<pre style=\"background-color:#2b303b;color:#c0c5ce;\"><code><span>$ gutenberg server\n</span><span>$ ping\n</span></code></pre>\n"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -81,7 +81,7 @@ fn can_highlight_code_block_with_lang() {
|
||||||
let res = render_content("```python\nlist.append(1)\n```", &context).unwrap();
|
let res = render_content("```python\nlist.append(1)\n```", &context).unwrap();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
res.body,
|
res.body,
|
||||||
"<pre style=\"background-color:#2b303b;\">\n<code class=\"language-python\" data-lang=\"python\"><span style=\"color:#c0c5ce;\">list.</span><span style=\"color:#bf616a;\">append</span><span style=\"color:#c0c5ce;\">(</span><span style=\"color:#d08770;\">1</span><span style=\"color:#c0c5ce;\">)\n</span></code></pre>"
|
"<pre data-lang=\"python\" style=\"background-color:#2b303b;color:#c0c5ce;\" class=\"language-python \"><code class=\"language-python\" data-lang=\"python\"><span>list.</span><span style=\"color:#bf616a;\">append</span><span>(</span><span style=\"color:#d08770;\">1</span><span>)\n</span></code></pre>\n"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -103,7 +103,7 @@ fn can_higlight_code_block_with_unknown_lang() {
|
||||||
// defaults to plain text
|
// defaults to plain text
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
res.body,
|
res.body,
|
||||||
"<pre style=\"background-color:#2b303b;\">\n<code class=\"language-yolo\" data-lang=\"yolo\"><span style=\"color:#c0c5ce;\">list.append(1)\n</span></code></pre>"
|
"<pre data-lang=\"yolo\" style=\"background-color:#2b303b;color:#c0c5ce;\" class=\"language-yolo \"><code class=\"language-yolo\" data-lang=\"yolo\"><span>list.append(1)\n</span></code></pre>\n"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -14,6 +14,7 @@ use rayon::prelude::*;
|
||||||
use tera::{Context, Tera};
|
use tera::{Context, Tera};
|
||||||
use walkdir::{DirEntry, WalkDir};
|
use walkdir::{DirEntry, WalkDir};
|
||||||
|
|
||||||
|
use config::highlighting::export_theme_css;
|
||||||
use config::{get_config, Config};
|
use config::{get_config, Config};
|
||||||
use errors::{bail, Error, Result};
|
use errors::{bail, Error, Result};
|
||||||
use front_matter::InsertAnchor;
|
use front_matter::InsertAnchor;
|
||||||
|
@ -666,7 +667,8 @@ impl Site {
|
||||||
self.render_feed(pages, Some(&PathBuf::from(code)), &code, |c| c)?;
|
self.render_feed(pages, Some(&PathBuf::from(code)), &code, |c| c)?;
|
||||||
start = log_time(start, "Generated feed in other language");
|
start = log_time(start, "Generated feed in other language");
|
||||||
}
|
}
|
||||||
|
self.render_themes_css()?;
|
||||||
|
start = log_time(start, "Rendered themes css");
|
||||||
self.render_404()?;
|
self.render_404()?;
|
||||||
start = log_time(start, "Rendered 404");
|
start = log_time(start, "Rendered 404");
|
||||||
self.render_robots()?;
|
self.render_robots()?;
|
||||||
|
@ -684,6 +686,20 @@ impl Site {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn render_themes_css(&self) -> Result<()> {
|
||||||
|
ensure_directory_exists(&self.static_path)?;
|
||||||
|
|
||||||
|
for t in &self.config.markdown.highlight_themes_css {
|
||||||
|
let p = self.static_path.join(&t.filename);
|
||||||
|
if !p.exists() {
|
||||||
|
let content = export_theme_css(&t.theme);
|
||||||
|
create_file(&p, &content)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
pub fn build_search_index(&self) -> Result<()> {
|
pub fn build_search_index(&self) -> Result<()> {
|
||||||
ensure_directory_exists(&self.output_path)?;
|
ensure_directory_exists(&self.output_path)?;
|
||||||
// TODO: add those to the SITE_CONTENT map
|
// TODO: add those to the SITE_CONTENT map
|
||||||
|
|
|
@ -178,7 +178,7 @@ mod tests {
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.filter(&to_value(&md).unwrap(), &HashMap::new());
|
.filter(&to_value(&md).unwrap(), &HashMap::new());
|
||||||
assert!(result.is_ok());
|
assert!(result.is_ok());
|
||||||
assert!(result.unwrap().as_str().unwrap().contains("<pre style"));
|
assert!(result.unwrap().as_str().unwrap().contains("style"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
|
@ -8,6 +8,11 @@ build_search_index = true
|
||||||
[markdown]
|
[markdown]
|
||||||
highlight_code = true
|
highlight_code = true
|
||||||
highlight_theme = "kronuz"
|
highlight_theme = "kronuz"
|
||||||
|
#highlight_theme = "css"
|
||||||
|
#highlight_themes_css = [
|
||||||
|
# { theme = "base16-ocean-dark", filename = "syntax-theme-dark.css" },
|
||||||
|
# { theme = "base16-ocean-light", filename = "syntax-theme-light.css" },
|
||||||
|
#]
|
||||||
|
|
||||||
|
|
||||||
[extra]
|
[extra]
|
||||||
|
|
|
@ -171,6 +171,79 @@ If your site source is laid out as follows:
|
||||||
|
|
||||||
you would set your `extra_syntaxes` to `["syntaxes", "syntaxes/Sublime-Language1"]` to load `lang1.sublime-syntax` and `lang2.sublime-syntax`.
|
you would set your `extra_syntaxes` to `["syntaxes", "syntaxes/Sublime-Language1"]` to load `lang1.sublime-syntax` and `lang2.sublime-syntax`.
|
||||||
|
|
||||||
|
## Inline VS classed highlighting
|
||||||
|
|
||||||
|
If you use a highlighting scheme like
|
||||||
|
|
||||||
|
```toml
|
||||||
|
highlight_theme = "base16-ocean-dark"
|
||||||
|
```
|
||||||
|
|
||||||
|
for a code block like
|
||||||
|
|
||||||
|
````md
|
||||||
|
```rs
|
||||||
|
let highlight = true;
|
||||||
|
```
|
||||||
|
````
|
||||||
|
|
||||||
|
you get the colors directly encoded in the html file.
|
||||||
|
|
||||||
|
```html
|
||||||
|
<pre class="language-rs" style="background-color:#2b303b;">
|
||||||
|
<code class="language-rs">
|
||||||
|
<span style="color:#b48ead;">let</span>
|
||||||
|
<span style="color:#c0c5ce;"> highlight = </span>
|
||||||
|
<span style="color:#d08770;">true</span>
|
||||||
|
<span style="color:#c0c5ce;">;
|
||||||
|
</span>
|
||||||
|
</code>
|
||||||
|
</pre>
|
||||||
|
```
|
||||||
|
|
||||||
|
This is nice, because your page will load faster if everything is in one file.
|
||||||
|
But if you would like to have the user choose a theme from a
|
||||||
|
list, or use different color schemes for dark/light color schemes, you need a
|
||||||
|
different solution.
|
||||||
|
|
||||||
|
If you use the special `css` color scheme
|
||||||
|
|
||||||
|
```toml
|
||||||
|
highlight_theme = "css"
|
||||||
|
```
|
||||||
|
|
||||||
|
you get CSS class definitions, instead.
|
||||||
|
|
||||||
|
```html
|
||||||
|
<pre class="language-rs">
|
||||||
|
<code class="language-rs">
|
||||||
|
<span class="z-source z-rust">
|
||||||
|
<span class="z-storage z-type z-rust">let</span> highlight
|
||||||
|
<span class="z-keyword z-operator z-assignment z-rust">=</span>
|
||||||
|
<span class="z-constant z-language z-rust">true</span>
|
||||||
|
<span class="z-punctuation z-terminator z-rust">;</span>
|
||||||
|
</span>
|
||||||
|
</code>
|
||||||
|
</pre>
|
||||||
|
```
|
||||||
|
|
||||||
|
Zola can output a css file for a theme in the `static` directory using the `highlighting_themes_css` option.
|
||||||
|
|
||||||
|
```toml
|
||||||
|
highlight_themes_css = [
|
||||||
|
{ theme = "base16-ocean-dark", filename = "syntax-theme-dark.css" },
|
||||||
|
{ theme = "base16-ocean-light", filename = "syntax-theme-light.css" },
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
You can then support light and dark mode like so:
|
||||||
|
|
||||||
|
```css
|
||||||
|
@import url("syntax-theme-dark.css") (prefers-color-scheme: dark);
|
||||||
|
@import url("syntax-theme-light.css") (prefers-color-scheme: light);
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
## Annotations
|
## Annotations
|
||||||
|
|
||||||
You can use additional annotations to customize how code blocks are displayed:
|
You can use additional annotations to customize how code blocks are displayed:
|
||||||
|
@ -185,18 +258,28 @@ highlight(code);
|
||||||
```
|
```
|
||||||
````
|
````
|
||||||
|
|
||||||
- `hl_lines` to highlight lines. You must specify a list of ranges of lines to highlight,
|
- `linenostart` to specify the number for the first line (defaults to 1)
|
||||||
separated by ` `. Ranges are 1-indexed.
|
|
||||||
|
|
||||||
````
|
````
|
||||||
```rust,hl_lines=3
|
```rust,linenos,linenostart=20
|
||||||
use highlighter::highlight;
|
use highlighter::highlight;
|
||||||
let code = "...";
|
let code = "...";
|
||||||
highlight(code);
|
highlight(code);
|
||||||
```
|
```
|
||||||
````
|
````
|
||||||
|
|
||||||
- `hide_lines` to hide lines. You must specify a list of ranges of lines to hide,
|
- `hl_lines` to highlight lines. You must specify a list of inclusive ranges of lines to highlight,
|
||||||
|
separated by whitespaces. Ranges are 1-indexed and `linenostart` doesn't influence the values, it always refers to the codeblock line number.
|
||||||
|
|
||||||
|
````
|
||||||
|
```rust,hl_lines=1 3-5 9
|
||||||
|
use highlighter::highlight;
|
||||||
|
let code = "...";
|
||||||
|
highlight(code);
|
||||||
|
```
|
||||||
|
````
|
||||||
|
|
||||||
|
- `hide_lines` to hide lines. You must specify a list of inclusive ranges of lines to hide,
|
||||||
separated by ` `. Ranges are 1-indexed.
|
separated by ` `. Ranges are 1-indexed.
|
||||||
|
|
||||||
````
|
````
|
||||||
|
@ -206,3 +289,61 @@ let code = "...";
|
||||||
highlight(code);
|
highlight(code);
|
||||||
```
|
```
|
||||||
````
|
````
|
||||||
|
|
||||||
|
## Styling codeblocks
|
||||||
|
|
||||||
|
Depending on the annotations used, some codeblocks will be hard to read without any CSS. We recommend using the following
|
||||||
|
snippet in your sites:
|
||||||
|
|
||||||
|
```scss
|
||||||
|
pre {
|
||||||
|
padding: 1rem;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
// The line numbers already provide some kind of left/right padding
|
||||||
|
pre[data-linenos] {
|
||||||
|
padding: 1rem 0;
|
||||||
|
}
|
||||||
|
pre table td {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
// The line number cells
|
||||||
|
pre table td:nth-of-type(1) {
|
||||||
|
text-align: center;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
pre mark {
|
||||||
|
// If you want your highlights to take the full width.
|
||||||
|
display: block;
|
||||||
|
// The default background colour of a mark is bright yellow
|
||||||
|
background-color: rgba(254, 252, 232, 0.9);
|
||||||
|
}
|
||||||
|
pre table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
This snippet makes the highlighting work on the full width and ensures that a user can copy the content without
|
||||||
|
selecting the line numbers. Obviously you will probably need to adjust it to fit your site style.
|
||||||
|
|
||||||
|
Here's an example with all the options used: `scss, linenos, linenostart=10, hl_lines=3-4 8-9, hide_lines=2 7` with the
|
||||||
|
snippet above.
|
||||||
|
|
||||||
|
```scss, linenos, linenostart=10, hl_lines=3-4 8-9, hide_lines=2 7
|
||||||
|
pre mark {
|
||||||
|
// If you want your highlights to take the full width.
|
||||||
|
display: block;
|
||||||
|
color: currentcolor;
|
||||||
|
}
|
||||||
|
pre table td:nth-of-type(1) {
|
||||||
|
// Select a colour matching your theme
|
||||||
|
color: #6b6b6b;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Line 2 and 7 are comments that are not shown in the final output.
|
||||||
|
|
||||||
|
When line numbers are active, the code block is turned into a table with one row and two cells. The first cell contains the line number and the second cell contains the code.
|
||||||
|
Highlights are done via the `<mark>` HTML tag. When a line with line number is highlighted two `<mark>` tags are created: one around the line number(s) and one around the code.
|
||||||
|
|
|
@ -82,6 +82,26 @@ pre {
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
}
|
}
|
||||||
|
pre[data-linenos] {
|
||||||
|
padding: 1rem 0;
|
||||||
|
}
|
||||||
|
pre mark {
|
||||||
|
// If you want your highlights to take the full width.
|
||||||
|
display: block;
|
||||||
|
// The default background colour of a mark is bright yellow
|
||||||
|
background-color: rgba(254, 252, 232, 0.9);
|
||||||
|
}
|
||||||
|
pre table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
pre table td {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
pre table td:nth-of-type(1) {
|
||||||
|
text-align: center;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
p code, li code {
|
p code, li code {
|
||||||
background-color: #f5f5f5;
|
background-color: #f5f5f5;
|
||||||
|
|
|
@ -17,3 +17,6 @@ $link-color: #007CBC;
|
||||||
@import "docs";
|
@import "docs";
|
||||||
@import "themes";
|
@import "themes";
|
||||||
@import "search";
|
@import "search";
|
||||||
|
|
||||||
|
//@import url("syntax-theme-dark.css") (prefers-color-scheme: dark);
|
||||||
|
//@import url("syntax-theme-light.css") (prefers-color-scheme: light);
|
||||||
|
|
Loading…
Reference in a new issue