monastery 1.31.6 → 1.31.7
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/changelog.md +7 -0
- package/docs/Gemfile +3 -9
- package/docs/_config.yml +1 -0
- package/docs/{schema.md → schema/index.md} +1 -0
- package/docs/{rules.md → schema/rules.md} +1 -0
- package/lib/model-crud.js +135 -171
- package/package.json +1 -1
package/changelog.md
CHANGED
|
@@ -2,6 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines.
|
|
4
4
|
|
|
5
|
+
### [1.31.7](https://github.com/boycce/monastery/compare/1.31.6...1.31.7) (2022-02-28)
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
### Bug Fixes
|
|
9
|
+
|
|
10
|
+
* refactored crud ops ([e7f3f78](https://github.com/boycce/monastery/commit/e7f3f784e123e4a66586a4d9e733d5cac477b98b))
|
|
11
|
+
|
|
5
12
|
### [1.31.6](https://github.com/boycce/monastery/compare/1.31.5...1.31.6) (2022-02-25)
|
|
6
13
|
|
|
7
14
|
|
package/docs/Gemfile
CHANGED
|
@@ -1,10 +1,4 @@
|
|
|
1
|
-
# Old
|
|
2
|
-
# source 'https://rubygems.org'
|
|
3
|
-
# gem 'github-pages', group: :jekyll_plugins
|
|
4
|
-
|
|
5
|
-
# Below pulls the latest remote_theme in development
|
|
6
1
|
source 'https://rubygems.org'
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
end
|
|
2
|
+
|
|
3
|
+
# Development (strict gem versions used)
|
|
4
|
+
gem 'github-pages', group: :jekyll_plugins
|
package/docs/_config.yml
CHANGED
package/lib/model-crud.js
CHANGED
|
@@ -2,7 +2,7 @@ let util = require('./util')
|
|
|
2
2
|
|
|
3
3
|
module.exports = {
|
|
4
4
|
|
|
5
|
-
insert: function(opts, cb) {
|
|
5
|
+
insert: async function(opts, cb) {
|
|
6
6
|
/**
|
|
7
7
|
* Inserts document(s) with monk after validating data & before hooks.
|
|
8
8
|
* @param {object} opts
|
|
@@ -18,44 +18,36 @@ module.exports = {
|
|
|
18
18
|
* @this model
|
|
19
19
|
* @return promise
|
|
20
20
|
*/
|
|
21
|
-
opts = opts || {}
|
|
22
|
-
opts.insert = true
|
|
23
|
-
opts.model = this
|
|
24
|
-
let req = opts.req
|
|
25
|
-
let data = opts.data = util.isDefined(opts.data)? opts.data : util.isDefined((req||{}).body) ? req.body : {}
|
|
26
|
-
let options = util.omit(opts, [
|
|
27
|
-
'data', 'insert', 'model', 'respond', 'validateUndefined', 'skipValidation', 'blacklist'
|
|
28
|
-
])
|
|
29
21
|
if (cb && !util.isFunction(cb)) {
|
|
30
22
|
throw new Error(`The callback passed to ${this.name}.insert() is not a function`)
|
|
31
23
|
}
|
|
32
|
-
|
|
33
|
-
opts
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
if (cb) cb(null,
|
|
48
|
-
else if (opts.req && opts.respond) opts.req.res.json(
|
|
49
|
-
else return Promise.resolve(
|
|
50
|
-
|
|
51
|
-
}
|
|
24
|
+
try {
|
|
25
|
+
opts = await this._queryObject(opts, 'insert')
|
|
26
|
+
let options = util.omit(opts, [
|
|
27
|
+
'data', 'insert', 'model', 'respond', 'validateUndefined', 'skipValidation', 'blacklist'
|
|
28
|
+
])
|
|
29
|
+
|
|
30
|
+
// Validate
|
|
31
|
+
opts.data = await this.validate(opts.data||{}, { ...opts })
|
|
32
|
+
|
|
33
|
+
// Insert
|
|
34
|
+
await util.runSeries(this.beforeInsert.map(f => f.bind(opts, opts.data)))
|
|
35
|
+
let response = await this._insert(opts.data, options)
|
|
36
|
+
await util.runSeries(this.afterInsert.map(f => f.bind(opts, response)))
|
|
37
|
+
|
|
38
|
+
// Success/error
|
|
39
|
+
if (cb) cb(null, response)
|
|
40
|
+
else if (opts.req && opts.respond) opts.req.res.json(response)
|
|
41
|
+
else return Promise.resolve(response)
|
|
42
|
+
|
|
43
|
+
} catch (err) {
|
|
52
44
|
if (cb) cb(err)
|
|
53
45
|
else if (opts && opts.req && opts.respond) opts.req.res.error(err)
|
|
54
46
|
else throw err
|
|
55
|
-
}
|
|
47
|
+
}
|
|
56
48
|
},
|
|
57
49
|
|
|
58
|
-
find: function(opts, cb, one) {
|
|
50
|
+
find: async function(opts, cb, one) {
|
|
59
51
|
/**
|
|
60
52
|
* Finds document(s) with monk, also auto populates
|
|
61
53
|
* @param {object} opts
|
|
@@ -69,19 +61,15 @@ module.exports = {
|
|
|
69
61
|
* @this model
|
|
70
62
|
* @return promise
|
|
71
63
|
*/
|
|
72
|
-
let options, lookups = []
|
|
73
64
|
if (cb && !util.isFunction(cb)) {
|
|
74
65
|
throw new Error(`The callback passed to ${this.name}.find() is not a function`)
|
|
75
66
|
}
|
|
76
|
-
|
|
77
|
-
opts = this._queryObject(opts)
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
options = util.omit(opts, ['blacklist', 'one', 'populate', 'project', 'query', 'respond'])
|
|
81
|
-
options.sort = options.sort || { 'createdAt': -1 }
|
|
82
|
-
options.skip = Math.max(0, options.skip || 0)
|
|
83
|
-
options.limit = opts.one? 1 : parseInt(options.limit || this.manager.limit || 0)
|
|
67
|
+
try {
|
|
68
|
+
opts = await this._queryObject(opts, 'find', one)
|
|
69
|
+
let lookups = []
|
|
70
|
+
let options = util.omit(opts, ['blacklist', 'one', 'populate', 'project', 'query', 'respond'])
|
|
84
71
|
options.addFields = options.addFields || {}
|
|
72
|
+
|
|
85
73
|
// Project, or use blacklisting
|
|
86
74
|
if (opts.project) {
|
|
87
75
|
// Can be an inclusion or exclusion projection
|
|
@@ -105,14 +93,10 @@ module.exports = {
|
|
|
105
93
|
// options.projection.score = { $meta: 'textScore' }
|
|
106
94
|
// options.sort = { score: { $meta: 'textScore' }}
|
|
107
95
|
// }
|
|
108
|
-
// Sort string passed
|
|
109
|
-
if (util.isString(options.sort)) {
|
|
110
|
-
let name = (options.sort.match(/([a-z0-9-_]+)/) || [])[0]
|
|
111
|
-
let order = (options.sort.match(/:(-?[0-9])/) || [])[1]
|
|
112
|
-
options.sort = { [name]: parseInt(order || 1) }
|
|
113
|
-
}
|
|
114
96
|
// Wanting to populate?
|
|
115
|
-
if (opts.populate) {
|
|
97
|
+
if (!opts.populate) {
|
|
98
|
+
var response = await this[`_find${opts.one? 'One' : ''}`](opts.query, options)
|
|
99
|
+
} else {
|
|
116
100
|
loop: for (let item of opts.populate) {
|
|
117
101
|
let path = util.isObject(item)? item.as : item
|
|
118
102
|
// Blacklisted?
|
|
@@ -148,7 +132,7 @@ module.exports = {
|
|
|
148
132
|
}
|
|
149
133
|
// console.log(1, options.projection)
|
|
150
134
|
// console.log(2, lookups)
|
|
151
|
-
let
|
|
135
|
+
let aggregate = [
|
|
152
136
|
{ $match: opts.query },
|
|
153
137
|
{ $sort: options.sort },
|
|
154
138
|
{ $skip: options.skip },
|
|
@@ -157,41 +141,37 @@ module.exports = {
|
|
|
157
141
|
...util.isEmpty(options.addFields)? [] : [{ $addFields: options.addFields }],
|
|
158
142
|
{ $project: options.projection }
|
|
159
143
|
]
|
|
160
|
-
|
|
161
|
-
this.info('aggregate', JSON.stringify(
|
|
162
|
-
|
|
163
|
-
// Normal operation
|
|
164
|
-
} else {
|
|
165
|
-
operation = this[opts.one? '_findOne' : '_find'].bind(this._collection, opts.query, options)
|
|
144
|
+
response = await this._aggregate(aggregate)
|
|
145
|
+
this.info('aggregate', JSON.stringify(aggregate))
|
|
166
146
|
}
|
|
167
|
-
operation()
|
|
168
|
-
.then(data => resolve(data))
|
|
169
|
-
.catch(err => reject(err))
|
|
170
147
|
|
|
171
|
-
|
|
172
|
-
if (opts.one && util.isArray(data)) data = data[0]
|
|
148
|
+
if (opts.one && util.isArray(response)) response = response[0]
|
|
173
149
|
// (Not using) Project works with lookup.
|
|
174
150
|
// Remove blacklisted properties from joined models, because subpiplines with 'project' are slower
|
|
175
151
|
// if (opts.populate) this._depreciated_removeBlacklisted(data, opts.populate, options.projection)
|
|
176
|
-
|
|
152
|
+
response = await this._processAfterFind(response, options.projection, opts)
|
|
177
153
|
|
|
178
|
-
|
|
179
|
-
if (cb) cb(null,
|
|
180
|
-
else if (opts.req && opts.respond) opts.req.res.json(
|
|
181
|
-
else return Promise.resolve(
|
|
154
|
+
// Success
|
|
155
|
+
if (cb) cb(null, response)
|
|
156
|
+
else if (opts.req && opts.respond) opts.req.res.json(response)
|
|
157
|
+
else return Promise.resolve(response)
|
|
182
158
|
|
|
183
|
-
}
|
|
159
|
+
} catch (err) {
|
|
184
160
|
if (cb) cb(err)
|
|
185
161
|
else if (opts && opts.req && opts.respond) opts.req.res.error(err)
|
|
186
162
|
else throw err
|
|
187
|
-
}
|
|
163
|
+
}
|
|
188
164
|
},
|
|
189
165
|
|
|
190
|
-
findOne: function(opts, cb) {
|
|
166
|
+
findOne: async function(opts, cb) {
|
|
191
167
|
return this.find(opts, cb, true)
|
|
192
168
|
},
|
|
193
169
|
|
|
194
|
-
|
|
170
|
+
// findOneAndUpdate: function(opts, cb) {
|
|
171
|
+
// return this._findOneAndUpdate(opts, cb)
|
|
172
|
+
// },
|
|
173
|
+
|
|
174
|
+
update: async function(opts, cb) {
|
|
195
175
|
/**
|
|
196
176
|
* Updates document(s) with monk after validating data & before hooks.
|
|
197
177
|
* @param {object} opts
|
|
@@ -208,78 +188,51 @@ module.exports = {
|
|
|
208
188
|
* @this model
|
|
209
189
|
* @return promise(data)
|
|
210
190
|
*/
|
|
211
|
-
let data, options, operators
|
|
212
191
|
if (cb && !util.isFunction(cb)) {
|
|
213
192
|
throw new Error(`The callback passed to ${this.name}.update() is not a function`)
|
|
214
193
|
}
|
|
215
|
-
|
|
216
|
-
opts = this._queryObject(opts)
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
let
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
options.limit = parseInt(options.limit || 0)
|
|
226
|
-
// Sort string passed
|
|
227
|
-
if (util.isString(options.sort)) {
|
|
228
|
-
let name = (options.sort.match(/([a-z0-9-_]+)/) || [])[0]
|
|
229
|
-
let order = (options.sort.match(/:(-?[0-9])/) || [])[1]
|
|
230
|
-
options.sort = { [name]: parseInt(order || 1) }
|
|
194
|
+
try {
|
|
195
|
+
opts = await this._queryObject(opts, 'update')
|
|
196
|
+
let response = null
|
|
197
|
+
let operators = util.pluck(opts, [/^\$/])
|
|
198
|
+
let options = util.omit(opts, ['data', 'query', 'respond', 'validateUndefined', 'skipValidation', 'blacklist'])
|
|
199
|
+
|
|
200
|
+
// Validate
|
|
201
|
+
if (util.isDefined(opts.data)) opts.data = await this.validate(opts.data, { ...opts })
|
|
202
|
+
if (!util.isDefined(opts.data) && util.isEmpty(operators)) {
|
|
203
|
+
throw new Error(`Please pass an update operator to ${this.name}.update(), e.g. data, $unset, etc`)
|
|
231
204
|
}
|
|
232
|
-
util.
|
|
233
|
-
|
|
234
|
-
}).then(data => {
|
|
235
|
-
opts.data = data
|
|
236
|
-
if (util.isDefined(data)) return this.validate(data, { ...opts })
|
|
237
|
-
else return Promise.resolve(data)
|
|
238
|
-
|
|
239
|
-
}).then(data => {
|
|
240
|
-
if (util.isDefined(data) && (!data || util.isEmpty(data))) {
|
|
205
|
+
if (util.isDefined(opts.data) && (!opts.data || util.isEmpty(opts.data))) {
|
|
241
206
|
throw new Error(`No valid data passed to ${this.name}.update({ data: .. })`)
|
|
242
207
|
}
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
return util.runSeries(this.beforeUpdate.map(f => f.bind(opts, data||{}))).then(() => data)
|
|
247
|
-
|
|
248
|
-
}).then(data => {
|
|
249
|
-
if (data && operators['$set']) {
|
|
208
|
+
// Hook: beforeUpdate
|
|
209
|
+
await util.runSeries(this.beforeUpdate.map(f => f.bind(opts, opts.data||{})))
|
|
210
|
+
if (opts.data && operators['$set']) {
|
|
250
211
|
this.warn(`'$set' fields take precedence over the data fields for \`${this.name}.update()\``)
|
|
251
212
|
}
|
|
252
|
-
if (data || operators['$set']) {
|
|
253
|
-
operators['$set'] = {
|
|
254
|
-
...data,
|
|
255
|
-
...(operators['$set'] || {}),
|
|
256
|
-
}
|
|
213
|
+
if (opts.data || operators['$set']) {
|
|
214
|
+
operators['$set'] = { ...opts.data, ...(operators['$set'] || {}) }
|
|
257
215
|
}
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
output = data = null // Cleanup (just incase)
|
|
262
|
-
return response
|
|
263
|
-
})
|
|
216
|
+
// Update
|
|
217
|
+
let update = await this._update(opts.query, operators, options)
|
|
218
|
+
if (update.n) response = Object.assign(Object.create({ _output: update }), operators['$set']||{})
|
|
264
219
|
|
|
265
|
-
|
|
266
|
-
if (
|
|
267
|
-
return util.runSeries(this.afterUpdate.map(f => f.bind(opts, data))).then(() => data)
|
|
220
|
+
// Hook: afterUpdate
|
|
221
|
+
if (update.n) await util.runSeries(this.afterUpdate.map(f => f.bind(opts, response)))
|
|
268
222
|
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
if (
|
|
272
|
-
else
|
|
273
|
-
else return Promise.resolve(data)
|
|
223
|
+
// Success
|
|
224
|
+
if (cb) cb(null, response)
|
|
225
|
+
else if (opts.req && opts.respond) opts.req.res.json(response)
|
|
226
|
+
else return response
|
|
274
227
|
|
|
275
|
-
}
|
|
228
|
+
} catch (err) {
|
|
276
229
|
if (cb) cb(err)
|
|
277
230
|
else if (opts && opts.req && opts.respond) opts.req.res.error(err)
|
|
278
231
|
else throw err
|
|
279
|
-
}
|
|
232
|
+
}
|
|
280
233
|
},
|
|
281
234
|
|
|
282
|
-
remove: function(opts, cb) {
|
|
235
|
+
remove: async function(opts, cb) {
|
|
283
236
|
/**
|
|
284
237
|
* Remove document(s) with monk after before hooks.
|
|
285
238
|
* @param {object} opts
|
|
@@ -290,73 +243,84 @@ module.exports = {
|
|
|
290
243
|
* @this model
|
|
291
244
|
* @return promise
|
|
292
245
|
*/
|
|
293
|
-
let options
|
|
294
246
|
if (cb && !util.isFunction(cb)) {
|
|
295
247
|
throw new Error(`The callback passed to ${this.name}.remove() is not a function`)
|
|
296
248
|
}
|
|
297
|
-
|
|
298
|
-
opts = this._queryObject(opts)
|
|
299
|
-
|
|
300
|
-
opts.model = this
|
|
249
|
+
try {
|
|
250
|
+
opts = await this._queryObject(opts, 'remove')
|
|
251
|
+
let options = util.omit(opts, ['query', 'respond'])
|
|
301
252
|
if (util.isEmpty(opts.query)) throw new Error('Please specify opts.query')
|
|
302
|
-
// Operation options
|
|
303
|
-
options = util.omit(opts, ['query', 'respond'])
|
|
304
|
-
options.sort = options.sort || { 'createdAt': -1 }
|
|
305
|
-
options.limit = parseInt(options.limit || 1)
|
|
306
|
-
// Sort string passed
|
|
307
|
-
if (util.isString(options.sort)) {
|
|
308
|
-
let name = (options.sort.match(/([a-z0-9-_]+)/) || [])[0]
|
|
309
|
-
let order = (options.sort.match(/:(-?[0-9])/) || [])[1]
|
|
310
|
-
options.sort = { [name]: parseInt(order || 1) }
|
|
311
|
-
}
|
|
312
|
-
util.runSeries(this.beforeRemove.map(f => f.bind(opts)))
|
|
313
|
-
.then(() => resolve())
|
|
314
|
-
.catch(e => reject(e))
|
|
315
|
-
|
|
316
|
-
}).then(() => {
|
|
317
|
-
return this._remove(opts.query, options)
|
|
318
253
|
|
|
319
|
-
|
|
320
|
-
|
|
254
|
+
// Remove
|
|
255
|
+
await util.runSeries(this.beforeRemove.map(f => f.bind(opts)))
|
|
256
|
+
let response = await this._remove(opts.query, options)
|
|
257
|
+
await util.runSeries(this.afterRemove.map(f => f.bind(response)))
|
|
321
258
|
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
if (
|
|
325
|
-
else
|
|
326
|
-
else return Promise.resolve(data)
|
|
259
|
+
// Success
|
|
260
|
+
if (cb) cb(null, response)
|
|
261
|
+
else if (opts.req && opts.respond) opts.req.res.json(response)
|
|
262
|
+
else return Promise.resolve(response)
|
|
327
263
|
|
|
328
|
-
}
|
|
264
|
+
} catch (err) {
|
|
329
265
|
if (cb) cb(err)
|
|
330
266
|
else if (opts && opts.req && opts.respond) opts.req.res.error(err)
|
|
331
267
|
else throw err
|
|
332
|
-
}
|
|
268
|
+
}
|
|
333
269
|
},
|
|
334
270
|
|
|
335
|
-
_queryObject: function(opts) {
|
|
271
|
+
_queryObject: async function(opts, type, one) {
|
|
336
272
|
/**
|
|
337
|
-
*
|
|
338
|
-
* @param {MongoId|
|
|
339
|
-
* @
|
|
273
|
+
* Normalise options
|
|
274
|
+
* @param {MongoId|string|object} opts
|
|
275
|
+
* @param {string} type - operation type
|
|
276
|
+
* @param {boolean} one - return one document
|
|
277
|
+
* @this model
|
|
278
|
+
* @return {Promise} opts
|
|
340
279
|
*
|
|
341
|
-
* opts == string|
|
|
280
|
+
* opts == string|MongodId - treated as an id
|
|
342
281
|
* opts == undefined|null|false - throw error
|
|
343
282
|
* opts.query == string|MongodID - treated as an id
|
|
344
283
|
* opts.query == undefined|null|false - throw error
|
|
345
284
|
*/
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
if (
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
285
|
+
|
|
286
|
+
// Query
|
|
287
|
+
if (type != 'insert') {
|
|
288
|
+
let isIdType = (o) => util.isId(o) || util.isString(o)
|
|
289
|
+
if (isIdType(opts)) {
|
|
290
|
+
opts = { query: { _id: opts || '' }}
|
|
291
|
+
}
|
|
292
|
+
if (isIdType((opts||{}).query)) {
|
|
293
|
+
opts.query = { _id: opts.query || '' }
|
|
294
|
+
}
|
|
295
|
+
if (!util.isObject(opts) || !util.isObject(opts.query)) {
|
|
296
|
+
throw new Error('Please pass an object or MongoId to options.query')
|
|
297
|
+
}
|
|
298
|
+
// For security, if _id is set and undefined, throw an error
|
|
299
|
+
if (typeof opts.query._id == 'undefined' && opts.query.hasOwnProperty('_id')) {
|
|
300
|
+
throw new Error('Please pass an object or MongoId to options.query')
|
|
301
|
+
}
|
|
302
|
+
if (util.isId(opts.query._id)) opts.query._id = this.manager.id(opts.query._id)
|
|
303
|
+
if (isIdType(opts.query._id) || one) opts.one = true
|
|
304
|
+
opts.query = util.removeUndefined(opts.query)
|
|
305
|
+
|
|
306
|
+
// Query options
|
|
307
|
+
opts.limit = opts.one? 1 : parseInt(opts.limit || (type == 'remove' ? 1 : this.manager.limit || 0))
|
|
308
|
+
opts.skip = Math.max(0, opts.skip || 0)
|
|
309
|
+
opts.sort = opts.sort || { 'createdAt': -1 }
|
|
310
|
+
if (util.isString(opts.sort)) {
|
|
311
|
+
let name = (opts.sort.match(/([a-z0-9-_]+)/) || [])[0]
|
|
312
|
+
let order = (opts.sort.match(/:(-?[0-9])/) || [])[1]
|
|
313
|
+
opts.sort = { [name]: parseInt(order || 1) }
|
|
314
|
+
}
|
|
355
315
|
}
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
if (
|
|
359
|
-
if (
|
|
316
|
+
|
|
317
|
+
// Data
|
|
318
|
+
if (!opts) opts = {}
|
|
319
|
+
if (!util.isDefined(opts.data) && util.isDefined((opts.req||{}).body)) opts.data = opts.req.body
|
|
320
|
+
if (util.isDefined(opts.data)) opts.data = await util.parseData(opts.data)
|
|
321
|
+
|
|
322
|
+
opts[type] = true
|
|
323
|
+
opts.model = this
|
|
360
324
|
return opts
|
|
361
325
|
},
|
|
362
326
|
|
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "monastery",
|
|
3
3
|
"description": "⛪ A straight forward MongoDB ODM built around Monk",
|
|
4
4
|
"author": "Ricky Boyce",
|
|
5
|
-
"version": "1.31.
|
|
5
|
+
"version": "1.31.7",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"repository": "github:boycce/monastery",
|
|
8
8
|
"homepage": "https://boycce.github.io/monastery/",
|