@gesslar/bedoc 1.7.0 → 1.10.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.
@@ -0,0 +1,50 @@
1
+ {
2
+ "$schema": "http://json-schema.org/draft-07/schema#",
3
+ "$id": "https://bedoc.gesslar.dev/schemas/v1/bedoc.action.json",
4
+ "title": "BeDoc Action Schema",
5
+ "type": "object",
6
+ "properties": {
7
+ "accepts": {
8
+ "type": "object",
9
+ "properties": {
10
+ "type": {
11
+ "const": "object"
12
+ }
13
+ },
14
+ "required": [
15
+ "type"
16
+ ]
17
+ },
18
+ "provides": {
19
+ "type": "object",
20
+ "properties": {
21
+ "type": {
22
+ "const": "object"
23
+ }
24
+ },
25
+ "required": [
26
+ "type"
27
+ ],
28
+ "not": {
29
+ "properties": {
30
+ "required": {}
31
+ },
32
+ "required": [
33
+ "required"
34
+ ]
35
+ }
36
+ }
37
+ },
38
+ "oneOf": [
39
+ {
40
+ "required": [
41
+ "accepts"
42
+ ]
43
+ },
44
+ {
45
+ "required": [
46
+ "provides"
47
+ ]
48
+ }
49
+ ]
50
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gesslar/bedoc",
3
- "version": "1.7.0",
3
+ "version": "1.10.0",
4
4
  "description": "Pluggable documentation engine for any language and format",
5
5
  "publisher": "gesslar",
6
6
  "author": "gesslar",
@@ -37,6 +37,7 @@
37
37
  "lint:fix": "npx eslint . --fix"
38
38
  },
39
39
  "dependencies": {
40
+ "ajv": "^8.17.1",
40
41
  "commander": "^13.0.0",
41
42
  "dotenv": "^16.4.7",
42
43
  "error-stack-parser": "^2.1.4",
@@ -7,10 +7,12 @@ export default class ActionManager {
7
7
  #log
8
8
  #debug
9
9
  #file
10
+ #variables
10
11
 
11
- constructor(actionDefinition, logger) {
12
+ constructor({actionDefinition, logger, variables}) {
12
13
  this.#log = logger
13
14
  this.#debug = this.#log.newDebug()
15
+ this.#variables = variables
14
16
 
15
17
  this.#initialize(actionDefinition)
16
18
  }
@@ -65,6 +67,10 @@ export default class ActionManager {
65
67
  return this.#log
66
68
  }
67
69
 
70
+ get variables() {
71
+ return this.#variables
72
+ }
73
+
68
74
  async #setupAction() {
69
75
  const setup = this.action?.setup
70
76
 
@@ -90,7 +96,11 @@ export default class ActionManager {
90
96
  return
91
97
 
92
98
  await this.hookManager.setup.call(
93
- this.hookManager.hooks, {parent: this.action, log: this.#log}
99
+ this.hookManager.hooks, {
100
+ action: this.action,
101
+ variables: this.#variables,
102
+ log: this.#log
103
+ }
94
104
  )
95
105
  }
96
106
 
@@ -246,9 +246,22 @@ export default class Configuration {
246
246
  if(!configFile)
247
247
  throw new Error("No config file specified")
248
248
 
249
- const config = loadJson(configFile)
250
-
251
- allOptions.push({source: "config", options: config})
249
+ const configObject = loadJson(configFile)
250
+ const subConfigName =
251
+ entryOptions?.sub ||
252
+ packageJson?.sub ||
253
+ environmentVariables?.sub
254
+
255
+ // If we didn't specify a subconfiguration, let's just remove
256
+ // it so it doesn't pollute anything.
257
+ if(!subConfigName)
258
+ delete configObject.sub
259
+
260
+ const finalConfig = subConfigName?.value
261
+ ? this.#resolveSubconfigs(configObject, subConfigName.value)
262
+ : configObject
263
+
264
+ allOptions.push({source: "config", options: finalConfig})
252
265
  }
253
266
 
254
267
  allOptions.push({source: "entry", options: entryOptions})
@@ -256,6 +269,21 @@ export default class Configuration {
256
269
  return allOptions
257
270
  }
258
271
 
272
+ #resolveSubconfigs(configObject, subConfigName) {
273
+ const subConfig = configObject.sub?.find(sub => sub.name === subConfigName)
274
+
275
+ if(!subConfig)
276
+ throw new Error(`No such subconfiguration \`${subConfigName}\``)
277
+
278
+ // We don't need this anymore
279
+ delete subConfig.name
280
+
281
+ for(const [key,val] of Object.entries(subConfig))
282
+ configObject[key] = val
283
+
284
+ return configObject
285
+ }
286
+
259
287
  /**
260
288
  * Get environment variables
261
289
  *
@@ -120,6 +120,14 @@ const ConfigurationParameters = Object.freeze({
120
120
  mustExist: true,
121
121
  },
122
122
  },
123
+ sub: {
124
+ short: "s",
125
+ param: "name",
126
+ description: "Specify a subconfiguration",
127
+ type: newTypeSpec("string"),
128
+ required: false,
129
+ dependent: "config",
130
+ },
123
131
  debug: {
124
132
  short: "d",
125
133
  description: "Enable debug mode",
@@ -0,0 +1,112 @@
1
+ import yaml from "yaml"
2
+ import JSON5 from "json5"
3
+
4
+ import * as FDUtil from "./util/FDUtil.js"
5
+ import * as ContractUtil from "./util/ContractUtil.js"
6
+ import * as DataUtil from "./util/DataUtil.js"
7
+
8
+ const {resolveFilename, readFile} = FDUtil
9
+ const {loadSchema, getValidator} = ContractUtil
10
+ const {findClosestMatch} = DataUtil
11
+
12
+ const refex = /^ref:\/\/(.*)$/
13
+
14
+ export default class ContractManager {
15
+ static async newContract(actionType, terms) {
16
+ // Load and validate against the BeDoc contract schema
17
+ const schema = await loadSchema()
18
+ const validator = getValidator(schema)
19
+ const valid = validator(terms)
20
+
21
+ if(!valid) {
22
+ const error = ContractManager.reportValidationErrors(validator.errors)
23
+
24
+ throw new Error(`Invalid contract terms:\n${error}`)
25
+ }
26
+
27
+ const dataValidator = getValidator({
28
+ "$schema": "http://json-schema.org/draft-07/schema#",
29
+ "$id": `${actionType} Schema`,
30
+ title: `${actionType} Schema`,
31
+ type: "object",
32
+ properties: terms,
33
+ })
34
+
35
+ return new Contract(dataValidator)
36
+ }
37
+
38
+ static parse(contractData, directoryObject) {
39
+ if(typeof contractData === "string") {
40
+ const match = refex.exec(contractData)
41
+
42
+ if(match)
43
+ contractData = readFile(resolveFilename(match[1], directoryObject))
44
+
45
+ return yaml.parse(String(contractData))
46
+ }
47
+
48
+ throw new Error(`Invalid contract data: ${JSON5.stringify(contractData)}`)
49
+ }
50
+
51
+ static reportValidationErrors(errors) {
52
+ return errors.reduce((acc, error) => {
53
+ let msg = `- "${error.instancePath || "(root)"}" ${error.message}`
54
+
55
+ if(error.params) {
56
+ const details = []
57
+
58
+ if(error.params.type)
59
+ details.push(` ➜ Expected type: ${error.params.type}`)
60
+
61
+ if(error.params.missingProperty)
62
+ details.push(` ➜ Missing required field: ${error.params.missingProperty}`)
63
+
64
+ if(error.params.allowedValues) {
65
+ details.push(` ➜ Allowed values: "${error.params.allowedValues.join('", "')}"`)
66
+ details.push(` ➜ Received value: "${error.data}"`)
67
+ const closestMatch =
68
+ findClosestMatch(error.data, error.params.allowedValues)
69
+ if(closestMatch)
70
+ details.push(` ➜ Did you mean: "${closestMatch}"?`)
71
+ }
72
+
73
+ if(error.params.pattern)
74
+ details.push(` ➜ Expected pattern: ${error.params.pattern}`)
75
+
76
+ if(error.params.format)
77
+ details.push(` ➜ Expected format: ${error.params.format}`)
78
+
79
+ if(error.params.additionalProperty)
80
+ details.push(` ➜ Unexpected property: ${error.params.additionalProperty}`)
81
+
82
+ if(details.length)
83
+ msg += `\n${details.join("\n")}`
84
+ }
85
+
86
+ return acc ? `${acc}\n${msg}` : msg
87
+ }, "")
88
+ }
89
+ }
90
+
91
+ class Contract {
92
+ #validator = null
93
+
94
+ constructor(validator) {
95
+ this.#validator = validator
96
+ }
97
+
98
+ get validator() {
99
+ return this.#validator
100
+ }
101
+
102
+ validate(data) {
103
+ const validator = this.validator
104
+ const valid = validator(data)
105
+
106
+ if(!valid) {
107
+ const error = ContractManager.reportValidationErrors(validator.errors)
108
+
109
+ throw new Error(`This document violates the agreed upon contract:\n${error}`)
110
+ }
111
+ }
112
+ }
@@ -107,6 +107,9 @@ export default class Conveyor {
107
107
  return {status: "warning", file, warning: mess}
108
108
  }
109
109
 
110
+ parse.contract.validate(parseResult)
111
+ print.contract.validate(parseResult)
112
+
110
113
  // Step 3: Print file
111
114
  const printResult = await print.runAction({
112
115
  file,
package/src/core/Core.js CHANGED
@@ -42,7 +42,7 @@ export default class Core {
42
42
  const instance = new Core({...validConfig, name: "BeDoc"})
43
43
  const debug = instance.logger.newDebug()
44
44
 
45
- debug("Creating new BeDoc instance with options: `%o`", 2, validConfig)
45
+ debug("Creating new BeDoc instance with options: `%o`", 3, validConfig)
46
46
 
47
47
  const discovery = new Discovery(instance)
48
48
  const actionDefs = await discovery.discoverActions({
@@ -52,7 +52,7 @@ export default class Core {
52
52
 
53
53
  const validCrit = discovery.satisfyCriteria(actionDefs, validConfig)
54
54
 
55
- debug("Actions that met criteria: `%o`", 2, validCrit)
55
+ debug("Actions that met criteria: `%o`", 3, validCrit)
56
56
 
57
57
  if(Object.values(validCrit).some(arr => arr.length === 0))
58
58
  throw new Error("No found matching parser and printer")
@@ -63,6 +63,7 @@ export default class Core {
63
63
  const printer = validCrit.print[printers]
64
64
  const printerSchema = printer.contract
65
65
  const satisfied = []
66
+
66
67
  for(const parser of validCrit.parse) {
67
68
  const parserSchema = parser.contract
68
69
  const result = schemaCompare(parserSchema, printerSchema)
@@ -91,14 +92,18 @@ export default class Core {
91
92
 
92
93
  // Adding to instance
93
94
  instance.actions = {}
95
+ const {variables} = validConfig
94
96
  const managers = {print: PrintManager, parse: ParseManager}
95
- for(const [, value] of Object.entries(finalActions)) {
96
- const {action: actionType} = value.action.meta
97
+ for(const [, actionDefinition] of Object.entries(finalActions)) {
98
+ const {action: actionType} = actionDefinition.action.meta
97
99
 
98
100
  debug("Attaching %o action to instance", 2, actionType)
99
- instance.actions[actionType] = new managers[actionType](
100
- value, instance.logger
101
- )
101
+ instance.actions[actionType] =
102
+ new managers[actionType] ({
103
+ actionDefinition,
104
+ logger: instance.logger,
105
+ variables
106
+ })
102
107
 
103
108
  if(validConfig.hooks) {
104
109
  const hookManager = await HookManager.new({
@@ -1,13 +1,14 @@
1
- // import {process} from "node:process"
2
- import yaml from "yaml"
3
1
  import {execSync} from "child_process"
2
+ import path from "node:path"
4
3
 
4
+ import ContractManager from "./ContractManager.js"
5
5
  import * as FDUtil from "./util/FDUtil.js"
6
6
  import * as ActionUtil from "./util/ActionUtil.js"
7
7
  import * as DataUtil from "./util/DataUtil.js"
8
- import {composeDirectory,directoryExists} from "./util/FDUtil.js"
9
8
 
10
- const {ls,resolveFilename,getFiles} = FDUtil
9
+ const {composeDirectory,directoryExists,resolveDirectory} = FDUtil
10
+ const {newContract,parse} = ContractManager
11
+ const {ls,fileExists,composeFilename,getFiles} = FDUtil
11
12
  const {actionTypes, actionMetaRequirements, loadJson} = ActionUtil
12
13
  const {isType} = DataUtil
13
14
 
@@ -34,15 +35,16 @@ export default class Discovery {
34
35
 
35
36
  debug("Discovering actions", 2)
36
37
 
37
- debug("Specific modules provided: %o", 2, specific)
38
+ debug("Specific modules provided: %o", 2, Object.values(specific).filter(Boolean).length)
39
+ debug("Specific modules provided: %o", 3, specific)
38
40
 
39
- const bucket = []
41
+ const files = []
40
42
  const options = this.core.options ?? {}
41
43
 
42
44
  if(options?.mockPath) {
43
45
  debug("Discovering mock actions in `%s`", 2, options.mockPath)
44
46
 
45
- bucket.push(
47
+ files.push(
46
48
  ...(await getFiles([
47
49
  `${options.mockPath}/bedoc-*-printer.js`,
48
50
  `${options.mockPath}/bedoc-*-parser.js`,
@@ -52,76 +54,94 @@ export default class Discovery {
52
54
  debug("Mock path not set, discovering actions in node_modules", 2)
53
55
 
54
56
  debug("Looking for actions in project's package.json", 2)
55
- if(this.core.packageJson?.modules) {
56
- const actions = this.core.packageJson?.modules
57
+ if(this.core.packageJson) {
58
+ const exported = (this.core.packageJson.modules || [])
59
+ .map(m => composeFilename(options.basePath, m))
60
+ .flat()
57
61
 
58
- debug("Found %o actions in package.json", 3, actions)
59
- debug("Actions found in package.json action in package.json: %o", 3, actions)
62
+ debug("Found %o modules in project's package.json", 2, exported.length)
63
+ debug("Found modules in project's package.json: %o", 2, exported)
60
64
 
61
- if(actions && typeof(actions) === "object")
62
- bucket.push(...actions)
63
- else
64
- debug("No actions found in package.json", 3)
65
+ files.push(...exported)
65
66
  } else {
66
- debug("No actions found in project's package.json", 2)
67
+ debug("No modules found in project's package.json", 2)
67
68
  }
68
69
 
69
- debug("Looking for actions in node_modules (global and locally installed", 2)
70
+ debug("Looking for modules in node_modules (global and locally installed)", 2)
70
71
  const directories = [
71
- "./node_modules",
72
+ execSync("npm root").toString().trim(),
72
73
  execSync("npm root -g").toString().trim(),
73
- ]
74
+ ].filter(Boolean)
75
+
76
+ const nodeModulesDirs = directories
77
+ .map(composeDirectory)
78
+ .filter(directoryExists)
74
79
 
75
80
  debug("Found %o directories to search for actions", 2, directories.length)
76
81
  debug("Directories to search for actions: %o", 3, directories)
77
82
 
78
- const moduleDirectories = directories
79
- .map(composeDirectory)
80
- .filter(directoryExists)
81
- for(const moduleDirectory of moduleDirectories) {
82
- const {directories: dirs} = await ls(moduleDirectory.absolutePath)
83
+ for(const nodeModulesDir of nodeModulesDirs) {
84
+ const dirsToSearch = []
85
+ const {directories: moduleDirs} = await ls(nodeModulesDir.absolutePath)
83
86
 
84
- debug("Found %o directories in `%s`", 2,
85
- dirs.length, moduleDirectory.absolutePath
86
- )
87
+ debug("Found %o directories in %o", 2, moduleDirs.length, nodeModulesDir.absolutePath)
87
88
 
88
- const bedocDirs = dirs.filter(d => d.name.startsWith("bedoc-"))
89
- debug("Found %o bedoc directories under %s", 2, bedocDirs.length, moduleDirectory.absolutePath)
89
+ // Handle scoped packages (e.g., @bedoc/something)
90
+ const scopedDirs = moduleDirs.filter(d => d.name.startsWith("@"))
90
91
 
91
- const exports = bedocDirs.map(d => this.#getModuleExports(d))
92
- debug("Found %o module exports under %s", 2, exports.length, moduleDirectory.absolutePath)
92
+ dirsToSearch.push(...moduleDirs)
93
93
 
94
- bucket.push(...exports.flat())
95
- }
96
- }
94
+ // If we find a scope (e.g., "@bedoc"), look inside it for bedoc modules
95
+ for(const scopedDir of scopedDirs) {
96
+ const {directories: scopedPackages} = await ls(scopedDir.absolutePath)
97
97
 
98
- debug("Discovered %d actions", 2, bucket.length)
98
+ debug("Found %o directories under scoped package %o", 2, directories.length, scopedDir.name)
99
+ debug("Found directories under scoped package %o\n%o", 2, scopedDir.absolutePath, scopedPackages.map(d => d.absolutePath))
99
100
 
100
- return await this.#loadActionsAndContracts(bucket, specific)
101
- }
101
+ dirsToSearch.push(...scopedPackages)
102
+ }
102
103
 
103
- /**
104
- * Get the exports from a module's package.json file, resolved to file paths
105
- *
106
- * @param {object} dirMap The directory map object
107
- * @returns {object[]} The discovered module exports
108
- */
109
- #getModuleExports(dirMap) {
110
- const debug = this.#debug
111
- debug("Getting module exports from `%s`", 3, dirMap.absolutePath)
104
+ debug("1 Found %o directories to search for actions", 2, dirsToSearch.length)
105
+ debug("2 Found directories to search for actions: %o", 3, dirsToSearch)
112
106
 
113
- const packageJsonFile = resolveFilename("package.json", dirMap)
114
- debug("Loading package.json from `%s`", 3, packageJsonFile.absolutePath)
107
+ const visibleDirs = dirsToSearch.filter(d => !d.name.startsWith("."))
115
108
 
116
- const packageJson = loadJson(packageJsonFile)
117
- debug("Loaded package.json from `%s`", 3, packageJsonFile.absolutePath)
109
+ for(const dir of visibleDirs) {
110
+ const packageJsonFile = composeFilename(dir, "package.json")
118
111
 
119
- const bedocPackageJsonModules = packageJson.bedoc?.modules ?? []
120
- const bedocModuleFiles = bedocPackageJsonModules.map(file =>
121
- resolveFilename(file, dirMap)
122
- )
112
+ if(!fileExists(packageJsonFile))
113
+ continue
123
114
 
124
- return bedocModuleFiles
115
+ const packageJson = loadJson(packageJsonFile)
116
+ if(!packageJson.bedoc)
117
+ continue
118
+
119
+ const {modules} = packageJson.bedoc ?? null
120
+ if(!modules || !Array.isArray(modules))
121
+ continue
122
+
123
+ const moduleFiles = modules
124
+ .map(f => composeFilename(dir, f))
125
+ .filter(f => fileExists(f))
126
+
127
+ debug("Discovered %d modules from package.json file: %o", 2,
128
+ modules.length,
129
+ packageJsonFile.absolutePath
130
+ )
131
+ debug("Discovered from package.json files: %o", 3, modules)
132
+
133
+ files.push(...moduleFiles)
134
+ }
135
+ }
136
+ }
137
+
138
+ debug("Discovered %d modules", 2, files.length)
139
+ debug("Discovered modules", 2, files.map(f => f.path))
140
+ debug("Discovered modules %o", 3, files)
141
+
142
+ // const available = files.map(f => this.#getModuleExports(f))
143
+
144
+ return await this.#loadActionsAndContracts(files, specific)
125
145
  }
126
146
 
127
147
  /**
@@ -137,7 +157,7 @@ export default class Discovery {
137
157
 
138
158
  debug("Loading actions and contracts", 2)
139
159
  debug("Loading %d module files", 2, moduleFiles.length)
140
- debug("Specific modules to load: %o", 2, specificModules)
160
+ debug("Specific modules to load: %o", 3, specificModules)
141
161
 
142
162
  const resultActions = {}
143
163
  actionTypes.forEach(actionType => (resultActions[actionType] = []))
@@ -164,12 +184,19 @@ export default class Discovery {
164
184
  debug("Loading module `%s`", 2, file.absoluteUri)
165
185
 
166
186
  const loading = await this.#loadModule(file)
167
- const loaded = loading.actions.map((action, index) => {
168
- const contract = yaml.parse(loading.contracts[index])
187
+ for(let index = 0; index < loading.actions.length; index++) {
188
+ const action = loading.actions[index]
169
189
 
170
- return {file, action, contract}
171
- })
172
- loadedActions.push(...loaded)
190
+ if(!file.directory)
191
+ file.directory = resolveDirectory(path.dirname(file.path))
192
+
193
+ debug(`Loading %o contract from %o`, 2, action.meta.action, file.path)
194
+
195
+ const terms = parse(loading.contracts[index], file.directory)
196
+ const contract = await newContract(action.meta.action, terms)
197
+
198
+ loadedActions.push({file, action, contract})
199
+ }
173
200
  }
174
201
 
175
202
  debug("Loaded %d actions", 2, loadedActions.length)
@@ -225,7 +252,7 @@ export default class Discovery {
225
252
  JSON.stringify(moduleFile, null, 2) + "\n" +
226
253
  JSON.stringify(action, null, 2))
227
254
 
228
- debug("Checking action `%s`", 2, metaAction)
255
+ debug("Checking action %o", 2, metaAction)
229
256
 
230
257
  const isValid = this.#validMeta(metaAction, {action, contract})
231
258
 
@@ -246,7 +273,7 @@ export default class Discovery {
246
273
 
247
274
  for(const actionType of actionTypes) {
248
275
  const total = resultActions[actionType].length
249
- debug("Found %o `%o` actions", 2, total, actionType)
276
+ debug("Found %o %o actions", 2, total, actionType)
250
277
  }
251
278
 
252
279
  const total = Object.keys(resultActions).reduce((acc, curr) => {
@@ -280,7 +307,7 @@ export default class Discovery {
280
307
 
281
308
  // First let's check if we wanted something specific
282
309
  if(validatedConfig[config]) {
283
- debug("Checking for specific `%s` action", 3, actionType)
310
+ debug("Checking for specific %o action", 3, actionType)
284
311
  const found = actions[actionType].find(
285
312
  a => a.file.specificType.includes(actionType)
286
313
  )
@@ -294,7 +321,7 @@ export default class Discovery {
294
321
  }
295
322
 
296
323
  // Hmm! We didn't find anything specific. Let's check the criterion
297
- debug("Checking for `%s` actions with criterion `%s`", 3, actionType, criterion)
324
+ debug("Checking for %o actions with criterion %o", 3, actionType, criterion)
298
325
  debug("Validated config to check against: %o", 3, validatedConfig)
299
326
  const found = actions[actionType].filter(a => {
300
327
  debug("Meta criterion value: %o", 4, a.action.meta[criterion])
@@ -324,7 +351,7 @@ export default class Discovery {
324
351
  async #loadModule(module) {
325
352
  const debug = this.#debug
326
353
 
327
- debug("2 Loading module `%j`", 2, module)
354
+ debug("Loading module `%j`", 2, module)
328
355
 
329
356
  const {absoluteUri} = module
330
357
  const moduleExports = await import(absoluteUri)
@@ -341,7 +368,7 @@ export default class Discovery {
341
368
  */
342
369
  #validMeta(actionType, toValidate) {
343
370
  const debug = this.#debug
344
- debug("Checking meta requirements for `%s`", 3, actionType)
371
+ debug("Checking meta requirements for %o", 3, actionType)
345
372
 
346
373
  const requirements = actionMetaRequirements[actionType]
347
374
  if(!requirements)
@@ -0,0 +1,7 @@
1
+ import {Contract} from "../ContractManager.js"
2
+
3
+ export default class ParseContract extends Contract {
4
+ constructor(actionDefinition, logger) {
5
+ super(actionDefinition, logger)
6
+ }
7
+ }
@@ -0,0 +1,7 @@
1
+ import {Contract} from "../ContractManager.js"
2
+
3
+ export default class PrintContract extends Contract {
4
+ constructor(actionDefinition, logger) {
5
+ super(actionDefinition, logger)
6
+ }
7
+ }
@@ -0,0 +1,63 @@
1
+ import fetch from "node-fetch"
2
+ import JSON5 from "json5"
3
+ import Ajv from "ajv"
4
+ import {fileURLToPath,URL} from "node:url"
5
+
6
+ import * as FDUtil from "./FDUtil.js"
7
+
8
+ const {composeFilename,fileExists,readFile,writeFile} = FDUtil
9
+
10
+ const schemaUrl = "https://bedoc.gesslar.dev/schemas/v1/bedoc.action.json"
11
+ const localSchema = "./dist/bedoc.action.json"
12
+
13
+ /**
14
+ * Takes a schema and returns a validator function
15
+ *
16
+ * @param {object} schema The schema to compile
17
+ * @returns {Function} The schema validator function
18
+ */
19
+ function getValidator(schema) {
20
+ const ajv = new Ajv({allErrors: true, verbose: true})
21
+ const f = ajv.compile(schema)
22
+
23
+ return f
24
+ }
25
+
26
+ /**
27
+ * Downloads and preserves a copy of the action schema
28
+ * within the dist/ folder.
29
+ *
30
+ * @returns {object} The schema validator
31
+ */
32
+ async function fetchSchema() {
33
+ const response = await fetch(schemaUrl)
34
+ const schema = await response.text()
35
+
36
+ const output = composeFilename(fileURLToPath(new URL("../../../", import.meta.url)), localSchema)
37
+ writeFile(output, schema)
38
+
39
+ return JSON5.parse(schema)
40
+ }
41
+
42
+ /**
43
+ * Loads a schema from file or fetches it if it is missing.
44
+ *
45
+ * @returns {object} The schema object
46
+ */
47
+ async function loadSchema() {
48
+ const schemaFile = composeFilename(fileURLToPath(new URL("../../../", import.meta.url)), localSchema)
49
+
50
+ if(fileExists(schemaFile)) {
51
+ const schema = readFile(schemaFile)
52
+
53
+ return JSON5.parse(schema)
54
+ }
55
+
56
+ return await fetchSchema()
57
+ }
58
+
59
+ export {
60
+ fetchSchema,
61
+ getValidator,
62
+ loadSchema,
63
+ }
@@ -383,6 +383,9 @@ function deepFreezeObject(obj) {
383
383
  /**
384
384
  * Validates that a schema matches the expected structure.
385
385
  *
386
+ * TODO get rid of this and all of its uses. We have a new
387
+ * contract system now.
388
+ *
386
389
  * @param {object} schema - The schema to validate.
387
390
  * @param {object} definition - The expected structure.
388
391
  * @param {Array} stack - The stack trace for nested validation.
@@ -452,6 +455,60 @@ function schemaCompare(schema, definition, stack = [], logger = new Logger()) {
452
455
  return {status: errors.length === 0 ? "success" : "error", errors}
453
456
  }
454
457
 
458
+ /**
459
+ * Determine the Levenshtein distance between two string values
460
+ *
461
+ * @param {string} a The first value for comparison.
462
+ * @param {string} b The second value for comparison.
463
+ * @returns {number} The Levenshtein distance
464
+ */
465
+ function levenshteinDistance(a, b) {
466
+ const matrix = Array.from({length: a.length + 1}, (_, i) =>
467
+ Array.from({length: b.length + 1}, (_, j) =>
468
+ (i === 0 ? j : j === 0 ? i : 0)
469
+ )
470
+ )
471
+
472
+ for(let i = 1; i <= a.length; i++) {
473
+ for(let j = 1; j <= b.length; j++) {
474
+ matrix[i][j] =
475
+ a[i - 1] === b[j - 1]
476
+ ? matrix[i - 1][j - 1]
477
+ : 1 + Math.min(
478
+ matrix[i - 1][j], matrix[i][j - 1],
479
+ matrix[i - 1][j - 1]
480
+ )
481
+ }
482
+ }
483
+
484
+ return matrix[a.length][b.length]
485
+ }
486
+
487
+ /**
488
+ * Determine the closest match between a string and allowed values
489
+ * from the Levenshtein distance.
490
+ *
491
+ * @param {string} input The input string to resolve
492
+ * @param {Array<string>} allowedValues The values which are permitted
493
+ * @returns {string} Suggested, probable match.
494
+ */
495
+ function findClosestMatch(input, allowedValues) {
496
+ const threshold = 2 // Max edit distance for a "close match"
497
+ let closestMatch = null
498
+ let closestDistance = Infinity
499
+
500
+ for(const value of allowedValues) {
501
+ const distance = levenshteinDistance(input, value)
502
+ if(distance < closestDistance && distance <= threshold) {
503
+ closestMatch = value
504
+ closestDistance = distance
505
+ }
506
+ }
507
+
508
+ return closestMatch
509
+ }
510
+
511
+
455
512
  export {
456
513
  // Classes
457
514
  TypeSpec,
@@ -465,6 +522,7 @@ export {
465
522
  arrayPad,
466
523
  cloneObject,
467
524
  deepFreezeObject,
525
+ findClosestMatch,
468
526
  isArrayUniform,
469
527
  isArrayUnique,
470
528
  isBaseType,
@@ -473,6 +531,7 @@ export {
473
531
  isObjectEmpty,
474
532
  isType,
475
533
  isValidType,
534
+ levenshteinDistance,
476
535
  mapObject,
477
536
  newTypeSpec,
478
537
  prependString,