Packages

Zero runtime syntax highlighter for lustre/ssg.

Current section

Files

Jump to
glimra src glimra.gleam
Raw

src/glimra.gleam

//// `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 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
@external(erlang, "libglimra", "is_windows")
fn is_windows() -> Bool
fn newline() -> String {
case is_windows() {
True -> "\r\n"
False -> "\n"
}
}
// CONSTANTS --------------------------------------------------------------------
const css_path = "/glimra.css"
const glimra_notice = "/* generated by glimra */"
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) |> string.replace("\r\n", "\n")
False -> source |> string.replace("\r\n", "\n")
}
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
let newline = newline()
newline
<> glimra_notice
<> newline
<> pre_styling(config)
<> code_styling(config)
<> line_styling(config)
<> theme.to_css(theme)
}
// 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 <span> 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 `<pre>` element based on the provided theme.
///
fn pre_styling(config config: Config(HasTheme)) -> String {
let assert Config(_, _, Some(theme)) = config
let newline = newline()
newline
<> "pre {"
<> newline
<> " background-color: rgb("
<> int.to_string(theme.background_color.r)
<> ", "
<> int.to_string(theme.background_color.g)
<> ", "
<> int.to_string(theme.background_color.b)
<> ");"
<> newline
<> " padding-right: "
<> float.to_string(theme.padding_x)
<> "rem;"
<> newline
<> " padding-left: "
<> float.to_string(theme.padding_x)
<> "rem;"
<> newline
<> " padding-top: "
<> float.to_string(theme.padding_y)
<> "rem;"
<> newline
<> " padding-bottom: "
<> float.to_string(theme.padding_y)
<> "rem;"
<> newline
<> " border-radius: "
<> float.to_string(theme.border_radius)
<> "rem;"
<> newline
<> "}"
<> newline
}
/// Generate CSS styles for the `<code>` element when line numbers are enabled.
///
fn code_styling(config config: Config(HasTheme)) -> String {
let Config(line_numbers, _, _) = config
let newline = newline()
case line_numbers {
True -> {
newline
<> "code {"
<> newline
<> " counter-reset: step;"
<> newline
<> " counter-increment: step 0;"
<> newline
<> "}"
<> newline
}
False -> ""
}
}
/// Generate CSS styles `<span class="line">` elements.
///
fn line_styling(config config: Config(HasTheme)) -> String {
let Config(line_numbers, _, _) = config
let newline = newline()
case line_numbers {
True -> {
newline
<> "."
<> line_class
<> " { display: block; }"
<> newline
<> newline
<> "."
<> line_class
<> "::before {"
<> newline
<> " content: counter(step);"
<> newline
<> " counter-increment: step;"
<> newline
<> " width: 1rem;"
<> newline
<> " margin-right: 1.5rem;"
<> newline
<> " display: inline-block;"
<> newline
<> " text-align: right;"
<> newline
<> " color: rgba(115,138,148,.4);"
<> newline
<> "}"
<> newline
<> newline
<> "."
<> line_class
<> ":empty::before { content: counter(step) \"\\200b\" }"
<> newline
}
False -> {
newline
<> "."
<> line_class
<> " { display: block; }"
<> newline
<> newline
<> "."
<> line_class
<> ":empty::before { content: \"\\200b\"; }"
<> newline
}
}
}