@loomcli/core 0.2.0 → 0.4.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 (51) hide show
  1. package/dist/application.d.ts +59 -34
  2. package/dist/application.js +162 -59
  3. package/dist/chain.d.ts +25 -6
  4. package/dist/chain.js +99 -15
  5. package/dist/command.d.ts +146 -42
  6. package/dist/command.js +642 -78
  7. package/dist/environment.d.ts +22 -0
  8. package/dist/environment.js +1 -0
  9. package/dist/errors.d.ts +31 -59
  10. package/dist/errors.js +49 -106
  11. package/dist/extension.d.ts +80 -20
  12. package/dist/extension.js +115 -30
  13. package/dist/globals.d.ts +14 -25
  14. package/dist/globals.js +10 -61
  15. package/dist/glyphs.generated.d.ts +464 -0
  16. package/dist/glyphs.generated.js +491 -0
  17. package/dist/host.js +2 -1
  18. package/dist/index.d.ts +15 -6
  19. package/dist/index.js +5 -2
  20. package/dist/inspect.d.ts +38 -4
  21. package/dist/inspect.js +69 -6
  22. package/dist/lanes.d.ts +26 -0
  23. package/dist/lanes.js +45 -0
  24. package/dist/output.d.ts +93 -15
  25. package/dist/output.js +307 -34
  26. package/dist/plugin.d.ts +32 -16
  27. package/dist/plugin.js +46 -18
  28. package/dist/rendering.d.ts +21 -0
  29. package/dist/rendering.js +72 -0
  30. package/dist/sequence.d.ts +41 -0
  31. package/dist/sequence.js +225 -0
  32. package/dist/style-ansi.d.ts +13 -0
  33. package/dist/style-ansi.js +306 -0
  34. package/dist/style-layout.d.ts +29 -0
  35. package/dist/style-layout.js +228 -0
  36. package/dist/style-resolve.d.ts +6 -0
  37. package/dist/style-resolve.js +26 -0
  38. package/dist/style-state.d.ts +14 -0
  39. package/dist/style-state.js +179 -0
  40. package/dist/style-wire.d.ts +31 -0
  41. package/dist/style-wire.js +201 -0
  42. package/dist/style.d.ts +86 -0
  43. package/dist/style.js +201 -0
  44. package/dist/theme.d.ts +3 -0
  45. package/dist/theme.js +22 -0
  46. package/dist/types.d.ts +174 -21
  47. package/dist/validation.d.ts +8 -1
  48. package/dist/validation.js +17 -2
  49. package/dist/view.d.ts +180 -0
  50. package/dist/view.js +307 -0
  51. package/package.json +2 -1
package/dist/output.js CHANGED
@@ -1,5 +1,19 @@
1
1
  import { setImmediate } from 'node:timers/promises';
2
- import { FatalError, notTextReason } from './errors.js';
2
+ import { FatalError, notTextReason, ResultError } from './errors.js';
3
+ import { incompleteResult, lanes } from './lanes.js';
4
+ import { capabilities } from './rendering.js';
5
+ import { writeSequence } from './sequence.js';
6
+ import { resolveText, width } from './style-resolve.js';
7
+ import { createStyle, tokens } from './style.js';
8
+ import { resolveRowView, resolveView, shapeOf } from './view.js';
9
+ function stringValue(value) {
10
+ if (typeof value !== 'string') {
11
+ throw new Error(notTextReason(value));
12
+ }
13
+ return value;
14
+ }
15
+ /** The opener a reservation holds until its own gate publishes the real one. */
16
+ const unopened = () => undefined;
3
17
  class Destination {
4
18
  stream;
5
19
  tail = Promise.resolve();
@@ -14,16 +28,44 @@ class Destination {
14
28
  }
15
29
  };
16
30
  write(text) {
17
- const pending = this.tail.then(() => {
18
- if (this.state.kind === 'failed') {
19
- throw this.state.error;
20
- }
21
- return this.writeText(text);
22
- });
31
+ const pending = this.tail.then(() => this.accept(text));
23
32
  // Keep the returned rejection observable, while accounting for calls without await.
24
33
  this.tail = pending.catch(this.onError);
25
34
  return pending;
26
35
  }
36
+ /** One text queued behind whatever preceded it, on a destination that has not failed. */
37
+ accept(text) {
38
+ if (this.state.kind === 'failed') {
39
+ throw this.state.error;
40
+ }
41
+ return this.writeText(text);
42
+ }
43
+ /**
44
+ * One place held in this destination's order from the moment a sequence is issued until it
45
+ * closes. Later calls queue behind the gate, so they write after the sequence's last piece
46
+ * whether or not their caller awaited the sequence, and the gate keeps the destination undrained
47
+ * while the sequence is live, which is what makes a pending sequence open output.
48
+ */
49
+ reserve() {
50
+ const previous = this.tail;
51
+ let open = unopened;
52
+ const gate = new Promise((resolve) => {
53
+ open = resolve;
54
+ });
55
+ this.tail = previous.then(() => gate);
56
+ // The sequence's own pieces queue on each other, ahead of the gate that holds its place.
57
+ let pieces = previous;
58
+ return {
59
+ close: () => {
60
+ open(pieces);
61
+ },
62
+ write: (text) => {
63
+ const pending = pieces.then(() => this.accept(text));
64
+ pieces = pending.catch(this.onError);
65
+ return pending;
66
+ },
67
+ };
68
+ }
27
69
  writeText(text) {
28
70
  return new Promise((resolve, reject) => {
29
71
  if (this.stream.destroyed || this.stream.writableEnded) {
@@ -63,7 +105,18 @@ class Destination {
63
105
  this.stream.off('error', this.onError);
64
106
  }
65
107
  }
66
- /** The text a renderer produced, or the value that stands for its failure to produce text. */
108
+ /** The sentence one view value of the wrong shape reports, which is the fault of its call. */
109
+ function shapeReason(both) {
110
+ return both
111
+ ? 'The view carries render and row. Supply one of the two.'
112
+ : 'The view carries neither render nor row. Supply a view with render or a row view with row.';
113
+ }
114
+ /**
115
+ * No contributor at all. A result's selected view is replaced by view name alone, so the
116
+ * Application's override list never reaches a result's views, which carry no identity.
117
+ */
118
+ const bare = [];
119
+ /** The text a view produced, or the value that stands for its failure to produce text. */
67
120
  function renderText(produce) {
68
121
  try {
69
122
  const text = produce();
@@ -73,50 +126,266 @@ function renderText(produce) {
73
126
  return { failed: error };
74
127
  }
75
128
  }
129
+ /**
130
+ * The data one view reads back through the key that resolved it. A result's views are stored with
131
+ * their data type erased, as the view registry erases a declared view's, so the write site hands
132
+ * each function the value its own declaration checked.
133
+ */
134
+ function erased(value) {
135
+ // Last resort: no typed path exists.
136
+ // A views record holds one entry per view name and carries no type parameter per entry.
137
+ // Every view it stores reads its data as the erased type the registry uses.
138
+ // It holds because the authoring call checked the value against the declaration the record answers.
139
+ // Build proved every entry in that record renders the declared type.
140
+ // oxlint-disable-next-line typescript/no-unsafe-type-assertion
141
+ return value;
142
+ }
76
143
  export class Output {
77
144
  host;
145
+ signal;
78
146
  destinations = new Map();
79
147
  renderFault = undefined;
148
+ // Every fault the output path raised beside its calls, reported after the primary outcome.
149
+ // A source a sequence stopped on and a results-lane fault are both such faults.
150
+ stops = [];
151
+ // The routed Command an incomplete sequence names, published once routing resolved it.
152
+ route = [];
153
+ /**
154
+ * The channel every caller writes through. It is typed with the result left open, because the
155
+ * declaration a call answers to is checked where the action was authored.
156
+ */
80
157
  out;
81
- constructor(host) {
158
+ palette = new Map();
159
+ policy = {};
160
+ // The contributors this invocation resolves a declared view through, published once they build.
161
+ registry = [];
162
+ style = createStyle();
163
+ constructor(host, signal) {
82
164
  this.host = host;
165
+ this.signal = signal;
83
166
  this.out = {
84
- error: (message) => this.emit('error', message),
167
+ error: (message) => this.emit('error', message, 'stderr'),
85
168
  fatal: (message) => {
86
169
  throw new FatalError(message);
87
170
  },
88
- info: (message) => this.emit('info', message),
89
- print: (message) => this.emit('print', message),
90
- render: (data, renderer) => this.rendered(() => renderer.render(data)),
91
- success: (message) => this.emit('success', message),
92
- warn: (message) => this.emit('warn', message),
171
+ info: (message) => this.emit('info', message, 'stderr'),
172
+ print: (message) => this.emit('print', message, 'stdout'),
173
+ // The data type is erased here, as it is in the registry.
174
+ // One call dispatches on the shape of the view it was handed.
175
+ // Every view function reads its data back through its own key.
176
+ render: (data, value) => this.renderValue(data, value, { destination: 'stdout' }),
177
+ // Only the action emits a result, so this call is the middleware fault whatever was declared.
178
+ results: () => this.resultFault('middleware'),
179
+ success: (message) => this.emit('success', message, 'stderr'),
180
+ warn: (message) => this.emit('warn', message, 'stderr'),
181
+ };
182
+ }
183
+ /**
184
+ * The channel one action receives. On a Command that declares a result nothing the action writes
185
+ * but the result reaches stdout: `print` and `render` move to stderr, and they move the view
186
+ * context with the destination, so capability detection follows the stream the bytes reach. The
187
+ * destination is decided here, from the declaration, and never from the view a run selected.
188
+ */
189
+ channel(binding) {
190
+ const destination = binding.result ? 'stderr' : 'stdout';
191
+ const emission = { calls: 0, live: new Set() };
192
+ const target = { destination, live: emission.live };
193
+ return {
194
+ emitted: () => emission.calls > 0,
195
+ out: {
196
+ ...this.out,
197
+ print: (message) => this.emit('print', message, destination),
198
+ // The data type is erased here, as it is in the registry.
199
+ // One call dispatches on the shape of the view it was handed.
200
+ // Every view function reads its data back through its own key.
201
+ render: (data, value) => this.renderValue(data, value, target),
202
+ results: (value) => this.results(binding, emission, value),
203
+ },
204
+ stop: () => {
205
+ for (const stop of emission.live) {
206
+ stop();
207
+ }
208
+ },
93
209
  };
94
210
  }
95
- /** A semantic message is one line on its destination; only `print` writes to stdout. */
96
- emit(kind, message) {
97
- return this.write(kind === 'print' ? this.host.stdout : this.host.stderr, `${message}\n`);
211
+ /**
212
+ * One `out.results` call on the action's channel. The declaration decides the unit, the selected
213
+ * view decides how it renders, and stdout carries the result under either one. The selected
214
+ * view is the one a middleware named before the action dispatched, or the declaration's default
215
+ * when none did. A call the declaration does not answer for is a fault of the lane and writes
216
+ * nothing.
217
+ */
218
+ results(binding, emission, value) {
219
+ const { path, result } = binding;
220
+ if (!result) {
221
+ return this.resultFault('undeclared', path);
222
+ }
223
+ if (emission.calls > 0) {
224
+ return this.resultFault('repeated', path);
225
+ }
226
+ emission.calls += 1;
227
+ const selected = binding.view ?? result.default;
228
+ const view = result.views.get(selected);
229
+ if (!view) {
230
+ // Build proved the default names a view the record holds.
231
+ // The boundary proved a selected name is one too, so this is core's own fault.
232
+ return this.renderFailed(new Error(`The view "${selected}" is not declared.`));
233
+ }
234
+ if (result.kind === 'rows') {
235
+ return this.sequence(erased(value), this.sequenceView(view, bare), {
236
+ destination: 'stdout',
237
+ live: emission.live,
238
+ });
239
+ }
240
+ if (typeof view.row === 'function') {
241
+ // Build rejects a row view on a value result, so reaching one here is core's own fault.
242
+ return this.renderFailed(new Error(`The view "${selected}" renders rows, not a value.`));
243
+ }
244
+ return this.rendered(() => resolveView(bare, view)(erased(value), this.context('stdout')));
245
+ }
246
+ /**
247
+ * One fault of the results lane: the call rejects, and the same failure is reported after this
248
+ * invocation's primary outcome, so a call the action never awaited still turns a would-be 0 into
249
+ * 1 and one the action let propagate is reported once.
250
+ */
251
+ resultFault(kind, path = this.route) {
252
+ const fault = new ResultError(kind, path);
253
+ this.stops.push(fault);
254
+ const rejected = Promise.reject(fault);
255
+ void rejected.catch(() => undefined);
256
+ return rejected;
257
+ }
258
+ /** The registry one invocation resolves through, republished as each contributor is read. */
259
+ useViews(registry) {
260
+ this.registry = registry;
261
+ }
262
+ /** The routed path, published once routing resolved it, which an incomplete sequence names. */
263
+ useRoute(path) {
264
+ this.route = path;
265
+ }
266
+ /** What this invocation's output raised beside its calls, in the order it was raised. */
267
+ get stopped() {
268
+ return this.stops;
269
+ }
270
+ configure(policy, palette) {
271
+ this.policy = policy;
272
+ this.palette = palette;
273
+ this.style = createStyle(new Set([...tokens, ...palette.keys()]));
274
+ }
275
+ context(destination) {
276
+ const caps = capabilities(this.host, destination, this.policy);
277
+ return Object.freeze({
278
+ style: this.style,
279
+ width: (text) => width(text, this.palette, caps),
280
+ });
281
+ }
282
+ /**
283
+ * A semantic message is one line on its destination; only `print` writes to stdout. The message
284
+ * is checked before the lane view runs, and this call appends the one newline after it, so a
285
+ * lane view returns none and an override that returns the empty string still writes one.
286
+ */
287
+ emit(kind, message, destination) {
288
+ return this.rendered(() => {
289
+ if (typeof message !== 'string') {
290
+ throw new TypeError('Output messages must be strings.');
291
+ }
292
+ const render = resolveView(this.registry, lanes[kind]);
293
+ return `${stringValue(render(message, this.context(destination)))}\n`;
294
+ }, destination);
98
295
  }
99
296
  /**
100
297
  * The failure report of one invocation. Like `render`, the text is queued on its destination
101
- * exactly as given: the caller already carries its own trailing newline, whether that text came
102
- * from a registered renderer or from core's own default text.
298
+ * after style resolution: the caller already carries its own trailing newline, whether that text
299
+ * came from a resolved view or from core's own default text.
103
300
  */
104
301
  report(text) {
105
- return this.write(this.host.stderr, text);
302
+ return this.rendered(() => text, 'stderr');
106
303
  }
107
304
  /**
108
- * The renderer owns every byte, so its text is queued on stdout exactly as returned. A throw or
109
- * a non-string return rejects this call alone: nothing is written for it, later output still
110
- * writes, and the recorded cause ends the invocation once the action has completed.
305
+ * One `out.render` call, dispatched on the shape of the view it was handed. The two shapes are
306
+ * exclusive, so a JavaScript author's value that carries both, or neither, is the output-view
307
+ * fault of this call and nothing is written for it.
111
308
  */
112
- rendered(produce) {
113
- const rendered = renderText(produce);
114
- return 'text' in rendered
115
- ? this.write(this.host.stdout, rendered.text)
116
- : this.renderFailed(rendered.failed);
309
+ renderValue(data, value, target) {
310
+ const shape = shapeOf(value);
311
+ if (shape === 'both' || shape === 'neither') {
312
+ return this.renderFailed(new Error(shapeReason(shape === 'both')));
313
+ }
314
+ // The shape was named above, so this second read narrows the value rather than deciding it.
315
+ if (typeof value.render === 'function') {
316
+ return this.rendered(() => resolveView(this.registry, value)(data, this.context(target.destination)), target.destination);
317
+ }
318
+ return this.sequence(data, this.sequenceView(value, this.registry), target);
319
+ }
320
+ /**
321
+ * The view one sequence writes through, in the shape its own view carries. The caller supplies
322
+ * the contributors the view resolves through: `out.render`'s call-site view resolves through
323
+ * this invocation's registry, as ADR-0021 requires, and a result's view resolves through none,
324
+ * because a result's selected view is replaced by view name alone.
325
+ */
326
+ sequenceView(value, registry) {
327
+ if (typeof value.row === 'function') {
328
+ return { kind: 'rows', view: resolveRowView(registry, value) };
329
+ }
330
+ const render = resolveView(registry, value);
331
+ return { kind: 'whole', render: (rows, context) => render(erased(rows), context) };
117
332
  }
118
333
  /**
119
- * The rejected call. The first renderer failure is the reported one, so a later one adds no
334
+ * One sequence, which holds its place on the destination from here until its last piece is
335
+ * written. The returned rejection is observed here as well, because an action that never awaits
336
+ * the call must not end the process with an unhandled rejection.
337
+ */
338
+ sequence(source, view, target) {
339
+ const { destination } = target;
340
+ const place = this.destination(this.host[destination]).reserve();
341
+ const sequence = writeSequence({
342
+ close: place.close,
343
+ context: this.context(destination),
344
+ incomplete: (facts) => {
345
+ this.incomplete(facts);
346
+ },
347
+ path: this.route,
348
+ piece: (produce) => this.rendered(produce, destination, place.write),
349
+ signal: this.signal,
350
+ source,
351
+ stopped: (cause) => {
352
+ this.stops.push(cause);
353
+ },
354
+ view,
355
+ });
356
+ // A sequence its own channel can stop, so an action's failure ends it rather than draining it.
357
+ target.live?.add(sequence.stop);
358
+ void sequence.pending.catch(() => undefined);
359
+ return sequence.pending;
360
+ }
361
+ /**
362
+ * The line one incomplete sequence writes on stderr, ahead of the fault's own report. It resolves
363
+ * through the registry like any other rendered output, so an override that returns the empty
364
+ * string silences it and one that throws is a view fault. A stderr that has failed already takes
365
+ * the plain fallback path and no further.
366
+ */
367
+ incomplete(facts) {
368
+ const context = this.context('stderr');
369
+ if (this.destinations.get(this.host.stderr)?.state.kind === 'failed') {
370
+ void reportPlainly(this.host.stderr, incompleteResult.render(facts, context));
371
+ return;
372
+ }
373
+ void this.rendered(() => resolveView(this.registry, incompleteResult)(facts, context), 'stderr').catch(() => undefined);
374
+ }
375
+ /**
376
+ * The write site owns its newline; core resolves the view's marked text before queuing it. A
377
+ * throw or a non-string return rejects this call alone: nothing is written for it, later output
378
+ * still writes, and the recorded cause ends the invocation once the action has completed.
379
+ */
380
+ rendered(produce, destination = 'stdout', write) {
381
+ const rendered = renderText(() => resolveText(stringValue(produce()), this.palette, capabilities(this.host, destination, this.policy)));
382
+ if (!('text' in rendered)) {
383
+ return this.renderFailed(rendered.failed);
384
+ }
385
+ return write ? write(rendered.text) : this.write(this.host[destination], rendered.text);
386
+ }
387
+ /**
388
+ * The rejected call. The first view failure is the reported one, so a later one adds no
120
389
  * second diagnostic, and the rejection is observed here as well, because an action that never
121
390
  * awaits the call must not end the process with an unhandled rejection.
122
391
  */
@@ -126,17 +395,21 @@ export class Output {
126
395
  void rejection.catch(() => undefined);
127
396
  return rejection;
128
397
  }
129
- /** What a renderer failed with during this invocation, if one did. */
398
+ /** What a view failed with during this invocation, if one did. */
130
399
  get fault() {
131
400
  return this.renderFault;
132
401
  }
133
- write(stream, text) {
402
+ /** The queue one stream writes through, opened the first time this invocation reaches it. */
403
+ destination(stream) {
134
404
  let destination = this.destinations.get(stream);
135
405
  if (!destination) {
136
406
  destination = new Destination(stream);
137
407
  this.destinations.set(stream, destination);
138
408
  }
139
- return destination.write(text);
409
+ return destination;
410
+ }
411
+ write(stream, text) {
412
+ return this.destination(stream).write(text);
140
413
  }
141
414
  async settle() {
142
415
  let drained = false;
@@ -167,7 +440,7 @@ export class Output {
167
440
  }
168
441
  /**
169
442
  * The plain fallback path: a fresh destination on stderr, outside the invocation's queues and
170
- * outside every registration, so no application code runs on it. The caller composes the newlines
443
+ * outside every override, so no application code runs on it. The caller composes the newlines
171
444
  * between whatever it is reporting, then passes the one string this writes verbatim. A failed
172
445
  * write ends reporting.
173
446
  */
package/dist/plugin.d.ts CHANGED
@@ -1,9 +1,11 @@
1
1
  import type { MiddlewareContext } from './chain.js';
2
- import type { FailureRenderer } from './errors.js';
3
2
  import type { AnyExtension, DescriptorRegistry, ExtensionRecords } from './extension.js';
4
3
  import type { ProcessSignal } from './signals.js';
5
- import type { OptionValue, PluginOptionConfig } from './types.js';
4
+ import type { Palette } from './style-state.js';
5
+ import type { ThemeConstraint, ThemeMapping } from './style.js';
6
+ import type { CommandAttachHook, OptionValue, PluginOptionConfig } from './types.js';
6
7
  import type { OptionInput } from './validation.js';
8
+ import type { ViewContribution } from './view.js';
7
9
  /**
8
10
  * The declaration record a plugin contributes its options under: the parsing part of an option
9
11
  * config, keyed by option name. A plugin option carries no schema and no presence rule, so the
@@ -16,19 +18,22 @@ type PluginOptionValues<Options extends PluginOptions> = {
16
18
  };
17
19
  /** Phantom key. It carries a plugin's declared options in a read position and holds no value. */
18
20
  declare const pluginOptions: unique symbol;
21
+ declare const pluginTheme: unique symbol;
19
22
  /**
20
23
  * One plugin's declarations as the registry holds them, with the generic parts erased. Build reads
21
24
  * every one of them defensively, because a JavaScript author reaches the same slots, so the erased
22
25
  * shape is what the rules below read and no declaration is claimed to be well formed here.
23
26
  */
24
27
  interface DeclaredPlugin {
28
+ theme?: unknown;
25
29
  options?: PluginOptions;
26
30
  middleware?: {
27
31
  activate?: unknown;
28
32
  load?: unknown;
29
33
  };
34
+ onCommandAttach?: unknown;
30
35
  extensions?: readonly AnyExtension[];
31
- failures?: readonly FailureRenderer[];
36
+ views?: unknown;
32
37
  signals?: unknown;
33
38
  }
34
39
  /** The declarations behind one plugin value, read by this package alone. */
@@ -38,10 +43,11 @@ interface PluginNode {
38
43
  }
39
44
  /**
40
45
  * The runtime value `plugin()` returns. `Options` appears in a read position alone, which makes it
41
- * covariant: a `plugins` list holds plugins with different options the way `failures` holds
42
- * renderers for different classes, and `Middleware` and `load` accept a narrower plugin.
46
+ * covariant: a `plugins` list holds plugins with different options the way `views` holds
47
+ * overrides for different keys, and `Middleware` and `load` accept a narrower plugin.
43
48
  */
44
- declare class PluginDeclaration<Options extends PluginOptions> {
49
+ declare class PluginDeclaration<Options extends PluginOptions, Theme extends ThemeMapping> {
50
+ readonly [pluginTheme]: Theme;
45
51
  readonly [pluginOptions]: () => Options;
46
52
  constructor(node: PluginNode);
47
53
  }
@@ -49,13 +55,17 @@ declare class PluginDeclaration<Options extends PluginOptions> {
49
55
  * One plugin, as the opaque value `plugin()` returns. The declarations behind it stay private to
50
56
  * this package, so no consumer can read or replace them.
51
57
  */
52
- type Plugin<Options extends PluginOptions = PluginOptions> = Pick<PluginDeclaration<Options>, typeof pluginOptions>;
58
+ type Plugin<Options extends PluginOptions = PluginOptions, Theme extends ThemeMapping = {}> = Pick<PluginDeclaration<Options, Theme>, typeof pluginOptions | typeof pluginTheme>;
53
59
  /** The declared options of a plugin, or of the factory that returns one. */
54
60
  type OptionsOf<Contributor> = Contributor extends Plugin<infer Options> ? Options : Contributor extends (...args: never[]) => Plugin<infer Options> ? Options : PluginOptions;
55
61
  /** A middleware reads its own plugin's options and either takes over or continues the chain. */
56
62
  type Middleware<Contributor extends Plugin | ((...args: never[]) => Plugin)> = (context: MiddlewareContext<OptionsOf<Contributor>>) => Promise<void> | void;
57
- /** Everything a plugin declares. It holds declarations alone and performs no work. */
58
- interface PluginDefinition<Options extends PluginOptions = PluginOptions> {
63
+ /**
64
+ * Everything a plugin declares. Creating and installing the value runs none of its code: a hook
65
+ * runs at graph build, and the middleware runs inside an invocation.
66
+ */
67
+ interface PluginDefinition<Options extends PluginOptions = PluginOptions, Theme extends ThemeMapping = ThemeMapping> {
68
+ theme?: Theme & ThemeConstraint<Theme>;
59
69
  options?: Options;
60
70
  middleware?: {
61
71
  activate: 'always' | readonly (keyof Options & string)[];
@@ -63,16 +73,17 @@ interface PluginDefinition<Options extends PluginOptions = PluginOptions> {
63
73
  default: Middleware<Plugin<Options>>;
64
74
  }>;
65
75
  };
76
+ onCommandAttach?: CommandAttachHook;
66
77
  extensions?: readonly AnyExtension[];
67
- failures?: readonly FailureRenderer[];
78
+ views?: readonly ViewContribution[];
68
79
  signals?: readonly ('SIGINT' | 'SIGTERM')[];
69
80
  }
70
81
  /**
71
- * One plugin: an identity and the declarations it contributes. The value performs no work when it
72
- * is created and none when it is installed, so an installed plugin an invocation never reaches
73
- * costs that invocation nothing.
82
+ * One plugin: an identity and the contributions it carries. Creating and installing the value runs
83
+ * none of its code: a hook runs at graph build, and the middleware runs inside an invocation, so an
84
+ * installed plugin an invocation never reaches costs that invocation its hooks alone.
74
85
  */
75
- declare function plugin<Options extends PluginOptions = PluginOptions>(identity: string, definition: PluginDefinition<Options>): Plugin<Options>;
86
+ declare function plugin<Options extends PluginOptions = {}, const Theme extends ThemeMapping = {}>(identity: string, definition: PluginDefinition<Options, Theme>): Plugin<NoInfer<Options>, NoInfer<Theme>>;
76
87
  /** One installed plugin, with the declarations build reads out of it in installation order. */
77
88
  interface InstalledPlugin {
78
89
  declaration: DeclaredPlugin;
@@ -93,7 +104,11 @@ interface BuiltMiddleware {
93
104
  }
94
105
  /** One installed plugin's declarations, read once per build in installation order. */
95
106
  interface BuiltPlugin {
96
- failures: readonly FailureRenderer[];
107
+ theme: Palette | undefined;
108
+ /** The hook core calls once per Command at graph build, or nothing where none is declared. */
109
+ onCommandAttach: CommandAttachHook | undefined;
110
+ /** The plugin's own `views` slot, read once the validated theme is in place. */
111
+ views: unknown;
97
112
  identity: string;
98
113
  inputs: readonly OptionInput[];
99
114
  middleware: BuiltMiddleware | undefined;
@@ -112,5 +127,6 @@ interface PluginBuild {
112
127
  declare function buildPlugins(installed: readonly InstalledPlugin[], build: PluginBuild): readonly BuiltPlugin[];
113
128
  /** The signals the one slot owner claimed, or none when no installed plugin claims the slot. */
114
129
  declare function ownedSignals(plugins: readonly BuiltPlugin[]): readonly ProcessSignal[];
115
- export type { BuiltPlugin, InstalledPlugin, Middleware, OptionsOf, Plugin, PluginBuild, PluginDefinition, PluginOptions, PluginOptionValues, };
130
+ type ThemeOf<Contributor> = [Contributor] extends [never] ? {} : Contributor extends Plugin<PluginOptions, infer Theme> ? Theme : {};
131
+ export type { ThemeOf, BuiltPlugin, InstalledPlugin, Middleware, OptionsOf, Plugin, PluginBuild, PluginDefinition, PluginOptions, PluginOptionValues, };
116
132
  export { buildPlugins, installPlugins, ownedSignals, plugin, pluginSentence };
package/dist/plugin.js CHANGED
@@ -2,13 +2,14 @@ import { DeclarationError } from './errors.js';
2
2
  import { buildExtensions, isDescriptor, registerDescriptor } from './extension.js';
3
3
  import { checkDeprecated, checkDescription, checkHidden, isPlainObject } from './facts.js';
4
4
  import { isProcessSignal } from './signals.js';
5
+ import { buildTheme } from './theme.js';
5
6
  import { captureConfig, checkDeclarations } from './validation.js';
6
7
  /** Authored values register here, so the public type publishes no state to reach or replace. */
7
8
  const nodes = new WeakMap();
8
9
  /**
9
10
  * The runtime value `plugin()` returns. `Options` appears in a read position alone, which makes it
10
- * covariant: a `plugins` list holds plugins with different options the way `failures` holds
11
- * renderers for different classes, and `Middleware` and `load` accept a narrower plugin.
11
+ * covariant: a `plugins` list holds plugins with different options the way `views` holds
12
+ * overrides for different keys, and `Middleware` and `load` accept a narrower plugin.
12
13
  */
13
14
  class PluginDeclaration {
14
15
  constructor(node) {
@@ -17,12 +18,21 @@ class PluginDeclaration {
17
18
  }
18
19
  }
19
20
  /**
20
- * One plugin: an identity and the declarations it contributes. The value performs no work when it
21
- * is created and none when it is installed, so an installed plugin an invocation never reaches
22
- * costs that invocation nothing.
21
+ * One plugin: an identity and the contributions it carries. Creating and installing the value runs
22
+ * none of its code: a hook runs at graph build, and the middleware runs inside an invocation, so an
23
+ * installed plugin an invocation never reaches costs that invocation its hooks alone.
23
24
  */
24
25
  function plugin(identity, definition) {
25
- return new PluginDeclaration({ definition, identity });
26
+ const captured = {
27
+ ...definition,
28
+ ...(definition?.theme === undefined
29
+ ? {}
30
+ : { theme: isPlainObject(definition.theme) ? { ...definition.theme } : definition.theme }),
31
+ };
32
+ return new PluginDeclaration({
33
+ definition: isPlainObject(definition) ? captured : definition,
34
+ identity,
35
+ });
26
36
  }
27
37
  /** How every plugin diagnostic names one plugin at the start of a sentence. */
28
38
  function pluginSentence(identity) {
@@ -54,7 +64,7 @@ function readIdentity(node, installed) {
54
64
  function definitionOf(identity, node) {
55
65
  const { definition } = node;
56
66
  if (!isPlainObject(definition)) {
57
- throw new DeclarationError(`${pluginSentence(identity)} declares a definition that is not an object. Supply { options, middleware, extensions, failures }.`);
67
+ throw new DeclarationError(`${pluginSentence(identity)} declares a definition that is not an object. Supply { options, middleware, extensions, views }.`);
58
68
  }
59
69
  return definition;
60
70
  }
@@ -189,6 +199,23 @@ function readSignals(identity, declared) {
189
199
  }
190
200
  return [...claimed];
191
201
  }
202
+ /**
203
+ * A lifecycle hook is a function core calls at one named point, so being callable is the whole
204
+ * claim this check makes; every rule the calls it makes carry belongs to the build that calls it.
205
+ */
206
+ function isHook(value) {
207
+ return typeof value === 'function';
208
+ }
209
+ /** One plugin's `onCommandAttach` hook, or `undefined` for a plugin that declares none. */
210
+ function readHook(identity, declared) {
211
+ if (declared === undefined) {
212
+ return undefined;
213
+ }
214
+ if (!isHook(declared)) {
215
+ throw new DeclarationError(`${pluginSentence(identity)} declares onCommandAttach that is not a function. Supply a function of the Command.`);
216
+ }
217
+ return declared;
218
+ }
192
219
  /** A plugin's own list names the extensions it defines, before any declaration carries one. */
193
220
  function defineExtensions(identity, declaration, build) {
194
221
  const { extensions } = declaration;
@@ -202,16 +229,6 @@ function defineExtensions(identity, declaration, build) {
202
229
  registerDescriptor(build.descriptors, descriptor);
203
230
  }
204
231
  }
205
- /** One plugin's failure registrations, which are a list before any of them is read. */
206
- function readFailures(identity, declared) {
207
- if (declared === undefined) {
208
- return [];
209
- }
210
- if (!Array.isArray(declared)) {
211
- throw new DeclarationError(`${pluginSentence(identity)} declares failures that are not an array. Supply a list of renderFailure values.`);
212
- }
213
- return declared;
214
- }
215
232
  /**
216
233
  * Every installed plugin's declarations, in installation order. A plugin's own extensions register
217
234
  * before any declaration carries a value, so a duplicated package copy is reported from the list
@@ -223,7 +240,16 @@ function buildPlugins(installed, build) {
223
240
  * diagnostic. An empty claim leaves the slot free.
224
241
  */
225
242
  let owner = undefined;
243
+ let themeOwner = undefined;
226
244
  return installed.map(({ declaration, identity }) => {
245
+ let theme = undefined;
246
+ if (declaration.theme !== undefined) {
247
+ if (themeOwner !== undefined) {
248
+ throw new DeclarationError(`${pluginSentence(identity)} claims the theme slot, which plugin "${themeOwner}" already holds. Install one owner.`);
249
+ }
250
+ theme = buildTheme(declaration.theme, identity);
251
+ themeOwner = identity;
252
+ }
227
253
  defineExtensions(identity, declaration, build);
228
254
  const inputs = readOptions(identity, declaration.options, build);
229
255
  const names = new Set(inputs.map((input) => input.name));
@@ -235,11 +261,13 @@ function buildPlugins(installed, build) {
235
261
  owner = identity;
236
262
  }
237
263
  return {
238
- failures: readFailures(identity, declaration.failures),
239
264
  identity,
240
265
  inputs,
241
266
  middleware: readMiddleware(identity, declaration.middleware, names),
267
+ onCommandAttach: readHook(identity, declaration.onCommandAttach),
242
268
  signals,
269
+ theme,
270
+ views: declaration.views,
243
271
  };
244
272
  });
245
273
  }