adapt-authoring-content 0.0.1

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.
package/.eslintignore ADDED
@@ -0,0 +1 @@
1
+ node_modules
package/.eslintrc ADDED
@@ -0,0 +1,14 @@
1
+ {
2
+ "env": {
3
+ "browser": false,
4
+ "node": true,
5
+ "commonjs": false,
6
+ "es2020": true
7
+ },
8
+ "extends": [
9
+ "standard"
10
+ ],
11
+ "parserOptions": {
12
+ "ecmaVersion": 2020
13
+ }
14
+ }
@@ -0,0 +1,55 @@
1
+ name: Bug Report
2
+ description: File a bug report
3
+ labels: ["bug"]
4
+ body:
5
+ - type: markdown
6
+ attributes:
7
+ value: |
8
+ Thanks for taking the time to fill out this bug report!
9
+ - type: textarea
10
+ id: description
11
+ attributes:
12
+ label: What happened?
13
+ description: Please describe the issue
14
+ validations:
15
+ required: true
16
+ - type: textarea
17
+ id: expected
18
+ attributes:
19
+ label: Expected behaviour
20
+ description: Tell us what should have happened
21
+ - type: textarea
22
+ id: repro-steps
23
+ attributes:
24
+ label: Steps to reproduce
25
+ description: Tell us how to reproduce the issue
26
+ validations:
27
+ required: true
28
+ - type: input
29
+ id: aat-version
30
+ attributes:
31
+ label: Authoring tool version
32
+ description: What version of the Adapt authoring tool are you running?
33
+ validations:
34
+ required: true
35
+ - type: input
36
+ id: fw-version
37
+ attributes:
38
+ label: Framework version
39
+ description: What version of the Adapt framework are you running?
40
+ - type: dropdown
41
+ id: browsers
42
+ attributes:
43
+ label: What browsers are you seeing the problem on?
44
+ multiple: true
45
+ options:
46
+ - Firefox
47
+ - Chrome
48
+ - Safari
49
+ - Microsoft Edge
50
+ - type: textarea
51
+ id: logs
52
+ attributes:
53
+ label: Relevant log output
54
+ description: Please copy and paste any relevant log output. This will be automatically formatted into code, so no need for backticks.
55
+ render: sh
@@ -0,0 +1 @@
1
+ blank_issues_enabled: false
@@ -0,0 +1,22 @@
1
+ name: Feature request
2
+ description: Request a new feature
3
+ labels: ["enhancement"]
4
+ body:
5
+ - type: markdown
6
+ attributes:
7
+ value: |
8
+ Thanks for taking the time to request a new feature in the Adapt authoring tool! The Adapt team will consider all new feature requests, but unfortunately cannot commit to every one.
9
+ - type: textarea
10
+ id: description
11
+ attributes:
12
+ label: Feature description
13
+ description: Please describe your feature request
14
+ validations:
15
+ required: true
16
+ - type: checkboxes
17
+ id: contribute
18
+ attributes:
19
+ label: Can you work on this feature?
20
+ description: If you are able to commit your own time to work on this feature, it will greatly increase the liklihood of it being implemented by the core dev team. Otherwise, it will be triaged and prioritised alongside the core team's current priorities.
21
+ options:
22
+ - label: I can contribute
@@ -0,0 +1,11 @@
1
+ # To get started with Dependabot version updates, you'll need to specify which
2
+ # package ecosystems to update and where the package manifests are located.
3
+ # Please see the documentation for all configuration options:
4
+ # https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file
5
+
6
+ version: 2
7
+ updates:
8
+ - package-ecosystem: "npm" # See documentation for possible values
9
+ directory: "/" # Location of package manifests
10
+ schedule:
11
+ interval: "weekly"
@@ -0,0 +1,25 @@
1
+ [//]: # (Please title your PR according to eslint commit conventions)
2
+ [//]: # (See https://github.com/conventional-changelog/conventional-changelog/tree/master/packages/conventional-changelog-eslint#eslint-convention for details)
3
+
4
+ [//]: # (Add a link to the original issue)
5
+
6
+ [//]: # (Delete as appropriate)
7
+ ### Fix
8
+ * A sentence describing each fix
9
+
10
+ ### Update
11
+ * A sentence describing each udpate
12
+
13
+ ### New
14
+ * A sentence describing each new feature
15
+
16
+ ### Breaking
17
+ * A sentence describing each breaking change
18
+
19
+ [//]: # (List appropriate steps for testing if needed)
20
+ ### Testing
21
+ 1. Steps for testing
22
+
23
+ [//]: # (Mention any other dependencies)
24
+
25
+
@@ -0,0 +1,16 @@
1
+ name: Add labelled PRs to project
2
+
3
+ on:
4
+ pull_request:
5
+ types: [ labeled ]
6
+
7
+ jobs:
8
+ add-to-project:
9
+ if: ${{ github.event.label.name == 'dependencies' }}
10
+ name: Add to main project
11
+ runs-on: ubuntu-latest
12
+ steps:
13
+ - uses: actions/add-to-project@v0.1.0
14
+ with:
15
+ project-url: https://github.com/orgs/adapt-security/projects/5
16
+ github-token: ${{ secrets.PROJECTS_SECRET }}
@@ -0,0 +1,19 @@
1
+ name: Add to main project
2
+
3
+ on:
4
+ issues:
5
+ types:
6
+ - opened
7
+ pull_request:
8
+ types:
9
+ - opened
10
+
11
+ jobs:
12
+ add-to-project:
13
+ name: Add to main project
14
+ runs-on: ubuntu-latest
15
+ steps:
16
+ - uses: actions/add-to-project@v0.1.0
17
+ with:
18
+ project-url: https://github.com/orgs/adapt-security/projects/5
19
+ github-token: ${{ secrets.PROJECTS_SECRET }}
@@ -0,0 +1,5 @@
1
+ {
2
+ "documentation": {
3
+ "enable": true
4
+ }
5
+ }
@@ -0,0 +1,18 @@
1
+ {
2
+ "INVALID_PARENT": {
3
+ "data": {
4
+ "parentId": "_id of the parent item"
5
+ },
6
+ "description": "Specified item is not a valid content item Invalid parent itemparent",
7
+ "statusCode": 500
8
+ },
9
+ "UNKNOWN_SCHEMA_NAME": {
10
+ "data": {
11
+ "_id": "The database _id",
12
+ "_type": "The _type value",
13
+ "_component": "The _component value"
14
+ },
15
+ "description": "Failed to determine schema name",
16
+ "statusCode": 500
17
+ }
18
+ }
package/index.js ADDED
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Functionality related to course content
3
+ * @namespace content
4
+ */
5
+ export { default } from './lib/ContentModule.js'
@@ -0,0 +1,384 @@
1
+ import { AbstractApiModule, AbstractApiUtils } from 'adapt-authoring-api'
2
+ import apidefs from './apidefs.js'
3
+ /**
4
+ * Module which handles course content
5
+ * @memberof content
6
+ * @extends {AbstractApiModule}
7
+ */
8
+ class ContentModule extends AbstractApiModule {
9
+ /** @override */
10
+ async setValues () {
11
+ const server = await this.app.waitForModule('server')
12
+ /** @ignore */ this.root = 'content'
13
+ /** @ignore */ this.collectionName = 'content'
14
+ /** @ignore */ this.schemaName = 'content'
15
+ /** @ignore */ this.router = server.api.createChildRouter('content')
16
+ this.useDefaultRouteConfig()
17
+ /** @ignore */ this.routes = [
18
+ {
19
+ route: '/insertrecusive',
20
+ handlers: { post: this.handleInsertRecursive.bind(this) },
21
+ permissions: { post: ['write:content'] },
22
+ meta: apidefs.insertrecursive
23
+ },
24
+ {
25
+ route: '/clone',
26
+ handlers: { post: this.handleClone.bind(this) },
27
+ permissions: { post: ['write:content'] },
28
+ meta: apidefs.clone
29
+ },
30
+ ...this.routes
31
+ ]
32
+ }
33
+
34
+ /** @override */
35
+ async init () {
36
+ await super.init()
37
+
38
+ const [authored, jsonschema, mongodb, tags] = await this.app.waitForModule('authored', 'jsonschema', 'mongodb', 'tags')
39
+
40
+ await authored.registerModule(this)
41
+ await tags.registerModule(this)
42
+ /**
43
+ * we have to extend config specifically here because it doesn't use the default content schema
44
+ */
45
+ jsonschema.registerSchemasHook.tap(this.registerConfigSchemas.bind(this))
46
+ await this.registerConfigSchemas()
47
+
48
+ await mongodb.setIndex(this.collectionName, { _courseId: 1, _parentId: 1, _type: 1 })
49
+ }
50
+
51
+ /** @override */
52
+ async getSchemaName (data) {
53
+ const contentplugin = await this.app.waitForModule('contentplugin')
54
+ let { _component, _id, _type } = data
55
+ const defaultSchemaName = super.getSchemaName(data)
56
+
57
+ if (_id && (!_type || !_component)) { // no explicit type, so look for record in the DB
58
+ const [item] = await this.find({ _id }, { validate: false })
59
+ if (item) {
60
+ _type = item._type
61
+ _component = item._component
62
+ }
63
+ }
64
+ if (!_type && !_component) { // can't go any further, return default value
65
+ return defaultSchemaName
66
+ }
67
+ if (_type !== 'component') {
68
+ return _type === 'page' || _type === 'menu' ? 'contentobject' : _type
69
+ }
70
+ const [component] = await contentplugin.find({ name: _component }, { validate: false })
71
+ return component ? `${component.targetAttribute.slice(1)}-component` : defaultSchemaName
72
+ }
73
+
74
+ /** @override */
75
+ async getSchema (schemaName, data) {
76
+ const jsonschema = await this.app.waitForModule('jsonschema')
77
+ try { // try and determine a more specific schema
78
+ schemaName = await this.getSchemaName(data)
79
+ } catch (e) {}
80
+ const contentplugin = await this.app.waitForModule('contentplugin')
81
+ const _courseId = data._courseId ??
82
+ (data._id ? (await this.find({ _id: data._id }, { validate: false }))[0]?._courseId : undefined)
83
+ let enabledPluginSchemas = []
84
+ if (_courseId) {
85
+ try {
86
+ const [config] = await this.find({ _type: 'config', _courseId }, { validate: false })
87
+ enabledPluginSchemas = config._enabledPlugins.reduce((m, p) => [...m, ...contentplugin.getPluginSchemas(p)], [])
88
+ } catch (e) {}
89
+ }
90
+ return jsonschema.getSchema(schemaName, {
91
+ useCache: false,
92
+ extensionFilter: s => contentplugin.isPluginSchema(s) ? enabledPluginSchemas.includes(s) : true
93
+ })
94
+ }
95
+
96
+ /**
97
+ * Adds config schema extensions
98
+ */
99
+ async registerConfigSchemas () {
100
+ const [authored, jsonschema, tags] = await this.app.waitForModule('authored', 'jsonschema', 'tags')
101
+ jsonschema.extendSchema('config', authored.schemaName)
102
+ jsonschema.extendSchema('config', tags.schemaExtensionName)
103
+ }
104
+
105
+ /** @override */
106
+ async insert (data, options = {}, mongoOptions = {}) {
107
+ const doc = await super.insert(data, options, mongoOptions)
108
+
109
+ if (doc._type === 'course') { // add the _courseId to a new course to make querying easier
110
+ return this.update({ _id: doc._id }, { _courseId: doc._id.toString() })
111
+ }
112
+ await Promise.all([
113
+ options.updateSortOrder !== false && this.updateSortOrder(doc, data),
114
+ options.updateEnabledPlugins !== false && this.updateEnabledPlugins(doc)
115
+ ])
116
+ return doc
117
+ }
118
+
119
+ /** @override */
120
+ async update (query, data, options, mongoOptions) {
121
+ const doc = await super.update(query, data, options, mongoOptions)
122
+ await Promise.all([
123
+ this.updateSortOrder(doc, data),
124
+ this.updateEnabledPlugins(doc, data._enabledPlugins ? { forceUpdate: true } : {})
125
+ ])
126
+ return doc
127
+ }
128
+
129
+ /** @override */
130
+ async delete (query, options, mongoOptions) {
131
+ this.setDefaultOptions(options)
132
+
133
+ const [targetDoc] = await this.find(query)
134
+
135
+ if (!targetDoc) {
136
+ throw this.app.errors.NOT_FOUND.setData({ type: options.schemaName, id: JSON.stringify(query) })
137
+ }
138
+ const descendants = await this.getDescendants(targetDoc)
139
+
140
+ await Promise.all([...descendants, targetDoc].map(d => {
141
+ return super.delete({ _id: d._id })
142
+ }))
143
+ await Promise.all([
144
+ this.updateEnabledPlugins(targetDoc),
145
+ this.updateSortOrder(targetDoc)
146
+ ])
147
+ return [targetDoc, ...descendants]
148
+ }
149
+
150
+ /**
151
+ * Finds all descendant content items for a given root
152
+ * @param {Object} rootItem The root item document
153
+ * @returns {Array<Object>} Array of content items
154
+ */
155
+ async getDescendants (rootItem) {
156
+ const courseItems = await this.find({ _courseId: rootItem._courseId })
157
+ const descendants = []
158
+ let items = [rootItem]
159
+ do {
160
+ items = items.reduce((m, i) => [...m, ...courseItems.filter(c => c._parentId?.toString() === i._id.toString())], [])
161
+ descendants.push(...items)
162
+ } while (items.length)
163
+
164
+ if (rootItem._type === 'course') {
165
+ const config = courseItems.find(c => c._type === 'config')
166
+ if (config) descendants.push(config)
167
+ }
168
+ return descendants
169
+ }
170
+
171
+ /**
172
+ * Creates a new parent content type, along with any necessary children
173
+ * @param {external:ExpressRequest} req
174
+ */
175
+ async insertRecursive (req) {
176
+ const rootId = req.apiData.query.rootId
177
+ const createdBy = req.auth.user._id.toString()
178
+ let childTypes = ['course', 'page', 'article', 'block', 'component']
179
+ const defaultData = {
180
+ page: { title: req.translate('app.newpagetitle') },
181
+ article: { title: req.translate('app.newarticletitle') },
182
+ block: { title: req.translate('app.newblocktitle') },
183
+ component: {
184
+ _component: 'adapt-contrib-text',
185
+ _layout: 'full',
186
+ title: req.translate('app.newtextcomponenttitle'),
187
+ body: req.translate('app.newtextcomponentbody')
188
+ }
189
+ }
190
+ const newItems = []
191
+ let parent
192
+ try {
193
+ // figure out which children need creating
194
+ if (rootId === undefined) { // new course
195
+ parent = await this.insert({ _type: 'course', createdBy, ...req.apiData.data }, { schemaName: 'course' })
196
+ newItems.push(parent)
197
+ childTypes.splice(0, 1, 'config')
198
+ } else {
199
+ parent = (await this.find({ _id: rootId }))[0]
200
+ // special case for menus
201
+ req.body?._type === 'menu'
202
+ ? childTypes.splice(0, 1, 'menu')
203
+ : childTypes = childTypes.slice(childTypes.indexOf(parent._type) + 1)
204
+ }
205
+ for (const _type of childTypes) {
206
+ const data = Object.assign({ _type, createdBy }, defaultData[_type])
207
+ if (parent) {
208
+ Object.assign(data, {
209
+ _parentId: parent._id.toString(),
210
+ _courseId: parent._courseId.toString()
211
+ })
212
+ }
213
+ const item = await this.insert(data)
214
+ newItems.push(item)
215
+ if (_type !== 'config') parent = item
216
+ }
217
+ } catch (e) {
218
+ await Promise.all(newItems.map(({ _id }) => super.delete({ _id }, { invokePostHook: false })))
219
+ throw e
220
+ }
221
+ // return the topmost new item
222
+ return newItems[0]
223
+ }
224
+
225
+ /**
226
+ * Recursively clones a content item
227
+ * @param {String} userId The user performing the action
228
+ * @param {String} _id ID of the object to clone
229
+ * @param {String} _parentId The intended parent object (if this is not passed, no parent will be set)
230
+ * @param {Object} customData Data to be applied to the content item
231
+ * @return {Promise}
232
+ */
233
+ async clone (userId, _id, _parentId, customData = {}) {
234
+ const [originalDoc] = await this.find({ _id })
235
+ if (!originalDoc) {
236
+ throw this.app.errors.NOT_FOUND
237
+ .setData({ type: originalDoc?._type, id: _id })
238
+ }
239
+ const [parent] = _parentId ? await this.find({ _id: _parentId }) : []
240
+
241
+ if (!parent && originalDoc._type !== 'course' && originalDoc._type !== 'config') {
242
+ throw this.app.errors.INVALID_PARENT.setData({ parentId: _parentId })
243
+ }
244
+ const schemaName = originalDoc._type === 'menu' || originalDoc._type === 'page' ? 'contentobject' : originalDoc._type
245
+ const payload = AbstractApiUtils.stringifyValues({
246
+ ...originalDoc,
247
+ _id: undefined,
248
+ _trackingId: undefined,
249
+ _courseId: parent?._type === 'course' ? parent?._id : parent?._courseId,
250
+ _parentId,
251
+ createdBy: userId,
252
+ ...customData
253
+ })
254
+ const newData = await this.insert(payload, { schemaName })
255
+
256
+ if (originalDoc._type === 'course') {
257
+ const [config] = await this.find({ _type: 'config', _courseId: originalDoc._courseId })
258
+ await this.clone(userId, config._id, undefined, { _courseId: newData._id.toString() })
259
+ delete payload._id
260
+ delete payload._courseId
261
+ // the config did not exist when the new course object was created
262
+ // schema validation will have therefore stripped the plugin configuration data
263
+ // here we restore the configuraton data
264
+ await this.update({ _id: newData._id }, payload)
265
+ }
266
+ const children = await this.find({ _parentId: _id })
267
+ for (let i = 0; i < children.length; i++) {
268
+ await this.clone(userId, children[i]._id, newData._id)
269
+ }
270
+ return newData
271
+ }
272
+
273
+ /**
274
+ * Recalculates the _sortOrder values for all content items affected by an update
275
+ * @param {Object} item The existing item data
276
+ * @param {Object} updateData The update data
277
+ * @return {Promise}
278
+ */
279
+ async updateSortOrder (item, updateData) {
280
+ // some exceptions which don't need a _sortOrder
281
+ if (item._type === 'config' || item._type === 'course' || !item._parentId) {
282
+ return
283
+ }
284
+ const siblings = await this.find({ _parentId: item._parentId, _id: { $ne: item._id } }, {}, { sort: { _sortOrder: 1 } })
285
+ if (updateData) {
286
+ const newSO = item._sortOrder - 1 > -1 ? item._sortOrder - 1 : siblings.length
287
+ siblings.splice(newSO, 0, item)
288
+ }
289
+ return Promise.all(siblings.map(async (s, i) => {
290
+ const _sortOrder = i + 1
291
+ if (s._sortOrder !== _sortOrder) super.update({ _id: s._id }, { _sortOrder })
292
+ }))
293
+ }
294
+
295
+ /**
296
+ * Maintains the list of plugins used in the current course
297
+ * @param {Object} item The updated item
298
+ * @param {Object} options
299
+ * @param {Boolean} options.forceUpdate Forces an update of defaults regardless of whether the _enabledPlugins list has changed
300
+ * @return {Promise}
301
+ */
302
+ async updateEnabledPlugins ({ _courseId }, options = {}) {
303
+ const [contentplugin, jsonschema] = await this.app.waitForModule('contentplugin', 'jsonschema')
304
+ const contentItems = await this.find({ _courseId })
305
+ const config = contentItems.find(c => c._type === 'config')
306
+
307
+ if (!config) {
308
+ return // can't continue if there's no config to update
309
+ }
310
+ const extensionNames = (await contentplugin.find({ type: 'extension' })).map(p => p.name)
311
+ const componentNames = (contentItems.filter(c => c._type === 'component')).map(c => c._component)
312
+ // generate unique list of used plugins
313
+ const _enabledPlugins = Array.from(new Set([
314
+ ...config._enabledPlugins.filter(name => extensionNames.includes(name)), // only extensions, rest are calculated below
315
+ ...componentNames,
316
+ config._menu,
317
+ config._theme
318
+ ]))
319
+ if (options.forceUpdate !== true &&
320
+ config._enabledPlugins.length === _enabledPlugins.length &&
321
+ config._enabledPlugins.every(p => _enabledPlugins.includes(p))) {
322
+ return // return early if the lists already match
323
+ }
324
+ // generate list of used content types which need defaults applied
325
+ const types = _enabledPlugins
326
+ .filter(p => options.forceUpdate || !config._enabledPlugins.includes(p))
327
+ .reduce((m, p) => m.concat(contentplugin.getPluginSchemas(p)), [])
328
+ .reduce((types, pluginSchemaName) => {
329
+ const rawSchema = jsonschema.schemas[pluginSchemaName].raw
330
+ const type = rawSchema?.$merge?.source?.$ref ?? rawSchema?.$patch?.source?.$ref
331
+ return (type === 'contentobject' ? ['menu', 'page'] : [type]).reduce((m, t) => {
332
+ if (t && !m.includes(t)) m.push(t)
333
+ return m
334
+ }, types)
335
+ }, [])
336
+ // update config._enabledPlugins
337
+ await super.update({ _courseId, _type: 'config' }, { _enabledPlugins })
338
+ // update other affected content objects to ensure new defaults are applied
339
+ // note: due to the complex data, each must be updated separately rather than using updateMany
340
+ if (types.length > 0) {
341
+ const toUpdate = await super.find({ _courseId, _type: { $in: types } }, {})
342
+ return Promise.all(toUpdate.map(c => super.update({ _id: c._id }, {})))
343
+ }
344
+ }
345
+
346
+ /**
347
+ * Special request handler for bootstrapping a new content object with dummy content
348
+ * @param {external:ExpressRequest} req
349
+ * @param {external:ExpressResponse} res
350
+ * @param {Function} next
351
+ */
352
+ async handleInsertRecursive (req, res, next) {
353
+ try {
354
+ res.status(201).json(await this.insertRecursive(req))
355
+ } catch (e) {
356
+ return next(e)
357
+ }
358
+ }
359
+
360
+ /**
361
+ * Request handler for cloning content items
362
+ * @param {external:ExpressRequest} req
363
+ * @param {external:ExpressResponse} res
364
+ * @param {Function} next
365
+ * @return {Promise} Resolves with the cloned data
366
+ */
367
+ async handleClone (req, res, next) {
368
+ try {
369
+ await this.checkAccess(req, req.apiData.query)
370
+ const { _id, _parentId } = req.body
371
+
372
+ const customData = { ...req.body }
373
+ delete customData._id
374
+ delete customData._parentId
375
+
376
+ const newData = await this.clone(req.auth.user._id, _id, _parentId, customData)
377
+ res.status(201).json(newData)
378
+ } catch (e) {
379
+ return next(e)
380
+ }
381
+ }
382
+ }
383
+
384
+ export default ContentModule
package/lib/apidefs.js ADDED
@@ -0,0 +1,50 @@
1
+ export default {
2
+ clone: {
3
+ post: {
4
+ summary: 'Clones a content item',
5
+ description: 'Duplicates a content item as well as all its children.',
6
+ responses: {
7
+ 201: {
8
+ description: 'The newly cloned data',
9
+ content: {
10
+ 'application/json': {
11
+ schema: {
12
+ $schema: 'https://json-schema.org/draft/2020-12/schema',
13
+ type: 'array',
14
+ items: { $ref: '#components/schemas/content' }
15
+ }
16
+ }
17
+ }
18
+ }
19
+ }
20
+ }
21
+ },
22
+ insertrecursive: {
23
+ post: {
24
+ summary: 'Insert hierarchical content data',
25
+ description: 'Recursively inserts content data',
26
+ parameters: [{ name: 'rootId', in: 'path', description: 'The parent content item _id', required: true }],
27
+ requestBody: {
28
+ content: {
29
+ 'application/json': {
30
+ schema: { $ref: '#components/schemas/content' }
31
+ }
32
+ }
33
+ },
34
+ responses: {
35
+ 201: {
36
+ description: 'The newly inserted data',
37
+ content: {
38
+ 'application/json': {
39
+ schema: {
40
+ $schema: 'https://json-schema.org/draft/2020-12/schema',
41
+ type: 'array',
42
+ items: { $ref: '#components/schemas/content' }
43
+ }
44
+ }
45
+ }
46
+ }
47
+ }
48
+ }
49
+ }
50
+ }
package/package.json ADDED
@@ -0,0 +1,14 @@
1
+ {
2
+ "name": "adapt-authoring-content",
3
+ "version": "0.0.1",
4
+ "description": "Module for managing Adapt content",
5
+ "homepage": "https://github.com/adapt-security/adapt-authoring-content",
6
+ "license": "GPL-3.0",
7
+ "type": "module",
8
+ "main": "index.js",
9
+ "repository": "github:adapt-security/adapt-authoring-content",
10
+ "devDependencies": {
11
+ "eslint": "^9.12.0",
12
+ "standard": "^17.1.0"
13
+ }
14
+ }