docs(themes): add syntax highlighting documentation and test coverage
- Update syntax-highlighting.md with tree-house integration details - Add themes/README.md explaining copy-to-project workflow - Add 13 tests: hierarchical scopes, injections (Nix+bash, MD, HTML) - All 64 tests passing
This commit is contained in:
@@ -1,10 +1,10 @@
|
|||||||
---
|
---
|
||||||
title: Syntax Highlighting
|
title: Syntax Highlighting
|
||||||
description: Build-time code highlighting with Tree-sitter
|
description: Build-time code highlighting with Tree-sitter and tree-house
|
||||||
weight: 3
|
weight: 3
|
||||||
---
|
---
|
||||||
|
|
||||||
sukr highlights code blocks at build time using Tree-sitter. No client-side JavaScript required.
|
sukr highlights code blocks at build time using [tree-house](https://github.com/helix-editor/tree-house) (Helix editor's Tree-sitter integration). No client-side JavaScript required.
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
@@ -150,37 +150,81 @@ int main() {
|
|||||||
## How It Works
|
## How It Works
|
||||||
|
|
||||||
1. During Markdown parsing, code blocks are intercepted
|
1. During Markdown parsing, code blocks are intercepted
|
||||||
2. Tree-sitter parses the code and generates a syntax tree
|
2. tree-house parses the code and generates a syntax tree
|
||||||
3. Spans are generated with semantic CSS classes
|
3. Spans are generated with **hierarchical CSS classes** (e.g., `.hl-keyword-control-return`)
|
||||||
4. All work happens at build time
|
4. All work happens at build time—zero JavaScript in the browser
|
||||||
|
|
||||||
## Styling
|
## Theme System
|
||||||
|
|
||||||
Highlighted code uses semantic CSS classes:
|
sukr uses a **decoupled theme system** with CSS custom properties. Themes are separate CSS files that define colors for syntax highlighting classes.
|
||||||
|
|
||||||
|
### Hierarchical Scopes
|
||||||
|
|
||||||
|
Highlighting uses fine-grained scope classes with hierarchical fallback:
|
||||||
|
|
||||||
|
| Scope Class | Description |
|
||||||
|
| --------------------------------- | --------------------- |
|
||||||
|
| `.hl-keyword` | Generic keywords |
|
||||||
|
| `.hl-keyword-control` | Control flow |
|
||||||
|
| `.hl-keyword-control-return` | return/break/continue |
|
||||||
|
| `.hl-function` | Function names |
|
||||||
|
| `.hl-function-builtin` | Built-in functions |
|
||||||
|
| `.hl-type` | Type names |
|
||||||
|
| `.hl-variable` | Variables |
|
||||||
|
| `.hl-variable-parameter` | Function parameters |
|
||||||
|
| `.hl-string` | String literals |
|
||||||
|
| `.hl-comment` | Comments |
|
||||||
|
| `.hl-comment-block-documentation` | Doc comments |
|
||||||
|
|
||||||
|
If a theme only defines `.hl-keyword`, it will apply to all keyword subtypes.
|
||||||
|
|
||||||
|
### Using a Theme
|
||||||
|
|
||||||
|
Themes are CSS files that define the color palette. Import a theme at the top of your stylesheet:
|
||||||
|
|
||||||
```css
|
```css
|
||||||
.keyword {
|
@import "path/to/theme.css";
|
||||||
color: #ff79c6;
|
|
||||||
}
|
|
||||||
.string {
|
|
||||||
color: #f1fa8c;
|
|
||||||
}
|
|
||||||
.function {
|
|
||||||
color: #50fa7b;
|
|
||||||
}
|
|
||||||
.comment {
|
|
||||||
color: #6272a4;
|
|
||||||
}
|
|
||||||
.number {
|
|
||||||
color: #bd93f9;
|
|
||||||
}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
The exact classes depend on the language grammar.
|
sukr uses [lightningcss](https://lightningcss.dev/) which inlines `@import` rules at build time, producing a single bundled CSS file.
|
||||||
|
|
||||||
|
### Available Themes
|
||||||
|
|
||||||
|
sukr includes several themes in the `themes/` directory:
|
||||||
|
|
||||||
|
- **default.css** — Dracula-inspired dark theme (batteries included)
|
||||||
|
- **dracula.css** — Classic Dracula colors
|
||||||
|
- **gruvbox.css** — Warm retro palette
|
||||||
|
- **nord.css** — Cool arctic colors
|
||||||
|
- **github_dark.css** — GitHub's dark mode
|
||||||
|
- **github_light.css** — GitHub's light mode
|
||||||
|
|
||||||
|
Copy the theme files to your project and import as shown above.
|
||||||
|
|
||||||
|
### Theme Structure
|
||||||
|
|
||||||
|
Themes use CSS custom properties for easy customization:
|
||||||
|
|
||||||
|
```css
|
||||||
|
:root {
|
||||||
|
--hl-keyword: #ff79c6;
|
||||||
|
--hl-string: #f1fa8c;
|
||||||
|
--hl-function: #50fa7b;
|
||||||
|
--hl-comment: #6272a4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hl-keyword {
|
||||||
|
color: var(--hl-keyword);
|
||||||
|
}
|
||||||
|
.hl-string {
|
||||||
|
color: var(--hl-string);
|
||||||
|
}
|
||||||
|
/* ... */
|
||||||
|
```
|
||||||
|
|
||||||
## Injection Support
|
## Injection Support
|
||||||
|
|
||||||
Some languages support injection—highlighting embedded languages. For example, Bash inside Nix strings:
|
Some languages support **injection**—highlighting embedded languages. For example, bash inside Nix strings:
|
||||||
|
|
||||||
```nix
|
```nix
|
||||||
stdenv.mkDerivation {
|
stdenv.mkDerivation {
|
||||||
@@ -193,6 +237,8 @@ stdenv.mkDerivation {
|
|||||||
|
|
||||||
Markdown also supports injection—code blocks inside markdown fences are highlighted with their respective languages.
|
Markdown also supports injection—code blocks inside markdown fences are highlighted with their respective languages.
|
||||||
|
|
||||||
|
Languages with injection support: Bash, C, CSS, Go, HTML, JavaScript, Markdown, Nix, Python, Rust, TOML, TypeScript, YAML.
|
||||||
|
|
||||||
## Fallback
|
## Fallback
|
||||||
|
|
||||||
Unknown languages fall back to plain `<code>` blocks without highlighting.
|
Unknown languages fall back to plain `<code>` blocks without highlighting.
|
||||||
|
|||||||
184
src/highlight.rs
184
src/highlight.rs
@@ -712,4 +712,188 @@ mod tests {
|
|||||||
|
|
||||||
assert!(html.contains("pkgs"));
|
assert!(html.contains("pkgs"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// === Hierarchical Scope Tests ===
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_scope_to_class_generates_hierarchical_names() {
|
||||||
|
// Verify the SCOPE_CLASSES static contains hierarchical class names
|
||||||
|
let classes = SCOPE_CLASSES.clone();
|
||||||
|
|
||||||
|
// Should have keyword hierarchy
|
||||||
|
assert!(classes.contains(&"hl-keyword"));
|
||||||
|
assert!(classes.contains(&"hl-keyword-control"));
|
||||||
|
assert!(classes.contains(&"hl-keyword-control-return"));
|
||||||
|
|
||||||
|
// Should have function hierarchy
|
||||||
|
assert!(classes.contains(&"hl-function"));
|
||||||
|
assert!(classes.contains(&"hl-function-builtin"));
|
||||||
|
|
||||||
|
// Should have comment hierarchy
|
||||||
|
assert!(classes.contains(&"hl-comment"));
|
||||||
|
assert!(classes.contains(&"hl-comment-block-documentation"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_hierarchical_scope_resolution_fallback() {
|
||||||
|
// Direct match should work
|
||||||
|
let kw = resolve_scope("keyword");
|
||||||
|
assert!(kw.is_some());
|
||||||
|
|
||||||
|
// Hierarchical match should find parent
|
||||||
|
let kw_ctrl = resolve_scope("keyword.control");
|
||||||
|
assert!(kw_ctrl.is_some());
|
||||||
|
|
||||||
|
// Deeper hierarchy should find closest ancestor
|
||||||
|
let kw_ctrl_ret = resolve_scope("keyword.control.return");
|
||||||
|
assert!(kw_ctrl_ret.is_some());
|
||||||
|
|
||||||
|
// Completely unknown should return None
|
||||||
|
assert!(resolve_scope("nonexistent.scope.here").is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_highlight_generates_hl_prefixed_classes() {
|
||||||
|
// Rust code that should produce keyword highlighting
|
||||||
|
let code = "fn main() { return 42; }";
|
||||||
|
let html = highlight_code(Language::Rust, code);
|
||||||
|
|
||||||
|
// Should contain hl-prefixed span classes
|
||||||
|
assert!(
|
||||||
|
html.contains("hl-"),
|
||||||
|
"Expected hl-prefixed classes in: {html}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_highlight_rust_keywords() {
|
||||||
|
let code = "pub fn foo() -> Result<(), Error> { Ok(()) }";
|
||||||
|
let html = highlight_code(Language::Rust, code);
|
||||||
|
|
||||||
|
// Should contain span elements
|
||||||
|
assert!(html.contains("<span"));
|
||||||
|
// Should have keyword classes (fn, pub, etc.)
|
||||||
|
assert!(html.contains("hl-keyword") || html.contains("class="));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_highlight_python_function_definition() {
|
||||||
|
let code = "def greet(name: str) -> str:\n return f'Hello, {name}'";
|
||||||
|
let html = highlight_code(Language::Python, code);
|
||||||
|
|
||||||
|
// Should contain the function name
|
||||||
|
assert!(html.contains("greet"));
|
||||||
|
// Should have span highlighting
|
||||||
|
assert!(html.contains("<span"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Injection Tests ===
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_injection_nix_with_bash() {
|
||||||
|
// Nix code with bash in multi-line strings (common pattern)
|
||||||
|
let code = r#"
|
||||||
|
stdenv.mkDerivation {
|
||||||
|
buildPhase = ''
|
||||||
|
echo "Building..."
|
||||||
|
make -j$NIX_BUILD_CORES
|
||||||
|
'';
|
||||||
|
}
|
||||||
|
"#;
|
||||||
|
let html = highlight_code(Language::Nix, code);
|
||||||
|
|
||||||
|
// Should produce HTML output with the nix content
|
||||||
|
assert!(html.contains("stdenv"));
|
||||||
|
assert!(html.contains("mkDerivation"));
|
||||||
|
// The injection should be handled (even if bash isn't fully highlighted, should not error)
|
||||||
|
assert!(html.contains("echo"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_injection_markdown_with_fenced_code() {
|
||||||
|
// Markdown with a fenced code block
|
||||||
|
let code = r#"# Header
|
||||||
|
|
||||||
|
Here is some code:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
fn main() {}
|
||||||
|
```
|
||||||
|
"#;
|
||||||
|
let html = highlight_code(Language::Markdown, code);
|
||||||
|
|
||||||
|
// Should handle the markdown content
|
||||||
|
assert!(html.contains("Header"));
|
||||||
|
// Fenced code block should be present (may have spans inside)
|
||||||
|
assert!(html.contains("fn") && html.contains("main"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_injection_html_with_script() {
|
||||||
|
// HTML with embedded JavaScript
|
||||||
|
let code = r#"
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<script>
|
||||||
|
const x = 42;
|
||||||
|
console.log(x);
|
||||||
|
</script>
|
||||||
|
</head>
|
||||||
|
</html>
|
||||||
|
"#;
|
||||||
|
let html = highlight_code(Language::Html, code);
|
||||||
|
|
||||||
|
// Should contain HTML structure
|
||||||
|
assert!(html.contains("html"));
|
||||||
|
assert!(html.contains("script"));
|
||||||
|
// JavaScript content should be present
|
||||||
|
assert!(html.contains("const"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_injection_html_with_style() {
|
||||||
|
// HTML with embedded CSS
|
||||||
|
let code = r#"
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<style>
|
||||||
|
.container { display: flex; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
</html>
|
||||||
|
"#;
|
||||||
|
let html = highlight_code(Language::Html, code);
|
||||||
|
|
||||||
|
// Should handle CSS injection
|
||||||
|
assert!(html.contains("style"));
|
||||||
|
assert!(html.contains("container"));
|
||||||
|
assert!(html.contains("flex"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Edge Cases ===
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_empty_input() {
|
||||||
|
let html = highlight_code(Language::Rust, "");
|
||||||
|
// Empty input should produce minimal output
|
||||||
|
assert!(html.is_empty() || html.len() < 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_whitespace_only_input() {
|
||||||
|
let html = highlight_code(Language::Rust, " \n\t\n ");
|
||||||
|
// Whitespace should be preserved
|
||||||
|
assert!(html.contains(' ') || html.contains('\n'));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_special_characters_escaped() {
|
||||||
|
let code = r#"let x = "<script>alert('xss')</script>";"#;
|
||||||
|
let html = highlight_code(Language::Rust, code);
|
||||||
|
|
||||||
|
// HTML special chars should be escaped
|
||||||
|
assert!(!html.contains("<script>"));
|
||||||
|
assert!(html.contains("<") || html.contains("script"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
90
themes/README.md
Normal file
90
themes/README.md
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
# Syntax Highlighting Themes
|
||||||
|
|
||||||
|
This directory contains CSS themes for sukr's syntax highlighting system.
|
||||||
|
|
||||||
|
## Available Themes
|
||||||
|
|
||||||
|
| Theme | Description |
|
||||||
|
| ------------------ | --------------------------- |
|
||||||
|
| `default.css` | Dracula-inspired dark theme |
|
||||||
|
| `dracula.css` | Classic Dracula colors |
|
||||||
|
| `gruvbox.css` | Warm retro palette |
|
||||||
|
| `nord.css` | Cool arctic colors |
|
||||||
|
| `github_dark.css` | GitHub's dark mode |
|
||||||
|
| `github_light.css` | GitHub's light mode |
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
1. **Copy a theme** to your project's static directory
|
||||||
|
2. **Import it** in your main CSS file:
|
||||||
|
|
||||||
|
```css
|
||||||
|
@import "themes/default.css";
|
||||||
|
```
|
||||||
|
|
||||||
|
sukr uses [lightningcss](https://lightningcss.dev/) which bundles `@import` rules at build time—your final CSS will be a single minified file.
|
||||||
|
|
||||||
|
## Customization
|
||||||
|
|
||||||
|
Themes use CSS custom properties for easy customization. Override any variable in your own CSS:
|
||||||
|
|
||||||
|
```css
|
||||||
|
@import "themes/default.css";
|
||||||
|
|
||||||
|
/* Override just the keyword color */
|
||||||
|
:root {
|
||||||
|
--hl-keyword: #e879f9;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Core Variables
|
||||||
|
|
||||||
|
All themes define these variables in `:root`:
|
||||||
|
|
||||||
|
| Variable | Description |
|
||||||
|
| --------------- | ---------------------- |
|
||||||
|
| `--hl-keyword` | Keywords, control flow |
|
||||||
|
| `--hl-string` | String literals |
|
||||||
|
| `--hl-function` | Function names |
|
||||||
|
| `--hl-comment` | Comments |
|
||||||
|
| `--hl-type` | Type names |
|
||||||
|
| `--hl-number` | Numeric literals |
|
||||||
|
| `--hl-variable` | Variables |
|
||||||
|
| `--hl-operator` | Operators |
|
||||||
|
|
||||||
|
## Hierarchical Scopes
|
||||||
|
|
||||||
|
sukr generates **hierarchical CSS classes** for fine-grained styling:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<span class="hl-keyword-control-return">return</span>
|
||||||
|
```
|
||||||
|
|
||||||
|
Themes can style at any level of specificity:
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* All keywords */
|
||||||
|
.hl-keyword {
|
||||||
|
color: var(--hl-keyword);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Just control-flow keywords */
|
||||||
|
.hl-keyword-control {
|
||||||
|
color: #ff79c6;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Just return/break/continue */
|
||||||
|
.hl-keyword-control-return {
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
If a specific class isn't styled, highlighting falls back up the hierarchy.
|
||||||
|
|
||||||
|
## Creating Custom Themes
|
||||||
|
|
||||||
|
Start with `default.css` and modify the `:root` variables to create your own color scheme. The class rules reference these variables, so changing values updates the entire theme.
|
||||||
|
|
||||||
|
## Note
|
||||||
|
|
||||||
|
These themes are **not bundled into the sukr binary**—they're provided as starting points. Copy what you need to your project and customize to match your site's design.
|
||||||
Reference in New Issue
Block a user