//// `glimra` is a zero runtime syntax highlighter for `lustre/ssg`. //// //// In Swedish, `glimra` describes the brilliant gleam or lustre that comes from a polished, //// reflective surface, capturing the essence of light shining off something smooth and glossy. //// //// `glimra` uses [NIFs](https://www.erlang.org/doc/system/nif) to extract syntax highlighting //// events provided by the [`tree-sitter`](https://crates.io/crates/tree-sitter) and //// [`tree-sitter-highlight`](https://crates.io/crates/tree-sitter-highlight) crates. //// This allows `glimra` to provide syntax highlighting for a wide range of languages with minimal effort. // IMPORTS --------------------------------------------------------------------- import gleam/bool import gleam/dict.{type Dict} import gleam/float import gleam/int import gleam/list import gleam/option.{type Option, None, Some} import gleam/result import gleam/string import gleam/string_builder import glimra/theme.{type Theme} import lustre/attribute import lustre/element.{type Element} import lustre/element/html import lustre/ssg // FFI ------------------------------------------------------------------------- @external(erlang, "libglimra", "get_supported_languages") fn get_supported_languages() -> List(String) @external(erlang, "libglimra", "get_highlight_events") fn get_highlight_events( source_code: String, lang_atom: String, ) -> Result(List(HighlightEvent), String) @external(erlang, "libglimra", "get_highlight_name") fn get_highlight_name(index: Int) -> String // CONSTANTS -------------------------------------------------------------------- const css_path = "/glimra.css" const glimra_notice = "\n/* generated by glimra */\n" const line_class = "line" // MAIN ------------------------------------------------------------------------ /// Create a new syntax highlighter configuration with default settings. /// /// The default configuration has line numbers disabled and source trimming /// enabled, with no theme applied. /// pub fn new_syntax_highlighter() -> Config(NoTheme) { Config(line_numbers: False, trim_source: True, theme: None) } /// Perform syntax highlighting on the provided source code using the given /// configuration and programming language. /// /// Returns a Lustre `Element` on success, or a `SyntaxHighlightingError` on failure. /// /// - `config`: The syntax highlighter configuration. /// - `source`: The source code to highlight. /// - `language`: The programming language of the source code. /// pub fn syntax_highlight( config config: Config(has_theme), source source: String, language language: String, ) -> Result(Element(Nil), SyntaxHighlightingError) { let language = string.lowercase(language) let language_supported = list.contains(get_supported_languages(), language) use <- bool.guard( when: !language_supported, return: Error(UnsupportedLanguage(language)), ) let source = case config.trim_source { True -> string.trim(source) False -> source } use events <- result.try( get_highlight_events(source, language) |> result.replace_error(TreeSitterError), ) use reversed_lines <- result.try(do_syntax_highlight( source:, events:, code_block: [], code_row: [], highlights: [], snippet: "", )) Ok(html.code( [attribute.attribute("data-lang", language)], list.reverse(reversed_lines), )) } /// Generate CSS for the syntax highlighter configuration. /// /// The CSS includes styles for the highlighted code blocks based on the /// configuration's theme. /// pub fn to_css(config config: Config(HasTheme)) -> String { let assert Config(_, _, Some(theme)) = config string_builder.new() |> string_builder.append(glimra_notice) |> string_builder.append(pre_styling(config)) |> string_builder.append(code_styling(config)) |> string_builder.append(line_styling(config)) |> string_builder.append(theme.to_css(theme)) |> string_builder.to_string() } // TYPES ----------------------------------------------------------------------- /// A configuration for the syntax highlighter with a theme. /// /// - `line_numbers`: Whether to display line numbers. /// - `trim_source`: Whether to trim whitespace from the source code. /// - `theme`: A theme to style the highlighted code. /// pub opaque type Config(has_theme) { Config(line_numbers: Bool, trim_source: Bool, theme: Option(Theme)) } /// A phantom type representing a configuration without a theme. pub type NoTheme /// A phantom type representing a configuration with a theme. pub type HasTheme /// Possible errors that can occur during syntax highlighting. pub type SyntaxHighlightingError { /// The specified language is not supported. UnsupportedLanguage(language: String) /// An error occurred with the Tree-sitter syntax highlighting library. TreeSitterError /// There were unmatched highlight events during the syntax highlighting process. UnmatchedHighlightEvents } /// Syntax highlighting event, such as starting or ending a highlight, or encountering a source code segment. type HighlightEvent { HighlightStart(highlight_type: Int) Source(start: Int, end: Int) HighlightEnd } // BUILDERS -------------------------------------------------------------------- /// Enable line numbers in the syntax highlighter configuration. /// pub fn enable_line_numbers( config config: Config(has_theme), ) -> Config(has_theme) { Config(..config, line_numbers: True) } /// Set whether to trim the source code in the syntax highlighter configuration. /// /// - `trim_source`: Whether to trim the source code. /// pub fn set_trim_source( config config: Config(has_theme), trim_source trim_source: Bool, ) -> Config(has_theme) { Config(..config, trim_source:) } /// Apply a theme to the syntax highlighter configuration. /// pub fn set_theme( config config: Config(NoTheme), theme theme: Theme, ) -> Config(HasTheme) { let Config(line_numbers, trim_source, _) = config Config(line_numbers:, trim_source:, theme: Some(theme)) } // LUSTRE/SSG ------------------------------------------------------------------ /// Add a static stylesheet for syntax highlighting to a lustre/ssg configuration. /// /// - `ssg_config`: The static site generator configuration. /// - `syntax_highlighter`: The syntax highlighter configuration with a theme. /// pub fn add_static_stylesheet( ssg_config ssg_config: ssg.Config( has_static_routes, has_static_dir, use_index_routes, ), syntax_highlighter syntax_highlighter: Config(HasTheme), ) -> ssg.Config(has_static_routes, has_static_dir, use_index_routes) { ssg.add_static_asset(ssg_config, css_path, to_css(syntax_highlighter)) } /// Generate a link element to include the static syntax highlighting stylesheet. /// pub fn link_static_stylesheet() { html.link([attribute.href(css_path), attribute.rel("stylesheet")]) } /// Create a renderer function for code blocks that uses the syntax highlighter. /// /// - `syntax_highlighter`: The syntax highlighter configuration with a theme. /// pub fn codeblock_renderer( syntax_highlighter syntax_highlighter: Config(HasTheme), ) -> fn(Dict(String, String), Option(String), String) -> Element(Nil) { fn(attrs, language, source) { let to_attributes = fn(attrs) { use attrs, key, val <- dict.fold(attrs, []) [attribute.attribute(key, val), ..attrs] } let language = option.unwrap(language, "text") html.pre(to_attributes(attrs), [ syntax_highlighter |> syntax_highlight(source:, language:) |> result.unwrap( html.code([attribute.attribute("data-lang", language)], [ element.text(source), ]), ), ]) } } // EVENT PARSING --------------------------------------------------------------- fn do_syntax_highlight( source source: String, events events: List(HighlightEvent), // accumulators below code_block code_block: List(Element(Nil)), code_row code_row: List(Element(Nil)), highlights highlights: List(Int), snippet snippet: String, ) -> Result(List(Element(Nil)), SyntaxHighlightingError) { let #(highlight_name, rest_of_highlights) = parse_highlights(highlights) case events { [event, ..rest] -> { case event { HighlightStart(highlight_type) -> { case snippet { "" -> { // if the current snippet is empty, we just add the highlight type do_syntax_highlight( source:, events: rest, code_block:, code_row:, highlights: [highlight_type, ..highlights], snippet:, ) } _ -> { // add the current snippet to the current row before starting a new highlight do_syntax_highlight( source:, events: rest, code_block:, code_row: prepend_with_snippet( code_row:, highlight_name: highlight_name, snippet: snippet, ), highlights: [highlight_type, ..highlights], snippet: "", ) } } } HighlightEnd -> { case string.is_empty(highlight_name) && list.is_empty(rest_of_highlights) { True -> { // if there is no current highlight, we have an unmatched highlight end Error(UnmatchedHighlightEvents) } False -> { // add the current snippet to the current row before discarding the current highlight do_syntax_highlight( source:, events: rest, code_block:, code_row: prepend_with_snippet( code_row:, highlight_name: highlight_name, snippet: snippet, ), highlights: rest_of_highlights, snippet: "", ) } } } Source(start, end) -> { let new_snippet = string.slice(source, start, end - start) // if the new snippet contains a newline... case string.contains(does: new_snippet, contain: "\n") { True -> { // then we want to add what is before the linebreak // to the current and then add a new row let assert Ok(#(before_linebreak, _)) = string.split_once(new_snippet, on: "\n") let new_start = start + string.length(before_linebreak) + 1 do_syntax_highlight( source:, events: [Source(new_start, end), ..rest], code_block: prepend_with_linebreak( current_code_block: code_block, children: prepend_with_snippet( code_row:, highlight_name: highlight_name, snippet: snippet <> before_linebreak, ), ), code_row: [], highlights:, snippet: "", ) } False -> { // otherwise we just add the snippet to the current snippet do_syntax_highlight( source:, events: rest, code_block:, code_row:, highlights:, snippet: snippet <> new_snippet, ) } } } } } [] -> { // add remaining snippet to the current Ok(prepend_with_linebreak( current_code_block: code_block, children: prepend_with_snippet( code_row:, highlight_name: highlight_name, snippet: snippet, ), )) } } } // HELPERS --------------------------------------------------------------------- fn parse_highlights(highlights: List(Int)) -> #(String, List(Int)) { case highlights { [highlight, ..rest_of_highlights] -> { #(get_highlight_name(highlight), rest_of_highlights) } [] -> { #("", []) } } } // ELEMENT BUILDERS ------------------------------------------------------------ fn prepend_with_snippet( code_row siblings: List(Element(Nil)), highlight_name highlight_name: String, snippet snippet: String, ) -> List(Element(Nil)) { case string.is_empty(snippet) { True -> siblings False -> [ html.span([attribute.class(highlight_name)], [html.text(snippet)]), ..siblings ] } } fn prepend_with_linebreak( current_code_block siblings: List(Element(Nil)), children children: List(Element(Nil)), ) -> List(Element(Nil)) { [html.span([attribute.class(line_class)], list.reverse(children)), ..siblings] } // CSS UTILS ------------------------------------------------------------------- /// Generate CSS styles for the `
` element based on the provided theme.
///
fn pre_styling(config config: Config(HasTheme)) -> String {
  let assert Config(_, _, Some(theme)) = config

  "\npre {\n"
  <> "  background-color: rgb("
  <> int.to_string(theme.background_color.r)
  <> ", "
  <> int.to_string(theme.background_color.g)
  <> ", "
  <> int.to_string(theme.background_color.b)
  <> ");\n"
  <> "  padding-right: "
  <> float.to_string(theme.padding_x)
  <> "rem;\n"
  <> "  padding-left: "
  <> float.to_string(theme.padding_x)
  <> "rem;\n"
  <> "  padding-top: "
  <> float.to_string(theme.padding_y)
  <> "rem;\n"
  <> "  padding-bottom: "
  <> float.to_string(theme.padding_y)
  <> "rem;\n"
  <> "  border-radius: "
  <> float.to_string(theme.border_radius)
  <> "rem;\n"
  <> "}\n"
}

/// Generate CSS styles for the `` element when line numbers are enabled.
///
fn code_styling(config config: Config(HasTheme)) -> String {
  let Config(line_numbers, _, _) = config

  case line_numbers {
    True -> {
      "\ncode {\n"
      <> "  counter-reset: step;\n"
      <> "  counter-increment: step 0;\n"
      <> "}\n"
    }
    False -> ""
  }
}

/// Generate CSS styles `` elements.
///
fn line_styling(config config: Config(HasTheme)) -> String {
  let Config(line_numbers, _, _) = config
  case line_numbers {
    True -> {
      "\n."
      <> line_class
      <> " { display: block; }\n"
      <> "\n."
      <> line_class
      <> "::before {\n"
      <> "  content: counter(step);\n"
      <> "  counter-increment: step;\n"
      <> "  width: 1rem;\n"
      <> "  margin-right: 1.5rem;\n"
      <> "  display: inline-block;\n"
      <> "  text-align: right;\n"
      <> "  color: rgba(115,138,148,.4);\n"
      <> "}\n\n."
      <> line_class
      <> ":empty::before { content: counter(step) \"\\200b\" }\n"
    }
    False -> {
      "\n."
      <> line_class
      <> " { display: block; }\n"
      <> "\n."
      <> line_class
      <> ":empty::before { content: \"\\200b\"; }\n"
    }
  }
}