From b7eae77ebf60eb1008db919c1148899409366f37 Mon Sep 17 00:00:00 2001 From: Juan Sugg Date: Wed, 15 Jul 2026 04:57:26 -0300 Subject: [PATCH] ci(docs): give the docs gate assertions worth requiring MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A required check named `docs` verified three things — non-empty, LF endings, no conflict markers — on Markdown only. It could pass stale, broken, or leaked documentation, and did. Five checks now stand behind the name: Image alt text. Raw must carry an alt attribute; the value may be empty, because alt="" is the honest way to say "decorative", but it has to be a decision rather than an omission. Markdown's ![alt](src) always carries the slot, so only the HTML form can be missing it — which is exactly what the README banner was doing, in a project whose entire purpose is alt text. Env-var coverage. Every variable read by config/*.js must appear in DEVELOPMENT.md, which calls itself the full configuration reference, and in .env.example. This is the check that pays for the rest: it found six variables nobody had written down, including OUTBOUND_ALLOWED_HOSTS, which bypasses the SSRF guard. Scoped to config/*.js rather than config/**, since config/jest's ALLURE_RESULTS_DIR is a reporter switch and does not belong in .env.example. Internal links and anchors. Link targets must be tracked by git and fragments must match a real heading, using GitHub's slug rules. This was already green; it is locked in before it rots. Only link targets are held to that standard — prose stays free to name a gitignored path, because certs/localhost-key.pem is documented precisely because it is not checked in. Prohibited references. Published documentation must not point at internal working notes (.local/, typecheck-debt.md, jobs.md), whatever they are named. Markdown lint. Structure and heading hierarchy. Line-length, inline-HTML, and ordered-list-prefix rules are off, with reasons recorded in the config: each contradicts a deliberate choice in these documents rather than catching a mistake in them, and the banner needs the inline the alt lives on. The job now runs unconditionally. The gate asserts across the repository rather than within the changed files, so a change that adds an undocumented env var without touching Markdown is the case most worth catching — and a docs_changed condition would have skipped precisely that change. Fixes what the new checks caught: the README banner gets alt="", .env.example stops claiming development TLS points at "checked-in localhost certs" (certs/ is gitignored and absent — the app self-signs in-process), and the five remaining undocumented variables are written down. OUTBOUND_ALLOWED_HOSTS becomes a supported, validated setting rather than an undocumented one: a host[:port] pattern, so `*.example.com` now fails at boot instead of being accepted and silently never matching — which, for an allowlist, is indistinguishable from working. Its documentation states what it actually does: a listed host returns before both assertPublicAddress() and DNS resolution, so an entry resolving to a link-local address will be fetched. --- .env.example | 34 +- .github/workflows/ci.yml | 21 +- .markdownlint-cli2.jsonc | 29 + DEVELOPMENT.md | 30 + README.md | 2 +- package-lock.json | 1099 +++++++++++++++++- package.json | 2 + scripts/docs/validate-docs.js | 331 +++++- src/utils/validateEnvVars.js | 23 + tests/unit/scripts/docs/validateDocs.test.js | 187 +++ tests/unit/utils/validateEnvVars.test.js | 37 + 11 files changed, 1767 insertions(+), 28 deletions(-) create mode 100644 .markdownlint-cli2.jsonc diff --git a/.env.example b/.env.example index d64110098a..64e4f3ce4c 100644 --- a/.env.example +++ b/.env.example @@ -57,10 +57,30 @@ TLS_PORT=8443 # Optional app-managed supplemental CA bundle for outbound HTTPS. # OUTBOUND_CA_BUNDLE_FILE=/absolute/path/to/outbound-extra-ca.pem +# +# Node's own extra-CA variable is honoured as a fallback for the same purpose. +# NODE_EXTRA_CA_CERTS=/absolute/path/to/outbound-extra-ca.pem + +# Hosts outbound fetches may reach WITHOUT the private-network SSRF check. +# Comma-separated host[:port]; matched exactly and case-normalized against the +# URL host (with port) or hostname (without). No wildcards, no suffix matching: +# example.com does NOT match api.example.com. +# +# WARNING: a listed host short-circuits BOTH assertPublicAddress() and DNS +# resolution, so an entry resolving to a loopback/link-local/private address +# WILL be fetched. Intended for a local fixture or test-harness origin (this is +# what `npm run postman:full` uses); do not use it to force through a public +# host the policy rejected. See DEVELOPMENT.md → Outbound host allowlist. +# OUTBOUND_ALLOWED_HOSTS=127.0.0.1:19090 # Optional rate limiting and logging. # RATE_LIMIT_WINDOW_MS=900000 # RATE_LIMIT_MAX=100 +# +# Status/health routes use their own limiter so probes do not share the main +# API request budget. +# STATUS_RATE_LIMIT_WINDOW_MS=60000 +# STATUS_RATE_LIMIT_MAX=60 # LOG_LEVEL=debug # Optional API access control. When set, scraper and description endpoints @@ -118,7 +138,13 @@ TLS_PORT=8443 # TOGETHER_MAX_TOKENS=160 # TOGETHER_PROMPT=Write concise alt text for this image. -# Development TLS defaults already point to the checked-in localhost certs. -# Uncomment only when overriding them. -# TLS_KEY=../../certs/localhost-key.pem -# TLS_CERT=../../certs/localhost.pem +# TLS is enabled by default. +# In development the app uses certs/localhost*.pem when present (they are NOT +# checked in — certs/ is gitignored); otherwise it generates a temporary +# self-signed localhost certificate in-process. +# Set TLS_ENABLED=false only behind a trusted TLS-terminating platform edge. +# TLS_ENABLED=true +# +# Prefer absolute paths; relative paths resolve against src/infrastructure/. +# TLS_KEY=/absolute/path/to/localhost-key.pem +# TLS_CERT=/absolute/path/to/localhost.pem diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a8ce432081..4515328394 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -50,28 +50,27 @@ jobs: node scripts/ci/classify-changed-paths.js --input changed-files.txt + # Runs unconditionally, unlike the other gates' docs-only skips. docs:validate + # now asserts across the repository, not just within the changed files: it + # holds every env var read by config/*.js to being documented. A change that + # adds one without touching any Markdown is exactly the case worth catching, + # and a docs_changed condition would skip precisely that change. docs: name: docs - needs: - - changes runs-on: ubuntu-latest - timeout-minutes: 5 + timeout-minutes: 10 steps: - name: Checkout uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - - name: Setup Node - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 - with: - node-version-file: .nvmrc + - name: Setup project + uses: ./.github/actions/setup-node-project - name: Validate docs - if: needs.changes.outputs.docs_changed == 'true' run: npm run docs:validate - - name: Skip docs validation when no docs changed - if: needs.changes.outputs.docs_changed != 'true' - run: echo "No docs-only validation needed for this change." + - name: Lint Markdown + run: npm run docs:lint actionlint: name: actionlint diff --git a/.markdownlint-cli2.jsonc b/.markdownlint-cli2.jsonc new file mode 100644 index 0000000000..7b54572941 --- /dev/null +++ b/.markdownlint-cli2.jsonc @@ -0,0 +1,29 @@ +{ + // Structure and heading hierarchy are what this gate is for. Three default + // rules are off because they contradict deliberate choices in these docs + // rather than catching mistakes in them: + // + // MD013 line-length prose, tables, and command lines are intentionally + // long; enforcing 80 columns would rewrap every file + // and change nothing about correctness. + // MD033 no-inline-html the README banner is a
/ block, and the + // img is exactly where the alt attribute has to live. + // MD041 first-line-h1 the README opens with that banner, not a heading. + // MD029 ol-prefix the runbook sequences interleave unindented code + // fences, which ends each list and starts the next at + // its own number. That renders correctly (
    ) + // and re-indenting eight fences to satisfy the rule + // would churn the document without fixing anything. + // + // Everything else stays on, including the heading-hierarchy rules this gate + // is for: MD001 increment, MD024 duplicates, MD025 single H1, MD022 spacing. + "config": { + "default": true, + "MD013": false, + "MD033": false, + "MD041": false, + "MD029": false + }, + "globs": ["**/*.md"], + "gitignore": true +} diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index b34bc13f35..16ae21aa75 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -463,8 +463,38 @@ Public traffic is still end-to-end HTTPS to the client; only the edge→app hop | Variable | Required | Default | Description | | --- | --- | --- | --- | | `OUTBOUND_CA_BUNDLE_FILE` | No | unset | App-managed supplemental PEM bundle used to extend Node trust for outbound HTTPS. | +| `OUTBOUND_ALLOWED_HOSTS` | No | unset | Comma-separated `host[:port]` allowlist for outbound fetches. **Each entry bypasses the private-network SSRF guard** — see [Outbound host allowlist](#outbound-host-allowlist) before setting it. | | `NODE_EXTRA_CA_CERTS` | No | unset | Node-supported extra CA file. This app also accepts it as a fallback. Not validated by Joi. | +#### Outbound host allowlist + +`OUTBOUND_ALLOWED_HOSTS` names hosts that outbound fetches may reach **without +the private-network check**. Read this before setting it: it is the one setting +here that can turn the scraper into an SSRF primitive. + +**Syntax.** A comma-separated list of `host[:port]` entries, validated at boot: + +```dotenv +OUTBOUND_ALLOWED_HOSTS=127.0.0.1:19090,fixtures.internal:8080 +``` + +**Semantics.** Matching is exact and case-normalized against the URL's `host` +(with port) or `hostname` (without). There are **no wildcards and no suffix +matching** — `example.com` does not match `api.example.com`, and +`example.com:8443` does not match `example.com`. Entries are compared verbatim +(`src/infrastructure/outboundUrlPolicy.js`). + +**What it bypasses.** A matching host returns *before* `assertPublicAddress()` +and before DNS resolution, which are the guards that stop the app from being +pointed at loopback, link-local, or private-range addresses. An allowlisted host +that resolves to `169.254.169.254` will be fetched. That is the entire point of +the setting, and the entire risk of it. + +**Use it for** a local fixture or test-harness origin — which is what +`npm run postman:full` does. **Do not use it** to work around a legitimate +rejection of a public host; nothing about the allowlist is scoped to a path, +method, or protocol. + ### Worker, Proxy, and Scraper Runtime Controls | Variable | Required | Default | Description | diff --git a/README.md b/README.md index 46fb34d4f0..7f1984dfe9 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@
    - +
    # Alt-Text 4 All diff --git a/package-lock.json b/package-lock.json index f3bacf8cdc..daf6bf72ac 100644 --- a/package-lock.json +++ b/package-lock.json @@ -43,6 +43,7 @@ "jest": "^29.7.0", "jest-environment-node": "^30.4.1", "jest-junit": "^16.0.0", + "markdownlint-cli2": "0.18.1", "newman": "^6.2.2", "newman-reporter-allure": "^3.6.0", "nodemon": "^3.1.14", @@ -1490,6 +1491,44 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/@paralleldrive/cuid2": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.3.1.tgz", @@ -1802,6 +1841,19 @@ "integrity": "sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==", "dev": true }, + "node_modules/@sindresorhus/merge-streams": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-2.3.0.tgz", + "integrity": "sha512-LtoMMhxAlorcGhmFYI+LhPgbPZCkgP6ra1YL604EeF6U98pLlQ3iWIGMdWSC+vWmPBWBNgmDBAhnAobLROJmwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/@sinonjs/commons": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz", @@ -1894,6 +1946,16 @@ "@babel/types": "^7.20.7" } }, + "node_modules/@types/debug": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", + "integrity": "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/ms": "*" + } + }, "node_modules/@types/estree": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", @@ -1946,6 +2008,20 @@ "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==", "dev": true }, + "node_modules/@types/katex": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@types/katex/-/katex-0.16.8.tgz", + "integrity": "sha512-trgaNyfU+Xh2Tc+ABIb44a5AYUpicB3uwirOioeOkNPPbmgRNtcWyDeeFRzjPZENO9Vq8gvVqfhaaXWLlevVwg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/node": { "version": "18.15.10", "resolved": "https://registry.npmjs.org/@types/node/-/node-18.15.10.tgz", @@ -1959,6 +2035,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/unist": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz", + "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/yargs": { "version": "17.0.35", "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz", @@ -2947,6 +3030,39 @@ "node": ">=10" } }, + "node_modules/character-entities": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", + "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-legacy": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", + "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-reference-invalid": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-2.0.1.tgz", + "integrity": "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/chardet": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/chardet/-/chardet-2.0.0.tgz", @@ -3429,6 +3545,20 @@ } } }, + "node_modules/decode-named-character-reference": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", + "integrity": "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "character-entities": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/dedent": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.5.1.tgz", @@ -3508,6 +3638,16 @@ "node": ">= 0.8" } }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/des.js": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/des.js/-/des.js-1.1.0.tgz", @@ -3528,6 +3668,20 @@ "node": ">=8" } }, + "node_modules/devlop": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", + "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", + "dev": true, + "license": "MIT", + "dependencies": { + "dequal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/dezalgo": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/dezalgo/-/dezalgo-1.0.4.tgz", @@ -4407,6 +4561,23 @@ "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==" }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, "node_modules/fast-json-stable-stringify": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", @@ -4448,6 +4619,16 @@ ], "license": "BSD-3-Clause" }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, "node_modules/fb-watchman": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz", @@ -4891,6 +5072,50 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/globby": { + "version": "14.1.0", + "resolved": "https://registry.npmjs.org/globby/-/globby-14.1.0.tgz", + "integrity": "sha512-0Ia46fDOaT7k4og1PDW4YbodWWr3scS2vAr2lTbsplOt2WkKp0vQbkI9wKis/T5LV/dqPjO3bpS/z6GTJB82LA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sindresorhus/merge-streams": "^2.1.0", + "fast-glob": "^3.3.3", + "ignore": "^7.0.3", + "path-type": "^6.0.0", + "slash": "^5.1.0", + "unicorn-magic": "^0.3.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/globby/node_modules/ignore": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.6.tgz", + "integrity": "sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/globby/node_modules/slash": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-5.1.0.tgz", + "integrity": "sha512-ZA6oR3T/pEyuqwMgAKT0/hAv8oAXckzbkmR0UkUosQ+Mc4RxGoJkRmwHgHufaenlyAgE1Mxgpdcrf75y6XcnDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -5329,6 +5554,32 @@ "node": ">= 0.10" } }, + "node_modules/is-alphabetical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz", + "integrity": "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-alphanumerical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-2.0.1.tgz", + "integrity": "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-alphabetical": "^2.0.0", + "is-decimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/is-array-buffer": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", @@ -5481,6 +5732,17 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-decimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-2.0.1.tgz", + "integrity": "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/is-document.all": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-document.all/-/is-document.all-1.0.0.tgz", @@ -5569,6 +5831,17 @@ "node": ">=0.10.0" } }, + "node_modules/is-hexadecimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz", + "integrity": "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/is-map": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", @@ -6998,6 +7271,13 @@ "json5": "lib/cli.js" } }, + "node_modules/jsonc-parser": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz", + "integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==", + "dev": true, + "license": "MIT" + }, "node_modules/jsprim": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/jsprim/-/jsprim-2.0.2.tgz", @@ -7014,6 +7294,33 @@ "verror": "1.10.0" } }, + "node_modules/katex": { + "version": "0.16.47", + "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.47.tgz", + "integrity": "sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg==", + "dev": true, + "funding": [ + "https://opencollective.com/katex", + "https://github.com/sponsors/katex" + ], + "license": "MIT", + "dependencies": { + "commander": "^8.3.0" + }, + "bin": { + "katex": "cli.js" + } + }, + "node_modules/katex/node_modules/commander": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz", + "integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -7060,6 +7367,26 @@ "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", "dev": true }, + "node_modules/linkify-it": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.2.tgz", + "integrity": "sha512-ONTm2jCMAVZjgQa/Fy1kScXsuOoF5NPTsoFBdE1KVIZ2vAh/r9+Bqo+0jINCBYnavTPQZz38QzFTme79ENoN3Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/markdown-it" + } + ], + "license": "MIT", + "dependencies": { + "uc.micro": "^2.0.0" + } + }, "node_modules/liquid-json": { "version": "0.3.1", "resolved": "https://registry.npmjs.org/liquid-json/-/liquid-json-0.3.1.tgz", @@ -7139,6 +7466,98 @@ "tmpl": "1.0.5" } }, + "node_modules/markdown-it": { + "version": "14.1.0", + "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.0.tgz", + "integrity": "sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1", + "entities": "^4.4.0", + "linkify-it": "^5.0.0", + "mdurl": "^2.0.0", + "punycode.js": "^2.3.1", + "uc.micro": "^2.1.0" + }, + "bin": { + "markdown-it": "bin/markdown-it.mjs" + } + }, + "node_modules/markdownlint": { + "version": "0.38.0", + "resolved": "https://registry.npmjs.org/markdownlint/-/markdownlint-0.38.0.tgz", + "integrity": "sha512-xaSxkaU7wY/0852zGApM8LdlIfGCW8ETZ0Rr62IQtAnUMlMuifsg09vWJcNYeL4f0anvr8Vo4ZQar8jGpV0btQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "micromark": "4.0.2", + "micromark-core-commonmark": "2.0.3", + "micromark-extension-directive": "4.0.0", + "micromark-extension-gfm-autolink-literal": "2.1.0", + "micromark-extension-gfm-footnote": "2.1.0", + "micromark-extension-gfm-table": "2.1.1", + "micromark-extension-math": "3.1.0", + "micromark-util-types": "2.0.2" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/DavidAnson" + } + }, + "node_modules/markdownlint-cli2": { + "version": "0.18.1", + "resolved": "https://registry.npmjs.org/markdownlint-cli2/-/markdownlint-cli2-0.18.1.tgz", + "integrity": "sha512-/4Osri9QFGCZOCTkfA8qJF+XGjKYERSHkXzxSyS1hd3ZERJGjvsUao2h4wdnvpHp6Tu2Jh/bPHM0FE9JJza6ng==", + "dev": true, + "license": "MIT", + "dependencies": { + "globby": "14.1.0", + "js-yaml": "4.1.0", + "jsonc-parser": "3.3.1", + "markdown-it": "14.1.0", + "markdownlint": "0.38.0", + "markdownlint-cli2-formatter-default": "0.0.5", + "micromatch": "4.0.8" + }, + "bin": { + "markdownlint-cli2": "markdownlint-cli2-bin.mjs" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/DavidAnson" + } + }, + "node_modules/markdownlint-cli2-formatter-default": { + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/markdownlint-cli2-formatter-default/-/markdownlint-cli2-formatter-default-0.0.5.tgz", + "integrity": "sha512-4XKTwQ5m1+Txo2kuQ3Jgpo/KmnG+X90dWt4acufg6HVGadTUG5hzHF/wssp9b5MBYOMCnZ9RMPaU//uHsszF8Q==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/DavidAnson" + }, + "peerDependencies": { + "markdownlint-cli2": ">=0.0.4" + } + }, + "node_modules/markdownlint-cli2/node_modules/js-yaml": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", + "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -7160,6 +7579,13 @@ "is-buffer": "~1.1.6" } }, + "node_modules/mdurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.0.0.tgz", + "integrity": "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==", + "dev": true, + "license": "MIT" + }, "node_modules/media-typer": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", @@ -7185,15 +7611,561 @@ "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", "dev": true }, - "node_modules/methods": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", - "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", "dev": true, "engines": { "node": ">= 0.6" } }, + "node_modules/micromark": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz", + "integrity": "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "@types/debug": "^4.0.0", + "debug": "^4.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-core-commonmark": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-2.0.3.tgz", + "integrity": "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-factory-destination": "^2.0.0", + "micromark-factory-label": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-factory-title": "^2.0.0", + "micromark-factory-whitespace": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-html-tag-name": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-extension-directive": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-directive/-/micromark-extension-directive-4.0.0.tgz", + "integrity": "sha512-/C2nqVmXXmiseSSuCdItCMho7ybwwop6RrrRPk0KbOHW21JKoCldC+8rFOaundDoRBUWBnJJcxeA/Kvi34WQXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-factory-whitespace": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "parse-entities": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-autolink-literal": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-autolink-literal/-/micromark-extension-gfm-autolink-literal-2.1.0.tgz", + "integrity": "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==", + "dev": true, + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-footnote/-/micromark-extension-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-table": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-table/-/micromark-extension-gfm-table-2.1.1.tgz", + "integrity": "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==", + "dev": true, + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-math": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-math/-/micromark-extension-math-3.1.0.tgz", + "integrity": "sha512-lvEqd+fHjATVs+2v/8kg9i5Q0AP2k85H0WUOwpIVvUML8BapsMvh1XAogmQjOCsLpoKRCVQqEkQBB3NhVBcsOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/katex": "^0.16.0", + "devlop": "^1.0.0", + "katex": "^0.16.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-factory-destination": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz", + "integrity": "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-label": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-label/-/micromark-factory-label-2.0.1.tgz", + "integrity": "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-space": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz", + "integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-title": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-title/-/micromark-factory-title-2.0.1.tgz", + "integrity": "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-whitespace": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.1.tgz", + "integrity": "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-character": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-chunked": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-chunked/-/micromark-util-chunked-2.0.1.tgz", + "integrity": "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-classify-character": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-classify-character/-/micromark-util-classify-character-2.0.1.tgz", + "integrity": "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-combine-extensions": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.1.tgz", + "integrity": "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-chunked": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-numeric-character-reference": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.2.tgz", + "integrity": "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-encode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", + "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-html-tag-name": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz", + "integrity": "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-normalize-identifier": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.1.tgz", + "integrity": "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-resolve-all": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.1.tgz", + "integrity": "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-sanitize-uri": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", + "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-subtokenize": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-subtokenize/-/micromark-util-subtokenize-2.1.0.tgz", + "integrity": "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-symbol": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-types": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz", + "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, "node_modules/micromatch": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", @@ -7788,6 +8760,26 @@ "node": ">=6" } }, + "node_modules/parse-entities": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz", + "integrity": "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^2.0.0", + "character-entities-legacy": "^3.0.0", + "character-reference-invalid": "^2.0.0", + "decode-named-character-reference": "^1.0.0", + "is-alphanumerical": "^2.0.0", + "is-decimal": "^2.0.0", + "is-hexadecimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/parse-json": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", @@ -7913,6 +8905,19 @@ "url": "https://opencollective.com/express" } }, + "node_modules/path-type": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-6.0.0.tgz", + "integrity": "sha512-Vj7sf++t5pBD637NSfkxpHSMfWaeig5+DKWLhcqIYx6mWQz5hdJTGDVMQiJcw1ZYkhs7AazKDGpRVji1LJCZUQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/performance-now": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", @@ -8501,6 +9506,16 @@ "node": ">=6" } }, + "node_modules/punycode.js": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz", + "integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/pure-rand": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.0.3.tgz", @@ -8558,6 +9573,27 @@ "dev": true, "license": "MIT" }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/quick-format-unescaped": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", @@ -8813,6 +9849,17 @@ "node": ">=10" } }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, "node_modules/router": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", @@ -8828,6 +9875,30 @@ "node": ">= 18" } }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, "node_modules/safe-array-concat": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.4.tgz", @@ -9990,6 +11061,13 @@ "node": ">=14.17" } }, + "node_modules/uc.micro": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz", + "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==", + "dev": true, + "license": "MIT" + }, "node_modules/uglify-js": { "version": "3.19.3", "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz", @@ -10044,6 +11122,19 @@ "node": ">=18.17" } }, + "node_modules/unicorn-magic": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.3.0.tgz", + "integrity": "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/universalify": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz", diff --git a/package.json b/package.json index 88c217d3b6..9ae6a427d0 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "verify:action-pins": "node scripts/github/verify-action-pins.js", "doctor:tls": "node scripts/doctor-tls.js", "docs:validate": "node scripts/docs/validate-docs.js", + "docs:lint": "markdownlint-cli2", "dev:test-env": "ENV_FILE=.env.test NODE_ENV=development nodemon src/app.js | pino-pretty -i streams,options", "lint": "eslint . --max-warnings=0", "openapi:generate": "node scripts/generate-openapi-spec.js", @@ -98,6 +99,7 @@ "jest": "^29.7.0", "jest-environment-node": "^30.4.1", "jest-junit": "^16.0.0", + "markdownlint-cli2": "0.18.1", "newman": "^6.2.2", "newman-reporter-allure": "^3.6.0", "nodemon": "^3.1.14", diff --git a/scripts/docs/validate-docs.js b/scripts/docs/validate-docs.js index 43fe11e150..db98ccf67a 100644 --- a/scripts/docs/validate-docs.js +++ b/scripts/docs/validate-docs.js @@ -6,10 +6,41 @@ const path = require('node:path'); const ROOT = path.resolve(__dirname, '../..'); +// Runtime configuration modules. Deliberately config/*.js rather than config/**: +// the latter would sweep in config/jest/*.cjs, whose ALLURE_RESULTS_DIR is a +// reporter switch rather than application configuration and has no business in +// .env.example. +const CONFIG_PATHSPEC = 'config/*.js'; + +// Internal working notes. These are never a legitimate reference from published +// documentation, whatever they are named or wherever they are kept. +const PROHIBITED_REFERENCES = [ + { label: '.local/', pattern: /\.local\// }, + { label: 'docs/typecheck-debt.md', pattern: /docs\/typecheck-debt\.md/ }, + { label: 'jobs.md', pattern: /(?:^|[\s(['"/])jobs\.md/ }, +]; + /** * @typedef {{ file: string, line: number, message: string }} DocViolation */ +/** + * @param {string} rootDir + * @param {string} [pathspec] + * @returns {string[]} + */ +function gitLsFiles(rootDir, pathspec) { + const args = ['-C', rootDir, 'ls-files', '-z']; + + if (pathspec) { + args.push('--', pathspec); + } + + return execFileSync('git', args, { encoding: 'utf8' }) + .split('\0') + .filter(Boolean); +} + /** * Enumerates the Markdown files git tracks under `rootDir`. * @@ -24,17 +55,288 @@ const ROOT = path.resolve(__dirname, '../..'); * @returns {string[]} */ function listMarkdownFiles(rootDir = ROOT) { - const stdout = execFileSync('git', ['-C', rootDir, 'ls-files', '-z', '--', '*.md'], { - encoding: 'utf8', - }); - - return stdout - .split('\0') - .filter(Boolean) + return gitLsFiles(rootDir, '*.md') .map((relativePath) => path.join(rootDir, relativePath)) .sort((left, right) => left.localeCompare(right)); } +/** + * Blanks out fenced blocks and inline code spans so illustrative snippets are + * not mistaken for real images, links, or references. Newlines survive, so + * reported line numbers still point at the right source line. + * + * @param {string} content + * @returns {string} + */ +function stripCode(content) { + return content + .replace(/```[\s\S]*?```/g, (block) => block.replace(/[^\n]/g, ' ')) + .replace(/`[^`\n]*`/g, (span) => span.replace(/[^\n]/g, ' ')); +} + +/** + * GitHub's heading-anchor slug: lowercase, drop everything that is not a word + * character, space, or hyphen, then hyphenate the spaces. + * + * @param {string} heading + * @returns {string} + */ +function slugifyHeading(heading) { + return heading + .trim() + .toLowerCase() + .replace(/[^\w\s-]/g, '') + .replace(/\s+/g, '-'); +} + +/** + * Repeated headings get `-1`, `-2`, ... exactly as GitHub disambiguates them. + * + * @param {string} content + * @returns {Set} + */ +function collectAnchors(content) { + /** @type {Map} */ + const seen = new Map(); + /** @type {Set} */ + const anchors = new Set(); + + stripCode(content).split('\n').forEach((line) => { + const match = /^#{1,6}\s+(.*?)\s*$/.exec(line); + + if (!match) { + return; + } + + const base = slugifyHeading(match[1]); + + if (!base) { + return; + } + + const count = seen.get(base) ?? 0; + + seen.set(base, count + 1); + anchors.add(count === 0 ? base : `${base}-${count}`); + }); + + return anchors; +} + +/** + * Raw `` tags must carry an `alt` attribute. The value may be empty — + * `alt=""` is the honest way to say "decorative" — but it has to be a decision + * someone made rather than an omission. Markdown's `![alt](src)` always carries + * the slot, so only the HTML form can be missing it; both syntaxes appear in + * Markdown files, which is why both are scanned. + * + * @param {string} content + * @param {string} relativePath + * @returns {DocViolation[]} + */ +function checkImageAlt(content, relativePath) { + /** @type {DocViolation[]} */ + const violations = []; + + stripCode(content).split('\n').forEach((line, index) => { + (line.match(/]*>/gi) ?? []).forEach((tag) => { + if (!/\salt\s*=/i.test(tag)) { + violations.push({ + file: relativePath, + line: index + 1, + message: 'Image must have an alt attribute (use alt="" if decorative)', + }); + } + }); + }); + + return violations; +} + +/** + * @param {string} content + * @param {string} relativePath + * @returns {DocViolation[]} + */ +function checkProhibitedReferences(content, relativePath) { + /** @type {DocViolation[]} */ + const violations = []; + + stripCode(content).split('\n').forEach((line, index) => { + PROHIBITED_REFERENCES.forEach(({ label, pattern }) => { + if (pattern.test(line)) { + violations.push({ + file: relativePath, + line: index + 1, + message: `Documentation must not reference internal working notes (${label})`, + }); + } + }); + }); + + return violations; +} + +/** + * @param {string} content + * @returns {Array<{ target: string, line: number }>} + */ +function collectLinkTargets(content) { + /** @type {Array<{ target: string, line: number }>} */ + const links = []; + + stripCode(content).split('\n').forEach((line, index) => { + const inline = /!?\[[^\]]*\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g; + let match = inline.exec(line); + + while (match !== null) { + links.push({ target: match[1], line: index + 1 }); + match = inline.exec(line); + } + }); + + return links; +} + +/** + * Internal links must resolve: the target has to be tracked by git, and any + * fragment has to match a heading in it. + * + * Only link *targets* are held to that standard. Prose stays free to name a + * gitignored path — `certs/localhost-key.pem` is documented precisely because it + * is not checked in — so this never inspects prose. + * + * @param {string[]} files + * @param {string} rootDir + * @returns {DocViolation[]} + */ +function checkInternalLinks(files, rootDir) { + const tracked = new Set(gitLsFiles(rootDir)); + /** @type {Map>} */ + const anchorCache = new Map(); + + /** @param {string} absolutePath */ + const anchorsFor = (absolutePath) => { + if (!anchorCache.has(absolutePath)) { + anchorCache.set(absolutePath, collectAnchors(fs.readFileSync(absolutePath, 'utf8'))); + } + + return /** @type {Set} */ (anchorCache.get(absolutePath)); + }; + + return files.flatMap((filePath) => { + const relativePath = path.relative(rootDir, filePath); + const content = fs.readFileSync(filePath, 'utf8'); + + return collectLinkTargets(content).flatMap(({ target, line }) => { + // Anything with a scheme (http:, mailto:) or protocol-relative is external. + if (/^(?:[a-z][a-z0-9+.-]*:|\/\/)/i.test(target)) { + return []; + } + + const hashIndex = target.indexOf('#'); + const rawPath = hashIndex === -1 ? target : target.slice(0, hashIndex); + const fragment = hashIndex === -1 ? '' : decodeURIComponent(target.slice(hashIndex + 1)); + + if (!rawPath) { + if (anchorsFor(filePath).has(fragment)) { + return []; + } + + return [{ + file: relativePath, + line, + message: `Link points at a heading that does not exist: #${fragment}`, + }]; + } + + const resolved = path.normalize( + path.join(path.dirname(relativePath), decodeURIComponent(rawPath)), + ); + + if (!tracked.has(resolved)) { + return [{ + file: relativePath, + line, + message: `Link points at a path git does not track: ${rawPath}`, + }]; + } + + if (!fragment || !resolved.toLowerCase().endsWith('.md')) { + return []; + } + + if (anchorsFor(path.join(rootDir, resolved)).has(fragment)) { + return []; + } + + return [{ + file: relativePath, + line, + message: `Link points at a heading that does not exist in ${rawPath}: #${fragment}`, + }]; + }); + }); +} + +/** + * Every environment variable the runtime configuration reads must be documented + * in DEVELOPMENT.md — which calls itself the full configuration reference — and + * in .env.example. This is the check that catches a setting becoming a + * production affordance nobody wrote down. + * + * @param {string} rootDir + * @returns {DocViolation[]} + */ +function checkEnvVarCoverage(rootDir) { + const configFiles = gitLsFiles(rootDir, CONFIG_PATHSPEC); + + if (configFiles.length === 0) { + return []; + } + + /** @type {Map} */ + const readBy = new Map(); + + configFiles.forEach((relativePath) => { + const content = fs.readFileSync(path.join(rootDir, relativePath), 'utf8'); + const pattern = /process\.env\.([A-Z0-9_]+)/g; + let match = pattern.exec(content); + + while (match !== null) { + if (!readBy.has(match[1])) { + readBy.set(match[1], relativePath); + } + + match = pattern.exec(content); + } + }); + + const surfaces = [ + { file: 'DEVELOPMENT.md', label: 'the configuration reference' }, + { file: '.env.example', label: 'the environment template' }, + ]; + + return surfaces.flatMap(({ file, label }) => { + const absolutePath = path.join(rootDir, file); + + if (!fs.existsSync(absolutePath)) { + return []; + } + + const content = fs.readFileSync(absolutePath, 'utf8'); + + return [...readBy.entries()] + .filter(([name]) => !new RegExp(`\\b${name}\\b`).test(content)) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([name, source]) => ({ + file, + line: 1, + message: `${name} is read by ${source} but is undocumented in ${label}`, + })); + }); +} + /** * @param {string} filePath * @param {string} [rootDir] @@ -71,6 +373,9 @@ function validateMarkdownFile(filePath, rootDir = ROOT) { } }); + violations.push(...checkImageAlt(content, relativePath)); + violations.push(...checkProhibitedReferences(content, relativePath)); + return violations; } @@ -79,7 +384,11 @@ function validateDocs({ rootDir = ROOT } = {}) { return { files: files.map((filePath) => path.relative(rootDir, filePath) || filePath), - violations: files.flatMap((filePath) => validateMarkdownFile(filePath, rootDir)), + violations: [ + ...files.flatMap((filePath) => validateMarkdownFile(filePath, rootDir)), + ...checkInternalLinks(files, rootDir), + ...checkEnvVarCoverage(rootDir), + ], }; } @@ -150,9 +459,15 @@ if (require.main === module) { } module.exports = { + checkEnvVarCoverage, + checkImageAlt, + checkInternalLinks, + checkProhibitedReferences, + collectAnchors, listMarkdownFiles, main, parseArgs, + slugifyHeading, validateDocs, validateMarkdownFile, }; diff --git a/src/utils/validateEnvVars.js b/src/utils/validateEnvVars.js index 3f5e62164d..af3cab9b69 100644 --- a/src/utils/validateEnvVars.js +++ b/src/utils/validateEnvVars.js @@ -19,6 +19,17 @@ const { RATE_LIMIT_STORE_MODES, } = require('../../config/rateLimitStore'); +// OUTBOUND_ALLOWED_HOSTS entries are matched verbatim against a URL's `host` +// (with port) or `hostname` (without), so the accepted syntax is exactly +// `host[:port]` — no wildcards, no suffix matching, nothing to interpret. +// Validating the shape here means a typo fails at boot rather than silently +// never matching, which for an allowlist would look identical to working. +const HOST_LABEL = '[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?'; +const HOST_ENTRY = `(?:${HOST_LABEL}(?:\\.${HOST_LABEL})*|\\[[0-9A-Fa-f:.]+\\])(?::\\d{1,5})?`; +const OUTBOUND_ALLOWED_HOSTS_PATTERN = new RegExp( + `^\\s*${HOST_ENTRY}(?:\\s*,\\s*${HOST_ENTRY})*\\s*$`, +); + /** * @param {unknown} value * @returns {string[]} @@ -75,6 +86,18 @@ const envVarsSchema = Joi.object({ }), OUTBOUND_CA_BUNDLE_FILE: Joi.string().optional(), + // Every entry here bypasses the private-network SSRF guard — see + // src/infrastructure/outboundUrlPolicy.js and the warning in DEVELOPMENT.md. + OUTBOUND_ALLOWED_HOSTS: Joi.string() + .pattern(OUTBOUND_ALLOWED_HOSTS_PATTERN) + .allow('') + .optional() + .messages({ + 'string.pattern.base': + '"OUTBOUND_ALLOWED_HOSTS" must be a comma-separated list of host[:port] ' + + 'entries (exact matches only — wildcards and suffix patterns are not supported)', + }), + // Scraper HTTP safeguards SCRAPER_REQUEST_TIMEOUT_MS: Joi.number().integer().min(1).optional(), SCRAPER_MAX_REDIRECTS: Joi.number().integer().min(0).optional(), diff --git a/tests/unit/scripts/docs/validateDocs.test.js b/tests/unit/scripts/docs/validateDocs.test.js index e35abfd15a..b00b7b0eaa 100644 --- a/tests/unit/scripts/docs/validateDocs.test.js +++ b/tests/unit/scripts/docs/validateDocs.test.js @@ -4,8 +4,13 @@ const os = require('node:os'); const path = require('node:path'); const { + checkEnvVarCoverage, + checkImageAlt, + checkProhibitedReferences, + collectAnchors, listMarkdownFiles, parseArgs, + slugifyHeading, validateDocs, validateMarkdownFile, } = require('../../../../scripts/docs/validate-docs'); @@ -144,4 +149,186 @@ describe('Unit | Scripts | Docs | Validate Docs', () => { }); expect(() => parseArgs(['--root'])).toThrow('--root requires a directory argument'); }); + + describe('image alt text', () => { + it('accepts an img with descriptive alt, and an explicit empty alt', () => { + expect(checkImageAlt('A bar chart\n', 'x.md')).toEqual([]); + expect(checkImageAlt('\n', 'x.md')).toEqual([]); + }); + + it('accepts Markdown image syntax, which always carries the alt slot', () => { + expect(checkImageAlt('![A bar chart](a.png)\n', 'x.md')).toEqual([]); + expect(checkImageAlt('![](banner.png)\n', 'x.md')).toEqual([]); + }); + + it('rejects a raw img with no alt attribute', () => { + expect(checkImageAlt('
    \n\n
    \n', 'x.md')) + .toEqual([{ + file: 'x.md', + line: 2, + message: 'Image must have an alt attribute (use alt="" if decorative)', + }]); + }); + + it('ignores an img inside a fenced code block', () => { + expect(checkImageAlt('```html\n\n```\n', 'x.md')).toEqual([]); + }); + }); + + describe('prohibited references', () => { + it('accepts prose naming a gitignored runtime path', () => { + // certs/ is gitignored on purpose, and documenting that the app looks + // there is correct. Only internal working notes are prohibited. + const content = 'the app tries `certs/localhost-key.pem` and certs/localhost.pem\n'; + + expect(checkProhibitedReferences(content, 'x.md')).toEqual([]); + }); + + it.each([ + ['see .local/docs-audit.md for detail', '.local/'], + ['tracked in docs/typecheck-debt.md', 'docs/typecheck-debt.md'], + ['see jobs.md', 'jobs.md'], + ])('rejects a reference to an internal working note (%s)', (line, label) => { + expect(checkProhibitedReferences(`${line}\n`, 'x.md')).toEqual([{ + file: 'x.md', + line: 1, + message: `Documentation must not reference internal working notes (${label})`, + }]); + }); + }); + + describe('internal links and anchors', () => { + it('accepts links to tracked files and existing headings', () => { + const rootDir = createTempDocsRepo(); + + try { + writeDoc(rootDir, 'docs/guide.md', '# Guide\n\n## Deep Section: HTTPS-first\n'); + writeDoc( + rootDir, + 'README.md', + [ + '# Readme', + '', + '## Local Heading', + '', + 'See [the guide](docs/guide.md).', + 'See [a section](docs/guide.md#deep-section-https-first).', + 'See [local](#local-heading).', + 'See [external](https://example.com/x).', + '', + ].join('\n'), + ); + + expect(validateDocs({ rootDir }).violations).toEqual([]); + } finally { + fs.rmSync(rootDir, { force: true, recursive: true }); + } + }); + + it('rejects a link to a path git does not track', () => { + const rootDir = createTempDocsRepo(); + + try { + writeDoc(rootDir, 'notes.md', '# Notes\n', { track: false }); + writeDoc(rootDir, 'README.md', '# Readme\n\nSee [notes](notes.md).\n'); + + expect(validateDocs({ rootDir }).violations).toEqual([{ + file: 'README.md', + line: 3, + message: 'Link points at a path git does not track: notes.md', + }]); + } finally { + fs.rmSync(rootDir, { force: true, recursive: true }); + } + }); + + it('rejects a link to a heading that does not exist', () => { + const rootDir = createTempDocsRepo(); + + try { + writeDoc(rootDir, 'README.md', '# Readme\n\nSee [gone](#no-such-heading).\n'); + + expect(validateDocs({ rootDir }).violations).toEqual([{ + file: 'README.md', + line: 3, + message: 'Link points at a heading that does not exist: #no-such-heading', + }]); + } finally { + fs.rmSync(rootDir, { force: true, recursive: true }); + } + }); + }); + + describe('heading slugs', () => { + it('matches how GitHub slugifies punctuation and spacing', () => { + expect(slugifyHeading('Inbound TLS posture: HTTPS-first vs. edge termination')) + .toBe('inbound-tls-posture-https-first-vs-edge-termination'); + expect(slugifyHeading('Quick Start (Dev)')).toBe('quick-start-dev'); + }); + + it('disambiguates repeated headings the way GitHub does', () => { + expect([...collectAnchors('## Notes\n\n## Notes\n\n## Notes\n')]) + .toEqual(['notes', 'notes-1', 'notes-2']); + }); + }); + + describe('env var coverage', () => { + const seedConfig = (rootDir, body) => { + writeDoc(rootDir, 'config/index.js', body); + }; + + it('accepts a variable documented in both surfaces', () => { + const rootDir = createTempDocsRepo(); + + try { + seedConfig(rootDir, 'module.exports = { a: process.env.SOME_SETTING };\n'); + writeDoc(rootDir, 'DEVELOPMENT.md', '# Dev\n\n| `SOME_SETTING` | No | unset | Does a thing. |\n'); + writeDoc(rootDir, '.env.example', '# SOME_SETTING=1\n'); + + expect(checkEnvVarCoverage(rootDir)).toEqual([]); + } finally { + fs.rmSync(rootDir, { force: true, recursive: true }); + } + }); + + it('rejects a variable missing from each surface independently', () => { + const rootDir = createTempDocsRepo(); + + try { + seedConfig(rootDir, 'module.exports = { a: process.env.SECRET_BYPASS };\n'); + writeDoc(rootDir, 'DEVELOPMENT.md', '# Dev\n\nNothing here.\n'); + writeDoc(rootDir, '.env.example', '# UNRELATED=1\n'); + + expect(checkEnvVarCoverage(rootDir)).toEqual([ + { + file: 'DEVELOPMENT.md', + line: 1, + message: 'SECRET_BYPASS is read by config/index.js but is undocumented in the configuration reference', + }, + { + file: '.env.example', + line: 1, + message: 'SECRET_BYPASS is read by config/index.js but is undocumented in the environment template', + }, + ]); + } finally { + fs.rmSync(rootDir, { force: true, recursive: true }); + } + }); + + it('does not sweep in Jest lane config, whose env is not app configuration', () => { + const rootDir = createTempDocsRepo(); + + try { + writeDoc(rootDir, 'config/jest/jest.base.cjs', 'const d = process.env.ALLURE_RESULTS_DIR;\n'); + writeDoc(rootDir, 'config/index.js', 'module.exports = {};\n'); + writeDoc(rootDir, 'DEVELOPMENT.md', '# Dev\n'); + writeDoc(rootDir, '.env.example', '# nothing\n'); + + expect(checkEnvVarCoverage(rootDir)).toEqual([]); + } finally { + fs.rmSync(rootDir, { force: true, recursive: true }); + } + }); + }); }); diff --git a/tests/unit/utils/validateEnvVars.test.js b/tests/unit/utils/validateEnvVars.test.js index 6b2d831010..07a2e40cf3 100644 --- a/tests/unit/utils/validateEnvVars.test.js +++ b/tests/unit/utils/validateEnvVars.test.js @@ -260,6 +260,43 @@ describe('Unit | Utils | Validate Env Vars', () => { expect(() => validateEnvVars()).not.toThrow(); }); + it.each([ + ['127.0.0.1:19090'], + ['localhost'], + ['fixtures.internal:8080,127.0.0.1:19090'], + ['api.example.com'], + ['[::1]:8080'], + [''], + ])('accepts OUTBOUND_ALLOWED_HOSTS=%s', (value) => { + const validateEnvVars = loadValidator({ + overrides: { + REPLICATE_API_TOKEN: 'test-token', + OUTBOUND_ALLOWED_HOSTS: value, + }, + }); + + expect(() => validateEnvVars()).not.toThrow(); + }); + + // Every one of these would previously have been accepted and then silently + // never matched, which for an allowlist is indistinguishable from working. + it.each([ + ['*.example.com'], + ['example.com/path'], + ['https://example.com'], + ['example.com:99999999'], + ['exam ple.com'], + ])('rejects OUTBOUND_ALLOWED_HOSTS=%s', (value) => { + const validateEnvVars = loadValidator({ + overrides: { + REPLICATE_API_TOKEN: 'test-token', + OUTBOUND_ALLOWED_HOSTS: value, + }, + }); + + expect(() => validateEnvVars()).toThrow(/OUTBOUND_ALLOWED_HOSTS/); + }); + it('accepts a non-negative TRUST_PROXY_HOPS override', () => { const validateEnvVars = loadValidator({ overrides: {