Skip to content

Fix/code block language label - #363

Closed
sonthicse wants to merge 2 commits into
kepano:mainfrom
sonthicse:fix/code-block-language-label
Closed

Fix/code block language label#363
sonthicse wants to merge 2 commits into
kepano:mainfrom
sonthicse:fix/code-block-language-label

Conversation

@sonthicse

Copy link
Copy Markdown

Symptom

A code block on a documentation site rendered like this:

```
shellsession
user@host[~]$ for i in $(cat subdomains);do host $i | grep "has address";done
```

Two separate defects in one block:

  1. The fence lost its language, so no syntax highlighting downstream.
  2. The language label was pulled into the code. Copying the block pastes shellsession as the first command.

Expected:

```shellsession
user@host[~]$ for i in $(cat subdomains);do host $i | grep "has address";done
```

Source HTML

<pre dir="ltr" class="language-shellsession group relative px-4 my-4 mb-6">
    <span class="float-end opacity-40 right-5 absolute top-0 m-2 text-xs">shellsession</span>
<code>user@host[~]$ for i in $(cat subdomains);do host $i | ...</code>
</pre>

Three things matter here, and none of them are specific to one site:

  • language-shellsession sits on the <pre>, not the <code>.
  • The label is a <span> inside the <pre> but outside the <code>, positioned with utility classes.
  • Every other class is a Tailwind utility, describing appearance rather than role.

Cause 1 — the language whitelist rejects explicit declarations

getCodeLanguage() matched language-shellsession and extracted shellsession, then dropped it because CODE_LANGUAGES does not contain that name:

if (match && match[1] && CODE_LANGUAGES.has(match[1].toLowerCase())) {
    return match[1].toLowerCase();
}

class="language-X" is the CommonMark / Prism / highlight.js convention — the page is stating the language outright. Filtering that through a fixed list means every unlisted language needs a library patch. shellsession, liquid, css, diff, console, hcl and jsonc are all absent today.

Note the asymmetry: data-lang / data-language return immediately without consulting the whitelist. Only the class branch filters, which looks unintended.

The whitelist does not merely lose languages — it can pick the wrong one. Because the resolver walks up the ancestors when it finds nothing, a rejected value on the element falls through to whatever an ancestor declares. On the Docusaurus code-block docs, two blocks were tagged markdown when their content was CSS and a diff respectively.

Cause 2 — chrome is detected by class name

The UI-chrome filter only matches on names:

el.querySelectorAll('[class*="header"], [class*="toolbar"], [class*="titlebar"], [class*="title-bar"]')

The label above carries float-end opacity-40 right-5 absolute top-0 m-2 text-xs. Nothing matches, so it survives and extractStructuredText() folds its text into the code. Guessing at class names cannot work against utility-first CSS, where names describe appearance rather than role.

The fix

Two independent commits.

1. Trust explicit declarations, keep checking guesses. HIGHLIGHTER_PATTERNS is split. language- / lang- / syntax- / brush- state the language, so the value is validated by shape. code-X, X-snippet and bare class names are guesses that match layout classes too easily, so they still consult the whitelist. The whitelist itself is unchanged.

Shape alone is not enough on an arbitrary ancestor, where language-switcher or syntax-highlighter is ordinary markup rather than a declaration, so shape-based resolution applies only to the matched block and to pre/code elements; ancestors above that keep going through the whitelist. Validation allows at most one hyphen or underscore, which keeps multi-word layout classes out — language-in-header-enabled and syntax-highlighter-line-number both occur in this repo's fixtures. Tokens meaning "no language" and BCP 47 subtags are rejected, so neither lang-en nor lang-zh-hans becomes a fence tag, and Prism's diff-highlight language-diff-<lang> resolves to diff.

2. Remove chrome by structure, not by name. Once a <pre> contains a <code>, the code lives in that <code>; short div/span siblings of it are chrome. This holds no matter how the classes are named, and requiring the <code> to exist leaves blocks that put content directly in the <pre> untouched.

Removing text is worse than leaving a stray label, so a sibling is kept whenever it might be code: when it repeats a sibling with the same tag and class, since one element per line is a rendering pattern while a label appears once (Chroma marks its line spans cl, with no line in the name); when it is or contains a line element; when it holds code, a table or an image; and when its text is pure punctuation, as shell prompt markers are.

Testing

Two fixtures, each failing before its own commit and passing after:

  • codeblocks--unlisted-language-class — explicit declaration of a language the whitelist does not know, no chrome. Isolates cause 1.
  • codeblocks--tailwind-lang-label — the full case above. Needs both fixes.

All 443 pre-existing tests are unchanged, under both linkedom and jsdom. Bundle size grows 0.5 KB gzip.

Spot-checked against live pages, diffing output before and after:

Page Result
Hugo, Astro Starlight, Docker, Stripe, Tailwind byte-identical
Jekyll docs 11 fences gained liquid / css; no content changed
Terraform tutorials 12 fences gained shellsession
Docusaurus code-blocks 2 fences corrected from markdown to css and diff

Happy to split this into two PRs if you prefer — either commit stands alone.

sonthicse and others added 2 commits August 11, 2026 20:38
getCodeLanguage() matched `language-shellsession` and then discarded it,
because `shellsession` is not in the hardcoded CODE_LANGUAGES set, leaving the
fence with no language at all. The whitelist can also produce a wrong
language: once the value is dropped, the ancestor walk keeps going and picks
up whatever a wrapper declares. On the Docusaurus code-block docs two blocks
were tagged `markdown` while holding CSS and a diff.

`language-`/`lang-`/`syntax-`/`brush-` prefixes are declarations by the page
(CommonMark, Prism, highlight.js), so the value is now validated by shape
rather than by membership, and a closed list no longer has to keep up with
every highlighter. Ambiguous patterns (`code-X`, `X-snippet`, bare class
names) still check the whitelist — they match layout classes too easily.

Shape alone is not enough on an arbitrary ancestor, where `language-switcher`
or `syntax-highlighter` is ordinary markup rather than a declaration, so it
applies only to the matched block and to pre/code elements; ancestors above
that keep going through the whitelist. Validation allows at most one hyphen or
underscore, which keeps multi-word layout classes out — `language-in-header-enabled`
and `syntax-highlighter-line-number` both occur in this repo's fixtures.
Tokens meaning "no language" and BCP 47 subtags are rejected, so neither
`lang-en` nor `lang-zh-hans` becomes a fence tag, and Prism's diff-highlight
`language-diff-<lang>` resolves to `diff`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Language labels and copy buttons were only removed by class name (`header`,
`toolbar`, `titlebar`). Sites built on utility-first CSS name classes after
appearance rather than role, so a label reading
`class="float-end opacity-40 absolute top-0"` matched nothing and its text was
extracted into the code content — a reader copying the block pastes the
language name as the first command.

When a <pre> already contains a <code>, the code lives in that <code>; short
div/span siblings of it are chrome. That holds regardless of how the classes
are named, and requiring the <code> to exist leaves blocks that put content
directly in the <pre> untouched.

Removing text is worse than leaving a stray label, so a sibling is kept
whenever it might be code: when it repeats a sibling with the same tag and
class, since one element per line is a rendering pattern while a label appears
once (Chroma marks its line spans "cl", with no "line" in the name); when it
is or contains a line element; when it holds code, a table or an image; and
when its text is pure punctuation, as shell prompt markers are.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@sonthicse sonthicse closed this by deleting the head repository Aug 12, 2026
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.

1 participant