diff --git a/docs/content-plugins.md b/docs/content-plugins.md index f5a16c2..d0cd1d7 100644 --- a/docs/content-plugins.md +++ b/docs/content-plugins.md @@ -104,9 +104,11 @@ Routes are declared explicitly (`routes.json`) to omit the default `POST /` and | `DELETE /:_id` | `requestHandler` (uninstall) | `write:contentplugins` | | `POST /query` | `queryHandler` | `read:contentplugins` | | `GET /schema` | `serveSchema` | `read:schema` | +| `GET /readme` | `readmesHandler` | `read:contentplugins` | | `POST /install` | `installHandler` | `install:contentplugins` | | `POST /:_id/update` | `updateHandler` | `update:contentplugins` | | `GET /:_id/uses` | `usesHandler` | `read:contentplugins` | +| `GET /:_id/readme` | `readmeHandler` | `read:contentplugins` | ### Install @@ -144,6 +146,18 @@ the offending courses) if any course's `_enabledPlugins` references it. Otherwise deregisters the plugin's schemas, runs CLI `uninstallPlugins`, then deletes the DB record. +### READMEs + +Each plugin ships a `README.md` in its framework source directory +(`/src/{components,extensions,menu,theme}//README.md`). +`getReadmes(name?)` reads them via `getPluginReadmes` and returns a +`{ : }` map. + +- `GET /api/contentplugins/readme` returns the map for every installed plugin + (plugins with no `README.md` are omitted). +- `GET /api/contentplugins/:_id/readme` looks the plugin up by `_id` and returns + `{ name, readme }`, throwing `NOT_FOUND` if the plugin or its README is absent. + ## Backup & restore Backups are created automatically around local installs (above). Restore is diff --git a/lib/ContentPluginModule.js b/lib/ContentPluginModule.js index 6ff2a00..4e004c7 100644 --- a/lib/ContentPluginModule.js +++ b/lib/ContentPluginModule.js @@ -9,7 +9,8 @@ import { getMostRecentBackup, cleanupOldPluginBackups, restorePluginFromBackup, - processPluginFiles + processPluginFiles, + getPluginReadmes } from './utils.js' import semver from 'semver' /** @@ -293,6 +294,15 @@ class ContentPluginModule extends AbstractApiModule { ])).toArray() } + /** + * Retrieves the README contents of installed content plugins + * @param {String} [name] Limit the result to a single named plugin + * @returns {Promise>} Map of plugin name to README contents + */ + async getReadmes (name) { + return getPluginReadmes(path.join(this.framework.path, 'src'), name) + } + /** * Installs new plugins * @param {Array[]} plugins 2D array of strings in the format [pluginName, versionOrPath] @@ -504,6 +514,42 @@ class ContentPluginModule extends AbstractApiModule { return next(error) } } + + /** + * Express request handler for retrieving the README of every content plugin + * @param {external:ExpressRequest} req + * @param {external:ExpressResponse} res + * @param {Function} next + */ + async readmesHandler (req, res, next) { + try { + res.send(await this.getReadmes()) + } catch (error) { + return next(error) + } + } + + /** + * Express request handler for retrieving the README of a single content plugin + * @param {external:ExpressRequest} req + * @param {external:ExpressResponse} res + * @param {Function} next + */ + async readmeHandler (req, res, next) { + try { + const plugin = await this.findOne({ _id: req.params._id }, { strict: false }) + if (!plugin) { + throw this.app.errors.NOT_FOUND.setData({ type: this.schemaName, id: req.params._id }) + } + const readme = (await this.getReadmes(plugin.name))[plugin.name] + if (readme === undefined) { + throw this.app.errors.NOT_FOUND.setData({ type: 'README', id: plugin.name }) + } + res.send({ name: plugin.name, readme }) + } catch (error) { + return next(error) + } + } } export default ContentPluginModule diff --git a/lib/utils.js b/lib/utils.js index fc8dcc1..2c4497b 100644 --- a/lib/utils.js +++ b/lib/utils.js @@ -1,6 +1,7 @@ export { addDefaultPlugins } from './utils/addDefaultPlugins.js' export { backupPluginVersion } from './utils/backupPluginVersion.js' export { getMostRecentBackup } from './utils/getMostRecentBackup.js' +export { getPluginReadmes } from './utils/getPluginReadmes.js' export { cleanupOldPluginBackups } from './utils/cleanupOldPluginBackups.js' export { restorePluginFromBackup } from './utils/restorePluginFromBackup.js' export { processPluginFiles } from './utils/processPluginFiles.js' diff --git a/lib/utils/getPluginReadmes.js b/lib/utils/getPluginReadmes.js new file mode 100644 index 0000000..94b391e --- /dev/null +++ b/lib/utils/getPluginReadmes.js @@ -0,0 +1,21 @@ +import fs from 'node:fs/promises' +import path from 'node:path' +import { globAbsolute } from './globAbsolute.js' + +const README_PATTERN = '{components,extensions,menu,theme}/*/README.md' + +/** + * Reads the README.md of installed content plugins from the framework src directory + * @param {String} srcDir The framework's src directory + * @param {String} [name] Limit the result to a single named plugin + * @returns {Promise>} Map of plugin name to README contents + */ +export async function getPluginReadmes (srcDir, name) { + const pattern = name ? `{components,extensions,menu,theme}/${name}/README.md` : README_PATTERN + const readmePaths = await globAbsolute(pattern, srcDir) + const entries = await Promise.all(readmePaths.map(async p => [ + path.basename(path.dirname(p)), + await fs.readFile(p, 'utf8') + ])) + return Object.fromEntries(entries) +} diff --git a/routes.json b/routes.json index 3e24caa..dcec40d 100644 --- a/routes.json +++ b/routes.json @@ -12,6 +12,16 @@ "handlers": { "get": "serveSchema" }, "permissions": { "get": ["read:schema"] } }, + { + "route": "/readme", + "handlers": { "get": "readmesHandler" }, + "permissions": { "get": ["read:${scope}"] }, + "meta": { + "get": { + "summary": "Return the README of every content plugin, keyed by plugin name" + } + } + }, { "route": "/:_id", "handlers": { "get": "requestHandler", "patch": "requestHandler", "delete": "requestHandler" }, @@ -71,6 +81,17 @@ "parameters": [{ "name": "_id", "in": "path", "description": "Content plugin _id", "required": true }] } } + }, + { + "route": "/:_id/readme", + "handlers": { "get": "readmeHandler" }, + "permissions": { "get": ["read:${scope}"] }, + "meta": { + "get": { + "summary": "Return the README of a single content plugin", + "parameters": [{ "name": "_id", "in": "path", "description": "Content plugin _id", "required": true }] + } + } } ] } diff --git a/tests/utils-getPluginReadmes.spec.js b/tests/utils-getPluginReadmes.spec.js new file mode 100644 index 0000000..5d15f4a --- /dev/null +++ b/tests/utils-getPluginReadmes.spec.js @@ -0,0 +1,58 @@ +import { describe, it, beforeEach, afterEach } from 'node:test' +import assert from 'node:assert/strict' +import fs from 'node:fs/promises' +import path from 'node:path' +import os from 'node:os' + +import { getPluginReadmes } from '../lib/utils/getPluginReadmes.js' + +describe('getPluginReadmes()', () => { + let srcDir + + const writePlugin = async (category, name, readme) => { + const dir = path.join(srcDir, category, name) + await fs.mkdir(dir, { recursive: true }) + if (readme !== undefined) await fs.writeFile(path.join(dir, 'README.md'), readme) + } + + beforeEach(async () => { + srcDir = await fs.mkdtemp(path.join(os.tmpdir(), 'readme-test-')) + await writePlugin('components', 'adapt-contrib-text', '# Text') + await writePlugin('extensions', 'adapt-contrib-spoor', '# Spoor') + await writePlugin('menu', 'adapt-contrib-boxMenu', '# Box Menu') + await writePlugin('theme', 'adapt-contrib-vanilla', '# Vanilla') + }) + + afterEach(async () => { + await fs.rm(srcDir, { recursive: true, force: true }) + }) + + it('should return every plugin README keyed by plugin name', async () => { + const result = await getPluginReadmes(srcDir) + assert.deepEqual(result, { + 'adapt-contrib-text': '# Text', + 'adapt-contrib-spoor': '# Spoor', + 'adapt-contrib-boxMenu': '# Box Menu', + 'adapt-contrib-vanilla': '# Vanilla' + }) + }) + + it('should limit the result to a single named plugin', async () => { + const result = await getPluginReadmes(srcDir, 'adapt-contrib-spoor') + assert.deepEqual(result, { 'adapt-contrib-spoor': '# Spoor' }) + }) + + it('should return an empty object for an unknown plugin', async () => { + assert.deepEqual(await getPluginReadmes(srcDir, 'does-not-exist'), {}) + }) + + it('should ignore plugins without a README and non-plugin directories', async () => { + await writePlugin('components', 'adapt-no-readme') + await fs.mkdir(path.join(srcDir, 'core'), { recursive: true }) + await fs.writeFile(path.join(srcDir, 'core', 'README.md'), '# Core') + + const result = await getPluginReadmes(srcDir) + assert.ok(!('adapt-no-readme' in result)) + assert.ok(!('core' in result)) + }) +})