From 014d4e61f662d308e434afe1fce513fda93e2d67 Mon Sep 17 00:00:00 2001 From: Tony Brix Date: Fri, 10 Nov 2023 22:49:27 -0700 Subject: [PATCH] fix: run spec tests with testutils (#3017) BREAKING CHANGE: drop support for node v16 --- .eslintignore | 1 + .github/workflows/codeql-analysis.yml | 78 -- .github/workflows/tests.yml | 37 +- .gitignore | 1 + .vscode/settings.json | 9 - package-lock.json | 522 +--------- package.json | 13 +- test/.eslintrc.json | 3 +- test/bench.js | 59 +- test/helpers/helpers.js | 56 - test/helpers/html-differ.js | 38 - test/helpers/load.js | 93 -- test/run-spec-tests.js | 50 + test/specs/gfm/gfm.0.29.json | 55 +- test/specs/run-spec.js | 52 - test/unit/{Hooks-spec.js => Hooks.test.js} | 43 +- test/unit/{Lexer-spec.js => Lexer.test.js} | 588 ++++++++--- test/unit/{Parser-spec.js => Parser.test.js} | 258 ++--- test/unit/{bin-spec.js => bin.test.js} | 28 +- .../{instance-spec.js => instance.test.js} | 24 +- test/unit/marked-spec.js | 953 ----------------- test/unit/marked.test.js | 963 ++++++++++++++++++ test/update-specs.js | 37 +- 23 files changed, 1820 insertions(+), 2141 deletions(-) delete mode 100644 .github/workflows/codeql-analysis.yml delete mode 100644 .vscode/settings.json delete mode 100644 test/helpers/helpers.js delete mode 100644 test/helpers/html-differ.js delete mode 100644 test/helpers/load.js create mode 100644 test/run-spec-tests.js delete mode 100644 test/specs/run-spec.js rename test/unit/{Hooks-spec.js => Hooks.test.js} (61%) rename test/unit/{Lexer-spec.js => Lexer.test.js} (68%) rename test/unit/{Parser-spec.js => Parser.test.js} (62%) rename test/unit/{bin-spec.js => bin.test.js} (68%) rename test/unit/{instance-spec.js => instance.test.js} (66%) delete mode 100644 test/unit/marked-spec.js create mode 100644 test/unit/marked.test.js diff --git a/.eslintignore b/.eslintignore index f09ec93e09..0bba8b612d 100644 --- a/.eslintignore +++ b/.eslintignore @@ -1,2 +1,3 @@ lib *.min.js +public diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml deleted file mode 100644 index 5cf305a804..0000000000 --- a/.github/workflows/codeql-analysis.yml +++ /dev/null @@ -1,78 +0,0 @@ -# For most projects, this workflow file will not need changing; you simply need -# to commit it to your repository. -# -# You may wish to alter this file to override the set of languages analyzed, -# or to provide custom queries or build logic. -name: "CodeQL" - -on: - push: - branches: [master] - pull_request: - # The branches below must be a subset of the branches above - branches: [master] - schedule: - - cron: '0 9 * * 3' - -permissions: - contents: read - -jobs: - analyze: - permissions: - actions: read # for github/codeql-action/init to get workflow details - contents: read # for actions/checkout to fetch code - security-events: write # for github/codeql-action/autobuild to send a status report - name: Analyze - runs-on: ubuntu-latest - - strategy: - fail-fast: false - matrix: - # Override automatic language detection by changing the below list - # Supported options are ['csharp', 'cpp', 'go', 'java', 'javascript', 'python'] - language: ['javascript'] - # Learn more... - # https://docs.github.com/en/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#overriding-automatic-language-detection - - steps: - - name: Checkout repository - uses: actions/checkout@v4 - with: - # We must fetch at least the immediate parents so that if this is - # a pull request then we can checkout the head. - fetch-depth: 2 - - # If this run was triggered by a pull request event, then checkout - # the head of the pull request instead of the merge commit. - - run: git checkout HEAD^2 - if: ${{ github.event_name == 'pull_request' }} - - # Initializes the CodeQL tools for scanning. - - name: Initialize CodeQL - uses: github/codeql-action/init@v2 - with: - languages: ${{ matrix.language }} - # If you wish to specify custom queries, you can do so here or in a config file. - # By default, queries listed here will override any specified in a config file. - # Prefix the list here with "+" to use these queries and those in the config file. - # queries: ./path/to/local/query, your-org/your-repo/queries@main - - # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). - # If this step fails, then you should remove it and run the build manually (see below) - - name: Autobuild - uses: github/codeql-action/autobuild@v2 - - # ℹī¸ Command-line programs to run using the OS shell. - # 📚 https://git.io/JvXDl - - # ✏ī¸ If the Autobuild fails above, remove it and uncomment the following three lines - # and modify them (or add more) to build your code if your project - # uses a compiled language - - #- run: | - # make bootstrap - # make release - - - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v2 diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 45313ef65a..ee5fa10bf0 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -9,11 +9,11 @@ permissions: contents: read jobs: - Test: + UnitTests: strategy: matrix: # lowest verison here should also be in `engines` field - node_version: [16, 18, 'lts/*', '*'] + node_version: [18, "lts/*", "*"] runs-on: ubuntu-latest steps: - name: Checkout Code @@ -25,31 +25,14 @@ jobs: check-latest: true - name: Install Dependencies run: npm ci + - name: Build 🗜ī¸ + run: npm run build - name: Run Unit Tests 👩đŸŊ‍đŸ’ģ run: npm run test:unit - name: Run Spec Tests 👩đŸŊ‍đŸ’ģ run: npm run test:specs - - name: Run UMD Tests 👩đŸŊ‍đŸ’ģ - run: npm run test:umd - - name: Run Types Tests 👩đŸŊ‍đŸ’ģ - run: npm run test:types - - Lint: - runs-on: ubuntu-latest - steps: - - name: Checkout Code - uses: actions/checkout@v4 - - name: Install Node - uses: actions/setup-node@v4 - with: - node-version: 'lts/*' - - name: Install Dependencies - run: npm ci - - name: Lint ✨ - run: npm run test:lint - Build: - name: Build and Test Types + OtherTests: runs-on: ubuntu-latest steps: - name: Checkout Code @@ -57,18 +40,22 @@ jobs: - name: Install Node uses: actions/setup-node@v4 with: - node-version: 'lts/*' + node-version: "lts/*" - name: Install Dependencies run: npm ci - name: Build 🗜ī¸ run: npm run build + - name: Run UMD Tests 👩đŸŊ‍đŸ’ģ + run: npm run test:umd - name: Run Types Tests 👩đŸŊ‍đŸ’ģ run: npm run test:types + - name: Lint ✨ + run: npm run test:lint Release: permissions: contents: write - needs: [Test, Lint, Build] + needs: [UnitTests, OtherTests] if: | github.ref == 'refs/heads/master' && github.event.repository.fork == false @@ -79,7 +66,7 @@ jobs: - name: Install Node uses: actions/setup-node@v4 with: - node-version: 'lts/*' + node-version: "lts/*" - name: Install Dependencies run: npm ci - name: Build 🗜ī¸ diff --git a/.gitignore b/.gitignore index 233666f3ca..6dedd8e232 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ .DS_Store .vercel +.vscode node_modules/ test/compiled_tests public diff --git a/.vscode/settings.json b/.vscode/settings.json deleted file mode 100644 index 46453cb8c5..0000000000 --- a/.vscode/settings.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "cSpell.words": [ - "commonmark", - "markedjs", - "titleize", - "uglifyjs", - "vuln" - ] -} diff --git a/package-lock.json b/package-lock.json index 020f576c5b..5f5f82a35c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,7 +13,7 @@ }, "devDependencies": { "@arethetypeswrong/cli": "^0.13.1", - "@markedjs/html-differ": "^4.0.2", + "@markedjs/testutils": "9.1.5-0", "@rollup/plugin-terser": "^0.4.4", "@rollup/plugin-typescript": "^11.1.5", "@semantic-release/commit-analyzer": "^11.1.0", @@ -32,9 +32,7 @@ "eslint-plugin-import": "^2.29.0", "eslint-plugin-n": "^16.2.0", "eslint-plugin-promise": "^6.1.1", - "front-matter": "^4.0.2", "highlight.js": "^11.9.0", - "jasmine": "^5.1.0", "markdown-it": "13.0.2", "marked-highlight": "^2.0.6", "marked-man": "^2.0.0", @@ -44,11 +42,10 @@ "semantic-release": "^22.0.7", "titleize": "^4.0.0", "ts-expect": "^1.3.0", - "ts-node": "^10.9.1", "typescript": "5.2.2" }, "engines": { - "node": ">= 16" + "node": ">= 18" } }, "node_modules/@aashutoshrathi/word-wrap": { @@ -258,28 +255,6 @@ "node": ">=0.1.90" } }, - "node_modules/@cspotcode/source-map-support": { - "version": "0.8.1", - "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", - "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", - "dev": true, - "dependencies": { - "@jridgewell/trace-mapping": "0.3.9" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/@cspotcode/source-map-support/node_modules/@jridgewell/trace-mapping": { - "version": "0.3.9", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", - "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", - "dev": true, - "dependencies": { - "@jridgewell/resolve-uri": "^3.0.3", - "@jridgewell/sourcemap-codec": "^1.4.10" - } - }, "node_modules/@eslint-community/eslint-utils": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.0.tgz", @@ -369,102 +344,6 @@ "integrity": "sha512-dvuCeX5fC9dXgJn9t+X5atfmgQAzUOWqS1254Gh0m6i8wKd10ebXkfNKiRK+1GWi/yTvvLDHpoxLr0xxxeslWw==", "dev": true }, - "node_modules/@isaacs/cliui": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", - "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", - "dev": true, - "dependencies": { - "string-width": "^5.1.2", - "string-width-cjs": "npm:string-width@^4.2.0", - "strip-ansi": "^7.0.1", - "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", - "wrap-ansi": "^8.1.0", - "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/@isaacs/cliui/node_modules/ansi-regex": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.0.1.tgz", - "integrity": "sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==", - "dev": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } - }, - "node_modules/@isaacs/cliui/node_modules/ansi-styles": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", - "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", - "dev": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/@isaacs/cliui/node_modules/emoji-regex": { - "version": "9.2.2", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", - "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", - "dev": true - }, - "node_modules/@isaacs/cliui/node_modules/string-width": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", - "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", - "dev": true, - "dependencies": { - "eastasianwidth": "^0.2.0", - "emoji-regex": "^9.2.2", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@isaacs/cliui/node_modules/strip-ansi": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.0.1.tgz", - "integrity": "sha512-cXNxvT8dFNRVfhVME3JAe98mkXDYN2O1l7jmcwMnOslDeESg1rF/OZMtK0nRAhiari1unG5cD4jG3rapUAkLbw==", - "dev": true, - "dependencies": { - "ansi-regex": "^6.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } - }, - "node_modules/@isaacs/cliui/node_modules/wrap-ansi": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", - "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", - "dev": true, - "dependencies": { - "ansi-styles": "^6.1.0", - "string-width": "^5.0.1", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.3", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.3.tgz", @@ -550,6 +429,18 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/@markedjs/testutils": { + "version": "9.1.5-0", + "resolved": "https://registry.npmjs.org/@markedjs/testutils/-/testutils-9.1.5-0.tgz", + "integrity": "sha512-cQ//6dq/bZt4TxI2WJUVYxuxwaI7JMCa9InV0jSaBaKtyvWJlEFG+iAtCKWkpR7WxUiAZa9jWtxg2+g59gSOzA==", + "dev": true, + "dependencies": { + "@markedjs/html-differ": "^4.0.2", + "front-matter": "^4.0.2", + "marked": "^9.1.5", + "marked-repo": "https://github.com/markedjs/marked/tarball/v9.1.5" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -763,16 +654,6 @@ "@octokit/openapi-types": "^18.0.0" } }, - "node_modules/@pkgjs/parseargs": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", - "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", - "dev": true, - "optional": true, - "engines": { - "node": ">=14" - } - }, "node_modules/@pnpm/network.ca-file": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/@pnpm/network.ca-file/-/network.ca-file-1.0.2.tgz", @@ -1590,30 +1471,6 @@ "url": "https://github.com/sindresorhus/is?sponsor=1" } }, - "node_modules/@tsconfig/node10": { - "version": "1.0.9", - "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.9.tgz", - "integrity": "sha512-jNsYVVxU8v5g43Erja32laIDHXeoNvFEpX33OK4d6hljo3jDhCBDhx5dhCCTMWUojscpAagGiRkBKxpdl9fxqA==", - "dev": true - }, - "node_modules/@tsconfig/node12": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz", - "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==", - "dev": true - }, - "node_modules/@tsconfig/node14": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz", - "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==", - "dev": true - }, - "node_modules/@tsconfig/node16": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz", - "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", - "dev": true - }, "node_modules/@types/estree": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.2.tgz", @@ -1632,13 +1489,6 @@ "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==", "dev": true }, - "node_modules/@types/node": { - "version": "10.17.60", - "resolved": "https://registry.npmjs.org/@types/node/-/node-10.17.60.tgz", - "integrity": "sha512-F0KIgDJfy2nA3zMLmWGKxcH2ZVEtCZXHHdOQs2gSaQ27+lNeEfGxzkIw90aXswATX7AZ33tahPbzy6KAfUreVw==", - "dev": true, - "peer": true - }, "node_modules/@types/normalize-package-data": { "version": "2.4.3", "resolved": "https://registry.npmjs.org/@types/normalize-package-data/-/normalize-package-data-2.4.3.tgz", @@ -1947,15 +1797,6 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, - "node_modules/acorn-walk": { - "version": "8.2.0", - "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.2.0.tgz", - "integrity": "sha512-k+iyHEuPgSw6SbuDpGQM+06HQUa04DZ3o+F6CSzXMvvI5KMvnaEqXe+YVe555R9nn6GPt404fos4wcgpw12SDA==", - "dev": true, - "engines": { - "node": ">=0.4.0" - } - }, "node_modules/agent-base": { "version": "7.1.0", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.0.tgz", @@ -2039,12 +1880,6 @@ "integrity": "sha512-QXu7BPrP29VllRxH8GwB7x5iX5qWKAAMLqKQGWTeLWVlNHNOpVMJ91dsxQAIWXpjuW5wqvxu3Jd/nRjrJ+0pqg==", "dev": true }, - "node_modules/arg": { - "version": "4.1.3", - "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", - "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", - "dev": true - }, "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", @@ -2577,12 +2412,6 @@ "node": ">=14" } }, - "node_modules/create-require": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", - "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", - "dev": true - }, "node_modules/cross-env": { "version": "7.0.3", "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-7.0.3.tgz", @@ -2885,12 +2714,6 @@ "readable-stream": "^2.0.2" } }, - "node_modules/eastasianwidth": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", - "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", - "dev": true - }, "node_modules/emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", @@ -3901,34 +3724,6 @@ "is-callable": "^1.1.3" } }, - "node_modules/foreground-child": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.1.1.tgz", - "integrity": "sha512-TMKDUnIte6bfb5nWv7V/caI169OHgvwjb7V4WkeUvbQQdjr5rWKqHFiKWb/fcOwB+CzBT+qbWjvj+DVwRskpIg==", - "dev": true, - "dependencies": { - "cross-spawn": "^7.0.0", - "signal-exit": "^4.0.1" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/foreground-child/node_modules/signal-exit": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.0.2.tgz", - "integrity": "sha512-MY2/qGx4enyjprQnFaZsHib3Yadh3IXyV2C321GY0pjGfVBu4un0uDJkwgdxqO+Rdx8JMT8IfJIRwbYVz3Ob3Q==", - "dev": true, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/formdata-polyfill": { "version": "4.0.10", "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", @@ -4921,89 +4716,6 @@ "node": ">=10.13" } }, - "node_modules/jackspeak": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-2.2.1.tgz", - "integrity": "sha512-MXbxovZ/Pm42f6cDIDkl3xpwv1AGwObKwfmjs2nQePiy85tP3fatofl3FC1aBsOtP/6fq5SbtgHwWcMsLP+bDw==", - "dev": true, - "dependencies": { - "@isaacs/cliui": "^8.0.2" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - }, - "optionalDependencies": { - "@pkgjs/parseargs": "^0.11.0" - } - }, - "node_modules/jasmine": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/jasmine/-/jasmine-5.1.0.tgz", - "integrity": "sha512-prmJlC1dbLhti4nE4XAPDWmfJesYO15sjGXVp7Cs7Ym5I9Xtwa/hUHxxJXjnpfLO72+ySttA0Ztf8g/RiVnUKw==", - "dev": true, - "dependencies": { - "glob": "^10.2.2", - "jasmine-core": "~5.1.0" - }, - "bin": { - "jasmine": "bin/jasmine.js" - } - }, - "node_modules/jasmine-core": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/jasmine-core/-/jasmine-core-5.1.0.tgz", - "integrity": "sha512-bFMMwpKuTZXCuGd51yClFobw5SOtad1kmdWnYO8dNwYV8i01Xj0C2+nyQpSKl1EKxiPfyd1ZgBl/rsusL3aS6w==", - "dev": true - }, - "node_modules/jasmine/node_modules/brace-expansion": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", - "dev": true, - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/jasmine/node_modules/glob": { - "version": "10.2.6", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.2.6.tgz", - "integrity": "sha512-U/rnDpXJGF414QQQZv5uVsabTVxMSwzS5CH0p3DRCIV6ownl4f7PzGnkGmvlum2wB+9RlJWJZ6ACU1INnBqiPA==", - "dev": true, - "dependencies": { - "foreground-child": "^3.1.0", - "jackspeak": "^2.0.3", - "minimatch": "^9.0.1", - "minipass": "^5.0.0 || ^6.0.2", - "path-scurry": "^1.7.0" - }, - "bin": { - "glob": "dist/cjs/src/bin.js" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/jasmine/node_modules/minimatch": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.1.tgz", - "integrity": "sha512-0jWhJpD/MdhPXwPuiRkCbfYfSKp2qnn2eOc279qI7f+osl/l+prKSrvhg157zSYvx/1nmgn2NqdT6k2Z7zSH9w==", - "dev": true, - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/java-properties": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/java-properties/-/java-properties-1.0.2.tgz", @@ -5239,12 +4951,6 @@ "node": ">=10" } }, - "node_modules/make-error": { - "version": "1.3.6", - "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", - "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", - "dev": true - }, "node_modules/markdown-it": { "version": "13.0.2", "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-13.0.2.tgz", @@ -5274,9 +4980,9 @@ } }, "node_modules/marked": { - "version": "9.1.2", - "resolved": "https://registry.npmjs.org/marked/-/marked-9.1.2.tgz", - "integrity": "sha512-qoKMJqK0w6vkLk8+KnKZAH6neUZSNaQqVZ/h2yZ9S7CbLuFHyS2viB0jnqcWF9UKjwsAbMrQtnQhdmdvOVOw9w==", + "version": "9.1.5", + "resolved": "https://registry.npmjs.org/marked/-/marked-9.1.5.tgz", + "integrity": "sha512-14QG3shv8Kg/xc0Yh6TNkMj90wXH9mmldi5941I2OevfJ/FQAFLEwtwU2/FfgSAOMlWHrEukWSGQf8MiVYNG2A==", "dev": true, "bin": { "marked": "bin/marked.js" @@ -5307,6 +5013,20 @@ "marked-man": "bin/marked-man.js" } }, + "node_modules/marked-repo": { + "name": "marked", + "version": "9.1.5", + "resolved": "https://github.com/markedjs/marked/tarball/v9.1.5", + "integrity": "sha512-ESYdlhsf+u82SDP6V229Dms0llLuzqDPkym0eX8B4kUI3HTTtD0u1fPYmfk8vdsvTQOYAdCI6H/UuWrHFhCjcg==", + "dev": true, + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 16" + } + }, "node_modules/marked-terminal": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/marked-terminal/-/marked-terminal-6.0.0.tgz", @@ -5424,15 +5144,6 @@ "integrity": "sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q==", "dev": true }, - "node_modules/minipass": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-6.0.2.tgz", - "integrity": "sha512-MzWSV5nYVT7mVyWCwn2o7JH13w2TBRmmSqSRCKzTw+lmft9X4z+3wjvs06Tzijo5z4W/kahUCDpRXTF+ZrmF/w==", - "dev": true, - "engines": { - "node": ">=16 || 14 >=14.17" - } - }, "node_modules/ms": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", @@ -9278,31 +8989,6 @@ "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", "dev": true }, - "node_modules/path-scurry": { - "version": "1.9.2", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.9.2.tgz", - "integrity": "sha512-qSDLy2aGFPm8i4rsbHd4MNyTcrzHFsLQykrtbuGRknZZCBBVXSv2tSCDN2Cg6Rt/GFRw8GoW9y9Ecw5rIPG1sg==", - "dev": true, - "dependencies": { - "lru-cache": "^9.1.1", - "minipass": "^5.0.0 || ^6.0.2" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/path-scurry/node_modules/lru-cache": { - "version": "9.1.1", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-9.1.1.tgz", - "integrity": "sha512-65/Jky17UwSb0BuB9V+MyDpsOtXKmYwzhyl+cOa9XUiI4uV2Ouy/2voFP3+al0BjZbJgMBD8FojMpAf+Z+qn4A==", - "dev": true, - "engines": { - "node": "14 || >=16.14" - } - }, "node_modules/path-type": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", @@ -10510,21 +10196,6 @@ "node": ">=8" } }, - "node_modules/string-width-cjs": { - "name": "string-width", - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/string.prototype.repeat": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/string.prototype.repeat/-/string.prototype.repeat-0.2.0.tgz", @@ -10588,19 +10259,6 @@ "node": ">=8" } }, - "node_modules/strip-ansi-cjs": { - "name": "strip-ansi", - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/strip-bom": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", @@ -10830,58 +10488,6 @@ "integrity": "sha512-e4g0EJtAjk64xgnFPD6kTBUtpnMVzDrMb12N1YZV0VvSlhnVT3SGxiYTLdGy8Q5cYHOIC/FAHmZ10eGrAguicQ==", "dev": true }, - "node_modules/ts-node": { - "version": "10.9.1", - "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.1.tgz", - "integrity": "sha512-NtVysVPkxxrwFGUUxGYhfux8k78pQB3JqYBXlLRZgdGUqTO5wU/UyHop5p70iEbGhB7q5KmiZiU0Y3KlJrScEw==", - "dev": true, - "dependencies": { - "@cspotcode/source-map-support": "^0.8.0", - "@tsconfig/node10": "^1.0.7", - "@tsconfig/node12": "^1.0.7", - "@tsconfig/node14": "^1.0.0", - "@tsconfig/node16": "^1.0.2", - "acorn": "^8.4.1", - "acorn-walk": "^8.1.1", - "arg": "^4.1.0", - "create-require": "^1.1.0", - "diff": "^4.0.1", - "make-error": "^1.1.1", - "v8-compile-cache-lib": "^3.0.1", - "yn": "3.1.1" - }, - "bin": { - "ts-node": "dist/bin.js", - "ts-node-cwd": "dist/bin-cwd.js", - "ts-node-esm": "dist/bin-esm.js", - "ts-node-script": "dist/bin-script.js", - "ts-node-transpile-only": "dist/bin-transpile.js", - "ts-script": "dist/bin-script-deprecated.js" - }, - "peerDependencies": { - "@swc/core": ">=1.2.50", - "@swc/wasm": ">=1.2.50", - "@types/node": "*", - "typescript": ">=2.7" - }, - "peerDependenciesMeta": { - "@swc/core": { - "optional": true - }, - "@swc/wasm": { - "optional": true - } - } - }, - "node_modules/ts-node/node_modules/diff": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", - "integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==", - "dev": true, - "engines": { - "node": ">=0.3.1" - } - }, "node_modules/tsconfig-paths": { "version": "3.14.2", "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.14.2.tgz", @@ -11101,12 +10707,6 @@ "integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=", "dev": true }, - "node_modules/v8-compile-cache-lib": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", - "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", - "dev": true - }, "node_modules/validate-npm-package-license": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz", @@ -11211,57 +10811,6 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, - "node_modules/wrap-ansi-cjs": { - "name": "wrap-ansi", - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "dev": true, - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/wrap-ansi-cjs/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/wrap-ansi-cjs/node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/wrap-ansi-cjs/node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true - }, "node_modules/wrap-ansi/node_modules/ansi-styles": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", @@ -11352,15 +10901,6 @@ "node": ">=12" } }, - "node_modules/yn": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", - "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", - "dev": true, - "engines": { - "node": ">=6" - } - }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/package.json b/package.json index 35cb86014b..4b3263d3d8 100644 --- a/package.json +++ b/package.json @@ -50,8 +50,8 @@ "html" ], "devDependencies": { + "@markedjs/testutils": "9.1.5-0", "@arethetypeswrong/cli": "^0.13.1", - "@markedjs/html-differ": "^4.0.2", "@rollup/plugin-terser": "^0.4.4", "@rollup/plugin-typescript": "^11.1.5", "@semantic-release/commit-analyzer": "^11.1.0", @@ -70,9 +70,7 @@ "eslint-plugin-import": "^2.29.0", "eslint-plugin-n": "^16.2.0", "eslint-plugin-promise": "^6.1.1", - "front-matter": "^4.0.2", "highlight.js": "^11.9.0", - "jasmine": "^5.1.0", "markdown-it": "13.0.2", "marked-highlight": "^2.0.6", "marked-man": "^2.0.0", @@ -82,14 +80,13 @@ "semantic-release": "^22.0.7", "titleize": "^4.0.0", "ts-expect": "^1.3.0", - "ts-node": "^10.9.1", "typescript": "5.2.2" }, "scripts": { - "test": "npm run build && cross-env NODE_OPTIONS=--loader=ts-node/esm jasmine --config=jasmine.json", + "test": "npm run build && npm run test:specs && npm run test:unit", "test:all": "npm test && npm run test:umd && npm run test:types && npm run test:lint", - "test:unit": "npm test -- test/unit/**/*-spec.js", - "test:specs": "npm test -- test/specs/**/*-spec.js", + "test:unit": "node --test --test-reporter=spec test/unit", + "test:specs": "node --test --test-reporter=spec test/run-spec-tests.js", "test:lint": "eslint .", "test:redos": "node test/recheck.js > vuln.js", "test:types": "tsc --project tsconfig-type-test.json && attw -P --exclude-entrypoints ./bin/marked ./marked.min.js", @@ -106,6 +103,6 @@ "rollup": "rollup -c rollup.config.js" }, "engines": { - "node": ">= 16" + "node": ">= 18" } } diff --git a/test/.eslintrc.json b/test/.eslintrc.json index 8926cc0d7f..3d143d1fb2 100644 --- a/test/.eslintrc.json +++ b/test/.eslintrc.json @@ -22,7 +22,6 @@ "no-var": "error" }, "env": { - "node": true, - "jasmine": true + "node": true } } diff --git a/test/bench.js b/test/bench.js index 355b6021cf..e878a333cc 100644 --- a/test/bench.js +++ b/test/bench.js @@ -1,7 +1,6 @@ -import { dirname, resolve } from 'path'; -import { fileURLToPath } from 'url'; -import { isEqual } from './helpers/html-differ.js'; -import { loadFiles } from './helpers/load.js'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { htmlIsEqual, getTests } from '@markedjs/testutils'; import { marked as cjsMarked } from '../lib/marked.cjs'; import { marked as esmMarked } from '../lib/marked.esm.js'; @@ -13,9 +12,9 @@ let marked; /** * Load specs */ -export function load() { +export async function load() { const dir = resolve(__dirname, './specs/commonmark'); - const sections = loadFiles(dir); + const sections = await getTests(dir); let specs = []; for (const section in sections) { @@ -30,7 +29,7 @@ export function load() { */ export async function runBench(options) { options = options || {}; - const specs = load(); + const specs = await load(); const tests = {}; // Non-GFM, Non-pedantic @@ -55,24 +54,24 @@ export async function runBench(options) { tests['esm marked'] = esmMarked.parse; try { - tests.commonmark = (await (async() => { + tests.commonmark = await (async() => { const { Parser, HtmlRenderer } = await import('commonmark'); const parser = new Parser(); const writer = new HtmlRenderer(); return function(text) { return writer.render(parser.parse(text)); }; - })()); + })(); } catch (e) { console.error('Could not bench commonmark. (Error: %s)', e.message); } try { - tests['markdown-it'] = (await (async() => { + tests['markdown-it'] = await (async() => { const MarkdownIt = (await import('markdown-it')).default; const md = new MarkdownIt(); return md.render.bind(md); - })()); + })(); } catch (e) { console.error('Could not bench markdown-it. (Error: %s)', e.message); } @@ -92,7 +91,11 @@ export async function bench(tests, specs) { console.log(); for (let i = 0; i < specs.length; i++) { const spec = specs[i]; - process.stdout.write(`${(i * 100 / specs.length).toFixed(1).padStart(5)}% ${i.toString().padStart(specs.length.toString().length)} of ${specs.length}\r`); + process.stdout.write( + `${((i * 100) / specs.length).toFixed(1).padStart(5)}% ${i + .toString() + .padStart(specs.length.toString().length)} of ${specs.length}\r` + ); for (const name in tests) { const test = tests[name]; const before = process.hrtime.bigint(); @@ -101,13 +104,18 @@ export async function bench(tests, specs) { } const after = process.hrtime.bigint(); stats[name].elapsed += after - before; - stats[name].correct += (await isEqual(spec.html, await test(spec.markdown)) ? 1 : 0); + stats[name].correct += (await htmlIsEqual( + spec.html, + await test(spec.markdown) + )) + ? 1 + : 0; } } for (const name in tests) { const ms = prettyElapsedTime(stats[name].elapsed); - const percent = (stats[name].correct / specs.length * 100).toFixed(2); + const percent = ((stats[name].correct / specs.length) * 100).toFixed(2); console.log(`${name} completed in ${ms}ms and passed ${percent}%`); } } @@ -121,7 +129,7 @@ function parseArg(argv) { const options = {}; const orphans = []; - function getarg() { + function getArg() { let arg = argv.shift(); if (arg.indexOf('--') === 0) { @@ -135,9 +143,11 @@ function parseArg(argv) { } else if (arg[0] === '-') { if (arg.length > 2) { // e.g. -abc - argv = arg.substring(1).split('').map(ch => { - return `-${ch}`; - }).concat(argv); + argv = arg + .substring(1) + .split('') + .map((ch) => `-${ch}`) + .concat(argv); arg = argv.shift(); } else { // e.g. -a @@ -152,7 +162,7 @@ function parseArg(argv) { const defaults = marked.getDefaults(); while (argv.length) { - const arg = getarg(); + const arg = getArg(); if (arg.indexOf('--') === 0) { const opt = camelize(arg.replace(/^--(no-)?/, '')); if (!defaults.hasOwnProperty(opt)) { @@ -160,13 +170,10 @@ function parseArg(argv) { } options.marked = options.marked || {}; if (arg.indexOf('--no-') === 0) { - options.marked[opt] = typeof defaults[opt] !== 'boolean' - ? null - : false; + options.marked[opt] = typeof defaults[opt] !== 'boolean' ? null : false; } else { - options.marked[opt] = typeof defaults[opt] !== 'boolean' - ? argv.shift() - : true; + options.marked[opt] = + typeof defaults[opt] !== 'boolean' ? argv.shift() : true; } } else { orphans.push(arg); @@ -176,7 +183,7 @@ function parseArg(argv) { if (orphans.length > 0) { console.error(); console.error('The following arguments are not used:'); - orphans.forEach(arg => console.error(` ${arg}`)); + orphans.forEach((arg) => console.error(` ${arg}`)); console.error(); } diff --git a/test/helpers/helpers.js b/test/helpers/helpers.js deleted file mode 100644 index 50515f79ec..0000000000 --- a/test/helpers/helpers.js +++ /dev/null @@ -1,56 +0,0 @@ -import { Marked, setOptions, getDefaults } from '../../src/marked.ts'; -import { isEqual, firstDiff } from './html-differ.js'; -import { strictEqual } from 'assert'; - -beforeEach(() => { - setOptions(getDefaults()); - setOptions({ silent: true }); - - jasmine.addAsyncMatchers({ - toRender: () => { - return { - compare: async(spec, expected) => { - const marked = new Marked(); - const result = {}; - const actual = marked.parse(spec.markdown, spec.options); - result.pass = await isEqual(expected, actual); - - if (result.pass) { - result.message = `${spec.markdown}\n------\n\nExpected: Should Fail`; - } else { - const diff = await firstDiff(actual, expected); - result.message = `Expected: ${diff.expected}\n Actual: ${diff.actual}`; - } - return result; - } - }; - }, - toEqualHtml: () => { - return { - compare: async(actual, expected) => { - const result = {}; - result.pass = await isEqual(expected, actual); - - if (result.pass) { - result.message = `Expected '${actual}' not to equal '${expected}'`; - } else { - const diff = await firstDiff(actual, expected); - result.message = `Expected: ${diff.expected}\n Actual: ${diff.actual}`; - } - return result; - } - }; - }, - toRenderExact: () => ({ - compare: async(spec, expected) => { - const marked = new Marked(); - const result = {}; - const actual = marked.parse(spec.markdown, spec.options); - - result.pass = strictEqual(expected, actual) === undefined; - - return result; - } - }) - }); -}); diff --git a/test/helpers/html-differ.js b/test/helpers/html-differ.js deleted file mode 100644 index d8176f4fe8..0000000000 --- a/test/helpers/html-differ.js +++ /dev/null @@ -1,38 +0,0 @@ -import { HtmlDiffer } from '@markedjs/html-differ'; -const htmlDiffer = new HtmlDiffer({ - ignoreSelfClosingSlash: true, - ignoreComments: false -}); - -export const isEqual = htmlDiffer.isEqual.bind(htmlDiffer); -export async function firstDiff(actual, expected, padding) { - padding = padding || 30; - const diffHtml = await htmlDiffer.diffHtml(actual, expected); - const result = diffHtml.reduce((obj, diff) => { - if (diff.added) { - if (obj.firstIndex === null) { - obj.firstIndex = obj.expected.length; - } - obj.expected += diff.value; - } else if (diff.removed) { - if (obj.firstIndex === null) { - obj.firstIndex = obj.actual.length; - } - obj.actual += diff.value; - } else { - obj.actual += diff.value; - obj.expected += diff.value; - } - - return obj; - }, { - firstIndex: null, - actual: '', - expected: '' - }); - - return { - actual: result.actual.substring(result.firstIndex - padding, result.firstIndex + padding), - expected: result.expected.substring(result.firstIndex - padding, result.firstIndex + padding) - }; -} diff --git a/test/helpers/load.js b/test/helpers/load.js deleted file mode 100644 index bdae88e6dd..0000000000 --- a/test/helpers/load.js +++ /dev/null @@ -1,93 +0,0 @@ -import fs from 'fs'; -import path from 'path'; -import fm from 'front-matter'; -import { createRequire } from 'module'; - -const require = createRequire(import.meta.url); - -export function outputCompletionTable(title, specs) { - let longestName = 0; - let maxSpecs = 0; - - for (const section in specs) { - longestName = Math.max(section.length, longestName); - maxSpecs = Math.max(specs[section].total, maxSpecs); - } - - const maxSpecsLen = ('' + maxSpecs).length; - const spaces = maxSpecsLen * 2 + longestName + 11; - - console.log('-'.padEnd(spaces + 4, '-')); - console.log(`| ${title.padStart(Math.ceil((spaces + title.length) / 2)).padEnd(spaces)} |`); - console.log(`| ${' '.padEnd(spaces)} |`); - for (const section in specs) { - console.log(`| ${section.padEnd(longestName)} ${('' + specs[section].pass).padStart(maxSpecsLen)} of ${('' + specs[section].total).padStart(maxSpecsLen)} ${(100 * specs[section].pass / specs[section].total).toFixed().padStart(4)}% |`); - } - console.log('-'.padEnd(spaces + 4, '-')); - console.log(); -} - -export function loadFiles(dir) { - const files = fs.readdirSync(dir); - - return files.reduce((obj, file) => { - const ext = path.extname(file); - const name = path.basename(file, ext); - const absFile = path.join(dir, file); - let specs; - - switch (ext) { - case '.md': { - const content = fm(fs.readFileSync(absFile, 'utf8')); - const skip = content.attributes.skip; - delete content.attributes.skip; - const only = content.attributes.only; - delete content.attributes.only; - specs = [{ - section: name, - markdown: content.body, - html: fs.readFileSync(absFile.replace(/[^.]+$/, 'html'), 'utf8'), - options: content.attributes, - only, - skip - }]; - break; - } - case '.cjs': - case '.json': { - try { - specs = require(absFile); - } catch (err) { - console.log(`Error loading ${absFile}`); - throw err; - } - if (!Array.isArray(specs)) { - specs = [specs]; - } - break; - } - default: - return obj; - } - - for (let i = 0; i < specs.length; i++) { - const spec = specs[i]; - if (!spec.section) { - spec.section = `${name}[${i}]`; - } - if (!obj[spec.section]) { - obj[spec.section] = { - total: 0, - pass: 0, - specs: [] - }; - } - - obj[spec.section].total++; - obj[spec.section].pass += spec.shouldFail ? 0 : 1; - obj[spec.section].specs.push(spec); - } - - return obj; - }, {}); -} diff --git a/test/run-spec-tests.js b/test/run-spec-tests.js new file mode 100644 index 0000000000..d42bfcb9f8 --- /dev/null +++ b/test/run-spec-tests.js @@ -0,0 +1,50 @@ +import { Marked } from '../lib/marked.esm.js'; +import { getTests, runTests, outputCompletionTable } from '@markedjs/testutils'; +import { resolve, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +function parse(markdown, options) { + const marked = new Marked(options); + return marked.parse(markdown); +} + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +const [commonMarkTests, gfmTests, newTests, originalTests, redosTests] = + await getTests([ + resolve(__dirname, './specs/commonmark'), + resolve(__dirname, './specs/gfm'), + resolve(__dirname, './specs/new'), + resolve(__dirname, './specs/original'), + resolve(__dirname, './specs/redos') + ]); + +outputCompletionTable('CommonMark', commonMarkTests); +runTests({ + tests: commonMarkTests, + parse, + defaultMarkedOptions: { gfm: false, pedantic: false } +}); + +outputCompletionTable('GFM', gfmTests); +runTests({ + tests: gfmTests, + parse, + defaultMarkedOptions: { gfm: true, pedantic: false } +}); + +runTests({ + tests: newTests, + parse +}); + +runTests({ + tests: originalTests, + parse, + defaultMarkedOptions: { gfm: false, pedantic: true } +}); + +runTests({ + tests: redosTests, + parse +}); diff --git a/test/specs/gfm/gfm.0.29.json b/test/specs/gfm/gfm.0.29.json index 50c4ac8d09..9d403a9a8b 100644 --- a/test/specs/gfm/gfm.0.29.json +++ b/test/specs/gfm/gfm.0.29.json @@ -61,8 +61,8 @@ }, { "section": "[extension] Strikethrough", - "html": "

Hi Hello, world!

", - "markdown": "~~Hi~~ Hello, world!", + "html": "

Hi Hello, there world!

", + "markdown": "~~Hi~~ Hello, ~there~ world!", "example": 491 }, { @@ -71,77 +71,104 @@ "markdown": "This ~~has a\n\nnew paragraph~~.", "example": 492 }, + { + "section": "[extension] Strikethrough", + "html": "

This will ~~~not~~~ strike.

", + "markdown": "This will ~~~not~~~ strike.", + "example": 493 + }, { "section": "[extension] Autolinks", "html": "

www.commonmark.org

", "markdown": "www.commonmark.org", - "example": 621 + "example": 622 }, { "section": "[extension] Autolinks", "html": "

Visit www.commonmark.org/help for more information.

", "markdown": "Visit www.commonmark.org/help for more information.", - "example": 622 + "example": 623 }, { "section": "[extension] Autolinks", "html": "

Visit www.commonmark.org.

\n

Visit www.commonmark.org/a.b.

", "markdown": "Visit www.commonmark.org.\n\nVisit www.commonmark.org/a.b.", - "example": 623 + "example": 624 }, { "section": "[extension] Autolinks", "html": "

www.google.com/search?q=Markup+(business)

\n

www.google.com/search?q=Markup+(business)))

\n

(www.google.com/search?q=Markup+(business))

\n

(www.google.com/search?q=Markup+(business)

", "markdown": "www.google.com/search?q=Markup+(business)\n\nwww.google.com/search?q=Markup+(business)))\n\n(www.google.com/search?q=Markup+(business))\n\n(www.google.com/search?q=Markup+(business)", - "example": 624 + "example": 625 }, { "section": "[extension] Autolinks", "html": "

www.google.com/search?q=(business))+ok

", "markdown": "www.google.com/search?q=(business))+ok", - "example": 625 + "example": 626 }, { "section": "[extension] Autolinks", "html": "

www.google.com/search?q=commonmark&hl=en

\n

www.google.com/search?q=commonmark&hl;

", "markdown": "www.google.com/search?q=commonmark&hl=en\n\nwww.google.com/search?q=commonmark&hl;", - "example": 626 + "example": 627 }, { "section": "[extension] Autolinks", "html": "

www.commonmark.org/he<lp

", "markdown": "www.commonmark.org/hehttp://commonmark.org

\n

(Visit https://encrypted.google.com/search?q=Markup+(business))

", "markdown": "http://commonmark.org\n\n(Visit https://encrypted.google.com/search?q=Markup+(business))", - "example": 628 + "example": 629 }, { "section": "[extension] Autolinks", "html": "

foo@bar.baz

", "markdown": "foo@bar.baz", - "example": 629 + "example": 630 }, { "section": "[extension] Autolinks", "html": "

hello@mail+xyz.example isn't valid, but hello+xyz@mail.example is.

", "markdown": "hello@mail+xyz.example isn't valid, but hello+xyz@mail.example is.", - "example": 630 + "example": 631 }, { "section": "[extension] Autolinks", "html": "

a.b-c_d@a.b

\n

a.b-c_d@a.b.

\n

a.b-c_d@a.b-

\n

a.b-c_d@a.b_

", "markdown": "a.b-c_d@a.b\n\na.b-c_d@a.b.\n\na.b-c_d@a.b-\n\na.b-c_d@a.b_", - "example": 631 + "example": 632 + }, + { + "section": "[extension] Autolinks", + "html": "

mailto:foo@bar.baz

\n

mailto:a.b-c_d@a.b

\n

mailto:a.b-c_d@a.b.

\n

mailto:a.b-c_d@a.b/

\n

mailto:a.b-c_d@a.b-

\n

mailto:a.b-c_d@a.b_

\n

xmpp:foo@bar.baz

\n

xmpp:foo@bar.baz.

", + "markdown": "mailto:foo@bar.baz\n\nmailto:a.b-c_d@a.b\n\nmailto:a.b-c_d@a.b.\n\nmailto:a.b-c_d@a.b/\n\nmailto:a.b-c_d@a.b-\n\nmailto:a.b-c_d@a.b_\n\nxmpp:foo@bar.baz\n\nxmpp:foo@bar.baz.", + "example": 633, + "shouldFail": true + }, + { + "section": "[extension] Autolinks", + "html": "

xmpp:foo@bar.baz/txt

\n

xmpp:foo@bar.baz/txt@bin

\n

xmpp:foo@bar.baz/txt@bin.com

", + "markdown": "xmpp:foo@bar.baz/txt\n\nxmpp:foo@bar.baz/txt@bin\n\nxmpp:foo@bar.baz/txt@bin.com", + "example": 634, + "shouldFail": true + }, + { + "section": "[extension] Autolinks", + "html": "

xmpp:foo@bar.baz/txt/bin

", + "markdown": "xmpp:foo@bar.baz/txt/bin", + "example": 635, + "shouldFail": true }, { "section": "[extension] Disallowed Raw HTML", "html": "

<title> <style>

\n
\n <xmp> is disallowed. <XMP> is also disallowed.\n
", "markdown": " <style> <em>\n\n<blockquote>\n <xmp> is disallowed. <XMP> is also disallowed.\n</blockquote>", - "example": 653, + "example": 657, "shouldFail": true } ] diff --git a/test/specs/run-spec.js b/test/specs/run-spec.js deleted file mode 100644 index 7884b25646..0000000000 --- a/test/specs/run-spec.js +++ /dev/null @@ -1,52 +0,0 @@ -import { dirname, resolve } from 'path'; -import { fileURLToPath } from 'url'; -import { loadFiles, outputCompletionTable } from '../helpers/load.js'; - -const __dirname = dirname(fileURLToPath(import.meta.url)); - -function runSpecs(title, dir, showCompletionTable, options) { - options = options || {}; - const specs = loadFiles(resolve(__dirname, dir)); - - if (showCompletionTable) { - outputCompletionTable(title, specs); - } - - describe(title, () => { - Object.keys(specs).forEach(section => { - describe(section, () => { - specs[section].specs.forEach((spec) => { - spec.options = Object.assign({}, options, (spec.options || {})); - const example = (spec.example ? ' example ' + spec.example : ''); - const passFail = (spec.shouldFail ? 'fail' : 'pass'); - - if (typeof spec.options.silent === 'undefined') { - spec.options.silent = true; - } - - (spec.only ? fit : (spec.skip ? xit : it))('should ' + passFail + example, async() => { - const before = process.hrtime(); - if (spec.shouldFail) { - await expectAsync(spec).not.toRender(spec.html); - } else if (spec.options.renderExact) { - await expectAsync(spec).toRenderExact(spec.html); - } else { - await expectAsync(spec).toRender(spec.html); - } - const elapsed = process.hrtime(before); - if (elapsed[0] > 0) { - const s = (elapsed[0] + elapsed[1] * 1e-9).toFixed(3); - fail(`took too long: ${s}s`); - } - }); - }); - }); - }); - }); -} - -runSpecs('GFM', './gfm', true, { gfm: true, pedantic: false }); -runSpecs('CommonMark', './commonmark', true, { gfm: false, pedantic: false }); -runSpecs('Original', './original', false, { gfm: false, pedantic: true }); -runSpecs('New', './new'); -runSpecs('ReDOS', './redos'); diff --git a/test/unit/Hooks-spec.js b/test/unit/Hooks.test.js similarity index 61% rename from test/unit/Hooks-spec.js rename to test/unit/Hooks.test.js index 32e68650ed..cfa8ac365f 100644 --- a/test/unit/Hooks-spec.js +++ b/test/unit/Hooks.test.js @@ -1,7 +1,14 @@ -import { marked } from '../../src/marked.js'; +import { Marked } from '../../lib/marked.esm.js'; import { timeout } from './utils.js'; +import { describe, it, beforeEach } from 'node:test'; +import assert from 'node:assert'; describe('Hooks', () => { + let marked; + beforeEach(() => { + marked = new Marked(); + }); + it('should preprocess markdown', () => { marked.use({ hooks: { @@ -10,8 +17,8 @@ describe('Hooks', () => { } } }); - const html = marked('*text*'); - expect(html.trim()).toBe('<h1>preprocess</h1>\n<p><em>text</em></p>'); + const html = marked.parse('*text*'); + assert.strictEqual(html.trim(), '<h1>preprocess</h1>\n<p><em>text</em></p>'); }); it('should preprocess async', async() => { @@ -24,10 +31,10 @@ describe('Hooks', () => { } } }); - const promise = marked('*text*'); - expect(promise).toBeInstanceOf(Promise); + const promise = marked.parse('*text*'); + assert.ok(promise instanceof Promise); const html = await promise; - expect(html.trim()).toBe('<h1>preprocess async</h1>\n<p><em>text</em></p>'); + assert.strictEqual(html.trim(), '<h1>preprocess async</h1>\n<p><em>text</em></p>'); }); it('should preprocess options', () => { @@ -39,8 +46,8 @@ describe('Hooks', () => { } } }); - const html = marked('line1\nline2'); - expect(html.trim()).toBe('<p>line1<br>line2</p>'); + const html = marked.parse('line1\nline2'); + assert.strictEqual(html.trim(), '<p>line1<br>line2</p>'); }); it('should preprocess options async', async() => { @@ -54,8 +61,8 @@ describe('Hooks', () => { } } }); - const html = await marked('line1\nline2'); - expect(html.trim()).toBe('<p>line1<br>line2</p>'); + const html = await marked.parse('line1\nline2'); + assert.strictEqual(html.trim(), '<p>line1<br>line2</p>'); }); it('should postprocess html', () => { @@ -66,8 +73,8 @@ describe('Hooks', () => { } } }); - const html = marked('*text*'); - expect(html.trim()).toBe('<p><em>text</em></p>\n<h1>postprocess</h1>'); + const html = marked.parse('*text*'); + assert.strictEqual(html.trim(), '<p><em>text</em></p>\n<h1>postprocess</h1>'); }); it('should postprocess async', async() => { @@ -80,10 +87,10 @@ describe('Hooks', () => { } } }); - const promise = marked('*text*'); - expect(promise).toBeInstanceOf(Promise); + const promise = marked.parse('*text*'); + assert.ok(promise instanceof Promise); const html = await promise; - expect(html.trim()).toBe('<p><em>text</em></p>\n<h1>postprocess async</h1>'); + assert.strictEqual(html.trim(), '<p><em>text</em></p>\n<h1>postprocess async</h1>'); }); it('should process all hooks in reverse', async() => { @@ -109,9 +116,9 @@ describe('Hooks', () => { } } }); - const promise = marked('*text*'); - expect(promise).toBeInstanceOf(Promise); + const promise = marked.parse('*text*'); + assert.ok(promise instanceof Promise); const html = await promise; - expect(html.trim()).toBe('<h1>preprocess1</h1>\n<h1>preprocess2</h1>\n<p><em>text</em></p>\n<h1>postprocess2 async</h1>\n<h1>postprocess1</h1>'); + assert.strictEqual(html.trim(), '<h1>preprocess1</h1>\n<h1>preprocess2</h1>\n<p><em>text</em></p>\n<h1>postprocess2 async</h1>\n<h1>postprocess1</h1>'); }); }); diff --git a/test/unit/Lexer-spec.js b/test/unit/Lexer.test.js similarity index 68% rename from test/unit/Lexer-spec.js rename to test/unit/Lexer.test.js index 4d26da4629..8a331fbbe7 100644 --- a/test/unit/Lexer-spec.js +++ b/test/unit/Lexer.test.js @@ -1,20 +1,28 @@ -import { _Lexer } from '../../src/Lexer.js'; +import { Lexer } from '../../lib/marked.esm.js'; +import { describe, it } from 'node:test'; +import assert from 'node:assert'; -function expectTokens({ md, options, tokens = [], links = {} }) { - const lexer = new _Lexer(options); +function expectTokens({ md, options, tokens = [], links = {}, log = false }) { + const lexer = new Lexer(options); const actual = lexer.lex(md); const expected = tokens; expected.links = links; - // console.log(JSON.stringify(actual, null, 2)); - expect(actual).toEqual(expected); + if (log) { + console.log(JSON.stringify( + actual, + (k, v) => v === undefined ? null : v, + 2 + )); + } + assert.deepEqual(actual, expected); } -function expectInlineTokens({ md, options, tokens = jasmine.any(Array), links = {} }) { - const lexer = new _Lexer(options); +function expectInlineTokens({ md, options, tokens, links = {} }) { + const lexer = new Lexer(options); lexer.tokens.links = links; const outTokens = []; lexer.inlineTokens(md, outTokens); - expect(outTokens).toEqual(tokens); + assert.deepEqual(outTokens, tokens); } describe('Lexer', () => { @@ -504,26 +512,65 @@ a | b 1. item 1 2. item 2 `, - tokens: jasmine.arrayContaining([ - jasmine.objectContaining({ + tokens: [ + { type: 'space', raw: '\n' - }), - jasmine.objectContaining({ + }, + { type: 'list', raw: '1. item 1\n2. item 2\n', ordered: true, start: 1, + loose: false, items: [ - jasmine.objectContaining({ - raw: '1. item 1\n' - }), - jasmine.objectContaining({ - raw: '2. item 2' - }) + { + type: 'list_item', + raw: '1. item 1\n', + task: false, + checked: undefined, + loose: false, + text: 'item 1', + tokens: [ + { + type: 'text', + raw: 'item 1', + text: 'item 1', + tokens: [ + { + type: 'text', + raw: 'item 1', + text: 'item 1' + } + ] + } + ] + }, + { + type: 'list_item', + raw: '2. item 2', + task: false, + checked: undefined, + loose: false, + text: 'item 2', + tokens: [ + { + type: 'text', + raw: 'item 2', + text: 'item 2', + tokens: [ + { + type: 'text', + raw: 'item 2', + text: 'item 2' + } + ] + } + ] + } ] - }) - ]) + } + ] }); }); @@ -533,26 +580,65 @@ a | b 1) item 1 2) item 2 `, - tokens: jasmine.arrayContaining([ - jasmine.objectContaining({ + tokens: [ + { type: 'space', raw: '\n' - }), - jasmine.objectContaining({ + }, + { type: 'list', raw: '1) item 1\n2) item 2\n', ordered: true, start: 1, + loose: false, items: [ - jasmine.objectContaining({ - raw: '1) item 1\n' - }), - jasmine.objectContaining({ - raw: '2) item 2' - }) + { + type: 'list_item', + raw: '1) item 1\n', + task: false, + checked: undefined, + loose: false, + text: 'item 1', + tokens: [ + { + type: 'text', + raw: 'item 1', + text: 'item 1', + tokens: [ + { + type: 'text', + raw: 'item 1', + text: 'item 1' + } + ] + } + ] + }, + { + type: 'list_item', + raw: '2) item 2', + task: false, + checked: undefined, + loose: false, + text: 'item 2', + tokens: [ + { + type: 'text', + raw: 'item 2', + text: 'item 2', + tokens: [ + { + type: 'text', + raw: 'item 2', + text: 'item 2' + } + ] + } + ] + } ] - }) - ]) + } + ] }); }); @@ -583,12 +669,20 @@ paragraph checked: undefined, loose: false, text: 'item 1', - tokens: [{ - type: 'text', - raw: 'item 1', - text: 'item 1', - tokens: [{ type: 'text', raw: 'item 1', text: 'item 1' }] - }] + tokens: [ + { + type: 'text', + raw: 'item 1', + text: 'item 1', + tokens: [ + { + type: 'text', + raw: 'item 1', + text: 'item 1' + } + ] + } + ] }, { type: 'list_item', @@ -597,25 +691,38 @@ paragraph checked: undefined, loose: false, text: 'item 2', - tokens: [{ - type: 'text', - raw: 'item 2', - text: 'item 2', - tokens: [{ type: 'text', raw: 'item 2', text: 'item 2' }] - }] + tokens: [ + { + type: 'text', + raw: 'item 2', + text: 'item 2', + tokens: [ + { + type: 'text', + raw: 'item 2', + text: 'item 2' + } + ] + } + ] } ] }, - { type: 'space', raw: '\n\n' }, + { + type: 'space', + raw: '\n\n' + }, { type: 'paragraph', raw: 'paragraph\n', text: 'paragraph', - tokens: [{ - type: 'text', - raw: 'paragraph', - text: 'paragraph' - }] + tokens: [ + { + type: 'text', + raw: 'paragraph', + text: 'paragraph' + } + ] } ] }); @@ -627,26 +734,65 @@ paragraph 2. item 1 3. item 2 `, - tokens: jasmine.arrayContaining([ - jasmine.objectContaining({ + tokens: [ + { type: 'space', raw: '\n' - }), - jasmine.objectContaining({ + }, + { type: 'list', raw: '2. item 1\n3. item 2\n', ordered: true, start: 2, + loose: false, items: [ - jasmine.objectContaining({ - raw: '2. item 1\n' - }), - jasmine.objectContaining({ - raw: '3. item 2' - }) + { + type: 'list_item', + raw: '2. item 1\n', + task: false, + checked: undefined, + loose: false, + text: 'item 1', + tokens: [ + { + type: 'text', + raw: 'item 1', + text: 'item 1', + tokens: [ + { + type: 'text', + raw: 'item 1', + text: 'item 1' + } + ] + } + ] + }, + { + type: 'list_item', + raw: '3. item 2', + task: false, + checked: undefined, + loose: false, + text: 'item 2', + tokens: [ + { + type: 'text', + raw: 'item 2', + text: 'item 2', + tokens: [ + { + type: 'text', + raw: 'item 2', + text: 'item 2' + } + ] + } + ] + } ] - }) - ]) + } + ] }); }); @@ -657,27 +803,65 @@ paragraph - item 2 `, - tokens: jasmine.arrayContaining([ - jasmine.objectContaining({ + tokens: [ + { type: 'space', raw: '\n' - }), - jasmine.objectContaining({ + }, + { type: 'list', raw: '- item 1\n\n- item 2\n', + ordered: false, + start: '', loose: true, items: [ - jasmine.objectContaining({ + { + type: 'list_item', raw: '- item 1\n\n', - loose: true - }), - jasmine.objectContaining({ + task: false, + checked: undefined, + loose: true, + text: 'item 1\n', + tokens: [ + { + type: 'text', + raw: 'item 1\n', + text: 'item 1', + tokens: [ + { + type: 'text', + raw: 'item 1', + text: 'item 1' + } + ] + } + ] + }, + { + type: 'list_item', raw: '- item 2', - loose: true - }) + task: false, + checked: undefined, + loose: true, + text: 'item 2', + tokens: [ + { + type: 'text', + raw: 'item 2', + text: 'item 2', + tokens: [ + { + type: 'text', + raw: 'item 2', + text: 'item 2' + } + ] + } + ] + } ] - }) - ]) + } + ] }); }); @@ -690,31 +874,103 @@ paragraph item 2a - item 3 `, - tokens: jasmine.arrayContaining([ - jasmine.objectContaining({ + tokens: [ + { type: 'space', raw: '\n' - }), - jasmine.objectContaining({ + }, + { type: 'list', raw: '- item 1\n- item 2\n\n item 2a\n- item 3\n', + ordered: false, + start: '', loose: true, items: [ - jasmine.objectContaining({ + { + type: 'list_item', raw: '- item 1\n', - loose: true - }), - jasmine.objectContaining({ + task: false, + checked: undefined, + loose: true, + text: 'item 1', + tokens: [ + { + type: 'text', + raw: 'item 1', + text: 'item 1', + tokens: [ + { + type: 'text', + raw: 'item 1', + text: 'item 1' + } + ] + } + ] + }, + { + type: 'list_item', raw: '- item 2\n\n item 2a\n', - loose: true - }), - jasmine.objectContaining({ + task: false, + checked: undefined, + loose: true, + text: 'item 2\n\nitem 2a', + tokens: [ + { + type: 'text', + raw: 'item 2', + text: 'item 2', + tokens: [ + { + type: 'text', + raw: 'item 2', + text: 'item 2' + } + ] + }, + { + type: 'space', + raw: '\n\n' + }, + { + type: 'text', + raw: 'item 2a', + text: 'item 2a', + tokens: [ + { + type: 'text', + raw: 'item 2a', + text: 'item 2a' + } + ] + } + ] + }, + { + type: 'list_item', raw: '- item 3', - loose: true - }) + task: false, + checked: undefined, + loose: true, + text: 'item 3', + tokens: [ + { + type: 'text', + raw: 'item 3', + text: 'item 3', + tokens: [ + { + type: 'text', + raw: 'item 3', + text: 'item 3' + } + ] + } + ] + } ] - }) - ]) + } + ] }); }); @@ -724,32 +980,74 @@ paragraph - item 1 - item 2 `, - tokens: jasmine.arrayContaining([ - jasmine.objectContaining({ + tokens: [ + { type: 'space', raw: '\n' - }), - jasmine.objectContaining({ + }, + { type: 'list', raw: '- item 1\n - item 2\n', + ordered: false, + start: '', loose: false, items: [ - jasmine.objectContaining({ + { + type: 'list_item', raw: '- item 1\n - item 2', + task: false, + checked: undefined, loose: false, - tokens: jasmine.arrayContaining([ - jasmine.objectContaining({ - raw: 'item 1\n' - }), - jasmine.objectContaining({ + text: 'item 1\n- item 2', + tokens: [ + { + type: 'text', + raw: 'item 1\n', + text: 'item 1', + tokens: [ + { + type: 'text', + raw: 'item 1', + text: 'item 1' + } + ] + }, + { type: 'list', - raw: '- item 2' - }) - ]) - }) + raw: '- item 2', + ordered: false, + start: '', + loose: false, + items: [ + { + type: 'list_item', + raw: '- item 2', + task: false, + checked: undefined, + loose: false, + text: 'item 2', + tokens: [ + { + type: 'text', + raw: 'item 2', + text: 'item 2', + tokens: [ + { + type: 'text', + raw: 'item 2', + text: 'item 2' + } + ] + } + ] + } + ] + } + ] + } ] - }) - ]) + } + ] }); }); @@ -759,28 +1057,65 @@ paragraph - [ ] item 1 - [x] item 2 `, - tokens: jasmine.arrayContaining([ - jasmine.objectContaining({ + tokens: [ + { type: 'space', raw: '\n' - }), - jasmine.objectContaining({ + }, + { type: 'list', raw: '- [ ] item 1\n- [x] item 2\n', + ordered: false, + start: '', + loose: false, items: [ - jasmine.objectContaining({ + { + type: 'list_item', raw: '- [ ] item 1\n', task: true, - checked: false - }), - jasmine.objectContaining({ + checked: false, + loose: false, + text: 'item 1', + tokens: [ + { + type: 'text', + raw: 'item 1', + text: 'item 1', + tokens: [ + { + type: 'text', + raw: 'item 1', + text: 'item 1' + } + ] + } + ] + }, + { + type: 'list_item', raw: '- [x] item 2', task: true, - checked: true - }) + checked: true, + loose: false, + text: 'item 2', + tokens: [ + { + type: 'text', + raw: 'item 2', + text: 'item 2', + tokens: [ + { + type: 'text', + raw: 'item 2', + text: 'item 2' + } + ] + } + ] + } ] - }) - ]) + } + ] }); }); }); @@ -1145,9 +1480,22 @@ paragraph expectInlineTokens({ md: 'a\nb', options: { gfm: true, breaks: true }, - tokens: jasmine.arrayContaining([ - { type: 'br', raw: '\n' } - ]) + tokens: [ + { + raw: 'a', + text: 'a', + type: 'text' + }, + { + raw: '\n', + type: 'br' + }, + { + raw: 'b', + text: 'b', + type: 'text' + } + ] }); }); diff --git a/test/unit/Parser-spec.js b/test/unit/Parser.test.js similarity index 62% rename from test/unit/Parser-spec.js rename to test/unit/Parser.test.js index 487641adf9..5e2a6f3f53 100644 --- a/test/unit/Parser-spec.js +++ b/test/unit/Parser.test.js @@ -1,9 +1,13 @@ -import { _Parser } from '../../src/Parser.js'; +import { Parser } from '../../lib/marked.esm.js'; +import { htmlIsEqual, firstDiff } from '@markedjs/testutils'; +import assert from 'node:assert'; +import { describe, it } from 'node:test'; async function expectHtml({ tokens, options, html, inline }) { - const parser = new _Parser(options); + const parser = new Parser(options); const actual = parser[inline ? 'parseInline' : 'parse'](tokens); - await expectAsync(actual).toEqualHtml(html); + const testDiff = await firstDiff(actual, html); + assert.ok(await htmlIsEqual(html, actual), `Expected: ${testDiff.expected}\n Actual: ${testDiff.actual}`); } describe('Parser', () => { @@ -14,17 +18,13 @@ describe('Parser', () => { { type: 'paragraph', text: 'paragraph 1', - tokens: [ - { type: 'text', text: 'paragraph 1' } - ] + tokens: [{ type: 'text', text: 'paragraph 1' }] }, { type: 'space' }, { type: 'paragraph', text: 'paragraph 2', - tokens: [ - { type: 'text', text: 'paragraph 2' } - ] + tokens: [{ type: 'text', text: 'paragraph 2' }] } ], html: '<p>paragraph 1</p><p>paragraph 2</p>' @@ -33,65 +33,71 @@ describe('Parser', () => { it('hr', async() => { await expectHtml({ - tokens: [{ - type: 'hr' - }], + tokens: [ + { + type: 'hr' + } + ], html: '<hr />' }); }); it('heading', async() => { await expectHtml({ - tokens: [{ - type: 'heading', - depth: 1, - text: 'heading', - tokens: [ - { type: 'text', text: 'heading' } - ] - }], + tokens: [ + { + type: 'heading', + depth: 1, + text: 'heading', + tokens: [{ type: 'text', text: 'heading' }] + } + ], html: '<h1>heading</h1>' }); }); it('code', async() => { await expectHtml({ - tokens: [{ - type: 'code', - text: 'code' - }], + tokens: [ + { + type: 'code', + text: 'code' + } + ], html: '<pre><code>code</code></pre>' }); }); it('table', async() => { await expectHtml({ - tokens: [{ - type: 'table', - align: ['left', 'right'], - header: [ - { - text: 'a', - tokens: [{ type: 'text', raw: 'a', text: 'a' }] - }, - { - text: 'b', - tokens: [{ type: 'text', raw: 'b', text: 'b' }] - } - ], - rows: [ - [ + tokens: [ + { + type: 'table', + align: ['left', 'right'], + header: [ { - text: '1', - tokens: [{ type: 'text', raw: '1', text: '1' }] + text: 'a', + tokens: [{ type: 'text', raw: 'a', text: 'a' }] }, { - text: '2', - tokens: [{ type: 'text', raw: '2', text: '2' }] + text: 'b', + tokens: [{ type: 'text', raw: 'b', text: 'b' }] } + ], + rows: [ + [ + { + text: '1', + tokens: [{ type: 'text', raw: '1', text: '1' }] + }, + { + text: '2', + tokens: [{ type: 'text', raw: '2', text: '2' }] + } + ] ] - ] - }], + } + ], html: ` <table> <thead> @@ -115,13 +121,13 @@ describe('Parser', () => { tokens: [ { type: 'blockquote', - tokens: [{ - type: 'paragraph', - text: 'blockquote', - tokens: [ - { type: 'text', text: 'blockquote' } - ] - }] + tokens: [ + { + type: 'paragraph', + text: 'blockquote', + tokens: [{ type: 'text', text: 'blockquote' }] + } + ] } ], html: '<blockquote><p>blockquote</p></blockquote>' @@ -141,20 +147,24 @@ describe('Parser', () => { { task: false, checked: undefined, - tokens: [{ - type: 'text', - text: 'item 1', - tokens: [{ type: 'text', text: 'item 1' }] - }] + tokens: [ + { + type: 'text', + text: 'item 1', + tokens: [{ type: 'text', text: 'item 1' }] + } + ] }, { task: false, checked: undefined, - tokens: [{ - type: 'text', - text: 'item 2', - tokens: [{ type: 'text', text: 'item 2' }] - }] + tokens: [ + { + type: 'text', + text: 'item 2', + tokens: [{ type: 'text', text: 'item 2' }] + } + ] } ] } @@ -179,20 +189,24 @@ describe('Parser', () => { { task: false, checked: undefined, - tokens: [{ - type: 'text', - text: 'item 1', - tokens: [{ type: 'text', text: 'item 1' }] - }] + tokens: [ + { + type: 'text', + text: 'item 1', + tokens: [{ type: 'text', text: 'item 1' }] + } + ] }, { task: false, checked: undefined, - tokens: [{ - type: 'text', - text: 'item 2', - tokens: [{ type: 'text', text: 'item 2' }] - }] + tokens: [ + { + type: 'text', + text: 'item 2', + tokens: [{ type: 'text', text: 'item 2' }] + } + ] } ] } @@ -217,20 +231,24 @@ describe('Parser', () => { { task: true, checked: false, - tokens: [{ - type: 'text', - text: 'item 1', - tokens: [{ type: 'text', text: 'item 1' }] - }] + tokens: [ + { + type: 'text', + text: 'item 1', + tokens: [{ type: 'text', text: 'item 1' }] + } + ] }, { task: true, checked: true, - tokens: [{ - type: 'text', - text: 'item 2', - tokens: [{ type: 'text', text: 'item 2' }] - }] + tokens: [ + { + type: 'text', + text: 'item 2', + tokens: [{ type: 'text', text: 'item 2' }] + } + ] } ] } @@ -255,20 +273,24 @@ describe('Parser', () => { { task: false, checked: undefined, - tokens: [{ - type: 'text', - text: 'item 1', - tokens: [{ type: 'text', text: 'item 1' }] - }] + tokens: [ + { + type: 'text', + text: 'item 1', + tokens: [{ type: 'text', text: 'item 1' }] + } + ] }, { task: false, checked: undefined, - tokens: [{ - type: 'text', - text: 'item 2', - tokens: [{ type: 'text', text: 'item 2' }] - }] + tokens: [ + { + type: 'text', + text: 'item 2', + tokens: [{ type: 'text', text: 'item 2' }] + } + ] } ] } @@ -284,23 +306,25 @@ describe('Parser', () => { it('html', async() => { await expectHtml({ - tokens: [{ - type: 'html', - text: '<div>html</div>' - }], + tokens: [ + { + type: 'html', + text: '<div>html</div>' + } + ], html: '<div>html</div>' }); }); it('paragraph', async() => { await expectHtml({ - tokens: [{ - type: 'paragraph', - text: 'paragraph 1', - tokens: [ - { type: 'text', text: 'paragraph 1' } - ] - }], + tokens: [ + { + type: 'paragraph', + text: 'paragraph 1', + tokens: [{ type: 'text', text: 'paragraph 1' }] + } + ], html: '<p>paragraph 1</p>' }); }); @@ -320,9 +344,7 @@ describe('Parser', () => { it('escape', async() => { await expectHtml({ inline: true, - tokens: [ - { type: 'escape', text: '>' } - ], + tokens: [{ type: 'escape', text: '>' }], html: '>' }); }); @@ -348,9 +370,7 @@ describe('Parser', () => { text: 'link', href: 'https://example.com', title: 'title', - tokens: [ - { type: 'text', text: 'link' } - ] + tokens: [{ type: 'text', text: 'link' }] } ], html: '<a href="https://example.com" title="title">link</a>' @@ -379,9 +399,7 @@ describe('Parser', () => { { type: 'strong', text: 'strong', - tokens: [ - { type: 'text', text: 'strong' } - ] + tokens: [{ type: 'text', text: 'strong' }] } ], html: '<strong>strong</strong>' @@ -395,9 +413,7 @@ describe('Parser', () => { { type: 'em', text: 'em', - tokens: [ - { type: 'text', text: 'em' } - ] + tokens: [{ type: 'text', text: 'em' }] } ], html: '<em>em</em>' @@ -420,9 +436,11 @@ describe('Parser', () => { it('br', async() => { await expectHtml({ inline: true, - tokens: [{ - type: 'br' - }], + tokens: [ + { + type: 'br' + } + ], html: '<br />' }); }); @@ -434,9 +452,7 @@ describe('Parser', () => { { type: 'del', text: 'del', - tokens: [ - { type: 'text', text: 'del' } - ] + tokens: [{ type: 'text', text: 'del' }] } ], html: '<del>del</del>' diff --git a/test/unit/bin-spec.js b/test/unit/bin.test.js similarity index 68% rename from test/unit/bin-spec.js rename to test/unit/bin.test.js index e2bcb88662..e9e0bcbbd3 100644 --- a/test/unit/bin-spec.js +++ b/test/unit/bin.test.js @@ -1,6 +1,10 @@ import { main } from '../../bin/main.js'; +import { htmlIsEqual } from '@markedjs/testutils'; import { dirname, resolve } from 'node:path'; -import { fileURLToPath } from 'url'; +import { fileURLToPath } from 'node:url'; +import { describe, it, mock } from 'node:test'; +import assert from 'node:assert'; + const __dirname = dirname(fileURLToPath(import.meta.url)); function createMocks() { @@ -14,23 +18,23 @@ function createMocks() { end: null }, process: { - cwd: jasmine.createSpy('process.cwd').and.returnValue('/cwd'), + cwd: mock.fn(() => '/cwd'), env: [], argv: [], stdout: { - write: jasmine.createSpy('process.stdout.write').and.callFake((str) => { mocks.stdout += str; }) + write: mock.fn((str) => { mocks.stdout += str; }) }, stderr: { - write: jasmine.createSpy('process.stderr.write').and.callFake((str) => { mocks.stderr += str; }) + write: mock.fn((str) => { mocks.stderr += str; }) }, stdin: { - setEncoding: jasmine.createSpy('process.stdin.setEncoding'), - on: jasmine.createSpy('process.stdin.on').and.callFake((method, func) => { + setEncoding: mock.fn(), + on: mock.fn((method, func) => { mocks.stdin[method] = func; }), - resume: jasmine.createSpy('process.stdin.resume') + resume: mock.fn }, - exit: jasmine.createSpy('process.exit').and.callFake((code) => { mocks.code = code; }) + exit: mock.fn((code) => { mocks.code = code; }) } }; @@ -53,9 +57,9 @@ function testInput({ args = [], stdin = '', stdinError = '', stdout = '', stderr } await mainPromise; - await expectAsync(mocks.stdout).toEqualHtml(stdout); - expect(mocks.stderr).toEqual(stderr); - expect(mocks.code).toBe(code); + assert.ok(await htmlIsEqual(mocks.stdout, stdout)); + assert.strictEqual(mocks.stderr, stderr); + assert.strictEqual(mocks.code, code); }; } @@ -89,7 +93,7 @@ describe('bin/marked', () => { it('not found', testInput({ args: ['--config', fixturePath('does-not-exist.js'), '-s', 'line1\nline2'], - stderr: jasmine.stringContaining(`Cannot load config file '${fixturePath('does-not-exist.js')}'`), + stderr: `Error: Cannot load config file '${fixturePath('does-not-exist.js')}'`, code: 1 })); }); diff --git a/test/unit/instance-spec.js b/test/unit/instance.test.js similarity index 66% rename from test/unit/instance-spec.js rename to test/unit/instance.test.js index f4c12232a0..c7e525616d 100644 --- a/test/unit/instance-spec.js +++ b/test/unit/instance.test.js @@ -1,4 +1,6 @@ -import { marked, Marked, Renderer } from '../../src/marked.js'; +import { marked, Marked, Renderer } from '../../lib/marked.esm.js'; +import { describe, it } from 'node:test'; +import assert from 'node:assert'; describe('Marked', () => { it('should allow multiple instances', () => { @@ -20,9 +22,9 @@ describe('Marked', () => { } }); - expect(marked1.parse('# header')).toBe('im marked1'); - expect(marked2.parse('# header')).toBe('im marked2'); - expect(marked.parse('# header')).toBe('<h1>header</h1>\n'); + assert.strictEqual(marked1.parse('# header'), 'im marked1'); + assert.strictEqual(marked2.parse('# header'), 'im marked2'); + assert.strictEqual(marked.parse('# header'), '<h1>header</h1>\n'); }); it('should work with use', () => { @@ -46,9 +48,9 @@ describe('Marked', () => { } }); - expect(marked1.parse('# header')).toBe('im marked1'); - expect(marked2.parse('# header')).toBe('im marked2'); - expect(marked.parse('# header')).toBe('<h1>header</h1>\n'); + assert.strictEqual(marked1.parse('# header'), 'im marked1'); + assert.strictEqual(marked2.parse('# header'), 'im marked2'); + assert.strictEqual(marked.parse('# header'), '<h1>header</h1>\n'); }); it('should work with setOptions', () => { @@ -68,9 +70,9 @@ describe('Marked', () => { renderer: marked2Renderer }); - expect(marked1.parse('# header')).toBe('im marked1'); - expect(marked2.parse('# header')).toBe('im marked2'); - expect(marked.parse('# header')).toBe('<h1>header</h1>\n'); + assert.strictEqual(marked1.parse('# header'), 'im marked1'); + assert.strictEqual(marked2.parse('# header'), 'im marked2'); + assert.strictEqual(marked.parse('# header'), '<h1>header</h1>\n'); }); it('should pass defaults to lexer and parser', () => { @@ -85,6 +87,6 @@ describe('Marked', () => { const tokens = marked1.lexer('# hi'); const html = marked1.parser(tokens); - expect(html).toBe('test'); + assert.strictEqual(html, 'test'); }); }); diff --git a/test/unit/marked-spec.js b/test/unit/marked-spec.js deleted file mode 100644 index f6fe3686d9..0000000000 --- a/test/unit/marked-spec.js +++ /dev/null @@ -1,953 +0,0 @@ -import { marked, Renderer, lexer, parseInline, use, getDefaults, walkTokens, defaults, setOptions } from '../../src/marked.js'; -import { timeout } from './utils.js'; - -describe('Test paragraph token type', () => { - it('should use the "paragraph" type on top level', () => { - const md = 'A Paragraph.\n\n> A blockquote\n\n- list item\n'; - - const tokens = lexer(md); - - expect(tokens[0].type).toBe('paragraph'); - expect(tokens[2].tokens[0].type).toBe('paragraph'); - expect(tokens[3].items[0].tokens[0].type).toBe('text'); - }); -}); - -describe('changeDefaults', () => { - it('should change global defaults', async() => { - const { _defaults, changeDefaults } = await import('../../src/defaults.js'); - expect(_defaults.test).toBeUndefined(); - changeDefaults({ test: true }); - expect((await import('../../src/defaults.js'))._defaults.test).toBe(true); - }); -}); - -describe('inlineLexer', () => { - it('should send html to renderer.html', () => { - const renderer = new Renderer(); - spyOn(renderer, 'html').and.callThrough(); - const md = 'HTML Image: <img alt="MY IMAGE" src="example.png" />'; - marked(md, { renderer }); - - expect(renderer.html).toHaveBeenCalledWith('<img alt="MY IMAGE" src="example.png" />'); - }); -}); - -describe('task', () => { - it('space after checkbox', () => { - const html = marked('- [ ] item'); - - expect(html).toBe('<ul>\n<li><input disabled="" type="checkbox"> item</li>\n</ul>\n'); - }); - - it('space after loose checkbox', () => { - const html = marked('- [ ] item 1\n\n- [ ] item 2'); - - expect(html).toBe('<ul>\n<li><p><input disabled="" type="checkbox"> \nitem 1</p>\n</li>\n<li><p><input disabled="" type="checkbox"> \nitem 2</p>\n</li>\n</ul>\n'); - }); -}); - -describe('parseInline', () => { - it('should parse inline tokens', () => { - const md = '**strong** _em_'; - const html = parseInline(md); - - expect(html).toBe('<strong>strong</strong> <em>em</em>'); - }); - - it('should not parse block tokens', () => { - const md = '# header\n\n_em_'; - const html = parseInline(md); - - expect(html).toBe('# header\n\n<em>em</em>'); - }); -}); - -describe('use extension', () => { - it('should use custom block tokenizer + renderer extensions', () => { - const underline = { - name: 'underline', - level: 'block', - tokenizer(src) { - const rule = /^:([^\n]*)(?:\n|$)/; - const match = rule.exec(src); - if (match) { - return { - type: 'underline', - raw: match[0], // This is the text that you want your token to consume from the source - text: match[1].trim() // You can add additional properties to your tokens to pass along to the renderer - }; - } - }, - renderer(token) { - return `<u>${token.text}</u>\n`; - } - }; - use({ extensions: [underline] }); - let html = marked('Not Underlined\n:Underlined\nNot Underlined'); - expect(html).toBe('<p>Not Underlined\n:Underlined\nNot Underlined</p>\n'); - - html = marked('Not Underlined\n\n:Underlined\n\nNot Underlined'); - expect(html).toBe('<p>Not Underlined</p>\n<u>Underlined</u>\n<p>Not Underlined</p>\n'); - }); - - it('should interrupt paragraphs if using "start" property', () => { - const underline = { - extensions: [{ - name: 'underline', - level: 'block', - start(src) { return src.indexOf(':'); }, - tokenizer(src) { - const rule = /^:([^\n]*):(?:\n|$)/; - const match = rule.exec(src); - if (match) { - return { - type: 'underline', - raw: match[0], // This is the text that you want your token to consume from the source - text: match[1].trim() // You can add additional properties to your tokens to pass along to the renderer - }; - } - }, - renderer(token) { - return `<u>${token.text}</u>\n`; - } - }] - }; - use(underline); - const html = marked('Not Underlined A\n:Underlined B:\nNot Underlined C\n:Not Underlined D'); - expect(html).toBe('<p>Not Underlined A</p>\n<u>Underlined B</u>\n<p>Not Underlined C\n:Not Underlined D</p>\n'); - }); - - it('should use custom inline tokenizer + renderer extensions', () => { - const underline = { - name: 'underline', - level: 'inline', - start(src) { return src.indexOf('='); }, - tokenizer(src) { - const rule = /^=([^=]+)=/; - const match = rule.exec(src); - if (match) { - return { - type: 'underline', - raw: match[0], // This is the text that you want your token to consume from the source - text: match[1].trim() // You can add additional properties to your tokens to pass along to the renderer - }; - } - }, - renderer(token) { - return `<u>${token.text}</u>`; - } - }; - use({ extensions: [underline] }); - const html = marked('Not Underlined =Underlined= Not Underlined'); - expect(html).toBe('<p>Not Underlined <u>Underlined</u> Not Underlined</p>\n'); - }); - - it('should handle interacting block and inline extensions', () => { - const descriptionlist = { - name: 'descriptionList', - level: 'block', - start(src) { - const match = src.match(/:[^:\n]/); - if (match) { - return match.index; - } - }, - tokenizer(src, tokens) { - const rule = /^(?::[^:\n]+:[^:\n]*(?:\n|$))+/; - const match = rule.exec(src); - if (match) { - const token = { - type: 'descriptionList', - raw: match[0], // This is the text that you want your token to consume from the source - text: match[0].trim(), // You can add additional properties to your tokens to pass along to the renderer - tokens: [] - }; - this.lexer.inlineTokens(token.text, token.tokens); - return token; - } - }, - renderer(token) { - return `<dl>${this.parser.parseInline(token.tokens)}\n</dl>`; - } - }; - - const description = { - name: 'description', - level: 'inline', - start(src) { return src.indexOf(':'); }, - tokenizer(src, tokens) { - const rule = /^:([^:\n]+):([^:\n]*)(?:\n|$)/; - const match = rule.exec(src); - if (match) { - const token = { - type: 'description', - raw: match[0], - dt: [], - dd: [] - }; - this.lexer.inline(match[1].trim(), token.dt); - this.lexer.inline(match[2].trim(), token.dd); - return token; - } - }, - renderer(token) { - return `\n<dt>${this.parser.parseInline(token.dt)}</dt><dd>${this.parser.parseInline(token.dd)}</dd>`; - } - }; - use({ extensions: [descriptionlist, description] }); - const html = marked('A Description List with One Description:\n' - + ': Topic 1 : Description 1\n' - + ': **Topic 2** : *Description 2*'); - expect(html).toBe('<p>A Description List with One Description:</p>\n' - + '<dl>' - + '\n<dt>Topic 1</dt><dd>Description 1</dd>' - + '\n<dt><strong>Topic 2</strong></dt><dd><em>Description 2</em></dd>' - + '\n</dl>'); - }); - - it('should allow other options mixed into the extension', () => { - const extension = { - name: 'underline', - level: 'block', - start(src) { return src.indexOf(':'); }, - tokenizer(src) { - const rule = /^:([^\n]*):(?:\n|$)/; - const match = rule.exec(src); - if (match) { - return { - type: 'underline', - raw: match[0], // This is the text that you want your token to consume from the source - text: match[1].trim() // You can add additional properties to your tokens to pass along to the renderer - }; - } - }, - renderer(token) { - return `<u>${token.text}</u>\n`; - } - }; - use({ silent: true, extensions: [extension] }); - const html = marked(':test:\ntest\n<div></div>'); - expect(html).toBe('<u>test</u>\n<p>test</p>\n<div></div>'); - }); - - it('should handle renderers that return false', () => { - const extension = { - name: 'test', - level: 'block', - tokenizer(src) { - const rule = /^:([^\n]*):(?:\n|$)/; - const match = rule.exec(src); - if (match) { - return { - type: 'test', - raw: match[0], // This is the text that you want your token to consume from the source - text: match[1].trim() // You can add additional properties to your tokens to pass along to the renderer - }; - } - }, - renderer(token) { - if (token.text === 'test') { - return 'test'; - } - return false; - } - }; - const fallbackRenderer = { - name: 'test', - level: 'block', - renderer(token) { - if (token.text === 'Test') { - return 'fallback'; - } - return false; - } - }; - use({ extensions: [fallbackRenderer, extension] }); - const html = marked(':Test:\n\n:test:\n\n:none:'); - expect(html).toBe('fallbacktest'); - }); - - it('should fall back when tokenizers return false', () => { - const extension = { - name: 'test', - level: 'block', - tokenizer(src) { - const rule = /^:([^\n]*):(?:\n|$)/; - const match = rule.exec(src); - if (match) { - return { - type: 'test', - raw: match[0], // This is the text that you want your token to consume from the source - text: match[1].trim() // You can add additional properties to your tokens to pass along to the renderer - }; - } - return false; - }, - renderer(token) { - return token.text; - } - }; - const extension2 = { - name: 'test', - level: 'block', - tokenizer(src) { - const rule = /^:([^\n]*):(?:\n|$)/; - const match = rule.exec(src); - if (match) { - if (match[1].match(/^[A-Z]/)) { - return { - type: 'test', - raw: match[0], - text: match[1].trim().toUpperCase() - }; - } - } - return false; - } - }; - use({ extensions: [extension, extension2] }); - const html = marked(':Test:\n\n:test:'); - expect(html).toBe('TESTtest'); - }); - - it('should override original tokenizer/renderer with same name, but fall back if returns false', () => { - const extension = { - extensions: [{ - name: 'heading', - level: 'block', - tokenizer(src) { - return false; // fall back to default `heading` tokenizer - }, - renderer(token) { - return '<h' + token.depth + '>' + token.text + ' RENDERER EXTENSION</h' + token.depth + '>\n'; - } - }, - { - name: 'code', - level: 'block', - tokenizer(src) { - const rule = /^:([^\n]*):(?:\n|$)/; - const match = rule.exec(src); - if (match) { - return { - type: 'code', - raw: match[0], - text: match[1].trim() + ' TOKENIZER EXTENSION' - }; - } - }, - renderer(token) { - return false; // fall back to default `code` renderer - } - }] - }; - use(extension); - const html = marked('# extension1\n:extension2:'); - expect(html).toBe('<h1>extension1 RENDERER EXTENSION</h1>\n<pre><code>extension2 TOKENIZER EXTENSION\n</code></pre>\n'); - }); - - it('should walk only specified child tokens', () => { - const walkableDescription = { - extensions: [{ - name: 'walkableDescription', - level: 'inline', - start(src) { return src.indexOf(':'); }, - tokenizer(src, tokens) { - const rule = /^:([^:\n]+):([^:\n]*)(?:\n|$)/; - const match = rule.exec(src); - if (match) { - const token = { - type: 'walkableDescription', - raw: match[0], - dt: this.lexer.inline(match[1].trim()), - dd: [], - tokens: [] - }; - this.lexer.inline(match[2].trim(), token.dd); - this.lexer.inline('unwalked', token.tokens); - return token; - } - }, - renderer(token) { - return `\n<dt>${this.parser.parseInline(token.dt)} - ${this.parser.parseInline(token.tokens)}</dt><dd>${this.parser.parseInline(token.dd)}</dd>`; - }, - childTokens: ['dd', 'dt'] - }], - walkTokens(token) { - if (token.type === 'text') { - token.text += ' walked'; - } - } - }; - use(walkableDescription); - const html = marked(': Topic 1 : Description 1\n' - + ': **Topic 2** : *Description 2*'); - expect(html).toBe('<p>\n<dt>Topic 1 walked - unwalked</dt><dd>Description 1 walked</dd>' - + '\n<dt><strong>Topic 2 walked</strong> - unwalked</dt><dd><em>Description 2 walked</em></dd></p>\n'); - }); - - describe('multiple extensions', () => { - function createExtension(name) { - return { - extensions: [{ - name: `block-${name}`, - level: 'block', - start(src) { return src.indexOf('::'); }, - tokenizer(src, tokens) { - if (src.startsWith(`::${name}\n`)) { - const text = `:${name}`; - const token = { - type: `block-${name}`, - raw: `::${name}\n`, - text, - tokens: [] - }; - this.lexer.inline(token.text, token.tokens); - return token; - } - }, - renderer(token) { - return `<${token.type}>${this.parser.parseInline(token.tokens)}</${token.type}>\n`; - } - }, { - name: `inline-${name}`, - level: 'inline', - start(src) { return src.indexOf(':'); }, - tokenizer(src, tokens) { - if (src.startsWith(`:${name}`)) { - return { - type: `inline-${name}`, - raw: `:${name}`, - text: `used ${name}` - }; - } - }, - renderer(token) { - return token.text; - } - }], - tokenizer: { - heading(src) { - if (src.startsWith(`# ${name}`)) { - const token = { - type: 'heading', - raw: `# ${name}`, - text: `used ${name}`, - depth: 1, - tokens: [] - }; - this.lexer.inline(token.text, token.tokens); - return token; - } - return false; - } - }, - renderer: { - heading(text, depth, raw) { - if (text === name) { - return `<h${depth}>${text}</h${depth}>\n`; - } - return false; - } - }, - walkTokens(token) { - if (token.text === `used ${name}`) { - token.text += ' walked'; - } - } - }; - } - - function createFalseExtension(name) { - return { - extensions: [{ - name: `block-${name}`, - level: 'block', - start(src) { return src.indexOf('::'); }, - tokenizer(src, tokens) { - return false; - }, - renderer(token) { - return false; - } - }, { - name: `inline-${name}`, - level: 'inline', - start(src) { return src.indexOf(':'); }, - tokenizer(src, tokens) { - return false; - }, - renderer(token) { - return false; - } - }] - }; - } - - function runTest() { - const html = marked(` -::extension1 -::extension2 - -:extension1 -:extension2 - -# extension1 - -# extension2 - -# no extension -`); - - expect(`\n${html}\n`.replace(/\n+/g, '\n')).toBe(` -<block-extension1>used extension1 walked</block-extension1> -<block-extension2>used extension2 walked</block-extension2> -<p>used extension1 walked -used extension2 walked</p> -<h1>used extension1 walked</h1> -<h1>used extension2 walked</h1> -<h1>no extension</h1> -`); - } - - it('should merge extensions when calling marked.use multiple times', () => { - use(createExtension('extension1')); - use(createExtension('extension2')); - - runTest(); - }); - - it('should merge extensions when calling marked.use with multiple extensions', () => { - use( - createExtension('extension1'), - createExtension('extension2') - ); - - runTest(); - }); - - it('should fall back to any extensions with the same name if the first returns false', () => { - use( - createExtension('extension1'), - createExtension('extension2'), - createFalseExtension('extension1'), - createFalseExtension('extension2') - ); - - runTest(); - }); - - it('should merge extensions correctly', () => { - use( - {}, - { tokenizer: {} }, - { renderer: {} }, - { walkTokens: () => {} }, - { extensions: [] } - ); - - expect(() => marked('# test')).not.toThrow(); - }); - }); - - it('should be async if any extension in use args is async', () => { - use( - { async: true }, - { async: false } - ); - - expect(defaults.async).toBeTrue(); - }); - - it('should be async if any extension in use is async', () => { - use({ async: true }); - use({ async: false }); - - expect(defaults.async).toBeTrue(); - }); - - it('should reset async with setOptions', () => { - use({ async: true }); - setOptions({ async: false }); - - expect(defaults.async).toBeFalse(); - }); - - it('should return Promise if async', () => { - expect(marked('test', { async: true })).toBeInstanceOf(Promise); - }); - - it('should return string if not async', () => { - expect(typeof marked('test', { async: false })).toBe('string'); - }); - - it('should return Promise if async is set by extension', () => { - use({ async: true }); - - expect(marked('test', { async: false })).toBeInstanceOf(Promise); - }); - - it('should allow deleting/editing tokens', () => { - const styleTags = { - extensions: [{ - name: 'inlineStyleTag', - level: 'inline', - start(src) { - const match = src.match(/ *{[^\{]/); - if (match) { - return match.index; - } - }, - tokenizer(src, tokens) { - const rule = /^ *{([^\{\}\n]+)}$/; - const match = rule.exec(src); - if (match) { - return { - type: 'inlineStyleTag', - raw: match[0], // This is the text that you want your token to consume from the source - text: match[1] - }; - } - } - }, - { - name: 'styled', - renderer(token) { - token.type = token.originalType; - const text = this.parser.parse([token]); - const openingTag = /(<[^\s<>]+)([^\n<>]*>.*)/s.exec(text); - if (openingTag) { - return `${openingTag[1]} ${token.style}${openingTag[2]}`; - } - return text; - } - }], - walkTokens(token) { - if (token.tokens) { - const finalChildToken = token.tokens[token.tokens.length - 1]; - if (finalChildToken && finalChildToken.type === 'inlineStyleTag') { - token.originalType = token.type; - token.type = 'styled'; - token.style = `style="color:${finalChildToken.text};"`; - token.tokens.pop(); - } - } - } - }; - use(styleTags); - const html = marked('This is a *paragraph* with blue text. {blue}\n' - + '# This is a *header* with red text {red}'); - expect(html).toBe('<p style="color:blue;">This is a <em>paragraph</em> with blue text.</p>\n' - + '<h1 style="color:red;">This is a <em>header</em> with red text</h1>\n'); - }); - - it('should use renderer', () => { - const extension = { - renderer: { - paragraph(text) { - return 'extension'; - } - } - }; - spyOn(extension.renderer, 'paragraph').and.callThrough(); - use(extension); - const html = marked('text'); - expect(extension.renderer.paragraph).toHaveBeenCalledWith('text'); - expect(html).toBe('extension'); - }); - - it('should use tokenizer', () => { - const extension = { - tokenizer: { - paragraph(text) { - const token = { - type: 'paragraph', - raw: text, - text: 'extension', - tokens: [] - }; - this.lexer.inline(token.text, token.tokens); - return token; - } - } - }; - spyOn(extension.tokenizer, 'paragraph').and.callThrough(); - use(extension); - const html = marked('text'); - expect(extension.tokenizer.paragraph).toHaveBeenCalledWith('text'); - expect(html).toBe('<p>extension</p>\n'); - }); - - it('should use walkTokens', () => { - let walked = 0; - const extension = { - walkTokens(token) { - walked++; - } - }; - use(extension); - marked('text'); - expect(walked).toBe(2); - }); - - it('should use options from extension', () => { - const extension = { - breaks: true - }; - use(extension); - const html = marked('line1\nline2'); - expect(html).toBe('<p>line1<br>line2</p>\n'); - }); - - it('should call all walkTokens in reverse order', () => { - let walkedOnce = 0; - let walkedTwice = 0; - const extension1 = { - walkTokens(token) { - if (token.walkedOnce) { - walkedTwice++; - } - } - }; - const extension2 = { - walkTokens(token) { - walkedOnce++; - token.walkedOnce = true; - } - }; - use(extension1); - use(extension2); - marked('text'); - expect(walkedOnce).toBe(2); - expect(walkedTwice).toBe(2); - }); - - it('should use last extension function and not override others', () => { - const extension1 = { - renderer: { - paragraph(text) { - return 'extension1 paragraph\n'; - }, - html(html) { - return 'extension1 html\n'; - } - } - }; - const extension2 = { - renderer: { - paragraph(text) { - return 'extension2 paragraph\n'; - } - } - }; - use(extension1); - use(extension2); - const html = marked(` -paragraph - -<html /> - -# heading -`); - expect(html).toBe('extension2 paragraph\nextension1 html\n<h1>heading</h1>\n'); - }); - - it('should use previous extension when returning false', () => { - const extension1 = { - renderer: { - paragraph(text) { - if (text !== 'original') { - return 'extension1 paragraph\n'; - } - return false; - } - } - }; - const extension2 = { - renderer: { - paragraph(text) { - if (text !== 'extension1' && text !== 'original') { - return 'extension2 paragraph\n'; - } - return false; - } - } - }; - use(extension1); - use(extension2); - const html = marked(` -paragraph - -extension1 - -original -`); - expect(html).toBe('extension2 paragraph\nextension1 paragraph\n<p>original</p>\n'); - }); - - it('should get options with this.options', () => { - const extension = { - renderer: { - heading: () => { - return this && this.options ? 'arrow options\n' : 'arrow no options\n'; - }, - html: function() { - return this.options ? 'function options\n' : 'function no options\n'; - }, - paragraph() { - return this.options ? 'shorthand options\n' : 'shorthand no options\n'; - } - } - }; - use(extension); - const html = marked(` -# heading - -<html /> - -paragraph -`); - expect(html).toBe('arrow no options\nfunction options\nshorthand options\n'); - }); -}); - -describe('walkTokens', () => { - it('should walk over every token', () => { - const markdown = ` -paragraph - ---- - -# heading - -\`\`\` -code -\`\`\` - -| a | b | -|---|---| -| 1 | 2 | -| 3 | 4 | - -> blockquote - -- list - -<div>html</div> - -[link](https://example.com) - -![image](https://example.com/image.jpg) - -**strong** - -*em* - -\`codespan\` - -~~del~~ - -br -br -`; - const tokens = lexer(markdown, { ...getDefaults(), breaks: true }); - const tokensSeen = []; - walkTokens(tokens, (token) => { - tokensSeen.push([token.type, (token.raw || '').replace(/\n/g, '')]); - }); - - expect(tokensSeen).toEqual([ - ['space', ''], - ['paragraph', 'paragraph'], - ['text', 'paragraph'], - ['space', ''], - ['hr', '---'], - ['heading', '# heading'], - ['text', 'heading'], - ['code', '```code```'], - ['space', ''], - ['table', '| a | b ||---|---|| 1 | 2 || 3 | 4 |'], - ['text', 'a'], - ['text', 'b'], - ['text', '1'], - ['text', '2'], - ['text', '3'], - ['text', '4'], - ['blockquote', '> blockquote'], - ['paragraph', 'blockquote'], - ['text', 'blockquote'], - ['list', '- list'], - ['list_item', '- list'], - ['text', 'list'], - ['text', 'list'], - ['space', ''], - ['html', '<div>html</div>'], - ['paragraph', '[link](https://example.com)'], - ['link', '[link](https://example.com)'], - ['text', 'link'], - ['space', ''], - ['paragraph', '![image](https://example.com/image.jpg)'], - ['image', '![image](https://example.com/image.jpg)'], - ['space', ''], - ['paragraph', '**strong**'], - ['strong', '**strong**'], - ['text', 'strong'], - ['space', ''], - ['paragraph', '*em*'], - ['em', '*em*'], - ['text', 'em'], - ['space', ''], - ['paragraph', '`codespan`'], - ['codespan', '`codespan`'], - ['space', ''], - ['paragraph', '~~del~~'], - ['del', '~~del~~'], - ['text', 'del'], - ['space', ''], - ['paragraph', 'brbr'], - ['text', 'br'], - ['br', ''], - ['text', 'br'] - ]); - }); - - it('should assign marked to `this`', () => { - marked.use({ - walkTokens(token) { - if (token.type === 'em') { - token.text += ' walked'; - token.tokens = this.Lexer.lexInline(token.text); - } - } - }); - expect(marked('*text*').trim()).toBe('<p><em>text walked</em></p>'); - }); - - it('should wait for async `walkTokens` function', async() => { - marked.use({ - async: true, - async walkTokens(token) { - if (token.type === 'em') { - await timeout(); - token.text += ' walked'; - token.tokens = this.Lexer.lexInline(token.text); - } - } - }); - const promise = marked('*text*'); - expect(promise).toBeInstanceOf(Promise); - const html = await promise; - expect(html.trim()).toBe('<p><em>text walked</em></p>'); - }); - - it('should return promise if async and no walkTokens function', async() => { - marked.use({ - async: true - }); - const promise = marked('*text*'); - expect(promise).toBeInstanceOf(Promise); - const html = await promise; - expect(html.trim()).toBe('<p><em>text</em></p>'); - }); -}); diff --git a/test/unit/marked.test.js b/test/unit/marked.test.js new file mode 100644 index 0000000000..5bfc80b754 --- /dev/null +++ b/test/unit/marked.test.js @@ -0,0 +1,963 @@ +import { Marked, Renderer, lexer, parseInline, getDefaults, walkTokens, defaults, setOptions } from '../../lib/marked.esm.js'; +import { timeout } from './utils.js'; +import assert from 'node:assert'; +import { describe, it, beforeEach, mock } from 'node:test'; + +describe('marked unit', () => { + let marked; + beforeEach(() => { + marked = new Marked(); + }); + + describe('Test paragraph token type', () => { + it('should use the "paragraph" type on top level', () => { + const md = 'A Paragraph.\n\n> A blockquote\n\n- list item\n'; + + const tokens = lexer(md); + + assert.strictEqual(tokens[0].type, 'paragraph'); + assert.strictEqual(tokens[2].tokens[0].type, 'paragraph'); + assert.strictEqual(tokens[3].items[0].tokens[0].type, 'text'); + }); + }); + + describe('changeDefaults', () => { + it('should change global defaults', async() => { + const { defaults, setOptions } = await import('../../lib/marked.esm.js'); + assert.ok(!defaults.test); + setOptions({ test: true }); + assert.ok((await import('../../lib/marked.esm.js')).defaults.test); + }); + }); + + describe('inlineLexer', () => { + it('should send html to renderer.html', () => { + const renderer = new Renderer(); + mock.method(renderer, 'html'); + const md = 'HTML Image: <img alt="MY IMAGE" src="example.png" />'; + marked.parse(md, { renderer }); + + assert.strictEqual(renderer.html.mock.calls[0].arguments[0], '<img alt="MY IMAGE" src="example.png" />'); + }); + }); + + describe('task', () => { + it('space after checkbox', () => { + const html = marked.parse('- [ ] item'); + + assert.strictEqual(html, '<ul>\n<li><input disabled="" type="checkbox"> item</li>\n</ul>\n'); + }); + + it('space after loose checkbox', () => { + const html = marked.parse('- [ ] item 1\n\n- [ ] item 2'); + + assert.strictEqual(html, '<ul>\n<li><p><input disabled="" type="checkbox"> \nitem 1</p>\n</li>\n<li><p><input disabled="" type="checkbox"> \nitem 2</p>\n</li>\n</ul>\n'); + }); + }); + + describe('parseInline', () => { + it('should parse inline tokens', () => { + const md = '**strong** _em_'; + const html = parseInline(md); + + assert.strictEqual(html, '<strong>strong</strong> <em>em</em>'); + }); + + it('should not parse block tokens', () => { + const md = '# header\n\n_em_'; + const html = parseInline(md); + + assert.strictEqual(html, '# header\n\n<em>em</em>'); + }); + }); + + describe('use extension', () => { + it('should use custom block tokenizer + renderer extensions', () => { + const underline = { + name: 'underline', + level: 'block', + tokenizer(src) { + const rule = /^:([^\n]*)(?:\n|$)/; + const match = rule.exec(src); + if (match) { + return { + type: 'underline', + raw: match[0], // This is the text that you want your token to consume from the source + text: match[1].trim() // You can add additional properties to your tokens to pass along to the renderer + }; + } + }, + renderer(token) { + return `<u>${token.text}</u>\n`; + } + }; + marked.use({ extensions: [underline] }); + let html = marked.parse('Not Underlined\n:Underlined\nNot Underlined'); + assert.strictEqual(html, '<p>Not Underlined\n:Underlined\nNot Underlined</p>\n'); + + html = marked.parse('Not Underlined\n\n:Underlined\n\nNot Underlined'); + assert.strictEqual(html, '<p>Not Underlined</p>\n<u>Underlined</u>\n<p>Not Underlined</p>\n'); + }); + + it('should interrupt paragraphs if using "start" property', () => { + const underline = { + extensions: [{ + name: 'underline', + level: 'block', + start(src) { return src.indexOf(':'); }, + tokenizer(src) { + const rule = /^:([^\n]*):(?:\n|$)/; + const match = rule.exec(src); + if (match) { + return { + type: 'underline', + raw: match[0], // This is the text that you want your token to consume from the source + text: match[1].trim() // You can add additional properties to your tokens to pass along to the renderer + }; + } + }, + renderer(token) { + return `<u>${token.text}</u>\n`; + } + }] + }; + marked.use(underline); + const html = marked.parse('Not Underlined A\n:Underlined B:\nNot Underlined C\n:Not Underlined D'); + assert.strictEqual(html, '<p>Not Underlined A</p>\n<u>Underlined B</u>\n<p>Not Underlined C\n:Not Underlined D</p>\n'); + }); + + it('should use custom inline tokenizer + renderer extensions', () => { + const underline = { + name: 'underline', + level: 'inline', + start(src) { return src.indexOf('='); }, + tokenizer(src) { + const rule = /^=([^=]+)=/; + const match = rule.exec(src); + if (match) { + return { + type: 'underline', + raw: match[0], // This is the text that you want your token to consume from the source + text: match[1].trim() // You can add additional properties to your tokens to pass along to the renderer + }; + } + }, + renderer(token) { + return `<u>${token.text}</u>`; + } + }; + marked.use({ extensions: [underline] }); + const html = marked.parse('Not Underlined =Underlined= Not Underlined'); + assert.strictEqual(html, '<p>Not Underlined <u>Underlined</u> Not Underlined</p>\n'); + }); + + it('should handle interacting block and inline extensions', () => { + const descriptionlist = { + name: 'descriptionList', + level: 'block', + start(src) { + const match = src.match(/:[^:\n]/); + if (match) { + return match.index; + } + }, + tokenizer(src, tokens) { + const rule = /^(?::[^:\n]+:[^:\n]*(?:\n|$))+/; + const match = rule.exec(src); + if (match) { + const token = { + type: 'descriptionList', + raw: match[0], // This is the text that you want your token to consume from the source + text: match[0].trim(), // You can add additional properties to your tokens to pass along to the renderer + tokens: [] + }; + this.lexer.inlineTokens(token.text, token.tokens); + return token; + } + }, + renderer(token) { + return `<dl>${this.parser.parseInline(token.tokens)}\n</dl>`; + } + }; + + const description = { + name: 'description', + level: 'inline', + start(src) { return src.indexOf(':'); }, + tokenizer(src, tokens) { + const rule = /^:([^:\n]+):([^:\n]*)(?:\n|$)/; + const match = rule.exec(src); + if (match) { + const token = { + type: 'description', + raw: match[0], + dt: [], + dd: [] + }; + this.lexer.inline(match[1].trim(), token.dt); + this.lexer.inline(match[2].trim(), token.dd); + return token; + } + }, + renderer(token) { + return `\n<dt>${this.parser.parseInline(token.dt)}</dt><dd>${this.parser.parseInline(token.dd)}</dd>`; + } + }; + marked.use({ extensions: [descriptionlist, description] }); + const html = marked.parse('A Description List with One Description:\n' + + ': Topic 1 : Description 1\n' + + ': **Topic 2** : *Description 2*'); + assert.strictEqual(html, '<p>A Description List with One Description:</p>\n' + + '<dl>' + + '\n<dt>Topic 1</dt><dd>Description 1</dd>' + + '\n<dt><strong>Topic 2</strong></dt><dd><em>Description 2</em></dd>' + + '\n</dl>'); + }); + + it('should allow other options mixed into the extension', () => { + const extension = { + name: 'underline', + level: 'block', + start(src) { return src.indexOf(':'); }, + tokenizer(src) { + const rule = /^:([^\n]*):(?:\n|$)/; + const match = rule.exec(src); + if (match) { + return { + type: 'underline', + raw: match[0], // This is the text that you want your token to consume from the source + text: match[1].trim() // You can add additional properties to your tokens to pass along to the renderer + }; + } + }, + renderer(token) { + return `<u>${token.text}</u>\n`; + } + }; + marked.use({ silent: true, extensions: [extension] }); + const html = marked.parse(':test:\ntest\n<div></div>'); + assert.strictEqual(html, '<u>test</u>\n<p>test</p>\n<div></div>'); + }); + + it('should handle renderers that return false', () => { + const extension = { + name: 'test', + level: 'block', + tokenizer(src) { + const rule = /^:([^\n]*):(?:\n|$)/; + const match = rule.exec(src); + if (match) { + return { + type: 'test', + raw: match[0], // This is the text that you want your token to consume from the source + text: match[1].trim() // You can add additional properties to your tokens to pass along to the renderer + }; + } + }, + renderer(token) { + if (token.text === 'test') { + return 'test'; + } + return false; + } + }; + const fallbackRenderer = { + name: 'test', + level: 'block', + renderer(token) { + if (token.text === 'Test') { + return 'fallback'; + } + return false; + } + }; + marked.use({ extensions: [fallbackRenderer, extension] }); + const html = marked.parse(':Test:\n\n:test:\n\n:none:'); + assert.strictEqual(html, 'fallbacktest'); + }); + + it('should fall back when tokenizers return false', () => { + const extension = { + name: 'test', + level: 'block', + tokenizer(src) { + const rule = /^:([^\n]*):(?:\n|$)/; + const match = rule.exec(src); + if (match) { + return { + type: 'test', + raw: match[0], // This is the text that you want your token to consume from the source + text: match[1].trim() // You can add additional properties to your tokens to pass along to the renderer + }; + } + return false; + }, + renderer(token) { + return token.text; + } + }; + const extension2 = { + name: 'test', + level: 'block', + tokenizer(src) { + const rule = /^:([^\n]*):(?:\n|$)/; + const match = rule.exec(src); + if (match) { + if (match[1].match(/^[A-Z]/)) { + return { + type: 'test', + raw: match[0], + text: match[1].trim().toUpperCase() + }; + } + } + return false; + } + }; + marked.use({ extensions: [extension, extension2] }); + const html = marked.parse(':Test:\n\n:test:'); + assert.strictEqual(html, 'TESTtest'); + }); + + it('should override original tokenizer/renderer with same name, but fall back if returns false', () => { + const extension = { + extensions: [{ + name: 'heading', + level: 'block', + tokenizer(src) { + return false; // fall back to default `heading` tokenizer + }, + renderer(token) { + return '<h' + token.depth + '>' + token.text + ' RENDERER EXTENSION</h' + token.depth + '>\n'; + } + }, + { + name: 'code', + level: 'block', + tokenizer(src) { + const rule = /^:([^\n]*):(?:\n|$)/; + const match = rule.exec(src); + if (match) { + return { + type: 'code', + raw: match[0], + text: match[1].trim() + ' TOKENIZER EXTENSION' + }; + } + }, + renderer(token) { + return false; // fall back to default `code` renderer + } + }] + }; + marked.use(extension); + const html = marked.parse('# extension1\n:extension2:'); + assert.strictEqual(html, '<h1>extension1 RENDERER EXTENSION</h1>\n<pre><code>extension2 TOKENIZER EXTENSION\n</code></pre>\n'); + }); + + it('should walk only specified child tokens', () => { + const walkableDescription = { + extensions: [{ + name: 'walkableDescription', + level: 'inline', + start(src) { return src.indexOf(':'); }, + tokenizer(src, tokens) { + const rule = /^:([^:\n]+):([^:\n]*)(?:\n|$)/; + const match = rule.exec(src); + if (match) { + const token = { + type: 'walkableDescription', + raw: match[0], + dt: this.lexer.inline(match[1].trim()), + dd: [], + tokens: [] + }; + this.lexer.inline(match[2].trim(), token.dd); + this.lexer.inline('unwalked', token.tokens); + return token; + } + }, + renderer(token) { + return `\n<dt>${this.parser.parseInline(token.dt)} - ${this.parser.parseInline(token.tokens)}</dt><dd>${this.parser.parseInline(token.dd)}</dd>`; + }, + childTokens: ['dd', 'dt'] + }], + walkTokens(token) { + if (token.type === 'text') { + token.text += ' walked'; + } + } + }; + marked.use(walkableDescription); + const html = marked.parse(': Topic 1 : Description 1\n' + + ': **Topic 2** : *Description 2*'); + assert.strictEqual(html, '<p>\n<dt>Topic 1 walked - unwalked</dt><dd>Description 1 walked</dd>' + + '\n<dt><strong>Topic 2 walked</strong> - unwalked</dt><dd><em>Description 2 walked</em></dd></p>\n'); + }); + + describe('multiple extensions', () => { + function createExtension(name) { + return { + extensions: [{ + name: `block-${name}`, + level: 'block', + start(src) { return src.indexOf('::'); }, + tokenizer(src, tokens) { + if (src.startsWith(`::${name}\n`)) { + const text = `:${name}`; + const token = { + type: `block-${name}`, + raw: `::${name}\n`, + text, + tokens: [] + }; + this.lexer.inline(token.text, token.tokens); + return token; + } + }, + renderer(token) { + return `<${token.type}>${this.parser.parseInline(token.tokens)}</${token.type}>\n`; + } + }, { + name: `inline-${name}`, + level: 'inline', + start(src) { return src.indexOf(':'); }, + tokenizer(src, tokens) { + if (src.startsWith(`:${name}`)) { + return { + type: `inline-${name}`, + raw: `:${name}`, + text: `used ${name}` + }; + } + }, + renderer(token) { + return token.text; + } + }], + tokenizer: { + heading(src) { + if (src.startsWith(`# ${name}`)) { + const token = { + type: 'heading', + raw: `# ${name}`, + text: `used ${name}`, + depth: 1, + tokens: [] + }; + this.lexer.inline(token.text, token.tokens); + return token; + } + return false; + } + }, + renderer: { + heading(text, depth, raw) { + if (text === name) { + return `<h${depth}>${text}</h${depth}>\n`; + } + return false; + } + }, + walkTokens(token) { + if (token.text === `used ${name}`) { + token.text += ' walked'; + } + } + }; + } + + function createFalseExtension(name) { + return { + extensions: [{ + name: `block-${name}`, + level: 'block', + start(src) { return src.indexOf('::'); }, + tokenizer(src, tokens) { + return false; + }, + renderer(token) { + return false; + } + }, { + name: `inline-${name}`, + level: 'inline', + start(src) { return src.indexOf(':'); }, + tokenizer(src, tokens) { + return false; + }, + renderer(token) { + return false; + } + }] + }; + } + + function runTest() { + const html = marked.parse(` +::extension1 +::extension2 + +:extension1 +:extension2 + +# extension1 + +# extension2 + +# no extension +`); + + assert.strictEqual(`\n${html}\n`.replace(/\n+/g, '\n'), ` +<block-extension1>used extension1 walked</block-extension1> +<block-extension2>used extension2 walked</block-extension2> +<p>used extension1 walked +used extension2 walked</p> +<h1>used extension1 walked</h1> +<h1>used extension2 walked</h1> +<h1>no extension</h1> +`); + } + + it('should merge extensions when calling marked.use multiple times', () => { + marked.use(createExtension('extension1')); + marked.use(createExtension('extension2')); + + runTest(); + }); + + it('should merge extensions when calling marked.use with multiple extensions', () => { + marked.use( + createExtension('extension1'), + createExtension('extension2') + ); + + runTest(); + }); + + it('should fall back to any extensions with the same name if the first returns false', () => { + marked.use( + createExtension('extension1'), + createExtension('extension2'), + createFalseExtension('extension1'), + createFalseExtension('extension2') + ); + + runTest(); + }); + + it('should merge extensions correctly', () => { + marked.use( + {}, + { tokenizer: {} }, + { renderer: {} }, + { walkTokens: () => {} }, + { extensions: [] } + ); + + // should not throw + marked.parse('# test'); + }); + }); + + it('should be async if any extension in use args is async', () => { + marked.use( + { async: true }, + { async: false } + ); + + assert.ok(marked.defaults.async); + }); + + it.only('should be async if any extension in use is async', () => { + marked.use({ async: true }); + marked.use({ async: false }); + + assert.ok(marked.defaults.async); + }); + + it('should reset async with setOptions', () => { + marked.use({ async: true }); + setOptions({ async: false }); + + assert.ok(!defaults.async); + }); + + it('should return Promise if async', () => { + assert.ok(marked.parse('test', { async: true }) instanceof Promise); + }); + + it('should return string if not async', () => { + assert.strictEqual(typeof marked.parse('test', { async: false }), 'string'); + }); + + it('should return Promise if async is set by extension', () => { + marked.use({ async: true }); + + assert.ok(marked.parse('test', { async: false }) instanceof Promise); + }); + + it('should allow deleting/editing tokens', () => { + const styleTags = { + extensions: [{ + name: 'inlineStyleTag', + level: 'inline', + start(src) { + const match = src.match(/ *{[^\{]/); + if (match) { + return match.index; + } + }, + tokenizer(src, tokens) { + const rule = /^ *{([^\{\}\n]+)}$/; + const match = rule.exec(src); + if (match) { + return { + type: 'inlineStyleTag', + raw: match[0], // This is the text that you want your token to consume from the source + text: match[1] + }; + } + } + }, + { + name: 'styled', + renderer(token) { + token.type = token.originalType; + const text = this.parser.parse([token]); + const openingTag = /(<[^\s<>]+)([^\n<>]*>.*)/s.exec(text); + if (openingTag) { + return `${openingTag[1]} ${token.style}${openingTag[2]}`; + } + return text; + } + }], + walkTokens(token) { + if (token.tokens) { + const finalChildToken = token.tokens[token.tokens.length - 1]; + if (finalChildToken && finalChildToken.type === 'inlineStyleTag') { + token.originalType = token.type; + token.type = 'styled'; + token.style = `style="color:${finalChildToken.text};"`; + token.tokens.pop(); + } + } + } + }; + marked.use(styleTags); + const html = marked.parse('This is a *paragraph* with blue text. {blue}\n' + + '# This is a *header* with red text {red}'); + assert.strictEqual(html, '<p style="color:blue;">This is a <em>paragraph</em> with blue text.</p>\n' + + '<h1 style="color:red;">This is a <em>header</em> with red text</h1>\n'); + }); + + it('should use renderer', () => { + const extension = { + renderer: { + paragraph(text) { + return 'extension'; + } + } + }; + mock.method(extension.renderer, 'paragraph'); + marked.use(extension); + const html = marked.parse('text'); + assert.strictEqual(extension.renderer.paragraph.mock.calls[0].arguments[0], 'text'); + assert.strictEqual(html, 'extension'); + }); + + it('should use tokenizer', () => { + const extension = { + tokenizer: { + paragraph(text) { + const token = { + type: 'paragraph', + raw: text, + text: 'extension', + tokens: [] + }; + this.lexer.inline(token.text, token.tokens); + return token; + } + } + }; + mock.method(extension.tokenizer, 'paragraph'); + marked.use(extension); + const html = marked.parse('text'); + assert.strictEqual(extension.tokenizer.paragraph.mock.calls[0].arguments[0], 'text'); + assert.strictEqual(html, '<p>extension</p>\n'); + }); + + it('should use walkTokens', () => { + let walked = 0; + const extension = { + walkTokens(token) { + walked++; + } + }; + marked.use(extension); + marked.parse('text'); + assert.strictEqual(walked, 2); + }); + + it('should use options from extension', () => { + const extension = { + breaks: true + }; + marked.use(extension); + const html = marked.parse('line1\nline2'); + assert.strictEqual(html, '<p>line1<br>line2</p>\n'); + }); + + it('should call all walkTokens in reverse order', () => { + let walkedOnce = 0; + let walkedTwice = 0; + const extension1 = { + walkTokens(token) { + if (token.walkedOnce) { + walkedTwice++; + } + } + }; + const extension2 = { + walkTokens(token) { + walkedOnce++; + token.walkedOnce = true; + } + }; + marked.use(extension1); + marked.use(extension2); + marked.parse('text'); + assert.strictEqual(walkedOnce, 2); + assert.strictEqual(walkedTwice, 2); + }); + + it('should use last extension function and not override others', () => { + const extension1 = { + renderer: { + paragraph(text) { + return 'extension1 paragraph\n'; + }, + html(html) { + return 'extension1 html\n'; + } + } + }; + const extension2 = { + renderer: { + paragraph(text) { + return 'extension2 paragraph\n'; + } + } + }; + marked.use(extension1); + marked.use(extension2); + const html = marked.parse(` +paragraph + +<html /> + +# heading +`); + assert.strictEqual(html, 'extension2 paragraph\nextension1 html\n<h1>heading</h1>\n'); + }); + + it('should use previous extension when returning false', () => { + const extension1 = { + renderer: { + paragraph(text) { + if (text !== 'original') { + return 'extension1 paragraph\n'; + } + return false; + } + } + }; + const extension2 = { + renderer: { + paragraph(text) { + if (text !== 'extension1' && text !== 'original') { + return 'extension2 paragraph\n'; + } + return false; + } + } + }; + marked.use(extension1); + marked.use(extension2); + const html = marked.parse(` +paragraph + +extension1 + +original +`); + assert.strictEqual(html, 'extension2 paragraph\nextension1 paragraph\n<p>original</p>\n'); + }); + + it('should get options with this.options', () => { + const extension = { + renderer: { + heading: () => { + return this && this.options ? 'arrow options\n' : 'arrow no options\n'; + }, + html: function() { + return this.options ? 'function options\n' : 'function no options\n'; + }, + paragraph() { + return this.options ? 'shorthand options\n' : 'shorthand no options\n'; + } + } + }; + marked.use(extension); + const html = marked.parse(` +# heading + +<html /> + +paragraph +`); + assert.strictEqual(html, 'arrow no options\nfunction options\nshorthand options\n'); + }); + }); + + describe('walkTokens', () => { + it('should walk over every token', () => { + const markdown = ` +paragraph + +--- + +# heading + +\`\`\` +code +\`\`\` + +| a | b | +|---|---| +| 1 | 2 | +| 3 | 4 | + +> blockquote + +- list + +<div>html</div> + +[link](https://example.com) + +![image](https://example.com/image.jpg) + +**strong** + +*em* + +\`codespan\` + +~~del~~ + +br +br +`; + const tokens = lexer(markdown, { ...getDefaults(), breaks: true }); + const tokensSeen = []; + walkTokens(tokens, (token) => { + tokensSeen.push([token.type, (token.raw || '').replace(/\n/g, '')]); + }); + + assert.deepEqual(tokensSeen, [ + ['space', ''], + ['paragraph', 'paragraph'], + ['text', 'paragraph'], + ['space', ''], + ['hr', '---'], + ['heading', '# heading'], + ['text', 'heading'], + ['code', '```code```'], + ['space', ''], + ['table', '| a | b ||---|---|| 1 | 2 || 3 | 4 |'], + ['text', 'a'], + ['text', 'b'], + ['text', '1'], + ['text', '2'], + ['text', '3'], + ['text', '4'], + ['blockquote', '> blockquote'], + ['paragraph', 'blockquote'], + ['text', 'blockquote'], + ['list', '- list'], + ['list_item', '- list'], + ['text', 'list'], + ['text', 'list'], + ['space', ''], + ['html', '<div>html</div>'], + ['paragraph', '[link](https://example.com)'], + ['link', '[link](https://example.com)'], + ['text', 'link'], + ['space', ''], + ['paragraph', '![image](https://example.com/image.jpg)'], + ['image', '![image](https://example.com/image.jpg)'], + ['space', ''], + ['paragraph', '**strong**'], + ['strong', '**strong**'], + ['text', 'strong'], + ['space', ''], + ['paragraph', '*em*'], + ['em', '*em*'], + ['text', 'em'], + ['space', ''], + ['paragraph', '`codespan`'], + ['codespan', '`codespan`'], + ['space', ''], + ['paragraph', '~~del~~'], + ['del', '~~del~~'], + ['text', 'del'], + ['space', ''], + ['paragraph', 'brbr'], + ['text', 'br'], + ['br', ''], + ['text', 'br'] + ]); + }); + + it('should assign marked to `this`', () => { + marked.use({ + walkTokens(token) { + if (token.type === 'em') { + token.text += ' walked'; + token.tokens = this.Lexer.lexInline(token.text); + } + } + }); + assert.strictEqual(marked.parse('*text*').trim(), '<p><em>text walked</em></p>'); + }); + + it('should wait for async `walkTokens` function', async() => { + marked.use({ + async: true, + async walkTokens(token) { + if (token.type === 'em') { + await timeout(); + token.text += ' walked'; + token.tokens = this.Lexer.lexInline(token.text); + } + } + }); + const promise = marked.parse('*text*'); + assert.ok(promise instanceof Promise); + const html = await promise; + assert.strictEqual(html.trim(), '<p><em>text walked</em></p>'); + }); + + it('should return promise if async and no walkTokens function', async() => { + marked.use({ + async: true + }); + const promise = marked.parse('*text*'); + assert.ok(promise instanceof Promise); + const html = await promise; + assert.strictEqual(html.trim(), '<p><em>text</em></p>'); + }); + }); +}); diff --git a/test/update-specs.js b/test/update-specs.js index 470b74a03f..b3aad7f0dd 100644 --- a/test/update-specs.js +++ b/test/update-specs.js @@ -1,9 +1,12 @@ import fetch from 'node-fetch'; import { load } from 'cheerio'; -import marked from '../'; -import { isEqual } from './helpers/html-differ.js'; -import { readdirSync, unlinkSync, writeFileSync } from 'fs'; -import { join, resolve } from 'path'; +import { Marked } from '../lib/marked.esm.js'; +import { htmlIsEqual } from '@markedjs/testutils'; +import { readdirSync, unlinkSync, writeFileSync } from 'node:fs'; +import { join, resolve, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); function removeFiles(dir) { readdirSync(dir).forEach(file => { @@ -17,13 +20,16 @@ async function updateCommonmark(dir, options) { const pkg = await res.json(); const version = pkg.version.replace(/^(\d+\.\d+).*$/, '$1'); const res2 = await fetch(`https://spec.commonmark.org/${version}/spec.json`); - const specs = await res2.json(); - specs.forEach(spec => { - const html = marked(spec.markdown, options); - if (!isEqual(html, spec.html)) { + const json = await res2.json(); + const specs = await Promise.all(json.map(async(spec) => { + const marked = new Marked(); + const html = marked.parse(spec.markdown, options); + const isEqual = await htmlIsEqual(html, spec.html); + if (!isEqual) { spec.shouldFail = true; } - }); + return spec; + })); writeFileSync(resolve(dir, `./commonmark.${version}.json`), JSON.stringify(specs, null, 2) + '\n'); console.log(`Saved CommonMark v${version} specs`); } catch (ex) { @@ -40,7 +46,7 @@ async function updateGfm(dir) { if (!version) { throw new Error('No version found'); } - const specs = []; + let specs = []; $('.extension').each((i, ext) => { const section = $('.definition', ext).text().trim().replace(/^\d+\.\d+(.*?) \(extension\)[\s\S]*$/, '$1'); $('.example', ext).each((j, exa) => { @@ -56,12 +62,15 @@ async function updateGfm(dir) { }); }); - specs.forEach(spec => { - const html = marked(spec.markdown, { gfm: true, pedantic: false }); - if (!isEqual(html, spec.html)) { + specs = await Promise.all(specs.map(async(spec) => { + const marked = new Marked(); + const html = marked.parse(spec.markdown, { gfm: true, pedantic: false }); + const isEqual = await htmlIsEqual(html, spec.html); + if (!isEqual) { spec.shouldFail = true; } - }); + return spec; + })); writeFileSync(resolve(dir, `./gfm.${version}.json`), JSON.stringify(specs, null, 2) + '\n'); console.log(`Saved GFM v${version} specs.`); } catch (ex) {