@gesslar/bedoc 1.11.0 → 2.1.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.
Files changed (88) hide show
  1. package/LICENSE.txt +12 -0
  2. package/README.md +15 -3
  3. package/dist/schema/bedoc.action.json +42 -0
  4. package/dist/types/Action.d.ts +3 -0
  5. package/dist/types/Action.d.ts.map +1 -0
  6. package/dist/types/BeDoc.d.ts +208 -0
  7. package/dist/types/BeDoc.d.ts.map +1 -0
  8. package/dist/types/Configuration.d.ts +11 -0
  9. package/dist/types/Configuration.d.ts.map +1 -0
  10. package/dist/types/ConfigurationParameters.d.ts +3 -0
  11. package/dist/types/ConfigurationParameters.d.ts.map +1 -0
  12. package/dist/types/Conveyor.d.ts +27 -0
  13. package/dist/types/Conveyor.d.ts.map +1 -0
  14. package/dist/types/Discovery.d.ts +215 -0
  15. package/dist/types/Discovery.d.ts.map +1 -0
  16. package/dist/types/Environment.d.ts +3 -0
  17. package/dist/types/Environment.d.ts.map +1 -0
  18. package/dist/types/Logger.d.ts +47 -0
  19. package/dist/types/Logger.d.ts.map +1 -0
  20. package/dist/types/Schema.d.ts +3 -0
  21. package/dist/types/Schema.d.ts.map +1 -0
  22. package/dist/types/cli.d.ts +2 -2
  23. package/dist/types/cli.d.ts.map +1 -10
  24. package/package.json +24 -23
  25. package/src/Action.js +9 -0
  26. package/src/BeDoc.js +304 -0
  27. package/src/CLIOutput.js +198 -0
  28. package/src/{core/Configuration.js → Configuration.js} +73 -58
  29. package/src/{core/ConfigurationParameters.js → ConfigurationParameters.js} +35 -27
  30. package/src/Conveyor.js +256 -0
  31. package/src/Discovery.js +442 -0
  32. package/src/Environment.js +8 -0
  33. package/src/{core/Logger.js → Logger.js} +30 -18
  34. package/src/Schema.js +6 -0
  35. package/src/cli.js +86 -38
  36. package/tsconfig.types.json +42 -0
  37. package/LICENSE +0 -24
  38. package/dist/types/core/ActionManager.d.ts +0 -58
  39. package/dist/types/core/ActionManager.d.ts.map +0 -10
  40. package/dist/types/core/Configuration.d.ts +0 -27
  41. package/dist/types/core/Configuration.d.ts.map +0 -10
  42. package/dist/types/core/ConfigurationParameters.d.ts +0 -38
  43. package/dist/types/core/ConfigurationParameters.d.ts.map +0 -10
  44. package/dist/types/core/Conveyor.d.ts +0 -49
  45. package/dist/types/core/Conveyor.d.ts.map +0 -10
  46. package/dist/types/core/Core.d.ts +0 -48
  47. package/dist/types/core/Core.d.ts.map +0 -10
  48. package/dist/types/core/Discovery.d.ts +0 -73
  49. package/dist/types/core/Discovery.d.ts.map +0 -10
  50. package/dist/types/core/HookManager.d.ts +0 -60
  51. package/dist/types/core/HookManager.d.ts.map +0 -10
  52. package/dist/types/core/Logger.d.ts +0 -63
  53. package/dist/types/core/Logger.d.ts.map +0 -10
  54. package/dist/types/core/action/ParseManager.d.ts +0 -8
  55. package/dist/types/core/action/ParseManager.d.ts.map +0 -10
  56. package/dist/types/core/action/PrintManager.d.ts +0 -8
  57. package/dist/types/core/action/PrintManager.d.ts.map +0 -10
  58. package/dist/types/core/util/ActionUtil.d.ts +0 -35
  59. package/dist/types/core/util/ActionUtil.d.ts.map +0 -10
  60. package/dist/types/core/util/DataUtil.d.ts +0 -52
  61. package/dist/types/core/util/DataUtil.d.ts.map +0 -10
  62. package/dist/types/core/util/FDUtil.d.ts +0 -171
  63. package/dist/types/core/util/FDUtil.d.ts.map +0 -10
  64. package/dist/types/core/util/ModuleUtil.d.ts +0 -27
  65. package/dist/types/core/util/ModuleUtil.d.ts.map +0 -10
  66. package/dist/types/core/util/StringUtil.d.ts +0 -5
  67. package/dist/types/core/util/StringUtil.d.ts.map +0 -10
  68. package/dist/types/core/util/TypeSpec.d.ts +0 -42
  69. package/dist/types/core/util/TypeSpec.d.ts.map +0 -10
  70. package/dist/types/core/util/ValidUtil.d.ts +0 -29
  71. package/dist/types/core/util/ValidUtil.d.ts.map +0 -10
  72. package/src/core/ActionManager.js +0 -147
  73. package/src/core/ContractManager.js +0 -112
  74. package/src/core/Conveyor.js +0 -185
  75. package/src/core/Core.js +0 -166
  76. package/src/core/Discovery.js +0 -403
  77. package/src/core/HookManager.js +0 -143
  78. package/src/core/action/ParseManager.js +0 -7
  79. package/src/core/action/PrintManager.js +0 -7
  80. package/src/core/contract/ParseContract.js +0 -7
  81. package/src/core/contract/PrintContract.js +0 -7
  82. package/src/core/util/ActionUtil.js +0 -62
  83. package/src/core/util/ContractUtil.js +0 -63
  84. package/src/core/util/DataUtil.js +0 -540
  85. package/src/core/util/FDUtil.js +0 -388
  86. package/src/core/util/StringUtil.js +0 -11
  87. package/src/core/util/TypeSpec.js +0 -114
  88. package/src/core/util/ValidUtil.js +0 -50
@@ -1,24 +1,16 @@
1
- import process from "node:process"
2
- import {Environment} from "./Core.js"
1
+ import {Collection, Data, DirectoryObject, FileObject, FileSystem as FS, Promised, Sass, Tantrum} from "@gesslar/toolkit"
3
2
  import JSON5 from "json5"
3
+ import process from "node:process"
4
4
 
5
5
  import {
6
6
  ConfigurationParameters,
7
7
  ConfigurationPriorityKeys,
8
8
  } from "./ConfigurationParameters.js"
9
-
10
- import * as ActionUtil from "./util/ActionUtil.js"
11
- import * as DataUtil from "./util/DataUtil.js"
12
- import * as FDUtil from "./util/FDUtil.js"
13
-
14
- const {loadDataFile} = ActionUtil
15
- const {isNothing, isType, mapObject} = DataUtil
16
- const {getFiles, composeFilename, fileExists} = FDUtil
17
- const {resolveDirectory, resolveFilename} = FDUtil
18
- const {fdType, fdTypes} = FDUtil
9
+ import Environment from "./Environment.js"
19
10
 
20
11
  export default class Configuration {
21
12
  async validate({options, source}) {
13
+ const {basePath: base} = options
22
14
  const finalOptions = {}
23
15
 
24
16
  this.#mapEntryOptions({options, source})
@@ -34,14 +26,15 @@ export default class Configuration {
34
26
  // (Edit: No, I mean the ConfigurationParameters object. It's trash. Fix it
35
27
  // if you get this error.)
36
28
  const configValidationErrors = this.#validateConfigurationParameters()
37
- if(configValidationErrors.length > 0)
38
- throw new AggregateError(
39
- configValidationErrors,
40
- `ConfigurationParameters validation errors: `+
41
- `${configValidationErrors.join(", ")}`,
29
+
30
+ if(configValidationErrors.length > 0) {
31
+ throw Tantrum.new(
32
+ `ConfigurationParameters validation errors`,
33
+ configValidationErrors.map(e => new Sass(e))
42
34
  )
35
+ }
43
36
 
44
- const allOptions = this.#findAllOptions(options)
37
+ const allOptions = await this.#findAllOptions(options)
45
38
 
46
39
  Object.assign(finalOptions, await this.#mergeOptions(allOptions))
47
40
 
@@ -52,9 +45,10 @@ export default class Configuration {
52
45
  // Find them and add them to an array; the rest will be in pushed to the
53
46
  // end of the priority array.
54
47
  const orderedSections = []
48
+
55
49
  ConfigurationPriorityKeys.forEach(key => {
56
50
  if(!ConfigurationParameters[key])
57
- throw new Error(`Invalid priority key: ${key}`)
51
+ throw Sass.new(`Invalid priority key: ${key}`)
58
52
 
59
53
  if(finalOptions[key])
60
54
  orderedSections.push({key, value: finalOptions[key]})
@@ -63,6 +57,7 @@ export default class Configuration {
63
57
  const remainingSections = Object.keys(ConfigurationParameters).filter(
64
58
  key => !ConfigurationPriorityKeys.includes(key),
65
59
  )
60
+
66
61
  orderedSections.push(
67
62
  ...remainingSections.map(key => {
68
63
  return {key, value: finalOptions[key]}
@@ -95,7 +90,7 @@ export default class Configuration {
95
90
  continue
96
91
 
97
92
  let {value} = section
98
- const nothing = isNothing(value)
93
+ const nothing = Data.isNothing(value)
99
94
  const param = ConfigurationParameters[key]
100
95
  const {required, path} = param
101
96
 
@@ -110,29 +105,35 @@ export default class Configuration {
110
105
  if(path && !nothing) {
111
106
  const {mustExist, type: pathType} = path
112
107
 
113
- // Special for `input` and `exclude` because they can be a comma-
114
- // separated list of glob patterns.
108
+ // `input` and `exclude` are glob patterns (or arrays of them). Each
109
+ // is resolved against the base directory via DirectoryObject's glob.
115
110
  if(key === "input" || key === "exclude") {
116
- if(isType(value, "array"))
117
- value = await Promise.all(
118
- value.map(pattern => getFiles(pattern)),
119
- )
120
- else if(isType(value, "string"))
121
- value = await getFiles(value)
122
- else
111
+ if(!Data.isType(value, "Array") && !Data.isType(value, "String")) {
123
112
  throw new TypeError(
124
113
  `Option \`${key}\` must be a string or an array of strings`,
125
114
  )
115
+ }
116
+
117
+ const patterns = Data.isType(value, "Array") ? value : [value]
126
118
 
127
- finalOptions[key] = value.flat()
119
+ const settled = await Promised.settle(
120
+ patterns.map(async pat => {
121
+ const {files} = await base.glob(pat)
122
+
123
+ return files
124
+ })
125
+ )
126
+
127
+ value = Promised.values(settled).flat()
128
+
129
+ finalOptions[key] = value
128
130
 
129
131
  continue
130
132
  } else {
131
133
  if(mustExist === true) {
132
- finalOptions[key] =
133
- pathType === fdType.FILE
134
- ? resolveFilename(value)
135
- : resolveDirectory(value)
134
+ finalOptions[key] = pathType === FS.fdType.FILE
135
+ ? new FileObject(value)
136
+ : new DirectoryObject(value)
136
137
  }
137
138
  }
138
139
  }
@@ -141,6 +142,7 @@ export default class Configuration {
141
142
  return {
142
143
  status: "success",
143
144
  validated: true,
145
+ basePath: base,
144
146
  ...finalOptions,
145
147
  }
146
148
  }
@@ -150,17 +152,22 @@ export default class Configuration {
150
152
  if(source === Environment.CLI)
151
153
  return options
152
154
 
155
+ // `basePath` and `project` are consumed downstream as raw values (matching
156
+ // the shape the CLI provides); everything else is wrapped as {value, source}.
153
157
  for(const [key, value] of Object.entries(options)) {
158
+ if(key === "basePath" || key === "project")
159
+ continue
160
+
154
161
  options[key] = {value, source}
155
162
  }
156
163
 
157
164
  // We will need to inject some options if they are not available
158
165
  const cwd = process.cwd()
159
- const dir = resolveDirectory(cwd)
166
+ const dir = new DirectoryObject(cwd)
160
167
 
161
168
  // Inject basePath if not available
162
169
  if(!options.basePath)
163
- options.basePath = {value: dir, source}
170
+ options.basePath = dir
164
171
 
165
172
  // Add defaults which are missing
166
173
  for(const [key, param] of Object.entries(ConfigurationParameters)) {
@@ -196,7 +203,7 @@ export default class Configuration {
196
203
  errors.push(`Option \`${key}\` has no path type`)
197
204
 
198
205
  // Check if pathType is a valid key in FdTypes
199
- if(!fdTypes.includes(pathType))
206
+ if(!FS.fdTypes.includes(pathType))
200
207
  errors.push(`Option \`${key}\` has invalid path type: ${pathType}`)
201
208
  }
202
209
  }
@@ -210,19 +217,23 @@ export default class Configuration {
210
217
  * @param {object} entryOptions - The command line options.
211
218
  * @returns {Promise<object[]>} All options from all sources.
212
219
  */
213
- #findAllOptions(entryOptions) {
220
+ async #findAllOptions(entryOptions) {
221
+ const {basePath} = entryOptions
214
222
  const allOptions = []
215
223
  const environmentVariables = this.#getEnvironmentVariables()
224
+
216
225
  if(environmentVariables)
217
226
  allOptions.push({source: "environment", options: environmentVariables})
218
227
 
219
- const packageJson = entryOptions?.packageJson
228
+ const packageJson = entryOptions?.project
229
+
220
230
  if(packageJson) {
221
231
  allOptions.push({source: "packageJson", options: packageJson})
222
232
  } else {
223
- const packageJsonFile = composeFilename(process.cwd(), "package.json")
224
- if(fileExists(packageJsonFile)) {
225
- const packageJson = loadDataFile(packageJsonFile)
233
+ const packageJsonFile = basePath.getFile("package.json")
234
+
235
+ if(await packageJsonFile.exists) {
236
+ const packageJson = await packageJsonFile.loadData()
226
237
 
227
238
  if(packageJson.bedoc)
228
239
  allOptions.push({source: "packageJson", options: packageJson.bedoc})
@@ -238,15 +249,17 @@ export default class Configuration {
238
249
  if(useConfig) {
239
250
  const configFile =
240
251
  packageJson?.config
241
- ? resolveFilename(packageJson?.config)
252
+ ? new FileObject(packageJson.config)
242
253
  : entryOptions.config?.value
243
- ? resolveFilename(entryOptions.config.value)
244
- : null
254
+ ? new FileObject(entryOptions.config.value)
255
+ : environmentVariables?.config
256
+ ? new FileObject(environmentVariables.config)
257
+ : null
245
258
 
246
259
  if(!configFile)
247
- throw new Error("No config file specified")
260
+ throw Sass.new("No config file specified")
248
261
 
249
- const configObject = loadDataFile(configFile)
262
+ const configObject = await configFile.loadData()
250
263
  const subConfigName =
251
264
  entryOptions?.sub ||
252
265
  packageJson?.sub ||
@@ -273,7 +286,7 @@ export default class Configuration {
273
286
  const subConfig = configObject.sub?.find(sub => sub.name === subConfigName)
274
287
 
275
288
  if(!subConfig)
276
- throw new Error(`No such subconfiguration \`${subConfigName}\``)
289
+ throw Sass.new(`No such subconfiguration \`${subConfigName}\``)
277
290
 
278
291
  // We don't need this anymore
279
292
  delete subConfig.name
@@ -328,19 +341,21 @@ export default class Configuration {
328
341
  return acc
329
342
  }, {})
330
343
 
331
- const mappedOptions = await mapObject(mergedOptions, (option, value) => {
332
- const {value: entryValue, source: entrySource} = entryOptions[option] ?? {
333
- value: undefined,
334
- source: undefined,
335
- }
344
+ const mappedOptions = await Collection.mapObject(mergedOptions,
345
+ (option, value) => {
346
+ const {value: entryValue, source: entrySource} =
347
+ entryOptions[option] ?? {
348
+ value: undefined,
349
+ source: undefined,
350
+ }
336
351
 
337
- const entryDefaulted = entrySource === "default"
352
+ const entryDefaulted = entrySource === "default"
338
353
 
339
- if(entryValue && value !== entryValue)
340
- return entryDefaulted ? value : entryValue
354
+ if(entryValue && value !== entryValue)
355
+ return entryDefaulted ? value : entryValue
341
356
 
342
- return value
343
- })
357
+ return value
358
+ })
344
359
 
345
360
  // Last, but not least, add any defaulted options that are not in the
346
361
  // mapped options
@@ -1,13 +1,11 @@
1
- import * as DataUtil from "./util/DataUtil.js"
1
+ import {Data} from "@gesslar/toolkit"
2
2
 
3
- const {newTypeSpec} = DataUtil
4
-
5
- const ConfigurationParameters = Object.freeze({
3
+ const ConfigurationParameters = Data.deepFreezeObject({
6
4
  input: {
7
5
  short: "i",
8
6
  param: "file",
9
- description: "Comma-separated glob patterns to match files",
10
- type: newTypeSpec("string|string[]"),
7
+ description: "Glob pattern (or array of patterns) to match files",
8
+ type: Data.newTypeSpec("string|string[]"),
11
9
  required: true,
12
10
  path: {
13
11
  type: "file",
@@ -17,30 +15,30 @@ const ConfigurationParameters = Object.freeze({
17
15
  exclude: {
18
16
  short: "x",
19
17
  param: "file",
20
- description: "Comma-separated glob patterns to exclude files",
21
- type: newTypeSpec("string|string[]"),
18
+ description: "Glob pattern (or array of patterns) to exclude files",
19
+ type: Data.newTypeSpec("string|string[]"),
22
20
  required: false,
23
21
  },
24
22
  language: {
25
23
  short: "l",
26
24
  param: "lang",
27
25
  description: "Language parser to use",
28
- type: newTypeSpec("string"),
26
+ type: Data.newTypeSpec("string"),
29
27
  required: false,
30
28
  exclusiveOf: "parser",
31
29
  },
32
30
  format: {
33
31
  short: "f",
34
32
  description: "Output format",
35
- type: newTypeSpec("string"),
33
+ type: Data.newTypeSpec("string"),
36
34
  required: false,
37
- exclusiveOf: "printer",
35
+ exclusiveOf: "formatter",
38
36
  },
39
37
  maxConcurrent: {
40
38
  short: "C",
41
39
  param: "num",
42
40
  description: "Maximum number of concurrent tasks",
43
- type: newTypeSpec("number"),
41
+ type: Data.newTypeSpec("number"),
44
42
  required: false,
45
43
  default: 10,
46
44
  },
@@ -48,7 +46,7 @@ const ConfigurationParameters = Object.freeze({
48
46
  short: "k",
49
47
  param: "file",
50
48
  description: "Custom hooks JS file",
51
- type: newTypeSpec("string"),
49
+ type: Data.newTypeSpec("string"),
52
50
  required: false,
53
51
  path: {
54
52
  type: "file",
@@ -59,7 +57,7 @@ const ConfigurationParameters = Object.freeze({
59
57
  short: "o",
60
58
  param: "dir",
61
59
  description: "Output directory",
62
- type: newTypeSpec("string"),
60
+ type: Data.newTypeSpec("string"),
63
61
  required: false,
64
62
  path: {
65
63
  type: "directory",
@@ -70,7 +68,7 @@ const ConfigurationParameters = Object.freeze({
70
68
  short: "p",
71
69
  param: "file",
72
70
  description: "Custom parser JS file",
73
- type: newTypeSpec("string"),
71
+ type: Data.newTypeSpec("string"),
74
72
  required: false,
75
73
  exclusiveOf: "language",
76
74
  path: {
@@ -78,11 +76,11 @@ const ConfigurationParameters = Object.freeze({
78
76
  mustExist: true,
79
77
  },
80
78
  },
81
- printer: {
79
+ formatter: {
82
80
  short: "P",
83
81
  param: "file",
84
- description: "Custom printer JS file",
85
- type: newTypeSpec("string"),
82
+ description: "Custom formatter JS file",
83
+ type: Data.newTypeSpec("string"),
86
84
  required: false,
87
85
  exclusiveOf: "format",
88
86
  path: {
@@ -94,15 +92,15 @@ const ConfigurationParameters = Object.freeze({
94
92
  short: "T",
95
93
  param: "ms",
96
94
  description: "Timeout in milliseconds for hook execution",
97
- type: newTypeSpec("number"),
95
+ type: Data.newTypeSpec("number"),
98
96
  required: false,
99
97
  default: 5000,
100
98
  },
101
99
  mock: {
102
100
  short: "m",
103
101
  param: "dir",
104
- description: "Path to mock parsers and printers",
105
- type: newTypeSpec("string"),
102
+ description: "Path to mock parsers and formatters",
103
+ type: Data.newTypeSpec("string"),
106
104
  required: false,
107
105
  path: {
108
106
  type: "directory",
@@ -113,7 +111,7 @@ const ConfigurationParameters = Object.freeze({
113
111
  short: "c",
114
112
  param: "file",
115
113
  description: "Use JSON config file",
116
- type: newTypeSpec("string"),
114
+ type: Data.newTypeSpec("string"),
117
115
  required: false,
118
116
  path: {
119
117
  type: "file",
@@ -124,14 +122,14 @@ const ConfigurationParameters = Object.freeze({
124
122
  short: "s",
125
123
  param: "name",
126
124
  description: "Specify a subconfiguration",
127
- type: newTypeSpec("string"),
125
+ type: Data.newTypeSpec("string"),
128
126
  required: false,
129
127
  dependent: "config",
130
128
  },
131
129
  debug: {
132
130
  short: "d",
133
131
  description: "Enable debug mode",
134
- type: newTypeSpec("boolean"),
132
+ type: Data.newTypeSpec("boolean"),
135
133
  required: false,
136
134
  default: false,
137
135
  },
@@ -139,12 +137,22 @@ const ConfigurationParameters = Object.freeze({
139
137
  short: "D",
140
138
  param: "level",
141
139
  description: "Debug level",
142
- type: newTypeSpec("number"),
140
+ type: Data.newTypeSpec("number"),
143
141
  required: false,
144
142
  default: 0,
145
143
  },
144
+ terse: {
145
+ short: "t",
146
+ description: "Terse output (hide per-stage progress lines)",
147
+ type: Data.newTypeSpec("boolean"),
148
+ required: false,
149
+ default: false,
150
+ },
146
151
  })
147
152
 
148
- const ConfigurationPriorityKeys = Object.freeze(["exclude", "input"])
153
+ const ConfigurationPriorityKeys = Data.deepFreezeObject(["exclude", "input"])
149
154
 
150
- export {ConfigurationParameters, ConfigurationPriorityKeys}
155
+ export {
156
+ ConfigurationParameters,
157
+ ConfigurationPriorityKeys
158
+ }
@@ -0,0 +1,256 @@
1
+ import {ActionBuilder, ActionRunner, ACTIVITY} from "@gesslar/actioneer"
2
+ import {DirectoryObject, FileObject, Notify, Sass} from "@gesslar/toolkit"
3
+
4
+ /**
5
+ * @import {CLIOutput} from "./CLIOutput.js"
6
+ * @import {Contract} from "@gesslar/negotiator"
7
+ */
8
+
9
+ const {IF} = ACTIVITY
10
+
11
+ export default class Conveyor {
12
+ #parser
13
+ #formatter
14
+ /** An instance of CLIOutput @type {CLIOutput} */
15
+ #cli
16
+
17
+ /** @type {DirectoryObject} */
18
+ #output
19
+
20
+ /** @type {Contract} */
21
+ #contract
22
+
23
+ #hooks
24
+ #basePath
25
+
26
+ constructor({
27
+ basePath,
28
+ parser,
29
+ formatter,
30
+ hooks,
31
+ contract,
32
+ output,
33
+ cli
34
+ }) {
35
+ this.#basePath = basePath
36
+ this.#parser = parser
37
+ this.#formatter = formatter
38
+ this.#hooks = hooks
39
+ this.#contract = contract
40
+ this.#output = output
41
+ this.#cli = cli
42
+ }
43
+
44
+ /**
45
+ * Emits a pipeline stage transition for a file.
46
+ *
47
+ * @param {FileObject} file - The file the stage pertains to.
48
+ * @param {string} stage - The stage name (read|parse|validate|format|write).
49
+ * @param {string} state - The new state (active|done|warning|error).
50
+ */
51
+ #emitStage = (file, stage, state) =>
52
+ Notify.emit("update-data", {file, message: {kind: "stage", stage, state}})
53
+
54
+ /**
55
+ * Defines the per-file processing pipeline.
56
+ *
57
+ * @param {ActionBuilder} builder - The Actioneer builder instance.
58
+ */
59
+ setup(builder) {
60
+ builder
61
+ .do("read", this.#readFile)
62
+ .do("parse", this.#parseFile)
63
+ .do("validate", this.#validateContracts)
64
+ .do("format", this.#formatFile)
65
+ .do("write", IF, this.#shouldWrite, this.#writeOutput)
66
+ }
67
+
68
+ /**
69
+ * Processes files through the parser→formatter pipeline with concurrency.
70
+ *
71
+ * @param {Array<FileObject>} files - List of files to process.
72
+ * @param {number} [maxConcurrent] - Maximum number of files to process at a time.
73
+ * @returns {Promise<object>} - Resolves with {succeeded, errored, warned}.
74
+ */
75
+ async convey(files, maxConcurrent = 10) {
76
+ const builder = new ActionBuilder(this)
77
+ const runner = new ActionRunner(builder)
78
+ .addSetup(async() => {
79
+ if(this.#output && !await this.#output.exists)
80
+ await this.#output.assureExists({recursive: true})
81
+ })
82
+
83
+ const destExtension = this.#formatter.meta.extension ?? "txt"
84
+ const contexts = files.map(file => ({
85
+ file,
86
+ output: new FileObject(`${file.module}.${destExtension}`, this.#output)
87
+ }))
88
+
89
+ Notify.emit("conveyor-start", contexts)
90
+
91
+ const settled = await runner.pipe(contexts, maxConcurrent)
92
+
93
+ return this.#categorize(settled, files)
94
+ }
95
+
96
+ // -- Pipeline activities --------------------------------------------------
97
+
98
+ #readFile = async ctx => {
99
+ try {
100
+ this.#emitStage(ctx.file, "read", "active")
101
+
102
+ const content = await ctx.file.read()
103
+
104
+ Notify.emit("update-data", {file: ctx.file, message: {kind: "input-size", value: Buffer.byteLength(content)}})
105
+ this.#emitStage(ctx.file, "read", "done")
106
+
107
+ return {...ctx, content}
108
+ } catch(error) {
109
+ this.#emitStage(ctx.file, "read", "error")
110
+
111
+ return {...ctx, status: "error", error: Sass.new(`Reading file ${ctx.file}`, error)}
112
+ }
113
+ }
114
+
115
+ #parseFile = async ctx => {
116
+ if(ctx.error)
117
+ return ctx
118
+
119
+ try {
120
+ this.#emitStage(ctx.file, "parse", "active")
121
+
122
+ const {content} = ctx
123
+ const builder = new ActionBuilder(new this.#parser())
124
+
125
+ if(this.#hooks?.Parse)
126
+ builder.withHooks(new this.#hooks.Parse())
127
+
128
+ const runner = new ActionRunner(builder)
129
+ const result = await runner.run(content)
130
+
131
+ this.#emitStage(ctx.file, "parse", "done")
132
+
133
+ return Object.assign(ctx, {...result})
134
+ } catch(error) {
135
+ this.#emitStage(ctx.file, "parse", "error")
136
+
137
+ return {...ctx, status: "error", error: Sass.new(`Parsing file ${ctx.file}`, error)}
138
+ }
139
+ }
140
+
141
+ #validateContracts = ctx => {
142
+ if(ctx.error)
143
+ return ctx
144
+
145
+ try {
146
+ this.#emitStage(ctx.file, "validate", "active")
147
+
148
+ this.#contract.validate(ctx)
149
+
150
+ this.#emitStage(ctx.file, "validate", "done")
151
+ } catch(err) {
152
+ if(err) {
153
+ this.#emitStage(ctx.file, "validate", "error")
154
+
155
+ throw Sass.new(`Parser validation for ${ctx.file.path}`, err)
156
+ }
157
+ }
158
+
159
+ return ctx
160
+ }
161
+
162
+ #formatFile = async ctx => {
163
+ if(ctx.error)
164
+ return ctx
165
+
166
+ this.#emitStage(ctx.file, "format", "active")
167
+
168
+ const {functions} = ctx
169
+ const builder = new ActionBuilder(new this.#formatter())
170
+
171
+ if(this.#hooks?.Format)
172
+ builder.withHooks(new this.#hooks.Format())
173
+
174
+ const runner = new ActionRunner(builder)
175
+ const formatResult = await runner.run(functions)
176
+
177
+ this.#emitStage(ctx.file, "format", "done")
178
+
179
+ return Object.assign(ctx, {formatResult})
180
+ }
181
+
182
+ #shouldWrite = ctx => {
183
+ if(ctx.error)
184
+ return ctx
185
+
186
+ const result = this.#output != null && ctx?.formatResult
187
+
188
+ if(result)
189
+ return result
190
+
191
+ Object.assign(ctx, {status: "warning", warning: `No output content for ${ctx.file.path}`})
192
+
193
+ Notify.emit("update-data", {file: ctx.file, message: {kind: "output-size", value: 0}})
194
+ this.#emitStage(ctx.file, "write", "warning")
195
+
196
+ return false
197
+ }
198
+
199
+ #writeOutput = async ctx => {
200
+ if(ctx.error)
201
+ return ctx
202
+
203
+ try {
204
+ this.#emitStage(ctx.file, "write", "active")
205
+
206
+ const {formatResult: content, output} = ctx
207
+
208
+ await output.write(content)
209
+
210
+ Notify.emit("update-data", {file: ctx.file, message: {kind: "output-size", value: Buffer.byteLength(content)}})
211
+ this.#emitStage(ctx.file, "write", "done")
212
+
213
+ return {...ctx, status: "success", output}
214
+ } catch(error) {
215
+ this.#emitStage(ctx.file, "write", "error")
216
+
217
+ return {...ctx, status: "error", error: Sass.new(`Writing file ${ctx.file}`, error)}
218
+ }
219
+ }
220
+
221
+ // -- Result categorization ------------------------------------------------
222
+
223
+ #categorize(settled, files) {
224
+ const succeeded = []
225
+ const warned = []
226
+ const errored = []
227
+
228
+ for(let i = 0; i < settled.length; i++) {
229
+ const entry = settled[i]
230
+ const file = files[i]
231
+
232
+ if(entry.status === "rejected") {
233
+ errored.push({input: file, error: entry.reason})
234
+ continue
235
+ }
236
+
237
+ const val = entry.value
238
+
239
+ switch(val?.status) {
240
+ case "success":
241
+ succeeded.push({input: file, output: val.output})
242
+ break
243
+ case "warning":
244
+ warned.push({input: file, warning: val.warning})
245
+ break
246
+ case "error":
247
+ errored.push({input: file, error: val.error})
248
+ break
249
+ default:
250
+ errored.push({input: file, error: new Error(`Unknown status: ${val?.status}`)})
251
+ }
252
+ }
253
+
254
+ return {succeeded, errored, warned}
255
+ }
256
+ }