readme-assert 6.0.3 → 7.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +31 -40
- package/readme.md +90 -75
- package/src/cli.js +106 -0
- package/src/comment-to-assert.js +138 -0
- package/src/extract.js +66 -0
- package/src/generate.js +96 -0
- package/src/index.js +4 -0
- package/src/run.js +320 -0
- package/cli.js +0 -9
- package/lib/cli.js +0 -62
- package/lib/extract.js +0 -74
- package/lib/global-assert.js +0 -144
- package/lib/index.js +0 -111
- package/lib/runInThisContext.js +0 -45
- package/license +0 -21
package/package.json
CHANGED
|
@@ -1,54 +1,45 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "readme-assert",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
"
|
|
13
|
-
"
|
|
14
|
-
"
|
|
15
|
-
|
|
16
|
-
|
|
3
|
+
"version": "7.1.0",
|
|
4
|
+
"description": "Run code blocks in your readme as tests",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"readme",
|
|
7
|
+
"test",
|
|
8
|
+
"testing",
|
|
9
|
+
"documentation",
|
|
10
|
+
"doctest",
|
|
11
|
+
"assert",
|
|
12
|
+
"markdown",
|
|
13
|
+
"code-blocks",
|
|
14
|
+
"typescript"
|
|
15
|
+
],
|
|
16
|
+
"type": "module",
|
|
17
|
+
"exports": "./src/index.js",
|
|
18
|
+
"bin": {
|
|
19
|
+
"readme-assert": "./src/cli.js"
|
|
17
20
|
},
|
|
21
|
+
"files": [
|
|
22
|
+
"src"
|
|
23
|
+
],
|
|
24
|
+
"license": "MIT",
|
|
18
25
|
"author": {
|
|
19
26
|
"name": "Sigurd Fosseng",
|
|
20
27
|
"email": "sigurd@fosseng.net",
|
|
21
28
|
"url": "http://laat.io"
|
|
22
29
|
},
|
|
23
|
-
"license": "MIT",
|
|
24
30
|
"repository": {
|
|
25
31
|
"type": "git",
|
|
26
32
|
"url": "https://github.com/laat/readme-assert.git"
|
|
27
33
|
},
|
|
28
|
-
"
|
|
29
|
-
"
|
|
30
|
-
"@babel/plugin-syntax-typescript": "^7.3.3",
|
|
31
|
-
"@babel/plugin-transform-typescript": "^7.4.5",
|
|
32
|
-
"@babel/preset-env": "^7.0.0",
|
|
33
|
-
"babel-plugin-transform-comment-to-assert": "^4.1.0",
|
|
34
|
-
"babel-plugin-transform-rename-import": "^2.0.0",
|
|
35
|
-
"gfm-code-blocks": "^1.0.0",
|
|
36
|
-
"pkg-up": "^3.1.0",
|
|
37
|
-
"source-map-support": "^0.5.12",
|
|
38
|
-
"stack-utils": "^1.0.2",
|
|
39
|
-
"tap-yaml": "^1.0.0",
|
|
40
|
-
"yargs": "^13.2.4"
|
|
34
|
+
"engines": {
|
|
35
|
+
"node": ">=20.19.0 || >=22.12.0"
|
|
41
36
|
},
|
|
42
|
-
"
|
|
43
|
-
"
|
|
44
|
-
"
|
|
45
|
-
"
|
|
46
|
-
"npm-run-all": "4.1.5",
|
|
47
|
-
"prettier": "1.18.2",
|
|
48
|
-
"run-tests": "1.0.4"
|
|
37
|
+
"dependencies": {
|
|
38
|
+
"esbuild": "^0.27.0",
|
|
39
|
+
"magic-string": "^0.30.0",
|
|
40
|
+
"oxc-parser": "^0.123.0"
|
|
49
41
|
},
|
|
50
|
-
"
|
|
51
|
-
"
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
}
|
|
42
|
+
"scripts": {
|
|
43
|
+
"test": "node --test test/*.test.js && pnpm -r test"
|
|
44
|
+
}
|
|
45
|
+
}
|
package/readme.md
CHANGED
|
@@ -1,37 +1,42 @@
|
|
|
1
|
-
# readme-assert [![
|
|
1
|
+
# readme-assert [![npm][npm-image]][npm-url]
|
|
2
2
|
|
|
3
|
-
[travis-image]: https://travis-ci.org/laat/readme-assert.svg?branch=master
|
|
4
|
-
[travis-url]: https://travis-ci.org/laat/readme-assert
|
|
5
3
|
[npm-image]: https://img.shields.io/npm/v/readme-assert.svg?style=flat
|
|
6
4
|
[npm-url]: https://npmjs.org/package/readme-assert
|
|
7
5
|
|
|
8
|
-
|
|
6
|
+
`README.md` files often become outdated because the code examples are
|
|
7
|
+
not regularly tested. readme-assert extracts fenced code blocks from
|
|
8
|
+
your readme and runs them as tests, using special comments as assertions.
|
|
9
9
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
10
|
+
## Install
|
|
11
|
+
|
|
12
|
+
```
|
|
13
|
+
npm install readme-assert
|
|
14
|
+
```
|
|
14
15
|
|
|
15
16
|
## Usage
|
|
16
17
|
|
|
17
18
|
```
|
|
18
|
-
Run readme as test
|
|
19
|
-
|
|
20
19
|
Usage: readme-assert [options]
|
|
21
20
|
|
|
22
21
|
Options:
|
|
23
|
-
--auto, -a Auto discover test code block [boolean]
|
|
24
|
-
--all, -l Run all supported code blocks [boolean]
|
|
25
|
-
--babel Use babelrc when transpiling [boolean] [default: false]
|
|
26
22
|
--file, -f readme.md file to read
|
|
27
|
-
--main, -m
|
|
28
|
-
--
|
|
29
|
-
--
|
|
30
|
-
--
|
|
31
|
-
|
|
23
|
+
--main, -m Entry point of the module
|
|
24
|
+
--auto, -a Auto discover test code blocks
|
|
25
|
+
--all, -l Run all supported code blocks
|
|
26
|
+
--print-code, -p Print the transformed code
|
|
27
|
+
--version, -v Show version number
|
|
28
|
+
-h, --help Show help
|
|
32
29
|
```
|
|
33
30
|
|
|
34
|
-
|
|
31
|
+
Run in the same folder as your readme:
|
|
32
|
+
|
|
33
|
+
```
|
|
34
|
+
$ readme-assert
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Writing Tests
|
|
38
|
+
|
|
39
|
+
Tag your fenced code blocks with `test` or `should`:
|
|
35
40
|
|
|
36
41
|
````
|
|
37
42
|
```javascript test
|
|
@@ -39,88 +44,98 @@ Write a test in the readme with the special code-block tag `test`
|
|
|
39
44
|
```
|
|
40
45
|
````
|
|
41
46
|
|
|
42
|
-
|
|
47
|
+
### Assertion Comments
|
|
43
48
|
|
|
44
|
-
|
|
45
|
-
|
|
49
|
+
Use `//=>` to assert the value of an expression:
|
|
50
|
+
|
|
51
|
+
```javascript test
|
|
52
|
+
let a = 1;
|
|
53
|
+
a; //=> 1
|
|
46
54
|
```
|
|
47
55
|
|
|
48
|
-
|
|
56
|
+
The `// →` (unicode arrow) and `// ->` (ascii arrow) variants also work:
|
|
49
57
|
|
|
50
|
-
```
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
# tests 1
|
|
54
|
-
# pass 1
|
|
55
|
-
# fail 0
|
|
58
|
+
```javascript test
|
|
59
|
+
let b = 1;
|
|
60
|
+
b; // → 1
|
|
56
61
|
```
|
|
57
62
|
|
|
58
|
-
|
|
63
|
+
### throws
|
|
59
64
|
|
|
60
|
-
|
|
61
|
-
|
|
65
|
+
Assert that an expression throws using `// throws` with a regex pattern:
|
|
66
|
+
|
|
67
|
+
```javascript test
|
|
68
|
+
const b = () => {
|
|
69
|
+
throw new Error("fail");
|
|
70
|
+
};
|
|
71
|
+
b(); // throws /fail/
|
|
62
72
|
```
|
|
63
73
|
|
|
64
|
-
|
|
74
|
+
### console.log
|
|
65
75
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
TAP version 13
|
|
72
|
-
ok 1
|
|
73
|
-
1..1
|
|
74
|
-
# tests 1
|
|
75
|
-
# pass 1
|
|
76
|
-
# fail 0
|
|
76
|
+
Assert console output — the call is preserved and an assertion is added:
|
|
77
|
+
|
|
78
|
+
```javascript test
|
|
79
|
+
let a = { a: 1 };
|
|
80
|
+
console.log(a); //=> { a: 1 }
|
|
77
81
|
```
|
|
78
82
|
|
|
79
|
-
|
|
83
|
+
### Promises
|
|
80
84
|
|
|
81
|
-
|
|
85
|
+
Assert that a promise resolves to a value with `//=> resolves to`:
|
|
82
86
|
|
|
83
|
-
|
|
87
|
+
```javascript test
|
|
88
|
+
Promise.resolve(true) //=> resolves to true
|
|
89
|
+
```
|
|
84
90
|
|
|
85
|
-
|
|
91
|
+
Assert that a promise rejects with `// rejects`:
|
|
86
92
|
|
|
87
|
-
```javascript
|
|
88
|
-
|
|
89
|
-
a; //=> 1
|
|
93
|
+
```javascript test
|
|
94
|
+
Promise.reject(new Error("no")) // rejects /no/
|
|
90
95
|
```
|
|
91
96
|
|
|
92
|
-
###
|
|
97
|
+
### TypeScript
|
|
93
98
|
|
|
94
|
-
|
|
95
|
-
|
|
99
|
+
TypeScript code blocks are supported natively:
|
|
100
|
+
|
|
101
|
+
```typescript should add two numbers
|
|
102
|
+
const sum: number = 1 + 1;
|
|
103
|
+
sum; //=> 2
|
|
96
104
|
```
|
|
97
105
|
|
|
98
|
-
###
|
|
106
|
+
### Grouping blocks
|
|
99
107
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
console.log(a); //=> { a: 1 }
|
|
103
|
-
```
|
|
108
|
+
Each code block runs as its own file. To share variables across
|
|
109
|
+
blocks, give them the same group name with `test:groupname`:
|
|
104
110
|
|
|
105
|
-
|
|
111
|
+
````
|
|
112
|
+
```javascript test:math
|
|
113
|
+
let x = 2;
|
|
114
|
+
```
|
|
106
115
|
|
|
107
|
-
```javascript test
|
|
108
|
-
|
|
109
|
-
throw new Error("fail");
|
|
110
|
-
};
|
|
111
|
-
b(); // throws /fail/
|
|
116
|
+
```javascript test:math
|
|
117
|
+
x; //=> 2
|
|
112
118
|
```
|
|
119
|
+
````
|
|
113
120
|
|
|
114
|
-
###
|
|
121
|
+
### Auto-discover mode
|
|
115
122
|
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
123
|
+
With `--auto`, any code block containing `//=>`, `// →`, `// ->`,
|
|
124
|
+
`// throws`, or `// rejects` is treated as a test — no `test` tag needed.
|
|
125
|
+
|
|
126
|
+
### All mode
|
|
127
|
+
|
|
128
|
+
With `--all`, every JavaScript and TypeScript code block is executed,
|
|
129
|
+
regardless of tags.
|
|
130
|
+
|
|
131
|
+
## How It Works
|
|
132
|
+
|
|
133
|
+
1. Each fenced code block is extracted from the markdown
|
|
134
|
+
2. Blocks with the same `test:group` name are merged; others run independently
|
|
135
|
+
3. Assertion comments (`//=> value`) are transformed into `assert.deepEqual()` calls using [oxc-parser](https://oxc.rs) and [magic-string](https://github.com/rich-harris/magic-string)
|
|
136
|
+
4. Imports of your package name are rewritten to point to your local source
|
|
137
|
+
5. Each block is written to a temp file and executed with `node`
|
|
120
138
|
|
|
121
|
-
##
|
|
139
|
+
## License
|
|
122
140
|
|
|
123
|
-
|
|
124
|
-
- [babel-plugin-transform-comment-to-assert](https://github.com/laat/babel-plugin-transform-comment-to-assert)
|
|
125
|
-
- [babel-plugin-transform-rename-import](https://github.com/laat/babel-plugin-transform-rename-import)
|
|
126
|
-
- [escape-invisibles](https://github.com/laat/escape-invisibles)
|
|
141
|
+
MIT
|
package/src/cli.js
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { parseArgs } from "node:util";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import fs from "node:fs";
|
|
5
|
+
|
|
6
|
+
let args;
|
|
7
|
+
try {
|
|
8
|
+
({ values: args } = parseArgs({
|
|
9
|
+
options: {
|
|
10
|
+
file: { type: "string", short: "f" },
|
|
11
|
+
main: { type: "string", short: "m" },
|
|
12
|
+
auto: { type: "boolean", short: "a", default: false },
|
|
13
|
+
all: { type: "boolean", short: "l", default: false },
|
|
14
|
+
require: { type: "string", short: "r", multiple: true },
|
|
15
|
+
import: { type: "string", short: "i", multiple: true },
|
|
16
|
+
"print-code": { type: "boolean", short: "p", default: false },
|
|
17
|
+
help: { type: "boolean", short: "h", default: false },
|
|
18
|
+
version: { type: "boolean", short: "v", default: false },
|
|
19
|
+
},
|
|
20
|
+
strict: true,
|
|
21
|
+
allowPositionals: true,
|
|
22
|
+
}));
|
|
23
|
+
} catch (err) {
|
|
24
|
+
console.error(err.message);
|
|
25
|
+
console.error("Run with --help to see supported options.");
|
|
26
|
+
process.exit(1);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (args.help) {
|
|
30
|
+
console.log(`
|
|
31
|
+
Run code blocks in your readme as tests
|
|
32
|
+
|
|
33
|
+
Usage: readme-assert [options]
|
|
34
|
+
|
|
35
|
+
Options:
|
|
36
|
+
--file, -f readme.md file to read
|
|
37
|
+
--main, -m Entry point of the module
|
|
38
|
+
--auto, -a Auto discover test code blocks
|
|
39
|
+
--all, -l Run all supported code blocks
|
|
40
|
+
--require, -r Require a module before running [array]
|
|
41
|
+
--import, -i Import a module before running [array]
|
|
42
|
+
--print-code, -p Print the transformed code
|
|
43
|
+
--version, -v Show version number
|
|
44
|
+
-h, --help Show help
|
|
45
|
+
`);
|
|
46
|
+
process.exit(0);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (args.version) {
|
|
50
|
+
const pkgPath = new URL("../package.json", import.meta.url);
|
|
51
|
+
const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8"));
|
|
52
|
+
console.log(pkg.version);
|
|
53
|
+
process.exit(0);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function findReadme() {
|
|
57
|
+
for (const name of ["README.md", "readme.md"]) {
|
|
58
|
+
const p = path.resolve(name);
|
|
59
|
+
if (fs.existsSync(p)) return p;
|
|
60
|
+
}
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const filePath = args.file ? path.resolve(args.file) : findReadme();
|
|
65
|
+
|
|
66
|
+
if (!filePath) {
|
|
67
|
+
console.error("Could not locate readme.md");
|
|
68
|
+
process.exit(1);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const opts = {
|
|
72
|
+
auto: args.auto,
|
|
73
|
+
all: args.all,
|
|
74
|
+
main: args.main,
|
|
75
|
+
require: args.require,
|
|
76
|
+
import: args.import,
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
try {
|
|
80
|
+
if (args["print-code"]) {
|
|
81
|
+
const { processMarkdown } = await import("./run.js");
|
|
82
|
+
const units = await processMarkdown(filePath, opts);
|
|
83
|
+
for (const unit of units) {
|
|
84
|
+
console.log(`# --- ${unit.name} ---`);
|
|
85
|
+
console.log(unit.code);
|
|
86
|
+
}
|
|
87
|
+
} else {
|
|
88
|
+
const { run } = await import("./run.js");
|
|
89
|
+
// stream: true pipes each child's stdout to process.stdout live so
|
|
90
|
+
// long-running blocks don't look stalled.
|
|
91
|
+
const { exitCode, stderr, results } = await run(filePath, { ...opts, stream: true });
|
|
92
|
+
if (stderr) process.stderr.write(stderr);
|
|
93
|
+
if (exitCode === 0) {
|
|
94
|
+
console.log(`All assertions passed. (${results.length} blocks)`);
|
|
95
|
+
}
|
|
96
|
+
process.exitCode = exitCode;
|
|
97
|
+
}
|
|
98
|
+
} catch (err) {
|
|
99
|
+
if (err?.code === "NO_TEST_BLOCKS") {
|
|
100
|
+
const relPath = path.relative(process.cwd(), filePath);
|
|
101
|
+
console.error(`No test code blocks found in ${relPath}`);
|
|
102
|
+
process.exitCode = 1;
|
|
103
|
+
} else {
|
|
104
|
+
throw err;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { parseSync } from "oxc-parser";
|
|
2
|
+
import MagicString from "magic-string";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Transform assertion comments into assert calls.
|
|
6
|
+
*
|
|
7
|
+
* expr //=> value → assert.deepEqual(expr, value)
|
|
8
|
+
* expr // → value → assert.deepEqual(expr, value)
|
|
9
|
+
* expr // throws /pat/ → assert.throws(() => { expr }, /pat/)
|
|
10
|
+
* console.log(x) //=> v → console.log(x); assert.deepEqual(x, v)
|
|
11
|
+
* expr //=> resolves to v → assert.deepEqual(await expr, v)
|
|
12
|
+
* expr // rejects /pat/ → assert.rejects(() => expr, /pat/)
|
|
13
|
+
*
|
|
14
|
+
* Uses oxc-parser for AST + comment extraction. Handles both JS and TS.
|
|
15
|
+
*
|
|
16
|
+
* @param {string} code - JavaScript or TypeScript source
|
|
17
|
+
* @param {{ typescript?: boolean }} options
|
|
18
|
+
* @returns {{ code: string }}
|
|
19
|
+
*/
|
|
20
|
+
export function commentToAssert(code, { typescript = false } = {}) {
|
|
21
|
+
const ext = typescript ? "test.ts" : "test.js";
|
|
22
|
+
const result = parseSync(ext, code);
|
|
23
|
+
const ast = result.program;
|
|
24
|
+
const comments = result.comments;
|
|
25
|
+
|
|
26
|
+
const s = new MagicString(code);
|
|
27
|
+
let changed = false;
|
|
28
|
+
|
|
29
|
+
for (const node of ast.body) {
|
|
30
|
+
if (node.type !== "ExpressionStatement") continue;
|
|
31
|
+
|
|
32
|
+
const comment = findTrailingComment(comments, node, code);
|
|
33
|
+
if (!comment) continue;
|
|
34
|
+
|
|
35
|
+
const match = comment.value.match(/^\s*(=>|→|->)\s*([\s\S]*)$/);
|
|
36
|
+
const throwsMatch = comment.value.match(/^\s*throws\s+([\s\S]*)$/);
|
|
37
|
+
const rejectsMatch = comment.value.match(/^\s*rejects\s+([\s\S]*)$/);
|
|
38
|
+
|
|
39
|
+
if (match) {
|
|
40
|
+
const rest = match[2].trim();
|
|
41
|
+
const resolvesMatch = rest.match(/^resolves\s+(?:to\s+)?([\s\S]*)$/);
|
|
42
|
+
changed = true;
|
|
43
|
+
|
|
44
|
+
if (resolvesMatch) {
|
|
45
|
+
// expr //=> resolves to value → assert.deepEqual(await expr, value)
|
|
46
|
+
const expected = resolvesMatch[1].trim();
|
|
47
|
+
const exprSource = code.slice(
|
|
48
|
+
node.expression.start,
|
|
49
|
+
node.expression.end,
|
|
50
|
+
);
|
|
51
|
+
s.overwrite(
|
|
52
|
+
node.start,
|
|
53
|
+
comment.end,
|
|
54
|
+
`assert.deepEqual(await ${exprSource}, ${expected});`,
|
|
55
|
+
);
|
|
56
|
+
} else if (isConsoleCall(node.expression)) {
|
|
57
|
+
// console.log(expr) //=> value → keep log, add assertion after.
|
|
58
|
+
// Stay on the same line so subsequent markdown line numbers are
|
|
59
|
+
// preserved for error reporting.
|
|
60
|
+
const arg = code.slice(
|
|
61
|
+
node.expression.arguments[0].start,
|
|
62
|
+
node.expression.arguments[0].end,
|
|
63
|
+
);
|
|
64
|
+
s.overwrite(
|
|
65
|
+
node.expression.end,
|
|
66
|
+
comment.end,
|
|
67
|
+
`; assert.deepEqual(${arg}, ${rest});`,
|
|
68
|
+
);
|
|
69
|
+
} else {
|
|
70
|
+
// expr //=> value → assert.deepEqual(expr, value)
|
|
71
|
+
const exprSource = code.slice(
|
|
72
|
+
node.expression.start,
|
|
73
|
+
node.expression.end,
|
|
74
|
+
);
|
|
75
|
+
s.overwrite(
|
|
76
|
+
node.start,
|
|
77
|
+
comment.end,
|
|
78
|
+
`assert.deepEqual(${exprSource}, ${rest});`,
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
} else if (throwsMatch) {
|
|
82
|
+
const pattern = throwsMatch[1].trim();
|
|
83
|
+
const exprSource = code.slice(
|
|
84
|
+
node.expression.start,
|
|
85
|
+
node.expression.end,
|
|
86
|
+
);
|
|
87
|
+
s.overwrite(
|
|
88
|
+
node.start,
|
|
89
|
+
comment.end,
|
|
90
|
+
`assert.throws(() => { ${exprSource}; }, ${pattern});`,
|
|
91
|
+
);
|
|
92
|
+
changed = true;
|
|
93
|
+
} else if (rejectsMatch) {
|
|
94
|
+
const pattern = rejectsMatch[1].trim();
|
|
95
|
+
const exprSource = code.slice(
|
|
96
|
+
node.expression.start,
|
|
97
|
+
node.expression.end,
|
|
98
|
+
);
|
|
99
|
+
s.overwrite(
|
|
100
|
+
node.start,
|
|
101
|
+
comment.end,
|
|
102
|
+
`await assert.rejects(() => ${exprSource}, ${pattern});`,
|
|
103
|
+
);
|
|
104
|
+
changed = true;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (!changed) return { code };
|
|
109
|
+
|
|
110
|
+
return { code: s.toString() };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Find a trailing line comment for an expression statement.
|
|
115
|
+
* The comment must start after the expression and be on the same line.
|
|
116
|
+
*/
|
|
117
|
+
function findTrailingComment(comments, node, code) {
|
|
118
|
+
for (const c of comments) {
|
|
119
|
+
if (c.type !== "Line") continue;
|
|
120
|
+
if (c.start < node.expression.end) continue;
|
|
121
|
+
|
|
122
|
+
// Must be on the same line as the expression (no newline between)
|
|
123
|
+
const between = code.slice(node.expression.end, c.start);
|
|
124
|
+
if (between.includes("\n")) continue;
|
|
125
|
+
|
|
126
|
+
return c;
|
|
127
|
+
}
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function isConsoleCall(expr) {
|
|
132
|
+
return (
|
|
133
|
+
expr.type === "CallExpression" &&
|
|
134
|
+
expr.callee.type === "MemberExpression" &&
|
|
135
|
+
expr.callee.object.type === "Identifier" &&
|
|
136
|
+
expr.callee.object.name === "console"
|
|
137
|
+
);
|
|
138
|
+
}
|
package/src/extract.js
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Extract tagged code blocks from a markdown string.
|
|
3
|
+
*
|
|
4
|
+
* @param {string} markdown
|
|
5
|
+
* @param {{ auto?: boolean, all?: boolean }} options
|
|
6
|
+
* @returns {{ blocks: Block[], hasTypescript: boolean }}
|
|
7
|
+
*
|
|
8
|
+
* Block: { code, lang, tag, group, startLine, endLine }
|
|
9
|
+
*
|
|
10
|
+
* Tags: "test", "test:groupname", "should description", "should:groupname description"
|
|
11
|
+
* Blocks with the same group name are merged into a single execution unit.
|
|
12
|
+
* Blocks without a group name each run independently.
|
|
13
|
+
*/
|
|
14
|
+
export function extractBlocks(markdown, { auto = false, all = false } = {}) {
|
|
15
|
+
// Based on gfm-code-block-regex — the backreference \2 ensures a 4-backtick
|
|
16
|
+
// fence only closes with 4 backticks, so nested display fences are skipped.
|
|
17
|
+
const fenceRe = /^(([ \t]*`{3,4})([^\n]*)([\s\S]+?)(^[ \t]*\2))/gm;
|
|
18
|
+
const supportedLangs = new Set(["javascript", "js", "typescript", "ts"]);
|
|
19
|
+
const tsLangs = new Set(["typescript", "ts"]);
|
|
20
|
+
const assertRe = /\/[/*]\s*(=>|→|->|throws|rejects)/;
|
|
21
|
+
|
|
22
|
+
let hasTypescript = false;
|
|
23
|
+
const blocks = [];
|
|
24
|
+
let match;
|
|
25
|
+
|
|
26
|
+
while ((match = fenceRe.exec(markdown)) !== null) {
|
|
27
|
+
const infoString = match[3].trim();
|
|
28
|
+
const code = match[4].replace(/^\n/, "");
|
|
29
|
+
const blockStart = match.index;
|
|
30
|
+
|
|
31
|
+
// Parse language and tag from info string (e.g. "javascript test" or "ts test:group")
|
|
32
|
+
const parts = infoString.split(/\s+/);
|
|
33
|
+
const lang = parts[0] || "";
|
|
34
|
+
const tag = parts.slice(1).join(" ");
|
|
35
|
+
|
|
36
|
+
if (!supportedLangs.has(lang)) continue;
|
|
37
|
+
|
|
38
|
+
// Filter by mode
|
|
39
|
+
if (!all) {
|
|
40
|
+
if (auto) {
|
|
41
|
+
if (!assertRe.test(code)) continue;
|
|
42
|
+
} else {
|
|
43
|
+
const firstWord = tag.split(/\s+/)[0] || "";
|
|
44
|
+
const keyword = firstWord.split(":")[0];
|
|
45
|
+
if (keyword !== "test" && keyword !== "should") continue;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Count lines before this block to get startLine (1-based)
|
|
50
|
+
const linesBeforeBlock = markdown.slice(0, blockStart).split("\n").length;
|
|
51
|
+
const startLine = linesBeforeBlock + 1; // +1 for the fence line itself
|
|
52
|
+
const codeLines = code.split("\n").length;
|
|
53
|
+
const endLine = startLine + codeLines - 1;
|
|
54
|
+
|
|
55
|
+
if (tsLangs.has(lang)) hasTypescript = true;
|
|
56
|
+
|
|
57
|
+
// Parse group from tag: "test:mygroup ..." or "should:mygroup ..."
|
|
58
|
+
const firstTagWord = tag.split(/\s+/)[0] || "";
|
|
59
|
+
const colonIdx = firstTagWord.indexOf(":");
|
|
60
|
+
const group = colonIdx !== -1 ? firstTagWord.slice(colonIdx + 1) : null;
|
|
61
|
+
|
|
62
|
+
blocks.push({ code, lang, tag, group, startLine, endLine });
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return { blocks, hasTypescript };
|
|
66
|
+
}
|