ember-shiki

Embed code snippets with pretty syntax highlighting in Ember.js, powered by Shiki

Ember-shiki is an addon which makes using shiki in Ember a breeze. It offers a drop-in component to get syntax highlighting for code. This is perfect for documentation sites (such as this page), blogs, or wherever you need pretty formatted code.

  • โœ… TypeScript and Glint ready
  • ๐Ÿงต V2 addon format
  • ๐Ÿš€ FastBoot support
  • ๐Ÿ“ฆ Lazy loaded packages โ€” minimal impact on initial load
  • #๏ธโƒฃ Syntax highlighting for 170+ languages โ€” including `.gjs` and `.gts`
  • ๐ŸŽจ Theme support: 29 included themes โ€” load any external
  • ๐Ÿ”ข Line number support
  • ๐Ÿ”ค Custom fonts โ€” including font ligature support
  • ๐Ÿ“‹ Copy code to clipboard
  • ๐Ÿท๏ธ Code block naming
  • ๐Ÿ—‚๏ธ Group code blocks with tabs
  • โœจ Line highlighting
  • ๐Ÿ–Œ๏ธ Stylable via CSS variables

Getting started

Installation

npm yarn pnpm
pnpm install ember-shiki
npm install ember-shiki
yarn add ember-shiki

Make sure you meet the following requirements:

  • Embroider or ember-auto-import v2
  • Ember.js v4.4 or above
  • Node.js v16 or above

Usage

After installing the addon, the CodeBlock component can be used to syntax highlight a snippet. Simply pass the code as a string and set the language which should be used for parsing.

console.log('hello world');
Template tag (.gjs/.gts) Loose mode (.hbs)
import { CodeBlock } from 'ember-shiki';

<template>
  <CodeBlock
    @code="console.log('hello world');"
    @language="js"
  />
</template>
<CodeBlock
  @code="console.log('hello world');"
  @language="js"
/>

Glint types

This addon ships Glint types in a template registery. To use them in loose mode, register them in your global types declaration.

types/global.d.ts
import '@glint/environment-ember-loose';
import type EmberShikiRegistery from 'ember-shiki/template-registry';

declare module '@glint/environment-ember-loose/registry' {
  export default interface Registry extends EmberShikiRegistery, /* ... */ {
    // local entries
  }
}

Configure (optional)

By default all languages and themes are lazy loaded. Shiki loads the nord theme by default if no theme is specified. This docs website uses github-dark .

The global defaults can be adjusted in the environment.js file by adding an entry for ember-shiki .

app/config/environment.js
module.exports = function (environment) {
  const ENV = {
    // ...
    'ember-shiki': {
      defaultLanguages: ['gjs', 'gts', 'css'],
      defaultThemes: ['github-dark'],
    },
  };
  // ...
};

Shiki languages, themes, and WASM are loaded from a CDN by default. If you wish to change the CDN, or self-host these resources, you can set a custom cdnUrl in the environment config.


Options

Language

Shiki supports 170+ languages out of the box. Simply pass in the language identifier to the code block component to enable syntax highlighting for that grammar.

export type foo = 'bar' | null; // For example, TypeScript!
<CodeBlock
  @code="export type foo = 'bar' | null; // For example, TypeScript!"
  @language="ts"
/>

Theme

Shiki ships with 29 themes built in. These can be used out of the box.

Select a theme to preview:

const foo = (bar) => { bar === 1 };
<CodeBlock
  @code="const foo = (bar) => { bar === 1 };"
  @language="js"
  @theme="github-dark"
/>

Code block name

A code block can be given a name to clarify it's purpose or type.

example-name.js
const foo = (bar) => { bar === 1 };
<CodeBlock
  @code="const foo = (bar) => { bar === 1 };"
  @language="js"
  @name="example-name.js"
/>

Copy to clipboard

A copy to clipboard button is shown on hover by default. If you want to change this behaviour you can set the showCopyButton argument flag.

const foo = (bar) => { bar === 1 };
<CodeBlock
  @code="const foo = (bar) => { bar === 1 };"
  @language="js"
  @showCopyButton={{false}}
/>

This default can also be changed globally by setting the showCopyButton flag in the environment config.

Code groups

Code blocks can be grouped with tabs. Every tab has it's own isolated code block, meaning you can mix languages, themes, or basically any config option.

foo-bar.css foo-bar.hbs
body { color: red; }
<div>Hello {{name}}</div>
<CodeGroup @names={{array "foo-bar.css" "foo-bar.hbs"}} as |Tab|>
  <Tab @name="foo-bar.css">
    <CodeBlock
      @code="body { color: red; }"
      @language="css"
    />
  </Tab>
  <Tab @name="foo-bar.hbs">
    <CodeBlock
      @code={{"<div>Hello {{name}}</div>"}}
      @language="hbs"
    />
  </Tab>
</CodeGroup>

Line numbers

Code blocks have built in support for line numbering.

// Line numbers!
function foo(x, y) {
  return x + y;
}
<CodeBlock
  @code="// Line numbers!
function foo(x, y) {
  return x + y;
}"
  @language="js"
  @showLineNumbers={{true}}
/>

The start line number can also be adjusted in case your snippet doesn't start at line 1.

// Line numbers - with custom offset!
function foo(x, y) {
  return x + y;
}
<CodeBlock
  @code="// Line numbers - with custom offset!
function foo(x, y) {
  return x + y;
}"
  @language="js"
  @showLineNumbers={{true}}
  @lineNumberStart={{12}}
/>

Line numbers are disabled by default. If you wish to enable them for all code blocks at once, you can set the showLineNumbers flag in the environment config to true .

Line highlighting

Specific lines, or ranged of lines, can be highlighted. A highlight type can be highlight , add , or remove .

Here's a demonstration on a code example of the Embroider readme explaining how to set it up.

module.exports = function (defaults) {
  const app = new EmberApp(defaults, {});
  return app.toTree();
  const { Webpack } = require('@embroider/webpack');
  return require('@embroider/compat').compatBuild(app, Webpack);
}
<CodeBlock
  @code={{"module.exports = function (defaults) {
  const app = new EmberApp(defaults, {});
  return app.toTree();
  const { Webpack } = require('@embroider/webpack');
  return require('@embroider/compat').compatBuild(app, Webpack);
}"}}
  @language="js"
  @lineHighlights={{array
    (hash start=3 type="remove")
    (hash start=4 end=5 type="add")
  }}
/>

Loading state

Shiki highlights code asynchronously. The code block component offers a named block to render a custom loading state. By default, the code is rendered without syntax highlighting to prevent scroll jumps.

Besides a loading named block, there's also a callback function which is invoked when the rendering is complete.

<CodeBlock
  @code="const foo = (bar) => { bar === 1 };"
  @language="js"
  @onCodeHighlighted={{this.yourLoadedStateCallback}}
>
  <:loading>
    Custom loading state here ...
  </:loading>
</CodeBlock>

Styling

The components in this addon can be styled by overriding CSS variables. For example, any external font can be loaded and used. Even fonts with ligature support work out of the box.

const foo = (bar) => { bar === 1 };
<CodeBlock
  @code="const foo = (bar) => { bar === 1 };"
  @language="js"
  style="--ember-shiki-font: 'JetBrains Mono'"
/>

Here is a list of CSS variables which are currently defined:

:root {
  --ember-shiki-padding-x: 24px;
  --ember-shiki-padding-y: 20px;
  --ember-shiki-border-radius: 6px;
  --ember-shiki-background-color: #161b22;
  --ember-shiki-copy-background-color: #161b22;
  --ember-shiki-line-number-color: rgb(115 138 148 / 40%);
  --ember-shiki-font: ui-monospace, sfmono-regular, "SF Mono", menlo, monaco,
    consolas, "Liberation Mono", "Courier New", monospace;
  --ember-shiki-line-number-start: 1;
  --ember-shiki-line-height: 1.7;
  --ember-shiki-font-size: 0.875rem;
  --ember-shiki-icon-copy: url('data:image/svg+xml,<svg fill="none" stroke="rgba(128,128,128,1)" stroke-width="1.5" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="M15.666 3.888A2.25 2.25 0 0013.5 2.25h-3c-1.03 0-1.9.693-2.166 1.638m7.332 0c.055.194.084.4.084.612v0a.75.75 0 01-.75.75H9a.75.75 0 01-.75-.75v0c0-.212.03-.418.084-.612m7.332 0c.646.049 1.288.11 1.927.184 1.1.128 1.907 1.077 1.907 2.185V19.5a2.25 2.25 0 01-2.25 2.25H6.75A2.25 2.25 0 014.5 19.5V6.257c0-1.108.806-2.057 1.907-2.185a48.208 48.208 0 011.927-.184"></path></svg>');
  --ember-shiki-icon-copied: url('data:image/svg+xml,<svg fill="none" stroke="rgba(128,128,128,1)" stroke-width="1.5" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="M11.35 3.836c-.065.21-.1.433-.1.664 0 .414.336.75.75.75h4.5a.75.75 0 00.75-.75 2.25 2.25 0 00-.1-.664m-5.8 0A2.251 2.251 0 0113.5 2.25H15c1.012 0 1.867.668 2.15 1.586m-5.8 0c-.376.023-.75.05-1.124.08C9.095 4.01 8.25 4.973 8.25 6.108V8.25m8.9-4.414c.376.023.75.05 1.124.08 1.131.094 1.976 1.057 1.976 2.192V16.5A2.25 2.25 0 0118 18.75h-2.25m-7.5-10.5H4.875c-.621 0-1.125.504-1.125 1.125v11.25c0 .621.504 1.125 1.125 1.125h9.75c.621 0 1.125-.504 1.125-1.125V18.75m-7.5-10.5h6.375c.621 0 1.125.504 1.125 1.125v9.375m-8.25-3l1.5 1.5 3-3.75"></path></svg>');
}

Advanced

Custom grammar

Shiki supports loading custom language grammar definitions in the TextMate format. This can be useful if a new language hasn't landed in the base package yet, or if you want to use a custom definition for an existing language.

A great use case for this is Ember's template syntax. Ember uses Glimmer to render component with templates defined in a .hbs file. Glimmer grammar, however, is more extensive than handlebars. If you want to opt into Glimmer syntax parsing, you can load custom Glimmer grammar for the handlebars language definition. Here's an example of how you could do it:

app/routes/application.js
import Route from '@ember/routing/route';
import { service } from '@ember/service';

export default class ApplicationRoute extends Route {
  @service shiki;

  async beforeModel() {
    await this.loadCustomGrammar();
  }

  async loadCustomGrammar() {
    // Shiki has to be initialized before the highlighter is available
    await this.shiki.initialize.perform();
    // Get custom grammar
    const grammarTextmateDefinition = 'https://raw.githubusercontent.com/IgnaceMaes/glimmer-textmate-grammar/main/handlebars.tmLanguage.json';
    const glimmerHandlebarsGrammar = await fetch(grammarTextmateDefinition);
    const glimmerHandlebars = {
      id: 'handlebars',
      path: '',
      scopeName: 'text.html.handlebars',
      grammar: await glimmerHandlebarsGrammar.json(),
      aliases: ['hbs'],
    };
    // Load embedded languages first
    await this.shiki.loadLanguageAndEmbedded('js');
    await this.shiki.loadLanguageAndEmbedded('css');
    // Finally, register the custom language to the Shiki highlighter
    await this.shiki.highlighter?.loadLanguage(glimmerHandlebars);
  }
}

Note: this is just an example way of how it's made possible, but not the recommended approach per se. In the future a more ergonomic, officially recommended way might be added.

Custom theme

Shiki ships with 29 themes available by default, but any VS Code theme can be loaded dynamically. Simply register the theme config JSON and pass the theme name to the CodeBlock component.

The following example shows a custom theme Night Owl :

app/routes/application.js
import Route from '@ember/routing/route';
import { service } from '@ember/service';

export default class ApplicationRoute extends Route {
  @service shiki;

  async beforeModel() {
    await this.loadCustomTheme();
  }

  async loadCustomTheme() {
    // Shiki has to be initialized before the highlighter is available
    await this.shiki.initialize.perform();
    // Fetch custom theme
    const nightOwlTheme = await fetch(
      'https://raw.githubusercontent.com/sdras/night-owl-vscode-theme/main/themes/Night%20Owl-color-theme.json',
    );
    const nightOwlThemeJson = await nightOwlTheme.json();
    // Make sure the name is set, as this is the value to be passed to the CodeBlock theme argument
    nightOwlThemeJson.name = 'Night Owl';
    await this.shiki.highlighter?.loadTheme(nightOwlThemeJson);
  }
}

Note: this is just an example way of how it's made possible, but not the recommended approach per se. In the future a more ergonomic, officially recommended way might be added.

Fastboot

The CodeBlock component automatically detects Fastboot if it's used in the app and makes sure the rendering awaits until the highlighting is completed.

Shiki makes use of fetch which is not available in the runtime environment of Fastboot by default. To make it available, set the globals in your fastboot config. Note that fetch is only built into Node.js starting from v18. If you want to support older versions you can install and import node-fetch .

config/fastboot.js
module.exports = function () {
  return {
    buildSandboxGlobals(defaultGlobals) {
      return Object.assign({}, defaultGlobals, {
        fetch: fetch,
        AbortController,
        ReadableStream:
          typeof ReadableStream !== 'undefined'
            ? ReadableStream
            : require('node:stream/web').ReadableStream,
        WritableStream:
          typeof WritableStream !== 'undefined'
            ? WritableStream
            : require('node:stream/web').WritableStream,
        TransformStream:
          typeof TransformStream !== 'undefined'
            ? TransformStream
            : require('node:stream/web').TransformStream,
        Headers: typeof Headers !== 'undefined' ? Headers : undefined,
      });
    },
  };
};

Prember for pre-rendering the app to static html is also supported. Make sure to use at least version ^2.0.0 so the fastboot.js config is correctly applied.