DEV Community

Zach Scroggins
Zach Scroggins

Posted on Originally published at zachscroggins.com on

Syntax Highlighting with sugar-high

The first version of this post was a gallery of code blocks. It showed off rehype-prism-plus, code titles, line highlighting, and a workaround for esbuild on Windows. Every one of those plugins is gone now. Code blocks are simpler, faster and match the site's theme in light and dark mode.

How a code block gets onto the page

  1. I write the code in Sanity Studio with the code input plugin, picking a language and adding an optional filename.
  2. Sanity stores the block as plain data: code, language, filename and any highlightedLines.
  3. A Portable Text component on the site passes the code to sugar-high during server rendering.
  4. sugar-high returns HTML with one class per token, and CSS variables color them.

No highlighter ships to the browser. The page arrives already colored.

The Studio side

The post body allows a code block next to text and images. Limiting the language list keeps the Studio dropdown short and matches what the site can highlight:

defineArrayMember({
  type: 'code',
  options: {
    withFilename: true,
    languageAlternatives: [
      {title: 'TypeScript', value: 'typescript'},
      {title: 'TSX', value: 'tsx'},
      {title: 'Shell', value: 'sh'},
      {title: 'Python', value: 'python'},
      // JSON, CSS, HTML, SQL, YAML, Markdown...
    ],
  },
}),
Enter fullscreen mode Exit fullscreen mode

The renderer

Here's a trimmed version of the component. The Studio stores language names like sh and tsx, and sugar-high's lang() helper turns them into its own names (shell, typescript). stegaClean removes the hidden characters Visual Editing adds to strings in preview, which would otherwise end up inside the code. The highlighted lines handle line highlighting, covered below.

export function CodeBlock({ value }: { value: CodeValue }) {
  if (!value.code) return null;
  const code = stegaClean(value.code);
  const language = stegaClean(value.language);
  const highlighted = new Set(value.highlightedLines ?? []);
  const html = highlight(code, {
    lang: language ? lang(language) : undefined,
    // sugar-high counts lines from 0; the Studio counts from 1.
    markLine(line) {
      if (highlighted.has(line.index + 1)) line.className += ' sh__line--highlighted';
    },
  });

  return (
    <figure data-slot="code-block">
      <figcaption>
        {value.filename}
        <CopyButton code={code} />
      </figcaption>
      <pre data-language={language}>
        <code dangerouslySetInnerHTML={{ __html: html }} />
      </pre>
    </figure>
  );
}
Enter fullscreen mode Exit fullscreen mode

The theme

sugar-high reads every color from a CSS custom property. I picked values that match the site's green and blue accents, then defined them again under .dark. Switching themes with next-themes recolors every code block, with no second stylesheet and no JavaScript.

:root {
  --sh-class: oklch(52% 0.18 259.8);
  --sh-property: oklch(50% 0.14 211.5);
  --sh-string: oklch(52% 0.17 151.7);
  --sh-keyword: oklch(52% 0.2 340);
  --sh-comment: oklch(55.1% 0.027 264.364);
}
.dark {
  --sh-class: oklch(70% 0.15 259.8);
  --sh-property: oklch(78.9% 0.154 211.53);
  --sh-string: oklch(79.2% 0.209 151.711);
  --sh-keyword: oklch(75% 0.17 340);
  --sh-comment: oklch(55.1% 0.027 264.364);
}
Enter fullscreen mode Exit fullscreen mode

Why sugar-high and not Shiki

Shiki uses the same TextMate grammars as VS Code, so it's the most accurate option, especially for unusual syntax. It also loads a grammar and a theme for every language you use. sugar-high is a small tokenizer with no dependencies. It covers JavaScript, TypeScript and JSX well, supports about thirty other languages, and runs anywhere JavaScript does, including a Server Component. Nearly everything I write about is TypeScript, so that's the right trade for this blog. If I start posting a lot of Rust or Go, I'll look at this again.

Examples

Shell

npm create sanity@latest -- --template clean --typescript --output-path my-website-cms
npm install next-sanity @sanity/image-url sugar-high
Enter fullscreen mode Exit fullscreen mode

TypeScript

A GROQ query from this site. defineQuery lets TypeGen generate the type of the result.

export const COMPANY_JOBS_QUERY = defineQuery(`
  *[_type == "company" && slug.current == $company][0]{
    _id,
    name,
    roles[]->{
      _id,
      title,
      accomplishments[]->{
        _id,
        accomplishment,
        "slug": slug.current,
        tags[]->{_id, name, "slug": slug.current}
      }
    }
  }
`);
Enter fullscreen mode Exit fullscreen mode

TSX

An example server component in the same style as this site's: it fetches through the shared cache boundary and renders a list.

export async function LatestPosts({ perspective, stega }: DynamicFetchOptions) {
  const { data: posts } = await cachedSanity({ query: POSTS_QUERY, perspective, stega });

  return (
    <ul className="space-y-4">
      {posts.slice(0, 3).map((post) => (
        <li key={post._id}>
          <Link href={`/blog/${post.slug}`} className="font-semibold hover:underline">
            {post.title}
          </Link>
        </li>
      ))}
    </ul>
  );
}
Enter fullscreen mode Exit fullscreen mode

Python

The 2021 version of this post had a long Pig Latin script. Here it is again, rewritten with type hints and a regular expression that leaves punctuation alone. The two highlighted lines do the real work.

import re

VOWELS = set("aeiouAEIOU")

def to_pig_latin(word: str) -> str:
    """Translate one word: leading consonants move to the end, then add ay."""
    if word[0] in VOWELS:
        return f"{word}yay"
    for i, letter in enumerate(word):
        if letter in VOWELS:
            return f"{word[i:]}{word[:i]}ay"
    return f"{word}ay"

def translate(sentence: str) -> str:
    # Swap each run of letters; spaces and punctuation stay where they are.
    return re.sub(r"[A-Za-z]+", lambda match: to_pig_latin(match.group()), sentence)

if __name__ == " __main__":
    print(translate(input("Sentence: ")))
Enter fullscreen mode Exit fullscreen mode

JSON

The Studio's scripts. typegen extracts the schema and regenerates the site's types in one step.

{
  "scripts": {
    "dev": "sanity dev",
    "build": "sanity build",
    "deploy": "sanity deploy",
    "typegen": "sanity schema extract --enforce-required-fields --force && sanity typegen generate"
  }
}
Enter fullscreen mode Exit fullscreen mode

Highlighting lines

Some examples only need you to look at a line or two. In the Studio, clicking a line number in the code input toggles that line, and Sanity saves the numbers in the block's highlightedLines array. There's no extra field to add to the schema.

On the site, sugar-high's markLine option runs once for every line. The renderer above adds a class to each line in that array (sugar-high counts lines from 0, the Studio from 1), and a few lines of CSS turn the class into a tinted bar with a green edge. The highlighted rules below are the ones doing it:

[data-slot='code-block'] code {
  display: grid;
  width: max-content;
  min-width: 100%;
}
[data-slot='code-block'] .sh__line {
  min-height: 1lh;
  margin-inline: -1.25rem;
  padding-inline: 1.25rem;
}
[data-slot='code-block'] .sh__line--highlighted {
  background-color: color-mix(in oklab, var(--color-green-500) 12%, transparent);
  box-shadow: inset 3px 0 0 var(--color-green-500);
}
Enter fullscreen mode Exit fullscreen mode

Making each line a grid row is what lets the bar run the full width of the block, even when a long line scrolls sideways. The negative margin pulls each line into the block's side padding, and min-height: 1lh keeps empty lines from collapsing. Dark mode uses the same rule with a lighter green.

Top comments (0)