@cap-js/cds-typer 0.2.5-beta.1 → 0.3.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.
- package/CHANGELOG.md +10 -1
- package/README.md +10 -21
- package/lib/compile.js +2 -859
- package/lib/components/inline.js +13 -12
- package/lib/components/resolver.js +471 -0
- package/lib/components/wrappers.js +66 -0
- package/lib/file.js +14 -0
- package/lib/logging.js +5 -4
- package/lib/util.js +47 -2
- package/lib/visitor.js +351 -0
- package/package.json +1 -1
package/lib/util.js
CHANGED
|
@@ -61,9 +61,9 @@ const unlocalize = (name) => {
|
|
|
61
61
|
* Attempts to derive the singular form of an English noun.
|
|
62
62
|
* If '@singular' is passed as annotation, that is preferred.
|
|
63
63
|
* @param {Annotations} dn annotations
|
|
64
|
-
* @param {boolean} stripped if true, leading namespace will be stripped
|
|
64
|
+
* @param {boolean?} stripped if true, leading namespace will be stripped
|
|
65
65
|
*/
|
|
66
|
-
const singular4 = (dn, stripped) => {
|
|
66
|
+
const singular4 = (dn, stripped = false) => {
|
|
67
67
|
let n = dn.name || dn
|
|
68
68
|
if (stripped) {
|
|
69
69
|
n = n.match(last)[0]
|
|
@@ -184,6 +184,50 @@ const parseCommandlineArgs = (argv, validFlags) => {
|
|
|
184
184
|
}
|
|
185
185
|
}
|
|
186
186
|
|
|
187
|
+
/**
|
|
188
|
+
* Entities inherit their ancestors annotations:
|
|
189
|
+
* https://cap.cloud.sap/docs/cds/cdl#annotation-propagation
|
|
190
|
+
* This is a problem if we annotate @singular/ @plural to an entity A,
|
|
191
|
+
* as we don't want all descendents B, C, ... to share the ancestor's
|
|
192
|
+
* annotated inflexion
|
|
193
|
+
* -> remove all such annotations that appear in a parent as well.
|
|
194
|
+
* BUT: we can't just delete the attributes. Imagine three classes
|
|
195
|
+
* A <- B <- C
|
|
196
|
+
* where A contains a @singular annotation.
|
|
197
|
+
* If we erase the annotation from B, C will still contain it and
|
|
198
|
+
* can not detect that its own annotation was inherited without
|
|
199
|
+
* travelling up the entire inheritance chain up to A.
|
|
200
|
+
* So instead, we monkey patch and maintain a dictionary "erased"
|
|
201
|
+
* when removing an annotation which we also check.
|
|
202
|
+
*/
|
|
203
|
+
function fixCSN(csn) {
|
|
204
|
+
const erase = (entity, parent, attr) => {
|
|
205
|
+
if (attr in entity) {
|
|
206
|
+
const ea = entity[attr]
|
|
207
|
+
if (parent[attr] === ea || (parent.erased && parent.erased[attr] === ea)) {
|
|
208
|
+
entity.erased ??= {}
|
|
209
|
+
entity.erased[attr] = ea
|
|
210
|
+
delete entity[attr]
|
|
211
|
+
this.logger.info(`Removing inherited attribute ${attr} from ${entity.name}.`)
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
for (const entity of Object.values(csn.definitions)) {
|
|
217
|
+
let i = 0
|
|
218
|
+
if (entity.includes) {
|
|
219
|
+
while (
|
|
220
|
+
(getSingularAnnotation(entity) || getPluralAnnotation(entity)) &&
|
|
221
|
+
i < entity.includes.length
|
|
222
|
+
) {
|
|
223
|
+
const parent = this.csn.definitions[entity.includes[i]]
|
|
224
|
+
Object.values(annotations).flat().forEach(an => erase(entity, parent, an))
|
|
225
|
+
i++
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
187
231
|
module.exports = {
|
|
188
232
|
annotations,
|
|
189
233
|
getSingularAnnotation,
|
|
@@ -193,4 +237,5 @@ module.exports = {
|
|
|
193
237
|
plural4,
|
|
194
238
|
parseCommandlineArgs,
|
|
195
239
|
deepMerge,
|
|
240
|
+
fixCSN
|
|
196
241
|
}
|
package/lib/visitor.js
ADDED
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const util = require('./util')
|
|
4
|
+
|
|
5
|
+
const { SourceFile, baseDefinitions, Buffer } = require('./file')
|
|
6
|
+
const { FlatInlineDeclarationResolver, StructuredInlineDeclarationResolver } = require('./components/inline')
|
|
7
|
+
const { Resolver } = require('./components/resolver')
|
|
8
|
+
const { Logger } = require('./logging')
|
|
9
|
+
const { docify } = require('./components/wrappers')
|
|
10
|
+
|
|
11
|
+
/** @typedef {import('./file').File} File */
|
|
12
|
+
/** @typedef {{ entity: String }} Context */
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* @typedef { {
|
|
16
|
+
* rootDirectory: string,
|
|
17
|
+
* logLevel: number,
|
|
18
|
+
* jsConfigPath?: string
|
|
19
|
+
* }} CompileParameters
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* - `propertiesOptional = true` -> all properties are generated as optional ?:. (standard CAP behaviour, where properties be unavailable)
|
|
24
|
+
* - `inlineDeclarations = 'structured'` -> @see inline.StructuredInlineDeclarationResolver
|
|
25
|
+
* - `inlineDeclarations = 'flat'` -> @see inline.FlatInlineDeclarationResolver
|
|
26
|
+
* @typedef { {
|
|
27
|
+
* propertiesOptional: boolean,
|
|
28
|
+
* inlineDeclarations: 'flat' | 'structured',
|
|
29
|
+
* }} VisitorOptions
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* @typedef {{
|
|
34
|
+
* typeName: string,
|
|
35
|
+
* singular: string,
|
|
36
|
+
* plural: string
|
|
37
|
+
* }} Inflection
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
const defaults = {
|
|
41
|
+
// FIXME: add defaults for remaining parameters
|
|
42
|
+
propertiesOptional: true,
|
|
43
|
+
inlineDeclarations: 'flat'
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
class Visitor {
|
|
47
|
+
/**
|
|
48
|
+
* Gathers all files that are supposed to be written to
|
|
49
|
+
* the type directory. Including generated source files,
|
|
50
|
+
* as well as library files.
|
|
51
|
+
* @returns {File[]} a full list of files to be written
|
|
52
|
+
*/
|
|
53
|
+
getWriteoutFiles() {
|
|
54
|
+
return Object.values(this.files).concat(this.resolver.getUsedLibraries())
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* @param csn root CSN
|
|
59
|
+
* @param {VisitorOptions} options
|
|
60
|
+
*/
|
|
61
|
+
constructor(csn, options = {}, logger = new Logger()) {
|
|
62
|
+
util.fixCSN(csn)
|
|
63
|
+
this.options = { ...defaults, ...options }
|
|
64
|
+
this.logger = logger
|
|
65
|
+
this.csn = csn
|
|
66
|
+
|
|
67
|
+
/** @type {Context[]} **/
|
|
68
|
+
this.contexts = []
|
|
69
|
+
|
|
70
|
+
/** @type {Resolver} */
|
|
71
|
+
this.resolver = new Resolver(this)
|
|
72
|
+
|
|
73
|
+
/** @type {Object<string, File>} */
|
|
74
|
+
this.files = {}
|
|
75
|
+
this.files[baseDefinitions.path.asIdentifier()] = baseDefinitions
|
|
76
|
+
this.inlineDeclarationResolver =
|
|
77
|
+
this.options.inlineDeclarations === 'structured'
|
|
78
|
+
? new StructuredInlineDeclarationResolver(this)
|
|
79
|
+
: new FlatInlineDeclarationResolver(this)
|
|
80
|
+
|
|
81
|
+
this.visitDefinitions()
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Determines the file corresponding to the namespace.
|
|
86
|
+
* If no such file exists yet, it is created first.
|
|
87
|
+
* @param {string} path the name of the namespace (foo.bar.baz)
|
|
88
|
+
* @returns {SourceFile} the file corresponding to that namespace name
|
|
89
|
+
*/
|
|
90
|
+
getNamespaceFile(path) {
|
|
91
|
+
return (this.files[path] ??= new SourceFile(path))
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Visits all definitions within the CSN definitions.
|
|
96
|
+
*/
|
|
97
|
+
visitDefinitions() {
|
|
98
|
+
for (const [name, entity] of Object.entries(this.csn.definitions)) {
|
|
99
|
+
this.visitEntity(name, entity)
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Transforms an entity or CDS aspect into a JS aspect (aka mixin).
|
|
105
|
+
* That is, for an element A we get:
|
|
106
|
+
* - the function A(B) to mix the aspect into another class B
|
|
107
|
+
* - the const AXtended which represents the entity A with all of its aspects mixed in (this const is not exported)
|
|
108
|
+
* - the type A to use for external typing and is derived from AXtended.
|
|
109
|
+
* @param {string} name the name of the entity
|
|
110
|
+
* @param {CSN} element the pointer into the CSN to extract the elements from
|
|
111
|
+
* @param {Buffer} buffer the buffer to write the resulting definitions into
|
|
112
|
+
* @param {string?} cleanName the clean name to use. If not passed, it is derived from the passed name instead.
|
|
113
|
+
*/
|
|
114
|
+
_aspectify(name, entity, buffer, cleanName = undefined) {
|
|
115
|
+
const clean = cleanName ?? this.resolver.trimNamespace(name)
|
|
116
|
+
const ns = this.resolver.resolveNamespace(name.split('.'))
|
|
117
|
+
const file = this.getNamespaceFile(ns)
|
|
118
|
+
|
|
119
|
+
const identSingular = (name) => name
|
|
120
|
+
const identAspect = (name) => `_${name}Aspect`
|
|
121
|
+
|
|
122
|
+
this.contexts.push({
|
|
123
|
+
entity: name,
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
// CLASS ASPECT
|
|
127
|
+
buffer.add(`export function ${identAspect(clean)}<TBase extends new (...args: any[]) => any>(Base: TBase) {`)
|
|
128
|
+
buffer.indent()
|
|
129
|
+
buffer.add(`return class ${clean} extends Base {`)
|
|
130
|
+
buffer.indent()
|
|
131
|
+
for (const [ename, element] of Object.entries(entity.elements ?? {})) {
|
|
132
|
+
this.visitElement(ename, element, file, buffer)
|
|
133
|
+
}
|
|
134
|
+
for (const [aname, action] of Object.entries(entity.actions ?? {})) {
|
|
135
|
+
buffer.add(
|
|
136
|
+
SourceFile.stringifyLambda(
|
|
137
|
+
aname,
|
|
138
|
+
Object.entries(action.params ?? {}).map(([n, t]) => [
|
|
139
|
+
n,
|
|
140
|
+
this.resolver.resolveAndRequire(t, file).typeName,
|
|
141
|
+
]),
|
|
142
|
+
action.returns ? this.resolver.resolveAndRequire(action.returns, file).typeName : 'any'
|
|
143
|
+
)
|
|
144
|
+
)
|
|
145
|
+
//this.visitEntity(aname, action, file, buffer)
|
|
146
|
+
}
|
|
147
|
+
buffer.outdent()
|
|
148
|
+
buffer.add('};')
|
|
149
|
+
buffer.outdent()
|
|
150
|
+
buffer.add('}')
|
|
151
|
+
|
|
152
|
+
// CLASS WITH ADDED ASPECTS
|
|
153
|
+
file.addImport(baseDefinitions.path)
|
|
154
|
+
const rhs = (entity.includes ?? [])
|
|
155
|
+
.map((parent) => {
|
|
156
|
+
const [ns, n] = this.resolver.untangle(parent)
|
|
157
|
+
file.addImport(ns)
|
|
158
|
+
return [ns, n]
|
|
159
|
+
})
|
|
160
|
+
.concat([[undefined, clean]]) // add own aspect without namespace AFTER imports were created
|
|
161
|
+
.reverse() // reverse so that own aspect A is applied before extensions B,C: B(C(A(Entity)))
|
|
162
|
+
.reduce(
|
|
163
|
+
(wrapped, [ns, n]) =>
|
|
164
|
+
!ns || ns.isCwd(file.path.asDirectory())
|
|
165
|
+
? `${identAspect(n)}(${wrapped})`
|
|
166
|
+
: `${ns.asIdentifier()}.${identAspect(n)}(${wrapped})`,
|
|
167
|
+
`${baseDefinitions.path.asIdentifier()}.Entity`
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
buffer.add(`export class ${identSingular(clean)} extends ${rhs} {}`)
|
|
171
|
+
//buffer.add(`export type ${clean} = InstanceType<typeof ${identSingular(clean)}>`)
|
|
172
|
+
this.contexts.pop()
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
#printEntity(name, entity) {
|
|
176
|
+
const clean = this.resolver.trimNamespace(name)
|
|
177
|
+
const ns = this.resolver.resolveNamespace(name.split('.'))
|
|
178
|
+
const file = this.getNamespaceFile(ns)
|
|
179
|
+
// entities are expected to be in plural anyway, so we would favour the regular name.
|
|
180
|
+
// If the user decides to pass a @plural annotation, that gets precedence over the regular name.
|
|
181
|
+
let plural = util.unlocalize(
|
|
182
|
+
this.resolver.trimNamespace(util.getPluralAnnotation(entity) ? util.plural4(entity, false) : name)
|
|
183
|
+
)
|
|
184
|
+
const singular = util.unlocalize(util.singular4(entity, true))
|
|
185
|
+
if (singular === plural) {
|
|
186
|
+
plural += '_'
|
|
187
|
+
this.logger.warning(
|
|
188
|
+
`Derived singular and plural forms for '${singular}' are the same. This usually happens when your CDS entities are named following singular flexion. Consider naming your entities in plural or providing '@singular:'/ '@plural:' annotations to have a clear distinction between the two. Plural form will be renamed to '${plural}' to avoid compilation errors within the output.`
|
|
189
|
+
)
|
|
190
|
+
}
|
|
191
|
+
if (singular in this.csn.definitions) {
|
|
192
|
+
this.logger.error(
|
|
193
|
+
`Derived singular '${singular}' for your entity '${name}', already exists. The resulting types will be erronous. Please consider using '@singular:'/ '@plural:' annotations in your model to resolve this collision.`
|
|
194
|
+
)
|
|
195
|
+
}
|
|
196
|
+
file.addClass(singular, name)
|
|
197
|
+
file.addClass(plural, name)
|
|
198
|
+
|
|
199
|
+
const parent = this.resolver.resolveParent(entity.name)
|
|
200
|
+
const buffer =
|
|
201
|
+
parent && parent.kind === 'entity'
|
|
202
|
+
? file.getSubNamespace(this.resolver.trimNamespace(parent.name))
|
|
203
|
+
: file.classes
|
|
204
|
+
|
|
205
|
+
// we can't just use "singular" here, as it may have the subnamespace removed:
|
|
206
|
+
// "Books.text" is just "text" in "singular". Within the inflected exports we need
|
|
207
|
+
// to have Books.texts = Books.text, so we derive the singular once more without cutting off the ns.
|
|
208
|
+
// Directly deriving it from the plural makes sure we retain any parent namespaces of kind "entity",
|
|
209
|
+
// which would not be possible while already in singular form, as "Book.text" could not be resolved in CSN.
|
|
210
|
+
file.addInflection(util.singular4(plural), plural, clean)
|
|
211
|
+
if ('doc' in entity) {
|
|
212
|
+
docify(entity.doc).forEach((d) => buffer.add(d))
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
this._aspectify(name, entity, file.classes, singular)
|
|
216
|
+
|
|
217
|
+
// PLURAL
|
|
218
|
+
if (plural.includes('.')) {
|
|
219
|
+
// Foo.text -> namespace Foo { class text { ... }}
|
|
220
|
+
plural = plural.split('.').pop()
|
|
221
|
+
}
|
|
222
|
+
// plural can not be a type alias to $singular[] but needs to be a proper class instead,
|
|
223
|
+
// so it can get passed as value to CQL functions.
|
|
224
|
+
buffer.add(`export class ${plural} extends Array<${singular}> {}`)
|
|
225
|
+
buffer.add('')
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
#printFunction(name, func) {
|
|
229
|
+
// FIXME: mostly duplicate of printAction -> reuse
|
|
230
|
+
this.logger.debug(`Printing function ${name}:\n${JSON.stringify(func, null, 2)}`)
|
|
231
|
+
const ns = this.resolver.resolveNamespace(name.split('.'))
|
|
232
|
+
const file = this.getNamespaceFile(ns)
|
|
233
|
+
const params = func.params
|
|
234
|
+
? Object.entries(func.params).map(([pname, ptype]) => [
|
|
235
|
+
pname,
|
|
236
|
+
this.resolver.resolveAndRequire(ptype, file).typeName,
|
|
237
|
+
])
|
|
238
|
+
: []
|
|
239
|
+
const returns = this.resolver.resolveAndRequire(func.returns, file).typeName
|
|
240
|
+
file.addFunction(name.split('.').at(-1), params, returns)
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
#printAction(name, action) {
|
|
244
|
+
this.logger.debug(`Printing action ${name}:\n${JSON.stringify(action, null, 2)}`)
|
|
245
|
+
const ns = this.resolver.resolveNamespace(name.split('.'))
|
|
246
|
+
const file = this.getNamespaceFile(ns)
|
|
247
|
+
const params = action.params
|
|
248
|
+
? Object.entries(action.params).map(([pname, ptype]) => [
|
|
249
|
+
pname,
|
|
250
|
+
this.resolver.resolveAndRequire(ptype, file).typeName,
|
|
251
|
+
])
|
|
252
|
+
: []
|
|
253
|
+
const returns = this.resolver.resolveAndRequire(action.returns, file).typeName
|
|
254
|
+
file.addAction(name.split('.').at(-1), params, returns)
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
#printType(name, type) {
|
|
258
|
+
this.logger.debug(`Printing type ${name}:\n${JSON.stringify(type, null, 2)}`)
|
|
259
|
+
const clean = this.resolver.trimNamespace(name)
|
|
260
|
+
const ns = this.resolver.resolveNamespace(name.split('.'))
|
|
261
|
+
const file = this.getNamespaceFile(ns)
|
|
262
|
+
if ('enum' in type) {
|
|
263
|
+
file.addEnum(
|
|
264
|
+
name,
|
|
265
|
+
clean,
|
|
266
|
+
Object.entries(type.enum).map(([k, v]) => [k, v.val])
|
|
267
|
+
)
|
|
268
|
+
} else {
|
|
269
|
+
// alias
|
|
270
|
+
file.addType(name, clean, this.resolver.resolveAndRequire(type, file).typeName)
|
|
271
|
+
}
|
|
272
|
+
// TODO: annotations not handled yet
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
#printAspect(name, aspect) {
|
|
276
|
+
this.logger.debug(`Printing aspect ${name}`)
|
|
277
|
+
const clean = this.resolver.trimNamespace(name)
|
|
278
|
+
const ns = this.resolver.resolveNamespace(name.split('.'))
|
|
279
|
+
const file = this.getNamespaceFile(ns)
|
|
280
|
+
// aspects are technically classes and can therefore be added to the list of defined classes.
|
|
281
|
+
// Still, when using them as mixins for a class, they need to already be defined.
|
|
282
|
+
// So we separate them into another buffer which is printed before the classes.
|
|
283
|
+
file.addClass(clean, name)
|
|
284
|
+
file.aspects.add(`// the following represents the CDS aspect '${clean}'`)
|
|
285
|
+
this._aspectify(name, aspect, file.aspects, clean)
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Visits a single entity from the CSN's definition field.
|
|
290
|
+
* Will call #printEntity or #printAction based on the entity's kind.
|
|
291
|
+
* @param {string} name name of the entity, fully qualified as is used in the definition field.
|
|
292
|
+
* @param {CSN} entity CSN data belonging to the entity to perform lookups in.
|
|
293
|
+
*/
|
|
294
|
+
visitEntity(name, entity) {
|
|
295
|
+
switch (entity.kind) {
|
|
296
|
+
case 'entity':
|
|
297
|
+
this.#printEntity(name, entity)
|
|
298
|
+
break
|
|
299
|
+
case 'action':
|
|
300
|
+
this.#printFunction(name, entity)
|
|
301
|
+
break
|
|
302
|
+
case 'function':
|
|
303
|
+
this.#printAction(name, entity)
|
|
304
|
+
break
|
|
305
|
+
case 'type':
|
|
306
|
+
this.#printType(name, entity)
|
|
307
|
+
break
|
|
308
|
+
case 'aspect':
|
|
309
|
+
this.#printAspect(name, entity)
|
|
310
|
+
break
|
|
311
|
+
default:
|
|
312
|
+
this.logger.error(`Unhandled entity kind '${entity.kind}'.`)
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* A self reference is a property that references the class it appears in.
|
|
318
|
+
* They need to be detected on CDS level, as the emitted TS types will try to
|
|
319
|
+
* refer to refer to types via their alias that hides the aspectification.
|
|
320
|
+
* If we attempt to directly refer to this alias while it has not been fully created,
|
|
321
|
+
* that will result in a TS error.
|
|
322
|
+
* @param {String} entityName
|
|
323
|
+
* @returns {boolean} true, if `entityName` refers to the surrounding class
|
|
324
|
+
* @example
|
|
325
|
+
* ```ts
|
|
326
|
+
* class TreeNode {
|
|
327
|
+
* value: number
|
|
328
|
+
* parent: TreeNode // <- self reference
|
|
329
|
+
* }
|
|
330
|
+
* ```
|
|
331
|
+
*/
|
|
332
|
+
isSelfReference(entityName) {
|
|
333
|
+
return entityName === this.contexts.at(-1)?.entity
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* Visits a single element in an entity.
|
|
338
|
+
* @param {string} name name of the element
|
|
339
|
+
* @param {import('./components/resolver').CSN} element CSN data belonging to the the element.
|
|
340
|
+
* @param {SourceFile} file the namespace file the surrounding entity is being printed into.
|
|
341
|
+
* @param {Buffer} buffer buffer to add the definition to. If no buffer is passed, the passed file's class buffer is used instead.
|
|
342
|
+
* @returns @see InlineDeclarationResolver.visitElement
|
|
343
|
+
*/
|
|
344
|
+
visitElement(name, element, file, buffer) {
|
|
345
|
+
return this.inlineDeclarationResolver.visitElement(name, element, file, buffer)
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
module.exports = {
|
|
350
|
+
Visitor
|
|
351
|
+
}
|
package/package.json
CHANGED