Skip to content

Repository files navigation

remark-enhance-codeblock

a remark plugin to enhance markdown code block or group of code blocks

MIT npm.badge codecov

Installation

Install with a package manager:

pnpm add -D remark-enhance-codeblock # or via npm, yarn, ...

In browser from esm.sh:

import remarkEnhanceCodeblock from 'https://esm.sh/remark-enhance-codeblock';

Usage

Important

This package assumes remark-rehype follows somewhere in the unified pipeline.

The central idea is to turn a markdown such as:

> [!CODEGROUP]
>
> ```html #title="index.html"
> <!doctype html>
> <html>
> 	<head>
> 		<link rel="stylesheet" href="./style.css" />
> 		<script module src="./script.js"></script>
> 	</head>
> 	<body>
> 		<main>Hello world!</main>
> 	</body>
> </html>
> ```
>
> ```css #title="style.css"
> main {
> 	color: green;
> }
> ```
>
> ```js #title="script.js"
> console.log('Hello world!');
> ```

...to the following UI:

demo UI

To achieve this, follow these steps:

  1. Add remark-enhance-codeblock to the unified pipeline:

    import remarkEnhanceCodeblock from 'remark-enhance-codeblock';
    import rehypeStringify from 'rehype-stringify';
    import remarkParse from 'remark-parse';
    import remarkRehype from 'remark-rehype';
    import { unified } from 'unified';
    
    const file = await unified()
    	.use(remarkParse)
    	.use(remarkEnhanceCodeblock)
    	.use(remarkRehype)
    	.use(rehypeStringify)
    	.process(input);
    
    console.log(String(file));

    Customisation is described in the Plugin Options section.

  2. Add the necessary CSS where applicable:

    @import 'remark-enhance-codeblock/styles/app.css';
    /* or via CDN: */
    @import 'https://esm.sh/remark-enhance-codeblock/styles/all.css';

    Several different CSS strategies are listed in the CSS Strategies section.

  3. Add progressive enhancement with JS when and where possible:

    import { enhanceCodeblock } from 'remark-enhance-codeblock/client';
    /* or via CDN: */
    import { enhanceCodeblock } from 'https://esm.sh/remark-enhance-codeblock/client';
    
    // call where appropriate
    enhanceCodeblock();

    Options to enhanceCodeblock are listed in the Client Options section.

Syntax

Individual Code Block

```lang #title="Some heading or filename.ext" #trim="both" #file-icon
console.log('Hello world!');
```

Enhancement Attributes

Enhancement attributes are processed by the plugin and marked with a # prefix to distinguish from regular HTML attributes. All attributes listed below are optional.

Attribute Type Inherits Supported Values Description
#title string none title to display in the header
#trim string from Plugin Options 'both', 'start', 'end', 'none' trim strategy for the code content
#file-icon boolean from Group, if any, and Plugin Options true, false, blank means true whether to show file icon preceding title

Passing Additional HTML Attributes

Regular HTML attributes may be added to the meta string of the code element and will be passed on to the final HTML. Specifying an attribute will replace any existing one by default. Alternatively, one may add a ^ prefix to prepend, or $ to append to existing attributes.

For example, to append some custom classes, one can do:

```js #title="script.js" $class="custom-codeblock"
console.log('Hello world!');
```

Important

HTML attributes will be passed onto the .codeblock element, that is, the direct parent of <pre>.

Group of Code Blocks

A group of code blocks is wrapped in a blockquote with a > [!CODEGROUP] marker.

> [!CODEGROUP]
>
> ```lang
> first tab
> ```
>
> ```lang
> second tab
> ```
>
> any non-code element will be stripped

One typical pattern is shown in Usage section, where several different code blocks are grouped as related files.

Attributes

Much like in individual code blocks, additional attributes may be specified on the group node. The meta string here, however, must be wrapped in backticks and follow directly after the marker.

> [!CODEGROUP] `$class="custom-codeblock-group" data-boolean`
> ...

Important

HTML Attributes will be passed onto the .codeblock-group element.

The only supported enhancement attribute for group at this time is #file-icon. For example a group of code blocks may not necessarily represent files, but different options, in which case showing file icons may not be appropriate:

> [!CODEGROUP] `#file-icon=false`
>
> ```bash #title="npm"
> npm install --save-dev remark-enhance-codeblock
> ```
>
> ```bash #title="pnpm"
> pnpm add -D remark-enhance-codeblock
> ```
>
> ```bash #title="yarn"
> yarn add -D remark-enhance-codeblock
> ```

demo no file icon

Plugin Options

Trim Strategy

The global trim strategy for code content:

type RemarkEnhanceCodeblockTrimStrategy = 'start' | 'end' | 'both' | 'none';

// default:
remarkEnhanceCodeblock({ trim: 'both' });

#trim attribute on individual code blocks will take precedence over the global trim strategy.

Texts and Internationalisation

Text labels for buttons and switches in remark-enhance-codeblock. Even though the typing is permissive (every field is optional), it is recommended, when supporting a different language, to provide all labels for a consistent user experience.

export interface RemarkEnhanceCodeblockIntl {
	copy?: {
		/** aria-label for the copy button */
		default?: string;
		/** aria-label for the copy button after code has been copied */
		copied?: string;
	};
	fullscreen?: {
		/** aria-label for the button that requests fullscreen */
		open?: string;
		/** aria-label for the button that exits fullscreen */
		exit?: string;
	};
	/** aria-label for the collapse switch */
	collapse?: string;
}

// default:
remarkEnhanceCodeblock({
	intl: {
		copy: {
			default: 'Copy',
			copied: 'Copied',
		},
		fullscreen: {
			open: 'Open fullscreen',
			exit: 'Exit fullscreen',
		},
		collapse: 'Collapse',
	},
});

Icon Classes

By default, icons are rendered using mask-image with URL-encoded SVG in CSS. Icon sources are from the Phosphor Icons set.

Icon classes may be customised, for example, to use different icon sources or tap into some existing design system / CSS framework such as Tailwind (e.g. using phosphor-icons-tailwindcss).

export interface RemarkEnhanceCodeblockIconClasses {
	/** icon class names for the copy button */
	copy?: {
		default?: string;
		copied?: string;
	};
	/** icon class names for the fullscreen button */
	fullscreen?: {
		open?: string;
		exit?: string;
	};
	/** icon class names for the collapse switch */
	collapse?: string;
	/** icon class names for language file icon before the title if provided */
	file?: (lang?: string | null | undefined) => string;
}

// default:
remarkEnhanceCodeblock({
	iconClasses: {
		copy: {
			default: 'i i-clipboard',
			copied: 'i i-clipboard-text',
		},
		fullscreen: {
			open: 'i i-corners-out',
			exit: 'i i-corners-in',
		},
		collapse: 'i i-caret-up',
		file: (lang) => `i i-file${lang ? ` i-file-${lang}` : ''}`;
	},
});

When customising icon classes, consider switching to the appropriate CSS strategy, as mentioned in the CSS Strategies section, for a smaller bundle size.

Group Blockquote Marker

The marker for group, i.e > [!<marker>] may be customised, even though this is typically not necessary, unless for some further remark transformation.

type RemarkEnhanceCodeblockGroupMarker = `!${string}`;

// default:
remarkEnhanceCodeblock({ groupBlockquoteMarker: '!CODEGROUP' });

Note

Blockquote transformation are made possible using remark-transform-blockquote.

MDAST Custom Node Type

When transforming the code element, some non-standard nodes are added to the syntax tree, marked for HTML output with rehype by data.hProperties and data.hName. If additional transformation is necessary, the node type may be customised:

type RemarkEnhanceCodeblockNodeType = string;

// default
remarkEnhanceCodeblock({ nodeType: 'enhance-codeblock' });

CSS Strategies

The stylesheet mentioned in Usage section, .../styles/app.css, is the maximal bundle that includes all features:

/* CSS for individual codeblock */
@import url('./bare/base.css') layer(enhance-code-block.base);

/* CSS for group of codeblocks */
@import url('./bare/group.css') layer(enhance-code-block.group);

/* CSS for SVG-in-CSS icon rendering strategy, and sources for icons used in actions */
@import url('./bare/icons.css') layer(enhance-code-block.icons);

/* sources for common file icons */
@import url('./bare/file-icons.css') layer(enhance-code-block.icons);

If file icons are turned off or provided via some custom solution, use the no-file-icons entry, which drops ./bare/file-icons.css:

@import 'remark-enhance-codeblock/styles/no-file-icons.css';
/* or via CDN: */
@import 'https://esm.sh/remark-enhance-codeblock/styles/no-file-icons.css';

If icons in actions are also provided via some custom solution, use the no-icons entry, which drops both ./bare/icons.css and ./bare/file-icons.css:

@import 'remark-enhance-codeblock/styles/no-icons.css';
/* or via CDN: */
@import 'https://esm.sh/remark-enhance-codeblock/styles/no-icons.css';

If the Group feature is not used, ./bare/group.css can also be dropped for the most minimal bundle:

@import url('remark-enhance-codeblock/styles/bare/base.css') layer(enhance-code-block.base);
/* or via CDN: */
@import 'https://esm.sh/remark-enhance-codeblock/styles/bare/base.css'
	layer(enhance-code-block.base);

CSS Layers

Notice that the default entries imports "bare" CSS files into different CSS layers. This is my preferred approach; but they can also be imported directly to the default layer:

@import url('remark-enhance-codeblock/styles/bare/base.css');

CSS Variables

The stylesheets rely heavily on CSS Custom Properties to allow customisation. They are named in a --c-* pattern and each has a default counterparts --cd-*. For example:

.codeblock-content {
	max-height: var(--c-max-height, var(--cd-content-max-height));
}

Specify any of the following variables where appropriate, typically on :root or where the design system lives. For example:

:root {
	--c-max-height: 25rem;
}
CSS Variable Default Counterpart Default Value
--c-font-size --cd-font-size 1rem
--c-font-size-fullscreen --cd-font-size-fullscreen calc(var(--c-font-size, var(--cd-font-size)) * 1.25)
--c-font-family --cd-font-family monospace
--c-margin-block --cd-margin-block 1.5em
--c-padding-block --cd-padding-block 0.75em
--c-padding-inline --cd-padding-inline 1em
--c-border-width --cd-border-width 1.5px
--c-border-style --cd-border-style solid
--c-border-color --cd-border-color light-dark(black, white)
--c-border-radius --cd-border-radius 0
--c-focus-outline-color --cd-focus-outline-color blue
--c-focus-outline-width --cd-focus-outline-width calc(var(--c-border-width, var(--cd-border-width)) * 2)
--c-header-bg --cd-header-bg light-dark(#f7f7f7, #151515)
--c-content-max-height --cd-content-max-height 30dvh
--c-content-bg --cd-content-bg light-dark(white, black)
--c-transition-duration --cd-transition-duration 120ms
--c-action-padding --cd-action-padding 0.5em
--c-hover-bg --cd-hover-bg light-dark(black, white)
--c-hover-color --cd-hover-color light-dark(white, black)
--c-tab-selected-color --cd-tab-selected-color var(--cd-border-color)
--c-file-icon-offset-inline-start --cd-file-icon-offset-inline-start 0em, 0.26em when using the default file-icon strategy

Default counterparts are defined on .codeblock for standalone code blocks, or on .codeblock-group for grouped code blocks.

Client Options

Progressive enhancement options and their defaults:

interface EnhanceCodeBlockOptions {
	/** instruction on what text to copy */
	copy: EnhanceCodeBlockCopy;
	/** how long to show the "copied" state before reverting back to the default state, in milliseconds */
	copyTimeoutMs:
}

/**
 * @param context
 * @returns a string to pass to `navigator.clipboard.writeText()`,
 *          or any falsy value if implementing custom copy logic
 *          (e.g. use the legacy `execCommand('copy')` method)
 */
type EnhanceCodeBlockCopy = (
	context: EnhanceCodeBlockCopyContext,
) => string | null | undefined | false | void;

interface EnhanceCodeBlockCopyContext {
	/** `pre` element of the associated code block, in group this is the currently selected one */
	pre: HTMLPreElement;
	/** `button` element that was clicked to trigger the copy action */
	btn: HTMLButtonElement;
}

// default
enhanceCodeblock({
	copy: ({ pre }) => pre.textContent,
	copyTimeoutMs: 3000,
});

Problem Space and Focus

This library is a compact and somewhat opinionated version of what I have used in several projects where sharing code vs blog posts or documentation was necessary. Existing solutions that I had found either relied too much on runtime Javascript, or were framework-specific.

My focus here includes:

  • accessibility (to the best of my knowledge),
  • progressive enhancement: works without JS, enhanced when JS is available,
  • a convenient code-sharing experience when authoring technical content on the web,
  • utilisation of contemporary baseline platform features (e.g. CSS anchor positioning and @starting-style),
  • sensible defaults with customisation capabilities.

Not a Syntax Highlighter

Syntax highlighting is not included in this library. I recommend shiki for that purpose.

Features

  • Optional header with title and file icon.
  • Can be collapsed in a accordion-like fashion.
  • Grouped multiple code blocks into a single tabbed interface.

Features that require JS:

  • Copy-to-clipboard
  • Fullscreen view

CONTRIBUTING

See CONTRIBUTING.md for contribution guidelines.

More unified Plugins by Me


built by human, not agents.

About

enhance markdown code block or group of code blocks

Topics

Resources

Contributing

Stars

1 star

Watchers

0 watching

Forks

Releases

Sponsor this project

Contributors

Languages