Skip to content

Commit 17ad318

Browse files
patrickerclaude
andcommitted
feat(v0.3.0): add Shiki, Docusaurus, and Starlight companion plugins
Three new subpath exports built on top of the core plugin's AST output: - remark-code-region/shiki — injects [!code highlight]/[!code focus] annotations from ?highlight= and ?focus= flags, with language-aware comment syntax (# for Python/Bash/Ruby, // for JS/Rust/Go, -- for SQL/Lua, /* */ for CSS, <!-- --> for HTML). Optional diffStepStyle: 'highlight' converts diff-step ++/-- markers into softer highlight emphasis. Shiki transformers only recognize annotations inside language-valid comments, so the language map is essential. - remark-code-region/docusaurus — turns tabGroup wrapper nodes into <Tabs>/<TabItem> MDX JSX with automatic import injection. - remark-code-region/starlight — turns tabGroup wrapper nodes into Starlight <Tabs>/<TabItem> components with syncKey support. Core changes: - parseFlags() extracts highlight=/focus= and stores raw specs on node.data for companion plugins to consume. - New scripts/build.mjs replaces the inline esbuild command and builds 4 CJS bundles in parallel. - CI smoke-tests all 4 CJS bundles. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent f5f5d5c commit 17ad318

17 files changed

Lines changed: 1660 additions & 26 deletions

.github/workflows/ci.yml

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,8 +29,12 @@ jobs:
2929
- name: Build
3030
run: npm run build
3131

32-
- name: Verify CJS bundle
33-
run: node -e "const p = require('./index.cjs'); if (typeof p !== 'function') { console.error('CJS export is not a function:', typeof p); process.exit(1); }"
32+
- name: Verify CJS bundles
33+
run: |
34+
node -e "const p = require('./index.cjs'); if (typeof p !== 'function') { console.error('CJS export is not a function:', typeof p); process.exit(1); }"
35+
node -e "const p = require('./lib/docusaurus.cjs'); if (typeof p !== 'function') { console.error('CJS export is not a function:', typeof p); process.exit(1); }"
36+
node -e "const p = require('./lib/starlight.cjs'); if (typeof p !== 'function') { console.error('CJS export is not a function:', typeof p); process.exit(1); }"
37+
node -e "const p = require('./lib/shiki.cjs'); if (typeof p !== 'function') { console.error('CJS export is not a function:', typeof p); process.exit(1); }"
3438
3539
- name: Test
3640
run: npm test

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,4 @@
11
node_modules/
22
index.cjs
3+
lib/*.cjs
4+
ref/

README.md

Lines changed: 68 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -430,7 +430,60 @@ To split adjacent tab groups without visible content between them, use an HTML c
430430

431431
The plugin emits `tabGroup` wrapper nodes with `data.hName = 'div'` and `data.hProperties.class = 'code-tabs'` (customizable via `tabGroupClass` option). Each child code node gets `data.tabLabel` and `data.hProperties['data-tab']`.
432432

433-
Without a companion plugin, this renders as a `<div class="code-tabs">` wrapping stacked `<pre><code>` blocks. Framework-specific companion plugins (coming soon) will transform these into native Docusaurus `<Tabs>`, Starlight tabs, etc.
433+
Without a companion plugin, this renders as a `<div class="code-tabs">` wrapping stacked `<pre><code>` blocks. Add a companion plugin to transform these into native framework tabs:
434+
435+
**Docusaurus:**
436+
```js
437+
const codeRegion = require('remark-code-region');
438+
const codeRegionTabs = require('remark-code-region/docusaurus');
439+
440+
remarkPlugins: [codeRegion, codeRegionTabs],
441+
```
442+
443+
**Astro Starlight:**
444+
```js
445+
import codeRegion from 'remark-code-region';
446+
import codeRegionTabs from 'remark-code-region/starlight';
447+
448+
remarkPlugins: [codeRegion, codeRegionTabs],
449+
```
450+
451+
For other remark pipelines, the `tabGroup` AST nodes are well-structured for custom rehype plugins to consume directly.
452+
453+
## Shiki annotations
454+
455+
Add `?highlight=` or `?focus=` to any reference to inject [Shiki transformer annotations](https://shiki.style/packages/transformers) into the extracted code:
456+
457+
````md
458+
```python reference="tests/test_sdk.py#create_user?highlight=1,3-5"
459+
```
460+
461+
```python reference="tests/test_sdk.py#create_user?focus=2,4-6"
462+
```
463+
````
464+
465+
Line numbers are 1-based and refer to the final output (after strip/transmute/dedent). Ranges (`3-5`) and mixed specs (`1,3-5,8`) are supported.
466+
467+
Add the Shiki companion plugin to inject the annotations:
468+
469+
```js
470+
import codeRegion from 'remark-code-region';
471+
import codeRegionShiki from 'remark-code-region/shiki';
472+
473+
remarkPlugins: [codeRegion, codeRegionShiki],
474+
```
475+
476+
Requires `@shikijs/transformers` (`transformerNotationHighlight`, `transformerNotationFocus`) configured in your Shiki setup.
477+
478+
### Soft diff-step highlighting
479+
480+
By default, `diff-step` emits `// [!code ++]` / `// [!code --]` for green/red diff highlighting. The Shiki companion can convert these to softer `// [!code highlight]` annotations — emphasizing new lines without the diff color scheme:
481+
482+
```js
483+
remarkPlugins: [codeRegion, [codeRegionShiki, { diffStepStyle: 'highlight' }]],
484+
```
485+
486+
Lines marked `// [!code --]` are removed (since diff-step shows the current code state), and `// [!code ++]` becomes `// [!code highlight]`.
434487

435488
## Auto-dedent
436489

@@ -553,15 +606,27 @@ Works inside MDX components (`<Tabs>`, admonitions) -- the plugin runs at the re
553606
554607
```js
555608
const codeRegion = require('remark-code-region');
609+
const codeRegionTabs = require('remark-code-region/docusaurus'); // optional, for tab groups
556610
557611
module.exports = {
558612
presets: [['classic', {
559-
docs: { remarkPlugins: [codeRegion] },
613+
docs: { remarkPlugins: [codeRegion, codeRegionTabs] },
560614
}]],
561615
};
562616
```
563617

564-
### Astro
618+
### Astro Starlight
619+
620+
```js
621+
import codeRegion from 'remark-code-region';
622+
import codeRegionTabs from 'remark-code-region/starlight'; // optional, for tab groups
623+
624+
export default defineConfig({
625+
markdown: { remarkPlugins: [codeRegion, codeRegionTabs] },
626+
});
627+
```
628+
629+
### Astro (non-Starlight)
565630

566631
```js
567632
import codeRegion from 'remark-code-region';

index.mjs

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -62,14 +62,16 @@ function fail(vfile, msg) {
6262
* Parse ?flags from a raw reference value (e.g., "file.py#region?noStrip&format=unified").
6363
* @param {string} rawValue
6464
* @param {'unified'|'inline-annotations'|'side-by-side'} defaultFormat
65-
* @returns {{ flagOverrides: { noStrip: boolean, noTransmute: boolean }, effectiveDiffFormat: string, formatFlag: string|null }}
65+
* @returns {{ flagOverrides: { noStrip: boolean, noTransmute: boolean }, effectiveDiffFormat: string, formatFlag: string|null, highlight: string|null, focus: string|null }}
6666
*/
6767
function parseFlags(rawValue, defaultFormat) {
6868
const hashIndex = rawValue.indexOf('#');
6969
const fragStr = hashIndex >= 0 ? rawValue.slice(hashIndex + 1) : null;
7070
const qIndex = fragStr ? fragStr.indexOf('?') : -1;
7171
const flags = qIndex >= 0 ? fragStr.slice(qIndex + 1).split('&') : [];
7272
const formatFlag = flags.find((f) => f.startsWith('format='));
73+
const highlightFlag = flags.find((f) => f.startsWith('highlight='));
74+
const focusFlag = flags.find((f) => f.startsWith('focus='));
7375
return {
7476
flagOverrides: {
7577
noStrip: flags.includes('noStrip'),
@@ -79,6 +81,8 @@ function parseFlags(rawValue, defaultFormat) {
7981
? formatFlag.slice('format='.length)
8082
: defaultFormat,
8183
formatFlag: formatFlag || null,
84+
highlight: highlightFlag ? highlightFlag.slice('highlight='.length) : null,
85+
focus: focusFlag ? focusFlag.slice('focus='.length) : null,
8286
};
8387
}
8488

@@ -431,10 +435,13 @@ export default function remarkCodeRegion(options = {}) {
431435
primaryResolveDir = file?.dirname || file?.cwd || process.cwd();
432436
}
433437

434-
const { flagOverrides, effectiveDiffFormat, formatFlag } = parseFlags(
435-
primaryRaw,
436-
diffFormat,
437-
);
438+
const {
439+
flagOverrides,
440+
effectiveDiffFormat,
441+
formatFlag,
442+
highlight,
443+
focus,
444+
} = parseFlags(primaryRaw, diffFormat);
438445

439446
const primary = resolveRef(
440447
primaryRaw,
@@ -469,6 +476,12 @@ export default function remarkCodeRegion(options = {}) {
469476
}
470477

471478
node.meta = cleanMeta(node.meta, isFileDirective, preserveFileMeta);
479+
480+
if (highlight || focus) {
481+
node.data = node.data || {};
482+
if (highlight) node.data.highlight = highlight;
483+
if (focus) node.data.focus = focus;
484+
}
472485
});
473486

474487
// Pass 2: group consecutive tab= fences into tabGroup wrapper nodes

lib/docusaurus.d.mts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import type { Plugin } from 'unified';
2+
3+
export interface DocusaurusOptions {
4+
/** Import path for Tabs component. Default: '@theme/Tabs'. */
5+
tabsImport?: string;
6+
/** Import path for TabItem component. Default: '@theme/TabItem'. */
7+
tabItemImport?: string;
8+
}
9+
10+
declare const remarkCodeRegionDocusaurus: Plugin<[DocusaurusOptions?]>;
11+
export default remarkCodeRegionDocusaurus;

lib/docusaurus.mjs

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
/**
2+
* Companion plugin for Docusaurus.
3+
*
4+
* Transforms `tabGroup` AST nodes into Docusaurus `<Tabs>` / `<TabItem>`
5+
* MDX JSX elements with `groupId` for synchronized tab switching.
6+
*
7+
* Usage:
8+
* import codeRegion from 'remark-code-region';
9+
* import codeRegionTabs from 'remark-code-region/docusaurus';
10+
* remarkPlugins: [codeRegion, codeRegionTabs],
11+
*/
12+
13+
import { visit } from 'unist-util-visit';
14+
15+
function slugify(label) {
16+
const slug = label
17+
.toLowerCase()
18+
.replace(/\+/g, 'p')
19+
.replace(/[^a-z0-9]+/g, '-')
20+
.replace(/^-|-$/g, '');
21+
return slug || `tab-${label.length}`;
22+
}
23+
24+
function attr(name, value) {
25+
return { type: 'mdxJsxAttribute', name, value };
26+
}
27+
28+
export default function remarkCodeRegionDocusaurus(options = {}) {
29+
const { tabsImport = '@theme/Tabs', tabItemImport = '@theme/TabItem' } =
30+
options;
31+
32+
return (tree) => {
33+
let hasTabGroups = false;
34+
35+
visit(tree, 'tabGroup', (node, index, parent) => {
36+
hasTabGroups = true;
37+
38+
const syncKey = node.data?.hProperties?.['data-tab-group'] || null;
39+
const children = node.children || [];
40+
41+
const tabItems = children.map((child) => {
42+
const label = child.data?.tabLabel || 'Code';
43+
const value = slugify(label);
44+
45+
const attributes = [attr('value', value), attr('label', label)];
46+
47+
return {
48+
type: 'mdxJsxFlowElement',
49+
name: 'TabItem',
50+
attributes,
51+
children: [child],
52+
};
53+
});
54+
55+
const tabsAttrs = [];
56+
if (syncKey) {
57+
tabsAttrs.push(attr('groupId', syncKey));
58+
}
59+
if (tabItems.length > 0) {
60+
const firstValue = tabItems[0].attributes.find(
61+
(a) => a.name === 'value',
62+
)?.value;
63+
if (firstValue) {
64+
tabsAttrs.push(attr('defaultValue', firstValue));
65+
}
66+
}
67+
68+
const tabsNode = {
69+
type: 'mdxJsxFlowElement',
70+
name: 'Tabs',
71+
attributes: tabsAttrs,
72+
children: tabItems,
73+
};
74+
75+
parent.children[index] = tabsNode;
76+
});
77+
78+
if (hasTabGroups) {
79+
injectImport(tree, tabsImport, tabItemImport);
80+
}
81+
};
82+
}
83+
84+
function injectImport(tree, tabsImport, tabItemImport) {
85+
const alreadyImported = tree.children.some(
86+
(node) =>
87+
node.type === 'mdxjsEsm' &&
88+
node.value &&
89+
(node.value.includes(`'${tabsImport}'`) ||
90+
node.value.includes(`"${tabsImport}"`)),
91+
);
92+
if (alreadyImported) return;
93+
94+
tree.children.unshift({
95+
type: 'mdxjsEsm',
96+
value: `import Tabs from '${tabsImport}';\nimport TabItem from '${tabItemImport}';`,
97+
data: { estree: null },
98+
});
99+
}

lib/shiki.d.mts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import type { Plugin } from 'unified';
2+
3+
export interface ShikiOptions {
4+
/** Convert diff-step // [!code ++] to // [!code highlight] and remove // [!code --] lines. */
5+
diffStepStyle?: 'highlight';
6+
}
7+
8+
declare const remarkCodeRegionShiki: Plugin<[ShikiOptions?]>;
9+
export default remarkCodeRegionShiki;

0 commit comments

Comments
 (0)