Skip to content

Commit 2f377eb

Browse files
patrickerclaude
andcommitted
fix: code quality hardening — CRLF, error handling, types, tests
Bugs fixed: - Normalize CRLF line endings at file-read boundary - Error on diff-step + side-by-side (was silently discarding diff) - Error on diff-reference + diff-step on same block (was silently ignoring diff-step) Code smells addressed: - Extract parseFlags() helper (eliminates duplicated flag parsing) - Consolidate resolveRef() last 2 params into opts bag - Deduplicate LINE_RANGE_RE (export from extract-lines, import in index) - Add (?:^|\s) anchoring to REF_REGEX, DIFF_REF_REGEX, DIFF_STEP_REGEX - Remove unnecessary .* prefix from PRESET_STRIP.markers patterns Project config: - Add unified as peerDependency - Add build + CJS smoke test to CI - Add TypeScript declarations (index.d.mts) - Document tab groups in README with tabGroupClass in Options table Test coverage: - CRLF fixture + region/line-range tests - Backslash-escaped spaces in file= paths - CJS entry point regression test (subprocess-based) - Focused cleanDiffMeta tests (8 tests for order-sensitive stripping) - Duplicate region name behavior documented in test Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 2fbf13e commit 2f377eb

11 files changed

Lines changed: 368 additions & 39 deletions

File tree

.github/workflows/ci.yml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,5 +26,11 @@ jobs:
2626
- name: Lint
2727
run: npm run lint
2828

29+
- name: Build
30+
run: npm run build
31+
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); }"
34+
2935
- name: Test
3036
run: npm test

README.md

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -378,6 +378,60 @@ The reader sees complete, copyable code at every step -- with green/red highligh
378378
| Output | Diff only | Full current code with change annotations |
379379
| Default format | `unified` (+/- prefixes) | `inline-annotations` (Shiki highlights) |
380380

381+
## Tab groups
382+
383+
Group consecutive code fences into tabs using the `tab` attribute. Each fence is processed independently (reference extraction, strip, transmute, diff — everything works), then consecutive `tab` fences are wrapped in a `tabGroup` AST node.
384+
385+
**Multi-language tabs:**
386+
387+
````md
388+
```python tab="Python" reference="tests/test_sdk.py#create_user"
389+
```
390+
391+
```javascript tab="Node.js" reference="tests/test_sdk.js#create_user"
392+
```
393+
````
394+
395+
**Same-language version comparison:**
396+
397+
````md
398+
```python tab="SDK v1" reference="tests/v1.py#create_user"
399+
```
400+
401+
```python tab="SDK v2" reference="tests/v2.py#create_user"
402+
```
403+
````
404+
405+
**Inline code tabs (no reference needed):**
406+
407+
````md
408+
```bash tab="npm"
409+
npm install myapp
410+
```
411+
412+
```bash tab="yarn"
413+
yarn add myapp
414+
```
415+
````
416+
417+
Bare `tab` (no value) derives the label from the language tag: `` ```python tab `` → label "Python".
418+
419+
### Sync key
420+
421+
Use `tab-group="id"` to synchronize tab selection across multiple tab groups on a page (like Docusaurus `groupId` or Starlight `syncKey`). Clicking "Python" in one group switches all groups with the same sync key.
422+
423+
```md
424+
```python tab="Python" tab-group="lang" reference="tests/install.py#pip"
425+
```
426+
427+
To split adjacent tab groups without visible content between them, use an HTML comment (`<!-- -->`), which creates an invisible node that breaks the consecutive grouping.
428+
429+
### AST output
430+
431+
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']`.
432+
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.
434+
381435
## Auto-dedent
382436

383437
Extracted regions are automatically dedented. Common leading whitespace is removed so that code nested inside test functions or classes renders flush-left in your docs.
@@ -442,6 +496,7 @@ remarkPlugins: [[codeRegion, { clean: false }]],
442496
| `regionSeparator` | `string` | `'\n\n'` | String inserted between regions in multi-region concatenation. |
443497
| `preserveFileMeta` | `boolean` | `false` | Keep `file=` in meta after processing (matches remark-code-import behavior). |
444498
| `diffFormat` | `string` | `'unified'` | Output format for diff blocks: `'unified'`, `'inline-annotations'`, or `'side-by-side'`. |
499+
| `tabGroupClass` | `string` | `'code-tabs'` | CSS class for tab group wrapper `<div>`. |
445500

446501
**Exports** (for composing custom configurations):
447502

index.d.mts

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import type { Plugin } from 'unified';
2+
3+
export interface RegionMarker {
4+
start: RegExp;
5+
end: RegExp;
6+
}
7+
8+
export interface TransmuteRule {
9+
match: RegExp;
10+
template: string;
11+
argMap?: Record<string, number>;
12+
}
13+
14+
export interface Options {
15+
/** Base directory for resolving reference paths. Defaults to process.cwd(). */
16+
rootDir?: string;
17+
/** Allow references outside rootDir. Default: false. */
18+
allowOutsideRoot?: boolean;
19+
/** Region marker pairs. Defaults cover # and // comments. */
20+
regionMarkers?: RegionMarker[];
21+
/** Patterns to strip. false=disable, undefined=defaults, RegExp[]=custom. */
22+
strip?: RegExp[] | false;
23+
/** Transmute rules. false/undefined=disabled, array=use rules. */
24+
transmute?: TransmuteRule[] | false;
25+
/** Cleaning steps. false=disable, undefined=defaults, string[]=custom. */
26+
clean?: string[] | false;
27+
/** String inserted between concatenated regions. Default: '\n\n'. */
28+
regionSeparator?: string;
29+
/** Keep file= in meta after processing. Default: false. */
30+
preserveFileMeta?: boolean;
31+
/** Output format for diff blocks. Default: 'unified'. */
32+
diffFormat?: 'unified' | 'inline-annotations' | 'side-by-side';
33+
/** CSS class for tab group wrapper div. Default: 'code-tabs'. */
34+
tabGroupClass?: string;
35+
}
36+
37+
declare const remarkCodeRegion: Plugin<[Options?]>;
38+
export default remarkCodeRegion;
39+
40+
// Preset strip patterns (grouped by language)
41+
export declare const PRESET_STRIP: {
42+
python: RegExp[];
43+
rust: RegExp[];
44+
java: RegExp[];
45+
js: RegExp[];
46+
cpp: RegExp[];
47+
go: RegExp[];
48+
markers: RegExp[];
49+
};
50+
51+
// Preset transmute rules (grouped by language)
52+
export declare const PRESET_TRANSMUTE: {
53+
python: TransmuteRule[];
54+
js: TransmuteRule[];
55+
rust: TransmuteRule[];
56+
java: TransmuteRule[];
57+
go: TransmuteRule[];
58+
cpp: TransmuteRule[];
59+
};
60+
61+
// Preset clean step lists
62+
export declare const PRESET_CLEAN: {
63+
default: string[];
64+
compat: string[];
65+
};
66+
67+
// Preset region markers (grouped by syntax)
68+
export declare const PRESET_MARKERS: {
69+
css: RegionMarker[];
70+
html: RegionMarker[];
71+
sql: RegionMarker[];
72+
};
73+
74+
// Default values (unions of all presets)
75+
export declare const DEFAULT_STRIP_PATTERNS: RegExp[];
76+
export declare const DEFAULT_TRANSMUTE_RULES: TransmuteRule[];
77+
export declare const DEFAULT_CLEAN: string[];
78+
export declare const DEFAULT_REGION_MARKERS: RegionMarker[];
79+
80+
// Language → comment prefix map (70+ entries)
81+
export declare const COMMENT_PREFIX: Record<string, string>;

index.mjs

Lines changed: 63 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import fs from 'node:fs';
1616
import path from 'node:path';
1717
import { SKIP, visit } from 'unist-util-visit';
1818
import { formatDiff } from './lib/diff-regions.mjs';
19-
import { extractLines } from './lib/extract-lines.mjs';
19+
import { extractLines, LINE_RANGE_RE } from './lib/extract-lines.mjs';
2020
import { extractRegion } from './lib/extract-region.mjs';
2121
import {
2222
DEFAULT_CLEAN,
@@ -27,26 +27,47 @@ import { cleanCode } from './lib/strip-asserts.mjs';
2727
import { groupTabsInParent, TAB_REGEX } from './lib/tab-groups.mjs';
2828

2929
/** Regex to find reference="..." in code fence meta (not diff-reference). */
30-
const REF_REGEX = /(?<!-)reference="([^"]+)"/;
30+
const REF_REGEX = /(?:^|\s)(?<!-)reference="([^"]+)"/;
3131

3232
/** Regex to find file=... (unquoted, backslash-escaped spaces, not diff-file=). */
3333
const FILE_REGEX = /(?:^|(?<!-)(?<=\s))file=((?:[^\s\\]|\\.)+)/;
3434

3535
/** Regex to find diff-reference="..." in code fence meta. */
36-
const DIFF_REF_REGEX = /diff-reference="([^"]+)"/;
36+
const DIFF_REF_REGEX = /(?:^|\s)diff-reference="([^"]+)"/;
3737

3838
/** Regex to find diff-file=... in code fence meta. */
3939
const DIFF_FILE_REGEX = /(?:^|\s)diff-file=((?:[^\s\\]|\\.)+)/;
4040

4141
/** Regex to find diff-step="..." in code fence meta (tutorial step diffing). */
42-
const DIFF_STEP_REGEX = /diff-step="([^"]+)"/;
43-
44-
/** Regex to detect line-range fragments like L3, L3-L6, L3- */
45-
const LINE_RANGE_RE = /^L(\d+)(?:-(?:L?(\d+))?)?$/;
42+
const DIFF_STEP_REGEX = /(?:^|\s)diff-step="([^"]+)"/;
4643

4744
/** Prefix for rootDir-relative file= paths. */
4845
const ROOT_DIR_PREFIX = '<rootDir>/';
4946

47+
/**
48+
* Parse ?flags from a raw reference value (e.g., "file.py#region?noStrip&format=unified").
49+
* @param {string} rawValue
50+
* @param {'unified'|'inline-annotations'|'side-by-side'} defaultFormat
51+
* @returns {{ flagOverrides: { noStrip: boolean, noTransmute: boolean }, effectiveDiffFormat: string }}
52+
*/
53+
function parseFlags(rawValue, defaultFormat) {
54+
const hashIndex = rawValue.indexOf('#');
55+
const fragStr = hashIndex >= 0 ? rawValue.slice(hashIndex + 1) : null;
56+
const qIndex = fragStr ? fragStr.indexOf('?') : -1;
57+
const flags = qIndex >= 0 ? fragStr.slice(qIndex + 1).split('&') : [];
58+
const formatFlag = flags.find((f) => f.startsWith('format='));
59+
return {
60+
flagOverrides: {
61+
noStrip: flags.includes('noStrip'),
62+
noTransmute: flags.includes('noTransmute'),
63+
},
64+
effectiveDiffFormat: formatFlag
65+
? formatFlag.slice('format='.length)
66+
: defaultFormat,
67+
formatFlag: formatFlag || null,
68+
};
69+
}
70+
5071
/**
5172
* @typedef {Object} RegionMarker
5273
* @property {RegExp} start - Regex matching region start. Group 1 = name.
@@ -111,8 +132,9 @@ export default function remarkCodeRegion(options = {}) {
111132
* @param {string} securityBase - Resolved rootDir for security checks
112133
* @param {object} vfile - The vfile object (for fail())
113134
* @param {string} lang - Code fence language tag
114-
* @param {object} flagOverrides - Per-block flag overrides { noStrip, noTransmute }
115-
* @param {string|null} [fallbackAbsPath] - Abs path to use when rawValue has no file portion
135+
* @param {object} [opts] - Optional parameters.
136+
* @param {object} [opts.flagOverrides] - Per-block flag overrides { noStrip, noTransmute }
137+
* @param {string|null} [opts.fallbackAbsPath] - Abs path to use when rawValue has no file portion
116138
* @returns {{ code: string, absPath: string }}
117139
*/
118140
function resolveRef(
@@ -122,9 +144,9 @@ export default function remarkCodeRegion(options = {}) {
122144
securityBase,
123145
vfile,
124146
lang,
125-
flagOverrides,
126-
fallbackAbsPath = null,
147+
opts = {},
127148
) {
149+
const { flagOverrides = {}, fallbackAbsPath = null } = opts;
128150
let raw = rawValue;
129151

130152
// Unescape backslash-spaces in file= values
@@ -188,7 +210,7 @@ export default function remarkCodeRegion(options = {}) {
188210
// Read the source file
189211
let content;
190212
try {
191-
content = fs.readFileSync(absPath, 'utf-8');
213+
content = fs.readFileSync(absPath, 'utf-8').replace(/\r\n/g, '\n');
192214
} catch (e) {
193215
const msg = `remark-code-region: cannot read file '${filePath || raw}' (resolved to '${absPath}'): ${e.message}`;
194216
if (vfile?.fail) {
@@ -270,6 +292,17 @@ export default function remarkCodeRegion(options = {}) {
270292
const hasDiff = !!(diffRefMatch || diffFileMatch);
271293
const hasDiffStep = !!diffStepMatch;
272294

295+
// Mutual exclusion: can't combine diff-reference/diff-file with diff-step
296+
if (hasDiff && hasDiffStep) {
297+
const msg =
298+
'remark-code-region: diff-reference/diff-file and diff-step cannot be used on the same code block';
299+
if (file?.fail) {
300+
file.fail(msg);
301+
return;
302+
}
303+
throw new Error(msg);
304+
}
305+
273306
// If no primary reference and no diff attribute, skip
274307
if (!refMatch && !fileMatch) {
275308
// Error if diff attr present without primary
@@ -298,25 +331,11 @@ export default function remarkCodeRegion(options = {}) {
298331
primaryResolveDir = file?.dirname || file?.cwd || process.cwd();
299332
}
300333

301-
// Parse primary flags for per-block overrides
302-
const primaryHashIndex = primaryRaw.indexOf('#');
303-
const primaryFragStr =
304-
primaryHashIndex >= 0 ? primaryRaw.slice(primaryHashIndex + 1) : null;
305-
const primaryQIndex = primaryFragStr ? primaryFragStr.indexOf('?') : -1;
306-
const primaryFlags =
307-
primaryQIndex >= 0
308-
? primaryFragStr.slice(primaryQIndex + 1).split('&')
309-
: [];
310-
const flagOverrides = {
311-
noStrip: primaryFlags.includes('noStrip'),
312-
noTransmute: primaryFlags.includes('noTransmute'),
313-
};
314-
315-
// Parse ?format= override for diffs
316-
const formatFlag = primaryFlags.find((f) => f.startsWith('format='));
317-
const effectiveDiffFormat = formatFlag
318-
? formatFlag.slice('format='.length)
319-
: diffFormat;
334+
// Parse per-block flag overrides and format from primary reference
335+
const { flagOverrides, effectiveDiffFormat, formatFlag } = parseFlags(
336+
primaryRaw,
337+
diffFormat,
338+
);
320339

321340
// Extract and clean the primary reference
322341
const primary = resolveRef(
@@ -326,7 +345,7 @@ export default function remarkCodeRegion(options = {}) {
326345
resolvedBase,
327346
file,
328347
node.lang || '',
329-
flagOverrides,
348+
{ flagOverrides },
330349
);
331350

332351
if (hasDiff) {
@@ -352,8 +371,7 @@ export default function remarkCodeRegion(options = {}) {
352371
resolvedBase,
353372
file,
354373
node.lang || '',
355-
flagOverrides,
356-
primary.absPath,
374+
{ flagOverrides, fallbackAbsPath: primary.absPath },
357375
);
358376

359377
// Format the diff
@@ -415,8 +433,7 @@ export default function remarkCodeRegion(options = {}) {
415433
resolvedBase,
416434
file,
417435
node.lang || '',
418-
flagOverrides,
419-
primary.absPath,
436+
{ flagOverrides, fallbackAbsPath: primary.absPath },
420437
);
421438

422439
// Default to inline-annotations for diff-step (shows full code with highlights)
@@ -427,6 +444,16 @@ export default function remarkCodeRegion(options = {}) {
427444

428445
const result = formatDiff(previousStep.code, primary.code, stepFormat);
429446

447+
if (result.mode === 'side-by-side') {
448+
const msg =
449+
'remark-code-region: side-by-side format is not supported with diff-step';
450+
if (file?.fail) {
451+
file.fail(msg);
452+
return;
453+
}
454+
throw new Error(msg);
455+
}
456+
430457
if (result.mode === 'unified') {
431458
node.value = result.value;
432459
node.lang = 'diff';

lib/extract-lines.mjs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
* @throws {Error} If the line specification is out of range or reversed.
99
*/
1010

11-
const LINE_RANGE_RE = /^L(\d+)(?:-(?:L?(\d+))?)?$/;
11+
export const LINE_RANGE_RE = /^L(\d+)(?:-(?:L?(\d+))?)?$/;
1212

1313
export function extractLines(content, lineSpec, filePath) {
1414
const m = lineSpec.match(LINE_RANGE_RE);

lib/patterns.mjs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -80,8 +80,8 @@ export const PRESET_STRIP = {
8080
],
8181
/** Matches lines ending with // test-only or # test-only — works in any language. */
8282
markers: [
83-
/.*\/\/\s*test-only\s*$/, // // test-only
84-
/.*#\s*test-only\s*$/, // # test-only
83+
/\/\/\s*test-only\s*$/, // // test-only
84+
/#\s*test-only\s*$/, // # test-only
8585
],
8686
};
8787

package.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,15 @@
77
"module": "./index.mjs",
88
"exports": {
99
".": {
10+
"types": "./index.d.mts",
1011
"require": "./index.cjs",
1112
"import": "./index.mjs"
1213
}
1314
},
1415
"files": [
1516
"index.mjs",
1617
"index.cjs",
18+
"index.d.mts",
1719
"lib/"
1820
],
1921
"scripts": {
@@ -49,6 +51,9 @@
4951
"bugs": {
5052
"url": "https://github.com/patricker/remark-code-region/issues"
5153
},
54+
"peerDependencies": {
55+
"unified": ">=11.0.0"
56+
},
5257
"dependencies": {
5358
"diff": "^8.0.4",
5459
"unist-util-visit": "^5.0.0"

0 commit comments

Comments
 (0)