@praxisui/visual-builder 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/LICENSE ADDED
@@ -0,0 +1,7 @@
1
+ This package is licensed under the Apache License, Version 2.0.
2
+
3
+ For the full license text, see the repository root LICENSE file:
4
+ ../../LICENSE
5
+
6
+ Apache License 2.0: https://www.apache.org/licenses/LICENSE-2.0
7
+
package/README.md ADDED
@@ -0,0 +1,650 @@
1
+ # Praxis Visual Builder
2
+
3
+ A comprehensive Angular library for building visual rule editors with support for mini-DSL expressions and contextual specifications.
4
+
5
+ ## Features
6
+
7
+ ### 🎯 **Core Components**
8
+
9
+ - **ExpressionEditorComponent**: Advanced DSL expression editor with syntax highlighting and autocomplete
10
+ - **ContextVariableManagerComponent**: Complete context variable management system
11
+ - **SpecificationBridgeService**: Bidirectional conversion between visual rules and DSL expressions
12
+
13
+ ### ⚡ **Advanced Capabilities**
14
+
15
+ - **Mini-DSL Support**: Full DSL parsing, validation, and round-trip conversion
16
+ - **Context Variables**: Dynamic variable resolution with scoped contexts
17
+ - **Real-time Validation**: Live syntax checking with performance metrics
18
+ - **Autocomplete**: Intelligent suggestions for fields, functions, operators, and variables
19
+ - **Round-trip Integrity**: Seamless conversion between visual and textual representations
20
+
21
+ ## Installation
22
+
23
+ ```bash
24
+ npm install @praxis/visual-builder
25
+ ```
26
+
27
+ ## Quick Start
28
+
29
+ ### Basic Expression Editor
30
+
31
+ ```typescript
32
+ import { ExpressionEditorComponent } from "@praxis/visual-builder";
33
+
34
+ @Component({
35
+ template: ` <praxis-expression-editor [fieldSchemas]="fieldSchemas" [contextVariables]="contextVariables" [(expression)]="expression" (validationChange)="onValidationChange($event)"> </praxis-expression-editor> `,
36
+ })
37
+ export class MyComponent {
38
+ fieldSchemas = [
39
+ { name: "age", type: "number", label: "Age" },
40
+ { name: "name", type: "string", label: "Name" },
41
+ { name: "active", type: "boolean", label: "Active" },
42
+ ];
43
+
44
+ contextVariables = [{ name: "user.minAge", type: "number", scope: "user", example: "18" }];
45
+
46
+ expression = "age > ${user.minAge} && active == true";
47
+
48
+ onValidationChange(result: ExpressionValidationResult) {
49
+ console.log("Expression valid:", result.isValid);
50
+ console.log("Issues:", result.issues);
51
+ }
52
+ }
53
+ ```
54
+
55
+ ### Context Variable Management
56
+
57
+ ```typescript
58
+ import { ContextVariableManagerComponent } from "@praxis/visual-builder";
59
+
60
+ @Component({
61
+ template: ` <praxis-context-variable-manager [(contextVariables)]="contextVariables" [allowedScopes]="allowedScopes" (variableAdd)="onVariableAdd($event)" (variableUpdate)="onVariableUpdate($event)"> </praxis-context-variable-manager> `,
62
+ })
63
+ export class VariableManagementComponent {
64
+ contextVariables: EnhancedContextVariable[] = [];
65
+ allowedScopes = ["user", "session", "global"];
66
+
67
+ onVariableAdd(variable: EnhancedContextVariable) {
68
+ console.log("New variable added:", variable);
69
+ }
70
+ }
71
+ ```
72
+
73
+ ### Visual Rule Editor in Embedded Mode
74
+
75
+ ```typescript
76
+ import { RuleEditorComponent } from "@praxis/visual-builder";
77
+
78
+ @Component({
79
+ template: ` <praxis-rule-editor [config]="builderConfig" [embedded]="true" (rulesChanged)="onRulesChanged($event)"> </praxis-rule-editor> `,
80
+ })
81
+ export class EmbeddedRuleEditor {
82
+ builderConfig = {
83
+ /* ... */
84
+ };
85
+
86
+ onRulesChanged(rules: any) {
87
+ console.log("Updated rules:", rules);
88
+ }
89
+ }
90
+ ```
91
+
92
+ `embedded` mode allows the editor to fit within existing layouts without spawning nested scrollbars, making it suitable for corporate dashboards and configuration panels where space is constrained.
93
+
94
+ ## Mini-DSL Language Guide
95
+
96
+ ### Basic Syntax
97
+
98
+ The mini-DSL supports a rich expression language for building validation rules and conditions.
99
+
100
+ #### Field References
101
+
102
+ ```dsl
103
+ age > 18
104
+ name == "John Doe"
105
+ active == true
106
+ score >= 80
107
+ ```
108
+
109
+ #### Context Variables
110
+
111
+ Context variables are referenced using `${scope.variable}` syntax:
112
+
113
+ ```dsl
114
+ age > ${user.minAge}
115
+ department == "${user.department}"
116
+ level >= ${policy.requiredLevel}
117
+ ```
118
+
119
+ #### Operators
120
+
121
+ **Comparison Operators:**
122
+
123
+ ```dsl
124
+ age == 25 # Equality
125
+ age != 30 # Inequality
126
+ age > 18 # Greater than
127
+ age >= 21 # Greater than or equal
128
+ age < 65 # Less than
129
+ age <= 64 # Less than or equal
130
+ ```
131
+
132
+ **Logical Operators:**
133
+
134
+ ```dsl
135
+ age > 18 && active == true # AND
136
+ priority == "high" || urgent == true # OR
137
+ !(deleted == true) # NOT
138
+ status == "A" ^ backup == true # XOR (exclusive or)
139
+ qualified == true => approved == true # IMPLIES
140
+ ```
141
+
142
+ **Membership Operators:**
143
+
144
+ ```dsl
145
+ category in ["tech", "science", "math"] # IN (array membership)
146
+ role not in ["guest", "anonymous"] # NOT IN
147
+ ```
148
+
149
+ **Null Checks:**
150
+
151
+ ```dsl
152
+ description != null # Not null
153
+ optional == null # Is null
154
+ ```
155
+
156
+ #### Functions
157
+
158
+ **String Functions:**
159
+
160
+ ```dsl
161
+ contains(tags, "important") # Check if string/array contains value
162
+ startsWith(email, "admin") # Check if string starts with value
163
+ endsWith(filename, ".pdf") # Check if string ends with value
164
+ length(description) > 10 # Get string/array length
165
+ upper(category) == "TECHNOLOGY" # Convert to uppercase
166
+ lower(name) == "john" # Convert to lowercase
167
+ ```
168
+
169
+ **Collection Functions:**
170
+
171
+ ```dsl
172
+ atLeast(2, [condition1, condition2, condition3]) # At least N conditions true
173
+ exactly(1, [optionA, optionB, optionC]) # Exactly N conditions true
174
+ forEach(items, itemCondition) # All items satisfy condition
175
+ uniqueBy(collection, ["field1", "field2"]) # Unique by specified fields
176
+ minLength(array, 3) # Minimum array length
177
+ maxLength(array, 10) # Maximum array length
178
+ ```
179
+
180
+ **Conditional Functions:**
181
+
182
+ ```dsl
183
+ requiredIf(field, condition) # Field required if condition true
184
+ visibleIf(field, condition) # Field visible if condition true
185
+ disabledIf(field, condition) # Field disabled if condition true
186
+ readonlyIf(field, condition) # Field readonly if condition true
187
+ ```
188
+
189
+ **Optional Handling:**
190
+
191
+ ```dsl
192
+ ifDefined(optionalField, condition) # Apply condition only if field defined
193
+ ifNotNull(field, condition) # Apply condition only if field not null
194
+ ifExists(field, condition) # Apply condition only if field exists
195
+ withDefault(field, defaultValue, condition) # Use default if field undefined
196
+ ```
197
+
198
+ ### Complex Expressions
199
+
200
+ #### Nested Conditions
201
+
202
+ ```dsl
203
+ ((age >= 18 && age <= 65) || experience > 10) &&
204
+ (department == "Engineering" || department == "Product") &&
205
+ !(archived == true || deleted == true)
206
+ ```
207
+
208
+ #### Mixed Context and Fields
209
+
210
+ ```dsl
211
+ salary >= ${policy.minSalary} &&
212
+ salary <= ${policy.maxSalary} &&
213
+ location in ${user.allowedLocations} &&
214
+ startDate >= "${session.currentDate}"
215
+ ```
216
+
217
+ #### Function Composition
218
+
219
+ ```dsl
220
+ contains(upper(department), "ENG") &&
221
+ length(trim(description)) > ${config.minDescLength} &&
222
+ endsWith(lower(email), "@company.com")
223
+ ```
224
+
225
+ ### Context Variables
226
+
227
+ Context variables provide dynamic values that can be resolved at runtime based on different scopes.
228
+
229
+ #### Scopes
230
+
231
+ **User Scope** (`user.*`):
232
+
233
+ - User-specific values (preferences, profile data)
234
+ - Example: `${user.department}`, `${user.role}`, `${user.level}`
235
+
236
+ **Session Scope** (`session.*`):
237
+
238
+ - Session-specific values (temporary data, current state)
239
+ - Example: `${session.currentDate}`, `${session.userId}`, `${session.locale}`
240
+
241
+ **Environment Scope** (`env.*`):
242
+
243
+ - Environment configuration (debug flags, feature toggles)
244
+ - Example: `${env.debug}`, `${env.features.newUI}`, `${env.apiVersion}`
245
+
246
+ **Global Scope** (`global.*`):
247
+
248
+ - Application-wide constants (policies, configurations)
249
+ - Example: `${global.minAge}`, `${global.maxFileSize}`, `${global.supportedFormats}`
250
+
251
+ #### Variable Types
252
+
253
+ **String Variables:**
254
+
255
+ ```dsl
256
+ name == "${user.fullName}"
257
+ department == "${user.department}"
258
+ ```
259
+
260
+ **Number Variables:**
261
+
262
+ ```dsl
263
+ age > ${user.minAge}
264
+ salary >= ${policy.baseSalary}
265
+ ```
266
+
267
+ **Boolean Variables:**
268
+
269
+ ```dsl
270
+ active == ${user.isActive}
271
+ debug == ${env.debugMode}
272
+ ```
273
+
274
+ **Array Variables:**
275
+
276
+ ```dsl
277
+ category in ${user.allowedCategories}
278
+ format in ${global.supportedFormats}
279
+ ```
280
+
281
+ **Date Variables:**
282
+
283
+ ```dsl
284
+ createdDate >= "${session.startDate}"
285
+ expirationDate <= "${policy.maxExpirationDate}"
286
+ ```
287
+
288
+ ### Validation and Error Handling
289
+
290
+ #### Syntax Errors
291
+
292
+ The DSL parser provides detailed error messages for syntax issues:
293
+
294
+ ```typescript
295
+ // Invalid syntax examples and their error messages:
296
+
297
+ // "age >" -> "Missing operand after comparison operator"
298
+ // "age >> 18" -> "Unknown operator '>>'"
299
+ // "(age > 18" -> "Unclosed parentheses"
300
+ // "unknownFunc(age)" -> "Unknown function 'unknownFunc'"
301
+ ```
302
+
303
+ #### Field Validation
304
+
305
+ ```typescript
306
+ // Unknown field warnings:
307
+ // "unknownField == 'test'" -> "Unknown field: unknownField. Did you mean 'knownField'?"
308
+ ```
309
+
310
+ #### Context Variable Validation
311
+
312
+ ```typescript
313
+ // Missing context variable warnings:
314
+ // "age > ${missing.variable}" -> "Unknown context variable: missing.variable"
315
+ // With suggestions: "Did you mean 'existing.variable'?"
316
+ ```
317
+
318
+ #### Performance Warnings
319
+
320
+ ```typescript
321
+ // Complex expression warnings:
322
+ // For expressions with >50 operators:
323
+ // "Expression complexity is high (67 operators). Consider breaking into smaller expressions."
324
+ ```
325
+
326
+ ## API Reference
327
+
328
+ ### Exports
329
+
330
+ Main exports available from `@praxis/visual-builder` (see `projects/praxis-visual-builder/src/public-api.ts`):
331
+
332
+ - Components: `RuleEditorComponent`, `RuleCanvasComponent`, `RuleNodeComponent`, `FieldConditionEditorComponent`, `ConditionalValidatorEditorComponent`, `CollectionValidatorEditorComponent`, `MetadataEditorComponent`, `DslViewerComponent`, `JsonViewerComponent`, `RoundTripTesterComponent`, `ExportDialogComponent`, `DslLinterComponent`, `VisualRuleBuilderComponent`, `TemplateGalleryComponent`, `TemplateEditorDialogComponent`, `TemplatePreviewDialogComponent`
333
+ - Services: `SpecificationBridgeService`, `RuleBuilderService`, `RoundTripValidatorService`, `ExportIntegrationService`, `WebhookIntegrationService`, `RuleTemplateService`, `RuleValidationService`, `RuleNodeRegistryService`, `ContextManagementService`, `FieldSchemaService`, `RuleConversionService`, `DslParsingService`
334
+ - Models/Types: `FieldSchema`, `RuleBuilderConfig`, `ArrayFieldSchema`, `ContextScope`, `ContextEntry`, `ContextValue`, `SpecificationContextualConfig`
335
+ - Errors: `VisualBuilderError`, `VBValidationError`, `ConversionError`, `RegistryError`, `DslError`, `ContextError`, `ConfigurationError`, `InternalError`, `ErrorCategory`, `ErrorSeverity`, `ErrorHandler`, `globalErrorHandler`, `createError`, `ErrorInfo`, `ErrorStatistics`
336
+
337
+ ### ExpressionEditorComponent
338
+
339
+ #### Inputs
340
+
341
+ ```typescript
342
+ @Input() expression: string = ''; // Current DSL expression
343
+ @Input() fieldSchemas: FieldSchema[] = []; // Available fields for autocomplete
344
+ @Input() contextVariables: ContextVariable[] = []; // Available context variables
345
+ @Input() functionRegistry?: FunctionRegistry; // Custom function registry
346
+ @Input() validationConfig?: ValidationConfig; // Validation configuration
347
+ @Input() editorOptions?: EditorOptions; // Editor display options
348
+ ```
349
+
350
+ #### Outputs
351
+
352
+ ```typescript
353
+ @Output() expressionChange = new EventEmitter<string>(); // Expression changes
354
+ @Output() validationChange = new EventEmitter<ExpressionValidationResult>(); // Validation updates
355
+ @Output() suggestionRequest = new EventEmitter<SuggestionContext>(); // Autocomplete requests
356
+ @Output() focusChange = new EventEmitter<boolean>(); // Focus state changes
357
+ ```
358
+
359
+ #### Methods
360
+
361
+ ```typescript
362
+ // Get autocomplete suggestions
363
+ getSuggestions(text: string, position: number): Promise<DslSuggestion[]>
364
+
365
+ // Validate current expression
366
+ validateExpression(): Promise<ExpressionValidationResult>
367
+
368
+ // Insert text at cursor position
369
+ insertTextAtCursor(text: string): void
370
+
371
+ // Format expression
372
+ formatExpression(): void
373
+
374
+ // Clear expression
375
+ clearExpression(): void
376
+ ```
377
+
378
+ ### ContextVariableManagerComponent
379
+
380
+ #### Inputs
381
+
382
+ ```typescript
383
+ @Input() contextVariables: EnhancedContextVariable[] = []; // Current variables
384
+ @Input() allowedScopes: ContextScope[] = []; // Allowed variable scopes
385
+ @Input() readOnly: boolean = false; // Read-only mode
386
+ @Input() showCategories: boolean = true; // Show category grouping
387
+ @Input() allowImportExport: boolean = true; // Enable import/export
388
+ ```
389
+
390
+ #### Outputs
391
+
392
+ ```typescript
393
+ @Output() contextVariablesChange = new EventEmitter<EnhancedContextVariable[]>(); // Variables updated
394
+ @Output() variableAdd = new EventEmitter<EnhancedContextVariable>(); // Variable added
395
+ @Output() variableUpdate = new EventEmitter<EnhancedContextVariable>(); // Variable updated
396
+ @Output() variableDelete = new EventEmitter<string>(); // Variable deleted
397
+ @Output() importComplete = new EventEmitter<ImportResult>(); // Import completed
398
+ ```
399
+
400
+ #### Methods
401
+
402
+ ```typescript
403
+ // Add new variable
404
+ addVariable(variable: Partial<EnhancedContextVariable>): void
405
+
406
+ // Update existing variable
407
+ updateVariable(id: string, updates: Partial<EnhancedContextVariable>): void
408
+
409
+ // Delete variable
410
+ deleteVariable(id: string): void
411
+
412
+ // Get variables by scope
413
+ getVariablesByScope(scope: string): EnhancedContextVariable[]
414
+
415
+ // Export variables
416
+ exportVariables(format: 'json' | 'csv'): string
417
+
418
+ // Import variables
419
+ importVariables(data: string, format: 'json' | 'csv'): Promise<ImportResult>
420
+
421
+ // Validate variable name
422
+ isValidVariableName(name: string): boolean
423
+ ```
424
+
425
+ ### SpecificationBridgeService
426
+
427
+ #### DSL Expression Methods
428
+
429
+ ```typescript
430
+ // Parse DSL expression to specification
431
+ parseDslExpression<T>(expression: string, config?: DslParsingConfig): DslParsingResult<T>
432
+
433
+ // Validate expression round-trip integrity
434
+ validateExpressionRoundTrip<T>(expression: string, config?: DslParsingConfig): RoundTripResult
435
+
436
+ // Create contextual specification
437
+ createContextualSpecification<T>(template: string, config?: ContextualConfig): ContextualSpecification<T>
438
+
439
+ // Resolve context tokens in template
440
+ resolveContextTokens(template: string, variables: ContextVariable[]): string
441
+
442
+ // Extract context tokens from template
443
+ extractContextTokens(template: string): string[]
444
+
445
+ // Validate context tokens
446
+ validateContextTokens(template: string, variables: ContextVariable[]): ValidationIssue[]
447
+ ```
448
+
449
+ #### Rule Node Conversion Methods
450
+
451
+ ```typescript
452
+ // Convert rule node to specification
453
+ ruleNodeToSpecification<T>(node: RuleNode): Specification<T>
454
+
455
+ // Convert specification to rule node
456
+ specificationToRuleNode<T>(spec: Specification<T>): RuleNode
457
+
458
+ // Export rule node to DSL
459
+ exportToDsl<T>(node: RuleNode, options?: ExportOptions): string
460
+
461
+ // Validate round-trip through rule nodes
462
+ validateRoundTrip<T>(node: RuleNode): RoundTripValidationResult
463
+ ```
464
+
465
+ #### Context Provider Methods
466
+
467
+ ```typescript
468
+ // Update context provider
469
+ updateContextProvider(provider: ContextProvider): void
470
+
471
+ // Get current context provider
472
+ getContextProvider(): ContextProvider | undefined
473
+ ```
474
+
475
+ ## Advanced Usage
476
+
477
+ ### Custom Function Registry
478
+
479
+ ```typescript
480
+ import { FunctionRegistry } from '@praxis/specification';
481
+
482
+ // Create custom function registry
483
+ const customRegistry = new FunctionRegistry();
484
+
485
+ // Register custom functions
486
+ customRegistry.register('isEmail', {
487
+ name: 'isEmail',
488
+ arity: 1,
489
+ implementation: (value: string) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value),
490
+ description: 'Validates email format'
491
+ });
492
+
493
+ customRegistry.register('dateAdd', {
494
+ name: 'dateAdd',
495
+ arity: 3,
496
+ implementation: (date: Date, amount: number, unit: string) => {
497
+ // Implementation for date arithmetic
498
+ },
499
+ description: 'Adds time to a date'
500
+ });
501
+
502
+ // Use in expression editor
503
+ <praxis-expression-editor
504
+ [functionRegistry]="customRegistry"
505
+ expression="isEmail(userEmail) && dateAdd(startDate, 30, 'days') > now()">
506
+ </praxis-expression-editor>
507
+ ```
508
+
509
+ ### Custom Context Provider
510
+
511
+ ```typescript
512
+ import { ContextProvider } from "@praxis/specification";
513
+
514
+ class DatabaseContextProvider implements ContextProvider {
515
+ private cache = new Map<string, any>();
516
+
517
+ async getValue(key: string): Promise<any> {
518
+ if (this.cache.has(key)) {
519
+ return this.cache.get(key);
520
+ }
521
+
522
+ // Fetch from database or API
523
+ const value = await this.fetchFromDatabase(key);
524
+ this.cache.set(key, value);
525
+ return value;
526
+ }
527
+
528
+ hasValue(key: string): boolean {
529
+ return this.cache.has(key) || this.isValidKey(key);
530
+ }
531
+
532
+ setValue(key: string, value: any): void {
533
+ this.cache.set(key, value);
534
+ }
535
+
536
+ // ... other methods
537
+ }
538
+
539
+ // Use custom provider
540
+ const provider = new DatabaseContextProvider();
541
+ this.bridgeService.updateContextProvider(provider);
542
+ ```
543
+
544
+ ### Integration with Form Builders
545
+
546
+ ```typescript
547
+ @Component({
548
+ template: `
549
+ <form [formGroup]="ruleForm">
550
+ <!-- Visual Rule Builder -->
551
+ <praxis-expression-editor formControlName="expression" [fieldSchemas]="formFieldSchemas" [contextVariables]="availableVariables"> </praxis-expression-editor>
552
+
553
+ <!-- Context Variable Management -->
554
+ <praxis-context-variable-manager [(contextVariables)]="availableVariables" [allowedScopes]="['user', 'session']"> </praxis-context-variable-manager>
555
+
556
+ <!-- Generated Rule Preview -->
557
+ <pre>{{ generatedRule | json }}</pre>
558
+ </form>
559
+ `,
560
+ })
561
+ export class RuleBuilderFormComponent {
562
+ ruleForm = this.fb.group({
563
+ expression: ["", Validators.required],
564
+ metadata: this.fb.group({
565
+ name: ["", Validators.required],
566
+ description: [""],
567
+ priority: [1],
568
+ }),
569
+ });
570
+
571
+ get generatedRule() {
572
+ const expression = this.ruleForm.get("expression")?.value;
573
+ if (!expression) return null;
574
+
575
+ const parseResult = this.bridgeService.parseDslExpression(expression, {
576
+ knownFields: this.formFieldSchemas.map((f) => f.name),
577
+ });
578
+
579
+ return parseResult.success ? parseResult.specification?.toJSON() : null;
580
+ }
581
+ }
582
+ ```
583
+
584
+ ## Performance Guidelines
585
+
586
+ ### Optimization Tips
587
+
588
+ 1. **Field Schema Management**:
589
+
590
+ ```typescript
591
+ // ✅ Good: Reuse field schema objects
592
+ const fieldSchemas = useMemo(() => fields.map((f) => ({ name: f.name, type: f.type, label: f.label })), [fields]);
593
+
594
+ // ❌ Avoid: Creating new objects on every render
595
+ const fieldSchemas = fields.map((f) => ({ name: f.name, type: f.type }));
596
+ ```
597
+
598
+ 2. **Expression Validation**:
599
+
600
+ ```typescript
601
+ // ✅ Good: Debounce validation
602
+ const debouncedValidation = useMemo(
603
+ () => debounce(validateExpression, 300),
604
+ []
605
+ );
606
+
607
+ // ❌ Avoid: Validating on every keystroke
608
+ onChange={(expr) => validateExpression(expr)}
609
+ ```
610
+
611
+ ## Building
612
+
613
+ To build the library, run:
614
+
615
+ ```bash
616
+ ng build praxis-visual-builder
617
+ ```
618
+
619
+ This command will compile your project, and the build artifacts will be placed in the `dist/` directory.
620
+
621
+ ### Publishing the Library
622
+
623
+ Once the project is built, you can publish your library by following these steps:
624
+
625
+ 1. Navigate to the `dist` directory:
626
+
627
+ ```bash
628
+ cd dist/praxis-visual-builder
629
+ ```
630
+
631
+ 2. Run the `npm publish` command to publish your library to the npm registry:
632
+ ```bash
633
+ npm publish
634
+ ```
635
+
636
+ ## Running unit tests
637
+
638
+ To execute unit tests with the [Karma](https://karma-runner.github.io) test runner, use the following command:
639
+
640
+ ```bash
641
+ ng test praxis-visual-builder
642
+ ```
643
+
644
+ ## Contributing
645
+
646
+ Please read our [Contributing Guide](CONTRIBUTING.md) for details on our code of conduct and the process for submitting pull requests.
647
+
648
+ ## License
649
+
650
+ Apache-2.0 – see the `LICENSE` packaged with this library or the repository root.