├── .github ├── FUNDING.yml └── ISSUE_TEMPLATE │ └── issue-template.md ├── .gitignore ├── .vscode └── tasks.json ├── CHANGELOG.md ├── LICENSE ├── README.md ├── demo ├── README.md ├── demo-babel.md ├── demo-bash.md ├── demo-javascript.md ├── demo-node-repl.md └── demo-typescript.md ├── doc ├── languages │ ├── html.md │ ├── rust.md │ └── typescript.md └── manual │ ├── README.md │ ├── faq.md │ ├── quick-start.md │ ├── reference.md │ ├── related-tools.md │ ├── screenshot-markcheck.jpg │ └── tips.md ├── markcheck-data ├── package-lock.json └── package.json ├── package-lock.json ├── package.json ├── src ├── core │ ├── config.ts │ ├── config_test.ts │ ├── parse-markdown.ts │ ├── parse-markdown_test.ts │ └── run-entities.ts ├── entity │ ├── config-mod.ts │ ├── directive.ts │ ├── directive_test.ts │ ├── entity-helpers.ts │ ├── entity-helpers_test.ts │ ├── heading.ts │ ├── insertion-rules.ts │ ├── insertion-rules_test.ts │ ├── line-loc-set.ts │ ├── line-loc-set_test.ts │ ├── line-mod.ts │ ├── markcheck-entity.ts │ ├── snippet.ts │ └── snippet_test.ts ├── markcheck.ts ├── translation │ ├── repl-to-js-translator.ts │ ├── repl-to-js-translator_test.ts │ └── translation.ts └── util │ ├── diffing.ts │ ├── errors.ts │ ├── line-tools.ts │ ├── path-tools.ts │ ├── search-and-replace-spec.ts │ ├── search-and-replace-spec_test.ts │ ├── string.ts │ └── string_test.ts ├── test ├── assembling-lines │ ├── before-after-around_test.ts │ ├── ignore-lines_test.ts │ ├── include_test.ts │ ├── insert_test.ts │ ├── line-mods_test.ts │ ├── node-repl_test.ts │ ├── search-and-replace_test.ts │ └── sequence_test.ts ├── checks │ ├── contained-in-file_test.ts │ ├── duplicate-id_test.ts │ ├── exit-status_test.ts │ └── stdout_test.ts ├── misc │ ├── config-mod_test.ts │ ├── indented-directive_test.ts │ ├── subdirectory_test.ts │ └── write_test.ts └── test-tools.ts └── tsconfig.json /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: ['rauschma'] 4 | liberapay: 'rauschma' 5 | custom: ['https://paypal.me/rauschma', 'https://buy.stripe.com/bIY4hd5etaYZ9d6cMM'] 6 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/issue-template.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug, feature suggestion or question 3 | about: Submit an issue for a bug or a feature suggestion. Create a discussion if you have a question. 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | --- 8 | 9 | What is your concern? 10 | 11 | * I have found a bug: 12 | * Please go through all existing issues. If your bug isn’t mentioned there: File an issue. 13 | * I’d like to suggest a feature: 14 | * Please go through all existing issues. If your feature isn’t mentioned there: File an issue. 15 | * I have a question about using Markcheck: 16 | * Please see [the Markcheck manual](https://github.com/rauschma/markcheck/tree/main/doc/manual). If you question isn’t answered there: [Create a discussion](https://github.com/rauschma/markcheck/discussions). 17 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dist 2 | # Also matches markcheck-data/node_modules 3 | node_modules 4 | .DS_Store 5 | markcheck-data/tmp 6 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0.0", 3 | "tasks": [ 4 | { 5 | "type": "npm", 6 | "script": "watch", 7 | "group": { 8 | "kind": "build", 9 | "isDefault": true 10 | }, 11 | "problemMatcher": [ 12 | "$tsc" 13 | ], 14 | "label": "npm: watch", 15 | "detail": "tsc --watch" 16 | } 17 | ] 18 | } -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | * 1.3.0 [2025-05-05]: 2 | * node-repl: support exception names such as "DOMException [DataCloneError]" 3 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Axel Rauschmayer 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Markcheck 2 | 3 | * Markcheck tests Markdown code blocks – to prevent errors in documentation (readmes, blogs, books, etc.). 4 | * Name of npm package: [`markcheck`](https://www.npmjs.com/package/markcheck) 5 | 6 | ## Why Markcheck? 7 | 8 | Highlights: 9 | 10 | * **Uses normal Markdown syntax** – not a custom version of it: Everything custom happens inside Markdown comments. 11 | 12 | * **No external files:** The Markdown file contains all the information that is needed to run it: Configuration files, demo text files, etc. can all be embedded in Markdown. 13 | * Exception: Some data remains external – e.g. npm packages used by JavaScript code. 14 | 15 | * **Works for most programming languages:** The only requirement is that there is a shell command that runs text files with the language’s code. See [`demo/demo-bash.md`](demo/demo-bash.md?plain=1) for an example of testing a programming language that Markcheck has no built-in support for. 16 | 17 | * **Successfully used in a big project:** I tested almost all of the code shown in my book [“Exploring JavaScript”](https://exploringjs.com/js/). Its PDF has 687 pages. 18 | 19 | * **Provides versatile tools for checking code:** Human readers of the published Markdown never see the complexity that is occasionally needed to make code blocks testable. These are some of the tools at our disposal – they can all be used from within Markdown files: 20 | * Check stderr and/or stdout. 21 | * Concatenate code blocks in any order. 22 | * Use code hidden from readers. 23 | * Write arbitrary text files to disk (example files, config files, etc.). 24 | * Etc. 25 | 26 | Checking JavaScript is reasonably fast: 27 | 28 | * Checking all the examples in “Exploring JavaScript” takes 50 seconds on a MacBook Pro with an M1 Pro processor. There is a lot of code in this book. 29 | * Checking one of the longer chapters takes 5 seconds. 30 | 31 | **Caveats:** 32 | 33 | * Only tested on macOS. I used cross-platform mechanisms where I could but I don’t know if Markcheck works on Windows. Please let me know either way. 34 | * ⚠️ There is currently no sandboxing of any kind: Only use Markcheck with files you trust. 35 | * Checking TypeScript code: 36 | * Downside: slow 37 | * Upside: You can write very expressive code that works well for explaining language mechanisms. See [`demo/demo-typescript.md`](demo/demo-typescript.md?plain=1) for more information. 38 | 39 | ## What does Markcheck’s syntax look like? 40 | 41 | The following subsections contain three examples. For more examples, see [the quick start part of Markcheck’s manual](doc/manual/quick-start.md#markdown-examples). 42 | 43 | ### Checking basic code blocks 44 | 45 | ``````md 46 | ```js 47 | assert.equal( 48 | 'abc' + 'abc', 49 | 'abcabc' 50 | ); 51 | ``` 52 | `````` 53 | 54 | No additional configuration is needed: The Node.js `assert.*` methods are available by default. 55 | 56 | ### Checking standard output via `stdout` 57 | 58 | 59 | ``````md 60 | ```js 61 | console.log('Hello!'); 62 | ``` 63 | 64 | 65 | ``` 66 | Hello! 67 | ``` 68 | `````` 69 | 70 | ### Hiding code via `before:` 71 | 72 | 73 | ``````md 74 | 79 | ```js 80 | try { 81 | functionThatShouldThrow(); 82 | assert.fail(); 83 | } catch (_) { 84 | // Success 85 | } 86 | ``` 87 | `````` 88 | 89 | ## More information on Markcheck 90 | 91 | * [Demo files](demo/README.md) with code blocks in these languages: JavaScript, TypeScript, Babel, Node.js REPL, Bash 92 | * [Quick start with many examples](doc/manual/quick-start.md) 93 | * [Manual](doc/manual/) 94 | 95 | ## Donations 96 | 97 | I have rewritten Markcheck several times over the years, until I arrived at the current version. If you find this tool or [any of my other free work](https://dr-axel.de) useful, I would appreciate a donation: 98 | 99 | * One-time donation: 100 | * [Paypal](https://paypal.me/rauschma) 101 | * [Stripe](https://buy.stripe.com/bIY4hd5etaYZ9d6cMM) 102 | * Recurring donations: 103 | * [GitHub Sponsors](https://github.com/sponsors/rauschma) 104 | * [Liberapay](https://liberapay.com/rauschma/donate) 105 | -------------------------------------------------------------------------------- /demo/README.md: -------------------------------------------------------------------------------- 1 | # Markcheck demos 2 | 3 | Check files like this: 4 | 5 | ``` 6 | npx markcheck demo-javascript.md 7 | ``` 8 | 9 | Files that don’t require installations: 10 | 11 | * [`demo-javascript.md`](demo-javascript.md?plain=1) 12 | * [`demo-node-repl.md`](demo-node-repl.md?plain=1) 13 | 14 | Files that have dependencies specified in `../markcheck-data/package.json` (and require an `npm install` there): 15 | 16 | * [`demo-typescript.md`](demo-typescript.md?plain=1) 17 | * [`demo-babel.md`](demo-babel.md?plain=1) 18 | 19 | The following file demonstrates how to check languages that have no built-in support in Markcheck: 20 | 21 | * [`demo-bash.md`](demo-bash.md?plain=1) 22 | -------------------------------------------------------------------------------- /demo/demo-babel.md: -------------------------------------------------------------------------------- 1 | # Markcheck demo: Babel 2 | 3 | ## Installing Babel and plugins 4 | 5 | The following packages must be installed manually via npm: 6 | 7 | 8 | ```json 9 | // markcheck-data/package.json 10 | { 11 | "dependencies": { 12 | "@babel/plugin-proposal-decorators": "^7.21.0", 13 | "babel-register-esm": "^1.2.4" 14 | } 15 | } 16 | ``` 17 | 18 | ## Setting up Markcheck 19 | 20 | We use a directive to tell Markcheck to treat `js` code blocks as if they were `babel` code blocks. 21 | 22 | 31 | 32 | ## Configuring Babel 33 | 34 | We need to configure Babel. We use the “project-wide” configuration file `babel.config.json` because it works with both files and stdin (which Markcheck may use in the future). 35 | 36 | More information on Babel configuration files: https://babeljs.io/docs/en/config-files 37 | 38 | 45 | 46 | ## JavaScript code 47 | 48 | This code will be transpiled via Babel before it runs: 49 | 50 | ```js 51 | @annotation 52 | class MyClass {} 53 | 54 | function annotation(target) { 55 | target.annotated = 'yes'; 56 | } 57 | 58 | assert.equal(MyClass.annotated, 'yes'); 59 | ``` 60 | -------------------------------------------------------------------------------- /demo/demo-bash.md: -------------------------------------------------------------------------------- 1 | # Markcheck demo: bash 2 | 3 | 15 | 16 | ### Checking standard output via `stdout` 17 | 18 | ```bash 19 | echo 'Hello!' 20 | ``` 21 | 22 | Expected stdout: 23 | 24 | 25 | ``` 26 | Hello! 27 | ``` 28 | 29 | ### Expecting a nonzero exit status 30 | 31 | This command fails: 32 | 33 | 34 | ```bash 35 | ls does-not-exist.txt 36 | ``` 37 | 38 | ### Expecting a nonzero exit status and error output 39 | 40 | This command fails: 41 | 42 | 43 | ```bash 44 | ls does-not-exist.txt 45 | ``` 46 | 47 | Expected stderr: 48 | 49 | 50 | ``` 51 | ls: does-not-exist.txt: No such file or directory 52 | ``` 53 | -------------------------------------------------------------------------------- /demo/demo-javascript.md: -------------------------------------------------------------------------------- 1 | # Markcheck demo: JavaScript 2 | 3 | ## Assertions 4 | 5 | ```js 6 | assert.equal( 7 | 'abc' + 'abc', 8 | 'abcabc' 9 | ); 10 | ``` 11 | 12 | ### Checking standard output via `stdout` 13 | 14 | ```js 15 | console.log('Hello!'); 16 | ``` 17 | 18 | 19 | ``` 20 | Hello! 21 | ``` 22 | 23 | **Alternative syntax:** 24 | 25 | 26 | ```js 27 | console.log('Hello!'); 28 | ``` 29 | 30 | 31 | ``` 32 | Hello! 33 | ``` 34 | 35 | ## Hiding code via `before:` 36 | 37 | 42 | ```js 43 | try { 44 | functionThatShouldThrow(); 45 | assert.fail(); 46 | } catch (_) { 47 | // Success 48 | } 49 | ``` 50 | 51 | ### Assembling code fragments sequentially via `sequence` 52 | 53 | 54 | ```js 55 | console.log("Snippet 1/3"); 56 | ``` 57 | 58 | 59 | ```js 60 | console.log("Snippet 2/3"); 61 | ``` 62 | 63 | 64 | ```js 65 | console.log("Snippet 3/3"); 66 | ``` 67 | 68 | Expected output: 69 | 70 | 71 | ``` 72 | Snippet 1/3 73 | Snippet 2/3 74 | Snippet 3/3 75 | ``` 76 | 77 | ## Assembling code fragments out of order 78 | 79 | 80 | ```js 81 | steps.push('Step 3'); 82 | 83 | assert.deepEqual( 84 | steps, 85 | ['Step 1', 'Step 2', 'Step 3'] 86 | ); 87 | ``` 88 | 89 | 90 | ```js 91 | const steps = []; 92 | steps.push('Step 1'); 93 | ``` 94 | 95 | 96 | ```js 97 | steps.push('Step 2'); 98 | ``` 99 | 100 | ## External files 101 | 102 | 103 | ```js 104 | // main.mjs 105 | import { GRINNING_FACE } from './other.mjs'; 106 | assert.equal(GRINNING_FACE, '😀'); 107 | ``` 108 | 109 | 110 | ```js 111 | // other.mjs 112 | export const GRINNING_FACE = '😀'; 113 | ``` 114 | 115 | ### Comment-only (“invisible”) snippets via `body:` 116 | 117 | Setting up an external file: 118 | 119 | 122 | 123 | ```js 124 | import * as fs from 'node:fs'; 125 | assert.equal( 126 | fs.readFileSync('some-file.txt', 'utf-8'), 127 | 'Content of some-file.txt' 128 | ); 129 | ``` 130 | 131 | Checking output: 132 | 133 | ```js 134 | console.log('How are you?'); 135 | ``` 136 | 137 | 140 | 141 | ### Hiding test code via ⎡half-brackets⎤ 142 | 143 | ```js 144 | ⎡await ⎤Promise.allSettled([ 145 | Promise.resolve('a'), 146 | Promise.reject('b'), 147 | ]) 148 | .then( 149 | (arr) => assert.deepEqual( 150 | arr, 151 | [ 152 | { status: 'fulfilled', value: 'a' }, 153 | { status: 'rejected', reason: 'b' }, 154 | ] 155 | ) 156 | ); 157 | ``` 158 | -------------------------------------------------------------------------------- /demo/demo-node-repl.md: -------------------------------------------------------------------------------- 1 | # Markcheck demo: Node.js REPL 2 | 3 | ## Values 4 | 5 | ```node-repl 6 | > Object.entries({a:1, b:2}) 7 | [ 8 | [ 'a', 1 ], 9 | [ 'b', 2 ], 10 | ] 11 | 12 | > Object.getOwnPropertyDescriptor({a:1}, 'a') 13 | { 14 | value: 1, 15 | writable: true, 16 | enumerable: true, 17 | configurable: true, 18 | } 19 | ``` 20 | 21 | ## Expecting exceptions 22 | 23 | ```node-repl 24 | > 2n + 1 25 | TypeError: Cannot mix BigInt and other types, 26 | use explicit conversions 27 | 28 | > const {prop} = undefined 29 | TypeError: Cannot destructure property 'prop' of 'undefined' 30 | as it is undefined. 31 | ``` 32 | 33 | ## Trailing semicolons 34 | 35 | The second input line in the previous example does not end with a semicolon. A trailing semicolon means that no result (value or exception) is expected: 36 | 37 | ```node-repl 38 | > const myVar = 123; 39 | > myVar 40 | 123 41 | ``` 42 | -------------------------------------------------------------------------------- /demo/demo-typescript.md: -------------------------------------------------------------------------------- 1 | # Markcheck demo: TypeScript 2 | 3 | By default, Markcheck uses the following tools (which must be installed – e.g. in `markcheck-data/node_modules/`): 4 | 5 | * Running TypeScript: [CLI tool `tsx`](https://github.com/privatenumber/tsx) 6 | * Does not perform any static checks! 7 | * Checking `@ts-expect-error` and performing static checks (option `--unexpected-errors`): [CLI tool `ts-expect-error`](https://github.com/rauschma/ts-expect-error) 8 | * Comparing types and checking the types of values: [library `ts-expect`](https://github.com/TypeStrong/ts-expect) 9 | 10 | When it comes to TypeScript examples in documentation, there are three important kinds of static checks that are useful (in addition to running the code as JavaScript to catch runtime errors). 11 | 12 | ## Static check: expecting errors 13 | 14 | ```ts 15 | // @ts-expect-error: A 'const' assertions can only be applied to references 16 | // to enum members, or string, number, boolean, array, or object literals. 17 | let sym = Symbol() as const; 18 | ``` 19 | 20 | ## Static check: types of values 21 | 22 | ```ts 23 | expectType('abc'); 24 | expectType(123); 25 | // @ts-expect-error: Argument of type 'string' 26 | // is not assignable to parameter of type 'number'. 27 | expectType('abc'); 28 | ``` 29 | 30 | ## Static check: equality of types 31 | 32 | ```ts 33 | type Pair = [T, T]; 34 | expectType, [string,string]>>(true); 35 | ``` 36 | -------------------------------------------------------------------------------- /doc/languages/html.md: -------------------------------------------------------------------------------- 1 | # HTML 2 | 3 | * https://html-validate.org/usage/cli.html 4 | -------------------------------------------------------------------------------- /doc/languages/rust.md: -------------------------------------------------------------------------------- 1 | # Markchecking Rust code 2 | 3 | ```js 4 | // Command 5 | ["cargo-play", "--quiet", "*.rs"] 6 | ``` 7 | 8 | ## Running code 9 | 10 | * Run Rust source code like a script: https://www.reddit.com/r/rust/comments/monzuc/run_rust_source_code_like_a_script_linux/ 11 | * ✅ cargo-play (292⭐️, 11 months ago on 2023-01-12): Run Rust files without setting up a Cargo project. https://github.com/fanzeyi/cargo-play 12 | * rust-script (645⭐️, 2 months ago on 2023-01-12): Run Rust files and expressions as scripts without any setup or compilation step. https://github.com/fornwall/rust-script 13 | * 👍 Scriptisto: language-agnostic "shebang interpreter" that enables you to write scripts in compiled languages. https://github.com/igor-petruk/scriptisto 14 | 15 | ## Testing types 16 | 17 | * `static_assertions`: for constants, types, and more. https://docs.rs/static_assertions/latest/static_assertions/ 18 | * rust-analyzer (Language Server Protocol): https://rust-analyzer.github.io 19 | 20 | Conceivable workaround: produce an error and extract the type information from stderr (after `= note`): 21 | 22 | ``` 23 | error[E0308]: mismatched types 24 | --> src/main.rs:18:9 25 | | 26 | 18 | let () = HashMap::::new(); 27 | | ^^ ------------------------------ this expression has type `HashMap` 28 | | | 29 | | expected struct `HashMap`, found `()` 30 | | 31 | = note: expected struct `HashMap` 32 | found unit type `()` 33 | ``` 34 | 35 | ## Testing errors 36 | 37 | * Testing `panic!()`: https://zhauniarovich.com/post/2021/2021-01-testing-errors-in-rust/#example 38 | 39 | ## Other tools 40 | 41 | * Built-in testing tools: https://users.rust-lang.org/t/checking-which-compile-time-errors-happen/ 42 | * Documentation tests: https://doc.rust-lang.org/rustdoc/write-documentation/documentation-tests.html#documentation-tests 43 | * `compile_fail`: https://doc.rust-lang.org/rustdoc/write-documentation/documentation-tests.html#attributes 44 | * Maybe: ```` ```compile_fail,E0123 ```` 45 | * 👉 `trybuild` checks stderr of compiler: https://lib.rs/crates/trybuild 46 | * Scraped examples: https://doc.rust-lang.org/rustdoc/scraped-examples.html#scraped-examples 47 | * `eval()` in Rust: https://crates.io/crates/evcxr 48 | * Evcxr Rust REPL: https://crates.io/crates/evcxr_repl -------------------------------------------------------------------------------- /doc/languages/typescript.md: -------------------------------------------------------------------------------- 1 | # TypeScript 2 | 3 | ## Running TypeScript code via Node.js 4 | 5 | * https://github.com/privatenumber/tsx 6 | 7 | ## Testing types 8 | 9 | * https://github.com/TypeStrong/ts-expect 10 | 11 | ```ts 12 | import { expectType } from 'ts-expect'; 13 | 14 | expectType('test'); 15 | expectType(123); 16 | ``` 17 | 18 | ```ts 19 | import { expectType, type TypeEqual } from 'ts-expect'; 20 | type Pair = [T, T]; 21 | expectType, [string,string]>>(true); 22 | ``` 23 | 24 | ### Other type utility libraries 25 | 26 | * tsafe: https://github.com/garronej/tsafe 27 | * type-plus: https://github.com/unional/type-plus/blob/main/packages/type-plus/readme.md 28 | * type-fest: https://github.com/sindresorhus/type-fest 29 | 30 | ## Related tools and libraries 31 | 32 | * TypeScript TwoSlash: https://www.npmjs.com/package/@typescript/twoslash 33 | 34 | ### eslint-plugin-expect-type 35 | 36 | * https://github.com/JoshuaKGoldberg/eslint-plugin-expect-type 37 | * Background: https://effectivetypescript.com/2022/05/28/eslint-plugin-expect-type/ 38 | 39 | ```ts 40 | 9001; 41 | // ^? number 42 | 43 | // $ExpectError 44 | const value: string = 9001; 45 | 46 | // $ExpectType number 47 | 9001; 48 | ``` 49 | -------------------------------------------------------------------------------- /doc/manual/README.md: -------------------------------------------------------------------------------- 1 | # Manual 2 | 3 | Table of contents: 4 | 5 | * [Quick start](quick-start.md) 6 | * [FAQ](faq.md) 7 | * [Tips](tips.md) 8 | * [Reference](reference.md) 9 | * [Related tools](related-tools.md) 10 | -------------------------------------------------------------------------------- /doc/manual/faq.md: -------------------------------------------------------------------------------- 1 | # FAQ 2 | 3 | ## How do I configure the location of the `markcheck-data/` directory? 4 | 5 | * Normally, `markcheck-data/` is searched for relatively to the location of a Markdown file. 6 | * But its location can also be specified in the first `config:` directive in a Markdown file (which doesn’t have to be the first directive overall). 7 | 8 | ```md 9 | 14 | ``` 15 | 16 | ## Markcheck complains about an “unknown language”: How do I set up a new language? 17 | 18 | There are two ways in which you can do so: 19 | 20 | * Put JSON5 data inside the file `markcheck-data/markcheck-config.json5` 21 | * Put JSON5 data inside a `config:` directive. 22 | 23 | Next, we’ll explore what the JSON5 data looks like, then what a `config:` directive looks like. 24 | 25 | Note that you can also simply skip code blocks with unknown languages – by putting a directive before the block: 26 | 27 | ```md 28 | 29 | ``` 30 | 31 | ### JSON5 data format 32 | 33 | You can see examples of JSON language definitions via: 34 | 35 | ``` 36 | markcheck --print-config 37 | ``` 38 | 39 | Examples: 40 | 41 | ```json5 42 | { 43 | lang: { 44 | "": "[skip]", 45 | txt: "[skip]", 46 | js: { 47 | before: [ 48 | "import assert from 'node:assert/strict';", 49 | ], 50 | runFileName: "main.mjs", 51 | commands: [ 52 | [ 53 | "node", 54 | "$FILE_NAME", 55 | ], 56 | ], 57 | }, 58 | "node-repl": { 59 | extends: "js", 60 | translator: "node-repl-to-js", 61 | }, 62 | }, 63 | } 64 | ``` 65 | 66 | ### Configuring languages inside Markdown files 67 | 68 | Example: [`demo-bash.md`](https://github.com/rauschma/markcheck/blob/main/demo/demo-bash.md) 69 | 70 | ```md 71 | 83 | ``` 84 | 85 | ## One among many code blocks fails. What do I do? 86 | 87 | Use ``: 88 | 89 | * Then only one specific code block runs. 90 | * That allows you to inspect the files in `markcheck-data/tmp/`: The files produced by that code block are still there (because it ran last). 91 | 92 | ## What files are written? What commands are run? 93 | 94 | Find out via option `--verbose` 95 | 96 | ## How do I get CLI help? 97 | 98 | Via option `--help` 99 | 100 | ## What is the format of config files? 101 | 102 | [The reference](./reference.md#configuration) has all the relevant information. 103 | -------------------------------------------------------------------------------- /doc/manual/quick-start.md: -------------------------------------------------------------------------------- 1 | # Quick start 2 | 3 | Requirement for using Markcheck: 4 | 5 | * [Node.js](https://nodejs.org/) must be installed. It runs Markcheck’s code. 6 | * Node.js comes bundled with npm which provides the `npx` command for running Markcheck without installing it permanently. 7 | 8 | ## Trying out Markcheck without installing it permanently 9 | 10 | There are demos for several languages in [`markcheck/demo/`](../../demo/README.md) 11 | 12 | As an example – [`demo/demo-javascript.md`](../../demo/demo-javascript.md?plain=1) is a file with JavaScript code blocks: 13 | 14 | ```txt 15 | cd markcheck/ 16 | npx markcheck demo/demo-javascript.md 17 | ``` 18 | 19 | The second command produces the following output. The lines with line numbers refer to code blocks and their languages. The bold lines between them are the most recent headings before the code blocks. 20 | 21 | Screenshot of Markcheck output with colors, bold letters, etc. An unstyled version of the output is shown below. 22 | 23 | Unstyled version of the previous screenshot: 24 | 25 | ```txt 26 | ========== demo/demo-javascript.md ========== 27 | Markcheck directory: markcheck-data 28 | Assertions 29 | L5 (js) ✔︎ 30 | Checking standard output via `stdout` 31 | L14 (js) ✔︎ 32 | Hiding code via `before:` 33 | L26 (js) ✔︎ 34 | Assembling code fragments sequentially via `sequence` 35 | L42 (js) ✔︎ 36 | Assembling code fragments out of order 37 | L68 (js) ✔︎ 38 | External files 39 | L91 (js) ✔︎ 40 | Comment-only (“invisible”) snippets via `body:` 41 | L112 (js) ✔︎ 42 | L122 (js) ✔︎ 43 | Asynchronous code and hiding test code via ⎡half-brackets⎤ 44 | L133 (js) ✔︎ 45 | ----- Summary of "demo/demo-javascript.md" ----- 46 | ✅ Successes: 9, Failures: 0, Syntax Errors: 0, Warnings: 0 47 | ``` 48 | 49 | ## What CLI options are available? 50 | 51 | Getting help for the command line interface of Markcheck: 52 | 53 | ```txt 54 | npx markcheck -h 55 | ``` 56 | 57 | ## Using Markcheck for your own files 58 | 59 | The remainder of this document explains the basics of using Markcheck – including [several common Markcheck patterns](#markdown-examples). 60 | 61 | ### Step 1: create a Markdown file 62 | 63 | We create the following Markdown file that we want to check with Markcheck: 64 | 65 | 66 | ``````md 67 | ```js 68 | assert.equal( 69 | 'abc' + 'abc', 70 | 'abcabc' 71 | ); 72 | ``` 73 | `````` 74 | 75 | * The Node.js `assert.*` methods are available by default. 76 | * We put the file at `/home/robin/proj/md/readme.md` 77 | 78 | ### Step 2: create directory `markcheck-data/` 79 | 80 | Markcheck stores the code in Markdown files in temporary files and feeds them to various shell commands. Therefore, to use it, we must provide a location for these files: 81 | 82 | * Per Markdown file, Markcheck searches for a directory `markcheck-data/`: First the file’s parent directory, then in the grand-parent directory, etc. 83 | * The temporary files are stored in `markcheck-data/tmp/`. 84 | 85 | Thus, for our example file, there needs to be a directory in one of these locations: 86 | 87 | ```txt 88 | /home/robin/proj/md/markcheck-data/ 89 | /home/robin/proj/markcheck-data/ 90 | /home/robin/markcheck-data/ 91 | /home/markcheck-data/ 92 | /markcheck-data/ 93 | ``` 94 | 95 | Since the code is run inside `markcheck-data/tmp/`, `markcheck-data/` itself is a good location for non-temporary data that code blocks should have access to, such as: 96 | 97 | * Modules with helper functions 98 | * `node_modules/` with installed npm packages 99 | 100 | ### Step 3: run Markcheck 101 | 102 | Now we can run Markcheck: 103 | 104 | ```txt 105 | npx run markcheck /home/robin/proj/md/readme.md 106 | ``` 107 | 108 | If we don’t want to use npx (which installs packages on demand and caches them locally), we can also install Markcheck somewhere: 109 | 110 | ```txt 111 | # Local installation (e.g. inside a repository) 112 | npm install --save-dev markcheck 113 | 114 | # Global installation 115 | npm install --global markcheck 116 | ``` 117 | 118 | ## How does Markcheck know what to do with code blocks? 119 | 120 | Markcheck writes each code block to disk and applies shell commands to it. The commands are determined by the language of the code block: 121 | 122 | * You can check out the defaults via `markcheck --print-config`. 123 | * The defaults can be changed inside Markdown files and via `markcheck-data/markcheck-config.json5`. See [the reference](./reference.md#configuration) for more information. 124 | 125 | The defaults look like this: 126 | 127 | 128 | ```json5 129 | { 130 | lang: { 131 | js: { 132 | before: [ 133 | "import assert from 'node:assert/strict';", 134 | ], 135 | runFileName: "main.mjs", 136 | commands: [ 137 | [ 138 | "node", 139 | "$FILE_NAME", 140 | ], 141 | ], 142 | }, 143 | // ··· 144 | }, 145 | // ··· 146 | } 147 | ``` 148 | 149 | `before` contains lines that are inserted at the beginning of each file that is written to disk. 150 | 151 | ### How to install shell commands that are invoked via `npx`? 152 | 153 | These commands are the default for TypeScript: 154 | 155 | ```json 156 | [ 157 | ["npx", "ts-expect-error", "--unexpected-errors", "$ALL_FILE_NAMES"], 158 | ["npx", "tsx", "$FILE_NAME"], 159 | ] 160 | ``` 161 | 162 | We can install the commands `ts-expect-error` and `tsx` in several ways: 163 | 164 | * Automatically, cached locally by `npx` 165 | * Manually: 166 | * Locally, e.g. inside `markcheck-data/node_modules/` 167 | * Globally via `npm install -g` 168 | 169 | ## Markdown examples 170 | 171 | ### Checking standard output via `stdout` 172 | 173 | 174 | ``````md 175 | ```js 176 | console.log('Hello!'); 177 | ``` 178 | 179 | 180 | ``` 181 | Hello! 182 | ``` 183 | `````` 184 | 185 | ### Hiding code via `before:` 186 | 187 | 188 | ``````md 189 | 194 | ```js 195 | try { 196 | functionThatShouldThrow(); 197 | assert.fail(); 198 | } catch (_) { 199 | // Success 200 | } 201 | ``` 202 | `````` 203 | 204 | ### Assembling code fragments sequentially via `sequence` 205 | 206 | 207 | ``````md 208 | 209 | ```js 210 | console.log("Snippet 1/3"); 211 | ``` 212 | 213 | 214 | ```js 215 | console.log("Snippet 2/3"); 216 | ``` 217 | 218 | 219 | ```js 220 | console.log("Snippet 3/3"); 221 | ``` 222 | 223 | Expected output: 224 | 225 | 226 | ``` 227 | Snippet 1/3 228 | Snippet 2/3 229 | Snippet 3/3 230 | ``` 231 | `````` 232 | 233 | ### Assembling code fragments out of order via `include` 234 | 235 | `$THIS` is optional if it is last among the `include` values. 236 | 237 | 238 | ``````md 239 | 240 | ```js 241 | steps.push('Step 3'); 242 | 243 | assert.deepEqual( 244 | steps, 245 | ['Step 1', 'Step 2', 'Step 3'] 246 | ); 247 | ``` 248 | 249 | 250 | ```js 251 | const steps = []; 252 | steps.push('Step 1'); 253 | ``` 254 | 255 | 256 | ```js 257 | steps.push('Step 2'); 258 | ``` 259 | `````` 260 | 261 | ### Setting up external files 262 | 263 | 264 | ``````md 265 | 266 | ```js 267 | // main.mjs 268 | import { GRINNING_FACE } from './other.mjs'; 269 | assert.equal(GRINNING_FACE, '😀'); 270 | ``` 271 | 272 | 273 | ```js 274 | // other.mjs 275 | export const GRINNING_FACE = '😀'; 276 | ``` 277 | `````` 278 | 279 | ### Comment-only (“invisible”) snippets via `body:` 280 | 281 | Sometimes readers should not see how a file is set up or that the output is checked. That can be done via `body:` 282 | 283 | Setting up an external file: 284 | 285 | 286 | ``````md 287 | 290 | 291 | ```js 292 | import * as fs from 'node:fs'; 293 | assert.equal( 294 | fs.readFileSync('some-file.txt', 'utf-8'), 295 | 'Content of some-file.txt' 296 | ); 297 | ``` 298 | `````` 299 | 300 | Checking output: 301 | 302 | 303 | ``````md 304 | ```js 305 | console.log('How are you?'); 306 | ``` 307 | 308 | 311 | `````` 312 | 313 | ### Hiding test code via ⎡half-brackets⎤ 314 | 315 | 316 | ``````md 317 | ```js 318 | ⎡await ⎤Promise.allSettled([ 319 | Promise.resolve('a'), 320 | Promise.reject('b'), 321 | ]) 322 | .then( 323 | (arr) => assert.deepEqual( 324 | arr, 325 | [ 326 | { status: 'fulfilled', value: 'a' }, 327 | { status: 'rejected', reason: 'b' }, 328 | ] 329 | ) 330 | ); 331 | ``` 332 | `````` 333 | 334 | The ⎡half-brackets⎤ are used for hiding test code from readers: 335 | 336 | * Markcheck removes individual half-brackets before writing the code to disk. 337 | * Before you publish the Markdown (e.g. to HTML), you need to remove the half-brackets and what’s inside them. See [the reference](reference.md#config-property-searchandreplace) for more information. 338 | 339 | ### Writing a configuration file to disk 340 | 341 | 342 | ``````md 343 | 350 | `````` 351 | 352 | See [`demo/demo-babel.md`](../../demo/demo-babel.md) for more information. 353 | -------------------------------------------------------------------------------- /doc/manual/related-tools.md: -------------------------------------------------------------------------------- 1 | # Related tools 2 | 3 | ## Universal 4 | 5 | * Universal: https://github.com/anko/txm 6 | * Uses indented code blocks 7 | * Universal: https://specdown.io 8 | * Changes Markdown syntax 9 | 10 | ## JavaScript and TypeScript 11 | 12 | * https://github.com/eslint/eslint-plugin-markdown 13 | * TypeScript TwoSlash: https://www.npmjs.com/package/@typescript/twoslash 14 | * Example: https://www.typescriptlang.org/dev/twoslash/ 15 | * Upsides: 16 | * Powerful 17 | * Great for web content (tooltips, colors, etc.) 18 | * Downsides: 19 | * Less suited for PDFs and print 20 | * Can’t split up examples into multiple fragments and connect them. 21 | * Doesn’t tie errors to specific lines in the code 22 | * Only checks error codes, not error messages. 23 | * Can’t check inferred types. 24 | 25 | ## Python 26 | 27 | * Pytest plugin: https://github.com/modal-labs/pytest-markdown-docs 28 | * Pytest plugin: https://github.com/nschloe/pytest-codeblocks 29 | 30 | ## Rust 31 | 32 | * https://crates.io/crates/skeptic 33 | * https://docs.rs/doc-comment/latest/doc_comment/ 34 | -------------------------------------------------------------------------------- /doc/manual/screenshot-markcheck.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rauschma/markcheck/c2ddae29ddc0c999ff71e5a06d8afc703d0543b2/doc/manual/screenshot-markcheck.jpg -------------------------------------------------------------------------------- /doc/manual/tips.md: -------------------------------------------------------------------------------- 1 | # Tips 2 | 3 | ## Visual Studio Code 4 | 5 | You can set up [Visual Studio Code snippets](https://code.visualstudio.com/docs/editor/userdefinedsnippets) for Markcheck. I’m using the following snippets: 6 | 7 | ```jsonc 8 | "Markcheck": { 9 | "prefix": "mc", 10 | "body": [ 11 | "$0" 12 | ], 13 | }, 14 | "Markcheck brackets": { 15 | "prefix": "mcb", 16 | "body": [ 17 | "⎡⎤" 18 | ], 19 | }, 20 | "Markcheck sequence": { 21 | "prefix": "mcs", 22 | "body": [ 23 | "$0" 24 | ], 25 | }, 26 | "Markcheck skip": { 27 | "prefix": "mck", 28 | "body": [ 29 | "$0" 30 | ], 31 | }, 32 | "Markcheck only": { 33 | "prefix": "mco", 34 | "body": [ 35 | "$0" 36 | ], 37 | }, 38 | ``` 39 | -------------------------------------------------------------------------------- /markcheck-data/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": "true", 3 | "type": "module", 4 | "// dependencies": { 5 | "@babel/plugin-proposal-decorators": "Needed only by demo-babel.md (which demos decorators)", 6 | "babel-register-esm": "Needed by Babel code", 7 | "ts-expect": "Needed by TypeScript code" 8 | }, 9 | "dependencies": { 10 | "@babel/plugin-proposal-decorators": "^7.23.9", 11 | "babel-register-esm": "^1.2.5", 12 | "ts-expect": "^1.3.0" 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "markcheck", 3 | "version": "1.3.0", 4 | "type": "module", 5 | "license": "MIT", 6 | "repository": "github:rauschma/markcheck", 7 | "author": "Axel Rauschmayer", 8 | "bin": { 9 | "markcheck": "./dist/src/markcheck.js" 10 | }, 11 | "files": [ 12 | "package.json", 13 | "README.md", 14 | "LICENSE", 15 | "dist/**/*.js", 16 | "!dist/**/*_test.js", 17 | "!dist/test/**/*.js" 18 | ], 19 | "imports": { 20 | "#root/*": "./*", 21 | "#demo/*": "./demo/*" 22 | }, 23 | "scripts": { 24 | "\n========== Building ==========": "", 25 | "build": "npm run clean && tsc && npm run chmod", 26 | "watch": "tsc --watch", 27 | "clean": "shx rm -rf ./dist/*", 28 | "chmod": "shx chmod u+x ./dist/src/markcheck.js", 29 | "\n========== Testing ==========": "", 30 | "checkmd": "markcheck doc/manual/quick-start.md demo/demo-*.md", 31 | "test": "mocha --ui qunit --enable-source-maps --no-warnings=ExperimentalWarning", 32 | "testall": "mocha --ui qunit --enable-source-maps --no-warnings=ExperimentalWarning \"./dist/**/*_test.js\"", 33 | "circular": "npx madge --circular src/markcheck.ts", 34 | "\n========== Publishing ==========": "", 35 | "publishd": "npm publish --dry-run", 36 | "prepublishOnly": "npm run build" 37 | }, 38 | "dependencies": { 39 | "@rauschma/helpers": "^0.1.0", 40 | "@rauschma/nodejs-tools": "^0.3.0", 41 | "diff": "^5.2.0", 42 | "json5": "^2.2.3", 43 | "markdown-it": "^14.1.0", 44 | "zod": "^3.22.4", 45 | "zod-to-json-schema": "^3.22.5" 46 | }, 47 | "devDependencies": { 48 | "@types/diff": "^5.0.9", 49 | "@types/json5": "^2.2.0", 50 | "@types/markdown-it": "^13.0.7", 51 | "@types/mocha": "^10.0.6", 52 | "@types/node": "^20.11.30", 53 | "mocha": "^10.3.0", 54 | "shx": "^0.3.4" 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/core/config.ts: -------------------------------------------------------------------------------- 1 | import { createSequentialRegExpEscaper } from '@rauschma/helpers/string/escaper.js'; 2 | import { UnsupportedValueError } from '@rauschma/helpers/typescript/error.js'; 3 | import { z } from 'zod'; 4 | import { CMD_VAR_ALL_FILE_NAMES, CMD_VAR_FILE_NAME, LANG_ERROR_IF_RUN, LANG_SKIP } from '../entity/directive.js'; 5 | import { parseSearchAndReplaceString } from '../util/search-and-replace-spec.js'; 6 | import { nodeReplToJs } from '../translation/repl-to-js-translator.js'; 7 | import type { Translator } from '../translation/translation.js'; 8 | import { EntityContextDescription, MarkcheckSyntaxError, type EntityContext } from '../util/errors.js'; 9 | 10 | const { stringify } = JSON; 11 | 12 | //#################### Types #################### 13 | 14 | export type LangDef = 15 | | LangDefCommand 16 | | LangDefSkip 17 | | LangDefErrorIfRun 18 | ; 19 | 20 | /** 21 | * - We delay assembling the actual language definition (via `.extends`) as 22 | * long as possible so that all pieces can be changed. 23 | */ 24 | export type LangDefCommand = { 25 | kind: 'LangDefCommand', 26 | extends?: string, 27 | beforeLines?: Array, 28 | afterLines?: Array, 29 | translator?: Translator, 30 | runFileName?: string, 31 | commands?: Array>, 32 | }; 33 | const PROP_KEY_EXTENDS = 'extends'; 34 | export const PROP_KEY_DEFAULT_FILE_NAME = 'runFileName'; 35 | export const PROP_KEY_COMMANDS = 'commands'; 36 | 37 | /** 38 | * Main use case – the empty language `""` (of “bare” code blocks without 39 | * language tags): 40 | * - It allows us to use them to write config files. 41 | * - But they won’t be run. 42 | */ 43 | export type LangDefSkip = { 44 | kind: 'LangDefSkip', 45 | }; 46 | export type LangDefErrorIfRun = { 47 | kind: 'LangDefErrorIfRun', 48 | }; 49 | 50 | //#################### Translators #################### 51 | 52 | const TRANSLATORS: Array = [ 53 | nodeReplToJs 54 | ]; 55 | 56 | export const TRANSLATOR_MAP = new Map( 57 | TRANSLATORS.map(t => [t.key, t]) 58 | ); 59 | 60 | //#################### Config #################### 61 | 62 | /** 63 | * Issues such as cycles in "extends" and unknown language names are 64 | * currently checked on demand: After (e.g.) the ConfigMod was parsed that 65 | * caused them. Thus, we can’t provide a better context for errors in this 66 | * class. 67 | */ 68 | export const CONFIG_ENTITY_CONTEXT = new EntityContextDescription('Configuration'); 69 | 70 | export const CONFIG_PROP_BEFORE_LINES = 'beforeLines'; 71 | export const CONFIG_PROP_AFTER_LINES = 'afterLines'; 72 | 73 | export class Config { 74 | searchAndReplaceFunc: (str: string) => string = (str) => str; 75 | #searchAndReplaceData: Array = []; 76 | #lang = new Map(); 77 | idToLineMod = new Map(); 78 | toJson(): ConfigModJson { 79 | return { 80 | searchAndReplace: this.#searchAndReplaceData, 81 | lang: Object.fromEntries( 82 | Array.from( 83 | this.#lang, 84 | ([key, value]) => [ 85 | key, langDefToJson(value) 86 | ] 87 | ) 88 | ), 89 | }; 90 | } 91 | /** 92 | * @param entityContext `configModJson` comes from a config file or a 93 | * ConfigMod (inside a Markdown file). 94 | */ 95 | applyMod(entityContext: EntityContext, configModJson: ConfigModJson): void { 96 | if (configModJson.searchAndReplace) { 97 | this.#setSearchAndReplace(entityContext, configModJson.searchAndReplace); 98 | } 99 | if (configModJson.lang) { 100 | for (const [key, langDefJson] of Object.entries(configModJson.lang)) { 101 | this.#lang.set(key, langDefFromJson(entityContext, langDefJson)); 102 | } 103 | } 104 | if (configModJson.lineMods) { 105 | for (const [key, lineModJson] of Object.entries(configModJson.lineMods)) { 106 | this.idToLineMod.set(key, lineModJson); 107 | } 108 | } 109 | } 110 | #setSearchAndReplace(entityContext: EntityContext, data: Array) { 111 | try { 112 | this.searchAndReplaceFunc = createSequentialRegExpEscaper( 113 | data.map( 114 | (str) => parseSearchAndReplaceString(str) 115 | ) 116 | ); 117 | this.#searchAndReplaceData = data; 118 | } catch (err) { 119 | throw new MarkcheckSyntaxError( 120 | `Could not parse value of property ${stringify(CONFIG_KEY_SEARCH_AND_REPLACE)}`, 121 | { entityContext, cause: err } 122 | ); 123 | } 124 | } 125 | getLang(langKey: string): undefined | LangDef { 126 | const langDef = this.#lang.get(langKey); 127 | if (langDef === undefined) { 128 | return langDef; 129 | } 130 | if (langDef.kind === 'LangDefCommand') { 131 | return this.#lookUpLangDefJson(langKey, langDef); 132 | } else { 133 | return langDef; 134 | } 135 | } 136 | #lookUpLangDefJson(parentKey: string, partialCommand: LangDefCommand, visitedLanguages = new Set): LangDef { 137 | const origParentKey = parentKey; 138 | let result = partialCommand; 139 | while (true) { 140 | if (partialCommand.extends === undefined) { 141 | break; 142 | } 143 | if (visitedLanguages.has(partialCommand.extends)) { 144 | const keyPath = [...visitedLanguages, partialCommand.extends]; 145 | throw new MarkcheckSyntaxError( 146 | `Cycle in property ${stringify(PROP_KEY_EXTENDS)} (object ${stringify(CONFIG_KEY_LANG)}): ${stringify(keyPath)}`, 147 | { entityContext: CONFIG_ENTITY_CONTEXT } 148 | ); 149 | } 150 | visitedLanguages.add(partialCommand.extends); 151 | const nextLangDef = this.#lang.get(partialCommand.extends); 152 | if (nextLangDef === undefined) { 153 | throw new MarkcheckSyntaxError( 154 | `Language definition ${stringify(parentKey)} refers to unknown language ${stringify(partialCommand.extends)}`, 155 | { entityContext: CONFIG_ENTITY_CONTEXT } 156 | ); 157 | } 158 | switch (nextLangDef.kind) { 159 | case 'LangDefSkip': 160 | case 'LangDefErrorIfRun': 161 | // End of the road 162 | return nextLangDef; 163 | case 'LangDefCommand': 164 | // `result` extends `nextLangDef` – the properties of the former 165 | // win. 166 | result = merge(result, nextLangDef); 167 | parentKey = partialCommand.extends; 168 | partialCommand = nextLangDef; 169 | break; 170 | default: 171 | throw new UnsupportedValueError(nextLangDef); 172 | } 173 | } // while 174 | if (result.runFileName === undefined) { 175 | throw new MarkcheckSyntaxError( 176 | `Language ${stringify(origParentKey)} does not have property ${stringify(PROP_KEY_DEFAULT_FILE_NAME)} (with ${stringify(PROP_KEY_EXTENDS)} taken into consideration)`, 177 | { entityContext: CONFIG_ENTITY_CONTEXT } 178 | ); 179 | } 180 | if (result.commands === undefined) { 181 | throw new MarkcheckSyntaxError( 182 | `Language ${stringify(origParentKey)} does not have property ${stringify(PROP_KEY_COMMANDS)} (with ${stringify(PROP_KEY_EXTENDS)} taken into consideration)`, 183 | { entityContext: CONFIG_ENTITY_CONTEXT } 184 | ); 185 | } 186 | return result; 187 | } 188 | addDefaults(): this { 189 | this.#setSearchAndReplace( 190 | CONFIG_ENTITY_CONTEXT, 191 | [ 192 | '/[⎡⎤]//', 193 | ] 194 | ); 195 | // Bare code blocks may be written but are never run. 196 | this.#lang.set( 197 | '', 198 | { 199 | kind: 'LangDefSkip' 200 | } 201 | ); 202 | this.#lang.set( 203 | 'md', 204 | { 205 | kind: 'LangDefSkip' 206 | } 207 | ); 208 | // txt code blocks are always skipped 209 | this.#lang.set( 210 | 'txt', 211 | { 212 | kind: 'LangDefSkip' 213 | } 214 | ); 215 | // Ignore for now, built-in check later 216 | this.#lang.set( 217 | 'json', 218 | { 219 | kind: 'LangDefSkip' 220 | } 221 | ); 222 | this.#lang.set( 223 | "js", 224 | { 225 | kind: 'LangDefCommand', 226 | runFileName: 'main.mjs', 227 | commands: [ 228 | ["node", CMD_VAR_FILE_NAME], 229 | ], 230 | beforeLines: [ 231 | `import assert from 'node:assert/strict';` 232 | ], 233 | } 234 | ); 235 | this.#lang.set( 236 | "node-repl", 237 | { 238 | kind: 'LangDefCommand', 239 | translator: nodeReplToJs, 240 | extends: 'js', 241 | } 242 | ); 243 | this.#lang.set( 244 | "babel", 245 | { 246 | kind: 'LangDefCommand', 247 | runFileName: 'main.mjs', 248 | commands: [ 249 | // https://github.com/giltayar/babel-register-esm 250 | ["node", "--loader=babel-register-esm", "--disable-warning=ExperimentalWarning", CMD_VAR_FILE_NAME], 251 | ], 252 | beforeLines: [ 253 | `import assert from 'node:assert/strict';` 254 | ], 255 | } 256 | ); 257 | this.#lang.set( 258 | "ts", 259 | { 260 | kind: 'LangDefCommand', 261 | runFileName: 'main.ts', 262 | commands: [ 263 | ["npx", "ts-expect-error", "--unexpected-errors", CMD_VAR_ALL_FILE_NAMES], 264 | // Snippets can only check stdout & stderr of last command 265 | ["npx", "tsx", CMD_VAR_FILE_NAME], 266 | ], 267 | beforeLines: [ 268 | `import { expectType, type TypeEqual } from 'ts-expect';`, 269 | `import assert from 'node:assert/strict';` 270 | ], 271 | } 272 | ); 273 | this.#lang.set( 274 | "html", 275 | { 276 | kind: 'LangDefCommand', 277 | runFileName: 'index.html', 278 | commands: [ 279 | ["npx", "html-validate", CMD_VAR_FILE_NAME], 280 | ], 281 | } 282 | ); 283 | return this; 284 | } 285 | } 286 | 287 | /** 288 | * We can’t use object spreading because an optional property may exist and 289 | * have the value `undefined` – in which case it overrides other, 290 | * potentially non-undefined, values. 291 | */ 292 | function merge(extending: LangDefCommand, extended: LangDefCommand): LangDefCommand { 293 | // The properties of `extending` override the properties of `extended` 294 | // (they win). 295 | return { 296 | kind: 'LangDefCommand', 297 | extends: extending.extends ?? extended.extends, 298 | beforeLines: extending.beforeLines ?? extended.beforeLines, 299 | afterLines: extending.afterLines ?? extended.afterLines, 300 | translator: extending.translator ?? extended.translator, 301 | runFileName: extending.runFileName ?? extended.runFileName, 302 | commands: extending.commands ?? extended.commands, 303 | }; 304 | } 305 | 306 | export function fillInCommandVariables(commands: Array>, vars: Record>): Array> { 307 | return commands.map(cmdParts => cmdParts.flatMap( 308 | (part): Array => { 309 | if (Object.hasOwn(vars, part)) { 310 | return vars[part]; 311 | } else { 312 | return [part]; 313 | } 314 | } 315 | )); 316 | } 317 | 318 | //#################### ConfigModJson #################### 319 | 320 | export type ConfigModJson = { 321 | /** 322 | * Ignored by class `Config`. The first ConfigMod in a file can set the 323 | * Markcheck directory. By including it here, we don’t need an extra 324 | * type+schema for parsing in that case. 325 | */ 326 | markcheckDirectory?: string, 327 | searchAndReplace?: Array, 328 | lang?: Record, 329 | lineMods?: Record, 330 | }; 331 | export const CONFIG_KEY_LANG = 'lang'; 332 | export const CONFIG_KEY_SEARCH_AND_REPLACE = 'searchAndReplace'; 333 | 334 | export type LineModJson = { 335 | before?: Array, 336 | after?: Array, 337 | }; 338 | 339 | export type LangDefJson = 340 | | LangDefCommandJson 341 | | typeof LANG_SKIP 342 | | typeof LANG_ERROR_IF_RUN 343 | ; 344 | 345 | export type LangDefCommandJson = { 346 | extends?: string, 347 | before?: Array, 348 | after?: Array, 349 | translator?: string, 350 | runFileName?: string, 351 | commands?: Array>, 352 | }; 353 | 354 | /** 355 | * @param context `configModJson` comes from a config file or a ConfigMod 356 | * (inside a Markdown file). 357 | */ 358 | function langDefFromJson(context: EntityContext, langDefJson: LangDefJson): LangDef { 359 | if (typeof langDefJson === 'string') { 360 | switch (langDefJson) { 361 | case LANG_SKIP: 362 | return { kind: 'LangDefSkip' }; 363 | case LANG_ERROR_IF_RUN: 364 | return { kind: 'LangDefErrorIfRun' }; 365 | default: 366 | throw new UnsupportedValueError(langDefJson); 367 | } 368 | } 369 | let translator: undefined | Translator; 370 | if (langDefJson.translator) { 371 | translator = TRANSLATOR_MAP.get(langDefJson.translator); 372 | if (translator === undefined) { 373 | throw new MarkcheckSyntaxError( 374 | `Unknown translator: ${stringify(langDefJson.translator)}`, 375 | { entityContext: context } 376 | ); 377 | } 378 | } 379 | return { 380 | kind: 'LangDefCommand', 381 | extends: langDefJson.extends, 382 | beforeLines: langDefJson.before, 383 | afterLines: langDefJson.after, 384 | translator, 385 | runFileName: langDefJson.runFileName, 386 | commands: langDefJson.commands, 387 | }; 388 | } 389 | 390 | function langDefToJson(langDef: LangDef): LangDefJson { 391 | switch (langDef.kind) { 392 | case 'LangDefSkip': 393 | return LANG_SKIP; 394 | case 'LangDefErrorIfRun': 395 | return LANG_ERROR_IF_RUN; 396 | case 'LangDefCommand': { 397 | const result: LangDefJson = { 398 | }; 399 | if (langDef.extends) { 400 | result.extends = langDef.extends; 401 | } 402 | if (langDef.beforeLines) { 403 | result.before = langDef.beforeLines; 404 | } 405 | if (langDef.afterLines) { 406 | result.before = langDef.afterLines; 407 | } 408 | if (langDef.translator) { 409 | result.translator = langDef.translator.key; 410 | } 411 | if (langDef.runFileName) { 412 | result.runFileName = langDef.runFileName; 413 | } 414 | if (langDef.commands) { 415 | result.commands = langDef.commands; 416 | } 417 | return result; 418 | } 419 | default: 420 | throw new UnsupportedValueError(langDef); 421 | } 422 | } 423 | 424 | //#################### ConfigModJsonSchema #################### 425 | 426 | export const LangDefCommandJsonSchema = z.object({ 427 | extends: z.optional(z.string()), 428 | before: z.optional(z.array(z.string())), 429 | after: z.optional(z.array(z.string())), 430 | translator: z.optional(z.string()), 431 | runFileName: z.optional(z.string()), 432 | commands: z.optional(z.array(z.array(z.string()))), 433 | }).strict(); 434 | 435 | export const LangDefJsonSchema = z.union([ 436 | z.literal(LANG_SKIP), 437 | z.literal(LANG_ERROR_IF_RUN), 438 | LangDefCommandJsonSchema, 439 | ]); 440 | 441 | export const LineModJsonSchema = z.object({ 442 | before: z.optional(z.array(z.string())), 443 | after: z.optional(z.array(z.string())), 444 | }).strict(); 445 | 446 | export const ConfigModJsonSchema = z.object({ 447 | markcheckDirectory: z.optional(z.string()), 448 | searchAndReplace: z.optional(z.array(z.string())), 449 | lang: z.optional( 450 | z.record(LangDefJsonSchema) 451 | ), 452 | lineMods: z.optional( 453 | z.record(LineModJsonSchema) 454 | ), 455 | }).strict(); 456 | -------------------------------------------------------------------------------- /src/core/config_test.ts: -------------------------------------------------------------------------------- 1 | import { createSuite } from '@rauschma/helpers/testing/mocha.js'; 2 | import assert from 'node:assert/strict'; 3 | import { CMD_VAR_FILE_NAME } from '../entity/directive.js'; 4 | import { EntityContextDescription, EntityContextLineNumber } from '../util/errors.js'; 5 | import { Config } from './config.js'; 6 | 7 | createSuite(import.meta.url); 8 | 9 | test('config.toJson()', () => { 10 | const config = new Config(); 11 | config.applyMod( 12 | new EntityContextDescription('Test'), 13 | { 14 | searchAndReplace: [ 15 | '/[⎡⎤]//', 16 | ], 17 | lang: { 18 | 'js': { 19 | before: [ 20 | "import assert from 'node:assert/strict';" 21 | ], 22 | runFileName: 'main.mjs', 23 | commands: [ 24 | ["node", CMD_VAR_FILE_NAME], 25 | ], 26 | }, 27 | }, 28 | } 29 | ); 30 | const json = config.toJson(); 31 | // console.log(JSON.stringify(json, null, 2)); 32 | assert.deepEqual( 33 | json, 34 | { 35 | "searchAndReplace": [ 36 | "/[⎡⎤]//" 37 | ], 38 | "lang": { 39 | "js": { 40 | "before": [ 41 | "import assert from 'node:assert/strict';" 42 | ], 43 | "runFileName": "main.mjs", 44 | "commands": [ 45 | [ 46 | "node", 47 | "$FILE_NAME" 48 | ] 49 | ] 50 | } 51 | } 52 | } 53 | ); 54 | }); 55 | 56 | test('config.getLang()', () => { 57 | const config = new Config().addDefaults(); 58 | config.applyMod(new EntityContextLineNumber(1), { 59 | "lang": { 60 | "js": { 61 | "extends": "babel", 62 | "runFileName": "main-babel.mjs", 63 | }, 64 | }, 65 | }); 66 | const langDef = config.getLang('js'); 67 | if (langDef === undefined || langDef.kind !== 'LangDefCommand') { 68 | throw new Error(); 69 | } 70 | assert.deepEqual( 71 | langDef, 72 | { 73 | beforeLines: [ 74 | "import assert from 'node:assert/strict';" 75 | ], 76 | afterLines: undefined, 77 | commands: [ 78 | [ 79 | 'node', 80 | '--loader=babel-register-esm', 81 | '--disable-warning=ExperimentalWarning', 82 | '$FILE_NAME' 83 | ] 84 | ], 85 | extends: 'babel', 86 | kind: 'LangDefCommand', 87 | runFileName: 'main-babel.mjs', 88 | translator: undefined 89 | } 90 | ); 91 | }); -------------------------------------------------------------------------------- /src/core/parse-markdown.ts: -------------------------------------------------------------------------------- 1 | import { splitLinesExclEol } from '@rauschma/helpers/string/line.js'; 2 | import { assertNonNullable, assertTrue } from '@rauschma/helpers/typescript/type.js'; 3 | import markdownit from 'markdown-it'; 4 | import { ConfigMod } from '../entity/config-mod.js'; 5 | import { ATTRS_APPLIABLE_LINE_MOD, ATTRS_APPLIABLE_LINE_MOD_BODY_LABEL_INSERT, ATTRS_CONFIG_MOD, ATTRS_LANGUAGE_LINE_MOD, ATTRS_SNIPPET, ATTRS_SNIPPET_BODY_LABEL_INSERT, ATTR_KEY_DEFINE, ATTR_KEY_EACH, ATTR_KEY_ID, ATTR_KEY_LINE_MOD_ID, ATTR_KEY_STDERR, ATTR_KEY_STDOUT, BODY_LABEL_AFTER, BODY_LABEL_AROUND, BODY_LABEL_BEFORE, BODY_LABEL_BODY, BODY_LABEL_CONFIG, BODY_LABEL_INSERT, Directive } from '../entity/directive.js'; 6 | import { Heading } from '../entity/heading.js'; 7 | import { LineModAppliable, LineModInternal, LineModLanguage } from '../entity/line-mod.js'; 8 | import { SequenceSnippet, SingleSnippet, Snippet } from '../entity/snippet.js'; 9 | import { MarkcheckSyntaxError } from '../util/errors.js'; 10 | import { UnsupportedValueError } from '@rauschma/helpers/typescript/error.js'; 11 | 12 | const { stringify } = JSON; 13 | 14 | //#################### parseMarkdown() #################### 15 | 16 | export type SoloEntity = SingleSnippet | LineModAppliable | LineModLanguage | ConfigMod | Heading; 17 | 18 | export type ParsedEntity = Snippet | LineModAppliable | LineModLanguage | ConfigMod | Heading; 19 | 20 | type ParsingState = ParsingStateNormal | ParsingStateOpenSnippet | ParsingStateOpenHeading; 21 | 22 | type ParsingStateNormal = { 23 | kind: 'ParsingStateNormal', 24 | }; 25 | type ParsingStateOpenSnippet = { 26 | kind: 'ParsingStateOpenSnippet', 27 | openSingleSnippet: SingleSnippet, 28 | }; 29 | type ParsingStateOpenHeading = { 30 | kind: 'ParsingStateOpenHeading', 31 | lineNumber: number, 32 | }; 33 | 34 | export function parseMarkdown(text: string): ParsedMarkdown { 35 | const parsed = parseEntities(text); 36 | const trimmedHeadings = Array.from(trimHeadings(parsed)); 37 | const withSequences = Array.from(createSequenceSnippets(trimmedHeadings)); 38 | const withEmbeddedSnippets = Array.from(embedDefinitionSnippets(withSequences)); 39 | return { 40 | entities: withEmbeddedSnippets, 41 | idToSnippet: createIdToSnippet(withEmbeddedSnippets), 42 | idToLineMod: createIdToLineMod(withEmbeddedSnippets), 43 | }; 44 | } 45 | 46 | function parseEntities(text: string): Array { 47 | const md = markdownit({ html: true }); 48 | const result = new Array(); 49 | const tokens = md.parse(text, { html: true }); 50 | 51 | let parsingState: ParsingState = { kind: 'ParsingStateNormal' }; 52 | 53 | // Look up tokens via “debug” here: https://markdown-it.github.io 54 | for (const token of tokens) { 55 | if (token.type === 'html_block') { 56 | const text = extractCommentContent(token.content); 57 | if (text === null) continue; 58 | 59 | assertNonNullable(token.map); 60 | const lineNumber = token.map[0] + 1; 61 | const directive = Directive.parse(lineNumber, splitLinesExclEol(text)); 62 | if (directive === null) continue; 63 | const entity = directiveToEntity(directive); 64 | if (entity === null) { 65 | // We have not detected a relevant entity: nothing changes 66 | continue; 67 | } 68 | 69 | // Illegal state: 70 | // - A directive is waiting for its code block 71 | // - A heading is waiting for its inlines 72 | throwIfStateIsNotNormal(parsingState, 'directive'); 73 | 74 | //----- ConfigMod | LineMod ----- 75 | 76 | if (entity instanceof ConfigMod || entity instanceof LineModAppliable || entity instanceof LineModLanguage) { 77 | result.push(entity); 78 | continue; 79 | } 80 | 81 | //----- SingleSnippet ----- 82 | 83 | if (entity.isClosed) { 84 | // The parsed directive is self-contained (a body directive) 85 | result.push(entity); 86 | assertTrue(parsingState.kind === 'ParsingStateNormal'); 87 | } else { 88 | parsingState = { 89 | kind: 'ParsingStateOpenSnippet', 90 | openSingleSnippet: entity, 91 | }; 92 | } 93 | } else if (token.type === 'fence' && token.tag === 'code' && token.markup.startsWith('```')) { 94 | assertNonNullable(token.map); 95 | const lineNumber = token.map[0] + 1; 96 | 97 | const text = token.content; 98 | const lines = splitLinesExclEol(text); 99 | const lang = token.info; 100 | switch (parsingState.kind) { 101 | case 'ParsingStateOpenSnippet': 102 | // Code block follows a directive 103 | result.push( 104 | parsingState.openSingleSnippet.closeWithBody(lang, lines) 105 | ); 106 | parsingState = { kind: 'ParsingStateNormal' }; 107 | break; 108 | case 'ParsingStateNormal': 109 | // Code block without preceding directive 110 | result.push( 111 | SingleSnippet.createClosedFromCodeBlock(lineNumber, lang, lines) 112 | ); 113 | assertTrue(parsingState.kind === 'ParsingStateNormal'); 114 | break; 115 | case 'ParsingStateOpenHeading': 116 | throw new MarkcheckSyntaxError( 117 | `An open heading was followed by a code block`, 118 | { lineNumber } 119 | ); 120 | default: 121 | throw new UnsupportedValueError(parsingState); 122 | } 123 | } else if (token.type === 'heading_open') { 124 | assertNonNullable(token.map); 125 | const lineNumber = token.map[0] + 1; 126 | parsingState = { 127 | kind: 'ParsingStateOpenHeading', 128 | lineNumber, 129 | }; 130 | } else if (token.type === 'inline' && parsingState.kind === 'ParsingStateOpenHeading') { 131 | assertNonNullable(token.map); 132 | const lineNumber = token.map[0] + 1; 133 | result.push( 134 | new Heading(lineNumber, token.content) 135 | ); 136 | parsingState = { 137 | kind: 'ParsingStateNormal', 138 | }; 139 | } 140 | } 141 | throwIfStateIsNotNormal(parsingState, 'end of file'); 142 | return result; 143 | } 144 | 145 | function throwIfStateIsNotNormal(parsingState: ParsingState, entityDescription: string) { 146 | let message: string; 147 | let lineNumber: number; 148 | switch (parsingState.kind) { 149 | case 'ParsingStateNormal': 150 | // Everything is OK 151 | return; 152 | case 'ParsingStateOpenHeading': 153 | message = `Open heading without content before ${entityDescription}`; 154 | lineNumber = parsingState.lineNumber; 155 | break; 156 | case 'ParsingStateOpenSnippet': 157 | message = `Directive without code block before ${entityDescription}`; 158 | lineNumber = parsingState.openSingleSnippet.lineNumber; 159 | break; 160 | default: 161 | throw new UnsupportedValueError(parsingState); 162 | } 163 | throw new MarkcheckSyntaxError( 164 | message, { lineNumber } 165 | ); 166 | } 167 | 168 | function* trimHeadings(parsedEntities: Array): Iterable { 169 | // We trim headings before we group single snippets into sequences 170 | // because those single snippets may be interspersed with headings – 171 | // which makes grouping harder. 172 | let currentHeading: null | Heading = null; 173 | for (const entity of parsedEntities) { 174 | if (entity instanceof Heading) { 175 | currentHeading = entity; 176 | } else if (entity instanceof SingleSnippet) { 177 | // A SingleSnippet activates a heading 178 | if (currentHeading !== null) { 179 | const num = entity.sequenceNumber; 180 | if (num === null || (num !== null && num.pos === 1)) { 181 | yield currentHeading; 182 | currentHeading = null; // use heading at most once 183 | } 184 | } 185 | yield entity; 186 | } else { 187 | // All other entities have no effect on headings 188 | yield entity; 189 | } 190 | } 191 | } 192 | 193 | function* createSequenceSnippets(parsedEntities: Array): Iterable { 194 | let openSequence: null | SequenceSnippet = null; 195 | for (const entity of parsedEntities) { 196 | if (openSequence !== null) { 197 | if (!(entity instanceof SingleSnippet || entity instanceof Heading)) { 198 | throw new MarkcheckSyntaxError( 199 | `Only SingleSnippets can be part of a sequence. Encountered a ${entity.constructor.name}`, 200 | { entity } 201 | ); 202 | } 203 | if (!(entity instanceof SingleSnippet)) { 204 | continue; 205 | } 206 | const num = entity.sequenceNumber; 207 | if (num === null) { 208 | throw new MarkcheckSyntaxError( 209 | `Snippet has no sequence number (expected: ${openSequence.nextSequenceNumber})`, 210 | { entity } 211 | ); 212 | } 213 | openSequence.pushElement(entity, num); 214 | if (openSequence.isComplete()) { 215 | yield openSequence; 216 | openSequence = null; 217 | } 218 | } else { 219 | // openSequence === null 220 | if (entity instanceof SingleSnippet && entity.sequenceNumber !== null) { 221 | openSequence = new SequenceSnippet(entity);; 222 | } else { 223 | yield entity; 224 | } 225 | } 226 | } 227 | if (openSequence !== null) { 228 | const first = openSequence.firstElement; 229 | const last = openSequence.lastElement; 230 | throw new MarkcheckSyntaxError( 231 | `Sequence was not completed – first element: ${first.getEntityContext()}, last element: ${last.getEntityContext()}` 232 | ); 233 | } 234 | } 235 | 236 | const DEFINE_STDOUT = 'stdout'; 237 | const DEFINE_STDERR = 'stderr'; 238 | const DEFINE_VALUES = [DEFINE_STDOUT, DEFINE_STDERR]; 239 | 240 | function* embedDefinitionSnippets(parsedEntities: Array): Iterable { 241 | // We collect definition snippets after we group single snippets into 242 | // sequences because definition snippets for sequences must come after 243 | // the complete sequence. 244 | let currentSnippet: null | Snippet = null; 245 | for (const entity of parsedEntities) { 246 | if (entity instanceof Snippet) { 247 | const definitionKey: null | string = entity.define; 248 | if (definitionKey !== null) { 249 | if (currentSnippet === null) { 250 | throw new MarkcheckSyntaxError( 251 | `A definition snippet (attribute ${stringify(ATTR_KEY_DEFINE)}) can only come after a snippet`, 252 | { entity } 253 | ); 254 | } 255 | if (!(entity instanceof SingleSnippet)) { 256 | throw new MarkcheckSyntaxError( 257 | `Only a ${SingleSnippet.name} can have the attribute ${stringify(ATTR_KEY_DEFINE)}`, 258 | { entity } 259 | ); 260 | } 261 | switch (definitionKey) { 262 | case DEFINE_STDOUT: 263 | if (currentSnippet.stdoutSpec !== null) { 264 | throw new MarkcheckSyntaxError( 265 | `Can’t define stdout for snippet ${currentSnippet.getEntityContext().describe()} – it already has the attribute ${stringify(ATTR_KEY_STDOUT)}`, 266 | { entity } 267 | ); 268 | } 269 | currentSnippet.stdoutSpec = { 270 | kind: 'StdStreamContentSpecDefinitionSnippet', 271 | snippet: entity, 272 | }; 273 | break; 274 | case DEFINE_STDERR: 275 | if (currentSnippet.stderrSpec !== null) { 276 | throw new MarkcheckSyntaxError( 277 | `Can’t define stderr for snippet ${currentSnippet.getEntityContext().describe()} – it already has the attribute ${stringify(ATTR_KEY_STDERR)}`, 278 | { entity } 279 | ); 280 | } 281 | currentSnippet.stderrSpec = { 282 | kind: 'StdStreamContentSpecDefinitionSnippet', 283 | snippet: entity, 284 | }; 285 | break; 286 | default: 287 | throw new MarkcheckSyntaxError( 288 | `Unsupported value for attribute ${stringify(ATTR_KEY_DEFINE)}. The only allowed values are: ${stringify(DEFINE_VALUES)}`, 289 | { entity } 290 | ); 291 | } 292 | // currentSnippet is unchanged and available for more `define` 293 | // snippets 294 | } else { 295 | yield entity; 296 | currentSnippet = entity; 297 | } 298 | } else { 299 | yield entity; 300 | currentSnippet = null; 301 | } 302 | } 303 | } 304 | 305 | export type ParsedMarkdown = { 306 | entities: Array, 307 | idToSnippet: Map, 308 | idToLineMod: Map, 309 | }; 310 | 311 | //#################### directiveToEntity #################### 312 | 313 | /** 314 | * Returned snippets are open or closed 315 | */ 316 | export function directiveToEntity(directive: Directive): null | ConfigMod | SingleSnippet | LineModAppliable | LineModLanguage { 317 | switch (directive.bodyLabel) { 318 | case BODY_LABEL_CONFIG: 319 | directive.checkAttributes(ATTRS_CONFIG_MOD); 320 | return new ConfigMod(directive); 321 | 322 | case BODY_LABEL_BODY: { 323 | directive.checkAttributes(ATTRS_SNIPPET); 324 | return SingleSnippet.createClosedFromBodyDirective(directive); 325 | } 326 | 327 | case BODY_LABEL_BEFORE: 328 | case BODY_LABEL_AFTER: 329 | case BODY_LABEL_AROUND: 330 | case BODY_LABEL_INSERT: 331 | case null: { 332 | // Either: 333 | // - Language LineMod 334 | // - Appliable LineMod 335 | // - Open snippet with local LineMod 336 | 337 | const each = directive.getString(ATTR_KEY_EACH); 338 | if (each !== null) { 339 | // Language LineMod 340 | directive.checkAttributes(ATTRS_LANGUAGE_LINE_MOD); 341 | return new LineModLanguage(directive, each); 342 | } 343 | 344 | const lineModId = directive.getString(ATTR_KEY_LINE_MOD_ID); 345 | if (lineModId !== null) { 346 | // Appliable LineMod 347 | if (directive.bodyLabel === BODY_LABEL_INSERT) { 348 | directive.checkAttributes(ATTRS_APPLIABLE_LINE_MOD_BODY_LABEL_INSERT); 349 | } else { 350 | directive.checkAttributes(ATTRS_APPLIABLE_LINE_MOD); 351 | } 352 | return new LineModAppliable(directive, lineModId); 353 | } 354 | 355 | // Open snippet with local LineMod 356 | if (directive.bodyLabel === BODY_LABEL_INSERT) { 357 | directive.checkAttributes(ATTRS_SNIPPET_BODY_LABEL_INSERT); 358 | } else { 359 | directive.checkAttributes(ATTRS_SNIPPET); 360 | } 361 | const snippet = SingleSnippet.createOpen(directive); 362 | snippet.internalLineMod = new LineModInternal(directive); 363 | return snippet; 364 | } 365 | 366 | default: { 367 | throw new MarkcheckSyntaxError( 368 | `Unsupported body label: ${stringify(directive.bodyLabel)}`, 369 | { lineNumber: directive.lineNumber } 370 | ); 371 | } 372 | } 373 | } 374 | 375 | //#################### Comments #################### 376 | 377 | const RE_COMMENT_START = /^(\r?\n)?$/; 379 | 380 | export function extractCommentContent(html: string): null | string { 381 | const startMatch = RE_COMMENT_START.exec(html); 382 | const endMatch = RE_COMMENT_END.exec(html); 383 | if (startMatch === null || endMatch === null) return null; 384 | return html.slice(startMatch[0].length, endMatch.index); 385 | } 386 | 387 | //#################### Indices into entities #################### 388 | 389 | function createIdToSnippet(entities: Array): Map { 390 | const idToSnippet = new Map(); 391 | for (const entity of entities) { 392 | if (entity instanceof Snippet && entity.id) { 393 | const other = idToSnippet.get(entity.id); 394 | if (other) { 395 | const description = other.getEntityContext().describe(); 396 | throw new MarkcheckSyntaxError( 397 | `Duplicate ${JSON.stringify(ATTR_KEY_ID)}: ${JSON.stringify(entity.id)} (other usage is ${description})`, 398 | { lineNumber: entity.lineNumber } 399 | ); 400 | } 401 | idToSnippet.set(entity.id, entity); 402 | } 403 | } 404 | return idToSnippet; 405 | } 406 | 407 | function createIdToLineMod(entities: Array): Map { 408 | const idToLineMod = new Map(); 409 | for (const entity of entities) { 410 | if (entity instanceof LineModAppliable) { 411 | const lineModId = entity.lineModId; 412 | if (lineModId) { 413 | const other = idToLineMod.get(lineModId); 414 | if (other) { 415 | const description = other.getEntityContext().describe(); 416 | throw new MarkcheckSyntaxError( 417 | `Duplicate ${JSON.stringify(ATTR_KEY_LINE_MOD_ID)}: ${JSON.stringify(entity.lineModId)} (other usage is ${description})`, 418 | { entityContext: entity.context } 419 | ); 420 | } 421 | idToLineMod.set(lineModId, entity); 422 | } 423 | } 424 | } 425 | return idToLineMod; 426 | } 427 | -------------------------------------------------------------------------------- /src/core/parse-markdown_test.ts: -------------------------------------------------------------------------------- 1 | import { splitLinesExclEol } from '@rauschma/helpers/string/line.js'; 2 | import { outdent } from '@rauschma/helpers/template-tag/outdent-template-tag.js'; 3 | import { createSuite } from '@rauschma/helpers/testing/mocha.js'; 4 | import assert from 'node:assert/strict'; 5 | import { Directive } from '../entity/directive.js'; 6 | import { LineModLanguage } from '../entity/line-mod.js'; 7 | import { MarkcheckSyntaxError } from '../util/errors.js'; 8 | import { directiveToEntity, extractCommentContent, parseMarkdown } from './parse-markdown.js'; 9 | 10 | createSuite(import.meta.url); 11 | 12 | test('extractCommentContent()', () => { 13 | assert.equal( 14 | extractCommentContent(''), 15 | 'ABC' 16 | ); 17 | assert.equal( 18 | extractCommentContent('\n'), 19 | 'First line\nSecond line\n' 20 | ); 21 | }); 22 | 23 | test('parseMarkdown(): LineMod', () => { 24 | const { entities } = parseMarkdown(outdent` 25 | 30 | `); 31 | const lineMod = entities[0]; 32 | assert.ok(lineMod instanceof LineModLanguage); 33 | assert.deepEqual( 34 | lineMod.toJson(), 35 | { 36 | targetLanguage: 'js', 37 | beforeLines: [ 38 | 'Line before' 39 | ], 40 | afterLines: [ 41 | 'Line after' 42 | ], 43 | } 44 | ); 45 | }); 46 | 47 | test('parseMarkdown(): heading followed by code block', () => { 48 | const { entities } = parseMarkdown( 49 | outdent` 50 | ## Creating Arrays 51 | ▲▲▲js 52 | const arr = []; 53 | ▲▲▲ 54 | --> 55 | `.replaceAll('▲', '`') 56 | ); 57 | assert.deepEqual( 58 | entities.map(e => e.toJson()), 59 | [ 60 | { 61 | heading: 'Creating Arrays', 62 | }, 63 | { 64 | body: [ 65 | 'const arr = [];' 66 | ], 67 | define: null, 68 | external: [], 69 | id: null, 70 | lang: 'js', 71 | lineNumber: 2, 72 | stderr: null, 73 | stdout: null, 74 | } 75 | ] 76 | ); 77 | }); 78 | 79 | test('directiveToEntity(): complain about illegal attributes', () => { 80 | const text = extractCommentContent(''); 81 | assert.ok(text !== null); 82 | const directive = Directive.parse(1, splitLinesExclEol(text)); 83 | assert.ok(directive !== null); 84 | assert.throws( 85 | () => directiveToEntity(directive), 86 | MarkcheckSyntaxError 87 | ); 88 | }); 89 | -------------------------------------------------------------------------------- /src/entity/config-mod.ts: -------------------------------------------------------------------------------- 1 | import { type JsonValue } from '@rauschma/helpers/typescript/json.js'; 2 | import json5 from 'json5'; 3 | import * as os from 'node:os'; 4 | import { ZodError } from 'zod'; 5 | import { ConfigModJsonSchema, type ConfigModJson } from '../core/config.js'; 6 | import { EntityContextLineNumber, MarkcheckSyntaxError, type EntityContext } from '../util/errors.js'; 7 | import { type Directive } from './directive.js'; 8 | import { MarkcheckEntity } from './markcheck-entity.js'; 9 | 10 | //#################### ConfigMod #################### 11 | 12 | export class ConfigMod extends MarkcheckEntity { 13 | lineNumber: number; 14 | configModJson: ConfigModJson; 15 | constructor(directive: Directive) { 16 | super(); 17 | this.lineNumber = directive.lineNumber; 18 | try { 19 | const text = directive.body.join(os.EOL); 20 | const json = json5.parse(text); 21 | this.configModJson = ConfigModJsonSchema.parse(json); 22 | } catch (err) { 23 | if (err instanceof SyntaxError) { 24 | throw new MarkcheckSyntaxError( 25 | `Error while parsing JSON5 config data:${os.EOL}${err.message}`, 26 | { lineNumber: this.lineNumber } 27 | ); 28 | } else if (err instanceof ZodError) { 29 | throw new MarkcheckSyntaxError( 30 | `Config properties are wrong:${os.EOL}${json5.stringify(err.format(), {space: 2})}`, 31 | { lineNumber: this.lineNumber } 32 | ); 33 | } else { 34 | // Unexpected error 35 | throw err; 36 | } 37 | } 38 | } 39 | override getEntityContext(): EntityContext { 40 | return new EntityContextLineNumber(this.lineNumber); 41 | } 42 | toJson(): JsonValue { 43 | return { 44 | configModJson: this.configModJson, 45 | }; 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/entity/directive.ts: -------------------------------------------------------------------------------- 1 | import { re } from '@rauschma/helpers/template-tag/re-template-tag.js'; 2 | import { UnsupportedValueError } from '@rauschma/helpers/typescript/error.js'; 3 | import { assertNonNullable, assertTrue } from '@rauschma/helpers/typescript/type.js'; 4 | import * as os from 'node:os'; 5 | import { InternalError, MarkcheckSyntaxError, type LineNumber } from '../util/errors.js'; 6 | 7 | const { stringify } = JSON; 8 | 9 | //#################### Constants #################### 10 | 11 | const RE_LABEL = /[A-Za-z0-9\-_]+/; 12 | 13 | //========== Attribute keys ========== 14 | 15 | // Referenced by attributes: external, sameAsId, include, stdout, stderr 16 | export const ATTR_KEY_ID = 'id'; 17 | 18 | //----- Running mode ----- 19 | 20 | export const ATTR_KEY_ONLY = 'only'; 21 | export const ATTR_KEY_SKIP = 'skip'; 22 | export const ATTR_ALWAYS_RUN = 'alwaysRun'; 23 | 24 | //----- Language ----- 25 | 26 | /** 27 | * Can prevent running! 28 | * - For body directives 29 | * - To override defaults from code blocks and `write` 30 | */ 31 | export const ATTR_KEY_LANG = 'lang'; 32 | 33 | //----- Assembling lines ----- 34 | 35 | export const ATTR_KEY_SEQUENCE = 'sequence'; 36 | export const ATTR_KEY_DEFINE = 'define'; 37 | export const ATTR_KEY_INCLUDE = 'include'; 38 | export const ATTR_KEY_APPLY_TO_BODY = 'applyToBody'; 39 | export const ATTR_KEY_APPLY_TO_OUTER = 'applyToOuter'; 40 | /** Exclude config lines and language LineMods */ 41 | export const ATTR_KEY_RUN_LOCAL_LINES = 'runLocalLines'; 42 | export const ATTR_KEY_IGNORE_LINES = 'ignoreLines'; 43 | export const ATTR_KEY_SEARCH_AND_REPLACE = 'searchAndReplace'; 44 | 45 | export const INCL_ID_THIS = '$THIS'; 46 | 47 | //----- Additional checks ----- 48 | 49 | export const ATTR_KEY_SAME_AS_ID = 'sameAsId'; 50 | export const ATTR_KEY_CONTAINED_IN_FILE = 'containedInFile'; 51 | 52 | //----- Writing and referring to files ----- 53 | 54 | export const ATTR_KEY_WRITE = 'write'; 55 | export const ATTR_KEY_WRITE_LOCAL_LINES = 'writeLocalLines'; 56 | export const ATTR_KEY_RUN_FILE_NAME = 'runFileName'; 57 | 58 | /** 59 | * ``` 60 | * external="id1>lib1.js, id2>lib2.js, id3>lib3.js" 61 | * external="lib1.ts, lib2.ts, lib3.ts" 62 | * ``` 63 | */ 64 | export const ATTR_KEY_EXTERNAL = 'external'; 65 | /** 66 | * - Local version of {@link ATTR_KEY_EXTERNAL} 67 | */ 68 | export const ATTR_KEY_EXTERNAL_LOCAL_LINES = 'externalLocalLines'; 69 | 70 | export enum LineScope { all, local }; 71 | export type ExternalSpec = { 72 | id: null | string, 73 | fileName: string, 74 | lineScope: LineScope, 75 | }; 76 | const RE_EXTERNAL_SPEC = re`/^((?${RE_LABEL}) *> *)?(?[^>]+)$/u`; 77 | export function parseExternalSpecs(lineNumber: number, lineScope: LineScope, str: string): Array { 78 | const specs = new Array(); 79 | 80 | const parts = str.split(/ *, */); 81 | for (const part of parts) { 82 | const match = RE_EXTERNAL_SPEC.exec(part); 83 | if (!match || !match.groups || !match.groups.fileName) { 84 | throw new MarkcheckSyntaxError( 85 | `Could not parse value of attribute ${ATTR_KEY_EXTERNAL}: ${JSON.stringify(part)}`, 86 | { lineNumber } 87 | ); 88 | } 89 | const id = match.groups.id ?? null; 90 | specs.push({ id, fileName: match.groups.fileName, lineScope }); 91 | } 92 | return specs; 93 | } 94 | 95 | //----- Checking command results ----- 96 | 97 | export const ATTR_KEY_EXIT_STATUS = 'exitStatus'; 98 | export const ATTR_KEY_STDOUT = 'stdout'; 99 | export const ATTR_KEY_STDERR = 'stderr'; 100 | 101 | export type StdStreamContentSpecSnippetId = { 102 | kind: 'StdStreamContentSpecSnippetId', 103 | lineModId: null | string; 104 | snippetId: string; 105 | }; 106 | export type StdStreamContentSpecIgnore = { 107 | kind: 'StdStreamContentSpecIgnore', 108 | }; 109 | 110 | export const IGNORE_STD_STREAM_STR = '[IGNORE]'; 111 | 112 | const RE_SPEC = re`/^(\|(?${RE_LABEL})=)?(?${RE_LABEL})$/`; 113 | export function parseStdStreamContentSpec(directiveLineNumber: number, attrKey: string, str: string): StdStreamContentSpecIgnore | StdStreamContentSpecSnippetId { 114 | if (str === IGNORE_STD_STREAM_STR) { 115 | return { 116 | kind: 'StdStreamContentSpecIgnore', 117 | }; 118 | } 119 | const match = RE_SPEC.exec(str); 120 | if (!match) { 121 | throw new MarkcheckSyntaxError( 122 | `Could not parse value of attribute ${stringify(attrKey)}: ${stringify(str)}`, 123 | { lineNumber: directiveLineNumber } 124 | ); 125 | } 126 | assertNonNullable(match.groups); 127 | assertNonNullable(match.groups.snippetId); 128 | return { 129 | kind: 'StdStreamContentSpecSnippetId', 130 | lineModId: match.groups.lineModId ?? null, 131 | snippetId: match.groups.snippetId, // never null 132 | }; 133 | } 134 | 135 | //----- Line mods ----- 136 | 137 | // Language LineMods 138 | export const ATTR_KEY_EACH = 'each'; 139 | 140 | // Appliable line mods 141 | // - Referenced by attributes: applyInner, applyOuter 142 | export const ATTR_KEY_LINE_MOD_ID = 'lineModId'; 143 | 144 | // Body label `insert:` (local LineMods only) 145 | export const ATTR_KEY_AT = 'at'; 146 | 147 | //========== Language constants ========== 148 | 149 | // In configurations for language definitions (property values of "lang" 150 | // object). 151 | export const LANG_KEY_EMPTY = ''; 152 | export const LANG_SKIP = '[skip]'; 153 | export const LANG_ERROR_IF_RUN = '[errorIfRun]'; 154 | 155 | // Values of property `lang` 156 | export const RE_LANG_VALUE = /^(?!\[).*$/; 157 | 158 | //========== Body labels ========== 159 | 160 | //----- Self-contained directives ----- 161 | export const BODY_LABEL_CONFIG = 'config:'; 162 | export const BODY_LABEL_BODY = 'body:'; 163 | 164 | //----- Line mods ----- 165 | export const BODY_LABEL_BEFORE = 'before:'; 166 | export const BODY_LABEL_AFTER = 'after:'; 167 | export const BODY_LABEL_AROUND = 'around:'; 168 | export const BODY_LABEL_INSERT = 'insert:'; // local LineMods only 169 | 170 | //========== Command variables ========== 171 | 172 | export const CMD_VAR_FILE_NAME = '$FILE_NAME'; 173 | export const CMD_VAR_ALL_FILE_NAMES = '$ALL_FILE_NAMES'; 174 | 175 | //#################### Expected attribute values #################### 176 | 177 | export const Valueless = Symbol('Valueless'); 178 | export enum AttrValue { 179 | Valueless, String 180 | } 181 | export type ExpectedAttributeValues = Map; 182 | 183 | function expectedAttrs(nameTypePairs: Array<[string, AttrValue | RegExp]>): ExpectedAttributeValues { 184 | nameTypePairs.sort( 185 | ([name1, _type1], [name2, _type2]) => name1.localeCompare(name2) 186 | ); 187 | return new Map(nameTypePairs); 188 | } 189 | 190 | export const ATTRS_LOCAL_LM_APPLIABLE_LM = expectedAttrs([ 191 | [ATTR_KEY_IGNORE_LINES, AttrValue.String], 192 | [ATTR_KEY_SEARCH_AND_REPLACE, AttrValue.String], 193 | ]); 194 | 195 | export const ATTRS_SNIPPET = expectedAttrs([ 196 | // These attributes are for the local LineMod: 197 | ...ATTRS_LOCAL_LM_APPLIABLE_LM, 198 | // 199 | [ATTR_KEY_ID, AttrValue.String], 200 | // 201 | [ATTR_KEY_ONLY, AttrValue.Valueless], 202 | [ATTR_KEY_SKIP, AttrValue.Valueless], 203 | [ATTR_ALWAYS_RUN, AttrValue.Valueless], 204 | // 205 | [ATTR_KEY_LANG, RE_LANG_VALUE], 206 | // 207 | [ATTR_KEY_SEQUENCE, AttrValue.String], 208 | [ATTR_KEY_DEFINE, AttrValue.String], 209 | [ATTR_KEY_INCLUDE, AttrValue.String], 210 | [ATTR_KEY_APPLY_TO_BODY, AttrValue.String], 211 | [ATTR_KEY_APPLY_TO_OUTER, AttrValue.String], 212 | [ATTR_KEY_RUN_LOCAL_LINES, AttrValue.Valueless], 213 | // 214 | [ATTR_KEY_SAME_AS_ID, AttrValue.String], 215 | [ATTR_KEY_CONTAINED_IN_FILE, AttrValue.String], 216 | // 217 | [ATTR_KEY_WRITE_LOCAL_LINES, AttrValue.String], 218 | [ATTR_KEY_WRITE, AttrValue.String], 219 | [ATTR_KEY_RUN_FILE_NAME, AttrValue.String], 220 | [ATTR_KEY_EXTERNAL, AttrValue.String], 221 | [ATTR_KEY_EXTERNAL_LOCAL_LINES, AttrValue.String], 222 | // 223 | [ATTR_KEY_EXIT_STATUS, AttrValue.String], 224 | [ATTR_KEY_STDOUT, AttrValue.String], 225 | [ATTR_KEY_STDERR, AttrValue.String], 226 | ]); 227 | 228 | export const ATTRS_SNIPPET_BODY_LABEL_INSERT = expectedAttrs([ 229 | ...ATTRS_SNIPPET, 230 | // Body label `insert:` (only local LineMods and appliable LineMods) 231 | [ATTR_KEY_AT, AttrValue.String], 232 | ]); 233 | 234 | export const ATTRS_APPLIABLE_LINE_MOD: ExpectedAttributeValues = new Map([ 235 | ...ATTRS_LOCAL_LM_APPLIABLE_LM, 236 | [ATTR_KEY_LINE_MOD_ID, AttrValue.String], 237 | ]); 238 | 239 | export const ATTRS_APPLIABLE_LINE_MOD_BODY_LABEL_INSERT = expectedAttrs([ 240 | ...ATTRS_APPLIABLE_LINE_MOD, 241 | // Body label `insert:` (only local LineMods and appliable LineMods) 242 | [ATTR_KEY_AT, AttrValue.String], 243 | ]); 244 | 245 | export const ATTRS_LANGUAGE_LINE_MOD: ExpectedAttributeValues = new Map([ 246 | [ATTR_KEY_EACH, AttrValue.String], 247 | // 248 | [ATTR_KEY_IGNORE_LINES, AttrValue.String], 249 | [ATTR_KEY_SEARCH_AND_REPLACE, AttrValue.String], 250 | // 251 | [ATTR_KEY_INCLUDE, AttrValue.String], 252 | ]); 253 | 254 | export const ATTRS_CONFIG_MOD: ExpectedAttributeValues = new Map([ 255 | ]); 256 | 257 | //#################### Directive #################### 258 | 259 | // Attribute values are stored raw – then we can decide in each case how to 260 | // handle backslashes. 261 | // - Use case – body label `insert:`: `at="before:'With \'single\' quotes'"` 262 | // - Use case: `searchAndReplace="/ \/\/ \([A-Z]\)//"` 263 | 264 | const MARKCHECK_MARKER = 'markcheck'; 265 | const RE_BODY_LABEL = re`/(?${RE_LABEL}:)/`; 266 | const RE_QUOTED_VALUE = re`/"(?(\\.|[^"])*)"/`; 267 | const RE_KEY_VALUE = re`/(?${RE_LABEL})([ \t]*=[ \t]*${RE_QUOTED_VALUE})?/`; 268 | const RE_TOKEN = re`/[ \t]+(${RE_BODY_LABEL}|${RE_KEY_VALUE})/uy`; 269 | 270 | export class Directive { 271 | static parse(lineNumber: LineNumber, commentLines: Array): null | Directive { 272 | let [firstLine, ...remainingLines] = commentLines; 273 | if (!firstLine.startsWith(MARKCHECK_MARKER)) { 274 | return null; 275 | } 276 | // Due to RE_TOKEN: 277 | // - We need a leading space: That’s why it isn‘t part of 278 | // MARKCHECK_MARKER. 279 | // - We don’t want trailing whitespace: Matching should stop at the 280 | // very end of the input but RE_TOKEN doesn’t account for trailing 281 | // whitespace. That’s why we .trimEnd(). 282 | firstLine = firstLine.slice(MARKCHECK_MARKER.length).trimEnd(); 283 | const body = remainingLines; 284 | const directive = new Directive(lineNumber, body); 285 | RE_TOKEN.lastIndex = 0; 286 | while (true) { 287 | // .lastIndex is reset if there is no match: Save it for later. 288 | const lastIndex = RE_TOKEN.lastIndex; 289 | const match = RE_TOKEN.exec(firstLine); 290 | if (!match) { 291 | if (lastIndex !== firstLine.length) { 292 | throw new MarkcheckSyntaxError( 293 | 'Could not parse attributes. Stopped parsing: `' + 294 | firstLine.slice(0, lastIndex) + '[HERE]' + firstLine.slice(lastIndex) + 295 | '`' 296 | , 297 | { lineNumber } 298 | ); 299 | } 300 | break; 301 | } 302 | if (directive.bodyLabel !== null) { 303 | throw new MarkcheckSyntaxError(`Body label ${JSON.stringify(directive.bodyLabel)} must come after all attributes`, { lineNumber }); 304 | } 305 | assertTrue(match.groups !== undefined); 306 | if (match.groups.key) { 307 | if (match.groups.value !== undefined) { 308 | directive.setAttribute( 309 | match.groups.key, 310 | match.groups.value 311 | ); 312 | } else { 313 | directive.setAttribute(match.groups.key, Valueless); 314 | } 315 | } else if (match.groups.bodyLabel) { 316 | directive.bodyLabel = match.groups.bodyLabel; 317 | } else { 318 | throw new InternalError(); 319 | } 320 | } 321 | return directive; 322 | } 323 | lineNumber: LineNumber; 324 | body: Array; 325 | #attributes = new Map(); 326 | bodyLabel: null | string = null; 327 | private constructor(lineNumber: LineNumber, body: Array) { 328 | this.lineNumber = lineNumber; 329 | this.body = body; 330 | } 331 | toJson() { 332 | return { 333 | lineNumber: this.lineNumber, 334 | attributes: Object.fromEntries( 335 | Array.from( 336 | this.#attributes, 337 | ([k, v]) => [k, (v === Valueless ? null : v)] 338 | ) 339 | ), 340 | bodyLabel: this.bodyLabel, 341 | body: this.body, 342 | }; 343 | } 344 | toString(): string { 345 | let result = ''; 362 | return result; 363 | } 364 | setAttribute(key: string, value: typeof Valueless | string): void { 365 | this.#attributes.set(key, value); 366 | } 367 | getAttribute(key: string): undefined | typeof Valueless | string { 368 | return this.#attributes.get(key); 369 | } 370 | getString(key: string): null | string { 371 | const value = this.getAttribute(key) ?? null; 372 | if (value === Valueless) { 373 | throw new MarkcheckSyntaxError( 374 | `Attribute ${stringify(key)} should be missing or a string but was valueless` 375 | ); 376 | } 377 | return value; 378 | } 379 | getBoolean(key: string): boolean { 380 | const value = this.getAttribute(key); 381 | if (value === undefined) return false; 382 | if (value === Valueless) return true; 383 | throw new MarkcheckSyntaxError( 384 | `Attribute ${stringify(key)} should be missing or valueless but was ${stringify(value)}` 385 | ); 386 | } 387 | hasAttribute(key: string): boolean { 388 | return this.#attributes.has(key); 389 | } 390 | checkAttributes(expectedAttributes: ExpectedAttributeValues): void { 391 | for (const [k, v] of this.#attributes) { 392 | const expectedValue = expectedAttributes.get(k); 393 | if (expectedValue === undefined) { 394 | throw new MarkcheckSyntaxError( 395 | `Unknown directive attribute key ${stringify(k)} (allowed are: ${Array.from(expectedAttributes.keys()).join(', ')})`, 396 | { lineNumber: this.lineNumber } 397 | ); 398 | } 399 | if (expectedValue === AttrValue.Valueless) { 400 | if (v !== Valueless) { 401 | throw new MarkcheckSyntaxError( 402 | `Directive attribute ${stringify(k)} must be valueless`, 403 | { lineNumber: this.lineNumber } 404 | ); 405 | } 406 | } else if (expectedValue === AttrValue.String) { 407 | if (typeof v !== 'string') { 408 | throw new MarkcheckSyntaxError( 409 | `Directive attribute ${stringify(k)} must have a string value`, 410 | { lineNumber: this.lineNumber } 411 | ); 412 | } 413 | } else if (expectedValue instanceof RegExp) { 414 | if (typeof v !== 'string' || !expectedValue.test(v)) { 415 | throw new MarkcheckSyntaxError( 416 | `Directive attribute ${stringify(k)} has an illegal value (must be string and match ${expectedValue})`, 417 | { lineNumber: this.lineNumber } 418 | ); 419 | } 420 | } else { 421 | throw new UnsupportedValueError(expectedValue) 422 | } 423 | } 424 | } 425 | } 426 | 427 | //#################### Helpers #################### 428 | 429 | //========== Sequence numbers ========== 430 | 431 | export class SequenceNumber { 432 | pos: number; 433 | total: number; 434 | constructor(pos: number, total: number) { 435 | this.pos = pos; 436 | this.total = total; 437 | } 438 | toString() { 439 | return this.pos + '/' + this.total; 440 | } 441 | } 442 | 443 | const RE_SEQUENCE_NUMBER = /^ *([0-9]+) *\/ *([0-9]+) *$/u; 444 | export function parseSequenceNumber(str: string): null | SequenceNumber { 445 | const match = RE_SEQUENCE_NUMBER.exec(str); 446 | if (!match) return null; 447 | const pos = Number(match[1]); 448 | const total = Number(match[2]); 449 | if (Number.isNaN(pos) || Number.isNaN(total)) { 450 | return null; 451 | } 452 | return new SequenceNumber(pos, total); 453 | } 454 | -------------------------------------------------------------------------------- /src/entity/directive_test.ts: -------------------------------------------------------------------------------- 1 | import { splitLinesExclEol } from '@rauschma/helpers/string/line.js'; 2 | import { createSuite } from '@rauschma/helpers/testing/mocha.js'; 3 | import assert from 'node:assert/strict'; 4 | import { extractCommentContent } from '../core/parse-markdown.js'; 5 | import { Directive, LineScope, parseExternalSpecs, type ExternalSpec } from './directive.js'; 6 | 7 | const { raw } = String; 8 | 9 | createSuite(import.meta.url); 10 | 11 | test('Directive.parse(): successes', () => { 12 | assert.deepEqual( 13 | parseDirective(raw``).toJson(), 14 | { 15 | lineNumber: 1, 16 | attributes: { 17 | noOuterLineMods: null, 18 | }, 19 | bodyLabel: null, 20 | body: [], 21 | }, 22 | 'Valueless attribute' 23 | ); 24 | assert.deepEqual( 25 | parseDirective(raw``).toJson(), 26 | { 27 | lineNumber: 1, 28 | attributes: { 29 | key: raw`value \"with\" quotes`, 30 | }, 31 | bodyLabel: null, 32 | body: [], 33 | }, 34 | 'Attribute value with escaped quotes (raw!)' 35 | ); 36 | assert.deepEqual( 37 | parseDirective(raw``).toJson(), 38 | { 39 | lineNumber: 1, 40 | attributes: { 41 | key: raw`a\\b\\c`, 42 | }, 43 | bodyLabel: null, 44 | body: [], 45 | }, 46 | 'Attribute value with backslashes (raw!)' 47 | ); 48 | assert.deepEqual( 49 | parseDirective(raw``).toJson(), 50 | { 51 | lineNumber: 1, 52 | attributes: { 53 | key: raw``, 54 | }, 55 | bodyLabel: null, 56 | body: [], 57 | }, 58 | 'Empty attribute value' 59 | ); 60 | }); 61 | 62 | test('Directive.parse(): failures', () => { 63 | assert.throws( 64 | () => parseDirective(raw``), 65 | { 66 | name: 'MarkcheckSyntaxError', 67 | message: 'Could not parse attributes. Stopped parsing: ` key[HERE]="unclosed`', 68 | }, 69 | 'Unclosed attribute value' 70 | ); 71 | }); 72 | 73 | test('parseExternalSpecs', () => { 74 | assert.deepEqual( 75 | parseExternalSpecs(1, LineScope.all, 'file0.js, id1>file1.js, id2>file2.js'), 76 | [ 77 | { 78 | id: null, 79 | lineScope: LineScope.all, 80 | fileName: 'file0.js', 81 | }, 82 | { 83 | id: 'id1', 84 | lineScope: LineScope.all, 85 | fileName: 'file1.js', 86 | }, 87 | { 88 | id: 'id2', 89 | lineScope: LineScope.all, 90 | fileName: 'file2.js', 91 | }, 92 | ] satisfies Array 93 | ); 94 | assert.deepEqual( 95 | parseExternalSpecs(1, LineScope.local, 'file0.js, id1>file1.js, id2>file2.js'), 96 | [ 97 | { 98 | id: null, 99 | lineScope: LineScope.local, 100 | fileName: 'file0.js', 101 | }, 102 | { 103 | id: 'id1', 104 | lineScope: LineScope.local, 105 | fileName: 'file1.js', 106 | }, 107 | { 108 | id: 'id2', 109 | lineScope: LineScope.local, 110 | fileName: 'file2.js', 111 | }, 112 | ] satisfies Array 113 | ); 114 | }); 115 | 116 | function parseDirective(md: string): Directive { 117 | const text = extractCommentContent(md); 118 | assert.ok(text !== null); 119 | const directive = Directive.parse(1, splitLinesExclEol(text)); 120 | assert.ok(directive !== null); 121 | return directive; 122 | } 123 | -------------------------------------------------------------------------------- /src/entity/entity-helpers.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * The result is double-quoted so that is can be used directly inside 3 | * messages etc. 4 | */ 5 | export function insertParsingPos(str: string, pos: number): string { 6 | return JSON.stringify(str.slice(0, pos) + '◆' + str.slice(pos)); 7 | } 8 | export function unescapeBackslashes(rawStr: string): string { 9 | return rawStr.replaceAll(/\\(.)/g, '$1'); 10 | } 11 | /** Similar to JSON.stringify(str) */ 12 | export function stringifyWithSingleQuote(str: string) { 13 | return `'` + str.replaceAll(/(['"\\])/g, String.raw`\$1`) + `'`; 14 | } -------------------------------------------------------------------------------- /src/entity/entity-helpers_test.ts: -------------------------------------------------------------------------------- 1 | import { createSuite } from '@rauschma/helpers/testing/mocha.js'; 2 | import assert from 'node:assert/strict'; 3 | import { insertParsingPos, stringifyWithSingleQuote, unescapeBackslashes } from './entity-helpers.js'; 4 | 5 | const {raw} = String; 6 | 7 | createSuite(import.meta.url); 8 | 9 | test('stringifySingleQuoted', () => { 10 | assert.equal( 11 | stringifyWithSingleQuote(raw`'a"b\c`), 12 | raw`'\'a\"b\\c'` 13 | ); 14 | }); 15 | 16 | test('unescapeBackslashes', () => { 17 | assert.equal( 18 | unescapeBackslashes(raw`\'a\"b\\c`), 19 | raw`'a"b\c` 20 | ); 21 | }); 22 | 23 | test('insertParsingPos', () => { 24 | assert.equal( 25 | insertParsingPos('AB', 0), 26 | '"◆AB"' 27 | ); 28 | assert.equal( 29 | insertParsingPos('AB', 1), 30 | '"A◆B"' 31 | ); 32 | assert.equal( 33 | insertParsingPos('AB', 2), 34 | '"AB◆"' 35 | ); 36 | }); 37 | -------------------------------------------------------------------------------- /src/entity/heading.ts: -------------------------------------------------------------------------------- 1 | import { type JsonValue } from '@rauschma/helpers/typescript/json.js'; 2 | import { EntityContextLineNumber, type EntityContext } from '../util/errors.js'; 3 | import { MarkcheckEntity } from './markcheck-entity.js'; 4 | 5 | //#################### Heading #################### 6 | 7 | export class Heading extends MarkcheckEntity { 8 | constructor(public lineNumber: number, public content: string) { 9 | super(); 10 | } 11 | override getEntityContext(): EntityContext { 12 | return new EntityContextLineNumber(this.lineNumber); 13 | } 14 | 15 | toJson(): JsonValue { 16 | return { 17 | heading: this.content, 18 | }; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/entity/insertion-rules.ts: -------------------------------------------------------------------------------- 1 | import { re } from '@rauschma/helpers/template-tag/re-template-tag.js'; 2 | import { type JsonValue } from '@rauschma/helpers/typescript/json.js'; 3 | import { assertNonNullable } from '@rauschma/helpers/typescript/type.js'; 4 | import { InternalError, MarkcheckSyntaxError, type EntityContext } from '../util/errors.js'; 5 | import { ATTR_KEY_AT } from './directive.js'; 6 | import { insertParsingPos, unescapeBackslashes } from './entity-helpers.js'; 7 | import { KEY_LINE_NUMBER, KEY_TEXT_FRAGMENT, RE_LINE_LOC, ensurePositiveLineNumber } from './line-loc-set.js'; 8 | 9 | const { stringify } = JSON; 10 | 11 | export enum LineLocModifier { 12 | Before = 'Before', 13 | After = 'After', 14 | } 15 | 16 | function lineLocationModifierToString(modifier: LineLocModifier): string { 17 | return modifier; 18 | } 19 | 20 | //#################### InsertionRules #################### 21 | 22 | export class InsertionRules { 23 | #rules = new Array(); 24 | 25 | isEmpty(): boolean { 26 | return this.#rules.length === 0; 27 | } 28 | 29 | toJson(): JsonValue { 30 | return this.#rules.map( 31 | rule => [rule.condition.toJson(), rule.lineGroup] 32 | ); 33 | } 34 | 35 | pushRule(insertionRule: InsertionRule): void { 36 | this.#rules.push(insertionRule); 37 | } 38 | 39 | maybePushLineGroup(entityContext: EntityContext, modifier: LineLocModifier, lastLineNumber: number, curLineNumber: number, line: string, linesOut: Array): void { 40 | const lineGroup = this.#getLineGroup(entityContext, modifier, lastLineNumber, curLineNumber, line); 41 | if (lineGroup) { 42 | linesOut.push(...lineGroup); 43 | } 44 | } 45 | #getLineGroup(entityContext: EntityContext, modifier: LineLocModifier, lastLineNumber: number, curLineNumber: number, line: string): null | Array { 46 | for (const rule of this.#rules) { 47 | if (rule.condition.matches(entityContext, modifier, lastLineNumber, curLineNumber, line)) { 48 | return rule.lineGroup; 49 | } 50 | } 51 | return null; 52 | } 53 | } 54 | 55 | export type InsertionRule = { 56 | condition: InsertionCondition; 57 | lineGroup: Array; 58 | }; 59 | 60 | //#################### InsertionCondition #################### 61 | 62 | const RE_PART = re`/(?before|after):${RE_LINE_LOC}(?\s*,\s*)?/uy`; 63 | 64 | export function parseInsertionConditions(directiveLineNumber: number, str: string): Array { 65 | const result = new Array(); 66 | RE_PART.lastIndex = 0; 67 | // Using `true` as `while` condition would make trailing commas illegal 68 | while (RE_PART.lastIndex < str.length) { 69 | const lastIndex = RE_PART.lastIndex; 70 | const match = RE_PART.exec(str); 71 | if (!match) { 72 | throw new MarkcheckSyntaxError( 73 | `Could not parse insertion conditions (attribute ${stringify(ATTR_KEY_AT)}): ${insertParsingPos(str, lastIndex)}`, 74 | {lineNumber: directiveLineNumber} 75 | ); 76 | } 77 | assertNonNullable(match.groups); 78 | let modifier; 79 | switch (match.groups.modifier) { 80 | case 'before': 81 | modifier = LineLocModifier.Before; 82 | break; 83 | case 'after': 84 | modifier = LineLocModifier.After; 85 | break; 86 | default: 87 | throw new InternalError(); 88 | } 89 | if (match.groups[KEY_LINE_NUMBER] !== undefined) { 90 | const lineNumber = Number(match.groups[KEY_LINE_NUMBER]); 91 | result.push(new InsCondLineNumber(modifier, lineNumber)); 92 | } else if (match.groups[KEY_TEXT_FRAGMENT] !== undefined) { 93 | const textFragment = unescapeBackslashes(match.groups[KEY_TEXT_FRAGMENT]); 94 | result.push(new InsCondTextFragment(modifier, textFragment)); 95 | } else { 96 | throw new InternalError(); 97 | } 98 | if (match.groups.comma === undefined) { 99 | // The separating comma can only be omitted at the end 100 | if (RE_PART.lastIndex >= str.length) { 101 | break; 102 | } 103 | throw new MarkcheckSyntaxError( 104 | `Expected a comma after an insertion condition (attribute ${stringify(ATTR_KEY_AT)}): ${insertParsingPos(str, RE_PART.lastIndex)}`, 105 | {lineNumber: directiveLineNumber} 106 | ); 107 | } 108 | } 109 | return result; 110 | } 111 | 112 | export function insertionConditionsToJson(conditions: Array): JsonValue { 113 | return conditions.map(c => c.toJson()); 114 | } 115 | 116 | export abstract class InsertionCondition { 117 | abstract matches(entityContext: EntityContext, modifier: LineLocModifier, lastLineNumber: number, curLineNumber: number, _line: string): boolean; 118 | abstract toJson(): JsonValue; 119 | } 120 | 121 | //========== InsCondTextFragment ========== 122 | 123 | class InsCondTextFragment extends InsertionCondition { 124 | constructor(public modifier: LineLocModifier, public textFragment: string) { 125 | super(); 126 | } 127 | override matches(_entityContext: EntityContext, modifier: LineLocModifier, _lastLineNumber: number, _curLineNumber: number, line: string): boolean { 128 | return ( 129 | modifier === this.modifier && 130 | line.includes(this.textFragment) 131 | ); 132 | } 133 | override toJson(): JsonValue { 134 | return { 135 | modifier: lineLocationModifierToString(this.modifier), 136 | textFragment: this.textFragment, 137 | }; 138 | } 139 | } 140 | 141 | //========== InsCondLineNumber ========== 142 | 143 | class InsCondLineNumber extends InsertionCondition { 144 | constructor(public modifier: LineLocModifier, public lineNumber: number) { 145 | super(); 146 | } 147 | override matches(entityContext: EntityContext, modifier: LineLocModifier, lastLineNumber: number, curLineNumber: number, _line: string): boolean { 148 | return ( 149 | modifier === this.modifier && 150 | curLineNumber === ensurePositiveLineNumber(entityContext, lastLineNumber, this.lineNumber, ATTR_KEY_AT) 151 | ); 152 | } 153 | override toJson(): JsonValue { 154 | return { 155 | modifier: lineLocationModifierToString(this.modifier), 156 | lineNumber: this.lineNumber, 157 | }; 158 | } 159 | } 160 | -------------------------------------------------------------------------------- /src/entity/insertion-rules_test.ts: -------------------------------------------------------------------------------- 1 | import { createSuite } from '@rauschma/helpers/testing/mocha.js'; 2 | import assert from 'node:assert/strict'; 3 | import { insertionConditionsToJson, parseInsertionConditions } from './insertion-rules.js'; 4 | 5 | const {raw} = String; 6 | 7 | createSuite(import.meta.url); 8 | 9 | test('parseInsertionConditions', () => { 10 | const parse = (str: string) => insertionConditionsToJson(parseInsertionConditions(0, str)); 11 | 12 | assert.deepEqual( 13 | parse(''), 14 | [] 15 | ); 16 | assert.deepEqual( 17 | parse(raw`before:-1, after:'' , after:'\"yes\"', before:'\'no\''`), 18 | [ 19 | { 20 | modifier: 'Before', 21 | lineNumber: -1, 22 | }, 23 | { 24 | modifier: 'After', 25 | textFragment: '', 26 | }, 27 | { 28 | modifier: 'After', 29 | textFragment: '"yes"', 30 | }, 31 | { 32 | modifier: 'Before', 33 | textFragment: "'no'", 34 | }, 35 | ] 36 | ); 37 | }); 38 | -------------------------------------------------------------------------------- /src/entity/line-loc-set.ts: -------------------------------------------------------------------------------- 1 | import { re } from '@rauschma/helpers/template-tag/re-template-tag.js'; 2 | import { UnsupportedValueError } from '@rauschma/helpers/typescript/error.js'; 3 | import { assertNonNullable } from '@rauschma/helpers/typescript/type.js'; 4 | import { InternalError, MarkcheckSyntaxError, type EntityContext } from '../util/errors.js'; 5 | import { ATTR_KEY_IGNORE_LINES } from './directive.js'; 6 | import { insertParsingPos, stringifyWithSingleQuote, unescapeBackslashes } from './entity-helpers.js'; 7 | 8 | const { stringify } = JSON; 9 | 10 | export type LineLocSet = Array; 11 | export type LineLocSetElement = LineLoc | LineRange; 12 | export type LineLoc = { 13 | kind: 'LineLoc', 14 | loc: number | string, 15 | }; 16 | export type LineRange = { 17 | kind: 'LineRange', 18 | start: LineLoc, 19 | end: LineLoc, 20 | }; 21 | function lineRangeToString(lineRange: LineRange): string { 22 | const start = lineLocToString(lineRange.start); 23 | const end = lineLocToString(lineRange.end); 24 | return `${start}${STR_RANGE_SEPARATOR}${end}`; 25 | } 26 | function lineLocToString(lineLoc: LineLoc) { 27 | if (typeof lineLoc.loc === 'number') { 28 | return String(lineLoc.loc); 29 | } else if (typeof lineLoc.loc === 'string') { 30 | return stringifyWithSingleQuote(lineLoc.loc); 31 | } else { 32 | throw new UnsupportedValueError(lineLoc.loc); 33 | } 34 | } 35 | 36 | export const KEY_LINE_NUMBER = 'lineNumber'; 37 | const RE_LINE_NUMBER = re`/(?<${KEY_LINE_NUMBER}>-?[0-9]+)/`; 38 | export const KEY_TEXT_FRAGMENT = 'textFragment'; 39 | const RE_TEXT_FRAGMENT = re`/'(?<${KEY_TEXT_FRAGMENT}>(\\.|[^'])*)'/`; 40 | export const RE_LINE_LOC = re`(${RE_LINE_NUMBER}|${RE_TEXT_FRAGMENT})`; 41 | 42 | export const KEY_LINE_NUMBER2 = 'lineNumber2'; 43 | const RE_LINE_NUMBER2 = re`/(?<${KEY_LINE_NUMBER2}>-?[0-9]+)/`; 44 | export const KEY_TEXT_FRAGMENT2 = 'textFragment2'; 45 | const RE_TEXT_FRAGMENT2 = re`/'(?<${KEY_TEXT_FRAGMENT2}>(\\.|[^'])*)'/`; 46 | export const RE_LINE_LOC2 = re`(${RE_LINE_NUMBER2}|${RE_TEXT_FRAGMENT2})`; 47 | 48 | const STR_RANGE_SEPARATOR = '..'; 49 | const RE_ELEMENT = re`/${RE_LINE_LOC}(\s*\.\.\s*${RE_LINE_LOC2})?(?\s*,\s*)?/uy`; 50 | 51 | export function parseLineLocSet(directiveLineNumber: number, str: string): LineLocSet { 52 | const result = new Array(); 53 | RE_ELEMENT.lastIndex = 0; 54 | // Using `true` as `while` condition would make trailing commas illegal 55 | while (RE_ELEMENT.lastIndex < str.length) { 56 | const lastIndex = RE_ELEMENT.lastIndex; 57 | const match = RE_ELEMENT.exec(str); 58 | if (!match) { 59 | throw new MarkcheckSyntaxError( 60 | `Could not parse line location set (attribute ${stringify(ATTR_KEY_IGNORE_LINES)}): ${insertParsingPos(str, lastIndex)}`, 61 | { lineNumber: directiveLineNumber } 62 | ); 63 | } 64 | assertNonNullable(match.groups); 65 | 66 | const loc = extractLineLoc(match, KEY_LINE_NUMBER, KEY_TEXT_FRAGMENT); 67 | // The properties always exist but their values may be `undefined` 68 | if (match.groups[KEY_LINE_NUMBER2] !== undefined || match.groups[KEY_TEXT_FRAGMENT2] !== undefined) { 69 | const loc2 = extractLineLoc(match, KEY_LINE_NUMBER2, KEY_TEXT_FRAGMENT2); 70 | result.push({ 71 | kind: 'LineRange', 72 | start: loc, 73 | end: loc2, 74 | }); 75 | } else { 76 | result.push(loc); 77 | } 78 | 79 | if (match.groups.comma === undefined) { 80 | // The separating comma can only be omitted at the end 81 | if (RE_ELEMENT.lastIndex >= str.length) { 82 | break; 83 | } 84 | throw new MarkcheckSyntaxError( 85 | `Expected a comma after an insertion condition (attribute ${stringify(ATTR_KEY_IGNORE_LINES)}): ${insertParsingPos(str, RE_ELEMENT.lastIndex)}`, 86 | { lineNumber: directiveLineNumber } 87 | ); 88 | } 89 | } 90 | return result; 91 | 92 | function extractLineLoc(match: RegExpExecArray, keyLineNumber: string, keyTextFragment: string): LineLoc { 93 | assertNonNullable(match.groups); 94 | if (match.groups[keyLineNumber] !== undefined) { 95 | return { 96 | kind: 'LineLoc', 97 | loc: Number(match.groups[keyLineNumber]), 98 | }; 99 | } else if (match.groups[keyTextFragment] !== undefined) { 100 | return { 101 | kind: 'LineLoc', 102 | loc: unescapeBackslashes(match.groups[keyTextFragment]), 103 | }; 104 | } else { 105 | throw new InternalError(); 106 | } 107 | } 108 | } 109 | 110 | export function lineLocSetToLineNumberSet(entityContext: EntityContext, lineLocSet: LineLocSet, lines: Array): Set { 111 | const result = new Set(); 112 | for (const elem of lineLocSet) { 113 | if (elem.kind === 'LineLoc') { 114 | result.add(lineLocToPositiveLineNumber(entityContext, lines, elem)); 115 | } else if (elem.kind === 'LineRange') { 116 | const start = lineLocToPositiveLineNumber(entityContext, lines, elem.start); 117 | const end = lineLocToPositiveLineNumber(entityContext, lines, elem.end); 118 | if (!(start <= end || start < 1 || end > lines.length)) { 119 | throw new MarkcheckSyntaxError( 120 | `Illegal range (attribute ${stringify(ATTR_KEY_IGNORE_LINES)}): ${lineRangeToString(elem)}`, 121 | { entityContext } 122 | ); 123 | } 124 | for (let i = start; i <= end; i++) { 125 | result.add(i); 126 | } 127 | } else { 128 | throw new UnsupportedValueError(elem); 129 | } 130 | } 131 | return result; 132 | } 133 | 134 | function lineLocToPositiveLineNumber(entityContext: EntityContext, lines: Array, lineLoc: LineLoc): number { 135 | if (typeof lineLoc.loc === 'number') { 136 | return ensurePositiveLineNumber(entityContext, lines.length, lineLoc.loc, ATTR_KEY_IGNORE_LINES); 137 | } else if (typeof lineLoc.loc === 'string') { 138 | const loc = lineLoc.loc; 139 | const index = lines.findIndex(line => line.includes(loc)); 140 | if (index < 0) { 141 | throw new MarkcheckSyntaxError( 142 | `Could not find text fragment in lines (attribute ${stringify(ATTR_KEY_IGNORE_LINES)}): ${stringify(lineLoc.loc)}`, 143 | { entityContext } 144 | ); 145 | } 146 | return index + 1; 147 | } else { 148 | throw new UnsupportedValueError(lineLoc.loc); 149 | } 150 | } 151 | 152 | //#################### Helpers #################### 153 | 154 | export function ensurePositiveLineNumber(entityContext: EntityContext, lastLineNumber: number, origLineNumber: number, attrKey: string): number { 155 | let posLineNumber = origLineNumber; 156 | if (posLineNumber < 0) { 157 | // * -1 is the last line number 158 | // * -2 is the second-to-last line number 159 | // * Etc. 160 | posLineNumber = lastLineNumber + posLineNumber + 1; 161 | } 162 | if (!(1 <= posLineNumber && posLineNumber <= lastLineNumber)) { 163 | throw new MarkcheckSyntaxError( 164 | `Line number must be within the range [1, ${lastLineNumber}]. -1 means ${lastLineNumber} etc. (attribute ${stringify(attrKey)}): ${attrKey}=${stringify(origLineNumber)}`, 165 | { entityContext } 166 | ); 167 | } 168 | return posLineNumber; 169 | } 170 | -------------------------------------------------------------------------------- /src/entity/line-loc-set_test.ts: -------------------------------------------------------------------------------- 1 | import { createSuite } from '@rauschma/helpers/testing/mocha.js'; 2 | import assert from 'node:assert/strict'; 3 | import { EntityContextLineNumber } from '../util/errors.js'; 4 | import { lineLocSetToLineNumberSet, parseLineLocSet } from './line-loc-set.js'; 5 | 6 | const { raw } = String; 7 | 8 | createSuite(import.meta.url); 9 | 10 | test('parseLineLocSet', () => { 11 | assert.deepEqual( 12 | parseLineLocSet(1, raw`1, -3, 'with \'single\' quotes', 'abc'`), 13 | [ 14 | { 15 | kind: 'LineLoc', 16 | loc: 1 17 | }, 18 | { 19 | kind: 'LineLoc', 20 | loc: -3 21 | }, 22 | { 23 | kind: 'LineLoc', 24 | loc: "with 'single' quotes" 25 | }, 26 | { 27 | kind: 'LineLoc', 28 | loc: 'abc' 29 | } 30 | ] 31 | ); 32 | assert.deepEqual( 33 | parseLineLocSet(1, raw`1..2, 4, -5..-3, 'x', 'a'..'z'`), 34 | [ 35 | { 36 | kind: 'LineRange', 37 | start: { 38 | kind: 'LineLoc', 39 | loc: 1 40 | }, 41 | end: { 42 | kind: 'LineLoc', 43 | loc: 2 44 | }, 45 | }, 46 | { 47 | kind: 'LineLoc', 48 | loc: 4 49 | }, 50 | { 51 | kind: 'LineRange', 52 | start: { 53 | kind: 'LineLoc', 54 | loc: -5 55 | }, 56 | end: { 57 | kind: 'LineLoc', 58 | loc: -3 59 | }, 60 | }, 61 | { 62 | kind: 'LineLoc', 63 | loc: 'x' 64 | }, 65 | { 66 | kind: 'LineRange', 67 | start: { 68 | kind: 'LineLoc', 69 | loc: 'a' 70 | }, 71 | end: { 72 | kind: 'LineLoc', 73 | loc: 'z' 74 | }, 75 | } 76 | ] 77 | ); 78 | }); 79 | 80 | test('lineLocSetToLineNumberSet', () => { 81 | const lines = [ 82 | 'one', 83 | 'two', 84 | 'three', 85 | 'four', 86 | 'five', 87 | ]; 88 | { 89 | const set = parseLineLocSet(1, `1..2,`); 90 | assert.deepEqual( 91 | lineLocSetToLineNumberSet(new EntityContextLineNumber(1), set, lines), 92 | new Set([1, 2]) 93 | ); 94 | } 95 | { 96 | const set = parseLineLocSet(1, `-2..-1`); 97 | assert.deepEqual( 98 | lineLocSetToLineNumberSet(new EntityContextLineNumber(1), set, lines), 99 | new Set([4, 5]) 100 | ); 101 | } 102 | { 103 | const set = parseLineLocSet(1, `'one'..'two', 5`); 104 | assert.deepEqual( 105 | lineLocSetToLineNumberSet(new EntityContextLineNumber(1), set, lines), 106 | new Set([1, 2, 5]) 107 | ); 108 | } 109 | }); 110 | -------------------------------------------------------------------------------- /src/entity/line-mod.ts: -------------------------------------------------------------------------------- 1 | import { type JsonValue } from '@rauschma/helpers/typescript/json.js'; 2 | import { assertNonNullable, type PublicDataProperties } from '@rauschma/helpers/typescript/type.js'; 3 | import type { Config } from '../core/config.js'; 4 | import type { Translator } from '../translation/translation.js'; 5 | import { EntityContextLineNumber, InternalError, MarkcheckSyntaxError, type EntityContext } from '../util/errors.js'; 6 | import { SearchAndReplaceSpec } from '../util/search-and-replace-spec.js'; 7 | import { trimTrailingEmptyLines } from '../util/string.js'; 8 | import { ATTR_KEY_APPLY_TO_BODY, ATTR_KEY_AT, ATTR_KEY_IGNORE_LINES, ATTR_KEY_INCLUDE, ATTR_KEY_SEARCH_AND_REPLACE, BODY_LABEL_AFTER, BODY_LABEL_AROUND, BODY_LABEL_BEFORE, BODY_LABEL_INSERT, INCL_ID_THIS, type Directive } from './directive.js'; 9 | import { InsertionRules, LineLocModifier, parseInsertionConditions } from './insertion-rules.js'; 10 | import { lineLocSetToLineNumberSet, parseLineLocSet, type LineLocSet } from './line-loc-set.js'; 11 | import { MarkcheckEntity } from './markcheck-entity.js'; 12 | import type { Snippet } from './snippet.js'; 13 | 14 | const { stringify } = JSON; 15 | 16 | //#################### Constants #################### 17 | 18 | const RE_AROUND_MARKER = /^[ \t]*•••[ \t]*$/; 19 | const STR_AROUND_MARKER = '•••'; 20 | 21 | // export type LineModKind = LineModKindConfig | 22 | // LineModKindLanguage | 23 | // LineModKindAppliable | 24 | // LineModKindBody; 25 | // export type LineModKindConfig = { 26 | // tag: 'LineModKindConfig'; 27 | // }; 28 | // export type LineModKindLanguage = { 29 | // tag: 'LineModKindLanguage'; 30 | // targetLanguage: string; 31 | // }; 32 | // export type LineModKindAppliable = { 33 | // tag: 'LineModKindAppliable'; 34 | // lineModId: string; 35 | // }; 36 | // export type LineModKindBody = { 37 | // tag: 'LineModKindBody'; 38 | // }; 39 | 40 | //#################### LineMod #################### 41 | 42 | type LineModProps = PublicDataProperties; 43 | function lineModPropsEmpty(context: EntityContext): LineModProps { 44 | return { 45 | context, 46 | // 47 | includeIds: [], 48 | ignoreLines: [], 49 | searchAndReplace: null, 50 | // 51 | insertionRules: new InsertionRules, 52 | beforeLines: [], 53 | afterLines: [] 54 | }; 55 | } 56 | 57 | function lineModPropsFromDirective(directive: Directive, allowBodyLabelInsert: boolean): LineModProps { 58 | const entityContext = new EntityContextLineNumber(directive.lineNumber); 59 | const props = lineModPropsEmpty(entityContext); 60 | 61 | const include = directive.getString(ATTR_KEY_INCLUDE); 62 | if (include) { 63 | props.includeIds = include.split(/ *, */); 64 | } 65 | 66 | const ignoreLinesStr = directive.getString(ATTR_KEY_IGNORE_LINES); 67 | if (ignoreLinesStr) { 68 | props.ignoreLines = parseLineLocSet(directive.lineNumber, ignoreLinesStr); 69 | } 70 | const searchAndReplaceStr = directive.getString(ATTR_KEY_SEARCH_AND_REPLACE); 71 | if (searchAndReplaceStr) { 72 | try { 73 | props.searchAndReplace = SearchAndReplaceSpec.fromString( 74 | searchAndReplaceStr 75 | ); 76 | } catch (err) { 77 | throw new MarkcheckSyntaxError( 78 | `Could not parse value of attribute ${stringify(ATTR_KEY_SEARCH_AND_REPLACE)}`, 79 | { entityContext, cause: err } 80 | ); 81 | } 82 | } 83 | 84 | const body = trimTrailingEmptyLines(directive.body.slice()); 85 | switch (directive.bodyLabel) { 86 | case null: 87 | // No body label – e.g.: 88 | break; 89 | case BODY_LABEL_BEFORE: { 90 | props.beforeLines = body; 91 | break; 92 | } 93 | case BODY_LABEL_AFTER: { 94 | props.afterLines = body; 95 | break; 96 | } 97 | case BODY_LABEL_AROUND: { 98 | const l = splitAroundLines(directive.lineNumber, body); 99 | props.beforeLines = l.beforeLines; 100 | props.afterLines = l.afterLines; 101 | break; 102 | } 103 | case BODY_LABEL_INSERT: { 104 | if (!allowBodyLabelInsert) { 105 | throw new MarkcheckSyntaxError( 106 | `Body label ${stringify(BODY_LABEL_INSERT)} is only allowed for internal LineMods and appliable LineMods` 107 | ); 108 | } 109 | 110 | const atStr = directive.getString(ATTR_KEY_AT); 111 | if (atStr === null) { 112 | throw new MarkcheckSyntaxError( 113 | `Directive has the body label ${stringify(BODY_LABEL_INSERT)} but not attribute ${stringify(ATTR_KEY_AT)}`, 114 | { lineNumber: directive.lineNumber } 115 | ); 116 | } 117 | const conditions = parseInsertionConditions(directive.lineNumber, atStr); 118 | const lineGroups = splitInsertedLines(body); 119 | if (conditions.length !== lineGroups.length) { 120 | throw new MarkcheckSyntaxError( 121 | `Attribute ${stringify(ATTR_KEY_AT)} mentions ${conditions.length} condition(s) but the body after ${stringify(BODY_LABEL_INSERT)} has ${lineGroups.length} line group(s) (separated by ${stringify(STR_AROUND_MARKER)})`, 122 | { lineNumber: directive.lineNumber } 123 | ); 124 | } 125 | for (const [index, condition] of conditions.entries()) { 126 | const lineGroup = lineGroups[index]; 127 | assertNonNullable(lineGroup); 128 | props.insertionRules.pushRule({ condition, lineGroup }); 129 | } 130 | break; 131 | } 132 | default: 133 | throw new InternalError(); 134 | } 135 | return props; 136 | } 137 | 138 | //#################### LineMod #################### 139 | 140 | export enum EmitLines { 141 | Before, 142 | After, 143 | } 144 | 145 | abstract class LineMod extends MarkcheckEntity { 146 | 147 | /** 148 | * LineMods can be created from line in Config. Then they don’t have a 149 | * line number. Thus, the more general EntityContext is needed. 150 | */ 151 | context: EntityContext; 152 | // 153 | includeIds: Array; 154 | ignoreLines: LineLocSet; 155 | searchAndReplace: null | SearchAndReplaceSpec; 156 | // 157 | insertionRules: InsertionRules; 158 | beforeLines: Array; 159 | afterLines: Array; 160 | 161 | protected constructor(props: LineModProps) { 162 | super(); 163 | this.context = props.context; 164 | // 165 | this.includeIds = props.includeIds; 166 | this.ignoreLines = props.ignoreLines; 167 | this.searchAndReplace = props.searchAndReplace; 168 | // 169 | this.insertionRules = props.insertionRules; 170 | this.beforeLines = props.beforeLines; 171 | this.afterLines = props.afterLines; 172 | } 173 | override getEntityContext(): EntityContext { 174 | return this.context; 175 | } 176 | 177 | throwIfThisChangesBody(): void { 178 | if ( 179 | this.ignoreLines.length > 0 || 180 | this.searchAndReplace !== null || 181 | !this.insertionRules.isEmpty() 182 | ) { 183 | // In principle, ATTR_KEY_SEARCH_AND_REPLACE is composable, but 184 | // excluding it is simpler because it is the only composable 185 | // attribute that affects the body. 186 | throw new MarkcheckSyntaxError( 187 | `If there is the an ${stringify(ATTR_KEY_APPLY_TO_BODY)} LineMod then the internal LineMod must not change the body: No attributes ${stringify(ATTR_KEY_IGNORE_LINES)}, ${stringify(ATTR_KEY_SEARCH_AND_REPLACE)}; no body label ${stringify(BODY_LABEL_INSERT)}`, 188 | { entityContext: this.context } 189 | ); 190 | } 191 | } 192 | 193 | emitLines(emitLines: EmitLines, config: Config, idToSnippet: Map, idToLineMod: Map, linesOut: Array, pathOfIncludeIds = new Set()): void { 194 | 195 | if (emitLines === EmitLines.After) { 196 | // .afterLines comes before “after” includes 197 | linesOut.push(...this.afterLines); 198 | } 199 | 200 | let thisWasIncluded = false; 201 | for (const includeId of this.includeIds) { 202 | if (includeId === INCL_ID_THIS) { 203 | thisWasIncluded = true; 204 | continue; 205 | } 206 | if ( 207 | (emitLines === EmitLines.Before && !thisWasIncluded) 208 | || (emitLines === EmitLines.After && thisWasIncluded) 209 | ) { 210 | this.#assembleOneInclude(config, idToSnippet, idToLineMod, linesOut, pathOfIncludeIds, includeId); 211 | } 212 | } // for 213 | // - Without INCL_ID_THIS, we pretend that it was mentioned last. 214 | // - That works well for EmitLines.Before and EmitLines.After. 215 | 216 | if (emitLines === EmitLines.Before) { 217 | // .beforeLines comes after “before” includes 218 | linesOut.push(...this.beforeLines); 219 | } 220 | } 221 | 222 | #assembleOneInclude(config: Config, idToSnippet: Map, idToLineMod: Map, lines: Array, pathOfIncludeIds: Set, includeId: string) { 223 | if (pathOfIncludeIds.has(includeId)) { 224 | const idPath = [...pathOfIncludeIds, includeId]; 225 | throw new MarkcheckSyntaxError(`Cycle of includedIds ` + JSON.stringify(idPath)); 226 | } 227 | const snippet = idToSnippet.get(includeId); 228 | if (snippet === undefined) { 229 | throw new MarkcheckSyntaxError( 230 | `Snippet includes the unknown ID ${JSON.stringify(includeId)}`, 231 | { entityContext: this.context } 232 | ); 233 | } 234 | pathOfIncludeIds.add(includeId); 235 | snippet.assembleInnerLines(config, idToSnippet, idToLineMod, lines, pathOfIncludeIds); 236 | pathOfIncludeIds.delete(includeId); 237 | } 238 | 239 | transformBody(body: Array, translator: Translator | undefined, lineNumber: number): Array { 240 | const lineNumberSet = lineLocSetToLineNumberSet(this.context, this.ignoreLines, body); 241 | body = body.filter( 242 | (_line, index) => !lineNumberSet.has(index + 1) 243 | ); 244 | 245 | // We only search-and-replace in visible (non-inserted) lines because 246 | // we can do whatever we want in invisible lines. 247 | const sar = this.searchAndReplace; 248 | if (sar) { 249 | body = body.map( 250 | line => sar.replaceAll(line) 251 | ); 252 | } 253 | 254 | { 255 | const nextBody = new Array(); 256 | for (const [index, line] of body.entries()) { 257 | this.insertionRules.maybePushLineGroup(this.context, LineLocModifier.Before, body.length, index + 1, line, nextBody); 258 | nextBody.push(line); 259 | this.insertionRules.maybePushLineGroup(this.context, LineLocModifier.After, body.length, index + 1, line, nextBody); 260 | } 261 | body = nextBody; 262 | } 263 | 264 | if (translator) { 265 | body = translator.translate(lineNumber, body); 266 | } 267 | return body; 268 | } 269 | 270 | toJson(): JsonValue { 271 | return { 272 | ...this.getSubclassProps(), 273 | beforeLines: this.beforeLines, 274 | afterLines: this.afterLines, 275 | }; 276 | } 277 | protected abstract getSubclassProps(): Record; 278 | } 279 | 280 | export class LineModInternal extends LineMod { 281 | constructor(directive: Directive) { 282 | super(lineModPropsFromDirective(directive, true)); 283 | } 284 | protected override getSubclassProps(): Record { 285 | return { 286 | insertionRules: this.insertionRules.toJson(), 287 | }; 288 | } 289 | } 290 | 291 | export class LineModAppliable extends LineMod { 292 | lineModId: string; 293 | constructor(directive: Directive, lineModId: string) { 294 | super(lineModPropsFromDirective(directive, true)); 295 | this.lineModId = lineModId; 296 | } 297 | protected override getSubclassProps(): Record { 298 | return { 299 | lineModId: this.lineModId, 300 | }; 301 | } 302 | } 303 | 304 | export class LineModLanguage extends LineMod { 305 | targetLanguage: string; 306 | constructor(directive: Directive, targetLanguage: string) { 307 | super(lineModPropsFromDirective(directive, true)); 308 | this.targetLanguage = targetLanguage; 309 | } 310 | protected override getSubclassProps(): Record { 311 | return { 312 | targetLanguage: this.targetLanguage, 313 | }; 314 | } 315 | } 316 | 317 | /** 318 | * Created on the fly for the “before” lines from the Config. 319 | */ 320 | export class LineModConfig extends LineMod { 321 | constructor(context: EntityContext, beforeLines: Array, afterLines: Array) { 322 | super({ 323 | ...lineModPropsEmpty(context), 324 | beforeLines, 325 | afterLines, 326 | }); 327 | } 328 | protected override getSubclassProps(): Record { 329 | return {}; 330 | } 331 | } 332 | 333 | //#################### Helpers #################### 334 | 335 | export function splitAroundLines(lineNumber: number, lines: Array): { beforeLines: Array; afterLines: Array; } { 336 | const markerIndex = lines.findIndex(line => RE_AROUND_MARKER.test(line)); 337 | if (markerIndex < 0) { 338 | throw new MarkcheckSyntaxError(`Missing around marker ${STR_AROUND_MARKER} in ${BODY_LABEL_AROUND} body`, { lineNumber }); 339 | } 340 | return { 341 | beforeLines: lines.slice(0, markerIndex), 342 | afterLines: lines.slice(markerIndex + 1), 343 | }; 344 | } 345 | export function splitInsertedLines(lines: Array): Array> { 346 | const result: Array> = [[]]; 347 | for (const line of lines) { 348 | if (RE_AROUND_MARKER.test(line)) { 349 | result.push([]); 350 | } else { 351 | const lastLineGroup = result.at(-1); 352 | assertNonNullable(lastLineGroup); 353 | lastLineGroup.push(line); 354 | } 355 | } 356 | return result; 357 | } 358 | -------------------------------------------------------------------------------- /src/entity/markcheck-entity.ts: -------------------------------------------------------------------------------- 1 | import { type EntityContext } from '../util/errors.js'; 2 | 3 | export abstract class MarkcheckEntity { 4 | abstract getEntityContext(): EntityContext; 5 | } 6 | -------------------------------------------------------------------------------- /src/entity/snippet_test.ts: -------------------------------------------------------------------------------- 1 | import { createSuite } from '@rauschma/helpers/testing/mocha.js'; 2 | import assert from 'node:assert/strict'; 3 | import { parseMarkdown } from '../core/parse-markdown.js'; 4 | import { SingleSnippet } from './snippet.js'; 5 | import { outdent } from '@rauschma/helpers/template-tag/outdent-template-tag.js'; 6 | 7 | createSuite(import.meta.url); 8 | 9 | test('singleSnippet.getAllFileNames', () => { 10 | { 11 | const { entities } = parseMarkdown( 12 | outdent` 13 | 14 | ▲▲▲js 15 | console.log(); 16 | ▲▲▲ 17 | `.replaceAll('▲', '`') 18 | ); 19 | const snippet = entities[0]; 20 | assert.ok(snippet instanceof SingleSnippet); 21 | assert.deepEqual( 22 | snippet.getAllFileNames({ 23 | kind: 'LangDefCommand', 24 | runFileName: 'main.js', 25 | commands: [], 26 | }), 27 | ['file.js'] 28 | ); 29 | } 30 | { 31 | const { entities } = parseMarkdown( 32 | outdent` 33 | 34 | ▲▲▲js 35 | console.log(); 36 | ▲▲▲ 37 | `.replaceAll('▲', '`') 38 | ); 39 | const snippet = entities[0]; 40 | assert.ok(snippet instanceof SingleSnippet); 41 | assert.deepEqual( 42 | snippet.getAllFileNames({ 43 | kind: 'LangDefCommand', 44 | runFileName: 'main.js', 45 | commands: [], 46 | }), 47 | ['main.js', 'lib1.js', 'lib2.js'] 48 | ); 49 | } 50 | }); 51 | -------------------------------------------------------------------------------- /src/markcheck.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import { outdent } from '@rauschma/helpers/template-tag/outdent-template-tag.js'; 4 | import { assertTrue } from '@rauschma/helpers/typescript/type.js'; 5 | import { style } from '@rauschma/nodejs-tools/cli/text-style.js'; 6 | import json5 from 'json5'; 7 | import * as fs from 'node:fs'; 8 | import path from 'path'; 9 | import { parseArgs, type ParseArgsConfig } from 'util'; 10 | import { zodToJsonSchema } from 'zod-to-json-schema'; 11 | import { Config, ConfigModJsonSchema } from './core/config.js'; 12 | import { parseMarkdown } from './core/parse-markdown.js'; 13 | import { CONFIG_FILE_REL_PATH, runParsedMarkdown } from './core/run-entities.js'; 14 | import { GlobalRunningMode, LogLevel, StatusCounts } from './entity/snippet.js'; 15 | import { MarkcheckSyntaxError, Output, SnippetStatusEmoji } from './util/errors.js'; 16 | import { relPath } from './util/path-tools.js'; 17 | 18 | import pkg from '#root/package.json' with { type: 'json' }; 19 | 20 | const {stringify} = JSON; 21 | 22 | const BIN_NAME = 'markcheck'; 23 | const ARG_OPTIONS = { 24 | 'help': { 25 | type: 'boolean', 26 | short: 'h', 27 | }, 28 | 'version': { 29 | type: 'boolean', 30 | }, 31 | 'print-config': { 32 | type: 'boolean', 33 | short: 'c', 34 | }, 35 | 'print-schema': { 36 | type: 'boolean', 37 | short: 's', 38 | }, 39 | 'verbose': { 40 | type: 'boolean', 41 | short: 'v', 42 | }, 43 | } satisfies ParseArgsConfig['options']; 44 | 45 | export function main() { 46 | const out = ( 47 | process.stdout.isTTY 48 | ? Output.toStdout() 49 | : Output.toStdoutWithoutAnsiEscapes() 50 | ); 51 | const args = parseArgs({ allowPositionals: true, options: ARG_OPTIONS }); 52 | 53 | if (args.values.version) { 54 | out.writeLine(pkg.version); 55 | return; 56 | } 57 | if (args.values['print-config']) { 58 | out.writeLine(json5.stringify(new Config().addDefaults().toJson(), {space: 2, quote: `"`})); 59 | return; 60 | } 61 | if (args.values['print-schema']) { 62 | const json = zodToJsonSchema(ConfigModJsonSchema, "MarkcheckConfig"); 63 | console.log(JSON.stringify(json, null, 2)); 64 | // Alternative: 65 | // npx ts-json-schema-generator --tsconfig tsconfig.json --type 'ConfigJson' 66 | return; 67 | } 68 | if (args.values.help || args.positionals.length === 0) { 69 | const helpText = outdent` 70 | ${style.Bold`${BIN_NAME} «file1.md» «file2.md» ...`} 71 | 72 | Options: 73 | ${style.Bold`--help -h`} get help 74 | ${style.Bold`--version`} print version 75 | ${style.Bold`--print-config -c`} print configuration defaults 76 | ${style.Bold`--print-schema -s`} print JSON schema for config data (in config: directives 77 | ${ ` `} and ${CONFIG_FILE_REL_PATH}) 78 | ${style.Bold`--verbose -v`} show more information (e.g. which shell commands are run) 79 | `; 80 | out.writeLine(helpText); 81 | return; 82 | } 83 | const logLevel = (args.values.verbose ? LogLevel.Verbose : LogLevel.Normal); 84 | const failedFiles = new Array(); 85 | 86 | const mdFiles = args.positionals; 87 | assertTrue(mdFiles.length > 0); 88 | const totalStatus = new StatusCounts(); 89 | for (const filePath of mdFiles) { 90 | const absFilePath = path.resolve(filePath); 91 | const relFilePath = relPath(absFilePath); 92 | out.writeLine(); 93 | out.writeLine(style.FgBlue.Bold`========== ${relFilePath} ==========`); 94 | const statusCounts = new StatusCounts(relFilePath); 95 | const text = fs.readFileSync(absFilePath, 'utf-8'); 96 | let globalRunningMode = GlobalRunningMode.Normal; 97 | try { 98 | const parsedMarkdown = parseMarkdown(text); 99 | globalRunningMode = runParsedMarkdown(out, absFilePath, logLevel, parsedMarkdown, statusCounts); 100 | } catch (err) { 101 | // runParsedMarkdown() handles many exceptions (incl. `TestFailure`). 102 | // Here, we only need to handle exceptions that happen during 103 | // parseMarkdown(). 104 | if (err instanceof MarkcheckSyntaxError) { 105 | statusCounts.syntaxErrors++; 106 | err.logTo(out, `${SnippetStatusEmoji.FailureOrError} `); 107 | } else { 108 | throw new Error(`Unexpected error in file ${stringify(relFilePath)}`, { cause: err }); 109 | } 110 | } 111 | let headingStyle; 112 | let statusEmoji; 113 | if (mdFiles.length === 1) { 114 | headingStyle = statusCounts.getHeadingStyle(); 115 | statusEmoji = statusCounts.getSummaryStatusEmoji() + ' '; 116 | } else { 117 | headingStyle = style.Reset; 118 | statusEmoji = ''; 119 | } 120 | out.writeLine(headingStyle`----- Summary of ${stringify(relFilePath)} -----`); 121 | if (globalRunningMode === GlobalRunningMode.Only) { 122 | out.writeLine(`(Running mode ${stringify(GlobalRunningMode.Only)} was active)`); 123 | } 124 | out.writeLine(statusEmoji + statusCounts.describe()); 125 | totalStatus.addOther(statusCounts); 126 | if (statusCounts.hasFailed()) { 127 | failedFiles.push(statusCounts); 128 | } 129 | } 130 | 131 | if (mdFiles.length > 1) { 132 | // Only show a total summary if there is more than one file 133 | const headingStyle = totalStatus.getHeadingStyle(); 134 | out.writeLine(); 135 | out.writeLine(headingStyle`========== TOTAL SUMMARY ==========`); 136 | const totalFileCount = args.positionals.length; 137 | const totalString = totalFileCount + ( 138 | totalFileCount === 1 ? ' file' : ' files' 139 | ); 140 | if (failedFiles.length === 0) { 141 | out.writeLine(`${totalStatus.getSummaryStatusEmoji()} All files succeeded: ${totalString}`); 142 | out.writeLine(totalStatus.describe()); 143 | } else { 144 | out.writeLine(`${totalStatus.getSummaryStatusEmoji()} Failed files: ${failedFiles.length} of ${totalString}`); 145 | out.writeLine(totalStatus.describe()); 146 | for (const failedFile of failedFiles) { 147 | out.writeLine(`• ${failedFile.toString()}`); 148 | } 149 | } 150 | } 151 | } 152 | 153 | main(); -------------------------------------------------------------------------------- /src/translation/repl-to-js-translator.ts: -------------------------------------------------------------------------------- 1 | import { isEmptyLine } from '@rauschma/helpers/string/string.js'; 2 | import { MarkcheckSyntaxError } from '../util/errors.js'; 3 | import { normalizeWhitespace } from '../util/string.js'; 4 | import { type Translator } from './translation.js'; 5 | 6 | const RE_INPUT = /^> ?(.*)$/; 7 | 8 | // Example: 9 | // "TypeError: Cannot mix BigInt and other types," 10 | // "use explicit conversions" 11 | // Example: 12 | // "DOMException [DataCloneError]: Symbol() could not be cloned." 13 | const RE_EXCEPTION = /^([A-Z][A-Za-z0-9_$]+)(?: \[([A-Z][A-Za-z0-9_$]+)\])?:/; 14 | // If there is a match: 15 | // - Group 1 always exists 16 | // - Group 2 may be undefined 17 | 18 | export const nodeReplToJs: Translator = { 19 | key: 'node-repl-to-js', 20 | 21 | translate(lineNumber: number, lines: Array): Array { 22 | const result = new Array(); 23 | let index = 0; 24 | while (index < lines.length) { 25 | const inputLine = lines[index]; 26 | const inputLineNumber = index + 1; 27 | 28 | if (isEmptyLine(inputLine)) { 29 | result.push(inputLine); 30 | index++; 31 | continue; 32 | } 33 | 34 | const match = RE_INPUT.exec(inputLine); 35 | if (!match) { 36 | throw new MarkcheckSyntaxError( 37 | `REPL line ${inputLineNumber} does not contain input (${RE_INPUT}): ${JSON.stringify(inputLine)}`, 38 | { lineNumber } 39 | ) 40 | } 41 | const input = match[1]; 42 | index += 1; 43 | if (input.endsWith(';')) { 44 | // A statement, not an expression: There is no output (= expected 45 | // result). 46 | result.push(input); 47 | continue; 48 | } 49 | const nextInputLineIndex = findNextInputLine(lines, index); 50 | const outputLen = nextInputLineIndex - index; 51 | if (outputLen === 0) { 52 | throw new MarkcheckSyntaxError( 53 | `No output after REPL line ${inputLineNumber}: ${JSON.stringify(inputLine)}`, 54 | { lineNumber } 55 | ) 56 | } 57 | const exceptionMatch = RE_EXCEPTION.exec(lines[index]); 58 | if (exceptionMatch) { 59 | // Example: 60 | // "TypeError: Cannot mix BigInt and other types," 61 | // "use explicit conversions" 62 | // Example: 63 | // "DOMException [DataCloneError]: Symbol() could not be cloned." 64 | let name; 65 | if (exceptionMatch[2] !== undefined) { 66 | name = exceptionMatch[2]; // name in brackets is .name of exception 67 | } else { 68 | name = exceptionMatch[1]; // always a string 69 | } 70 | 71 | // First line: remove prefixed exception name 72 | let message = lines[index].slice(exceptionMatch[0].length); 73 | 74 | // Remaining lines: join into a single line 75 | for (let i = index + 1; i < nextInputLineIndex; i++) { 76 | message += ' '; 77 | message += lines[i]; 78 | } 79 | message = normalizeWhitespace(message).trim(); 80 | 81 | result.push( 82 | `assert.throws(`, 83 | `() => {`, 84 | input, 85 | `},`, 86 | JSON.stringify({ name, message }), 87 | `);`, 88 | ); 89 | } else { 90 | result.push( 91 | `assert.deepEqual(`, 92 | input, 93 | `,`, 94 | ...lines.slice(index, nextInputLineIndex), 95 | `);`, 96 | ); 97 | } 98 | index = nextInputLineIndex; 99 | } // while 100 | return result; 101 | }, 102 | }; 103 | 104 | function findNextInputLine(lines: Array, start: number): number { 105 | for (let i = start; i < lines.length; i++) { 106 | if (RE_INPUT.test(lines[i])) { 107 | return i; 108 | } 109 | } 110 | return lines.length; 111 | } -------------------------------------------------------------------------------- /src/translation/repl-to-js-translator_test.ts: -------------------------------------------------------------------------------- 1 | import { createSuite } from '@rauschma/helpers/testing/mocha.js'; 2 | import assert from 'node:assert/strict'; 3 | import { nodeReplToJs } from './repl-to-js-translator.js'; 4 | 5 | createSuite(import.meta.url); 6 | 7 | test('nodeReplToJs.translate', () => { 8 | assert.deepEqual( 9 | nodeReplToJs.translate(1, 10 | [ 11 | `> 2n + 1`, 12 | `TypeError: Cannot mix BigInt and other types, use explicit conversions`, 13 | ] 14 | ), 15 | [ 16 | `assert.throws(`, 17 | `() => {`, 18 | `2n + 1`, 19 | '},', 20 | `{"name":"TypeError","message":"Cannot mix BigInt and other types, use explicit conversions"}`, 21 | `);`, 22 | ], 23 | 'Expected exception' 24 | ); 25 | assert.deepEqual( 26 | nodeReplToJs.translate(1, 27 | [ 28 | `> 2 ** 8`, 29 | `256`, 30 | ] 31 | ), 32 | [ 33 | 'assert.deepEqual(', 34 | '2 ** 8', 35 | ',', 36 | '256', 37 | ');', 38 | ], 39 | 'Input and output' 40 | ); 41 | assert.deepEqual( 42 | nodeReplToJs.translate(1, 43 | [ 44 | `> const map = new Map();`, 45 | ``, 46 | `> map.set(NaN, 123);`, 47 | `> map.get(NaN)`, 48 | `123`, 49 | ] 50 | ), 51 | [ 52 | "const map = new Map();", 53 | "", 54 | "map.set(NaN, 123);", 55 | "assert.deepEqual(", 56 | "map.get(NaN)", 57 | ",", 58 | "123", 59 | ");", 60 | ], 61 | 'Empty line before input' 62 | ); 63 | }); -------------------------------------------------------------------------------- /src/translation/translation.ts: -------------------------------------------------------------------------------- 1 | export type Translator = { 2 | key: string, 3 | translate(lineNumber: number, lines: Array): Array, 4 | }; 5 | -------------------------------------------------------------------------------- /src/util/diffing.ts: -------------------------------------------------------------------------------- 1 | import { splitLinesExclEol } from '@rauschma/helpers/string/line.js'; 2 | import { outdent } from '@rauschma/helpers/template-tag/outdent-template-tag.js'; 3 | import { style, type TextStyleResult } from '@rauschma/nodejs-tools/cli/text-style.js'; 4 | import { isEntryModule } from '@rauschma/nodejs-tools/misc/import-meta.js'; 5 | import * as diff from 'diff'; 6 | import { Output } from './errors.js'; 7 | 8 | export function logDiff(out: Output, expectedLines: Array, actualLines: Array) { 9 | const changes = diff.diffArrays(expectedLines, actualLines); 10 | for (const change of changes) { 11 | let prefix; 12 | let lineStyle: TextStyleResult; 13 | if (change.added) { 14 | prefix = '+ '; 15 | lineStyle = style.FgGreen; 16 | } else if (change.removed) { 17 | prefix = '- '; 18 | lineStyle = style.FgRed; 19 | } else { 20 | prefix = ' '; 21 | lineStyle = style.Reset; 22 | } 23 | for (const v of change.value) { 24 | out.writeLine(lineStyle(prefix + v)); 25 | } 26 | } 27 | } 28 | 29 | export function areLinesEqual(expectedLines: Array, actualLines: Array): boolean { 30 | const expectedLen = expectedLines.length; 31 | const actualLen = actualLines.length; 32 | if (expectedLen !== actualLen) { 33 | return false; 34 | } 35 | for (let i=0; i, 66 | expectedLines?: Array, 67 | }, 68 | [PROP_STDERR]?: { 69 | actualLines: Array, 70 | expectedLines?: Array, 71 | }, 72 | cause?: any, 73 | } 74 | 75 | /** 76 | * Thrown when running code did work as expected. 77 | */ 78 | export class TestFailure extends Error { 79 | override name = this.constructor.name; 80 | context: undefined | EntityContext; 81 | actualStdoutLines: undefined | Array; 82 | expectedStdoutLines: undefined | Array; 83 | actualStderrLines: undefined | Array; 84 | expectedStderrLines: undefined | Array; 85 | constructor(message: string, opts: TestFailureOptions = {}) { 86 | super( 87 | message, 88 | (opts.cause ? { cause: opts.cause } : undefined) 89 | ); 90 | if (opts.lineNumber) { 91 | this.context = new EntityContextLineNumber(opts.lineNumber); 92 | } 93 | this.actualStdoutLines = opts[PROP_STDOUT]?.actualLines; 94 | this.expectedStdoutLines = opts[PROP_STDOUT]?.expectedLines; 95 | this.actualStderrLines = opts[PROP_STDERR]?.actualLines; 96 | this.expectedStderrLines = opts[PROP_STDERR]?.expectedLines; 97 | } 98 | } 99 | 100 | //#################### MarkcheckSyntaxError #################### 101 | 102 | export interface MarkcheckSyntaxErrorOptions { 103 | entity?: { getEntityContext(): EntityContext }; 104 | entityContext?: EntityContext; 105 | lineNumber?: number; 106 | cause?: any; 107 | } 108 | 109 | /** 110 | * Thrown when any aspect of Markcheck’s syntax is wrong: unknown 111 | * attributes, wrong format for `searchAndReplace`, etc. 112 | */ 113 | export class MarkcheckSyntaxError extends Error { 114 | override name = this.constructor.name; 115 | context: undefined | EntityContext; 116 | constructor(message: string, opts: MarkcheckSyntaxErrorOptions = {}) { 117 | super( 118 | message, 119 | (opts.cause ? { cause: opts.cause } : undefined) 120 | ); 121 | if (opts.entity) { 122 | this.context = opts.entity.getEntityContext(); 123 | } else if (opts.entityContext) { 124 | this.context = opts.entityContext; 125 | } else if (opts.lineNumber) { 126 | this.context = new EntityContextLineNumber(opts.lineNumber); 127 | } 128 | } 129 | logTo(out: Output, prefix = ''): void { 130 | const description = ( 131 | this.context 132 | ? this.context.describe() 133 | : 'unknown context' 134 | ); 135 | out.writeLine(`${prefix}[${description}] ${this.message}`); 136 | } 137 | } 138 | 139 | //#################### StartupError #################### 140 | 141 | export class StartupError extends Error { 142 | constructor(message: string) { 143 | super(message); 144 | } 145 | } 146 | 147 | //#################### InternalError #################### 148 | 149 | export class InternalError extends Error { 150 | override name = this.constructor.name; 151 | } 152 | 153 | //#################### Output #################### 154 | 155 | /** @see https://en.wikipedia.org/wiki/ANSI_escape_code#SGR_(Select_Graphic_Rendition)_parameters */ 156 | const RE_ANSI_SGR_ESCAPE = /\x1B\[[0-9;]*m/g; 157 | 158 | export class Output { 159 | static toStdout(): Output { 160 | return Output.toWriteStream(process.stdout); 161 | } 162 | static toStdoutWithoutAnsiEscapes(): Output { 163 | return new Output((str) => process.stdout.write(str.replaceAll(RE_ANSI_SGR_ESCAPE, ''))); 164 | } 165 | static toWriteStream(writeStream: tty.WriteStream): Output { 166 | return new Output((str) => writeStream.write(str)); 167 | } 168 | static ignore(): Output { 169 | return new Output((_str) => { }); 170 | } 171 | #write; 172 | constructor(write: (str: string) => void) { 173 | this.#write = write; 174 | } 175 | write(str: string): void { 176 | this.#write(str); 177 | } 178 | writeLine(str = ''): void { 179 | this.#write(str + os.EOL); 180 | } 181 | writeLineVerbose(str = ''): void { 182 | this.writeLine(style.FgBlue`‣ ${str}`); 183 | } 184 | } 185 | -------------------------------------------------------------------------------- /src/util/line-tools.ts: -------------------------------------------------------------------------------- 1 | export function linesContain(lines: Array, part: Array): boolean { 2 | const lastIndex = lines.length - part.length; 3 | containerLoop: for (let containerIndex = 0; containerIndex <= lastIndex; containerIndex++) { 4 | for (let lineIndex = 0; lineIndex < part.length; lineIndex++) { 5 | const containerLine = lines[containerIndex + lineIndex]; 6 | const line = part[lineIndex]; 7 | if (containerLine.trim() !== line.trim()) { 8 | continue containerLoop; 9 | } 10 | } 11 | // All lines matched 12 | return true; 13 | } 14 | return false; 15 | } 16 | 17 | export function linesAreSame(here: Array, there: Array): boolean { 18 | if (here.length !== there.length) { 19 | return false; 20 | } 21 | for (let i = 0; i < here.length; i++) { 22 | if (here[i].trim() !== there[i].trim()) { 23 | return false; 24 | } 25 | } 26 | return true; 27 | } 28 | -------------------------------------------------------------------------------- /src/util/path-tools.ts: -------------------------------------------------------------------------------- 1 | import * as path from 'node:path'; 2 | 3 | export function relPath(absPath: string) { 4 | return path.relative(process.cwd(), absPath); 5 | } 6 | -------------------------------------------------------------------------------- /src/util/search-and-replace-spec.ts: -------------------------------------------------------------------------------- 1 | import type { SearchAndReplace } from '@rauschma/helpers/string/escaper.js'; 2 | import { re } from '@rauschma/helpers/template-tag/re-template-tag.js'; 3 | 4 | const { raw } = String; 5 | const { stringify } = JSON; 6 | 7 | const RE_INNER = /(?:[^/]|\\[/])*/; 8 | const RE_SEARCH_AND_REPLACE = re`/^[/](${RE_INNER})[/](${RE_INNER})[/]([giy]+)?$/`; 9 | 10 | /** 11 | * - Example: `"/[⎡⎤]//i"` 12 | * - This code is duplicated in Bookmaker 13 | */ 14 | export class SearchAndReplaceSpec { 15 | static fromString(str: string): SearchAndReplaceSpec { 16 | const { search, replace } = parseSearchAndReplaceString( str); 17 | return new SearchAndReplaceSpec(search, replace); 18 | } 19 | #search; 20 | #replace; 21 | private constructor(search: RegExp, replace: string) { 22 | this.#search = search; 23 | this.#replace = replace; 24 | } 25 | toString(): string { 26 | // Stringification of RegExp automatically escapes slashes 27 | const searchNoFlags = new RegExp(this.#search, '').toString(); 28 | const escapedReplace = this.#replace.replaceAll('/', String.raw`\/`); 29 | return searchNoFlags + escapedReplace + '/' + this.#search.flags; 30 | } 31 | replaceAll(str: string): string { 32 | return str.replaceAll(this.#search, this.#replace); 33 | } 34 | } 35 | 36 | /** 37 | * @throws SyntaxError 38 | */ 39 | export function parseSearchAndReplaceString(str: string): SearchAndReplace { 40 | const match = RE_SEARCH_AND_REPLACE.exec(str); 41 | if (!match) { 42 | throw new SyntaxError( 43 | `Not a valid searchAndReplace string: ${stringify(str)}` 44 | ); 45 | } 46 | const search = match[1]; 47 | const replace = match[2].replaceAll(raw`\/`, '/'); 48 | let flags = match[3] ?? ''; 49 | if (!flags.includes('g')) { 50 | flags += 'g'; 51 | } 52 | return { 53 | search: new RegExp(search, flags), 54 | replace, 55 | }; 56 | } 57 | -------------------------------------------------------------------------------- /src/util/search-and-replace-spec_test.ts: -------------------------------------------------------------------------------- 1 | import { createSuite } from '@rauschma/helpers/testing/mocha.js'; 2 | import assert from 'node:assert/strict'; 3 | import { SearchAndReplaceSpec } from './search-and-replace-spec.js'; 4 | 5 | const { raw } = String; 6 | 7 | createSuite(import.meta.url); 8 | 9 | test('SearchAndReplaceSpec', () => { 10 | const testObjs = [ 11 | { 12 | title: 'Flag /g (without /y)', 13 | spec: raw`/X/A/g`, 14 | toString: raw`/X/A/g`, 15 | in: 'XXXaX', 16 | out: 'AAAaA', 17 | }, 18 | { 19 | title: 'Flag /g (with /y)', 20 | spec: raw`/X/A/gy`, 21 | toString: raw`/X/A/gy`, 22 | in: 'XXXaX', 23 | out: 'AAAaX', 24 | }, 25 | 26 | { 27 | title: 'No flags', 28 | spec: raw`/[⎡⎤]//`, 29 | toString: raw`/[⎡⎤]//g`, 30 | in: '⎡await ⎤asyncFunc()', 31 | out: 'await asyncFunc()', 32 | }, 33 | { 34 | title: 'Case insensitive matching', 35 | spec: raw`/a/x/i`, 36 | toString: raw`/a/x/gi`, 37 | in: 'aAaA', 38 | out: 'xxxx', 39 | }, 40 | { 41 | title: 'Case sensitive matching', 42 | spec: raw`/a/x/`, 43 | toString: raw`/a/x/g`, 44 | in: 'aAaA', 45 | out: 'xAxA', 46 | }, 47 | { 48 | title: 'Escaped slash in search', 49 | spec: raw`/\//o/`, 50 | toString: raw`/\//o/g`, 51 | in: '/--/', 52 | out: 'o--o', 53 | }, 54 | { 55 | title: 'Escaped slash in replace', 56 | spec: raw`/o/\//`, 57 | toString: raw`/o/\//g`, 58 | in: 'oo', 59 | out: '//', 60 | }, 61 | ]; 62 | for (const testObj of testObjs) { 63 | const sar = SearchAndReplaceSpec.fromString( 64 | testObj.spec 65 | ); 66 | assert.equal( 67 | sar.toString(), 68 | testObj.toString, 69 | testObj.title + ': .toString()' 70 | ); 71 | assert.equal( 72 | sar.replaceAll(testObj.in), 73 | testObj.out, 74 | testObj.title + ': .replaceAll()' 75 | ); 76 | } 77 | }); 78 | -------------------------------------------------------------------------------- /src/util/string.ts: -------------------------------------------------------------------------------- 1 | import { isEmptyLine } from '@rauschma/helpers/string/string.js'; 2 | 3 | export function trimTrailingEmptyLines(lines: Array): Array { 4 | lines.length = getEndTrimmedLength(lines); 5 | return lines; 6 | } 7 | 8 | export function getEndTrimmedLength(lines: Array): number { 9 | for (let i = lines.length - 1; i >= 0; i--) { 10 | if (!isEmptyLine(lines[i])) { 11 | return i + 1; 12 | } 13 | } 14 | return 0; // only empty lines 15 | } 16 | 17 | export function normalizeWhitespace(str: string) { 18 | return str.replace(/\s+/ug, ' '); 19 | } 20 | -------------------------------------------------------------------------------- /src/util/string_test.ts: -------------------------------------------------------------------------------- 1 | import { createSuite } from '@rauschma/helpers/testing/mocha.js'; 2 | import assert from 'node:assert/strict'; 3 | import { linesContain } from './line-tools.js'; 4 | 5 | createSuite(import.meta.url); 6 | 7 | test('linesContain()', () => { 8 | assert.ok( 9 | linesContain( 10 | ['a', 'b'], 11 | ['a', 'b'] 12 | ) 13 | ); 14 | assert.ok( 15 | linesContain( 16 | ['a', 'b', 'c'], 17 | ['a', 'b'] 18 | ) 19 | ); 20 | assert.ok( 21 | linesContain( 22 | ['x', 'a', 'b'], 23 | ['a', 'b'] 24 | ) 25 | ); 26 | assert.ok( 27 | linesContain( 28 | ['a', 'b', 'c'], 29 | ['a'] 30 | ) 31 | ); 32 | assert.ok( 33 | linesContain( 34 | ['a', 'b', 'c'], 35 | ['c'] 36 | ) 37 | ); 38 | 39 | // Not contained 40 | assert.ok( 41 | !linesContain( 42 | ['a', 'b', 'c'], 43 | ['x'] 44 | ) 45 | ); 46 | assert.ok( 47 | !linesContain( 48 | ['a', 'b', 'c'], 49 | ['b', 'c', 'd'] 50 | ) 51 | ); 52 | }); 53 | -------------------------------------------------------------------------------- /test/assembling-lines/before-after-around_test.ts: -------------------------------------------------------------------------------- 1 | import { outdent } from '@rauschma/helpers/template-tag/outdent-template-tag.js'; 2 | import { createSuite } from '@rauschma/helpers/testing/mocha.js'; 3 | import assert from 'node:assert/strict'; 4 | 5 | // Only dynamically imported modules use the patched `node:fs`! 6 | import { mfs } from '@rauschma/nodejs-tools/testing/install-mem-node-fs.js'; 7 | const { dirToJson, jsonToCleanDir } = await import('@rauschma/nodejs-tools/testing/dir-json.js'); 8 | const { runMarkdownForTests } = await import('../test-tools.js'); 9 | 10 | createSuite(import.meta.url); 11 | 12 | test('before', () => { 13 | const readme = outdent` 14 | 17 | ▲▲▲js 18 | console.log('body'); 19 | ▲▲▲ 20 | `.replaceAll('▲', '`'); 21 | jsonToCleanDir(mfs, { 22 | '/tmp/markcheck-data': {}, 23 | '/tmp/markdown/readme.md': readme, 24 | }); 25 | assert.ok( 26 | runMarkdownForTests('/tmp/markdown/readme.md', readme).hasSucceeded() 27 | ); 28 | assert.deepEqual( 29 | dirToJson('/tmp/markcheck-data/tmp', { trimEndsOfFiles: true }), 30 | { 31 | 'main.mjs': outdent` 32 | // BEFORE 33 | console.log('body'); 34 | `, 35 | } 36 | ); 37 | }); 38 | 39 | test('after', () => { 40 | const readme = outdent` 41 | 44 | ▲▲▲js 45 | console.log('body'); 46 | ▲▲▲ 47 | `.replaceAll('▲', '`'); 48 | jsonToCleanDir(mfs, { 49 | '/tmp/markcheck-data': {}, 50 | '/tmp/markdown/readme.md': readme, 51 | }); 52 | assert.ok( 53 | runMarkdownForTests('/tmp/markdown/readme.md', readme).hasSucceeded() 54 | ); 55 | assert.deepEqual( 56 | dirToJson('/tmp/markcheck-data/tmp', { trimEndsOfFiles: true }), 57 | { 58 | 'main.mjs': outdent` 59 | console.log('body'); 60 | // AFTER 61 | `, 62 | } 63 | ); 64 | }); 65 | 66 | test('around', () => { 67 | const readme = outdent` 68 | 75 | ▲▲▲js 76 | throw new Error(); 77 | ▲▲▲ 78 | `.replaceAll('▲', '`'); 79 | jsonToCleanDir(mfs, { 80 | '/tmp/markcheck-data': {}, 81 | '/tmp/markdown/readme.md': readme, 82 | }); 83 | assert.ok( 84 | runMarkdownForTests('/tmp/markdown/readme.md', readme).hasSucceeded() 85 | ); 86 | assert.deepEqual( 87 | dirToJson('/tmp/markcheck-data/tmp', { trimEndsOfFiles: true }), 88 | { 89 | 'main.mjs': outdent` 90 | import assert from 'node:assert/strict'; 91 | assert.throws( 92 | () => { 93 | throw new Error(); 94 | } 95 | ); 96 | `, 97 | } 98 | ); 99 | }); 100 | -------------------------------------------------------------------------------- /test/assembling-lines/ignore-lines_test.ts: -------------------------------------------------------------------------------- 1 | import { outdent } from '@rauschma/helpers/template-tag/outdent-template-tag.js'; 2 | import { createSuite } from '@rauschma/helpers/testing/mocha.js'; 3 | import assert from 'node:assert/strict'; 4 | 5 | // Only dynamically imported modules use the patched `node:fs`! 6 | import { mfs } from '@rauschma/nodejs-tools/testing/install-mem-node-fs.js'; 7 | const { dirToJson, jsonToCleanDir } = await import('@rauschma/nodejs-tools/testing/dir-json.js'); 8 | const { runMarkdownForTests } = await import('../test-tools.js'); 9 | 10 | createSuite(import.meta.url); 11 | 12 | test('ignoreLines: line numbers (internal LineMod)', () => { 13 | const readme = outdent` 14 | 15 | ▲▲▲js 16 | // one 17 | // two 18 | // three 19 | // four 20 | // five 21 | ▲▲▲ 22 | `.replaceAll('▲', '`'); 23 | jsonToCleanDir(mfs, { 24 | '/tmp/markcheck-data': {}, 25 | '/tmp/markdown/readme.md': readme, 26 | }); 27 | 28 | assert.ok( 29 | runMarkdownForTests('/tmp/markdown/readme.md', readme).hasSucceeded() 30 | ); 31 | assert.deepEqual( 32 | dirToJson('/tmp/markcheck-data/tmp', { trimEndsOfFiles: true }), 33 | { 34 | 'main.mjs': outdent` 35 | // three 36 | // four 37 | `, 38 | } 39 | ); 40 | }); 41 | 42 | test('ignoreLines: line numbers (applyToBody LineMod)', () => { 43 | const readme = outdent` 44 | 45 | ▲▲▲js 46 | // one 47 | // two 48 | // three 49 | // four 50 | // five 51 | ▲▲▲ 52 | 53 | 54 | `.replaceAll('▲', '`'); 55 | jsonToCleanDir(mfs, { 56 | '/tmp/markcheck-data': {}, 57 | '/tmp/markdown/readme.md': readme, 58 | }); 59 | 60 | assert.ok( 61 | runMarkdownForTests('/tmp/markdown/readme.md', readme).hasSucceeded() 62 | ); 63 | assert.deepEqual( 64 | dirToJson('/tmp/markcheck-data/tmp', { trimEndsOfFiles: true }), 65 | { 66 | 'main.mjs': outdent` 67 | // three 68 | // four 69 | `, 70 | } 71 | ); 72 | }); 73 | 74 | test('ignoreLines: text fragments', () => { 75 | const readme = outdent` 76 | 77 | ▲▲▲js 78 | // one 79 | // two 80 | // three 81 | // four 82 | // five 83 | ▲▲▲ 84 | `.replaceAll('▲', '`'); 85 | jsonToCleanDir(mfs, { 86 | '/tmp/markcheck-data': {}, 87 | '/tmp/markdown/readme.md': readme, 88 | }); 89 | 90 | assert.ok( 91 | runMarkdownForTests('/tmp/markdown/readme.md', readme).hasSucceeded() 92 | ); 93 | assert.deepEqual( 94 | dirToJson('/tmp/markcheck-data/tmp', { trimEndsOfFiles: true }), 95 | { 96 | 'main.mjs': outdent` 97 | // one 98 | // three 99 | `, 100 | } 101 | ); 102 | }); 103 | -------------------------------------------------------------------------------- /test/assembling-lines/include_test.ts: -------------------------------------------------------------------------------- 1 | import { outdent } from '@rauschma/helpers/template-tag/outdent-template-tag.js'; 2 | import { createSuite } from '@rauschma/helpers/testing/mocha.js'; 3 | import assert from 'node:assert/strict'; 4 | 5 | // Only dynamically imported modules use the patched `node:fs`! 6 | import { mfs } from '@rauschma/nodejs-tools/testing/install-mem-node-fs.js'; 7 | const { dirToJson, jsonToCleanDir } = await import('@rauschma/nodejs-tools/testing/dir-json.js'); 8 | const { runMarkdownForTests } = await import('../test-tools.js'); 9 | 10 | createSuite(import.meta.url); 11 | 12 | test('Included snippet', () => { 13 | const readme = outdent` 14 | 15 | ▲▲▲node-repl 16 | > twice('abc') 17 | 'abcabc' 18 | ▲▲▲ 19 | 20 | 21 | ▲▲▲js 22 | function twice(str) { return str + str } 23 | ▲▲▲ 24 | `.replaceAll('▲', '`'); 25 | jsonToCleanDir(mfs, { 26 | '/tmp/markcheck-data': {}, 27 | '/tmp/markdown/readme.md': readme, 28 | }); 29 | 30 | assert.ok( 31 | runMarkdownForTests('/tmp/markdown/readme.md', readme).hasSucceeded() 32 | ); 33 | assert.deepEqual( 34 | dirToJson('/tmp/markcheck-data/tmp', { trimEndsOfFiles: true }), 35 | { 36 | 'main.mjs': outdent` 37 | import assert from 'node:assert/strict'; 38 | function twice(str) { return str + str } 39 | assert.deepEqual( 40 | twice('abc') 41 | , 42 | 'abcabc' 43 | ); 44 | `, 45 | } 46 | ); 47 | }); 48 | 49 | test('Included snippet with `before:`', () => { 50 | const readme = outdent` 51 | 54 | ▲▲▲js 55 | function httpGet(url) { 56 | const xhr = new XMLHttpRequest(); 57 | } 58 | ▲▲▲ 59 | 60 | 63 | ▲▲▲js 64 | await httpGet('http://example.com/textfile.txt'); 65 | ▲▲▲ 66 | `.replaceAll('▲', '`'); 67 | jsonToCleanDir(mfs, { 68 | '/tmp/markcheck-data': {}, 69 | '/tmp/markdown/readme.md': readme, 70 | }); 71 | 72 | assert.ok( 73 | runMarkdownForTests('/tmp/markdown/readme.md', readme).hasSucceeded() 74 | ); 75 | assert.deepEqual( 76 | dirToJson('/tmp/markcheck-data/tmp', { trimEndsOfFiles: true }), 77 | { 78 | 'main.mjs': outdent` 79 | import assert from 'node:assert/strict'; 80 | import {XMLHttpRequest} from 'some-lib'; 81 | function httpGet(url) { 82 | const xhr = new XMLHttpRequest(); 83 | } 84 | import nock from 'nock'; 85 | await httpGet('http://example.com/textfile.txt'); 86 | `, 87 | } 88 | ); 89 | }); 90 | 91 | test('Assembling code fragments out of order', () => { 92 | const readme = outdent` 93 | 94 | ▲▲▲js 95 | steps.push('Step 3'); 96 | 97 | assert.deepEqual( 98 | steps, 99 | ['Step 1', 'Step 2', 'Step 3'] 100 | ); 101 | ▲▲▲ 102 | 103 | 104 | ▲▲▲js 105 | const steps = []; 106 | steps.push('Step 1'); 107 | ▲▲▲ 108 | 109 | 110 | ▲▲▲js 111 | steps.push('Step 2'); 112 | ▲▲▲ 113 | `.replaceAll('▲', '`'); 114 | jsonToCleanDir(mfs, { 115 | '/tmp/markcheck-data': {}, 116 | '/tmp/markdown/readme.md': readme, 117 | }); 118 | 119 | assert.ok( 120 | runMarkdownForTests('/tmp/markdown/readme.md', readme).hasSucceeded() 121 | ); 122 | assert.deepEqual( 123 | dirToJson('/tmp/markcheck-data/tmp', { trimEndsOfFiles: true }), 124 | { 125 | 'main.mjs': outdent` 126 | import assert from 'node:assert/strict'; 127 | const steps = []; 128 | steps.push('Step 1'); 129 | steps.push('Step 2'); 130 | steps.push('Step 3'); 131 | 132 | assert.deepEqual( 133 | steps, 134 | ['Step 1', 'Step 2', 'Step 3'] 135 | ); 136 | `, 137 | } 138 | ); 139 | }); 140 | -------------------------------------------------------------------------------- /test/assembling-lines/insert_test.ts: -------------------------------------------------------------------------------- 1 | import { outdent } from '@rauschma/helpers/template-tag/outdent-template-tag.js'; 2 | import { createSuite } from '@rauschma/helpers/testing/mocha.js'; 3 | import assert from 'node:assert/strict'; 4 | 5 | // Only dynamically imported modules use the patched `node:fs`! 6 | import { mfs } from '@rauschma/nodejs-tools/testing/install-mem-node-fs.js'; 7 | const { dirToJson, jsonToCleanDir } = await import('@rauschma/nodejs-tools/testing/dir-json.js'); 8 | const { runMarkdownForTests } = await import('../test-tools.js'); 9 | 10 | createSuite(import.meta.url); 11 | 12 | test('Insert single line at a line number (internal LineMod)', () => { 13 | const readme = outdent` 14 | 17 | ▲▲▲js 18 | const err = new Error('Hello!'); 19 | assert.equal(err.stack, 'the-stack-trace'); 20 | ▲▲▲ 21 | `.replaceAll('▲', '`'); 22 | jsonToCleanDir(mfs, { 23 | '/tmp/markcheck-data': {}, 24 | '/tmp/markdown/readme.md': readme, 25 | }); 26 | 27 | assert.ok( 28 | runMarkdownForTests('/tmp/markdown/readme.md', readme).hasSucceeded() 29 | ); 30 | assert.deepEqual( 31 | dirToJson('/tmp/markcheck-data/tmp', { trimEndsOfFiles: true }), 32 | { 33 | 'main.mjs': outdent` 34 | import assert from 'node:assert/strict'; 35 | const err = new Error('Hello!'); 36 | err.stack = beautifyStackTrace(err.stack); 37 | assert.equal(err.stack, 'the-stack-trace'); 38 | `, 39 | } 40 | ); 41 | }); 42 | 43 | test('Insert single line at a line number (applyToBody LineMod)', () => { 44 | const readme = outdent` 45 | 46 | ▲▲▲js 47 | const err = new Error('Hello!'); 48 | assert.equal(err.stack, 'the-stack-trace'); 49 | ▲▲▲ 50 | 51 | 54 | `.replaceAll('▲', '`'); 55 | jsonToCleanDir(mfs, { 56 | '/tmp/markcheck-data': {}, 57 | '/tmp/markdown/readme.md': readme, 58 | }); 59 | 60 | assert.ok( 61 | runMarkdownForTests('/tmp/markdown/readme.md', readme).hasSucceeded() 62 | ); 63 | assert.deepEqual( 64 | dirToJson('/tmp/markcheck-data/tmp', { trimEndsOfFiles: true }), 65 | { 66 | 'main.mjs': outdent` 67 | import assert from 'node:assert/strict'; 68 | const err = new Error('Hello!'); 69 | err.stack = beautifyStackTrace(err.stack); 70 | assert.equal(err.stack, 'the-stack-trace'); 71 | `, 72 | } 73 | ); 74 | }); 75 | 76 | test('Insert multiple lines at line numbers', () => { 77 | const readme = outdent` 78 | 85 | ▲▲▲js 86 | // first 87 | // second 88 | ▲▲▲ 89 | `.replaceAll('▲', '`'); 90 | jsonToCleanDir(mfs, { 91 | '/tmp/markcheck-data': {}, 92 | '/tmp/markdown/readme.md': readme, 93 | }); 94 | 95 | assert.ok( 96 | runMarkdownForTests('/tmp/markdown/readme.md', readme).hasSucceeded() 97 | ); 98 | assert.deepEqual( 99 | dirToJson('/tmp/markcheck-data/tmp', { trimEndsOfFiles: true }), 100 | { 101 | 'main.mjs': outdent` 102 | import assert from 'node:assert/strict'; 103 | // START 104 | // first 105 | // MIDDLE 106 | // second 107 | // END 108 | `, 109 | } 110 | ); 111 | }); 112 | 113 | 114 | test('Insert multiple lines at text fragments', () => { 115 | const readme = outdent` 116 | 123 | ▲▲▲js 124 | // first 125 | // second 126 | ▲▲▲ 127 | `.replaceAll('▲', '`'); 128 | jsonToCleanDir(mfs, { 129 | '/tmp/markcheck-data': {}, 130 | '/tmp/markdown/readme.md': readme, 131 | }); 132 | 133 | assert.ok( 134 | runMarkdownForTests('/tmp/markdown/readme.md', readme).hasSucceeded() 135 | ); 136 | assert.deepEqual( 137 | dirToJson('/tmp/markcheck-data/tmp', { trimEndsOfFiles: true }), 138 | { 139 | 'main.mjs': outdent` 140 | import assert from 'node:assert/strict'; 141 | // START 142 | // first 143 | // MIDDLE 144 | // second 145 | // END 146 | `, 147 | } 148 | ); 149 | }); 150 | -------------------------------------------------------------------------------- /test/assembling-lines/line-mods_test.ts: -------------------------------------------------------------------------------- 1 | import { outdent } from '@rauschma/helpers/template-tag/outdent-template-tag.js'; 2 | import { createSuite } from '@rauschma/helpers/testing/mocha.js'; 3 | import assert from 'node:assert/strict'; 4 | 5 | // Only dynamically imported modules use the patched `node:fs`! 6 | import { mfs } from '@rauschma/nodejs-tools/testing/install-mem-node-fs.js'; 7 | const { dirToJson, jsonToCleanDir } = await import('@rauschma/nodejs-tools/testing/dir-json.js'); 8 | const { MarkcheckMockData } = await import('../../src/entity/snippet.js'); 9 | const { runMarkdownForTests } = await import('../test-tools.js'); 10 | 11 | createSuite(import.meta.url); 12 | 13 | test('Assemble snippet with internal LineMod and applyToOuter', () => { 14 | const readme = outdent` 15 | 20 | 21 | 26 | ▲▲▲js 27 | // Body 28 | ▲▲▲ 29 | 30 | 31 | ▲▲▲js 32 | // Included snippet 33 | ▲▲▲ 34 | 35 | 40 | `.replaceAll('▲', '`'); 41 | jsonToCleanDir(mfs, { 42 | '/tmp/markcheck-data': { 43 | 'markcheck-config.json5': outdent` 44 | { 45 | "lang": { 46 | "": "[skip]", 47 | "js": { 48 | "before": [ 49 | "// Config line BEFORE" 50 | ], 51 | "runFileName": "main.mjs", 52 | "commands": [ 53 | [ "js-command1", "$FILE_NAME" ], 54 | [ "js-command2", "$FILE_NAME" ], 55 | ] 56 | }, 57 | }, 58 | } 59 | `, 60 | }, 61 | '/tmp/markdown/readme.md': readme, 62 | }); 63 | 64 | const mockShellData = new MarkcheckMockData(); 65 | assert.equal( 66 | runMarkdownForTests('/tmp/markdown/readme.md', readme, { markcheckMockData: mockShellData }).getTotalProblemCount(), 67 | 0 68 | ); 69 | // Per file: config lines, language LineMods 70 | // Per snippet: applied line mod, local line mod 71 | assert.deepEqual( 72 | dirToJson('/tmp/markcheck-data/tmp', { trimEndsOfFiles: true }), 73 | { 74 | 'my-module.mjs': outdent` 75 | // Config line BEFORE 76 | // Language LineMod BEFORE 77 | // Outer applied LineMod BEFORE 78 | // Included snippet 79 | // Internal LineMod BEFORE 80 | // Body 81 | // Internal LineMod AFTER 82 | // Outer applied LineMod AFTER 83 | // Language LineMod AFTER 84 | `, 85 | } 86 | ); 87 | assert.deepEqual( 88 | mockShellData.interceptedCommands, 89 | [ 90 | 'js-command1 my-module.mjs', 91 | 'js-command2 my-module.mjs', 92 | ] 93 | ); 94 | }); 95 | 96 | test('Assemble snippet with runLocalLines and applyToOuter: must include applyToOuter lines but not config lines', () => { 97 | const readme = outdent` 98 | 99 | ▲▲▲js 100 | // Body 101 | ▲▲▲ 102 | 103 | 108 | `.replaceAll('▲', '`'); 109 | jsonToCleanDir(mfs, { 110 | '/tmp/markcheck-data': { 111 | 'markcheck-config.json5': outdent` 112 | { 113 | "lang": { 114 | "": "[skip]", 115 | "js": { 116 | "before": [ 117 | "// Config line BEFORE" 118 | ], 119 | "runFileName": "main.mjs", 120 | "commands": [ 121 | [ "js-command1", "$FILE_NAME" ], 122 | [ "js-command2", "$FILE_NAME" ], 123 | ] 124 | }, 125 | }, 126 | } 127 | `, 128 | }, 129 | '/tmp/markdown/readme.md': readme, 130 | }); 131 | 132 | const mockShellData = new MarkcheckMockData(); 133 | assert.equal( 134 | runMarkdownForTests('/tmp/markdown/readme.md', readme, { markcheckMockData: mockShellData }).getTotalProblemCount(), 135 | 0 136 | ); 137 | // Per file: config lines, language LineMods 138 | // Per snippet: applied line mod, local line mod 139 | assert.deepEqual( 140 | dirToJson('/tmp/markcheck-data/tmp', { trimEndsOfFiles: true }), 141 | { 142 | 'main.mjs': outdent` 143 | // Outer applied LineMod BEFORE 144 | // Body 145 | // Outer applied LineMod AFTER 146 | `, 147 | } 148 | ); 149 | assert.deepEqual( 150 | mockShellData.interceptedCommands, 151 | [ 152 | 'js-command1 main.mjs', 153 | 'js-command2 main.mjs', 154 | ] 155 | ); 156 | }); 157 | 158 | test('Assemble snippet with applyToBody LineMod from a snippet', () => { 159 | const readme = outdent` 160 | 165 | 166 | 167 | ▲▲▲js 168 | // Body 169 | ▲▲▲ 170 | 171 | 172 | ▲▲▲js 173 | // Included snippet 174 | ▲▲▲ 175 | 176 | 181 | 186 | `.replaceAll('▲', '`'); 187 | jsonToCleanDir(mfs, { 188 | '/tmp/markcheck-data': { 189 | 'markcheck-config.json5': outdent` 190 | { 191 | "lang": { 192 | "": "[skip]", 193 | "js": { 194 | "before": [ 195 | "// Config line BEFORE" 196 | ], 197 | "runFileName": "main.mjs", 198 | "commands": [ 199 | [ "js-command1", "$FILE_NAME" ], 200 | [ "js-command2", "$FILE_NAME" ], 201 | ] 202 | }, 203 | }, 204 | } 205 | `, 206 | }, 207 | '/tmp/markdown/readme.md': readme, 208 | }); 209 | 210 | const mockShellData = new MarkcheckMockData(); 211 | assert.equal( 212 | runMarkdownForTests('/tmp/markdown/readme.md', readme, { markcheckMockData: mockShellData }).getTotalProblemCount(), 213 | 0 214 | ); 215 | // Per file: config lines, language LineMods 216 | // Per snippet: applied line mod, local line mod 217 | assert.deepEqual( 218 | dirToJson('/tmp/markcheck-data/tmp', { trimEndsOfFiles: true }), 219 | { 220 | 'my-module.mjs': outdent` 221 | // Config line BEFORE 222 | // Language LineMod BEFORE 223 | // Outer applied LineMod BEFORE 224 | // Included snippet 225 | // applyToBody LineMod BEFORE 226 | // Body 227 | // applyToBody LineMod AFTER 228 | // Outer applied LineMod AFTER 229 | // Language LineMod AFTER 230 | `, 231 | } 232 | ); 233 | assert.deepEqual( 234 | mockShellData.interceptedCommands, 235 | [ 236 | 'js-command1 my-module.mjs', 237 | 'js-command2 my-module.mjs', 238 | ] 239 | ); 240 | }); 241 | 242 | test('Assemble snippet with applyToBody LineMod from the config', () => { 243 | const readme = outdent` 244 | 245 | ▲▲▲js 246 | test('Test with await', async () => { 247 | const value = await Promise.resolve(123); 248 | assert.equal(value, 123); 249 | }); 250 | ▲▲▲ 251 | `.replaceAll('▲', '`'); 252 | jsonToCleanDir(mfs, { 253 | '/tmp/markcheck-data': { 254 | 'markcheck-config.json5': outdent` 255 | { 256 | lineMods: { 257 | asyncTest: { 258 | before: [ 259 | 'function test(_name, callback) {', 260 | ' return callback();', 261 | '}', 262 | ], 263 | after: [ 264 | 'await test();', 265 | ], 266 | }, 267 | }, 268 | } 269 | `, 270 | }, 271 | '/tmp/markdown/readme.md': readme, 272 | }); 273 | 274 | const mockShellData = new MarkcheckMockData(); 275 | assert.equal( 276 | runMarkdownForTests('/tmp/markdown/readme.md', readme, { markcheckMockData: mockShellData }).getTotalProblemCount(), 277 | 0 278 | ); 279 | // Per file: config lines, language LineMods 280 | // Per snippet: applied line mod, local line mod 281 | assert.deepEqual( 282 | dirToJson('/tmp/markcheck-data/tmp', { trimEndsOfFiles: true }), 283 | { 284 | 'main.mjs': outdent` 285 | import assert from 'node:assert/strict'; 286 | function test(_name, callback) { 287 | return callback(); 288 | } 289 | test('Test with await', async () => { 290 | const value = await Promise.resolve(123); 291 | assert.equal(value, 123); 292 | }); 293 | await test(); 294 | `, 295 | } 296 | ); 297 | assert.deepEqual( 298 | mockShellData.interceptedCommands, 299 | [ 300 | 'node main.mjs', 301 | ] 302 | ); 303 | }); 304 | 305 | test('"each" plus "include"', () => { 306 | const readme = outdent` 307 | 310 | 311 | 312 | ▲▲▲js 313 | function promiseWithResolvers() {} 314 | ▲▲▲ 315 | 316 | ▲▲▲js 317 | const { promise, resolve, reject } = Promise.withResolvers(); 318 | ▲▲▲ 319 | `.replaceAll('▲', '`'); 320 | jsonToCleanDir(mfs, { 321 | '/tmp/markcheck-data': {}, 322 | '/tmp/markdown/readme.md': readme, 323 | }); 324 | assert.ok( 325 | runMarkdownForTests('/tmp/markdown/readme.md', readme).hasSucceeded() 326 | ); 327 | assert.deepEqual( 328 | dirToJson('/tmp/markcheck-data/tmp', { trimEndsOfFiles: true }), 329 | { 330 | 'main.mjs': outdent` 331 | import assert from 'node:assert/strict'; 332 | function promiseWithResolvers() {} 333 | Promise.withResolvers = promiseWithResolvers; 334 | const { promise, resolve, reject } = Promise.withResolvers(); 335 | `, 336 | } 337 | ); 338 | }); 339 | -------------------------------------------------------------------------------- /test/assembling-lines/node-repl_test.ts: -------------------------------------------------------------------------------- 1 | import { outdent } from '@rauschma/helpers/template-tag/outdent-template-tag.js'; 2 | import { createSuite } from '@rauschma/helpers/testing/mocha.js'; 3 | import assert from 'node:assert/strict'; 4 | 5 | // Only dynamically imported modules use the patched `node:fs`! 6 | import { mfs } from '@rauschma/nodejs-tools/testing/install-mem-node-fs.js'; 7 | const { dirToJson, jsonToCleanDir } = await import('@rauschma/nodejs-tools/testing/dir-json.js'); 8 | const { runMarkdownForTests } = await import('../test-tools.js'); 9 | 10 | createSuite(import.meta.url); 11 | 12 | test('node-repl: normal interaction', () => { 13 | const readme = outdent` 14 | ▲▲▲node-repl 15 | > Error.prototype.name 16 | 'Error' 17 | > RangeError.prototype.name 18 | 'RangeError' 19 | ▲▲▲ 20 | `.replaceAll('▲', '`'); 21 | jsonToCleanDir(mfs, { 22 | '/tmp/markcheck-data': {}, 23 | '/tmp/markdown/readme.md': readme, 24 | }); 25 | 26 | assert.ok( 27 | runMarkdownForTests('/tmp/markdown/readme.md', readme).hasSucceeded() 28 | ); 29 | assert.deepEqual( 30 | dirToJson('/tmp/markcheck-data/tmp', { trimEndsOfFiles: true }), 31 | { 32 | 'main.mjs': outdent` 33 | import assert from 'node:assert/strict'; 34 | assert.deepEqual( 35 | Error.prototype.name 36 | , 37 | 'Error' 38 | ); 39 | assert.deepEqual( 40 | RangeError.prototype.name 41 | , 42 | 'RangeError' 43 | ); 44 | `, 45 | } 46 | ); 47 | }); 48 | 49 | test('node-repl: exceptions', () => { 50 | const readme = outdent` 51 | ▲▲▲node-repl 52 | > structuredClone(() => {}) 53 | DOMException [DataCloneError]: () => {} could not be cloned. 54 | > null.prop 55 | TypeError: Cannot read properties of null (reading 'prop') 56 | ▲▲▲ 57 | `.replaceAll('▲', '`'); 58 | jsonToCleanDir(mfs, { 59 | '/tmp/markcheck-data': {}, 60 | '/tmp/markdown/readme.md': readme, 61 | }); 62 | 63 | assert.ok( 64 | runMarkdownForTests('/tmp/markdown/readme.md', readme).hasSucceeded() 65 | ); 66 | assert.deepEqual( 67 | dirToJson('/tmp/markcheck-data/tmp', { trimEndsOfFiles: true }), 68 | { 69 | 'main.mjs': outdent` 70 | import assert from 'node:assert/strict'; 71 | assert.throws( 72 | () => { 73 | structuredClone(() => {}) 74 | }, 75 | {"name":"DataCloneError","message":"() => {} could not be cloned."} 76 | ); 77 | assert.throws( 78 | () => { 79 | null.prop 80 | }, 81 | {"name":"TypeError","message":"Cannot read properties of null (reading 'prop')"} 82 | ); 83 | `, 84 | } 85 | ); 86 | }); 87 | -------------------------------------------------------------------------------- /test/assembling-lines/search-and-replace_test.ts: -------------------------------------------------------------------------------- 1 | import { outdent } from '@rauschma/helpers/template-tag/outdent-template-tag.js'; 2 | import { createSuite } from '@rauschma/helpers/testing/mocha.js'; 3 | import assert from 'node:assert/strict'; 4 | 5 | // Only dynamically imported modules use the patched `node:fs`! 6 | import { mfs } from '@rauschma/nodejs-tools/testing/install-mem-node-fs.js'; 7 | const { dirToJson, jsonToCleanDir } = await import('@rauschma/nodejs-tools/testing/dir-json.js'); 8 | const { runMarkdownForTests } = await import('../test-tools.js'); 9 | 10 | createSuite(import.meta.url); 11 | 12 | test('searchAndReplace slashes', () => { 13 | const readme = outdent` 14 | 15 | ▲▲▲js 16 | console.log('First'); // (A) 17 | console.log('Second'); // (B) 18 | ▲▲▲ 19 | `.replaceAll('▲', '`'); 20 | jsonToCleanDir(mfs, { 21 | '/tmp/markcheck-data': {}, 22 | '/tmp/markdown/readme.md': readme, 23 | }); 24 | 25 | assert.ok( 26 | runMarkdownForTests('/tmp/markdown/readme.md', readme).hasSucceeded() 27 | ); 28 | assert.deepEqual( 29 | dirToJson('/tmp/markcheck-data/tmp', { trimEndsOfFiles: true }), 30 | { 31 | 'main.mjs': outdent` 32 | import assert from 'node:assert/strict'; 33 | console.log('First'); 34 | console.log('Second'); 35 | `, 36 | } 37 | ); 38 | }); 39 | 40 | test('searchAndReplace double quotes', () => { 41 | const readme = outdent` 42 | 43 | ▲▲▲js 44 | "abc" 45 | ▲▲▲ 46 | `.replaceAll('▲', '`'); 47 | jsonToCleanDir(mfs, { 48 | '/tmp/markcheck-data': {}, 49 | '/tmp/markdown/readme.md': readme, 50 | }); 51 | 52 | assert.ok( 53 | runMarkdownForTests('/tmp/markdown/readme.md', readme).hasSucceeded() 54 | ); 55 | assert.deepEqual( 56 | dirToJson('/tmp/markcheck-data/tmp', { trimEndsOfFiles: true }), 57 | { 58 | 'main.mjs': outdent` 59 | XabcX 60 | `, 61 | } 62 | ); 63 | }); 64 | 65 | test('searchAndReplace not ignoring case', () => { 66 | const readme = outdent` 67 | 68 | ▲▲▲txt 69 | AaBbZz 70 | ▲▲▲ 71 | `.replaceAll('▲', '`'); 72 | jsonToCleanDir(mfs, { 73 | '/tmp/markcheck-data': {}, 74 | '/tmp/markdown/readme.md': readme, 75 | }); 76 | 77 | assert.ok( 78 | runMarkdownForTests('/tmp/markdown/readme.md', readme).hasSucceeded() 79 | ); 80 | assert.deepEqual( 81 | dirToJson('/tmp/markcheck-data/tmp', { trimEndsOfFiles: true }), 82 | { 83 | 'text-file.txt': outdent` 84 | AxBxZz 85 | `, 86 | } 87 | ); 88 | }); 89 | 90 | test('searchAndReplace ignoring case', () => { 91 | const readme = outdent` 92 | 93 | ▲▲▲txt 94 | AaBbZz 95 | ▲▲▲ 96 | `.replaceAll('▲', '`'); 97 | jsonToCleanDir(mfs, { 98 | '/tmp/markcheck-data': {}, 99 | '/tmp/markdown/readme.md': readme, 100 | }); 101 | 102 | assert.ok( 103 | runMarkdownForTests('/tmp/markdown/readme.md', readme).hasSucceeded() 104 | ); 105 | assert.deepEqual( 106 | dirToJson('/tmp/markcheck-data/tmp', { trimEndsOfFiles: true }), 107 | { 108 | 'text-file.txt': outdent` 109 | xxxxZz 110 | `, 111 | } 112 | ); 113 | }); 114 | 115 | test('searchAndReplace via applyToBody LineMod', () => { 116 | const readme = outdent` 117 | 118 | ▲▲▲txt 119 | ABC 120 | ▲▲▲ 121 | 122 | 123 | `.replaceAll('▲', '`'); 124 | jsonToCleanDir(mfs, { 125 | '/tmp/markcheck-data': {}, 126 | '/tmp/markdown/readme.md': readme, 127 | }); 128 | 129 | assert.ok( 130 | runMarkdownForTests('/tmp/markdown/readme.md', readme).hasSucceeded() 131 | ); 132 | assert.deepEqual( 133 | dirToJson('/tmp/markcheck-data/tmp', { trimEndsOfFiles: true }), 134 | { 135 | 'text-file.txt': outdent` 136 | AXC 137 | `, 138 | } 139 | ); 140 | }); 141 | -------------------------------------------------------------------------------- /test/assembling-lines/sequence_test.ts: -------------------------------------------------------------------------------- 1 | import { outdent } from '@rauschma/helpers/template-tag/outdent-template-tag.js'; 2 | import { createSuite } from '@rauschma/helpers/testing/mocha.js'; 3 | import assert from 'node:assert/strict'; 4 | 5 | // Only dynamically imported modules use the patched `node:fs`! 6 | import { mfs } from '@rauschma/nodejs-tools/testing/install-mem-node-fs.js'; 7 | const { dirToJson, jsonToCleanDir } = await import('@rauschma/nodejs-tools/testing/dir-json.js'); 8 | const { runMarkdownForTests } = await import('../test-tools.js'); 9 | 10 | createSuite(import.meta.url); 11 | 12 | test('Assemble sequence', () => { 13 | const readme = outdent` 14 | 15 | ▲▲▲js 16 | // Part 1 17 | ▲▲▲ 18 | 19 | 20 | ▲▲▲js 21 | // Part 2 22 | ▲▲▲ 23 | 24 | 25 | ▲▲▲js 26 | // Part 3 27 | ▲▲▲ 28 | `.replaceAll('▲', '`'); 29 | jsonToCleanDir(mfs, { 30 | '/tmp/markcheck-data': {}, 31 | '/tmp/markdown/readme.md': readme, 32 | }); 33 | 34 | assert.ok( 35 | runMarkdownForTests('/tmp/markdown/readme.md', readme).hasSucceeded() 36 | ); 37 | assert.deepEqual( 38 | dirToJson('/tmp/markcheck-data/tmp', { trimEndsOfFiles: true }), 39 | { 40 | 'main.mjs': outdent` 41 | import assert from 'node:assert/strict'; 42 | // Part 1 43 | // Part 2 44 | // Part 3 45 | `, 46 | } 47 | ); 48 | }); 49 | -------------------------------------------------------------------------------- /test/checks/contained-in-file_test.ts: -------------------------------------------------------------------------------- 1 | import { outdent } from '@rauschma/helpers/template-tag/outdent-template-tag.js'; 2 | import { createSuite } from '@rauschma/helpers/testing/mocha.js'; 3 | import assert from 'node:assert/strict'; 4 | 5 | // Only dynamically imported modules use the patched `node:fs`! 6 | import { mfs } from '@rauschma/nodejs-tools/testing/install-mem-node-fs.js'; 7 | const { dirToJson, jsonToCleanDir } = await import('@rauschma/nodejs-tools/testing/dir-json.js'); 8 | const { MarkcheckMockData } = await import('../../src/entity/snippet.js'); 9 | const { runMarkdownForTests } = await import('../test-tools.js'); 10 | 11 | createSuite(import.meta.url); 12 | 13 | test('containedInFile: success', () => { 14 | const readme = outdent` 15 | 16 | ▲▲▲js 17 | // green 18 | // blue 19 | ▲▲▲ 20 | `.replaceAll('▲', '`'); 21 | jsonToCleanDir(mfs, { 22 | '/tmp/markcheck-data': {}, 23 | '/tmp/markdown/readme.md': readme, 24 | '/tmp/markdown/other.js': outdent` 25 | // red 26 | // green 27 | // blue 28 | `, 29 | }); 30 | 31 | assert.ok( 32 | runMarkdownForTests('/tmp/markdown/readme.md', readme).hasSucceeded() 33 | ); 34 | assert.deepEqual( 35 | dirToJson('/tmp/markcheck-data/tmp', { trimEndsOfFiles: true }), 36 | {} 37 | ); 38 | }); 39 | 40 | test('containedInFile: failure', () => { 41 | const readme = outdent` 42 | 43 | ▲▲▲js 44 | // black 45 | ▲▲▲ 46 | `.replaceAll('▲', '`'); 47 | jsonToCleanDir(mfs, { 48 | '/tmp/markcheck-data': {}, 49 | '/tmp/markdown/readme.md': readme, 50 | '/tmp/markdown/other.js': outdent` 51 | // red 52 | // green 53 | // blue 54 | `, 55 | }); 56 | 57 | const markcheckMockData = new MarkcheckMockData().withPassOnUserExceptions(false); 58 | assert.deepEqual( 59 | runMarkdownForTests('/tmp/markdown/readme.md', readme, {markcheckMockData}).toJson(), 60 | { 61 | relFilePath: '/tmp/markdown/readme.md', 62 | testSuccesses: 0, 63 | testFailures: 1, 64 | syntaxErrors: 0, 65 | warnings: 0, 66 | } 67 | ); 68 | }); 69 | -------------------------------------------------------------------------------- /test/checks/duplicate-id_test.ts: -------------------------------------------------------------------------------- 1 | import { outdent } from '@rauschma/helpers/template-tag/outdent-template-tag.js'; 2 | import { createSuite } from '@rauschma/helpers/testing/mocha.js'; 3 | import assert from 'node:assert/strict'; 4 | 5 | // Only dynamically imported modules use the patched `node:fs`! 6 | import { mfs } from '@rauschma/nodejs-tools/testing/install-mem-node-fs.js'; 7 | const { jsonToCleanDir } = await import('@rauschma/nodejs-tools/testing/dir-json.js'); 8 | const { EntityContextLineNumber, MarkcheckSyntaxError } = await import('../../src/util/errors.js'); 9 | const { runMarkdownForTests } = await import('../test-tools.js'); 10 | 11 | createSuite(import.meta.url); 12 | 13 | test('Duplicate `id`', () => { 14 | const readme = outdent` 15 | 16 | ▲▲▲js 17 | ▲▲▲ 18 | 19 | 20 | ▲▲▲js 21 | ▲▲▲ 22 | `.replaceAll('▲', '`'); 23 | jsonToCleanDir(mfs, { 24 | '/tmp/markcheck-data': {}, 25 | '/tmp/markdown/readme.md': readme, 26 | }); 27 | 28 | assert.throws( 29 | () => runMarkdownForTests('/tmp/markdown/readme.md', readme), 30 | { 31 | name: 'MarkcheckSyntaxError', 32 | message: `Duplicate "id": "used-twice" (other usage is L1 (js))`, 33 | context: new EntityContextLineNumber(5), 34 | } 35 | ); 36 | }); 37 | 38 | test('Duplicate `lineModId`', () => { 39 | const readme = outdent` 40 | 41 | 42 | `; 43 | jsonToCleanDir(mfs, { 44 | '/tmp/markcheck-data': {}, 45 | '/tmp/markdown/readme.md': readme, 46 | }); 47 | 48 | assert.throws( 49 | () => runMarkdownForTests('/tmp/markdown/readme.md', readme), 50 | { 51 | name: 'MarkcheckSyntaxError', 52 | message: 'Duplicate "lineModId": "used-twice" (other usage is L1)', 53 | context: new EntityContextLineNumber(2), 54 | } 55 | ); 56 | }); 57 | -------------------------------------------------------------------------------- /test/checks/exit-status_test.ts: -------------------------------------------------------------------------------- 1 | import { outdent } from '@rauschma/helpers/template-tag/outdent-template-tag.js'; 2 | import { createSuite } from '@rauschma/helpers/testing/mocha.js'; 3 | import assert from 'node:assert/strict'; 4 | 5 | // Only dynamically imported modules use the patched `node:fs`! 6 | import { mfs } from '@rauschma/nodejs-tools/testing/install-mem-node-fs.js'; 7 | const { jsonToCleanDir } = await import('@rauschma/nodejs-tools/testing/dir-json.js'); 8 | const { MarkcheckMockData } = await import('../../src/entity/snippet.js'); 9 | const { runMarkdownForTests } = await import('../test-tools.js'); 10 | 11 | createSuite(import.meta.url); 12 | 13 | test('exitStatus: expecting 0 fails if actual is 1', () => { 14 | const readme = outdent` 15 | 16 | ▲▲▲js 17 | process.exit(1); 18 | ▲▲▲ 19 | `.replaceAll('▲', '`'); 20 | jsonToCleanDir(mfs, { 21 | '/tmp/markcheck-data': {}, 22 | '/tmp/markdown/readme.md': readme, 23 | }); 24 | 25 | // We don’t actually run the code, we only state what its output would be 26 | // – if it were to run! 27 | const mockShellData = new MarkcheckMockData({ 28 | passOnUserExceptions: false, 29 | lastCommandResult: { 30 | stdout: '', 31 | stderr: '', 32 | status: 1, 33 | signal: null, 34 | }, 35 | }); 36 | assert.equal( 37 | runMarkdownForTests('/tmp/markdown/readme.md', readme, { markcheckMockData: mockShellData }).testFailures, 38 | 1 39 | ); 40 | }); 41 | 42 | test('exitStatus: expecting "nonzero" succeeds if actual is 1', () => { 43 | const readme = outdent` 44 | 45 | ▲▲▲js 46 | process.exit(1); 47 | ▲▲▲ 48 | `.replaceAll('▲', '`'); 49 | jsonToCleanDir(mfs, { 50 | '/tmp/markcheck-data': {}, 51 | '/tmp/markdown/readme.md': readme, 52 | }); 53 | 54 | // We don’t actually run the code, we only state what its output would be 55 | // – if it were to run! 56 | const mockShellData = new MarkcheckMockData({ 57 | lastCommandResult: { 58 | stdout: '', 59 | stderr: '', 60 | status: 1, 61 | signal: null, 62 | }, 63 | }); 64 | assert.equal( 65 | runMarkdownForTests('/tmp/markdown/readme.md', readme, { markcheckMockData: mockShellData }).testFailures, 66 | 0 67 | ); 68 | }); 69 | -------------------------------------------------------------------------------- /test/checks/stdout_test.ts: -------------------------------------------------------------------------------- 1 | import { outdent } from '@rauschma/helpers/template-tag/outdent-template-tag.js'; 2 | import { createSuite } from '@rauschma/helpers/testing/mocha.js'; 3 | import assert from 'node:assert/strict'; 4 | 5 | // Only dynamically imported modules use the patched `node:fs`! 6 | import { mfs } from '@rauschma/nodejs-tools/testing/install-mem-node-fs.js'; 7 | const { jsonToCleanDir } = await import('@rauschma/nodejs-tools/testing/dir-json.js'); 8 | const { MarkcheckMockData } = await import('../../src/entity/snippet.js'); 9 | const { runMarkdownForTests } = await import('../test-tools.js'); 10 | 11 | createSuite(import.meta.url); 12 | 13 | test('stdout: success', () => { 14 | const readme = outdent` 15 | 16 | ▲▲▲js 17 | console.log('red'); 18 | console.log('green'); 19 | console.log('blue'); 20 | ▲▲▲ 21 | 22 | 23 | ▲▲▲ 24 | 🟢 25 | ▲▲▲ 26 | 27 | 28 | `.replaceAll('▲', '`'); 29 | jsonToCleanDir(mfs, { 30 | '/tmp/markcheck-data': {}, 31 | '/tmp/markdown/readme.md': readme, 32 | }); 33 | 34 | // We don’t actually run the code, we only state what its output would be 35 | // – if it were to run! 36 | const mockShellData = new MarkcheckMockData({ 37 | lastCommandResult: { 38 | stdout: 'red\ngreen\nblue', 39 | stderr: '', 40 | status: 0, 41 | signal: null, 42 | }, 43 | }); 44 | assert.ok( 45 | runMarkdownForTests('/tmp/markdown/readme.md', readme, { markcheckMockData: mockShellData }).hasSucceeded() 46 | ); 47 | }); 48 | 49 | test('stdout: failure', () => { 50 | const readme = outdent` 51 | 52 | ▲▲▲js 53 | console.log('red'); 54 | console.log('green'); 55 | console.log('blue'); 56 | ▲▲▲ 57 | 58 | 59 | ▲▲▲ 60 | black 61 | ▲▲▲ 62 | 63 | 64 | `.replaceAll('▲', '`'); 65 | jsonToCleanDir(mfs, { 66 | '/tmp/markcheck-data': {}, 67 | '/tmp/markdown/readme.md': readme, 68 | }); 69 | 70 | const mockShellData = new MarkcheckMockData({ 71 | passOnUserExceptions: false, 72 | lastCommandResult: { 73 | stdout: 'red\ngreen\nblue', 74 | stderr: '', 75 | status: 0, 76 | signal: null, 77 | }, 78 | }); 79 | assert.deepEqual( 80 | runMarkdownForTests('/tmp/markdown/readme.md', readme, { markcheckMockData: mockShellData }).toJson(), 81 | { 82 | relFilePath: '/tmp/markdown/readme.md', 83 | testFailures: 1, 84 | testSuccesses: 0, 85 | syntaxErrors: 0, 86 | warnings: 0, 87 | } 88 | ); 89 | }); 90 | -------------------------------------------------------------------------------- /test/misc/config-mod_test.ts: -------------------------------------------------------------------------------- 1 | import { outdent } from '@rauschma/helpers/template-tag/outdent-template-tag.js'; 2 | import { createSuite } from '@rauschma/helpers/testing/mocha.js'; 3 | import assert from 'node:assert/strict'; 4 | 5 | // Only dynamically imported modules use the patched `node:fs`! 6 | import { mfs } from '@rauschma/nodejs-tools/testing/install-mem-node-fs.js'; 7 | const { dirToJson, jsonToCleanDir } = await import('@rauschma/nodejs-tools/testing/dir-json.js'); 8 | const { MarkcheckMockData } = await import('../../src/entity/snippet.js'); 9 | const { runMarkdownForTests } = await import('../test-tools.js'); 10 | 11 | createSuite(import.meta.url); 12 | 13 | test('ConfigMod: success', () => { 14 | const readme = outdent` 15 | 24 | `; 25 | jsonToCleanDir(mfs, { 26 | '/tmp/markcheck-data': {}, 27 | '/tmp/markdown/readme.md': readme, 28 | }); 29 | 30 | assert.ok( 31 | runMarkdownForTests('/tmp/markdown/readme.md', readme).hasSucceeded() 32 | ); 33 | assert.deepEqual( 34 | dirToJson('/tmp/markcheck-data/tmp', { trimEndsOfFiles: true }), 35 | { 36 | } 37 | ); 38 | }); 39 | 40 | test('ConfigMod: unknown properties', () => { 41 | const readme = outdent` 42 | 47 | `; 48 | jsonToCleanDir(mfs, { 49 | '/tmp/markcheck-data': {}, 50 | '/tmp/markdown/readme.md': readme, 51 | }); 52 | 53 | const markcheckMockData = new MarkcheckMockData({ 54 | passOnUserExceptions: false, 55 | }); 56 | assert.throws( 57 | () => runMarkdownForTests('/tmp/markdown/readme.md', readme, {markcheckMockData}), 58 | { 59 | name: 'MarkcheckSyntaxError', 60 | message: /^Config properties are wrong:/, 61 | } 62 | ); 63 | }); 64 | 65 | test('ConfigMod: malformed JSON5', () => { 66 | const readme = outdent` 67 | 70 | `; 71 | jsonToCleanDir(mfs, { 72 | '/tmp/markcheck-data': {}, 73 | '/tmp/markdown/readme.md': readme, 74 | }); 75 | 76 | const markcheckMockData = new MarkcheckMockData({ 77 | passOnUserExceptions: false, 78 | }); 79 | assert.throws( 80 | () => runMarkdownForTests('/tmp/markdown/readme.md', readme, {markcheckMockData}), 81 | { 82 | name: 'MarkcheckSyntaxError', 83 | message: /^Error while parsing JSON5 config data:/, 84 | } 85 | ); 86 | }); 87 | -------------------------------------------------------------------------------- /test/misc/indented-directive_test.ts: -------------------------------------------------------------------------------- 1 | import { outdent } from '@rauschma/helpers/template-tag/outdent-template-tag.js'; 2 | import { createSuite } from '@rauschma/helpers/testing/mocha.js'; 3 | import assert from 'node:assert/strict'; 4 | 5 | // Only dynamically imported modules use the patched `node:fs`! 6 | import { mfs } from '@rauschma/nodejs-tools/testing/install-mem-node-fs.js'; 7 | const { dirToJson, jsonToCleanDir } = await import('@rauschma/nodejs-tools/testing/dir-json.js'); 8 | const { runMarkdownForTests } = await import('../test-tools.js'); 9 | 10 | createSuite(import.meta.url); 11 | 12 | test('Indented directive', () => { 13 | const readme = outdent` 14 | 1. Positional parameters: 15 | 16 | 19 | ▲▲▲js 20 | selectEntries(3, 20, 2) 21 | ▲▲▲ 22 | `.replaceAll('▲', '`'); 23 | jsonToCleanDir(mfs, { 24 | '/tmp/markcheck-data': {}, 25 | '/tmp/markdown/readme.md': readme, 26 | }); 27 | 28 | assert.ok( 29 | runMarkdownForTests('/tmp/markdown/readme.md', readme).hasSucceeded() 30 | ); 31 | assert.deepEqual( 32 | dirToJson('/tmp/markcheck-data/tmp', { trimEndsOfFiles: true }), 33 | { 34 | 'main.mjs': outdent` 35 | import assert from 'node:assert/strict'; 36 | const selectEntries = () => {}; 37 | selectEntries(3, 20, 2) 38 | `, 39 | } 40 | ); 41 | }); 42 | -------------------------------------------------------------------------------- /test/misc/subdirectory_test.ts: -------------------------------------------------------------------------------- 1 | import { outdent } from '@rauschma/helpers/template-tag/outdent-template-tag.js'; 2 | import { createSuite } from '@rauschma/helpers/testing/mocha.js'; 3 | import assert from 'node:assert/strict'; 4 | 5 | // Only dynamically imported modules use the patched `node:fs`! 6 | import { mfs } from '@rauschma/nodejs-tools/testing/install-mem-node-fs.js'; 7 | const { dirToJson, jsonToCleanDir } = await import('@rauschma/nodejs-tools/testing/dir-json.js'); 8 | const { runMarkdownForTests } = await import('../test-tools.js'); 9 | 10 | createSuite(import.meta.url); 11 | 12 | test('Subdirectory: writeLocalLines', () => { 13 | const readme = outdent` 14 | 17 | `; 18 | jsonToCleanDir(mfs, { 19 | '/tmp/markcheck-data': {}, 20 | '/tmp/markdown/readme.md': readme, 21 | }); 22 | 23 | assert.ok( 24 | runMarkdownForTests('/tmp/markdown/readme.md', readme).hasSucceeded() 25 | ); 26 | assert.deepEqual( 27 | dirToJson('/tmp/markcheck-data/tmp', { trimEndsOfFiles: true }), 28 | { 29 | 'dir': { 30 | 'code.js': outdent` 31 | // JavaScript code 32 | `, 33 | }, 34 | } 35 | ); 36 | }); 37 | 38 | test('Subdirectory: write', () => { 39 | const readme = outdent` 40 | 43 | `; 44 | jsonToCleanDir(mfs, { 45 | '/tmp/markcheck-data': {}, 46 | '/tmp/markdown/readme.md': readme, 47 | }); 48 | 49 | assert.ok( 50 | runMarkdownForTests('/tmp/markdown/readme.md', readme).hasSucceeded() 51 | ); 52 | assert.deepEqual( 53 | dirToJson('/tmp/markcheck-data/tmp', { trimEndsOfFiles: true }), 54 | { 55 | 'dir': { 56 | 'code.js': outdent` 57 | import assert from 'node:assert/strict'; 58 | // JavaScript code 59 | `, 60 | }, 61 | } 62 | ); 63 | }); 64 | 65 | test('Subdirectory: runFileName, external', () => { 66 | const readme = outdent` 67 | 68 | ▲▲▲js 69 | import {func} from './other.js'; 70 | ▲▲▲ 71 | 72 | 73 | ▲▲▲js 74 | export func() {} 75 | ▲▲▲ 76 | `.replaceAll('▲', '`'); 77 | jsonToCleanDir(mfs, { 78 | '/tmp/markcheck-data': {}, 79 | '/tmp/markdown/readme.md': readme, 80 | }); 81 | 82 | assert.ok( 83 | runMarkdownForTests('/tmp/markdown/readme.md', readme).hasSucceeded() 84 | ); 85 | assert.deepEqual( 86 | dirToJson('/tmp/markcheck-data/tmp', { trimEndsOfFiles: true }), 87 | { 88 | 'dir': { 89 | 'my-code.js': outdent` 90 | import assert from 'node:assert/strict'; 91 | import {func} from './other.js'; 92 | `, 93 | 'other.js': outdent` 94 | import assert from 'node:assert/strict'; 95 | export func() {} 96 | `, 97 | }, 98 | } 99 | ); 100 | }); 101 | 102 | test('Subdirectory: runFileName, externalLocalLines', () => { 103 | const readme = outdent` 104 | 105 | ▲▲▲js 106 | import {func} from './other.js'; 107 | ▲▲▲ 108 | 109 | 110 | ▲▲▲js 111 | export func() {} 112 | ▲▲▲ 113 | `.replaceAll('▲', '`'); 114 | jsonToCleanDir(mfs, { 115 | '/tmp/markcheck-data': {}, 116 | '/tmp/markdown/readme.md': readme, 117 | }); 118 | 119 | assert.ok( 120 | runMarkdownForTests('/tmp/markdown/readme.md', readme).hasSucceeded() 121 | ); 122 | assert.deepEqual( 123 | dirToJson('/tmp/markcheck-data/tmp', { trimEndsOfFiles: true }), 124 | { 125 | 'dir': { 126 | 'my-code.js': outdent` 127 | import {func} from './other.js'; 128 | `, 129 | 'other.js': outdent` 130 | export func() {} 131 | `, 132 | }, 133 | } 134 | ); 135 | }); 136 | -------------------------------------------------------------------------------- /test/misc/write_test.ts: -------------------------------------------------------------------------------- 1 | import { outdent } from '@rauschma/helpers/template-tag/outdent-template-tag.js'; 2 | import { createSuite } from '@rauschma/helpers/testing/mocha.js'; 3 | import assert from 'node:assert/strict'; 4 | 5 | // Only dynamically imported modules use the patched `node:fs`! 6 | import { mfs } from '@rauschma/nodejs-tools/testing/install-mem-node-fs.js'; 7 | const { dirToJson, jsonToCleanDir } = await import('@rauschma/nodejs-tools/testing/dir-json.js'); 8 | const { runMarkdownForTests } = await import('../test-tools.js'); 9 | 10 | createSuite(import.meta.url); 11 | 12 | test('writeLocalLines', () => { 13 | const readme = outdent` 14 | 17 | `.replaceAll('▲', '`'); 18 | jsonToCleanDir(mfs, { 19 | '/tmp/markcheck-data': {}, 20 | '/tmp/markdown/readme.md': readme, 21 | }); 22 | 23 | assert.ok( 24 | runMarkdownForTests('/tmp/markdown/readme.md', readme).hasSucceeded() 25 | ); 26 | assert.deepEqual( 27 | dirToJson('/tmp/markcheck-data/tmp', { trimEndsOfFiles: true }), 28 | { 29 | 'code.js': outdent` 30 | // JavaScript code 31 | `, 32 | } 33 | ); 34 | }); 35 | 36 | test('write', () => { 37 | const readme = outdent` 38 | 41 | `.replaceAll('▲', '`'); 42 | jsonToCleanDir(mfs, { 43 | '/tmp/markcheck-data': {}, 44 | '/tmp/markdown/readme.md': readme, 45 | }); 46 | 47 | assert.ok( 48 | runMarkdownForTests('/tmp/markdown/readme.md', readme).hasSucceeded() 49 | ); 50 | assert.deepEqual( 51 | dirToJson('/tmp/markcheck-data/tmp', { trimEndsOfFiles: true }), 52 | { 53 | 'code.js': outdent` 54 | import assert from 'node:assert/strict'; 55 | // JavaScript code 56 | `, 57 | } 58 | ); 59 | }); -------------------------------------------------------------------------------- /test/test-tools.ts: -------------------------------------------------------------------------------- 1 | import { parseMarkdown } from '../src/core/parse-markdown.js'; 2 | import { runParsedMarkdown } from '../src/core/run-entities.js'; 3 | import { LogLevel, MarkcheckMockData, StatusCounts } from '../src/entity/snippet.js'; 4 | import { Output } from '../src/util/errors.js'; 5 | 6 | export type RunOptions = { 7 | markcheckMockData?: MarkcheckMockData, 8 | out?: Output, 9 | }; 10 | 11 | export function runMarkdownForTests(absFilePath: string, md: string, opts: RunOptions = {}): StatusCounts { 12 | const markcheckMockData = opts.markcheckMockData ?? new MarkcheckMockData(); 13 | const out = opts.out ?? Output.ignore(); 14 | const statusCounts = new StatusCounts(absFilePath); 15 | const parsedMarkdown = parseMarkdown(md); 16 | runParsedMarkdown(out, absFilePath, LogLevel.Normal, parsedMarkdown, statusCounts, markcheckMockData); 17 | return statusCounts; 18 | } 19 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["src/**/*", "test/**/*"], 3 | "compilerOptions": { 4 | // Compilation output: dist/src and dist/test 5 | "rootDir": ".", 6 | "outDir": "dist", 7 | "target": "ES2023", 8 | "lib": [ 9 | "ES2023", "DOM" 10 | ], 11 | "module": "NodeNext", 12 | "moduleResolution": "NodeNext", 13 | "strict": true, 14 | "noImplicitReturns": true, 15 | "noImplicitOverride": true, 16 | "noFallthroughCasesInSwitch": true, 17 | // 18 | "sourceMap": true, 19 | 20 | // Ensure TS code can by compiled by esbuild 21 | // https://esbuild.github.io/content-types/#isolated-modules 22 | "isolatedModules": true, 23 | // Enforce `type` modifier for TypeScript-only imports 24 | // https://www.typescriptlang.org/tsconfig#verbatimModuleSyntax 25 | "verbatimModuleSyntax": true, 26 | 27 | // Importing JSON is generally useful 28 | "resolveJsonModule": true, 29 | } 30 | } 31 | --------------------------------------------------------------------------------