feat(cli)!: rework HTML output modes in highlight

- `--layout <document|line-numbers|fragment>` (default `document`): the
  document structure. `document` is a self-contained page wrapping a plain
  `<div class="highlight"><pre><code>` block, `line-numbers` adds a line-number
  `<table>`, and `fragment` emits only the code markup with no surrounding
  `<head>`/`<style>`/`<body>` so it can be embedded in an existing page.

- `--style <classes|inline|minimal>` (default `classes`): how token colors
  are applied. `classes` uses `class="..."` spans plus a generated `<style>`,
  `inline` bakes colors onto each span so the markup is self-contained, and
  `minimal` emits bare classes with no colors (bring your own stylesheet).

Deprecates the `--css-classes` flag.
This commit is contained in:
Will Lillis 2026-07-18 04:01:13 -04:00
parent d205dc92de
commit 3a76a8c8ca
3 changed files with 100 additions and 29 deletions

View file

@ -12,6 +12,7 @@ use std::{
use ansi_colours::{ansi256_from_rgb, rgb_from_ansi256};
use anstyle::{Ansi256Color, AnsiColor, Color, Effects, RgbColor};
use anyhow::Result;
use clap::ValueEnum;
use log::{info, warn};
use serde::{Deserialize, Deserializer, Serialize, Serializer, ser::SerializeMap};
use serde_json::{Value, json};
@ -26,8 +27,9 @@ pub const HTML_HEAD_HEADER: &str = "
<style>
body {
font-family: monospace
}
.line-number {
}";
pub const HTML_LINE_NUMBER_STYLE: &str = " .line-number {
user-select: none;
text-align: right;
color: rgba(27,31,35,.3);
@ -35,8 +37,7 @@ pub const HTML_HEAD_HEADER: &str = "
}
.line {
white-space: pre;
}
</style>";
}";
pub const HTML_BODY_HEADER: &str = "
</head>
@ -308,12 +309,36 @@ fn terminal_supports_truecolor() -> bool {
.is_ok_and(|truecolor| truecolor == "truecolor" || truecolor == "24bit")
}
/// The kind of HTML emitted when highlighting to HTML.
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
pub enum HtmlOutput {
/// A complete, self-contained document wrapping a plain
/// `<div class="highlight"><pre><code>` block.
Document,
/// A complete document with a line-number column (a `<table>` layout).
#[value(name = "line-numbers")]
NumberedDocument,
/// Only the code markup, without the surrounding document.
Fragment,
}
/// How token colors are applied in HTML output.
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
pub enum HtmlStyling {
/// `class="..."` spans plus a generated `<style>` carrying the theme's colors.
Classes,
/// `style="..."` spans with the colors inlined.
Inline,
/// `class="..."` spans with no colors emitted (supply your own stylesheet).
Minimal,
}
pub struct HighlightOptions {
pub theme: Theme,
pub check: bool,
pub captures_path: Option<PathBuf>,
pub inline_styles: bool,
pub html: bool,
/// `None` for regular output, `Some((layout, style))` when emitting HTML.
pub html: Option<(HtmlOutput, HtmlStyling)>,
pub quiet: bool,
pub print_time: bool,
pub cancellation_flag: Arc<AtomicUsize>,
@ -395,19 +420,25 @@ pub fn highlight(
)?;
let theme = &opts.theme;
if !opts.quiet && print_name {
// A fragment is pure code markup, so it must not be prefixed with the filename.
let html_fragment = opts
.html
.is_some_and(|(layout, _)| layout == HtmlOutput::Fragment);
if !opts.quiet && print_name && !html_fragment {
writeln!(&mut stdout, "{name}")?;
}
if opts.html {
if !opts.quiet {
if let Some((layout, style)) = opts.html {
if !opts.quiet && layout != HtmlOutput::Fragment {
writeln!(&mut stdout, "{HTML_HEAD_HEADER}")?;
writeln!(&mut stdout, " <style>")?;
let names = theme.highlight_names.iter();
let styles = theme.styles.iter();
for (name, style) in names.zip(styles) {
if let Some(css) = &style.css {
writeln!(&mut stdout, " .{name} {{ {css}; }}")?;
if layout == HtmlOutput::NumberedDocument {
writeln!(&mut stdout, "{HTML_LINE_NUMBER_STYLE}")?;
}
if style == HtmlStyling::Classes {
for (name, style) in theme.highlight_names.iter().zip(&theme.styles) {
if let Some(css) = &style.css {
writeln!(&mut stdout, " .{name} {{ {css}; }}")?;
}
}
}
writeln!(&mut stdout, " </style>")?;
@ -416,7 +447,7 @@ pub fn highlight(
let mut renderer = HtmlRenderer::new();
renderer.render(events, &source, &move |highlight, output| {
if opts.inline_styles {
if style == HtmlStyling::Inline {
output.extend(b"style='");
output.extend(
theme.styles[highlight.0]
@ -438,16 +469,29 @@ pub fn highlight(
})?;
if !opts.quiet {
writeln!(&mut stdout, "<table>")?;
for (i, line) in renderer.lines().enumerate() {
if layout == HtmlOutput::NumberedDocument {
writeln!(&mut stdout, "<table>")?;
for (i, line) in renderer.lines().enumerate() {
writeln!(
&mut stdout,
"<tr><td class=line-number>{}</td><td class=line>{line}</td></tr>",
i + 1,
)?;
}
writeln!(&mut stdout, "</table>")?;
} else {
let mut body = renderer.lines().collect::<String>();
if body.ends_with('\n') {
body.pop();
}
writeln!(
&mut stdout,
"<tr><td class=line-number>{}</td><td class=line>{line}</td></tr>",
i + 1,
"<div class=\"highlight\">\n<pre><code>{body}</code></pre>\n</div>",
)?;
}
writeln!(&mut stdout, "</table>")?;
writeln!(&mut stdout, "{HTML_FOOTER}")?;
if layout != HtmlOutput::Fragment {
writeln!(&mut stdout, "{HTML_FOOTER}")?;
}
}
} else {
let mut style_stack = vec![theme.default_style().ansi];

View file

@ -19,7 +19,7 @@ use tree_sitter_cli::{
DEFAULT_EDIT_COUNT, DEFAULT_ITERATION_COUNT, EDIT_COUNT, FuzzOptions, ITERATION_COUNT,
LOG_ENABLED, LOG_GRAPH_ENABLED, START_SEED, fuzz_language_corpus,
},
highlight::{self, HighlightOptions},
highlight::{self, HighlightOptions, HtmlOutput, HtmlStyling},
init::{JsonConfigOpts, TREE_SITTER_JSON_SCHEMA, generate_grammar_files},
input::{CliInput, get_input, get_tmp_source_file},
logger, paint,
@ -508,9 +508,15 @@ struct Highlight {
/// Generate highlighting as an HTML document
#[arg(long, short = 'H')]
pub html: bool,
/// When generating HTML, use css classes rather than inline styles
#[arg(long, requires = "html")]
/// Deprecated: use `--style classes`
#[arg(long, requires = "html", conflicts_with = "style")]
pub css_classes: bool,
/// When generating HTML, the document structure to emit
#[arg(long, requires = "html", value_enum, default_value = "document")]
pub layout: HtmlOutput,
/// When generating HTML, how token colors are applied
#[arg(long, requires = "html", value_enum, default_value = "classes")]
pub style: HtmlStyling,
/// Check that highlighting captures conform strictly to standards
#[arg(long)]
pub check: bool,
@ -1704,12 +1710,19 @@ impl Highlight {
Encoding::Utf16BE => ffi::TSInputEncodingUTF16BE,
});
let style = if self.css_classes {
// TODO: Remove during the 0.28 release cycle
warn!("--css-classes is deprecated, use --style classes instead");
HtmlStyling::Classes
} else {
self.style
};
let options = HighlightOptions {
theme: theme_config.theme,
check: self.check,
captures_path: self.captures_path,
inline_styles: !self.css_classes,
html: self.html,
html: self.html.then_some((self.layout, style)),
quiet: self.quiet,
print_time: self.time,
cancellation_flag: cancellation_flag.clone(),

View file

@ -14,9 +14,23 @@ tree-sitter highlight [OPTIONS] [PATHS]... # Aliases: hi
Output an HTML document with syntax highlighting.
### `--css-classes`
### `--layout <LAYOUT>`
Output HTML with CSS classes instead of inline styles.
When generating HTML, the document structure to emit. One of:
- `document` (default): a complete, self-contained HTML document wrapping a plain `<div class="highlight"><pre><code>`
block.
- `line-numbers`: a complete document with a line-number column (a `<table>` layout).
- `fragment`: only the code markup, without the surrounding `<head>`/`<style>`/`<body>` document, so it can be embedded
in an existing page.
### `--style <STYLE>`
When generating HTML, how token colors are applied. One of:
- `classes` (default): `class="..."` spans plus a generated `<style>` block carrying the theme's colors.
- `inline`: `style="..."` spans with the colors inlined, so the markup is self-contained.
- `minimal`: `class="..."` spans with no colors emitted (supply your own stylesheet).
### `--check`