diff --git a/package-lock.json b/package-lock.json index cffab339fe..008efb7fc2 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, @@ -11934,6 +11964,13 @@ "@types/send": "*" } }, + "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", @@ -12055,6 +12092,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", @@ -12253,6 +12297,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", @@ -13887,6 +13955,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", @@ -15668,6 +15743,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", @@ -16122,6 +16207,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", @@ -17467,6 +17559,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", @@ -19273,6 +19376,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 @@ -19433,7 +19543,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" @@ -19443,7 +19552,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" @@ -19663,6 +19771,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", @@ -23802,7 +23928,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", @@ -36424,6 +36549,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", @@ -40351,7 +40551,12 @@ "version": "0.0.3", "license": "Apache-2.0", "dependencies": { + "chokidar": "^3.6.0", "debug": "^4.3.7", + "ejs": "^4.0.1", + "express": "^4.21.2", + "github-slugger": "^1.5.0", + "globby": "^11.1.0", "gray-matter": "^4.0.3", "isomorphic-dompurify": "^2.16.0", "marked": "^13.0.0", @@ -40363,12 +40568,42 @@ "devDependencies": { "@types/debug": "^4.1.12", "@types/dompurify": "^3.2.0", - "@types/minimist": "^1.2.5" + "@types/express": "^4.17.23", + "@types/github-slugger": "^1.3.0", + "@types/minimist": "^1.2.5", + "@types/node": "^25.2.2", + "@types/supertest": "^6.0.3", + "supertest": "^7.2.2" }, "engines": { "node": ">=24" } }, + "packages/plugin-docs-renderer/node_modules/@types/node": { + "version": "25.2.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.2.2.tgz", + "integrity": "sha512-BkmoP5/FhRYek5izySdkOneRyXYN35I860MFAGupTdebyE66uZaR+bXLHq8k4DirE5DwQi3NuhvRU1jqTVwUrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "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/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 91868abfaf..e344a57cb2 100644 --- a/packages/plugin-docs-renderer/package.json +++ b/packages/plugin-docs-renderer/package.json @@ -26,6 +26,7 @@ }, "scripts": { "build": "rollup -c ../../rollup.config.ts --configPlugin esbuild", + "dev": "rollup -c ../../rollup.config.ts --configPlugin esbuild --watch", "lint": "eslint --cache ./src", "lint:fix": "npm run lint -- --fix", "lint:package": "publint", @@ -46,7 +47,12 @@ "node": ">=24" }, "dependencies": { + "chokidar": "^3.6.0", "debug": "^4.3.7", + "ejs": "^4.0.1", + "express": "^4.21.2", + "github-slugger": "^1.5.0", + "globby": "^11.1.0", "gray-matter": "^4.0.3", "isomorphic-dompurify": "^2.16.0", "marked": "^13.0.0", @@ -55,6 +61,11 @@ "devDependencies": { "@types/debug": "^4.1.12", "@types/dompurify": "^3.2.0", - "@types/minimist": "^1.2.5" + "@types/express": "^4.17.23", + "@types/github-slugger": "^1.3.0", + "@types/minimist": "^1.2.5", + "@types/node": "^25.2.2", + "@types/supertest": "^6.0.3", + "supertest": "^7.2.2" } } diff --git a/packages/plugin-docs-renderer/src/__fixtures__/test-docs-malformed/malformed-yaml.md b/packages/plugin-docs-renderer/src/__fixtures__/test-docs-malformed/malformed-yaml.md new file mode 100644 index 0000000000..dd93fa739d --- /dev/null +++ b/packages/plugin-docs-renderer/src/__fixtures__/test-docs-malformed/malformed-yaml.md @@ -0,0 +1,9 @@ +--- +title: [unclosed bracket +description: this: is: bad: yaml: {{{ +sidebar_position: not a number +--- + +# Malformed YAML + +This file has broken YAML in its frontmatter. diff --git a/packages/plugin-docs-renderer/src/__fixtures__/test-docs-malformed/missing-fields.md b/packages/plugin-docs-renderer/src/__fixtures__/test-docs-malformed/missing-fields.md new file mode 100644 index 0000000000..44e9027ef4 --- /dev/null +++ b/packages/plugin-docs-renderer/src/__fixtures__/test-docs-malformed/missing-fields.md @@ -0,0 +1,7 @@ +--- +title: Missing Fields Page +--- + +# Missing Fields + +This page has a title but is missing description and sidebar_position. diff --git a/packages/plugin-docs-renderer/src/__fixtures__/test-docs-malformed/no-frontmatter.md b/packages/plugin-docs-renderer/src/__fixtures__/test-docs-malformed/no-frontmatter.md new file mode 100644 index 0000000000..66fdfaa6b5 --- /dev/null +++ b/packages/plugin-docs-renderer/src/__fixtures__/test-docs-malformed/no-frontmatter.md @@ -0,0 +1,3 @@ +# No Frontmatter + +This markdown file has no frontmatter at all. diff --git a/packages/plugin-docs-renderer/src/__fixtures__/test-docs-malformed/valid.md b/packages/plugin-docs-renderer/src/__fixtures__/test-docs-malformed/valid.md new file mode 100644 index 0000000000..63789ba52b --- /dev/null +++ b/packages/plugin-docs-renderer/src/__fixtures__/test-docs-malformed/valid.md @@ -0,0 +1,9 @@ +--- +title: Valid Page +description: This page has correct frontmatter +sidebar_position: 1 +--- + +# Valid Page + +This is a valid page with proper frontmatter. 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..213d0d1e29 --- /dev/null +++ b/packages/plugin-docs-renderer/src/cli/scanner.test.ts @@ -0,0 +1,141 @@ +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('malformed metadata', () => { + const malformedDocsPath = join(__dirname, '..', '__fixtures__', 'test-docs-malformed'); + + it('should skip files with malformed YAML frontmatter and process valid ones', async () => { + const result = await scanDocsFolder(malformedDocsPath); + + expect(result.manifest.pages).toHaveLength(1); + expect(result.manifest.pages[0].title).toBe('Valid Page'); + }); + + it('should not include malformed or incomplete files in the files map', async () => { + const result = await scanDocsFolder(malformedDocsPath); + + expect(result.files['valid.md']).toBeDefined(); + expect(result.files['malformed-yaml.md']).toBeUndefined(); + expect(result.files['no-frontmatter.md']).toBeUndefined(); + expect(result.files['missing-fields.md']).toBeUndefined(); + }); + }); + + 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..31ab87316a --- /dev/null +++ b/packages/plugin-docs-renderer/src/cli/scanner.ts @@ -0,0 +1,263 @@ +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 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 (without frontmatter). + */ + content: string; +} + +/** + * 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'); + + let parsed; + try { + parsed = matter(fileContent); + } catch (error) { + debug('Failed to parse frontmatter for %s: %s', relativePath, error); + continue; + } + + // validate frontmatter has required fields + const frontmatter = parsed.data as Partial; + if (!frontmatter.title || !frontmatter.description) { + console.warn(`Warning: ${relativePath} missing required frontmatter fields (title, description)`); + continue; + } + + scannedFiles.push({ + absolutePath, + relativePath, + frontmatter: frontmatter as Frontmatter, + content: fileContent, // store original content with frontmatter + }); + } + + 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, then alphabetically by name + const childEntries = Array.from(node.children.entries()); + const sortedEntries = childEntries.sort((a, b) => { + const posA = a[1].file?.frontmatter.sidebar_position ?? Infinity; + const posB = b[1].file?.frontmatter.sidebar_position ?? Infinity; + if (posA !== posB) { + return posA - posB; + } + return a[0].localeCompare(b[0]); + }); + + 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, + }; + + // 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/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/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('