Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions docs/content-plugins.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
(`<frameworkDir>/src/{components,extensions,menu,theme}/<name>/README.md`).
`getReadmes(name?)` reads them via `getPluginReadmes` and returns a
`{ <pluginName>: <markdown> }` 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
Expand Down
48 changes: 47 additions & 1 deletion lib/ContentPluginModule.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ import {
getMostRecentBackup,
cleanupOldPluginBackups,
restorePluginFromBackup,
processPluginFiles
processPluginFiles,
getPluginReadmes
} from './utils.js'
import semver from 'semver'
/**
Expand Down Expand Up @@ -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<Object<string,string>>} 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]
Expand Down Expand Up @@ -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
1 change: 1 addition & 0 deletions lib/utils.js
Original file line number Diff line number Diff line change
@@ -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'
21 changes: 21 additions & 0 deletions lib/utils/getPluginReadmes.js
Original file line number Diff line number Diff line change
@@ -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<Object<string,string>>} 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)
}
21 changes: 21 additions & 0 deletions routes.json
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
Expand Down Expand Up @@ -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 }]
}
}
}
]
}
58 changes: 58 additions & 0 deletions tests/utils-getPluginReadmes.spec.js
Original file line number Diff line number Diff line change
@@ -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))
})
})
Loading