5htp 0.5.9-42 → 0.5.9-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.
@@ -4,7 +4,8 @@
4
4
 
5
5
  // Npm
6
6
  import * as types from '@babel/types'
7
- import type { PluginObj } from '@babel/core';
7
+ import type { NodePath, PluginObj } from '@babel/core';
8
+ import generate from '@babel/generator';
8
9
 
9
10
  // Core
10
11
  import cli from '@cli';
@@ -24,12 +25,19 @@ type TOptions = {
24
25
  * Extended source type: now includes "models"
25
26
  * so we can differentiate how we rewrite references.
26
27
  */
27
- type TImportSource = 'container' | 'application' | 'models';
28
+ type TImportSource = 'container' | 'services' | 'models' | 'request';
28
29
 
29
30
  module.exports = (options: TOptions) => (
30
31
  [Plugin, options]
31
32
  )
32
33
 
34
+ type TImportedIndex = {
35
+ local: string,
36
+ imported: string, // The original “imported” name
37
+ references: NodePath<types.Node>[], // reference paths
38
+ source: TImportSource // container | application | models
39
+ }
40
+
33
41
  /*----------------------------------
34
42
  - PLUGIN
35
43
  ----------------------------------*/
@@ -54,8 +62,6 @@ function Plugin(babel, { app, side, debug }: TOptions) {
54
62
  this.app.Models.client.MyModel.someCall();
55
63
 
56
64
  Processed files:
57
- @/server/config
58
- @/server/routes
59
65
  @/server/services
60
66
  */
61
67
 
@@ -68,40 +74,65 @@ function Plugin(babel, { app, side, debug }: TOptions) {
68
74
 
69
75
  // Count how many total imports we transform
70
76
  importedCount: number,
77
+ routeMethods: string[],
71
78
 
72
79
  // For every local identifier, store info about how it should be rewritten
73
- importedReferences: {
74
- [localName: string]: {
75
- imported: string, // The original “imported” name
76
- bindings: any, // reference paths
77
- source: TImportSource // container | application | models
78
- }
80
+ imported: {
81
+ [localName: string]: TImportedIndex
79
82
  }
80
-
81
- // Tally how many references per kind
82
- bySource: { [s in TImportSource]: number }
83
83
  }> = {
84
84
 
85
85
  pre(state) {
86
86
  this.filename = state.opts.filename as string;
87
- this.processFile = (
88
- this.filename.startsWith(cli.paths.appRoot + '/server/config')
89
- ||
90
- this.filename.startsWith(cli.paths.appRoot + '/server/services')
91
- );
92
-
93
- this.importedReferences = {};
94
- this.bySource = {
95
- container: 0,
96
- application: 0,
97
- models: 0
98
- };
87
+ this.processFile = this.filename.startsWith(cli.paths.appRoot + '/server/services');
88
+
89
+ this.imported = {};
90
+
99
91
  this.importedCount = 0;
100
92
  this.debug = debug || false;
93
+
94
+ this.routeMethods = [];
101
95
  },
102
96
 
103
97
  visitor: {
104
98
 
99
+ // Detect decored methods before other plugins remove decorators
100
+ Program(path) {
101
+
102
+ if (!this.processFile) return;
103
+
104
+ // Traverse the AST within the Program node
105
+ path.traverse({
106
+ ClassMethod: (subPath) => {
107
+ const { node } = subPath;
108
+ if (!node.decorators || node.key.type !== 'Identifier') return;
109
+
110
+ for (const decorator of node.decorators) {
111
+
112
+ const isRoute = (
113
+ // Handles the case of @Route without parameters
114
+ (
115
+ t.isIdentifier(decorator.expression) && decorator.expression.name === 'Route'
116
+ )
117
+ ||
118
+ // Handles the case of @Route() with parameters
119
+ (
120
+ t.isCallExpression(decorator.expression) &&
121
+ t.isIdentifier(decorator.expression.callee) &&
122
+ decorator.expression.callee.name === 'Route'
123
+ )
124
+ );
125
+
126
+ if (!isRoute) continue;
127
+
128
+ const methodName = node.key.name;
129
+ this.routeMethods.push( methodName );
130
+
131
+ }
132
+ }
133
+ });
134
+ },
135
+
105
136
  /**
106
137
  * Detect import statements from '@app' or '@models'
107
138
  */
@@ -109,7 +140,7 @@ function Plugin(babel, { app, side, debug }: TOptions) {
109
140
  if (!this.processFile) return;
110
141
 
111
142
  const source = path.node.source.value;
112
- if (source !== '@app' && source !== '@models') {
143
+ if (source !== '@app' && source !== '@models' && source !== '@request') {
113
144
  return;
114
145
  }
115
146
 
@@ -121,25 +152,32 @@ function Plugin(babel, { app, side, debug }: TOptions) {
121
152
  this.importedCount++;
122
153
 
123
154
  let importSource: TImportSource;
124
- if (source === '@app') {
125
- // Distinguish whether it's a container service or an application service
126
- if (app.containerServices.includes(specifier.imported.name)) {
127
- importSource = 'container';
128
- } else {
129
- importSource = 'application';
130
- }
131
- } else {
132
- // source === '@models'
133
- importSource = 'models';
155
+ switch (source) {
156
+ case '@app':
157
+ // Distinguish whether it's a container service or an application service
158
+ if (app.containerServices.includes(specifier.imported.name)) {
159
+ importSource = 'container';
160
+ } else {
161
+ importSource = 'services';
162
+ }
163
+ break;
164
+ case '@request':
165
+ importSource = 'request';
166
+ break;
167
+ case '@models':
168
+ // source === '@models'
169
+ importSource = 'models';
170
+ break;
171
+ default:
172
+ throw new Error(`Unknown import source: ${source}`);
134
173
  }
135
174
 
136
- this.importedReferences[specifier.local.name] = {
175
+ this.imported[specifier.local.name] = {
176
+ local: specifier.local.name,
137
177
  imported: specifier.imported.name,
138
- bindings: path.scope.bindings[specifier.local.name].referencePaths,
178
+ references: path.scope.bindings[specifier.local.name].referencePaths,
139
179
  source: importSource
140
180
  };
141
-
142
- this.bySource[importSource]++;
143
181
  }
144
182
 
145
183
  // Remove the original import line(s) and replace with any needed new import
@@ -149,7 +187,7 @@ function Plugin(babel, { app, side, debug }: TOptions) {
149
187
 
150
188
  // If this line had container references, add a default import for container
151
189
  // Example: import container from '<root>/server/app/container'
152
- if (source === '@app' && this.bySource.container > 0) {
190
+ if (source === '@app') {
153
191
  replaceWith.push(
154
192
  t.importDeclaration(
155
193
  [t.importDefaultSpecifier(t.identifier('container'))],
@@ -165,78 +203,124 @@ function Plugin(babel, { app, side, debug }: TOptions) {
165
203
  path.replaceWithMultiple(replaceWith);
166
204
  },
167
205
 
168
- /**
169
- * Rewrite references to the imports
170
- */
171
- Identifier(path) {
172
- if (!this.processFile || this.importedCount === 0) {
173
- return;
174
- }
206
+ // This visitor fires for every class method.
207
+ ClassMethod(path) {
175
208
 
176
- const name = path.node.name;
177
- const ref = this.importedReferences[name];
178
- if (!ref || !ref.bindings) {
179
- return;
180
- }
209
+ // Must be a server service
210
+ if (!this.processFile || path.replaced) return;
181
211
 
182
- // Find a specific binding that hasn't been replaced yet
183
- let foundBinding = undefined;
184
- for (const binding of ref.bindings) {
185
- if (!binding.replaced && path.getPathLocation() === binding.getPathLocation()) {
186
- foundBinding = binding;
187
- break;
212
+ // Must have a method name
213
+ if (path.node.key.type !== 'Identifier') return;
214
+
215
+ // Init context
216
+ const methodName = path.node.key.name;
217
+ let params = path.node.params;
218
+
219
+ // Prefix references
220
+ path.traverse({ Identifier: (subPath) => {
221
+
222
+ const { node } = subPath;
223
+ const name = node.name;
224
+ const ref = this.imported[name];
225
+ if (!ref || !ref.references) {
226
+ return;
188
227
  }
189
- }
190
- if (!foundBinding) {
191
- return;
192
- }
193
228
 
194
- // Mark as replaced to avoid loops
195
- foundBinding.replaced = true;
229
+ // Find a specific binding that hasn't been replaced yet
230
+ const foundBinding = ref.references.find(binding => {
231
+ return subPath.getPathLocation() === binding.getPathLocation();
232
+ });
196
233
 
197
- // Based on the source, replace the identifier with the proper MemberExpression
198
- if (ref.source === 'container') {
199
- // container.[identifier]
200
- // e.g. container.Environment
201
- path.replaceWith(
202
- t.memberExpression(
203
- t.identifier('container'),
204
- path.node
205
- )
206
- );
207
- }
208
- else if (ref.source === 'application') {
209
- // this.app.[identifier]
210
- // e.g. this.app.MyService
211
- path.replaceWith(
212
- t.memberExpression(
234
+ if (!foundBinding || foundBinding.replaced)
235
+ return;
236
+
237
+ // Mark as replaced to avoid loops
238
+ foundBinding.replaced = true;
239
+
240
+ // Based on the source, replace the identifier with the proper MemberExpression
241
+ if (ref.source === 'container') {
242
+ // container.[identifier]
243
+ // e.g. container.Environment
244
+ subPath.replaceWith(
213
245
  t.memberExpression(
214
- t.thisExpression(),
215
- t.identifier('app')
216
- ),
217
- path.node
218
- )
219
- );
220
- }
221
- else if (ref.source === 'models') {
222
- // this.app.Models.client.[identifier]
223
- // e.g. this.app.Models.client.MyModel
224
- path.replaceWith(
225
- t.memberExpression(
246
+ t.identifier('container'),
247
+ subPath.node
248
+ )
249
+ );
250
+ }
251
+ else if (ref.source === 'services') {
252
+ // this.app.[identifier]
253
+ // e.g. this.app.MyService
254
+ subPath.replaceWith(
255
+ t.memberExpression(
256
+ t.memberExpression(
257
+ t.thisExpression(),
258
+ t.identifier('app')
259
+ ),
260
+ subPath.node
261
+ )
262
+ );
263
+ }
264
+ else if (ref.source === 'models') {
265
+ // this.app.Models.client.[identifier]
266
+ // e.g. this.app.Models.client.MyModel
267
+ subPath.replaceWith(
226
268
  t.memberExpression(
227
269
  t.memberExpression(
228
270
  t.memberExpression(
229
- t.thisExpression(),
230
- t.identifier('app')
271
+ t.memberExpression(
272
+ t.thisExpression(),
273
+ t.identifier('app')
274
+ ),
275
+ t.identifier('Models')
231
276
  ),
232
- t.identifier('Models')
277
+ t.identifier('client')
233
278
  ),
234
- t.identifier('client')
235
- ),
236
- path.node
279
+ subPath.node
280
+ )
281
+ );
282
+ }
283
+ else if (ref.source === 'request') {
284
+ // this.app.Models.client.[identifier]
285
+ // e.g. this.app.Models.client.MyModel
286
+ subPath.replaceWith(
287
+ t.memberExpression(
288
+ t.identifier('context'),
289
+ subPath.node
290
+ )
291
+ );
292
+ }
293
+
294
+ } });
295
+
296
+ if (
297
+ this.routeMethods.includes(methodName)
298
+ &&
299
+ path.node.params.length < 2
300
+ ) {
301
+
302
+ // Expose router context variable via the second parameter
303
+ params = [
304
+ path.node.params[0] || t.objectPattern([]),
305
+ t.identifier('context'),
306
+ ];
307
+
308
+ // Apply changes
309
+ path.replaceWith(
310
+ t.classMethod(
311
+ path.node.kind,
312
+ path.node.key,
313
+ params,
314
+ path.node.body,
315
+ false,
316
+ false,
317
+ false,
318
+ path.node.async
237
319
  )
238
320
  );
239
321
  }
322
+
323
+ //console.log("ROUTE METHOD", this.filename, methodName, generate(path.node).code);
240
324
  }
241
325
  }
242
326
  };
@@ -173,6 +173,7 @@ function Plugin(babel, { app, side, debug }: TOptions) {
173
173
  }
174
174
 
175
175
  // Adjust
176
+ // /client/pages/*
176
177
  if (this.file.side === 'front') {
177
178
  transformDataFetchers(path, this, routeDef);
178
179
  }
@@ -183,12 +184,7 @@ function Plugin(babel, { app, side, debug }: TOptions) {
183
184
  // Delete the route def since it will be replaced by a wrapper
184
185
  path.replaceWithMultiple([]);
185
186
 
186
- /* [client] Backend Service calls: Transform to api.post( <method path>, <params> )
187
-
188
- Events.Create( form.data ).then(res => toast.success(res.message))
189
- =>
190
- api.post( '/api/events/create', form.data ).then(res => toast.success(res.message))
191
- */
187
+
192
188
  } else if (this.file.side === 'front') {
193
189
 
194
190
  const isAService = (
@@ -199,14 +195,20 @@ function Plugin(babel, { app, side, debug }: TOptions) {
199
195
  if(!isAService)
200
196
  return;
201
197
 
202
- if (side === 'client') {
198
+ /* [client] Backend Service calls: Transform to api.post( <method path>, <params> )
199
+
200
+ Events.Create( form.data ).then(res => toast.success(res.message))
201
+ =>
202
+ api.post( '/api/events/create', form.data ).then(res => toast.success(res.message))
203
+ */
204
+ if (side === 'client' && !clientServices.includes(serviceName)) {
203
205
 
204
206
  // Get complete call path
205
207
  const apiPath = '/api/' + completePath.join('/');
206
208
 
207
209
  // Replace by api.post( <method path>, <params> )
208
210
  const apiPostArgs: types.CallExpression["arguments"] = [t.stringLiteral(apiPath)];
209
- if (path.node.arguments.length === 1)
211
+ if (path.node.arguments.length >= 1)
210
212
  apiPostArgs.push( path.node.arguments[0] );
211
213
 
212
214
  path.replaceWith(
@@ -216,6 +218,13 @@ function Plugin(babel, { app, side, debug }: TOptions) {
216
218
  ), apiPostArgs
217
219
  )
218
220
  )
221
+
222
+ /* [server] Backend Service calls
223
+
224
+ Events.Create( form.data ).then(res => toast.success(res.message))
225
+ =>
226
+ app.Events.Create( form.data, context ).then(res => toast.success(res.message))
227
+ */
219
228
  } else {
220
229
 
221
230
  // Rebuild member expression from completePath, adding a api prefix
@@ -233,7 +242,8 @@ function Plugin(babel, { app, side, debug }: TOptions) {
233
242
  // Replace by app.<service>.<method>(...)
234
243
  path.replaceWith(
235
244
  t.callExpression(
236
- newCallee, [...path.node.arguments]
245
+ newCallee,
246
+ [...path.node.arguments]
237
247
  )
238
248
  )
239
249
  }
@@ -322,7 +332,39 @@ function Plugin(babel, { app, side, debug }: TOptions) {
322
332
  }
323
333
  */
324
334
  routeDef.dataFetchers.push(
325
- ...path.node.arguments[0].properties
335
+ ...path.node.arguments[0].properties.map(p => {
336
+
337
+ // Server side: Pass request context as 2nd argument
338
+ // companies: Companies.create( <params>, context )
339
+ if (
340
+ side === 'server'
341
+ &&
342
+ p.type === 'ObjectProperty'
343
+ &&
344
+ p.key.type === 'Identifier'
345
+ &&
346
+ p.value.type === 'CallExpression'
347
+ &&
348
+ // TODO: reliable way to know if it's a service
349
+ !(
350
+ p.value.callee.type === 'MemberExpression'
351
+ &&
352
+ p.value.callee.object.type === 'Identifier'
353
+ &&
354
+ p.value.callee.object.name === 'api'
355
+ )
356
+ ) {
357
+
358
+ // Pass request context as 2nd argument
359
+ p.value.arguments = p.value.arguments.length === 0
360
+ ? [ t.objectExpression([]), t.identifier('context') ]
361
+ : [ p.value.arguments[0], t.identifier('context') ];
362
+
363
+ }
364
+
365
+ return p;
366
+
367
+ })
326
368
  );
327
369
 
328
370
  /* Replace the:
@@ -376,9 +418,19 @@ function Plugin(babel, { app, side, debug }: TOptions) {
376
418
  // Add data fetchers
377
419
  if (routeDef.dataFetchers.length !== 0) {
378
420
 
421
+ const rendererContext = t.cloneNode( renderer.params[0] );
422
+ // If not already present, add context to the 1st argument (a object spread)
423
+ if (!rendererContext.properties.some( p => p.key.name === 'context' ))
424
+ rendererContext.properties.push(
425
+ t.objectProperty(
426
+ t.identifier('context'),
427
+ t.identifier('context')
428
+ )
429
+ )
430
+
379
431
  // (contollerParams) => { stats: api.get(...) }
380
432
  const dataFetchersFunc = t.arrowFunctionExpression(
381
- renderer.params.map( param => t.cloneNode( param )),
433
+ [rendererContext],
382
434
  t.objectExpression(
383
435
  routeDef.dataFetchers.map( df => t.cloneNode( df ))
384
436
  )
@@ -530,6 +582,10 @@ function Plugin(babel, { app, side, debug }: TOptions) {
530
582
  t.objectProperty(
531
583
  t.identifier('app'),
532
584
  t.identifier('app'),
585
+ ),
586
+ t.objectProperty(
587
+ t.identifier('context'),
588
+ t.identifier('context'),
533
589
  )
534
590
  ]
535
591
  for (const [local, imported] of importedServicesList) {
@@ -575,8 +631,8 @@ function Plugin(babel, { app, side, debug }: TOptions) {
575
631
  );
576
632
 
577
633
 
578
- /*(file.path.includes('client/pages/convert/auth/login.tsx'))
579
- && console.log( file.path, generate(exportDeclaration).code );*/
634
+ // (file.path.includes('clients/prospect/search') && side === 'client')
635
+ // && console.log( file.path, generate(exportDeclaration).code );
580
636
 
581
637
  return exportDeclaration;
582
638
  }
package/compiler/index.ts CHANGED
@@ -17,6 +17,7 @@ import cli from '..';
17
17
  import createServerConfig from './server';
18
18
  import createClientConfig from './client';
19
19
  import { TCompileMode } from './common';
20
+ import { routerServices } from './common/babel/plugins/services';
20
21
 
21
22
  type TCompilerCallback = (compiler: webpack.Compiler) => void
22
23
 
@@ -32,6 +33,7 @@ type TServiceMetas = {
32
33
  type TRegisteredService = {
33
34
  id?: string,
34
35
  name: string,
36
+ className: string,
35
37
  instanciation: (parentRef: string) => string,
36
38
  priority: number,
37
39
  }
@@ -253,6 +255,7 @@ export default class Compiler {
253
255
  id: serviceConfig.id,
254
256
  name: serviceName,
255
257
  instanciation,
258
+ className: serviceMetas.name,
256
259
  priority: serviceConfig.config?.priority || serviceMetas.priority || 0,
257
260
  };
258
261
  }
@@ -269,43 +272,67 @@ export default class Compiler {
269
272
  path.join( app.paths.client.generated, 'services.d.ts'),
270
273
  `declare module "@app" {
271
274
 
272
- import { RouenEvents as RouenEventsClient } from "@/client";
273
- import RouenEventsServer from "@/server/.generated/app";
275
+ import { ${appClassIdentifier} as ${appClassIdentifier}Client } from "@/client";
276
+ import ${appClassIdentifier}Server from "@/server/.generated/app";
274
277
 
275
278
  import { ApplicationProperties as ClientApplicationProperties } from "@client/app";
276
279
  import { ApplicationProperties as ServerApplicationProperties } from "@server/app";
277
280
 
278
- type ClientServices = Omit<RouenEventsClient, ClientApplicationProperties>;
279
- type ServerServices = Omit<RouenEventsServer, ServerApplicationProperties | keyof ClientServices>;
281
+ type ClientServices = Omit<${appClassIdentifier}Client, ClientApplicationProperties>;
282
+ type ServerServices = Omit<${appClassIdentifier}Server, ServerApplicationProperties | keyof ClientServices>;
280
283
 
281
284
  type CombinedServices = ClientServices & ServerServices;
282
285
 
283
286
  const appClass: CombinedServices;
284
287
  export = appClass;
285
288
  }
289
+
290
+ declare module '@models/types' {
291
+ export * from '@/var/prisma/index';
292
+ }
286
293
 
294
+ declare module '@request' {
295
+
296
+ }
287
297
 
288
- // Temporary
289
- /*declare module '@models' {
290
- export * from '@/var/prisma/index';
291
- }*/
292
-
293
- declare module '@models' {
294
- import { Prisma, PrismaClient } from '@/var/prisma/index';
295
-
296
- type ModelNames = Prisma.ModelName;
297
-
298
- type ModelDelegates = {
299
- [K in ModelNames]: PrismaClient[Uncapitalize<K>];
300
- };
301
-
302
- const models: ModelDelegates;
303
-
304
- export = models;
298
+ declare namespace preact.JSX {
299
+ interface HTMLAttributes {
300
+ src?: string;
301
+ }
305
302
  }
306
- `
303
+ `
307
304
  );
308
305
 
306
+ fs.outputFileSync(
307
+ path.join( app.paths.client.generated, 'context.ts'),
308
+ `// TODO: move it into core (but how to make sure usecontext returns ${appClassIdentifier}'s context ?)
309
+ import React from 'react';
310
+
311
+ import type ${appClassIdentifier}Server from '@/server/.generated/app';
312
+ import type { TRouterContext as TServerRouterRequestContext } from '@server/services/router/response';
313
+ import type { TRouterContext as TClientRouterRequestContext } from '@client/services/router/response';
314
+ import type ${appClassIdentifier}Client from '.';
315
+
316
+ // TO Fix: TClientRouterRequestContext is unable to get the right type of ${appClassIdentifier}Client["router"]
317
+ // (it gets ClientApplication instead of ${appClassIdentifier}Client)
318
+ type ClientRequestContext = TClientRouterRequestContext<${appClassIdentifier}Client["Router"], ${appClassIdentifier}Client>;
319
+ type ServerRequestContext = TServerRouterRequestContext<${appClassIdentifier}Server["Router"]>
320
+ type UniversalServices = ClientRequestContext | ServerRequestContext
321
+
322
+ // Non-universla services are flagged as potentially undefined
323
+ export type ClientContext = (
324
+ UniversalServices
325
+ &
326
+ Partial<Omit<ClientRequestContext, keyof UniversalServices>>
327
+ &
328
+ {
329
+ Router: ${appClassIdentifier}Client["Router"],
330
+ }
331
+ )
332
+
333
+ export const ReactClientContext = React.createContext<ClientContext>({} as ClientContext);
334
+ export default (): ClientContext => React.useContext<ClientContext>(ReactClientContext);`);
335
+
309
336
  fs.outputFileSync(
310
337
  path.join( app.paths.server.generated, 'app.ts'),
311
338
  `import { Application } from '@server/app/index';
@@ -315,7 +342,7 @@ ${imported.join('\n')}
315
342
  export default class ${appClassIdentifier} extends Application {
316
343
 
317
344
  ${sortedServices.map(service =>
318
- `public ${service.name}!: ReturnType<${appClassIdentifier}["registered"]["${service.id}"]["start"]>;`
345
+ `public ${service.name}!: ${service.className};`
319
346
  ).join('\n')}
320
347
 
321
348
  protected registered = {
@@ -373,6 +400,12 @@ declare module '@server/app' {
373
400
 
374
401
  export = foo;
375
402
  }
403
+
404
+ declare module '@request' {
405
+ import type { TRouterContext } from '@server/services/router/response';
406
+ const routerContext: TRouterContext<CrossPath["Router"]>;
407
+ export = routerContext;
408
+ }
376
409
 
377
410
  declare module '@models' {
378
411
  import { Prisma, PrismaClient } from '@/var/prisma/index';
@@ -386,6 +419,10 @@ declare module '@models' {
386
419
  const models: ModelDelegates;
387
420
 
388
421
  export = models;
422
+ }
423
+
424
+ declare module '@models/types' {
425
+ export * from '@/var/prisma/index';
389
426
  }`
390
427
  );
391
428
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "5htp",
3
3
  "description": "Convenient TypeScript framework designed for Performance and Productivity.",
4
- "version": "0.5.9-42",
4
+ "version": "0.5.9-7",
5
5
  "author": "Gaetan Le Gac (https://github.com/gaetanlegac)",
6
6
  "repository": "git://github.com/gaetanlegac/5htp.git",
7
7
  "license": "MIT",