diff --git a/.env.example b/.env.example
index d64110098..64e4f3ce4 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 a8ce43208..451532839 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 000000000..7b5457294
--- /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 b34bc13f3..16ae21aa7 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 46fb34d4f..7f1984dfe 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 f3bacf8cd..daf6bf72a 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 88c217d3b..9ae6a427d 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 43fe11e15..db98ccf67 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 `` 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 3f5e62164..af3cab9b6 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 e35abfd15..b00b7b0ea 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('
\n', 'x.md')).toEqual([]);
+ expect(checkImageAlt('
\n', 'x.md')).toEqual([]);
+ });
+
+ it('accepts Markdown image syntax, which always carries the alt slot', () => {
+ expect(checkImageAlt('\n', 'x.md')).toEqual([]);
+ expect(checkImageAlt('\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 6b2d83101..07a2e40cf 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: {