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.
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Assemble extracted code blocks into runnable JS modules.
3
+ *
4
+ * Each block becomes its own module unless blocks share a group name,
5
+ * in which case they are merged into a single module.
6
+ *
7
+ * @param {{ blocks: Block[], hasTypescript: boolean }} extracted
8
+ * @returns {{ units: Array<{ code: string, name: string, hasTypescript: boolean }> }}
9
+ */
10
+ export function generate({ blocks }) {
11
+ if (blocks.length === 0) return { units: [] };
12
+
13
+ // Group blocks: blocks with a group name are merged, others are standalone
14
+ const groups = new Map();
15
+ const units = [];
16
+
17
+ for (const block of blocks) {
18
+ if (block.group) {
19
+ if (!groups.has(block.group)) {
20
+ const entry = { blocks: [], name: block.group };
21
+ groups.set(block.group, entry);
22
+ units.push(entry);
23
+ }
24
+ groups.get(block.group).blocks.push(block);
25
+ } else {
26
+ units.push({ blocks: [block], name: block.tag || `line ${block.startLine}` });
27
+ }
28
+ }
29
+
30
+ return {
31
+ units: units.map((unit) => ({
32
+ code: assembleUnit(unit.blocks),
33
+ name: unit.name,
34
+ hasTypescript: unit.blocks.some((b) => b.lang === "typescript" || b.lang === "ts"),
35
+ })),
36
+ };
37
+ }
38
+
39
+ function assembleUnit(blocks) {
40
+ // Find the last line number we need to cover
41
+ const maxLine = Math.max(...blocks.map((b) => b.endLine));
42
+
43
+ // Build a line array filled with empty strings
44
+ const lines = new Array(maxLine).fill("");
45
+
46
+ // Place each block's code at its source position
47
+ for (const block of blocks) {
48
+ const codeLines = block.code.replace(/\n$/, "").split("\n");
49
+ for (let i = 0; i < codeLines.length; i++) {
50
+ lines[block.startLine - 1 + i] = codeLines[i];
51
+ }
52
+ }
53
+
54
+ // Separate import/export lines from body lines
55
+ const imports = [];
56
+ const bodyLines = [];
57
+
58
+ for (let i = 0; i < lines.length; i++) {
59
+ const trimmed = lines[i].trimStart();
60
+ if (isImportOrExport(trimmed)) {
61
+ imports.push(lines[i]);
62
+ bodyLines.push(""); // keep line padding
63
+ } else {
64
+ bodyLines.push(lines[i]);
65
+ }
66
+ }
67
+
68
+ const hasESM = imports.length > 0;
69
+ const hasCJS = /\brequire\s*\(/.test(bodyLines.join("\n"));
70
+
71
+ // Place assert import on line 0 (before markdown line 1) so line numbers
72
+ // in the generated code match the original markdown positions exactly.
73
+ let assertLine;
74
+ if (hasESM) {
75
+ assertLine = 'import assert from "node:assert/strict";';
76
+ } else if (hasCJS) {
77
+ assertLine = 'const assert = require("node:assert/strict");';
78
+ } else {
79
+ assertLine = 'const { default: assert } = await import("node:assert/strict");';
80
+ }
81
+
82
+ // imports go on line 0 too (they're already removed from bodyLines).
83
+ // Each piece is its own statement, so a single space between them is
84
+ // enough; joining with "; " produced a double semicolon like ";; ".
85
+ const header = [assertLine, ...imports].join(" ");
86
+ bodyLines[0] = header;
87
+ return bodyLines.join("\n") + "\n";
88
+ }
89
+
90
+ function isImportOrExport(line) {
91
+ return (
92
+ /^import\s/.test(line) ||
93
+ /^import\(/.test(line) ||
94
+ /^export\s/.test(line)
95
+ );
96
+ }
package/src/index.js ADDED
@@ -0,0 +1,4 @@
1
+ export { extractBlocks } from "./extract.js";
2
+ export { commentToAssert } from "./comment-to-assert.js";
3
+ export { generate } from "./generate.js";
4
+ export { processMarkdown, run } from "./run.js";
package/src/run.js ADDED
@@ -0,0 +1,320 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { spawn } from "node:child_process";
4
+ import { randomUUID } from "node:crypto";
5
+ import { extractBlocks } from "./extract.js";
6
+ import { generate } from "./generate.js";
7
+ import { commentToAssert } from "./comment-to-assert.js";
8
+
9
+ const tmpFiles = new Set();
10
+
11
+ function cleanupTmpFiles() {
12
+ for (const f of tmpFiles) {
13
+ try { fs.unlinkSync(f); } catch {}
14
+ }
15
+ tmpFiles.clear();
16
+ }
17
+
18
+ process.on("exit", cleanupTmpFiles);
19
+
20
+ for (const signal of ["SIGINT", "SIGTERM"]) {
21
+ process.once(signal, () => {
22
+ cleanupTmpFiles();
23
+ process.kill(process.pid, signal);
24
+ });
25
+ }
26
+
27
+ /**
28
+ * Process a markdown file into executable code units.
29
+ *
30
+ * @param {string} filePath
31
+ * @param {{ auto?: boolean, all?: boolean, main?: string }} options
32
+ * @returns {Promise<Array<{ code: string, name: string }>>}
33
+ */
34
+ export async function processMarkdown(filePath, options = {}) {
35
+ const markdown = fs.readFileSync(filePath, "utf-8");
36
+ const extracted = extractBlocks(markdown, {
37
+ auto: options.auto,
38
+ all: options.all,
39
+ });
40
+
41
+ if (extracted.blocks.length === 0) {
42
+ const err = new Error(`No test code blocks found in ${filePath}`);
43
+ err.code = "NO_TEST_BLOCKS";
44
+ throw err;
45
+ }
46
+
47
+ const { units } = generate(extracted);
48
+
49
+ // Resolve package info for import renaming
50
+ let packageName, localPath;
51
+ const pkgPath = findPackageJson(path.dirname(filePath));
52
+ if (pkgPath) {
53
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8"));
54
+ if (pkg.name) {
55
+ const mainEntry = options.main || resolveMainEntry(pkg) || "./index.js";
56
+ packageName = pkg.name;
57
+ localPath = path.resolve(path.dirname(pkgPath), mainEntry);
58
+ }
59
+ }
60
+
61
+ const results = [];
62
+ for (const unit of units) {
63
+ let code = unit.code;
64
+
65
+ if (packageName) {
66
+ code = renameImports(code, packageName, localPath);
67
+ }
68
+
69
+ const transformed = commentToAssert(code, {
70
+ typescript: unit.hasTypescript,
71
+ });
72
+ code = transformed.code;
73
+
74
+ if (unit.hasTypescript) {
75
+ const esbuild = await import("esbuild");
76
+ const result = await esbuild.transform(code, {
77
+ loader: "ts",
78
+ sourcemap: false,
79
+ });
80
+ code = result.code;
81
+ }
82
+
83
+ results.push({ code, name: unit.name });
84
+ }
85
+
86
+ return results;
87
+ }
88
+
89
+ /**
90
+ * Run a markdown file as a test.
91
+ *
92
+ * Each code block (or group) is written to a temp file and executed
93
+ * sequentially. Stops on first failure.
94
+ *
95
+ * When `options.stream` is true, each child's stdout chunk is written
96
+ * to `process.stdout` as it arrives so long-running blocks don't look
97
+ * stalled. Captured stdout is still returned in the result for
98
+ * programmatic callers.
99
+ *
100
+ * @param {string} filePath
101
+ * @param {{ auto?: boolean, all?: boolean, main?: string, stream?: boolean }} options
102
+ * @returns {Promise<{ exitCode: number, stdout: string, stderr: string, results: Array }>}
103
+ */
104
+ export async function run(filePath, options = {}) {
105
+ const units = await processMarkdown(filePath, options);
106
+ const dir = path.dirname(filePath);
107
+ let allStdout = "";
108
+ let allStderr = "";
109
+ const results = [];
110
+
111
+ const useRequire = options.require?.length > 0;
112
+ const stream = options.stream ?? false;
113
+
114
+ for (const unit of units) {
115
+ let code = unit.code;
116
+
117
+ // --require hooks only work with CJS, so downgrade dynamic import to require
118
+ if (useRequire && code.includes("await import(")) {
119
+ code = code.replace(
120
+ 'const { default: assert } = await import("node:assert/strict");',
121
+ 'const assert = require("node:assert/strict");',
122
+ );
123
+ }
124
+
125
+ const isESM = /^import\s/m.test(code) || /^export\s/m.test(code) || code.includes("await import(");
126
+ const ext = isESM ? ".mjs" : ".cjs";
127
+ const tmpFile = path.join(dir, `.readme-assert-${randomUUID().slice(0, 8)}${ext}`);
128
+ tmpFiles.add(tmpFile);
129
+ fs.writeFileSync(tmpFile, code);
130
+
131
+ try {
132
+ const nodeArgs = [];
133
+ for (const r of options.require || []) nodeArgs.push("--require", r);
134
+ for (const i of options.import || []) nodeArgs.push("--import", i);
135
+ nodeArgs.push(tmpFile);
136
+ const result = await exec("node", nodeArgs, dir, filePath, stream);
137
+ allStdout += result.stdout;
138
+ allStderr += result.stderr;
139
+ results.push({ name: unit.name, ...result });
140
+
141
+ if (result.exitCode !== 0) {
142
+ return { exitCode: result.exitCode, stdout: allStdout, stderr: allStderr, results };
143
+ }
144
+ } finally {
145
+ try { fs.unlinkSync(tmpFile); } catch {}
146
+ tmpFiles.delete(tmpFile);
147
+ }
148
+ }
149
+
150
+ return { exitCode: 0, stdout: allStdout, stderr: allStderr, results };
151
+ }
152
+
153
+ function exec(cmd, args, cwd, mdPath, stream) {
154
+ return new Promise((resolve) => {
155
+ const child = spawn(cmd, args, { cwd, stdio: ["ignore", "pipe", "pipe"] });
156
+ let stdout = "";
157
+ let stderr = "";
158
+
159
+ child.stdout.on("data", (chunk) => {
160
+ if (stream) process.stdout.write(chunk);
161
+ stdout += chunk;
162
+ });
163
+ child.stderr.on("data", (d) => (stderr += d));
164
+
165
+ child.on("close", (exitCode) => {
166
+ // Rewrite temp file paths to the markdown file path
167
+ const tmpFile = args[args.length - 1];
168
+ stderr = stderr.replaceAll(tmpFile, mdPath);
169
+ stdout = stdout.replaceAll(tmpFile, mdPath);
170
+
171
+ if (exitCode !== 0) {
172
+ stderr = formatError(stderr, mdPath);
173
+ }
174
+
175
+ resolve({ exitCode, stdout, stderr });
176
+ });
177
+ });
178
+ }
179
+
180
+ function formatError(stderr, mdPath) {
181
+ // Extract location from stack trace
182
+ const locMatch = stderr.match(new RegExp(`${escapeRegExp(mdPath)}:(\\d+):(\\d+)`));
183
+ const line = locMatch ? parseInt(locMatch[1]) : null;
184
+
185
+ // Extract actual/expected from the error object dump
186
+ const actualMatch = stderr.match(/actual: (.+)/);
187
+ const expectedMatch = stderr.match(/expected: (.+)/);
188
+ const operatorMatch = stderr.match(/operator: '(.+)'/);
189
+
190
+ // Extract the error message line
191
+ const msgMatch = stderr.match(/AssertionError.*?:\s*(.+)/);
192
+ // Also catch non-assertion errors (ReferenceError, TypeError, etc.)
193
+ const genericMatch = !msgMatch && stderr.match(/(\w*Error.*)/);
194
+
195
+ const parts = [];
196
+
197
+ // Location header
198
+ const relPath = path.relative(process.cwd(), mdPath);
199
+ if (line) {
200
+ parts.push(`\n FAIL ${relPath}:${line}\n`);
201
+ } else {
202
+ parts.push(`\n FAIL ${relPath}\n`);
203
+ }
204
+
205
+ // Source context from the markdown
206
+ if (line) {
207
+ try {
208
+ const mdLines = fs.readFileSync(mdPath, "utf-8").split("\n");
209
+ const start = Math.max(0, line - 3);
210
+ const end = Math.min(mdLines.length, line + 2);
211
+ for (let i = start; i < end; i++) {
212
+ const lineNum = String(i + 1).padStart(4);
213
+ const marker = i + 1 === line ? " > " : " ";
214
+ parts.push(`${marker}${lineNum} | ${mdLines[i]}`);
215
+ }
216
+ parts.push("");
217
+ } catch {
218
+ // ignore read errors
219
+ }
220
+ }
221
+
222
+ // Actual vs expected
223
+ if (actualMatch && expectedMatch) {
224
+ parts.push(` expected: ${expectedMatch[1].replace(/,\s*$/, "")}`);
225
+ parts.push(` received: ${actualMatch[1].replace(/,\s*$/, "")}`);
226
+ parts.push("");
227
+ } else if (msgMatch) {
228
+ parts.push(` ${msgMatch[0]}`);
229
+ parts.push("");
230
+ } else if (genericMatch) {
231
+ parts.push(` ${genericMatch[1]}`);
232
+ parts.push("");
233
+ } else {
234
+ // Fallback: strip Node internals and return cleaned stderr
235
+ parts.push(
236
+ stderr
237
+ .split("\n")
238
+ .filter((l) => !l.match(/^\s*(at [a-z].*\(node:|node:internal|Node\.js v|triggerUncaught|\^$)/i))
239
+ .join("\n")
240
+ .trim(),
241
+ );
242
+ parts.push("");
243
+ }
244
+
245
+ return parts.join("\n");
246
+ }
247
+
248
+ function escapeRegExp(s) {
249
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
250
+ }
251
+
252
+ function renameImports(code, packageName, localPath) {
253
+ const escaped = packageName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
254
+ return code
255
+ .replace(
256
+ new RegExp(`(from\\s+['"])${escaped}(['"])`, "g"),
257
+ `$1${localPath}$2`,
258
+ )
259
+ .replace(
260
+ new RegExp(`(from\\s+['"])${escaped}/`, "g"),
261
+ `$1${path.dirname(localPath)}/`,
262
+ )
263
+ .replace(
264
+ new RegExp(`(require\\s*\\(\\s*['"])${escaped}(['"]\\s*\\))`, "g"),
265
+ `$1${localPath}$2`,
266
+ );
267
+ }
268
+
269
+ function findPackageJson(dir) {
270
+ let current = path.resolve(dir);
271
+ while (true) {
272
+ const candidate = path.join(current, "package.json");
273
+ if (fs.existsSync(candidate)) return candidate;
274
+ const parent = path.dirname(current);
275
+ if (parent === current) return null;
276
+ current = parent;
277
+ }
278
+ }
279
+
280
+ /**
281
+ * Resolve a package's main entry point from its package.json.
282
+ *
283
+ * Handles `main`, plus the various shapes of `exports`:
284
+ * - string: "exports": "./lib/main.js"
285
+ * - subpath map: "exports": { ".": "./lib/main.js" }
286
+ * - conditional: "exports": { "import": "./esm.js", "require": "./cjs.js" }
287
+ * - nested: "exports": { ".": { "import": "./esm.js" } }
288
+ *
289
+ * Returns null if no entry can be determined.
290
+ */
291
+ export function resolveMainEntry(pkg) {
292
+ if (pkg.main) return pkg.main;
293
+
294
+ const exp = pkg.exports;
295
+ if (!exp) return null;
296
+ if (typeof exp === "string") return exp;
297
+ if (typeof exp !== "object") return null;
298
+
299
+ // If any key starts with ".", this is a subpath map and the root export
300
+ // lives at "."; otherwise the object itself is the conditional map.
301
+ const isSubpathMap = Object.keys(exp).some((k) => k.startsWith("."));
302
+ const root = isSubpathMap ? exp["."] : exp;
303
+
304
+ return resolveExportCondition(root);
305
+ }
306
+
307
+ function resolveExportCondition(node) {
308
+ if (node == null) return null;
309
+ if (typeof node === "string") return node;
310
+ if (typeof node !== "object") return null;
311
+
312
+ // Prefer import > default > require
313
+ for (const key of ["import", "default", "require"]) {
314
+ if (key in node) {
315
+ const resolved = resolveExportCondition(node[key]);
316
+ if (resolved) return resolved;
317
+ }
318
+ }
319
+ return null;
320
+ }
package/cli.js DELETED
@@ -1,9 +0,0 @@
1
- #!/usr/bin/env node
2
- /* eslint-disable */
3
- if (process.version.match(/v(\d+)\./)[1] < 8) {
4
- console.error(
5
- "readme-assert: Node 8 or greater is required. `readme-assert` did not run."
6
- );
7
- } else {
8
- require("./lib/cli.js");
9
- }
package/lib/cli.js DELETED
@@ -1,62 +0,0 @@
1
- "use strict";
2
-
3
- var _yargs = _interopRequireDefault(require("yargs"));
4
-
5
- var _ = _interopRequireDefault(require("."));
6
-
7
- var _package = require("../package.json");
8
-
9
- var _fs = _interopRequireDefault(require("fs"));
10
-
11
- var _path = _interopRequireDefault(require("path"));
12
-
13
- function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { "default": obj }; }
14
-
15
- var argv = _yargs["default"].usage("\nRun readme as test\n\nUsage: $0 [options]").option("auto", {
16
- alias: "a",
17
- description: "Auto discover test code block",
18
- type: "boolean"
19
- }).option("all", {
20
- alias: "l",
21
- description: "Run all supported code blocks",
22
- type: "boolean"
23
- }).option("babel", {
24
- description: "Use babelrc when transpiling",
25
- "default": false,
26
- type: "boolean"
27
- }).option("file", {
28
- alias: "f",
29
- description: "readme.md file to read"
30
- }).option("main", {
31
- alias: "m",
32
- description: "Points to the entry point of the module",
33
- type: "string"
34
- }).option("print-code", {
35
- alias: "p",
36
- description: "Print the transformed code",
37
- type: "boolean"
38
- }).option("require", {
39
- alias: "r",
40
- description: "Require a given module",
41
- type: "array"
42
- }).alias("h", "help").version(_package.version).help().argv;
43
-
44
- function resolve(file) {
45
- try {
46
- _fs["default"].statSync(_path["default"].join(process.cwd(), file));
47
-
48
- return _path["default"].resolve(file);
49
- } catch (err) {
50
- return undefined;
51
- }
52
- }
53
-
54
- var filename = argv.file ? _path["default"].resolve(argv.file) : resolve("README.md") || resolve("readme.md");
55
-
56
- if (filename == null) {
57
- console.log(_fs["default"].statSync(_path["default"].join(process.cwd(), "README.md")));
58
- console.error("could not locate readme.md");
59
- process.exit(1);
60
- }
61
-
62
- (0, _["default"])(argv.main, argv.require || [], argv["print-code"], argv.babel, argv.file ? _path["default"].resolve(argv.file) : resolve("README.md") || resolve("readme.md"), argv.auto, argv.all);
package/lib/extract.js DELETED
@@ -1,74 +0,0 @@
1
- "use strict";
2
-
3
- Object.defineProperty(exports, "__esModule", {
4
- value: true
5
- });
6
- exports["default"] = extractCode;
7
-
8
- var _gfmCodeBlocks = _interopRequireDefault(require("gfm-code-blocks"));
9
-
10
- function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { "default": obj }; }
11
-
12
- function _toConsumableArray(arr) { return _arrayWithoutHoles(arr) || _iterableToArray(arr) || _nonIterableSpread(); }
13
-
14
- function _nonIterableSpread() { throw new TypeError("Invalid attempt to spread non-iterable instance"); }
15
-
16
- function _iterableToArray(iter) { if (Symbol.iterator in Object(iter) || Object.prototype.toString.call(iter) === "[object Arguments]") return Array.from(iter); }
17
-
18
- function _arrayWithoutHoles(arr) { if (Array.isArray(arr)) { for (var i = 0, arr2 = new Array(arr.length); i < arr.length; i++) { arr2[i] = arr[i]; } return arr2; } }
19
-
20
- function isSupportedLang(block) {
21
- var lang = block.lang.split(" ")[0];
22
- return lang === "javascript" || lang === "js" || lang === "typescript" || lang === "ts";
23
- }
24
-
25
- function isTypescript(block) {
26
- var lang = block.lang.split(" ")[0];
27
- return lang === "typescript" || lang === "ts";
28
- }
29
-
30
- function isTest(block) {
31
- var tag = block.lang.split(" ")[1];
32
- return tag === "test" || tag === "should";
33
- }
34
-
35
- var arrowRegex = /\/(\/|\*)\s?(=>|→|throws)/;
36
-
37
- function isAutomaticTest(block) {
38
- return block.code.match(arrowRegex);
39
- }
40
-
41
- function extractCode(markdown) {
42
- var _ref = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {},
43
- _ref$auto = _ref.auto,
44
- auto = _ref$auto === void 0 ? false : _ref$auto,
45
- _ref$all = _ref.all,
46
- all = _ref$all === void 0 ? false : _ref$all;
47
-
48
- var hasTypescript = false;
49
- var code = new Array(markdown.length).fill(" ");
50
- var newline = /\n/gm;
51
- var result;
52
-
53
- while (result = newline.exec(markdown)) {
54
- code[result.index] = "\n";
55
- }
56
-
57
- var blocks = (0, _gfmCodeBlocks["default"])(markdown).filter(all ? function () {
58
- return true;
59
- } : auto ? isAutomaticTest : isTest).filter(isSupportedLang);
60
-
61
- if (blocks.length === 0) {
62
- console.error("\nREADME has no test code blocks\n");
63
- process.exit(1);
64
- }
65
-
66
- blocks.forEach(function (block) {
67
- hasTypescript = hasTypescript || isTypescript(block);
68
- code.splice.apply(code, [block.start, block.end - block.start].concat(_toConsumableArray(block.code.padStart(block.end - block.start, " ").split(""))));
69
- });
70
- return {
71
- code: code.join(""),
72
- hasTypescript: hasTypescript
73
- };
74
- }