Syntax Highlighting

Syntax highlighting lets a reader take in the shape of a program at a glance: strings in one color, comments in another, keywords in a third. But before anything else, one misconception has to be cleared away — syntax highlighting is not part of the Markdown specification. The spec only says that a fence may be followed by an info string, and that the first word of that string becomes a language- class name on the code element. What happens next is entirely up to the renderer. The same source file can look completely different on GitHub, in a documentation site, and in your own blog, and the only reason is which highlighter the renderer happens to use.

This page takes that apart. It explains the exact chain from the word you type to the color on the screen, gives you a table of the identifiers that are safe to use, describes what happens when the renderer has never heard of the language you named, and closes with the cases where the language label does more than you would expect — and the one place where the position of the code matters far more than its label.

Naming the Language

To ask for highlighting, write the language name right after the opening backticks:

```python
def hello():
    print("hi")
```
<pre><code class="language-python">def hello():
    print("hi")
</code></pre>

Look at the rendered element and you will find the entire Markdown contribution to this page: one class attribute, class="language-python". The parser took the first word of the info string, prefixed it with language-, and attached the result to the code element. That is all it did. The parser has no idea what Python is, and it never looks at the contents of the block.

Everything else happens afterwards, in a component usually called a highlighter. The renderer receives class="language-python", looks that name up in a grammar registry, and if it finds one, rewrites the code text into a set of <span> elements with color classes. The three implementations you are most likely to meet are Shiki (used by VitePress and many modern documentation sites), Prism (used by many Jekyll and Gatsby themes), and highlight.js. Each ships its own registry of grammars, and their contents overlap heavily but are not identical.

This is why “I set the language and nothing happened” is such a common experience. The identifier only produces a class name; if no grammar in the registry answers to that class name, the class is still there but nobody acts on it, and the code is displayed as plain text.

Common Language Identifiers

The accepted values are defined by the highlighter, not by any standard, so the table below covers the names that nearly every registry recognizes:

Content typeCommon identifiers
JavaScriptjs, javascript
TypeScriptts, typescript
Shell scriptsbash, sh, shell
Pythonpython, py
JSON / YAMLjson, yaml
HTML / CSShtml, css
Markdown itselfmarkdown, md
Plain text, highlighting offtext, plaintext

The aliases matter because the registries genuinely disagree about them. Prism and Shiki both accept js for JavaScript, but Shiki’s alias set is not identical to Prism’s, and a renderer that only loads a subset of grammars — many sites load just the languages their docs use — will happily reject a name that another site accepts. When you are unsure, the canonical name is the safer bet: javascript and python are the stable spellings, and it is the short aliases — js, py, md — that come and go between registries.

When a block genuinely has no syntax to speak of — a directory listing, a log excerpt, a terminal transcript — say so explicitly with text rather than leaving the info string empty. An empty info string is not a request for no highlighting; it is an absence of instruction, and some renderers will guess a default language and paint plain prose in whatever colors that grammar uses.

When the Language Is Not Recognized

The fallback behavior is not standardized, but the range of outcomes is small, and all of them are harmless:

  • The class name is still generated, but no grammar claims it, so the block renders as plain monospace text.
  • The renderer falls back to a default grammar, which sometimes looks better and sometimes looks wrong — a directory listing colored as if it were JavaScript.
  • A strict renderer raises an error at build time and refuses the page.

In every case the content is untouched. A wrong or missing identifier costs you colors, never the code. That asymmetry is worth remembering when you are unsure: it is always better to write text than to gamble on a name that might not exist.

When the Label Does More

Most renderers and documentation themes also offer a set of extras that are not part of Markdown, and they are driven by the extra words of the info string rather than by anything in the body:

  • File names. Many themes read a title="..." word and display it as a caption above the block. The language is still taken from the first word only.
  • Highlighted lines. A number of themes understand a range such as {1,3-4} and emphasize those lines. The braces are part of the info string, so they never reach the page as text.
  • Diff blocks. A block labeled diff is colored by the highlighter according to the leading - and + on each line. This is a grammar, not a Markdown feature.
- pnpm install
+ pnpm install --frozen-lockfile

Because all of these live in the info string, they are progressive enhancements. The same Markdown file moved to another site simply loses the caption, the highlighted lines, and the diff colors, and that is normal rather than broken.

MDX and the Difference Between a Label and a Location

Some publishing pipelines do not parse Markdown at all — they parse MDX, in which a Markdown document may also contain React-style components and JavaScript expressions. This changes the risk profile of a stray character in a surprising way, and it is worth being precise about it.

In MDX, a fenced code block is inert. Whatever you label it — jsx, html, mdx, anything — its contents are never parsed as markup or evaluated. The label is a note to the highlighter, never a switch that turns on parsing. The prose around the fence is the part that is parsed: a bare { there is read as a JavaScript expression, and a bare < is read as the start of a JSX element, which fails the build outright when the tag is never closed.

The practical rule is therefore the opposite of what the language label suggests. The label decides the colors; the location decides what the parser will try to do. Put anything that looks like markup inside a fence and it is safe, whatever you call it; put it in a paragraph and it is parsed, whatever you meant by it.

Common Pitfalls

Written asWhat actually resultsWhy
```pythonclass="language-python"The identifier took effect and the highlighter took over
```Pythonclass="language-Python"Identifiers are usually case sensitive
```js title="a.js"Still class="language-js"Only the first word is the language
No identifier at allNo class nameNo language was requested, so none was recorded
```textclass="language-text"An explicit request for no highlighting

Exercises

Rewrite the following plain text as Markdown. Requirements: represent the command line section and the JSON section as fenced code blocks with the correct language identifiers; and for the directory listing, which is not code in any language, use text to switch highlighting off explicitly.

First run pnpm install, then execute the following command:

pnpm build --filter @kuaiyizhi/kit-ui-astro

The output directory looks like this:

dist/
  index.mjs
  index.d.ts

Finally, check the scripts field in package.json:

{
  "scripts": {
    "build": "astro build"
  }
}
Syntax highlightingLanguage identifiersFenced code blocks

Approach:

Give every section its own fenced code block, and choose the identifier from the kind of content rather than from the program that produced it. The command line is a shell script, so it takes bash; the JSON fragment is JSON, so it takes json; the directory listing has no syntax at all, so it takes text, which is more predictable than leaving the info string empty and letting the renderer guess.

Steps:

  1. Open and close each block with three backticks, putting the closing fence on a line of its own, and write the identifier immediately after the opening fence.
  2. Put pnpm install and pnpm build --filter @kuaiyizhi/kit-ui-astro in one bash block. They are two consecutive shell commands, so they belong together and the reader can run them in order.
  3. Give the directory listing the identifier text. Left empty, a renderer may assume a default language and color dist/ and index.mjs as if they were keywords.
  4. Give the package.json fragment the identifier json, so the reader immediately sees that scripts holds a build entry.
  5. Check the result: the first block renders with class="language-bash", the last with class="language-json", and the directory listing with class="language-text".

Answer:

First run `pnpm install`, then execute the following command:

```bash
pnpm build --filter @kuaiyizhi/kit-ui-astro
```

The output directory looks like this:

```text
dist/
  index.mjs
  index.d.ts
```

Finally, check the `scripts` field in `package.json`:

```json
{
  "scripts": {
    "build": "astro build"
  }
}
```

Summary

You should now be able to say that syntax highlighting is decided by the renderer rather than by the Markdown specification, to explain exactly where the language- class name comes from, to pick a safe identifier for any common file type, and to recognize that an unrecognized name costs you colors and nothing else. You should also be able to tell the difference between a label that the highlighter reads and an extension that the theme reads, and to understand why, in an MDX pipeline, the position of a fragment matters more than the language you call it. The next time you paste code into a document, you will reach for the identifier out of habit — and you will know that if the site renders it in flat gray, the code is still perfectly readable.

Found an issue with this content?

Tell us what could be improved.