diff --git a/package-lock.json b/package-lock.json index 0a6f82a449..35675ecef8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7952,6 +7952,19 @@ "@tybys/wasm-util": "^0.9.0" } }, + "node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "license": "MIT", @@ -9470,6 +9483,16 @@ "node": ">=14" } }, + "node_modules/@paralleldrive/cuid2": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.3.1.tgz", + "integrity": "sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@noble/hashes": "^1.1.5" + } + }, "node_modules/@petamoriken/float16": { "version": "3.9.1", "dev": true, @@ -11834,6 +11857,13 @@ "@types/node": "*" } }, + "node_modules/@types/cookiejar": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@types/cookiejar/-/cookiejar-2.1.5.tgz", + "integrity": "sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/d3-color": { "version": "3.1.3", "dev": true, @@ -11872,6 +11902,12 @@ "@types/trusted-types": "*" } }, + "node_modules/@types/ejs": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/@types/ejs/-/ejs-3.1.5.tgz", + "integrity": "sha512-nv+GSx77ZtXiJzwKdsASqi+YQ5Z7vwHsTP0JY2SiQgjGckkBRKZnk8nIM+7oUZ1VCtuTz0+By4qVR7fqzp/Dfg==", + "license": "MIT" + }, "node_modules/@types/emscripten": { "version": "1.41.5", "resolved": "https://registry.npmjs.org/@types/emscripten/-/emscripten-1.41.5.tgz", @@ -11933,6 +11969,24 @@ "@types/send": "*" } }, + "node_modules/@types/fs-extra": { + "version": "11.0.4", + "resolved": "https://registry.npmjs.org/@types/fs-extra/-/fs-extra-11.0.4.tgz", + "integrity": "sha512-yTbItCNreRooED33qjunPthRcSjERP1r4MqCZc7wv0u2sUkzTFp45tgUfS5+r7FrZPdmCCNflLhVSP/o+SemsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/jsonfile": "*", + "@types/node": "*" + } + }, + "node_modules/@types/github-slugger": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@types/github-slugger/-/github-slugger-1.3.0.tgz", + "integrity": "sha512-J/rMZa7RqiH/rT29TEVZO4nBoDP9XJOjnbbIofg7GQKs4JIduEO3WLpte+6WeUz/TcrXKlY+bM7FYrp8yFB+3g==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/glob": { "version": "9.0.0", "resolved": "https://registry.npmjs.org/@types/glob/-/glob-9.0.0.tgz", @@ -12032,6 +12086,16 @@ "version": "7.0.15", "license": "MIT" }, + "node_modules/@types/jsonfile": { + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/@types/jsonfile/-/jsonfile-6.1.4.tgz", + "integrity": "sha512-D5qGUYwjvnNNextdU59/+fI+spnwtTFmyQP0h+PfIOSkNfpU6AOICUOkm4i0OnSk+NyjdPJrxCDro0sJsWlRpQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/keyv": { "version": "3.1.4", "resolved": "https://registry.npmjs.org/@types/keyv/-/keyv-3.1.4.tgz", @@ -12054,6 +12118,13 @@ "version": "2.0.13", "license": "MIT" }, + "node_modules/@types/methods": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@types/methods/-/methods-1.1.4.tgz", + "integrity": "sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/mime": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", @@ -12252,6 +12323,30 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/superagent": { + "version": "8.1.9", + "resolved": "https://registry.npmjs.org/@types/superagent/-/superagent-8.1.9.tgz", + "integrity": "sha512-pTVjI73witn+9ILmoJdajHGW2jkSaOzhiFYF1Rd3EQ94kymLqB9PjD9ISg7WaALC7+dCHT0FGe9T2LktLq/3GQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/cookiejar": "^2.1.5", + "@types/methods": "^1.1.4", + "@types/node": "*", + "form-data": "^4.0.0" + } + }, + "node_modules/@types/supertest": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@types/supertest/-/supertest-6.0.3.tgz", + "integrity": "sha512-8WzXq62EXFhJ7QsH3Ocb/iKQ/Ty9ZVWnVzoTKc9tyyFRRF3a74Tk2+TLFgaFFw364Ere+npzHKEJ6ga2LzIL7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/methods": "^1.1.4", + "@types/superagent": "^8.1.0" + } + }, "node_modules/@types/supports-color": { "version": "8.1.3", "resolved": "https://registry.npmjs.org/@types/supports-color/-/supports-color-8.1.3.tgz", @@ -13886,6 +13981,13 @@ "node": ">=0.10.0" } }, + "node_modules/asap": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", + "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==", + "dev": true, + "license": "MIT" + }, "node_modules/ast-types": { "version": "0.13.4", "license": "MIT", @@ -15667,6 +15769,16 @@ "dot-prop": "^5.1.0" } }, + "node_modules/component-emitter": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.1.tgz", + "integrity": "sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/compressible": { "version": "2.0.18", "resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz", @@ -16121,6 +16233,13 @@ "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==", "license": "MIT" }, + "node_modules/cookiejar": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/cookiejar/-/cookiejar-2.1.4.tgz", + "integrity": "sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==", + "dev": true, + "license": "MIT" + }, "node_modules/cookiejs": { "version": "2.1.3", "license": "MIT", @@ -17466,6 +17585,17 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/dezalgo": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/dezalgo/-/dezalgo-1.0.4.tgz", + "integrity": "sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==", + "dev": true, + "license": "ISC", + "dependencies": { + "asap": "^2.0.0", + "wrappy": "1" + } + }, "node_modules/didyoumean": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", @@ -19272,6 +19402,13 @@ "version": "2.0.6", "license": "MIT" }, + "node_modules/fast-safe-stringify": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", + "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==", + "dev": true, + "license": "MIT" + }, "node_modules/fast-shallow-equal": { "version": "1.0.0", "dev": true @@ -19432,7 +19569,6 @@ "version": "1.0.4", "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.4.tgz", "integrity": "sha512-w1cEuf3S+DrLCQL7ET6kz+gmlJdbq9J7yXCSjK/OZCPA+qEN1WyF4ZAf0YYJa4/shHJra2t/d/r8SV4Ji+x+8Q==", - "dev": true, "license": "Apache-2.0", "dependencies": { "minimatch": "^5.0.1" @@ -19442,7 +19578,6 @@ "version": "5.1.6", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", - "dev": true, "license": "ISC", "dependencies": { "brace-expansion": "^2.0.1" @@ -19662,6 +19797,24 @@ "node": ">=12.20.0" } }, + "node_modules/formidable": { + "version": "3.5.4", + "resolved": "https://registry.npmjs.org/formidable/-/formidable-3.5.4.tgz", + "integrity": "sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@paralleldrive/cuid2": "^2.2.2", + "dezalgo": "^1.0.4", + "once": "^1.4.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "url": "https://ko-fi.com/tunnckoCore/commissions" + } + }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -23801,7 +23954,6 @@ "version": "10.9.4", "resolved": "https://registry.npmjs.org/jake/-/jake-10.9.4.tgz", "integrity": "sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA==", - "dev": true, "license": "Apache-2.0", "dependencies": { "async": "^3.2.6", @@ -26066,7 +26218,6 @@ "version": "16.0.0", "resolved": "https://registry.npmjs.org/marked/-/marked-16.0.0.tgz", "integrity": "sha512-MUKMXDjsD/eptB7GPzxo4xcnLS6oo7/RHimUMHEDRhUooPwmN9BEpMl7AEOJv3bmso169wHI2wUF9VQgL7zfmA==", - "dev": true, "license": "MIT", "peer": true, "bin": { @@ -26076,6 +26227,24 @@ "node": ">= 20" } }, + "node_modules/marked-gfm-heading-id": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/marked-gfm-heading-id/-/marked-gfm-heading-id-4.1.3.tgz", + "integrity": "sha512-aR0i63LmFbuxU/gAgrgz1Ir+8HK6zAIFXMlckeKHpV+qKbYaOP95L4Ux5Gi+sKmCZU5qnN2rdKpvpb7PnUBIWg==", + "license": "MIT", + "dependencies": { + "github-slugger": "^2.0.0" + }, + "peerDependencies": { + "marked": ">=13 <18" + } + }, + "node_modules/marked-gfm-heading-id/node_modules/github-slugger": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/github-slugger/-/github-slugger-2.0.0.tgz", + "integrity": "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw==", + "license": "ISC" + }, "node_modules/marked-mangle": { "version": "1.1.11", "resolved": "https://registry.npmjs.org/marked-mangle/-/marked-mangle-1.1.11.tgz", @@ -36423,6 +36592,81 @@ "dev": true, "license": "MIT" }, + "node_modules/superagent": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/superagent/-/superagent-10.3.0.tgz", + "integrity": "sha512-B+4Ik7ROgVKrQsXTV0Jwp2u+PXYLSlqtDAhYnkkD+zn3yg8s/zjA2MeGayPoY/KICrbitwneDHrjSotxKL+0XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "component-emitter": "^1.3.1", + "cookiejar": "^2.1.4", + "debug": "^4.3.7", + "fast-safe-stringify": "^2.1.1", + "form-data": "^4.0.5", + "formidable": "^3.5.4", + "methods": "^1.1.2", + "mime": "2.6.0", + "qs": "^6.14.1" + }, + "engines": { + "node": ">=14.18.0" + } + }, + "node_modules/superagent/node_modules/mime": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", + "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", + "dev": true, + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/superagent/node_modules/qs": { + "version": "6.14.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz", + "integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/supertest": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/supertest/-/supertest-7.2.2.tgz", + "integrity": "sha512-oK8WG9diS3DlhdUkcFn4tkNIiIbBx9lI2ClF8K+b2/m8Eyv47LSawxUzZQSNKUrVb2KsqeTDCcjAAVPYaSLVTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cookie-signature": "^1.2.2", + "methods": "^1.1.2", + "superagent": "^10.3.0" + }, + "engines": { + "node": ">=14.18.0" + } + }, + "node_modules/supertest/node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, "node_modules/supports-color": { "version": "7.2.0", "license": "MIT", @@ -40350,10 +40594,18 @@ "version": "0.0.1", "license": "Apache-2.0", "dependencies": { + "@types/ejs": "^3.1.5", + "chokidar": "^3.6.0", "debug": "^4.3.7", + "ejs": "^4.0.1", + "express": "^4.18.0", + "fs-extra": "^11.3.3", + "github-slugger": "^1.5.0", + "globby": "^11.1.0", "gray-matter": "^4.0.3", "isomorphic-dompurify": "^2.16.0", "marked": "^13.0.0", + "marked-gfm-heading-id": "^4.1.3", "minimist": "^1.2.8" }, "bin": { @@ -40362,12 +40614,46 @@ "devDependencies": { "@types/debug": "^4.1.12", "@types/dompurify": "^3.0.5", - "@types/minimist": "^1.2.5" + "@types/express": "^4.17.21", + "@types/fs-extra": "^11.0.4", + "@types/github-slugger": "^1.3.0", + "@types/minimist": "^1.2.5", + "@types/supertest": "^6.0.2", + "supertest": "^7.0.0" }, "engines": { "node": ">=24" } }, + "packages/plugin-docs-renderer/node_modules/ejs": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/ejs/-/ejs-4.0.1.tgz", + "integrity": "sha512-krvQtxc0btwSm/nvnt1UpnaFDFVJpJ0fdckmALpCgShsr/iGYHTnJiUliZTgmzq/UxTX33TtOQVKaNigMQp/6Q==", + "license": "Apache-2.0", + "dependencies": { + "jake": "^10.9.1" + }, + "bin": { + "ejs": "bin/cli.js" + }, + "engines": { + "node": ">=0.12.18" + } + }, + "packages/plugin-docs-renderer/node_modules/fs-extra": { + "version": "11.3.3", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.3.tgz", + "integrity": "sha512-VWSRii4t0AFm6ixFFmLLx1t7wS1gh+ckoa84aOeapGum0h+EZd1EhEumSB+ZdDLnEPuucsVB9oB7cxJHap6Afg==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=14.14" + } + }, "packages/plugin-docs-renderer/node_modules/marked": { "version": "13.0.3", "resolved": "https://registry.npmjs.org/marked/-/marked-13.0.3.tgz", diff --git a/packages/plugin-docs-renderer/package.json b/packages/plugin-docs-renderer/package.json index bf2ee9ea2d..f05f2747d0 100644 --- a/packages/plugin-docs-renderer/package.json +++ b/packages/plugin-docs-renderer/package.json @@ -26,6 +26,8 @@ }, "scripts": { "build": "rollup -c ../../rollup.config.ts --configPlugin esbuild", + "postbuild": "cp -r src/server/views dist/server/ && cp -r src/server/styles dist/server/", + "dev": "rollup -c ../../rollup.config.ts --configPlugin esbuild --watch", "lint": "eslint --cache ./src", "lint:fix": "npm run lint -- --fix", "lint:package": "publint", @@ -46,15 +48,28 @@ "node": ">=24" }, "dependencies": { - "marked": "^13.0.0", + "@types/ejs": "^3.1.5", + "chokidar": "^3.6.0", + "debug": "^4.3.7", + "ejs": "^4.0.1", + "express": "^4.18.0", + "fs-extra": "^11.3.3", + "github-slugger": "^1.5.0", + "globby": "^11.1.0", "gray-matter": "^4.0.3", - "minimist": "^1.2.8", "isomorphic-dompurify": "^2.16.0", - "debug": "^4.3.7" + "marked": "^13.0.0", + "marked-gfm-heading-id": "^4.1.3", + "minimist": "^1.2.8" }, "devDependencies": { - "@types/minimist": "^1.2.5", + "@types/debug": "^4.1.12", "@types/dompurify": "^3.0.5", - "@types/debug": "^4.1.12" + "@types/express": "^4.17.21", + "@types/fs-extra": "^11.0.4", + "@types/github-slugger": "^1.3.0", + "@types/minimist": "^1.2.5", + "@types/supertest": "^6.0.2", + "supertest": "^7.0.0" } } diff --git a/packages/plugin-docs-renderer/src/__fixtures__/test-docs/advanced.md b/packages/plugin-docs-renderer/src/__fixtures__/test-docs/advanced.md new file mode 100644 index 0000000000..e8c13a1826 --- /dev/null +++ b/packages/plugin-docs-renderer/src/__fixtures__/test-docs/advanced.md @@ -0,0 +1,9 @@ +--- +title: Advanced Topics +description: Advanced topics for experienced users +sidebar_position: 3 +--- + +# Advanced Topics + +This covers advanced topics. diff --git a/packages/plugin-docs-renderer/src/__fixtures__/test-docs/config/database.md b/packages/plugin-docs-renderer/src/__fixtures__/test-docs/config/database.md new file mode 100644 index 0000000000..ccf14c8e5c --- /dev/null +++ b/packages/plugin-docs-renderer/src/__fixtures__/test-docs/config/database.md @@ -0,0 +1,9 @@ +--- +title: Database +description: Database configuration options +sidebar_position: 2 +--- + +# Database + +Configure database connections. diff --git a/packages/plugin-docs-renderer/src/__fixtures__/test-docs/config/settings.md b/packages/plugin-docs-renderer/src/__fixtures__/test-docs/config/settings.md new file mode 100644 index 0000000000..0dbb492f7e --- /dev/null +++ b/packages/plugin-docs-renderer/src/__fixtures__/test-docs/config/settings.md @@ -0,0 +1,9 @@ +--- +title: Settings +description: Configuration settings +sidebar_position: 1 +--- + +# Settings + +Configure your plugin settings here. diff --git a/packages/plugin-docs-renderer/src/__fixtures__/test-docs/guide.md b/packages/plugin-docs-renderer/src/__fixtures__/test-docs/guide.md new file mode 100644 index 0000000000..04f22a6cde --- /dev/null +++ b/packages/plugin-docs-renderer/src/__fixtures__/test-docs/guide.md @@ -0,0 +1,9 @@ +--- +title: User Guide +description: A guide page for users +sidebar_position: 2 +--- + +# User Guide + +This is a guide page. diff --git a/packages/plugin-docs-renderer/src/__fixtures__/test-docs/home.md b/packages/plugin-docs-renderer/src/__fixtures__/test-docs/home.md new file mode 100644 index 0000000000..fb408829af --- /dev/null +++ b/packages/plugin-docs-renderer/src/__fixtures__/test-docs/home.md @@ -0,0 +1,9 @@ +--- +title: Home Page +description: Welcome to the test docs +sidebar_position: 1 +--- + +# Welcome + +This is the home page of the test documentation. diff --git a/packages/plugin-docs-renderer/src/__fixtures__/test-docs/img/test.png b/packages/plugin-docs-renderer/src/__fixtures__/test-docs/img/test.png new file mode 100644 index 0000000000..613754cfaf Binary files /dev/null and b/packages/plugin-docs-renderer/src/__fixtures__/test-docs/img/test.png differ diff --git a/packages/plugin-docs-renderer/src/bin/run.ts b/packages/plugin-docs-renderer/src/bin/run.ts index 5504e298e7..b84dd8b115 100644 --- a/packages/plugin-docs-renderer/src/bin/run.ts +++ b/packages/plugin-docs-renderer/src/bin/run.ts @@ -1,53 +1,67 @@ #!/usr/bin/env node import { access } from 'node:fs/promises'; +import { resolve } from 'node:path'; import minimist from 'minimist'; -import { loadDocsFolder } from '../loader.js'; -import { parseMarkdown } from '../parser.js'; +import createDebug from 'debug'; +import { startServer } from '../server/server.js'; + +const debug = createDebug('plugin-docs-renderer:cli'); + +async function commandServe(docsPath: string, port: number, liveReload: boolean) { + debug('Command serve: docsPath=%s, port=%d, liveReload=%s', docsPath, port, liveReload); + + try { + await startServer({ + docsPath, + port, + liveReload, + }); + } catch (error) { + debug('Failed to start server: %O', error); + if (error instanceof Error) { + console.error('Error starting server:', error.message); + } else { + console.error('Error starting server:', error); + } + process.exit(1); + } +} async function main() { - const argv = minimist(process.argv.slice(2)); - // get docs path from command line arguments or use default (./docs) - const docsPath = argv._[0] || './docs'; + debug('CLI invoked with args: %O', process.argv.slice(2)); + + const argv = minimist(process.argv.slice(2), { + boolean: ['reload'], + string: ['port'], + alias: { + p: 'port', + r: 'reload', + }, + default: { + port: '3001', + reload: false, + }, + }); + + const docsPath = resolve(argv._[0] || './docs'); + debug('Resolved docs path: %s', docsPath); // check if the path exists try { await access(docsPath); } catch { - console.error(`Path not found: ${docsPath}`); - console.error('Usage: npx @grafana/plugin-docs-renderer [docs-folder]'); - console.error('Example: npx @grafana/plugin-docs-renderer ./docs'); + console.error(`Error: Path not found: ${docsPath}`); process.exit(1); } - // load and parse documentation - try { - const { manifest, files } = await loadDocsFolder(docsPath); - - // parse each markdown file - const pages: Record; html: string }> = {}; - for (const [filePath, content] of Object.entries(files)) { - const parsed = parseMarkdown(content); - pages[filePath] = { - frontmatter: parsed.frontmatter, - html: parsed.html, - }; - } - - // output to console - const result = { - manifest, - pages, - }; - - console.log(JSON.stringify(result, null, 2)); - } catch (error) { - if (error instanceof Error) { - console.error('Error processing documentation:', error.message); - } else { - console.error('Error processing documentation:', error); - } + // parse port + const port = parseInt(argv.port, 10); + if (isNaN(port)) { + console.error(`Error: Invalid port: ${argv.port}`); process.exit(1); } + + await commandServe(docsPath, port, argv.reload); } main(); diff --git a/packages/plugin-docs-renderer/src/cli/scanner.test.ts b/packages/plugin-docs-renderer/src/cli/scanner.test.ts new file mode 100644 index 0000000000..ffc6e77d03 --- /dev/null +++ b/packages/plugin-docs-renderer/src/cli/scanner.test.ts @@ -0,0 +1,121 @@ +import { describe, it, expect } from 'vitest'; +import { join } from 'node:path'; +import { scanDocsFolder } from './scanner.js'; + +describe('scanDocsFolder', () => { + const testDocsPath = join(__dirname, '..', '__fixtures__', 'test-docs'); + + it('should scan markdown files and generate manifest', async () => { + const result = await scanDocsFolder(testDocsPath); + + expect(result.manifest).toBeDefined(); + expect(result.manifest.title).toBe('Plugin Documentation'); + expect(result.manifest.pages).toHaveLength(4); // home, guide, advanced, config + }); + + it('should sort pages by sidebar_position', async () => { + const result = await scanDocsFolder(testDocsPath); + + const pages = result.manifest.pages; + expect(pages[0].title).toBe('Home Page'); + expect(pages[0].slug).toBe('home'); + expect(pages[1].title).toBe('User Guide'); + expect(pages[1].slug).toBe('guide'); + expect(pages[2].title).toBe('Advanced Topics'); + expect(pages[2].slug).toBe('advanced'); + }); + + it('should generate slugs from file paths', async () => { + const result = await scanDocsFolder(testDocsPath); + + const pages = result.manifest.pages; + expect(pages[0].slug).toBe('home'); + expect(pages[1].slug).toBe('guide'); + expect(pages[2].slug).toBe('advanced'); + }); + + it('should load file contents into memory', async () => { + const result = await scanDocsFolder(testDocsPath); + + expect(result.files).toBeDefined(); + expect(Object.keys(result.files)).toHaveLength(5); // includes nested config files + expect(result.files['home.md']).toContain('---'); + expect(result.files['home.md']).toContain('title: Home Page'); + expect(result.files['home.md']).toContain('# Welcome'); + }); + + it('should preserve frontmatter in file contents', async () => { + const result = await scanDocsFolder(testDocsPath); + + const homeContent = result.files['home.md']; + expect(homeContent).toContain('title: Home Page'); + expect(homeContent).toContain('description: Welcome to the test docs'); + expect(homeContent).toContain('sidebar_position: 1'); + }); + + it('should include file reference in page object', async () => { + const result = await scanDocsFolder(testDocsPath); + + const pages = result.manifest.pages; + expect(pages[0].file).toBe('home.md'); + expect(pages[1].file).toBe('guide.md'); + expect(pages[2].file).toBe('advanced.md'); + }); + + it('should throw error when no valid markdown files found', async () => { + const emptyPath = join(__dirname, '..', '__fixtures__', 'non-existent'); + + await expect(scanDocsFolder(emptyPath)).rejects.toThrow('No valid markdown files found'); + }); + + describe('nested directories', () => { + it('should create category page for directories with files', async () => { + const result = await scanDocsFolder(testDocsPath); + + const configPage = result.manifest.pages.find((p) => p.slug === 'config'); + expect(configPage).toBeDefined(); + expect(configPage?.title).toBe('Config'); + expect(configPage?.children).toBeDefined(); + expect(configPage?.children).toHaveLength(2); + }); + + it('should generate slugs with directory prefixes', async () => { + const result = await scanDocsFolder(testDocsPath); + + const configPage = result.manifest.pages.find((p) => p.slug === 'config'); + const children = configPage?.children || []; + + expect(children[0].slug).toBe('config/settings'); + expect(children[1].slug).toBe('config/database'); + }); + + it('should sort nested pages by sidebar_position', async () => { + const result = await scanDocsFolder(testDocsPath); + + const configPage = result.manifest.pages.find((p) => p.slug === 'config'); + const children = configPage?.children || []; + + expect(children[0].title).toBe('Settings'); + expect(children[1].title).toBe('Database'); + }); + + it('should store nested files with relative paths', async () => { + const result = await scanDocsFolder(testDocsPath); + + expect(result.files['config/settings.md']).toBeDefined(); + expect(result.files['config/settings.md']).toContain('title: Settings'); + expect(result.files['config/database.md']).toBeDefined(); + expect(result.files['config/database.md']).toContain('title: Database'); + }); + + it('should reference nested files correctly in page objects', async () => { + const result = await scanDocsFolder(testDocsPath); + + const configPage = result.manifest.pages.find((p) => p.slug === 'config'); + const children = configPage?.children || []; + + expect(children[0].file).toBe('config/settings.md'); + expect(children[1].file).toBe('config/database.md'); + }); + }); +}); diff --git a/packages/plugin-docs-renderer/src/cli/scanner.ts b/packages/plugin-docs-renderer/src/cli/scanner.ts new file mode 100644 index 0000000000..f7e4c1b26b --- /dev/null +++ b/packages/plugin-docs-renderer/src/cli/scanner.ts @@ -0,0 +1,265 @@ +import { readFile } from 'node:fs/promises'; +import { join, relative, parse, sep } from 'node:path'; +import globby from 'globby'; +import GithubSlugger from 'github-slugger'; +import matter from 'gray-matter'; +import createDebug from 'debug'; +import { extractHeadingsFromMarkdown } from './toc.js'; +import type { Manifest, Page, MarkdownFiles, Frontmatter } from '../types.js'; + +const debug = createDebug('plugin-docs-renderer:scanner'); + +/** + * A scanned markdown file with parsed frontmatter. + */ +interface ScannedFile { + /** + * Absolute path to the file. + */ + absolutePath: string; + + /** + * Relative path from docs root (e.g., "01-overview.md" or "config/01-auth.md"). + */ + relativePath: string; + + /** + * Parsed frontmatter metadata. + */ + frontmatter: Frontmatter; + + /** + * Raw markdown content (with frontmatter). + */ + content: string; + + /** + * Extracted headings for table of contents. + */ + headings: Array; +} + +/** + * Result of scanning a docs folder. + */ +export interface ScannedDocs { + /** + * Generated manifest from filesystem structure. + */ + manifest: Manifest; + + /** + * All markdown files indexed by their relative path. + */ + files: MarkdownFiles; +} + +/** + * Generates a URL slug from a file path. + * Examples: + * "overview.md" → "overview" + * "config/auth.md" → "config/auth" + */ +function generateSlug(filePath: string): string { + const slugger = new GithubSlugger(); + const parsed = parse(filePath); + + // build slug parts by slugifying each segment separately + const parts: string[] = []; + + if (parsed.dir) { + const dirParts = parsed.dir.split(sep); + parts.push(...dirParts.map((part) => slugger.slug(part))); + } + + parts.push(slugger.slug(parsed.name)); + + return parts.join('/'); +} + +/** + * Recursively scans a directory for markdown files and parses their frontmatter. + * + * @param docsPath - Absolute path to the docs folder + * @returns Array of scanned files with frontmatter + */ +async function scanMarkdownFiles(docsPath: string): Promise { + debug('Scanning for markdown files in: %s', docsPath); + + // find all .md files recursively + const pattern = join(docsPath, '**/*.md'); + const filePaths = await globby(pattern, { + ignore: ['**/node_modules/**', '**/dist/**'], + }); + + debug('Found %d markdown file(s)', filePaths.length); + + const scannedFiles: ScannedFile[] = []; + + for (const absolutePath of filePaths) { + const relativePath = relative(docsPath, absolutePath); + + // read and parse the file + const fileContent = await readFile(absolutePath, 'utf-8'); + const parsed = matter(fileContent); + + // validate frontmatter has required fields + const frontmatter = parsed.data as Partial; + if (!frontmatter.title || !frontmatter.description || frontmatter.sidebar_position === undefined) { + console.warn( + `Warning: ${relativePath} missing required frontmatter fields (title, description, sidebar_position)` + ); + continue; + } + + // extract headings directly from markdown + const headings = extractHeadingsFromMarkdown(fileContent); + + scannedFiles.push({ + absolutePath, + relativePath, + frontmatter: frontmatter as Frontmatter, + content: fileContent, // store original content with frontmatter + headings, + }); + } + + debug('Successfully scanned %d file(s) with valid frontmatter', scannedFiles.length); + return scannedFiles; +} + +/** + * Tree node for building hierarchical structure. + */ +interface TreeNode { + name: string; + file?: ScannedFile; + children: Map; +} + +function buildTree(scannedFiles: ScannedFile[]): TreeNode { + const root: TreeNode = { name: '', children: new Map() }; + + for (const file of scannedFiles) { + const parts = file.relativePath.split(sep); + let current = root; + + // navigate/create tree structure + for (let i = 0; i < parts.length - 1; i++) { + const part = parts[i]; + if (!current.children.has(part)) { + current.children.set(part, { name: part, children: new Map() }); + } + current = current.children.get(part)!; + } + + // add the file at the leaf + const fileName = parts[parts.length - 1]; + current.children.set(fileName, { + name: fileName, + file, + children: new Map(), + }); + } + + return root; +} + +// converts a tree node to Page array (recursively). +function treeToPages(node: TreeNode): Page[] { + const pages: Page[] = []; + + // convert children to array and sort by sidebar_position + const childEntries = Array.from(node.children.entries()); + const sortedEntries = childEntries.sort( + (a, b) => + (a[1].file?.frontmatter.sidebar_position ?? Infinity) - (b[1].file?.frontmatter.sidebar_position ?? Infinity) + ); + + for (const [name, child] of sortedEntries) { + if (child.file) { + // it's a markdown file + const page: Page = { + title: child.file.frontmatter.title, + slug: child.file.frontmatter.slug || generateSlug(child.file.relativePath), + file: child.file.relativePath, + headings: child.file.headings, + }; + + // if this file has children (folder with same name), add them + if (child.children.size > 0) { + page.children = treeToPages(child); + } + + pages.push(page); + } else if (child.children.size > 0) { + // it's a directory without a file - create category + const page: Page = { + title: name.charAt(0).toUpperCase() + name.slice(1).replace(/-/g, ' '), + slug: name, + file: '', + children: treeToPages(child), + }; + pages.push(page); + } + } + + return pages; +} + +/** + * Builds a hierarchical manifest from scanned files based on folder structure. + * + * @param scannedFiles - Array of scanned markdown files + * @returns Generated manifest + */ +function buildManifest(scannedFiles: ScannedFile[]): Manifest { + debug('Building manifest from %d file(s)', scannedFiles.length); + + // build tree structure from file paths + const tree = buildTree(scannedFiles); + + // convert tree to pages array + const pages = treeToPages(tree); + + const manifest: Manifest = { + title: 'Plugin Documentation', + pages, + }; + + debug('Generated manifest with %d top-level page(s)', pages.length); + return manifest; +} + +/** + * Scans a docs folder and generates a manifest from the filesystem structure. + * + * @param docsPath - Absolute path to the docs folder + * @returns Scanned docs with generated manifest and file contents + */ +export async function scanDocsFolder(docsPath: string): Promise { + debug('Starting scan of docs folder: %s', docsPath); + + // scan all markdown files + const scannedFiles = await scanMarkdownFiles(docsPath); + + if (scannedFiles.length === 0) { + throw new Error(`No valid markdown files found in ${docsPath}`); + } + + // build manifest from scanned files + const manifest = buildManifest(scannedFiles); + + // create files map + const files: MarkdownFiles = {}; + for (const file of scannedFiles) { + files[file.relativePath] = file.content; + } + + debug('Scan complete: %d files, %d pages', Object.keys(files).length, manifest.pages.length); + + return { + manifest, + files, + }; +} diff --git a/packages/plugin-docs-renderer/src/cli/toc.ts b/packages/plugin-docs-renderer/src/cli/toc.ts new file mode 100644 index 0000000000..efcaf99488 --- /dev/null +++ b/packages/plugin-docs-renderer/src/cli/toc.ts @@ -0,0 +1,46 @@ +/** + * Table of Contents utilities for plugin documentation. + * + * Provides functions to extract heading data from markdown or HTML. + * HTML rendering is handled by the EJS partial at src/server/views/partials/toc.ejs. + */ + +import { marked } from 'marked'; +import GithubSlugger from 'github-slugger'; +import type { Heading } from '../types.js'; + +// re-export Heading type for convenience +export type { Heading }; + +/** + * Extracts H2 and H3 headings directly from markdown content. + * Uses marked's lexer to tokenize markdown, more efficient than parsing HTML. + * + * @param content - The markdown content (with or without frontmatter) + * @returns Array of heading objects + */ +export function extractHeadingsFromMarkdown(content: string): Heading[] { + // remove frontmatter if present (marked.lexer will fail on it) + const withoutFrontmatter = content.replace(/^---\n[\s\S]*?\n---\n/, ''); + + // tokenize markdown + const tokens = marked.lexer(withoutFrontmatter); + const slugger = new GithubSlugger(); + const headings: Heading[] = []; + + // extract heading tokens + for (const token of tokens) { + if (token.type === 'heading' && (token.depth === 2 || token.depth === 3)) { + const text = token.text; + const id = slugger.slug(text); + + headings.push({ + level: token.depth, + text, + id, + }); + } + } + + return headings; +} diff --git a/packages/plugin-docs-renderer/src/index.test.ts b/packages/plugin-docs-renderer/src/index.test.ts index 4c3cfed236..e810a42989 100644 --- a/packages/plugin-docs-renderer/src/index.test.ts +++ b/packages/plugin-docs-renderer/src/index.test.ts @@ -4,7 +4,6 @@ import type { Manifest, Page, MarkdownFiles } from './index.js'; describe('@grafana/plugin-docs-renderer', () => { it('should export core types', () => { const manifest: Manifest = { - version: '1.0', title: 'Test Documentation', pages: [], }; @@ -19,7 +18,7 @@ describe('@grafana/plugin-docs-renderer', () => { 'index.md': '# Overview', }; - expect(manifest.version).toBe('1.0'); + expect(manifest.title).toBe('Test Documentation'); expect(page.title).toBe('Overview'); expect(files['index.md']).toBe('# Overview'); }); diff --git a/packages/plugin-docs-renderer/src/index.ts b/packages/plugin-docs-renderer/src/index.ts index 5a73e29cf6..aeda3d5aad 100644 --- a/packages/plugin-docs-renderer/src/index.ts +++ b/packages/plugin-docs-renderer/src/index.ts @@ -1,10 +1,5 @@ -// export types -export type { Manifest, Page, MarkdownFiles } from './types.js'; - -// export parser functions +// Library exports - pure markdown parsing functions only +// (CLI utilities like filesystem scanning are not exported) export { parseMarkdown } from './parser.js'; export type { ParsedMarkdown } from './parser.js'; - -// export loader functions -export { loadDocsFolder } from './loader.js'; -export type { LoadedDocs } from './loader.js'; +export type { Manifest, Page, MarkdownFiles, Frontmatter } from './types.js'; diff --git a/packages/plugin-docs-renderer/src/loader.ts b/packages/plugin-docs-renderer/src/loader.ts deleted file mode 100644 index d8d782d5d3..0000000000 --- a/packages/plugin-docs-renderer/src/loader.ts +++ /dev/null @@ -1,121 +0,0 @@ -import { readFile, readdir } from 'node:fs/promises'; -import { join, relative } from 'node:path'; -import createDebug from 'debug'; -import type { Manifest, MarkdownFiles } from './types.js'; - -const debug = createDebug('plugin-docs-renderer:loader'); - -/** - * Result of loading a docs folder. - */ -export interface LoadedDocs { - /** - * The parsed manifest.json. - */ - manifest: Manifest; - - /** - * All markdown files indexed by their relative path. - */ - files: MarkdownFiles; -} - -/** - * Recursively finds all markdown files in a directory. - * - * @param dir - The directory to search - * @param baseDir - The base directory for calculating relative paths - * @returns Array of absolute file paths - */ -async function findMarkdownFiles(dir: string, baseDir: string): Promise { - const files: string[] = []; - debug('Searching for markdown files in %s', dir); - - let entries; - try { - entries = await readdir(dir, { withFileTypes: true }); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - throw new Error(`Failed to read directory ${dir}: ${message}`); - } - - for (const entry of entries) { - const fullPath = join(dir, entry.name); - - if (entry.isDirectory()) { - // recursively search subdirectories - debug('Found subdirectory: %s', entry.name); - const subFiles = await findMarkdownFiles(fullPath, baseDir); - files.push(...subFiles); - } else if (entry.isFile() && entry.name.endsWith('.md')) { - debug('Found markdown file: %s', entry.name); - files.push(fullPath); - } - } - - debug('Found %d markdown file(s) in %s', files.length, dir); - return files; -} - -/** - * Loads a documentation folder containing manifest.json and markdown files. - * - * @param rootPath - The absolute path to the docs folder - * @returns The loaded manifest and markdown files - * @throws {Error} If manifest.json is not found or invalid - */ -export async function loadDocsFolder(rootPath: string): Promise { - debug('Loading docs folder: %s', rootPath); - - // read and parse manifest.json - const manifestPath = join(rootPath, 'manifest.json'); - debug('Reading manifest from: %s', manifestPath); - - let manifestContent; - try { - manifestContent = await readFile(manifestPath, 'utf-8'); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - throw new Error(`Failed to read manifest.json at ${manifestPath}: ${message}`); - } - - let manifest; - try { - manifest = JSON.parse(manifestContent) as Manifest; - debug('Parsed manifest: title=%s, version=%s, pages=%d', manifest.title, manifest.version, manifest.pages.length); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - throw new Error(`Failed to parse manifest.json: ${message}`); - } - - // find all markdown files - let markdownPaths; - try { - markdownPaths = await findMarkdownFiles(rootPath, rootPath); - debug('Found %d total markdown file(s)', markdownPaths.length); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - throw new Error(`Failed to find markdown files in ${rootPath}: ${message}`); - } - - // read all markdown files - const files: MarkdownFiles = {}; - for (const filePath of markdownPaths) { - const relativePath = relative(rootPath, filePath); - debug('Reading markdown file: %s', relativePath); - - try { - const content = await readFile(filePath, 'utf-8'); - files[relativePath] = content; - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - throw new Error(`Failed to read markdown file ${relativePath}: ${message}`); - } - } - - debug('Successfully loaded docs folder with %d file(s)', Object.keys(files).length); - return { - manifest, - files, - }; -} diff --git a/packages/plugin-docs-renderer/src/parser.ts b/packages/plugin-docs-renderer/src/parser.ts index 3673d46057..a44eabd4d9 100644 --- a/packages/plugin-docs-renderer/src/parser.ts +++ b/packages/plugin-docs-renderer/src/parser.ts @@ -2,9 +2,20 @@ import { marked } from 'marked'; import matter from 'gray-matter'; import DOMPurify from 'isomorphic-dompurify'; import createDebug from 'debug'; +import { gfmHeadingId } from 'marked-gfm-heading-id'; const debug = createDebug('plugin-docs-renderer:parser'); +// configure marked once at module level with gfm and heading IDs +marked.use({ + gfm: true, + breaks: false, +}); + +// use marked-gfm-heading-id extension for automatic heading IDs +// @ts-expect-error - type mismatch due to duplicate marked instances in monorepo +marked.use(gfmHeadingId()); + /** * Result of parsing a markdown file. */ @@ -45,12 +56,7 @@ export function parseMarkdown(content: string): ParsedMarkdown { } // parse markdown to HTML using marked - // configure marked to use GitHub Flavored Markdown - marked.setOptions({ - gfm: true, // enable GitHub Flavored Markdown - breaks: false, // don't convert \n to
- }); - + // heading IDs are automatically added by marked-gfm-heading-id extension let rawHtml: string; try { rawHtml = marked.parse(markdownContent) as string; diff --git a/packages/plugin-docs-renderer/src/server/server.test.ts b/packages/plugin-docs-renderer/src/server/server.test.ts new file mode 100644 index 0000000000..32cd7d318e --- /dev/null +++ b/packages/plugin-docs-renderer/src/server/server.test.ts @@ -0,0 +1,166 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import request from 'supertest'; +import { join } from 'node:path'; +import type { Express } from 'express'; +import { startServer, type Server } from './server.js'; + +describe('startServer', () => { + const testDocsPath = join(__dirname, '..', '__fixtures__', 'test-docs'); + let app: Express; + let server: Server | null = null; + + afterEach(async () => { + if (server) { + await server.close(); + server = null; + } + }); + + it('should serve the homepage (first page in manifest)', async () => { + const result = await startServer({ docsPath: testDocsPath, port: 0 }); + server = result; + app = result.app; + + const response = await request(app).get('/'); + + expect(response.status).toBe(200); + expect(response.text).toContain('Home Page - Plugin Documentation'); + expect(response.text).toContain('

Welcome

'); + expect(response.text).toContain('This is the home page of the test documentation.'); + }); + + it('should serve a specific page by slug', async () => { + const result = await startServer({ docsPath: testDocsPath, port: 0 }); + server = result; + app = result.app; + + const response = await request(app).get('/guide'); + + expect(response.status).toBe(200); + expect(response.text).toContain('User Guide - Plugin Documentation'); + expect(response.text).toContain('

User Guide

'); + expect(response.text).toContain('This is a guide page.'); + }); + + it('should serve a nested page by slug', async () => { + const result = await startServer({ docsPath: testDocsPath, port: 0 }); + server = result; + app = result.app; + + const response = await request(app).get('/advanced'); + + expect(response.status).toBe(200); + expect(response.text).toContain('

Advanced Topics

'); + expect(response.text).toContain('This covers advanced topics.'); + }); + + it('should return 404 for non-existent page', async () => { + const result = await startServer({ docsPath: testDocsPath, port: 0 }); + server = result; + app = result.app; + + const response = await request(app).get('/non-existent'); + + expect(response.status).toBe(404); + expect(response.text).toContain('Page not found'); + }); + + it('should include navigation links', async () => { + const result = await startServer({ docsPath: testDocsPath, port: 0 }); + server = result; + app = result.app; + + const response = await request(app).get('/'); + + expect(response.status).toBe(200); + expect(response.text).toContain('