adapt-authoring-contentplugin 1.7.1 → 1.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,8 @@
1
1
  {
2
2
  "documentation": {
3
- "enable": true
3
+ "enable": true,
4
+ "manualPages": {
5
+ "content-plugins.md": "development"
6
+ }
4
7
  }
5
8
  }
@@ -0,0 +1,183 @@
1
+ # Content plugins
2
+
3
+ `adapt-authoring-contentplugin` manages the **Adapt framework plugins** that ship
4
+ inside built courses: components, extensions, menus and themes. These are the
5
+ packages the Adapt framework installs and bundles at build time — *not* the
6
+ authoring tool's front-end "UI plugins" (those live in a module's `ui/plugin.js`;
7
+ content plugins are Adapt framework packages installed into the framework copy on
8
+ disk).
9
+
10
+ The module extends `AbstractApiModule`, so it inherits standard REST CRUD plus the
11
+ access/hook machinery, and layers on framework install/update/uninstall, schema
12
+ registration and on-disk backups.
13
+
14
+ Source: `lib/ContentPluginModule.js`, `lib/utils/*`.
15
+
16
+ ## The model
17
+
18
+ Each installed plugin is one document in the `contentplugins` collection
19
+ (`schemaName = 'contentplugin'`, root `contentplugins`). Schema:
20
+ `schema/contentplugin.schema.json`.
21
+
22
+ | Field | Notes |
23
+ | --- | --- |
24
+ | `name` | Unique plugin name, e.g. `adapt-contrib-text` (unique index) |
25
+ | `displayName` | User-friendly name (unique index) |
26
+ | `version` | Installed semver |
27
+ | `framework` | Compatible framework version range |
28
+ | `type` | Plugin type, e.g. `component` / `extension` / `menu` / `theme` (indexed) |
29
+ | `targetAttribute` | The content attribute the plugin binds to (required at install — see `CONTENTPLUGIN_ATTR_MISSING`) |
30
+ | `isLocalInstall` | `true` when installed from an uploaded zip rather than the registry |
31
+ | `isEnabled` | Default `true` |
32
+ | `isAddedByDefault` | If `true`, auto-added to every new course's `_enabledPlugins` |
33
+ | `pluginDependencies` | Plugin-to-version map |
34
+ | `canBeUpdated`, `latestCompatibleVersion` | Read-only; populated on demand (see below) |
35
+
36
+ `required`: `framework`, `name`, `type`, `version`, `isLocalInstall`.
37
+
38
+ ## Where plugins come from
39
+
40
+ Two sources, distinguished by `isLocalInstall`:
41
+
42
+ - **Registry / source string** — `versionOrPath` is a bare version or name
43
+ (no directory component), resolved by `adapt-cli` from the Adapt plugin
44
+ registry. `processPluginFiles` returns `isLocalInstall: false`.
45
+ - **Local zip upload** — `versionOrPath` is a filesystem path to an unzipped
46
+ upload. `processPluginFiles` reads `package.json` (falling back to
47
+ `bower.json`), copies the files into the persistent `pluginDir`, and marks
48
+ `isLocalInstall: true`. A zip with neither manifest throws
49
+ `CONTENTPLUGIN_INVALID_ZIP`.
50
+
51
+ The actual framework install/uninstall/update is always delegated to `adapt-cli`
52
+ via `this.framework.runCliCommand(...)` (`installPlugins`, `uninstallPlugins`,
53
+ `updatePlugins`, `getPluginUpdateInfos`). `init()` forces
54
+ `ADAPT_ALLOW_PRERELEASE=true` for the CLI if unset.
55
+
56
+ ## Storage & versioning
57
+
58
+ - Registry installs live inside the framework copy (managed by the CLI).
59
+ - Local installs are copied into `pluginDir` (config `pluginDir`, default
60
+ `$DATA/contentplugins`).
61
+ - Before a local install overwrites an existing plugin dir, the old one is
62
+ renamed to `<pluginPath>-v<version>` (`backupPluginVersion`). Only the single
63
+ most-recent backup is kept (`cleanupOldPluginBackups`); `getMostRecentBackup`
64
+ sorts `<name>-v*` dirs by semver.
65
+ - DB version is kept in step with the framework copy by `syncPluginData`, which
66
+ is run on init and tapped into the framework's `postInstallHook` /
67
+ `postUpdateHook`. If a plugin recorded in the DB is missing from disk on boot,
68
+ `getMissingPlugins` re-installs it (from registry, or from the most recent
69
+ on-disk backup for local installs).
70
+
71
+ ## Schemas
72
+
73
+ Content-plugin schemas (`$patch` extensions to content schemas) are registered
74
+ with the `jsonschema` module by `processPluginSchemas`. Because `jsonschema`
75
+ resets its registry on app-ready and only re-registers schemas owned by
76
+ `app.dependencies`, this module tracks plugin schema paths in `this.pluginSchemas`
77
+ and re-registers them via `jsonschema.registerSchemasHook`. `serveSchema` returns
78
+ the built schema for a plugin via `GET /api/contentplugins/schema?type=<name>`.
79
+
80
+ ## The `_enabledPlugins` relationship
81
+
82
+ A plugin is "used" by a course through the course's **config** document:
83
+ `config._enabledPlugins` is an array of plugin `name`s. There is no per-course
84
+ copy of the plugin — courses reference plugins by name.
85
+
86
+ - New courses: `addDefaultPlugins` taps the content module's `preInsertHook`;
87
+ on a `config` insert it appends every plugin with `isAddedByDefault: true`
88
+ to `_enabledPlugins`.
89
+ - `getPluginUses(_id)` aggregates the `content` collection for `config` docs
90
+ whose `_enabledPlugins` contains the plugin name, returning the owning courses
91
+ (title + creator email). It gates deletion and drives the `/uses` endpoint.
92
+
93
+ ## Endpoints
94
+
95
+ Routes are declared explicitly (`routes.json`) to omit the default `POST /` and
96
+ `PUT /:_id` — plugins are not created/edited as plain documents. Root:
97
+ `/api/contentplugins`.
98
+
99
+ | Method & route | Handler | Permission |
100
+ | --- | --- | --- |
101
+ | `GET /` | `requestHandler` | `read:contentplugins` |
102
+ | `GET /:_id` | `requestHandler` | `read:contentplugins` |
103
+ | `PATCH /:_id` | `requestHandler` | `write:contentplugins` |
104
+ | `DELETE /:_id` | `requestHandler` (uninstall) | `write:contentplugins` |
105
+ | `POST /query` | `queryHandler` | `read:contentplugins` |
106
+ | `GET /schema` | `serveSchema` | `read:schema` |
107
+ | `GET /readme` | `readmesHandler` | `read:contentplugins` |
108
+ | `POST /install` | `installHandler` | `install:contentplugins` |
109
+ | `POST /:_id/update` | `updateHandler` | `update:contentplugins` |
110
+ | `GET /:_id/uses` | `usesHandler` | `read:contentplugins` |
111
+ | `GET /:_id/readme` | `readmeHandler` | `read:contentplugins` |
112
+
113
+ ### Install
114
+
115
+ `POST /api/contentplugins/install` accepts either JSON (`name`, `version`,
116
+ `force`) or a multipart zip upload (parsed by the `middleware` module's
117
+ `fileUploadParser`; the uploaded file path becomes `versionOrPath`). Install is
118
+ `strict: true` from the HTTP path, so failures throw.
119
+
120
+ ```http
121
+ POST /api/contentplugins/install
122
+ Content-Type: application/json
123
+
124
+ { "name": "adapt-contrib-text", "version": "7.5.1", "force": false }
125
+ ```
126
+
127
+ `installPlugin` flow: resolve/process the source files → if the plugin already
128
+ exists and the new version is `<=` existing and `force` is false, throw
129
+ `CONTENTPLUGIN_ALREADY_EXISTS` → CLI `installPlugins` → `insertOrUpdate` the DB
130
+ record → register its schemas. Missing `targetAttribute` throws
131
+ `CONTENTPLUGIN_ATTR_MISSING`.
132
+
133
+ ### Update
134
+
135
+ `GET /?includeUpdateInfo=true` enriches results with `canBeUpdated` and
136
+ `latestCompatibleVersion` (from the CLI `getPluginUpdateInfos`).
137
+ `POST /api/contentplugins/:_id/update` runs the CLI `updatePlugins`, updates the
138
+ DB record and schemas, then — if any courses use the plugin — calls
139
+ `framework.migrateCourses({ fromPlugins, toPlugins, courseIds })` to migrate
140
+ affected course content between the old and new plugin versions.
141
+
142
+ ### Uninstall
143
+
144
+ `DELETE /api/contentplugins/:_id`. Blocked with `CONTENTPLUGIN_IN_USE` (listing
145
+ the offending courses) if any course's `_enabledPlugins` references it.
146
+ Otherwise deregisters the plugin's schemas, runs CLI `uninstallPlugins`, then
147
+ deletes the DB record.
148
+
149
+ ### READMEs
150
+
151
+ Each plugin ships a `README.md` in its framework source directory
152
+ (`<frameworkDir>/src/{components,extensions,menu,theme}/<name>/README.md`).
153
+ `getReadmes(name?)` reads them via `getPluginReadmes` and returns a
154
+ `{ <pluginName>: <markdown> }` map.
155
+
156
+ - `GET /api/contentplugins/readme` returns the map for every installed plugin
157
+ (plugins with no `README.md` are omitted).
158
+ - `GET /api/contentplugins/:_id/readme` looks the plugin up by `_id` and returns
159
+ `{ name, readme }`, throwing `NOT_FOUND` if the plugin or its README is absent.
160
+
161
+ ## Backup & restore
162
+
163
+ Backups are created automatically around local installs (above). Restore is
164
+ available programmatically via `restorePluginFromBackup(pluginName)` — it renames
165
+ the most-recent `<name>-v*` backup back into place and returns its manifest, or
166
+ throws `NOT_FOUND` if no backup exists. There is no HTTP endpoint for restore; it
167
+ is also used implicitly by `getMissingPlugins` on boot to recover a local plugin
168
+ whose directory has gone missing.
169
+
170
+ ## Config
171
+
172
+ | Option | Default | Notes |
173
+ | --- | --- | --- |
174
+ | `pluginDir` | `$DATA/contentplugins` | Location of locally installed plugins and their version backups |
175
+
176
+ ## Errors
177
+
178
+ Defined in `errors/errors.json`. Notable: `CONTENTPLUGIN_ALREADY_EXISTS`,
179
+ `CONTENTPLUGIN_IN_USE`, `CONTENTPLUGIN_INVALID_ZIP`, `CONTENTPLUGIN_ATTR_MISSING`,
180
+ `CONTENTPLUGIN_CLI_INSTALL_FAILED`, `CONTENTPLUGIN_INSTALL_FAILED`,
181
+ `CONTENTPLUGIN_INCOMPAT_FW`. (`CONTENTPLUGIN_ATTR_CLASH`,
182
+ `CONTENTPLUGIN_NEWER_INSTALLED` and `CONTENTPLUGIN_VERSION_MISMATCH` are declared
183
+ but not thrown from this module's code.)
@@ -9,7 +9,8 @@ import {
9
9
  getMostRecentBackup,
10
10
  cleanupOldPluginBackups,
11
11
  restorePluginFromBackup,
12
- processPluginFiles
12
+ processPluginFiles,
13
+ getPluginReadmes
13
14
  } from './utils.js'
14
15
  import semver from 'semver'
15
16
  /**
@@ -293,6 +294,15 @@ class ContentPluginModule extends AbstractApiModule {
293
294
  ])).toArray()
294
295
  }
295
296
 
297
+ /**
298
+ * Retrieves the README contents of installed content plugins
299
+ * @param {String} [name] Limit the result to a single named plugin
300
+ * @returns {Promise<Object<string,string>>} Map of plugin name to README contents
301
+ */
302
+ async getReadmes (name) {
303
+ return getPluginReadmes(path.join(this.framework.path, 'src'), name)
304
+ }
305
+
296
306
  /**
297
307
  * Installs new plugins
298
308
  * @param {Array[]} plugins 2D array of strings in the format [pluginName, versionOrPath]
@@ -504,6 +514,42 @@ class ContentPluginModule extends AbstractApiModule {
504
514
  return next(error)
505
515
  }
506
516
  }
517
+
518
+ /**
519
+ * Express request handler for retrieving the README of every content plugin
520
+ * @param {external:ExpressRequest} req
521
+ * @param {external:ExpressResponse} res
522
+ * @param {Function} next
523
+ */
524
+ async readmesHandler (req, res, next) {
525
+ try {
526
+ res.send(await this.getReadmes())
527
+ } catch (error) {
528
+ return next(error)
529
+ }
530
+ }
531
+
532
+ /**
533
+ * Express request handler for retrieving the README of a single content plugin
534
+ * @param {external:ExpressRequest} req
535
+ * @param {external:ExpressResponse} res
536
+ * @param {Function} next
537
+ */
538
+ async readmeHandler (req, res, next) {
539
+ try {
540
+ const plugin = await this.findOne({ _id: req.params._id }, { strict: false })
541
+ if (!plugin) {
542
+ throw this.app.errors.NOT_FOUND.setData({ type: this.schemaName, id: req.params._id })
543
+ }
544
+ const readme = (await this.getReadmes(plugin.name))[plugin.name]
545
+ if (readme === undefined) {
546
+ throw this.app.errors.NOT_FOUND.setData({ type: 'README', id: plugin.name })
547
+ }
548
+ res.send({ name: plugin.name, readme })
549
+ } catch (error) {
550
+ return next(error)
551
+ }
552
+ }
507
553
  }
508
554
 
509
555
  export default ContentPluginModule
@@ -0,0 +1,21 @@
1
+ import fs from 'node:fs/promises'
2
+ import path from 'node:path'
3
+ import { globAbsolute } from './globAbsolute.js'
4
+
5
+ const README_PATTERN = '{components,extensions,menu,theme}/*/README.md'
6
+
7
+ /**
8
+ * Reads the README.md of installed content plugins from the framework src directory
9
+ * @param {String} srcDir The framework's src directory
10
+ * @param {String} [name] Limit the result to a single named plugin
11
+ * @returns {Promise<Object<string,string>>} Map of plugin name to README contents
12
+ */
13
+ export async function getPluginReadmes (srcDir, name) {
14
+ const pattern = name ? `{components,extensions,menu,theme}/${name}/README.md` : README_PATTERN
15
+ const readmePaths = await globAbsolute(pattern, srcDir)
16
+ const entries = await Promise.all(readmePaths.map(async p => [
17
+ path.basename(path.dirname(p)),
18
+ await fs.readFile(p, 'utf8')
19
+ ]))
20
+ return Object.fromEntries(entries)
21
+ }
package/lib/utils.js CHANGED
@@ -1,6 +1,7 @@
1
1
  export { addDefaultPlugins } from './utils/addDefaultPlugins.js'
2
2
  export { backupPluginVersion } from './utils/backupPluginVersion.js'
3
3
  export { getMostRecentBackup } from './utils/getMostRecentBackup.js'
4
+ export { getPluginReadmes } from './utils/getPluginReadmes.js'
4
5
  export { cleanupOldPluginBackups } from './utils/cleanupOldPluginBackups.js'
5
6
  export { restorePluginFromBackup } from './utils/restorePluginFromBackup.js'
6
7
  export { processPluginFiles } from './utils/processPluginFiles.js'
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "adapt-authoring-contentplugin",
3
- "version": "1.7.1",
3
+ "version": "1.8.0",
4
4
  "description": "Module for managing framework plugins",
5
5
  "homepage": "https://github.com/adapt-security/adapt-authoring-contentplugin",
6
6
  "repository": "github:adapt-security/adapt-authoring-contentplugin",
package/routes.json CHANGED
@@ -12,6 +12,16 @@
12
12
  "handlers": { "get": "serveSchema" },
13
13
  "permissions": { "get": ["read:schema"] }
14
14
  },
15
+ {
16
+ "route": "/readme",
17
+ "handlers": { "get": "readmesHandler" },
18
+ "permissions": { "get": ["read:${scope}"] },
19
+ "meta": {
20
+ "get": {
21
+ "summary": "Return the README of every content plugin, keyed by plugin name"
22
+ }
23
+ }
24
+ },
15
25
  {
16
26
  "route": "/:_id",
17
27
  "handlers": { "get": "requestHandler", "patch": "requestHandler", "delete": "requestHandler" },
@@ -71,6 +81,17 @@
71
81
  "parameters": [{ "name": "_id", "in": "path", "description": "Content plugin _id", "required": true }]
72
82
  }
73
83
  }
84
+ },
85
+ {
86
+ "route": "/:_id/readme",
87
+ "handlers": { "get": "readmeHandler" },
88
+ "permissions": { "get": ["read:${scope}"] },
89
+ "meta": {
90
+ "get": {
91
+ "summary": "Return the README of a single content plugin",
92
+ "parameters": [{ "name": "_id", "in": "path", "description": "Content plugin _id", "required": true }]
93
+ }
94
+ }
74
95
  }
75
96
  ]
76
97
  }
@@ -0,0 +1,58 @@
1
+ import { describe, it, beforeEach, afterEach } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import fs from 'node:fs/promises'
4
+ import path from 'node:path'
5
+ import os from 'node:os'
6
+
7
+ import { getPluginReadmes } from '../lib/utils/getPluginReadmes.js'
8
+
9
+ describe('getPluginReadmes()', () => {
10
+ let srcDir
11
+
12
+ const writePlugin = async (category, name, readme) => {
13
+ const dir = path.join(srcDir, category, name)
14
+ await fs.mkdir(dir, { recursive: true })
15
+ if (readme !== undefined) await fs.writeFile(path.join(dir, 'README.md'), readme)
16
+ }
17
+
18
+ beforeEach(async () => {
19
+ srcDir = await fs.mkdtemp(path.join(os.tmpdir(), 'readme-test-'))
20
+ await writePlugin('components', 'adapt-contrib-text', '# Text')
21
+ await writePlugin('extensions', 'adapt-contrib-spoor', '# Spoor')
22
+ await writePlugin('menu', 'adapt-contrib-boxMenu', '# Box Menu')
23
+ await writePlugin('theme', 'adapt-contrib-vanilla', '# Vanilla')
24
+ })
25
+
26
+ afterEach(async () => {
27
+ await fs.rm(srcDir, { recursive: true, force: true })
28
+ })
29
+
30
+ it('should return every plugin README keyed by plugin name', async () => {
31
+ const result = await getPluginReadmes(srcDir)
32
+ assert.deepEqual(result, {
33
+ 'adapt-contrib-text': '# Text',
34
+ 'adapt-contrib-spoor': '# Spoor',
35
+ 'adapt-contrib-boxMenu': '# Box Menu',
36
+ 'adapt-contrib-vanilla': '# Vanilla'
37
+ })
38
+ })
39
+
40
+ it('should limit the result to a single named plugin', async () => {
41
+ const result = await getPluginReadmes(srcDir, 'adapt-contrib-spoor')
42
+ assert.deepEqual(result, { 'adapt-contrib-spoor': '# Spoor' })
43
+ })
44
+
45
+ it('should return an empty object for an unknown plugin', async () => {
46
+ assert.deepEqual(await getPluginReadmes(srcDir, 'does-not-exist'), {})
47
+ })
48
+
49
+ it('should ignore plugins without a README and non-plugin directories', async () => {
50
+ await writePlugin('components', 'adapt-no-readme')
51
+ await fs.mkdir(path.join(srcDir, 'core'), { recursive: true })
52
+ await fs.writeFile(path.join(srcDir, 'core', 'README.md'), '# Core')
53
+
54
+ const result = await getPluginReadmes(srcDir)
55
+ assert.ok(!('adapt-no-readme' in result))
56
+ assert.ok(!('core' in result))
57
+ })
58
+ })