Skip to content

Add line highlighting support to ClassedHTMLGenerator#611

Open
Madoshakalaka wants to merge 3 commits intotrishume:masterfrom
Madoshakalaka:line-highlighting
Open

Add line highlighting support to ClassedHTMLGenerator#611
Madoshakalaka wants to merge 3 commits intotrishume:masterfrom
Madoshakalaka:line-highlighting

Conversation

@Madoshakalaka
Copy link
Copy Markdown

@Madoshakalaka Madoshakalaka commented Mar 9, 2026

Background

I'm a Yew maintainer working on yewstack/yew#2779 - replacing our currently docusaurus based https://yew.rs documentation site to a Rust/Yew based one.

Docusaurus supports line-hightling with prism.js

so that (note the line number 7)

```toml title="Cargo.toml" {7}
[package]
name = "yew-app"
version = "0.1.0"
edition = "2021"

[dependencies]
yew = { git = "https://github.com/yewstack/yew/", features = ["csr"] }
```

shows as:
image

multi non-adjecent lines is possible through comma separated line ranges like:

```rust {3-11,18-19,21-28}

This PR adds line-highlighting support to syntect

Line Hightlighting API

let mut gen = ClassedHTMLGenerator::new_with_class_style_and_highlighted_lines(
    syntax, &syntax_set, ClassStyle::Spaced, &[2, 5, 6],
);
for line in LinesWithEndings::from(code) {
    gen.parse_html_for_line_which_includes_newline(line)?;
}
let html = gen.finalize();
// Lines 2, 5, 6 are wrapped in <span class="hl">...</span>

Visualization

I did some visualization on my branch:

image

Gap

prism.js supports comment based line hightlighting, which is very practical in some scenarios:

use yew::{component, html, Html, Properties};

#[component]
fn App() -> Html {
    html! {
        // highlight-start
        <HelloWorld>
            <span>{"Hey what is up ;)"}</span>
            <h1>{"THE SKY"}</h1>
        </HelloWorld>
        // highlight-end
    }
}

#[derive(Properties, PartialEq)]
pub struct Props {
    // highlight-next-line
    pub children: Html, // the field name `children` is important!
}

#[component]
fn HelloWorld(props: &Props) -> Html {
    html! {
        <div class="very-stylized-container">
    // highlight-next-line
            { props.children.clone() } // you can forward children like this
        </div>
    }
}
image

Add new constructor `new_with_class_style_and_highlighted_lines` that
accepts 1-indexed line numbers to wrap in `<span class="hl">`. The
wrapper correctly closes and reopens scope spans that cross line
boundaries, keeping the HTML well-nested. The newline character is
placed outside the highlight wrapper so `display: inline-block` styling
works without collapsing consecutive highlighted lines.

Also emit a `.hl` CSS rule in `css_for_theme_with_class_style` using
the theme's `lineHighlight` color (with alpha support) when available.
@Madoshakalaka
Copy link
Copy Markdown
Author

After some investigation, I realized comment based syntax highlighting comes from docusaurus, as opposed to prism.js

Not sure if it's a good idea to include it in syntect, open to suggestions.

@Madoshakalaka Madoshakalaka marked this pull request as ready for review March 9, 2026 11:05
stefanobaghino added a commit to stefanobaghino/syntect that referenced this pull request Apr 8, 2026
Introduce a ClassedRenderer trait that decouples syntax parsing from
HTML generation, allowing custom renderers to control per-token and
per-line output without accessing SCOPE_REPO.

- Add ClassedRenderer trait with begin_line/end_line/begin_scope/
  end_scope/write_text hooks; begin_scope receives pre-resolved
  atom strings so consumers never touch the scope repository
- Add DefaultClassedRenderer reproducing the original <span> output
- Rename ClassedHTMLGenerator to ClassedHighlighter<R>, parameterized
  by renderer; keep ClassedHTMLGenerator as a deprecated type alias
- Add LineHighlightingRenderer<R> composable wrapper for line
  highlighting with proper scope span close/reopen at boundaries
- Add Scope::with_atom_strs() closure-based API for reading atom
  strings without exposing the scope repository
- Remove SCOPE_REPO and lock_global_scope_repo from the public API
- Optimize locking: repo locked once per line instead of per scope

Addresses trishume#626, provides the extension points needed by trishume#611.
@stefanobaghino
Copy link
Copy Markdown
Contributor

I've been working on #626 and opened a draft PR (#627) that introduces a ClassedRenderer trait for ClassedHTMLGenerator. This could provide an alternative approach to line highlighting that doesn't require baking it into the struct.

The idea is that ClassedHTMLGenerator (renamed to ClassedHighlighter<R>) becomes parameterized by a renderer trait with hooks for begin_line, end_line, begin_scope, end_scope, and write_text. A LineHighlightingRenderer<R> wrapper composes with any inner renderer to add <span class="hl"> wrappers around specific lines, handling the scope span close/reopen at line boundaries.

Usage would look like:

let style = ClassStyle::Spaced;
let renderer = LineHighlightingRenderer::new(
    DefaultClassedRenderer::new(style),
    &[1, 4, 7], // 0-indexed line numbers to highlight
);
let mut gen = ClassedHighlighter::new_with_renderer(syntax, &syntax_set, style, renderer);
for line in LinesWithEndings::from(code) {
    gen.parse_html_for_line_which_includes_newline(line)?;
}
let html = gen.finalize();
// Highlighted lines are wrapped in <span class="hl">...</span>

There are a couple of things that would need work to fully cover this PR's scope:

  1. CSS generation for .hl: this PR adds a .hl rule to css_for_theme_with_class_style using the theme's lineHighlight color. That part is orthogonal to the renderer trait and could be added independently to css_for_theme_with_class_style in Streaming HighlightedWriter and layered renderer traits #627.

  2. 1-indexed vs 0-indexed lines: this PR uses 1-indexed line numbers, Streaming HighlightedWriter and layered renderer traits #627 currently uses 0-indexed. This should be aligned — open to feedback on which convention is preferable.

  3. Comment-based highlighting (// highlight-next-line etc.): as noted in this PR's description, that's a higher-level feature that sits above the renderer layer and would need separate handling regardless of approach.

Would appreciate any feedback on whether this direction works for the Yew documentation use case.

@stefanobaghino
Copy link
Copy Markdown
Contributor

Update on #627: the draft PR has evolved since my previous comment. Here's where things stand now:

  • ClassedHTMLGenerator keeps its name (no rename to ClassedHighlighter) but is now generic: ClassedHTMLGenerator<'a, R: ScopeRenderer = HtmlScopeRenderer>.
  • The ScopeRenderer trait (in syntect::renderer) provides hooks for begin_line, end_line, begin_scope, end_scope, and write_text — enough to implement line highlighting as a custom renderer without changes to syntect itself.
  • The LineHighlightingRenderer wrapper I mentioned earlier was removed from the PR to keep scope focused on the core trait, but line highlighting remains straightforward to implement externally via begin_line/end_line hooks and ClassedHTMLGenerator::new_with_renderer().

A line-highlighting renderer would look roughly like:

use syntect::parsing::Scope;
use syntect::renderer::ScopeRenderer;
use syntect::html::HtmlScopeRenderer;

struct LineHighlightingRenderer {
    inner: HtmlScopeRenderer,
    highlighted_lines: Vec<usize>, // 0-based line indices
}

impl ScopeRenderer for LineHighlightingRenderer {
    fn begin_line(&mut self, line_index: usize, scope_stack: &[Scope], output: &mut String) {
        if self.highlighted_lines.contains(&line_index) {
            output.push_str("<span class=\"hl\">");
        }
    }

    fn end_line(&mut self, line_index: usize, scope_stack: &[Scope], output: &mut String) {
        if self.highlighted_lines.contains(&line_index) {
            output.push_str("</span>");
        }
    }

    fn begin_scope(&mut self, atom_strs: &[&str], scope: Scope, scope_stack: &[Scope], output: &mut String) -> bool {
        self.inner.begin_scope(atom_strs, scope, scope_stack, output)
    }

    fn end_scope(&mut self, output: &mut String) {
        self.inner.end_scope(output);
    }

    fn write_text(&mut self, text: &str, output: &mut String) -> Result<(), std::fmt::Error> {
        self.inner.write_text(text, output)
    }
}

This is simpler than the previous LineHighlightingRenderer because it doesn't need to handle scope span close/reopen at line boundaries — wrapping the whole line in <span class="hl"> around the inner renderer's output is sufficient for CSS-based highlighting.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants