Add syntax highlighting to code editors
This commit is contained in:
@@ -10,7 +10,7 @@ The project is under active development. Core blogging workflows are broadly ava
|
|||||||
- Post and translation authoring with draft/published lifecycle, metadata, tags, categories, links, media, and batch gallery-image import.
|
- Post and translation authoring with draft/published lifecycle, metadata, tags, categories, links, media, and batch gallery-image import.
|
||||||
- Media import, thumbnails, metadata translations, filters, validation, post assignment, and sequential drag-and-drop insertion into post editors.
|
- Media import, thumbnails, metadata translations, filters, validation, post assignment, and sequential drag-and-drop insertion into post editors.
|
||||||
- WordPress WXR migration with saved analyses, HTML-to-Markdown and shortcode conversion, conflict/taxonomy review, recoverable 500-item execution batches, media-parent linking, progress reporting, and optional AI-assisted taxonomy mapping.
|
- WordPress WXR migration with saved analyses, HTML-to-Markdown and shortcode conversion, conflict/taxonomy review, recoverable 500-item execution batches, media-parent linking, progress reporting, and optional AI-assisted taxonomy mapping.
|
||||||
- Template and Lua script management with explicit syntax-check feedback, using a custom Ropey/Syntect/Cosmic Text editor and the documented, bDS2-signature-compatible project-scoped [`bds` host API](docs/scripting/API_REFERENCE.md) across utilities, rendered macros, and Blogmark transforms, including airplane-gated Git sync.
|
- Post, Liquid template, and Lua script editing with dedicated syntax highlighting and explicit syntax-check feedback, using a custom Ropey/Syntect/Cosmic Text editor and the documented, bDS2-signature-compatible project-scoped [`bds` host API](docs/scripting/API_REFERENCE.md) across utilities, rendered macros, and Blogmark transforms, including airplane-gated Git sync.
|
||||||
- SQLite and filesystem persistence with frontmatter, sidecars, rebuild, metadata diff/repair, and FTS5 search.
|
- SQLite and filesystem persistence with frontmatter, sidecars, rebuild, metadata diff/repair, and FTS5 search.
|
||||||
- Optional on-device multilingual semantic search and tag suggestions backed by a persistent USearch index, with duplicate-post review and dismissal in the desktop workspace.
|
- Optional on-device multilingual semantic search and tag suggestions backed by a persistent USearch index, with duplicate-post review and dismissal in the desktop workspace.
|
||||||
- Read-only in-app browsers in the Help menu for the bundled global `DOCUMENTATION.md`, the generated Lua API reference with public types and runnable examples, the [CLI/server/TUI documentation](CLI.md), and the [MCP server documentation](MCP.md), with safe GFM rendering and confirmed external links.
|
- Read-only in-app browsers in the Help menu for the bundled global `DOCUMENTATION.md`, the generated Lua API reference with public types and runnable examples, the [CLI/server/TUI documentation](CLI.md), and the [MCP server documentation](MCP.md), with safe GFM rendering and confirmed external links.
|
||||||
|
|||||||
@@ -2,7 +2,11 @@ use std::collections::HashMap;
|
|||||||
use std::sync::LazyLock;
|
use std::sync::LazyLock;
|
||||||
|
|
||||||
use syntect::highlighting::{Style, ThemeSet};
|
use syntect::highlighting::{Style, ThemeSet};
|
||||||
use syntect::parsing::{SyntaxReference, SyntaxSet};
|
use syntect::parsing::{SyntaxDefinition, SyntaxReference, SyntaxSet};
|
||||||
|
|
||||||
|
const MARKDOWN_WITH_MACROS_SYNTAX: &str =
|
||||||
|
include_str!("../syntaxes/Markdown with Macros.sublime-syntax");
|
||||||
|
const LIQUID_SYNTAX: &str = include_str!("../syntaxes/Liquid.sublime-syntax");
|
||||||
|
|
||||||
/// Global highlighter singleton (expensive to create, safe to share).
|
/// Global highlighter singleton (expensive to create, safe to share).
|
||||||
static GLOBAL_HIGHLIGHTER: LazyLock<Highlighter> = LazyLock::new(Highlighter::new);
|
static GLOBAL_HIGHLIGHTER: LazyLock<Highlighter> = LazyLock::new(Highlighter::new);
|
||||||
@@ -17,7 +21,7 @@ pub struct Highlighter {
|
|||||||
syntax_set: SyntaxSet,
|
syntax_set: SyntaxSet,
|
||||||
theme_set: ThemeSet,
|
theme_set: ThemeSet,
|
||||||
theme_name: String,
|
theme_name: String,
|
||||||
/// Extension aliases (e.g. "liquid" → "html")
|
/// Extension aliases used by the application editors.
|
||||||
extension_aliases: HashMap<String, String>,
|
extension_aliases: HashMap<String, String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -26,13 +30,24 @@ pub type HighlightedLine = Vec<(Style, String)>;
|
|||||||
|
|
||||||
impl Highlighter {
|
impl Highlighter {
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
|
let mut syntax_builder = SyntaxSet::load_defaults_newlines().into_builder();
|
||||||
|
for (definition, name) in [
|
||||||
|
(MARKDOWN_WITH_MACROS_SYNTAX, "Markdown with Macros"),
|
||||||
|
(LIQUID_SYNTAX, "Liquid"),
|
||||||
|
] {
|
||||||
|
syntax_builder.add(
|
||||||
|
SyntaxDefinition::load_from_str(definition, true, Some(name))
|
||||||
|
.unwrap_or_else(|error| panic!("invalid {name} syntax definition: {error}")),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
let mut extension_aliases = HashMap::new();
|
let mut extension_aliases = HashMap::new();
|
||||||
// Liquid templates use HTML syntax highlighting
|
extension_aliases.insert("md".into(), "bds-md".into());
|
||||||
extension_aliases.insert("liquid".into(), "html".into());
|
extension_aliases.insert("markdown".into(), "bds-md".into());
|
||||||
extension_aliases.insert("njk".into(), "html".into());
|
extension_aliases.insert("njk".into(), "html".into());
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
syntax_set: SyntaxSet::load_defaults_newlines(),
|
syntax_set: syntax_builder.build(),
|
||||||
theme_set: ThemeSet::load_defaults(),
|
theme_set: ThemeSet::load_defaults(),
|
||||||
theme_name: "base16-ocean.dark".to_string(),
|
theme_name: "base16-ocean.dark".to_string(),
|
||||||
extension_aliases,
|
extension_aliases,
|
||||||
@@ -40,7 +55,7 @@ impl Highlighter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Find the syntax definition for a file extension.
|
/// Find the syntax definition for a file extension.
|
||||||
/// Resolves aliases (e.g. liquid → html) before lookup.
|
/// Resolves application aliases before lookup.
|
||||||
pub fn syntax_for_extension(&self, ext: &str) -> &SyntaxReference {
|
pub fn syntax_for_extension(&self, ext: &str) -> &SyntaxReference {
|
||||||
let resolved = self
|
let resolved = self
|
||||||
.extension_aliases
|
.extension_aliases
|
||||||
@@ -123,6 +138,18 @@ impl Default for Highlighter {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
fn foreground_for<'a>(
|
||||||
|
line: &'a HighlightedLine,
|
||||||
|
fragment: &str,
|
||||||
|
) -> &'a syntect::highlighting::Color {
|
||||||
|
&line
|
||||||
|
.iter()
|
||||||
|
.find(|(_, text)| text.contains(fragment))
|
||||||
|
.unwrap_or_else(|| panic!("missing highlighted fragment {fragment:?} in {line:?}"))
|
||||||
|
.0
|
||||||
|
.foreground
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn highlight_markdown() {
|
fn highlight_markdown() {
|
||||||
let h = Highlighter::new();
|
let h = Highlighter::new();
|
||||||
@@ -142,11 +169,81 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn liquid_uses_html_syntax() {
|
fn markdown_uses_bds_macro_syntax() {
|
||||||
let h = Highlighter::new();
|
let h = Highlighter::new();
|
||||||
let liquid_syntax = h.syntax_for_extension("liquid");
|
assert_eq!(h.syntax_for_extension("md").name, "Markdown with Macros");
|
||||||
let html_syntax = h.syntax_for_extension("html");
|
}
|
||||||
assert_eq!(liquid_syntax.name, html_syntax.name);
|
|
||||||
|
#[test]
|
||||||
|
fn liquid_uses_dedicated_syntax() {
|
||||||
|
let h = Highlighter::new();
|
||||||
|
assert_eq!(h.syntax_for_extension("liquid").name, "Liquid");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn markdown_macro_components_receive_distinct_styles() {
|
||||||
|
let h = Highlighter::new();
|
||||||
|
let lines = h.highlight_lines(
|
||||||
|
"plain\n[[gallery columns=\"3\"]]",
|
||||||
|
h.syntax_for_extension("md"),
|
||||||
|
);
|
||||||
|
let plain = foreground_for(&lines[0], "plain");
|
||||||
|
assert_ne!(
|
||||||
|
foreground_for(&lines[1], "[[gallery"),
|
||||||
|
plain,
|
||||||
|
"macro name should be highlighted: {:?}",
|
||||||
|
lines[1]
|
||||||
|
);
|
||||||
|
assert_ne!(
|
||||||
|
foreground_for(&lines[1], "columns"),
|
||||||
|
plain,
|
||||||
|
"attribute name should be highlighted: {:?}",
|
||||||
|
lines[1]
|
||||||
|
);
|
||||||
|
assert_ne!(
|
||||||
|
foreground_for(&lines[1], "\"3\""),
|
||||||
|
plain,
|
||||||
|
"attribute value should be highlighted: {:?}",
|
||||||
|
lines[1]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn markdown_constructs_receive_distinct_styles() {
|
||||||
|
let h = Highlighter::new();
|
||||||
|
let lines = h.highlight_lines(
|
||||||
|
"plain\n# Heading\n[link](https://example.com)\n`code`\n**strong**\n*emphasis*\n- item",
|
||||||
|
h.syntax_for_extension("md"),
|
||||||
|
);
|
||||||
|
let plain = foreground_for(&lines[0], "plain");
|
||||||
|
for (line, fragment) in [
|
||||||
|
(1, "Heading"),
|
||||||
|
(2, "link"),
|
||||||
|
(3, "code"),
|
||||||
|
(4, "strong"),
|
||||||
|
(5, "emphasis"),
|
||||||
|
(6, "- "),
|
||||||
|
] {
|
||||||
|
assert_ne!(
|
||||||
|
foreground_for(&lines[line], fragment),
|
||||||
|
plain,
|
||||||
|
"Markdown fragment {fragment:?} should be colored: {:?}",
|
||||||
|
lines[line]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn liquid_tags_filters_and_comments_receive_distinct_styles() {
|
||||||
|
let h = Highlighter::new();
|
||||||
|
let lines = h.highlight_lines(
|
||||||
|
"plain\n{{ post.title | escape }}\n{% if post %}body{% endif %}\n{% comment %}hidden{% endcomment %}",
|
||||||
|
h.syntax_for_extension("liquid"),
|
||||||
|
);
|
||||||
|
let plain = foreground_for(&lines[0], "plain");
|
||||||
|
assert_ne!(foreground_for(&lines[1], "escape"), plain);
|
||||||
|
assert_ne!(foreground_for(&lines[2], "if"), plain);
|
||||||
|
assert_ne!(foreground_for(&lines[3], "hidden"), plain);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -170,6 +267,30 @@ mod tests {
|
|||||||
assert_ne!(syntax.name, "Plain Text");
|
assert_ne!(syntax.name, "Plain Text");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn editor_syntaxes_produce_distinct_foreground_colors() {
|
||||||
|
let h = Highlighter::new();
|
||||||
|
for (extension, source) in [
|
||||||
|
("md", "# Heading\n[link](https://example.com)\n`code`"),
|
||||||
|
(
|
||||||
|
"liquid",
|
||||||
|
"<h1>{{ post.title }}</h1>\n{% if post %}Body{% endif %}",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"lua",
|
||||||
|
"local answer = function(value)\n -- comment\n return value + 42\nend",
|
||||||
|
),
|
||||||
|
] {
|
||||||
|
let colors = h
|
||||||
|
.highlight_lines(source, h.syntax_for_extension(extension))
|
||||||
|
.into_iter()
|
||||||
|
.flatten()
|
||||||
|
.map(|(style, _)| style.foreground)
|
||||||
|
.collect::<std::collections::HashSet<_>>();
|
||||||
|
assert!(colors.len() > 1, "{extension} should use syntax colors");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn highlight_visible_range_returns_subset() {
|
fn highlight_visible_range_returns_subset() {
|
||||||
let h = Highlighter::new();
|
let h = Highlighter::new();
|
||||||
|
|||||||
@@ -118,12 +118,23 @@ fn committed_text_input(text: Option<&str>, is_command_shortcut: bool) -> Option
|
|||||||
|
|
||||||
/// Convert syntect RGBA color to Iced Color.
|
/// Convert syntect RGBA color to Iced Color.
|
||||||
fn syntect_to_iced(c: syntect::highlighting::Color) -> Color {
|
fn syntect_to_iced(c: syntect::highlighting::Color) -> Color {
|
||||||
Color::from_rgba(
|
let (red, green, blue) = match (c.r, c.g, c.b) {
|
||||||
c.r as f32 / 255.0,
|
// Translate the base16-ocean theme to the bDS2/VS Dark editor palette.
|
||||||
c.g as f32 / 255.0,
|
(0x65, 0x73, 0x7E) => (0x6A, 0x99, 0x55), // comments
|
||||||
c.b as f32 / 255.0,
|
(0xB4, 0x8E, 0xAD) => (0xC5, 0x86, 0xC0), // keywords
|
||||||
c.a as f32 / 255.0,
|
(0xA3, 0xBE, 0x8C) => (0xCE, 0x91, 0x78), // strings
|
||||||
)
|
(0xD0, 0x87, 0x70) => (0xB5, 0xCE, 0xA8), // numbers
|
||||||
|
(0xEB, 0xCB, 0x8B) => (0xDC, 0xDC, 0xAA), // functions
|
||||||
|
(0x96, 0xB5, 0xB4) => (0x4E, 0xC9, 0xB0), // types
|
||||||
|
(0xBF, 0x61, 0x6A) => (0x56, 0x9C, 0xD6), // tags
|
||||||
|
(0x8F, 0xA1, 0xB3) => (0x9C, 0xDC, 0xFE), // attributes and links
|
||||||
|
(0xAB, 0x79, 0x67) => (0xCE, 0x91, 0x78), // attribute values
|
||||||
|
(0xC0, 0xC5, 0xCE) | (0xA7, 0xAD, 0xBA) | (0xDF, 0xE1, 0xE8) | (0xEF, 0xF1, 0xF5) => {
|
||||||
|
(0xD4, 0xD4, 0xD4)
|
||||||
|
}
|
||||||
|
_ => (c.r, c.g, c.b),
|
||||||
|
};
|
||||||
|
rgba8(red, green, blue, c.a as f32 / 255.0)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Compute word-wrap break offsets for a line. Returns the char offsets where
|
/// Compute word-wrap break offsets for a line. Returns the char offsets where
|
||||||
@@ -1103,7 +1114,8 @@ where
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::committed_text_input;
|
use super::{committed_text_input, rgb8, syntect_to_iced};
|
||||||
|
use syntect::highlighting::Color as SyntectColor;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn committed_text_input_accepts_regular_and_ime_text() {
|
fn committed_text_input_accepts_regular_and_ime_text() {
|
||||||
@@ -1119,4 +1131,35 @@ mod tests {
|
|||||||
assert_eq!(committed_text_input(Some("\u{7f}"), false), None);
|
assert_eq!(committed_text_input(Some("\u{7f}"), false), None);
|
||||||
assert_eq!(committed_text_input(None, false), None);
|
assert_eq!(committed_text_input(None, false), None);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn syntax_palette_uses_the_bds2_editor_colors() {
|
||||||
|
assert_eq!(
|
||||||
|
syntect_to_iced(SyntectColor {
|
||||||
|
r: 101,
|
||||||
|
g: 115,
|
||||||
|
b: 126,
|
||||||
|
a: 255,
|
||||||
|
}),
|
||||||
|
rgb8(0x6A, 0x99, 0x55)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
syntect_to_iced(SyntectColor {
|
||||||
|
r: 180,
|
||||||
|
g: 142,
|
||||||
|
b: 173,
|
||||||
|
a: 255,
|
||||||
|
}),
|
||||||
|
rgb8(0xC5, 0x86, 0xC0)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
syntect_to_iced(SyntectColor {
|
||||||
|
r: 163,
|
||||||
|
g: 190,
|
||||||
|
b: 140,
|
||||||
|
a: 255,
|
||||||
|
}),
|
||||||
|
rgb8(0xCE, 0x91, 0x78)
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
58
crates/bds-editor/syntaxes/Liquid.sublime-syntax
Normal file
58
crates/bds-editor/syntaxes/Liquid.sublime-syntax
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
%YAML 1.2
|
||||||
|
---
|
||||||
|
name: Liquid
|
||||||
|
file_extensions:
|
||||||
|
- liquid
|
||||||
|
scope: text.html.liquid
|
||||||
|
contexts:
|
||||||
|
main:
|
||||||
|
- match: '\{\{-?'
|
||||||
|
scope: punctuation.section.embedded.begin.liquid
|
||||||
|
push: liquid-output
|
||||||
|
- match: '\{%-?\s*comment\b[^%]*-?%\}'
|
||||||
|
scope: comment.block.liquid
|
||||||
|
push: liquid-comment
|
||||||
|
- match: '\{%-?'
|
||||||
|
scope: punctuation.section.embedded.begin.liquid
|
||||||
|
push: liquid-tag
|
||||||
|
- include: scope:text.html.basic
|
||||||
|
|
||||||
|
liquid-output:
|
||||||
|
- meta_scope: meta.embedded.liquid
|
||||||
|
- match: '-?\}\}'
|
||||||
|
scope: punctuation.section.embedded.end.liquid
|
||||||
|
pop: true
|
||||||
|
- include: liquid-values
|
||||||
|
|
||||||
|
liquid-tag:
|
||||||
|
- meta_scope: meta.embedded.liquid
|
||||||
|
- match: '-?%\}'
|
||||||
|
scope: punctuation.section.embedded.end.liquid
|
||||||
|
pop: true
|
||||||
|
- match: '\b(assign|capture|case|comment|cycle|decrement|echo|elsif|else|endcase|endcapture|endif|endfor|endunless|endcomment|for|if|include|increment|liquid|paginate|raw|render|tablerow|unless|when)\b'
|
||||||
|
scope: keyword.control.liquid
|
||||||
|
- match: '[><=!]=?|\.|:'
|
||||||
|
scope: keyword.operator.liquid
|
||||||
|
- include: liquid-values
|
||||||
|
|
||||||
|
liquid-values:
|
||||||
|
- match: '\|\s*[a-zA-Z_][\w-]*'
|
||||||
|
scope: support.function.filter.liquid
|
||||||
|
- match: '\b(true|false|nil|blank|empty|contains)\b'
|
||||||
|
scope: constant.language.liquid
|
||||||
|
- match: '\b\d+(\.\d+)?\b'
|
||||||
|
scope: constant.numeric.liquid
|
||||||
|
- match: '"([^"\\]|\\.)*"'
|
||||||
|
scope: string.quoted.double.liquid
|
||||||
|
- match: "'([^'\\\\]|\\\\.)*'"
|
||||||
|
scope: string.quoted.single.liquid
|
||||||
|
- match: '[a-zA-Z_][\w.-]*'
|
||||||
|
scope: variable.other.liquid
|
||||||
|
- match: '[,()\[\]]'
|
||||||
|
scope: punctuation.separator.liquid
|
||||||
|
|
||||||
|
liquid-comment:
|
||||||
|
- meta_scope: comment.block.liquid
|
||||||
|
- match: '\{%-?\s*endcomment\s*-?%\}'
|
||||||
|
scope: comment.block.liquid
|
||||||
|
pop: true
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
%YAML 1.2
|
||||||
|
---
|
||||||
|
name: Markdown with Macros
|
||||||
|
file_extensions:
|
||||||
|
- bds-md
|
||||||
|
scope: text.html.markdown.bds
|
||||||
|
contexts:
|
||||||
|
main:
|
||||||
|
- match: '\[\[[a-zA-Z][\w-]*'
|
||||||
|
scope: keyword.control.macro.bds
|
||||||
|
push: macro-parameters
|
||||||
|
- match: '^#{1,6}\s.*$'
|
||||||
|
scope: keyword.control.heading.markdown
|
||||||
|
- match: '^\s*>+'
|
||||||
|
scope: string.quoted.block.markdown
|
||||||
|
- match: '^\s*[-+*]\s'
|
||||||
|
scope: keyword.control.list.unnumbered.markdown
|
||||||
|
- match: '^\s*\d+\.\s'
|
||||||
|
scope: keyword.control.list.numbered.markdown
|
||||||
|
- match: '^\s*```\w*'
|
||||||
|
scope: keyword.control.code-fence.markdown
|
||||||
|
push: fenced-code
|
||||||
|
- match: '\*\*[^*]+\*\*|__[^_]+__'
|
||||||
|
scope: entity.name.function.strong.markdown
|
||||||
|
- match: '\*[^*]+\*|_[^_]+_'
|
||||||
|
scope: string.quoted.emphasis.markdown
|
||||||
|
- match: '`[^`]+`'
|
||||||
|
scope: string.quoted.inline-code.markdown
|
||||||
|
- match: '!?\[[^\]]*\]\([^)]*\)'
|
||||||
|
scope: string.quoted.link.inline.markdown
|
||||||
|
- match: '!?\[[^\]]*\]\[[^\]]*\]'
|
||||||
|
scope: string.quoted.link.reference.markdown
|
||||||
|
|
||||||
|
macro-parameters:
|
||||||
|
- meta_scope: meta.macro.bds
|
||||||
|
- match: '\]\]'
|
||||||
|
scope: keyword.control.macro.bds
|
||||||
|
pop: true
|
||||||
|
- match: '[a-zA-Z][\w-]*(?=\s*=)'
|
||||||
|
scope: entity.other.attribute-name.bds
|
||||||
|
- match: '='
|
||||||
|
scope: punctuation.separator.key-value.bds
|
||||||
|
- match: '"[^"\n]*"'
|
||||||
|
scope: string.quoted.double.bds
|
||||||
|
- match: '\s+'
|
||||||
|
- match: '[^\]"=\s]+'
|
||||||
|
scope: string.unquoted.attribute-value.bds
|
||||||
|
|
||||||
|
fenced-code:
|
||||||
|
- meta_scope: string.unquoted.code-block.markdown
|
||||||
|
- match: '^\s*```\s*$'
|
||||||
|
scope: keyword.control.code-fence.markdown
|
||||||
|
pop: true
|
||||||
Reference in New Issue
Block a user