diff --git a/README.md b/README.md index 6e84f56..5599a39 100644 --- a/README.md +++ b/README.md @@ -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. - 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. -- 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. - 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. diff --git a/crates/bds-editor/src/highlight.rs b/crates/bds-editor/src/highlight.rs index 6f22b73..6d687f3 100644 --- a/crates/bds-editor/src/highlight.rs +++ b/crates/bds-editor/src/highlight.rs @@ -2,7 +2,11 @@ use std::collections::HashMap; use std::sync::LazyLock; 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). static GLOBAL_HIGHLIGHTER: LazyLock = LazyLock::new(Highlighter::new); @@ -17,7 +21,7 @@ pub struct Highlighter { syntax_set: SyntaxSet, theme_set: ThemeSet, theme_name: String, - /// Extension aliases (e.g. "liquid" → "html") + /// Extension aliases used by the application editors. extension_aliases: HashMap, } @@ -26,13 +30,24 @@ pub type HighlightedLine = Vec<(Style, String)>; impl Highlighter { 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(); - // Liquid templates use HTML syntax highlighting - extension_aliases.insert("liquid".into(), "html".into()); + extension_aliases.insert("md".into(), "bds-md".into()); + extension_aliases.insert("markdown".into(), "bds-md".into()); extension_aliases.insert("njk".into(), "html".into()); Self { - syntax_set: SyntaxSet::load_defaults_newlines(), + syntax_set: syntax_builder.build(), theme_set: ThemeSet::load_defaults(), theme_name: "base16-ocean.dark".to_string(), extension_aliases, @@ -40,7 +55,7 @@ impl Highlighter { } /// 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 { let resolved = self .extension_aliases @@ -123,6 +138,18 @@ impl Default for Highlighter { mod tests { 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] fn highlight_markdown() { let h = Highlighter::new(); @@ -142,11 +169,81 @@ mod tests { } #[test] - fn liquid_uses_html_syntax() { + fn markdown_uses_bds_macro_syntax() { let h = Highlighter::new(); - let liquid_syntax = h.syntax_for_extension("liquid"); - let html_syntax = h.syntax_for_extension("html"); - assert_eq!(liquid_syntax.name, html_syntax.name); + assert_eq!(h.syntax_for_extension("md").name, "Markdown with Macros"); + } + + #[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] @@ -170,6 +267,30 @@ mod tests { 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", + "

{{ post.title }}

\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::>(); + assert!(colors.len() > 1, "{extension} should use syntax colors"); + } + } + #[test] fn highlight_visible_range_returns_subset() { let h = Highlighter::new(); diff --git a/crates/bds-editor/src/widget.rs b/crates/bds-editor/src/widget.rs index 452fc6d..e6598a1 100644 --- a/crates/bds-editor/src/widget.rs +++ b/crates/bds-editor/src/widget.rs @@ -118,12 +118,23 @@ fn committed_text_input(text: Option<&str>, is_command_shortcut: bool) -> Option /// Convert syntect RGBA color to Iced Color. fn syntect_to_iced(c: syntect::highlighting::Color) -> Color { - Color::from_rgba( - c.r as f32 / 255.0, - c.g as f32 / 255.0, - c.b as f32 / 255.0, - c.a as f32 / 255.0, - ) + let (red, green, blue) = match (c.r, c.g, c.b) { + // Translate the base16-ocean theme to the bDS2/VS Dark editor palette. + (0x65, 0x73, 0x7E) => (0x6A, 0x99, 0x55), // comments + (0xB4, 0x8E, 0xAD) => (0xC5, 0x86, 0xC0), // keywords + (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 @@ -1103,7 +1114,8 @@ where #[cfg(test)] mod tests { - use super::committed_text_input; + use super::{committed_text_input, rgb8, syntect_to_iced}; + use syntect::highlighting::Color as SyntectColor; #[test] 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(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) + ); + } } diff --git a/crates/bds-editor/syntaxes/Liquid.sublime-syntax b/crates/bds-editor/syntaxes/Liquid.sublime-syntax new file mode 100644 index 0000000..edc732f --- /dev/null +++ b/crates/bds-editor/syntaxes/Liquid.sublime-syntax @@ -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 diff --git a/crates/bds-editor/syntaxes/Markdown with Macros.sublime-syntax b/crates/bds-editor/syntaxes/Markdown with Macros.sublime-syntax new file mode 100644 index 0000000..350d63f --- /dev/null +++ b/crates/bds-editor/syntaxes/Markdown with Macros.sublime-syntax @@ -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