diff --git a/package-lock.json b/package-lock.json index 0a6f82a449..b22f0bac88 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,6 +18,7 @@ "packages/tsconfig", "packages/react-detect", "packages/plugin-docs-renderer", + "packages/plugin-docs-cli", "libs/*" ], "devDependencies": { @@ -6268,6 +6269,10 @@ "node": "^20.19.0 || ^22.12.0 || >=23" } }, + "node_modules/@grafana/plugin-docs-cli": { + "resolved": "packages/plugin-docs-cli", + "link": true + }, "node_modules/@grafana/plugin-docs-renderer": { "resolved": "packages/plugin-docs-renderer", "link": true @@ -7952,6 +7957,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 +9488,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 +11862,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 +11907,13 @@ "@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==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/emscripten": { "version": "1.41.5", "resolved": "https://registry.npmjs.org/@types/emscripten/-/emscripten-1.41.5.tgz", @@ -11933,6 +11975,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", @@ -12054,6 +12103,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 +12308,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 +13966,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 +15754,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 +16218,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 +17570,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 +19387,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 +19554,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 +19563,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 +19782,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 +23939,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 +26203,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 +26212,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 +36577,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", @@ -40345,24 +40574,77 @@ "eslint": "^8.56.0 || ^9.0.0" } }, - "packages/plugin-docs-renderer": { - "name": "@grafana/plugin-docs-renderer", + "packages/plugin-docs-cli": { + "name": "@grafana/plugin-docs-cli", "version": "0.0.1", "license": "Apache-2.0", "dependencies": { + "@grafana/plugin-docs-renderer": "^0.0.1", + "chokidar": "^3.6.0", "debug": "^4.3.7", + "ejs": "^4.0.1", + "express": "^4.18.0", + "github-slugger": "^1.5.0", + "globby": "^11.1.0", "gray-matter": "^4.0.3", - "isomorphic-dompurify": "^2.16.0", "marked": "^13.0.0", "minimist": "^1.2.8" }, "bin": { - "plugin-docs-renderer": "dist/bin/run.js" + "plugin-docs-cli": "dist/bin/run.js" }, "devDependencies": { "@types/debug": "^4.1.12", - "@types/dompurify": "^3.0.5", - "@types/minimist": "^1.2.5" + "@types/ejs": "^3.1.5", + "@types/express": "^4.17.21", + "@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-cli/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-cli/node_modules/marked": { + "version": "13.0.3", + "resolved": "https://registry.npmjs.org/marked/-/marked-13.0.3.tgz", + "integrity": "sha512-rqRix3/TWzE9rIoFGIn8JmsVfhiuC8VIQ8IdX5TfzmeBucdY05/0UlzKaw0eVtpcN/OdVFpBk7CjKGo9iHJ/zA==", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 18" + } + }, + "packages/plugin-docs-renderer": { + "name": "@grafana/plugin-docs-renderer", + "version": "0.0.1", + "license": "Apache-2.0", + "dependencies": { + "gray-matter": "^4.0.3", + "isomorphic-dompurify": "^2.16.0", + "marked": "^13.0.0", + "marked-gfm-heading-id": "^4.1.3" + }, + "devDependencies": { + "@types/dompurify": "^3.0.5" }, "engines": { "node": ">=24" diff --git a/package.json b/package.json index 1b8e867a49..4457d71d88 100644 --- a/package.json +++ b/package.json @@ -78,6 +78,7 @@ "packages/tsconfig", "packages/react-detect", "packages/plugin-docs-renderer", + "packages/plugin-docs-cli", "libs/*" ], "lint-staged": { diff --git a/packages/plugin-docs-cli/CHANGELOG.md b/packages/plugin-docs-cli/CHANGELOG.md new file mode 100644 index 0000000000..517a034fe3 --- /dev/null +++ b/packages/plugin-docs-cli/CHANGELOG.md @@ -0,0 +1,8 @@ +# @grafana/plugin-docs-cli + +## 0.0.1 (Unreleased) + +### Features + +- Package structure created +- Implementation pending (split from `@grafana/plugin-docs-renderer`) diff --git a/packages/plugin-docs-cli/LICENSE b/packages/plugin-docs-cli/LICENSE new file mode 100644 index 0000000000..7a2f8dac4f --- /dev/null +++ b/packages/plugin-docs-cli/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2024 Grafana Labs + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/packages/plugin-docs-cli/README.md b/packages/plugin-docs-cli/README.md new file mode 100644 index 0000000000..2eddd2a270 --- /dev/null +++ b/packages/plugin-docs-cli/README.md @@ -0,0 +1,28 @@ +# @grafana/plugin-docs-cli + +CLI tool for developing, validating, and previewing Grafana plugin documentation locally. + +## Status + +🚧 Work in progress - Package structure created, implementation in progress. + +## Purpose + +This package provides developer tooling for plugin documentation: + +- šŸš€ Local preview server with hot reload +- āœ… Validation (coming soon) +- šŸ“¦ Manifest generation from filesystem + +For the core parsing library, see [@grafana/plugin-docs-renderer](../plugin-docs-renderer). + +## Usage + +```bash +# Using npx (no installation required) +npx @grafana/plugin-docs-cli serve ./docs + +# Or install globally +npm install -g @grafana/plugin-docs-cli +@grafana/plugin-docs-cli serve ./docs +``` diff --git a/packages/plugin-docs-cli/package.json b/packages/plugin-docs-cli/package.json new file mode 100644 index 0000000000..9cf9a2a07f --- /dev/null +++ b/packages/plugin-docs-cli/package.json @@ -0,0 +1,70 @@ +{ + "name": "@grafana/plugin-docs-cli", + "version": "0.0.1", + "private": "true", + "description": "CLI tool for developing, validating, and previewing Grafana plugin documentation locally.", + "type": "module", + "bin": "./dist/bin/run.js", + "files": [ + "dist", + "LICENSE", + "README.md", + "CHANGELOG.md" + ], + "repository": { + "directory": "packages/plugin-docs-cli", + "url": "https://github.com/grafana/plugin-tools" + }, + "author": "Grafana", + "license": "Apache-2.0", + "publishConfig": { + "registry": "https://registry.npmjs.org/", + "access": "public", + "provenance": true + }, + "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", + "test": "vitest", + "typecheck": "tsc --noEmit" + }, + "keywords": [ + "grafana", + "plugin", + "documentation", + "cli", + "preview", + "validation" + ], + "bugs": { + "url": "https://github.com/grafana/plugin-tools/issues" + }, + "engines": { + "node": ">=24" + }, + "dependencies": { + "@grafana/plugin-docs-renderer": "^0.0.1", + "chokidar": "^3.6.0", + "debug": "^4.3.7", + "ejs": "^4.0.1", + "express": "^4.18.0", + "github-slugger": "^1.5.0", + "globby": "^11.1.0", + "gray-matter": "^4.0.3", + "marked": "^13.0.0", + "minimist": "^1.2.8" + }, + "devDependencies": { + "@types/debug": "^4.1.12", + "@types/ejs": "^3.1.5", + "@types/express": "^4.17.21", + "@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-cli/src/__fixtures__/test-docs/advanced.md b/packages/plugin-docs-cli/src/__fixtures__/test-docs/advanced.md new file mode 100644 index 0000000000..e8c13a1826 --- /dev/null +++ b/packages/plugin-docs-cli/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-cli/src/__fixtures__/test-docs/config/database.md b/packages/plugin-docs-cli/src/__fixtures__/test-docs/config/database.md new file mode 100644 index 0000000000..ccf14c8e5c --- /dev/null +++ b/packages/plugin-docs-cli/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-cli/src/__fixtures__/test-docs/config/settings.md b/packages/plugin-docs-cli/src/__fixtures__/test-docs/config/settings.md new file mode 100644 index 0000000000..0dbb492f7e --- /dev/null +++ b/packages/plugin-docs-cli/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-cli/src/__fixtures__/test-docs/guide.md b/packages/plugin-docs-cli/src/__fixtures__/test-docs/guide.md new file mode 100644 index 0000000000..04f22a6cde --- /dev/null +++ b/packages/plugin-docs-cli/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-cli/src/__fixtures__/test-docs/home.md b/packages/plugin-docs-cli/src/__fixtures__/test-docs/home.md new file mode 100644 index 0000000000..fb408829af --- /dev/null +++ b/packages/plugin-docs-cli/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-cli/src/__fixtures__/test-docs/img/test.png b/packages/plugin-docs-cli/src/__fixtures__/test-docs/img/test.png new file mode 100644 index 0000000000..613754cfaf Binary files /dev/null and b/packages/plugin-docs-cli/src/__fixtures__/test-docs/img/test.png differ diff --git a/packages/plugin-docs-cli/src/bin/run.ts b/packages/plugin-docs-cli/src/bin/run.ts new file mode 100644 index 0000000000..777d511b96 --- /dev/null +++ b/packages/plugin-docs-cli/src/bin/run.ts @@ -0,0 +1,28 @@ +#!/usr/bin/env node +import minimist from 'minimist'; +import createDebug from 'debug'; +import { serve } from '../commands/serve.command.js'; + +const debug = createDebug('plugin-docs-cli:main'); + +async function main() { + 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, + }, + }); + + // Default to serve command for now + await serve(argv); +} + +main(); diff --git a/packages/plugin-docs-cli/src/commands/serve.command.ts b/packages/plugin-docs-cli/src/commands/serve.command.ts new file mode 100644 index 0000000000..0b72cd881c --- /dev/null +++ b/packages/plugin-docs-cli/src/commands/serve.command.ts @@ -0,0 +1,47 @@ +import { access } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import type minimist from 'minimist'; +import createDebug from 'debug'; +import { startServer } from '../server/server.js'; + +const debug = createDebug('plugin-docs-cli:serve'); + +export const serve = async (argv: minimist.ParsedArgs) => { + debug('Serve command invoked with args: %O', argv); + + const docsPath = resolve(argv._[0]?.toString() || './docs'); + debug('Resolved docs path: %s', docsPath); + + // check if the path exists + try { + await access(docsPath); + } catch { + console.error(`Error: Path not found: ${docsPath}`); + process.exit(1); + } + + // parse port + const port = parseInt(argv.port, 10); + if (isNaN(port)) { + console.error(`Error: Invalid port: ${argv.port}`); + process.exit(1); + } + + const liveReload = argv.reload || false; + + 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); + } +}; diff --git a/packages/plugin-docs-cli/src/scanner.test.ts b/packages/plugin-docs-cli/src/scanner.test.ts new file mode 100644 index 0000000000..1e2e3c04a7 --- /dev/null +++ b/packages/plugin-docs-cli/src/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-cli/src/scanner.ts b/packages/plugin-docs-cli/src/scanner.ts new file mode 100644 index 0000000000..0d9a39da7c --- /dev/null +++ b/packages/plugin-docs-cli/src/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, Heading } from './types.js'; + +const debug = createDebug('plugin-docs-cli: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: Heading[]; +} + +/** + * 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-cli/src/server/server.test.ts b/packages/plugin-docs-cli/src/server/server.test.ts new file mode 100644 index 0000000000..be5bde05fb --- /dev/null +++ b/packages/plugin-docs-cli/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('class="docs-nav"'); + expect(response.text).toContain(' { + const result = await startServer({ docsPath: testDocsPath, port: 0 }); + server = result; + app = result.app; + + const response = await request(app).get('/img/test.png'); + + expect(response.status).toBe(200); + expect(response.type).toBe('image/png'); + expect(response.body).toBeDefined(); + }); + + it('should not include live reload script by default', async () => { + const result = await startServer({ docsPath: testDocsPath, port: 0, liveReload: false }); + server = result; + app = result.app; + + const response = await request(app).get('/'); + + expect(response.status).toBe(200); + expect(response.text).not.toContain('__reload__'); + expect(response.text).not.toContain('location.reload()'); + }); + + it('should include live reload script when enabled', async () => { + const result = await startServer({ docsPath: testDocsPath, port: 0, liveReload: true }); + server = result; + app = result.app; + + const response = await request(app).get('/'); + + expect(response.status).toBe(200); + expect(response.text).toContain('__reload__'); + expect(response.text).toContain('location.reload()'); + }); + + it('should have live reload endpoint when enabled', async () => { + const result = await startServer({ docsPath: testDocsPath, port: 0, liveReload: true }); + server = result; + app = result.app; + + const response = await request(app).get('/__reload__?t=0'); + + // should return 204 (no changes) or 205 (reset content) + expect([204, 205]).toContain(response.status); + }); + + it('should use frontmatter title when available', async () => { + const result = await startServer({ docsPath: testDocsPath, port: 0 }); + server = result; + app = result.app; + + const response = await request(app).get('/home'); + + expect(response.status).toBe(200); + expect(response.text).toContain('Home Page - Plugin Documentation'); + }); + + it('should use frontmatter title from advanced page', 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); + // advanced.md now has frontmatter with title + expect(response.text).toContain('Advanced Topics - Plugin Documentation'); + }); + + it('should escape HTML entities in titles to prevent XSS', 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); + // verify that if manifest or page titles contained HTML, it would be escaped + // manifest title should appear escaped in title tag and h1 + expect(response.text).toMatch(/]*>[^<]*<\/title>/); + expect(response.text).toMatch(/]*>[^<]*<\/h1>/); + // navigation links should not contain unescaped HTML + expect(response.text).not.toMatch(/]*>[^<]* + + <% if (liveReload) { %> + + <% } %> + + diff --git a/packages/plugin-docs-cli/src/server/views/partials/navigation.ejs b/packages/plugin-docs-cli/src/server/views/partials/navigation.ejs new file mode 100644 index 0000000000..1d23f4c134 --- /dev/null +++ b/packages/plugin-docs-cli/src/server/views/partials/navigation.ejs @@ -0,0 +1,54 @@ +<% +/** + * Navigation partial - renders the left sidebar navigation tree + * + * Parameters: + * - pages: Array of Page objects from manifest + * - currentPath: Current page slug for highlighting + * - level: Nesting level (optional, for recursion) + */ +const renderPages = (pages, currentPath, level = 0) => { + if (!pages || pages.length === 0) return ''; + + return pages.map(page => { + const isActive = page.slug === currentPath; + const hasChildren = page.children && page.children.length > 0; + const hasFile = page.file && page.file.length > 0; + const activeClass = isActive ? ' active' : ''; + const collapsibleClass = hasChildren ? ' collapsible' : ''; + + let html = `
  • `; + + // add chevron for collapsible sections + if (hasChildren) { + html += ``; + } + + // render as link only if page has a file, otherwise just text + if (hasFile) { + html += `${page.title}`; + } else { + html += `${page.title}`; + } + + if (hasChildren) { + html += ` +
      + ${renderPages(page.children, currentPath, level + 1)} +
    + `; + } + + html += '
  • '; + return html; + }).join(''); +}; +%> + +
      + <%- renderPages(pages, currentPath) %> +
    diff --git a/packages/plugin-docs-cli/src/server/views/partials/toc.ejs b/packages/plugin-docs-cli/src/server/views/partials/toc.ejs new file mode 100644 index 0000000000..21ad4ffeff --- /dev/null +++ b/packages/plugin-docs-cli/src/server/views/partials/toc.ejs @@ -0,0 +1,13 @@ +<% if (!headings || headings.length === 0) { %> +

    No headings found

    +<% } else { %> + +<% } %> diff --git a/packages/plugin-docs-cli/src/toc.ts b/packages/plugin-docs-cli/src/toc.ts new file mode 100644 index 0000000000..b93c2958b0 --- /dev/null +++ b/packages/plugin-docs-cli/src/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-cli/src/types.ts b/packages/plugin-docs-cli/src/types.ts new file mode 100644 index 0000000000..5a2514c23b --- /dev/null +++ b/packages/plugin-docs-cli/src/types.ts @@ -0,0 +1,102 @@ +/** + * Represents a heading extracted from a page's HTML content. + */ +export interface Heading { + /** + * The heading level (2 for h2, 3 for h3). + */ + level: number; + + /** + * The text content of the heading. + */ + text: string; + + /** + * The ID attribute for anchor linking. + */ + id: string; +} + +/** + * Represents a documentation page in the manifest. + */ +export interface Page { + /** + * The display title of the page. + */ + title: string; + + /** + * The URL slug for the page. + */ + slug: string; + + /** + * The relative path to the markdown file (e.g., "installation.md"). + */ + file: string; + + /** + * Extracted headings for table of contents. + */ + headings?: Heading[]; + + /** + * Optional nested child pages. + */ + children?: Page[]; +} + +/** + * The documentation manifest that defines the structure and navigation. + */ +export interface Manifest { + /** + * The title of the documentation. + */ + title: string; + + /** + * The list of documentation pages. + */ + pages: Page[]; +} + +/** + * A collection of markdown files indexed by their file path. + */ +export interface MarkdownFiles { + [filePath: string]: string; +} + +/** + * Frontmatter metadata from a markdown file. + */ +export interface Frontmatter { + /** + * The display title of the page. + */ + title: string; + + /** + * A brief description of the page content. + */ + description: string; + + /** + * The position of this page in the sidebar navigation (used for sorting). + * Pages with lower numbers appear first. + */ + sidebar_position: number; + + /** + * Optional custom URL slug. If not provided, generated from file path. + */ + slug?: string; + + /** + * Optional tags for SEO and categorization. + */ + tags?: string[]; +} diff --git a/packages/plugin-docs-cli/tsconfig.json b/packages/plugin-docs-cli/tsconfig.json new file mode 100644 index 0000000000..83c11d6ec9 --- /dev/null +++ b/packages/plugin-docs-cli/tsconfig.json @@ -0,0 +1,9 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "compilerOptions": { + "outDir": "./dist" + }, + "exclude": ["node_modules"], + "extends": "../../tsconfig.base.json", + "include": ["src"] +} diff --git a/packages/plugin-docs-cli/vitest.config.ts b/packages/plugin-docs-cli/vitest.config.ts new file mode 100644 index 0000000000..014f97ef76 --- /dev/null +++ b/packages/plugin-docs-cli/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + environment: 'node', + globals: true, + }, +}); diff --git a/packages/plugin-docs-renderer/package.json b/packages/plugin-docs-renderer/package.json index bf2ee9ea2d..dc3859fcf6 100644 --- a/packages/plugin-docs-renderer/package.json +++ b/packages/plugin-docs-renderer/package.json @@ -2,11 +2,10 @@ "name": "@grafana/plugin-docs-renderer", "version": "0.0.1", "private": "true", - "description": "A library for rendering Grafana plugin documentation from markdown files.", + "description": "A lightweight library for parsing and rendering Grafana plugin documentation from markdown to HTML.", "type": "module", "main": "./dist/index.js", "types": "./dist/index.d.ts", - "bin": "./dist/bin/run.js", "files": [ "dist", "LICENSE", @@ -26,6 +25,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,15 +46,12 @@ "node": ">=24" }, "dependencies": { - "marked": "^13.0.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" }, "devDependencies": { - "@types/minimist": "^1.2.5", - "@types/dompurify": "^3.0.5", - "@types/debug": "^4.1.12" + "@types/dompurify": "^3.0.5" } } 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 deleted file mode 100644 index 5504e298e7..0000000000 --- a/packages/plugin-docs-renderer/src/bin/run.ts +++ /dev/null @@ -1,53 +0,0 @@ -#!/usr/bin/env node -import { access } from 'node:fs/promises'; -import minimist from 'minimist'; -import { loadDocsFolder } from '../loader.js'; -import { parseMarkdown } from '../parser.js'; - -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'; - - // 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'); - 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); - } - process.exit(1); - } -} - -main(); 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..a24e69753a 100644 --- a/packages/plugin-docs-renderer/src/index.ts +++ b/packages/plugin-docs-renderer/src/index.ts @@ -1,10 +1,3 @@ -// export types -export type { Manifest, Page, MarkdownFiles } from './types.js'; - -// export parser functions +// Library exports - pure markdown parsing functions only 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'; 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.test.ts b/packages/plugin-docs-renderer/src/parser.test.ts index 7e4ddf7b5b..90c5ad9268 100644 --- a/packages/plugin-docs-renderer/src/parser.test.ts +++ b/packages/plugin-docs-renderer/src/parser.test.ts @@ -6,7 +6,7 @@ describe('parseMarkdown', () => { const markdown = '# Hello World\n\nThis is a paragraph.'; const result = parseMarkdown(markdown); - expect(result.html).toContain('

    Hello World

    '); + expect(result.html).toContain('

    Hello World

    '); expect(result.html).toContain('

    This is a paragraph.

    '); expect(result.frontmatter).toEqual({}); }); @@ -26,7 +26,7 @@ Body text here.`; title: 'Test Page', description: 'A test page', }); - expect(result.html).toContain('

    Content

    '); + expect(result.html).toContain('

    Content

    '); expect(result.html).toContain('

    Body text here.

    '); }); @@ -82,7 +82,7 @@ Regular content. expect(result.html).not.toContain('onerror'); // safe content should remain - expect(result.html).toContain('

    Test

    '); + expect(result.html).toContain('

    Test

    '); expect(result.html).toContain('

    Regular content.

    '); }); }); diff --git a/packages/plugin-docs-renderer/src/parser.ts b/packages/plugin-docs-renderer/src/parser.ts index 3673d46057..e1c3e39661 100644 --- a/packages/plugin-docs-renderer/src/parser.ts +++ b/packages/plugin-docs-renderer/src/parser.ts @@ -1,9 +1,17 @@ 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. @@ -28,8 +36,6 @@ export interface ParsedMarkdown { * @throws {Error} If markdown parsing fails */ export function parseMarkdown(content: string): ParsedMarkdown { - debug('Parsing markdown content (%d bytes)', content.length); - // extract frontmatter using gray-matter let frontmatter: Record; let markdownContent: string; @@ -38,23 +44,16 @@ export function parseMarkdown(content: string): ParsedMarkdown { const result = matter(content); frontmatter = result.data; markdownContent = result.content; - debug('Extracted frontmatter with %d key(s)', Object.keys(frontmatter).length); } catch (error) { const message = error instanceof Error ? error.message : String(error); throw new Error(`Failed to extract frontmatter: ${message}`); } // 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; - debug('Parsed markdown to HTML (%d bytes)', rawHtml.length); } catch (error) { const message = error instanceof Error ? error.message : String(error); throw new Error(`Failed to parse markdown: ${message}`); @@ -64,7 +63,6 @@ export function parseMarkdown(content: string): ParsedMarkdown { let html: string; try { html = DOMPurify.sanitize(rawHtml); - debug('Sanitized HTML (%d bytes)', html.length); } catch (error) { const message = error instanceof Error ? error.message : String(error); throw new Error(`Failed to sanitize HTML: ${message}`); diff --git a/packages/plugin-docs-renderer/src/types.ts b/packages/plugin-docs-renderer/src/types.ts deleted file mode 100644 index 4e9b7f5d25..0000000000 --- a/packages/plugin-docs-renderer/src/types.ts +++ /dev/null @@ -1,57 +0,0 @@ -/** - * Core types for the plugin documentation renderer. - * - * These types define the structure of documentation manifests and pages. - */ - -/** - * Represents a documentation page in the manifest. - */ -export interface Page { - /** - * The display title of the page. - */ - title: string; - - /** - * The URL slug for the page. - */ - slug: string; - - /** - * The relative path to the markdown file (e.g., "installation.md"). - */ - file: string; - - /** - * Optional nested child pages. - */ - children?: Page[]; -} - -/** - * The documentation manifest that defines the structure and navigation. - */ -export interface Manifest { - /** - * The manifest format version. - */ - version: string; - - /** - * The title of the documentation. - */ - title: string; - - /** - * The list of documentation pages. - */ - pages: Page[]; -} - -/** - * A collection of markdown files indexed by their file path. - */ -export interface MarkdownFiles { - [filePath: string]: string; -} diff --git a/rollup.config.ts b/rollup.config.ts index 9a2fd089a2..a78aec578a 100644 --- a/rollup.config.ts +++ b/rollup.config.ts @@ -3,7 +3,7 @@ import json from '@rollup/plugin-json'; import { nodeResolve } from '@rollup/plugin-node-resolve'; import { glob, GlobOptions } from 'glob'; import { readFileSync } from 'node:fs'; -import { chmod } from 'node:fs/promises'; +import { chmod, cp } from 'node:fs/promises'; import { join } from 'node:path'; import { inspect } from 'node:util'; import { defineConfig, ExternalOption, Plugin, RollupOptions } from 'rollup'; @@ -76,6 +76,7 @@ const defaultOptions: Array> = [ tsconfig: tsconfigPath, }), shebang(), + copyAssets(), ], }, ]; @@ -126,3 +127,21 @@ function shebang(): Plugin { }, }; } + +// Copy static assets to dist +function copyAssets(): Plugin { + return { + name: 'copy-assets', + async writeBundle() { + if (pkg.name === '@grafana/plugin-docs-cli') { + const srcViews = join(projectRoot, 'src', 'server', 'views'); + const distViews = join(projectRoot, 'dist', 'server', 'views'); + await cp(srcViews, distViews, { recursive: true }); + + const srcStyles = join(projectRoot, 'src', 'server', 'styles'); + const distStyles = join(projectRoot, 'dist', 'server', 'styles'); + await cp(srcStyles, distStyles, { recursive: true }); + } + }, + }; +}