Highlight code blocks in article bodies

arborium (tree-sitter) renders each token as a flat <span class="hl-…">,
which the X4's no-descendant-selector stylesheet can still target.

The sanitizer strips class attributes, so language hints never reached
the database. annotate_hints now copies them onto <pre data-lang> before
readability and sanitizing: pre/code classes, Rouge and GitHub wrapper
divs, SyntaxHighlighter's brush:, and data-lang style attributes.
ammonia keeps only canonical values.

Highlighting runs at render time for the web article page and both EPUB
editions, so stored articles benefit too. Unhinted blocks are guessed
from content (JSON that parses, shebangs, leading <script>/<style>, then
betlang at >= 0.5). A borderline block also takes the article's prevailing
language when that language is among its top candidates. Anything else
stays plain, since wrong colours are worse than none.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
thalladaandClaude Opus 5.5 committed 2026-10-01 02:27:23 +00:00
1 parent 8d0516bedc
commit 78fc38e99d
7 files changed
+1744 -7

No files matched your search

+28 -3
View File
@@ -15,6 +15,7 @@ use std::time::Duration;
use futures::StreamExt;
use url::Url;
use crate::highlight::annotate_hints;
use crate::html::word_count;
use crate::images::normalize::{normalize_img_tags, prepare_for_readability};
use crate::images::refs::collect_image_urls;
@@ -338,7 +339,8 @@ fn readable_page(
html: &str,
url: &str,
) -> Result<(String, String, Option<String>, Option<String>), ExtractError> {
let html = prepare_for_readability(html);
// Readability strips classes, and with them code blocks' language hints.
let html = prepare_for_readability(&annotate_hints(html));
let config = dom_smoothie::Config {
max_elements_to_parse: 60_000,
..Default::default()
@@ -447,6 +449,7 @@ fn builder() -> ammonia::Builder<'static> {
.generic_attributes(HashSet::new())
.link_rel(None)
.strip_comments(true);
crate::highlight::keep_lang_attr(&mut b);
b
}
@@ -454,8 +457,10 @@ fn builder() -> ammonia::Builder<'static> {
///
/// Allowed: `p`, `h1`–`h4`, `ul`/`ol`/`li`, `blockquote`, `pre`, `code`, `em`,
/// `strong`, `a`, `img`, `figure`, `figcaption`, table basics, `hr`, `br`.
/// The one attribute kept beyond links and images is a code block's language,
/// as `<pre data-lang>` (see [`crate::highlight`]).
pub fn sanitize(html: &str) -> String {
builder().clean(html).to_string()
builder().clean(&annotate_hints(html)).to_string()
}
/// [`sanitize`], additionally rewriting relative `href`/`src` against `base_url`
@@ -464,7 +469,7 @@ pub fn sanitize_with_base(html: &str, base_url: &str) -> String {
match Url::parse(base_url) {
Ok(base) => builder()
.url_relative(ammonia::UrlRelative::RewriteWithBase(base))
.clean(html)
.clean(&annotate_hints(html))
.to_string(),
Err(_) => sanitize(html),
}
@@ -546,6 +551,26 @@ mod tests {
format!("<p>{}</p>", "lorem ".repeat(words))
}
#[test]
fn sanitize_keeps_code_block_languages_as_data_lang_only() {
let dirty = r#"<div class="language-ruby highlighter-rouge"><pre class="highlight"><code>puts 1</code></pre></div>
<pre><code class="language-rust" data-x="y">fn main() {}</code></pre>
<pre data-lang="&quot; onload=&quot;x">forged</pre>
<p data-lang="rust">not a block</p>"#;
let clean = sanitize(dirty);
assert!(
clean.contains(r#"<pre data-lang="ruby"><code>puts 1</code></pre>"#),
"{clean}"
);
assert!(
clean.contains(r#"<pre data-lang="rust"><code>fn main() {}</code></pre>"#),
"{clean}"
);
assert!(clean.contains("<pre>forged</pre>"), "{clean}");
assert!(clean.contains("<p>not a block</p>"), "{clean}");
assert!(!clean.contains("class="));
}
#[test]
fn sanitize_keeps_the_allowlist_and_drops_everything_else() {
let dirty = r#"