@loomcli/core 0.6.0 → 0.7.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.
package/dist/plugin.d.ts CHANGED
@@ -114,8 +114,8 @@ interface PluginDefinition<Options extends PluginOptions = PluginOptions, Theme
114
114
  * One plugin: an identity and the contributions it carries. Creating and installing the value runs
115
115
  * none of its code: `onCommandAttach` runs at graph build, `onFailure` runs when `run()` renders a
116
116
  * failure, and the middleware runs inside an invocation, so an installed plugin an invocation never
117
- * reaches costs that invocation its hooks alone. Every rule
118
- * that one definition carries on its own throws here, before the value exists.
117
+ * reaches costs that invocation its hooks alone. The definition is read once, after the identity,
118
+ * and every rule that one definition carries on its own throws here, before the value exists.
119
119
  */
120
120
  declare function plugin<Options extends PluginOptions = {}, const Theme extends ThemeMapping = {}>(identity: string, definition: PluginDefinition<Options, Theme>): Plugin<NoInfer<Options>, NoInfer<Theme>>;
121
121
  /** How every plugin diagnostic names one plugin at the start of a sentence. */
@@ -130,9 +130,9 @@ interface InstalledPlugins {
130
130
  /**
131
131
  * The installed list in composition order, with every rule that reads two plugins together: an
132
132
  * identity installed twice, a second claim on the theme slot, the signals slot, or the
133
- * configuration source, and two distinct descriptors under one identity. The slot is read
134
- * defensively, because a JavaScript author reaches it with any value. Each plugin's own rules
135
- * already ran at its `plugin()` call.
133
+ * configuration source, and two distinct descriptors under one identity. The slot is the copy the
134
+ * Application's constructor took, which a JavaScript author fills with any value. Each plugin's own
135
+ * rules already ran at its `plugin()` call.
136
136
  */
137
137
  declare function installPlugins(application: string, plugins: unknown): InstalledPlugins;
138
138
  /**
@@ -164,7 +164,7 @@ interface BuiltPlugin {
164
164
  onCommandAttach: CommandAttachHook | undefined;
165
165
  /** The hook core calls for each failure `run()` renders after graph build, or nothing. */
166
166
  onFailure: FailureHook | undefined;
167
- /** The plugin's own `views` slot, read once the validated theme is in place. */
167
+ /** The copy of the plugin's own `views` list, which the Application reads again. */
168
168
  views: unknown;
169
169
  /** The translations the plugin registers, which resolve after the application's. */
170
170
  translators: TranslationContributor;
package/dist/plugin.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { checkEnvBinding, claimVariables } from './bindings.js';
2
+ import { captureDeclaration, elidedRead, unreadableArgument, unreadableFault } from './capture.js';
2
3
  import { notACommand } from './command-rules.js';
3
4
  import { attach, commandCode, commandNode } from './command.js';
4
5
  import { elided, quoteString, spelled } from './diagnostic-text.js';
@@ -9,14 +10,16 @@ import { boundOptions, pluginSites } from './globals.js';
9
10
  import { checkIdentity } from './identity.js';
10
11
  import { coreViews } from './lanes.js';
11
12
  import { booleanValue, compileOptions } from './options.js';
12
- import { isPlainObject } from './plain.js';
13
+ import { copyOwnKeys, decidePlain, declaring, isPlainObject, shallowList, shallowRecord, } from './plain.js';
13
14
  import { foreignValue, middlewareActivation, notAFunction, notAList, notAnObject, pluginInstalledTwice, pluginOptionRule, signalClaimedTwice, slotTaken, sourceBinding, sourceBoundOwnOption, unknownSignal, } from './plugin-rules.js';
14
15
  import { pluginLoaderFailed } from './rules.js';
15
16
  import { isProcessSignal } from './signals.js';
16
17
  import { buildTheme } from './theme.js';
17
18
  import { readTranslations } from './translators.js';
18
- import { captureConfig, checkDeclarations } from './validation.js';
19
+ import { captureConfig, checkDeclarations, defaultDepthFault } from './validation.js';
19
20
  import { buildViews, viewIdentities } from './view.js';
21
+ /** The slots of a definition that hold a list, each copied with its entries as they were built. */
22
+ const definitionLists = ['extensions', 'signals', 'commands', 'views', 'translators'];
20
23
  /** Authored values register here, so the public type publishes no state to reach or replace. */
21
24
  const nodes = new WeakMap();
22
25
  /**
@@ -34,17 +37,47 @@ class PluginDeclaration {
34
37
  * One plugin: an identity and the contributions it carries. Creating and installing the value runs
35
38
  * none of its code: `onCommandAttach` runs at graph build, `onFailure` runs when `run()` renders a
36
39
  * failure, and the middleware runs inside an invocation, so an installed plugin an invocation never
37
- * reaches costs that invocation its hooks alone. Every rule
38
- * that one definition carries on its own throws here, before the value exists.
40
+ * reaches costs that invocation its hooks alone. The definition is read once, after the identity,
41
+ * and every rule that one definition carries on its own throws here, before the value exists.
39
42
  */
40
43
  function plugin(identity, definition) {
41
- const captured = {
42
- ...definition,
43
- ...(definition?.theme === undefined
44
- ? {}
45
- : { theme: isPlainObject(definition.theme) ? { ...definition.theme } : definition.theme }),
46
- };
47
- return new PluginDeclaration(readPlugin(identity, isPlainObject(definition) ? captured : definition));
44
+ return new PluginDeclaration(declaring(() => readPlugin(identity, definition)));
45
+ }
46
+ /**
47
+ * The one copy of a definition that `plugin()` reads: the definition, its theme mapping, options
48
+ * record, middleware with its activation list, and source, and each list it holds. An entry of a
49
+ * list is what its factory built and is not copied, and each option's config is captured where its
50
+ * own rules read it, so a fault about it names the option. A read that throws is the unreadable
51
+ * fault of the definition, and a definition that is not a plain object is the not-an-object fault.
52
+ */
53
+ function captureDefinition(identity, definition) {
54
+ return captureDeclaration(definition, (copy, read) => {
55
+ read.nested(copy, 'theme', shallowRecord);
56
+ read.nested(copy, 'options', shallowRecord);
57
+ read.nested(copy, 'middleware', copyMiddleware);
58
+ read.nested(copy, 'source', shallowRecord);
59
+ for (const list of definitionLists) {
60
+ read.nested(copy, list, shallowList);
61
+ }
62
+ }, {
63
+ notAnObject: () => new DeclarationError(notAnObject, {
64
+ correction: 'Supply { options, middleware, extensions, views }.',
65
+ findings: [{ arguments: [identity, definition], call: 'plugin', mark: '1' }],
66
+ sentence: `${pluginSentence(identity)} declares a definition that is not an object.`,
67
+ }),
68
+ unreadable: unreadableArgument({ call: 'plugin', named: identity, subject: pluginSentence(identity) }, 'definition'),
69
+ });
70
+ }
71
+ /** A middleware object's copy, its activation list copied too, or any other value as declared. */
72
+ function copyMiddleware(middleware) {
73
+ if (!decidePlain(middleware)) {
74
+ return middleware;
75
+ }
76
+ const copy = copyOwnKeys(middleware);
77
+ if (Object.hasOwn(copy, 'activate')) {
78
+ copy.activate = shallowList(copy.activate);
79
+ }
80
+ return copy;
48
81
  }
49
82
  /** How every plugin diagnostic names one plugin at the start of a sentence. */
50
83
  function pluginSentence(identity) {
@@ -71,17 +104,6 @@ function entryCode(value) {
71
104
  const node = nodeOf(value);
72
105
  return node ? spelled(`plugin(${quoteString(node.identity)}, ${elided})`) : value;
73
106
  }
74
- /** The declarations one plugin value carries, which a JavaScript author reaches as any value. */
75
- function definitionOf(identity, definition) {
76
- if (!isPlainObject(definition)) {
77
- throw new DeclarationError(notAnObject, {
78
- correction: 'Supply { options, middleware, extensions, views }.',
79
- findings: [{ arguments: [identity, definition], call: 'plugin', mark: '1' }],
80
- sentence: `${pluginSentence(identity)} declares a definition that is not an object.`,
81
- });
82
- }
83
- return definition;
84
- }
85
107
  /** How a second claim on one slot reads: its clause, the note on the owner's entry, and the fix. */
86
108
  const slotWords = {
87
109
  signals: {
@@ -116,9 +138,9 @@ function slotFault(site, slot, claim) {
116
138
  /**
117
139
  * The installed list in composition order, with every rule that reads two plugins together: an
118
140
  * identity installed twice, a second claim on the theme slot, the signals slot, or the
119
- * configuration source, and two distinct descriptors under one identity. The slot is read
120
- * defensively, because a JavaScript author reaches it with any value. Each plugin's own rules
121
- * already ran at its `plugin()` call.
141
+ * configuration source, and two distinct descriptors under one identity. The slot is the copy the
142
+ * Application's constructor took, which a JavaScript author fills with any value. Each plugin's own
143
+ * rules already ran at its `plugin()` call.
122
144
  */
123
145
  function installPlugins(application, plugins) {
124
146
  const printed = Array.isArray(plugins) ? Array.from(plugins, entryCode) : plugins;
@@ -188,16 +210,32 @@ function installPlugins(application, plugins) {
188
210
  }
189
211
  /** The keys a plugin option may not declare, in the order its diagnostic names them. */
190
212
  const forbidden = ['validate', 'validateOmitted', 'required'];
191
- /** The rules a plugin option answers before every rule an ordinary declaration carries. */
192
- function checkPluginOption(site, config) {
193
- const sentence = site.subject;
194
- if (!isPlainObject(config)) {
195
- throw new DeclarationError(notAnObject, {
213
+ /**
214
+ * The rules a plugin option answers before every rule an ordinary declaration carries, read from
215
+ * the one copy of its config that `captureConfig` takes, which it answers with for every later read.
216
+ * `siteOf` places the option with the value its entry prints as: the config as declared for a value
217
+ * that is not one, elided for a config whose read threw, and the copy for every later rule.
218
+ */
219
+ function checkPluginOption(siteOf, declared) {
220
+ const sentence = siteOf(declared).subject;
221
+ const config = captureConfig(declared, {
222
+ notAnObject: () => new DeclarationError(notAnObject, {
196
223
  correction: 'Supply { type, ... }.',
197
- findings: [partFinding(site, [])],
224
+ findings: [partFinding(siteOf(declared), [])],
198
225
  sentence: `${sentence} is not an option declaration.`,
199
- });
200
- }
226
+ }),
227
+ // The default's walk stopped at the limit, so the finding prints it elided.
228
+ tooDeep: () => {
229
+ const { keys, shown } = elidedRead('default');
230
+ return defaultDepthFault(sentence, [partFinding(siteOf(shown), keys)]);
231
+ },
232
+ // A part whose read threw is never read again, so the finding prints it elided.
233
+ unreadable: (thrown, slot) => {
234
+ const { keys, shown } = elidedRead(slot);
235
+ return unreadableFault({ declared: 'config', findings: [partFinding(siteOf(shown), keys)], subject: sentence }, thrown);
236
+ },
237
+ });
238
+ const site = siteOf(config);
201
239
  const rejected = forbidden.find((key) => key in config);
202
240
  if (rejected !== undefined) {
203
241
  throw factFault(pluginOptionRule, site, {
@@ -209,6 +247,7 @@ function checkPluginOption(site, config) {
209
247
  checkDescription(site, config.description);
210
248
  checkHidden(site, config.hidden);
211
249
  checkDeprecated(site, config.deprecated);
250
+ return config;
212
251
  }
213
252
  /**
214
253
  * One plugin's option declarations, in declaration order. They join the globals table, so the rules
@@ -223,12 +262,23 @@ function readOptions(identity, declared, build) {
223
262
  });
224
263
  }
225
264
  const inputs = [];
226
- for (const [name, config] of Object.entries(declared ?? {})) {
265
+ // A finding prints each option's captured config once it is taken, and each later one elided.
266
+ const shown = Object.fromEntries(Object.keys(declared ?? {}).map((name) => [name, spelled(elided)]));
267
+ for (const [name, entry] of Object.entries(declared ?? {})) {
227
268
  const sentence = `${pluginSentence(identity)} option ${quoted(name)}`;
228
- const site = pluginOptionSite({ identity, options: declared }, name, sentence);
229
- checkPluginOption(site, config);
269
+ // A computed key defines its own property even for `__proto__`, where assignment would not.
270
+ const siteOf = (value) => pluginOptionSite({ identity, options: { ...shown, [name]: value } }, name, sentence);
271
+ const config = checkPluginOption(siteOf, entry);
272
+ // Defining the key keeps its place, and holds even for a key of `__proto__`.
273
+ Object.defineProperty(shown, name, {
274
+ configurable: true,
275
+ enumerable: true,
276
+ value: config,
277
+ writable: true,
278
+ });
279
+ const site = siteOf(config);
230
280
  checkEnvBinding(site, config);
231
- const input = { config: captureConfig(config), kind: 'option', name };
281
+ const input = { config, kind: 'option', name };
232
282
  // The shared rules name the plugin and the option, so a fault reads with its contributor.
233
283
  checkDeclarations([{ input, site }], sentence);
234
284
  build.records.set(input, buildExtensions({
@@ -247,7 +297,7 @@ function readOptions(identity, declared, build) {
247
297
  const siteOf = pluginSites(identity, inputs);
248
298
  compileOptions(inputs, { siteOf, subject: `plugin ${quoted(identity)}` });
249
299
  claimVariables(boundOptions(inputs, (input) => ({
250
- phrase: `plugin ${quoted(identity)} option "${input.name}"`,
300
+ phrase: `plugin ${quoted(identity)} option ${quoted(input.name)}`,
251
301
  site: siteOf(input),
252
302
  })));
253
303
  return inputs;
@@ -532,7 +582,8 @@ function readSource(identity, declaration, own) {
532
582
  }
533
583
  const carrier = own.inputs.find((input) => Object.hasOwn(own.build.records.get(input) ?? {}, bindingIdentity));
534
584
  if (carrier) {
535
- const option = pluginOptionSite({ identity, options: declaration.options }, carrier.name, `${sentence} option ${quoted(carrier.name)}`);
585
+ // The record prints each option's captured config, which is what its rules read.
586
+ const option = pluginSites(identity, own.inputs)(carrier);
536
587
  throw new DeclarationError(sourceBoundOwnOption, {
537
588
  correction: "Remove the value; the source's own options resolve before it loads.",
538
589
  findings: [partFinding(option, ['extensions'])],
@@ -552,7 +603,8 @@ function defineExtensions(identity, declaration, build) {
552
603
  sentence: `${pluginSentence(identity)} declares extensions that are not an array.`,
553
604
  });
554
605
  }
555
- for (const [index, descriptor] of (extensions ?? []).entries()) {
606
+ const list = Array.isArray(extensions) ? extensions : [];
607
+ for (const [index, descriptor] of list.entries()) {
556
608
  if (!isDescriptor(descriptor)) {
557
609
  throw new DeclarationError(foreignValue, {
558
610
  correction: 'Supply the value returned by extension(identity, config).',
@@ -567,14 +619,15 @@ function defineExtensions(identity, declaration, build) {
567
619
  }
568
620
  }
569
621
  /**
570
- * Every rule one definition carries on its own, in the order the definition's slots are read. The
571
- * plugin's own extensions register before any declaration carries a value, so a duplicated package
572
- * copy is reported from the list that defines it. The views list is read against core's view
573
- * identities, and the Application reads it again against every other contributor's.
622
+ * Every rule one definition carries on its own, in the order the definition's slots are read, each
623
+ * from the one copy `captureDefinition` takes once the identity is judged. The plugin's own
624
+ * extensions register before any declaration carries a value, so a duplicated package copy is
625
+ * reported from the list that defines it. The views list is read against core's view identities,
626
+ * and the Application reads the same copy again against every other contributor's.
574
627
  */
575
628
  function readPlugin(named, definition) {
576
629
  checkIdentity('plugin', named);
577
- const declaration = definitionOf(named, definition);
630
+ const declaration = captureDefinition(named, definition);
578
631
  const theme = declaration.theme === undefined ? undefined : buildTheme(declaration.theme, named);
579
632
  const build = { descriptors: new Map(), records: new Map() };
580
633
  defineExtensions(named, declaration, build);
package/dist/sources.js CHANGED
@@ -1,4 +1,4 @@
1
- import { asSentence, foreignFailure, InternalError, LoomError, reasonOf } from './errors.js';
1
+ import { asSentence, foreignFailure, InternalError, LoomError, quoted, reasonOf, } from './errors.js';
2
2
  import { isProseLine } from './facts.js';
3
3
  import { isSupplied } from './options.js';
4
4
  import { isPlainObject } from './plain.js';
@@ -100,12 +100,12 @@ function readAnswer(sentence, input, answer) {
100
100
  const shape = isPlainObject(answer) ? answer : undefined;
101
101
  const label = shape?.label;
102
102
  if (!shape || !isProseLine(label)) {
103
- throw ruleFault(`${sentence} answered option "${input.name}" with an answer that is not { value, label }.`);
103
+ throw ruleFault(`${sentence} answered option ${quoted(input.name)} with an answer that is not { value, label }.`);
104
104
  }
105
105
  const kind = rawKind(input);
106
106
  const value = rawValue(kind, shape.value);
107
107
  if (value === undefined) {
108
- throw ruleFault(`${sentence} answered option "${input.name}" with a value that is not ${owed[kind]}.`);
108
+ throw ruleFault(`${sentence} answered option ${quoted(input.name)} with a value that is not ${owed[kind]}.`);
109
109
  }
110
110
  return { label, value };
111
111
  }
@@ -121,7 +121,7 @@ function readAnswers(sentence, answers, requested) {
121
121
  return Object.entries(answers).map(([name, answer]) => {
122
122
  const target = byName.get(name);
123
123
  if (!target) {
124
- throw ruleFault(`${sentence} answered option "${name}", which core did not request.`);
124
+ throw ruleFault(`${sentence} answered option ${quoted(name)}, which core did not request.`);
125
125
  }
126
126
  const { label, value } = readAnswer(sentence, target.input, answer);
127
127
  return { label, target, value };
@@ -1,4 +1,4 @@
1
- import { graphemeWidths } from '@rockorager/uucode/width';
1
+ import { graphemeWidths } from './style-width.js';
2
2
  const offsets = [0, 1, 2, 3, 4, 5, 6, 7];
3
3
  const empty = {
4
4
  advance: offsets.map(() => 0),
@@ -117,6 +117,7 @@ function padBlock(block, padding, attributes) {
117
117
  if (!block.tabs && block.minimum >= padding.width) {
118
118
  return block;
119
119
  }
120
+ const space = (count) => leaf({ attributes, kind: 'text', text: ' '.repeat(count) }, count);
120
121
  const lines = block.lines.map((line, index) => {
121
122
  // A terminal newline has no extra line to align; interior blank lines do.
122
123
  if (index > 0 && index === block.lines.length - 1 && !line.content.text) {
@@ -134,7 +135,6 @@ function padBlock(block, padding, attributes) {
134
135
  else if (padding.align === 'center') {
135
136
  before = Math.floor(missing / 2);
136
137
  }
137
- const space = (count) => leaf({ attributes, kind: 'text', text: ' '.repeat(count) }, count);
138
138
  // Re-segment inserted spaces too: a trailing Unicode Prepend character can absorb one.
139
139
  const spaced = join([space(before), content, space(missing - before)]);
140
140
  const row = measured([{ content: spaced, ending: [] }]);
@@ -0,0 +1,13 @@
1
+ /** One grapheme cluster of a string: where it starts and ends, and the columns it occupies. */
2
+ interface GraphemeWidth {
3
+ start: number;
4
+ end: number;
5
+ width: number;
6
+ }
7
+ /**
8
+ * Each grapheme cluster of a string with the terminal columns it occupies. Printable ASCII that no
9
+ * non-ASCII character follows is its own one-column cluster. Any other run of clusters is read by
10
+ * one cursor, which starts at the run and carries the break state from cluster to cluster.
11
+ */
12
+ export declare function graphemeWidths(text: string): Generator<GraphemeWidth>;
13
+ export {};
@@ -0,0 +1,170 @@
1
+ /*!
2
+ Ported from the grapheme width iterator of @rockorager/uucode 2.2.1, src/width.ts.
3
+ Copyright (c) 2026 Tim Culverhouse. Licensed under the MIT License,
4
+ in licenses/uucode-LICENSE.md of @loomcli/core.
5
+ */
6
+ import { graphemeTable, widthTable } from './unicode.generated.js';
7
+ /** The break state after a regional indicator, which pairs with the next one into a flag. */
8
+ const regionalIndicatorState = 1;
9
+ /** The break state inside an extended pictographic sequence, which a ZWJ continues. */
10
+ const pictographicState = 2;
11
+ // The table members, read once, so each lookup indexes the arrays directly.
12
+ const { stage1, stage1Shift, stage2, stage2Mask, stage3, widthMask, zeroWidthFlag, emojiVSFlag } = widthTable;
13
+ const { breakTable, graphemeBreakPropertyCount } = graphemeTable;
14
+ /** A code point's packed row: its grapheme break property, width, and emoji flags. */
15
+ function lookup(codePoint) {
16
+ const offset = stage1[codePoint >> stage1Shift] ?? 0;
17
+ return stage3[stage2[offset + (codePoint & stage2Mask)] ?? 0] ?? 0;
18
+ }
19
+ /** The row an empty string starts from: the Other break property at width one. */
20
+ const defaultRow = 1 << 5;
21
+ const graphemeBreak = (row) => row & 0x1f;
22
+ const rowWidth = (row) => (row >> 5) & widthMask;
23
+ const zeroInGrapheme = (row) => ((row >> 5) & zeroWidthFlag) !== 0;
24
+ const emojiSelectorBase = (row) => ((row >> 8) & emojiVSFlag) !== 0;
25
+ /** The next break state shifted left once, with whether a cluster boundary falls between. */
26
+ function transition(before, after, state) {
27
+ const count = graphemeBreakPropertyCount;
28
+ return breakTable[(state * count + before) * count + after] ?? 0;
29
+ }
30
+ /** A cursor at the start of a run, with the run's first code point read ahead. */
31
+ function cursor(text, start) {
32
+ const hasNext = start < text.length;
33
+ const codePoint = hasNext ? (text.codePointAt(start) ?? 0) : 0;
34
+ return {
35
+ codePoint: 0,
36
+ hasNext,
37
+ index: start,
38
+ isBreak: false,
39
+ nextCodePoint: codePoint,
40
+ nextIndex: hasNext ? start + (codePoint > 0xff_ff ? 2 : 1) : start,
41
+ nextRow: hasNext ? lookup(codePoint) : defaultRow,
42
+ row: defaultRow,
43
+ state: 0,
44
+ text,
45
+ };
46
+ }
47
+ /** Moves to the next code point and reads whether a cluster boundary follows it. */
48
+ function advance(at) {
49
+ if (!at.hasNext) {
50
+ return false;
51
+ }
52
+ at.codePoint = at.nextCodePoint;
53
+ at.row = at.nextRow;
54
+ const index = at.nextIndex;
55
+ at.index = index;
56
+ if (index >= at.text.length) {
57
+ at.hasNext = false;
58
+ at.isBreak = true;
59
+ return true;
60
+ }
61
+ const first = at.text.charCodeAt(index);
62
+ let codePoint = first;
63
+ let nextIndex = index + 1;
64
+ if (first >= 0xd8_00 && first <= 0xdb_ff && nextIndex < at.text.length) {
65
+ const second = at.text.charCodeAt(nextIndex);
66
+ if (second >= 0xdc_00 && second <= 0xdf_ff) {
67
+ codePoint = ((first - 0xd8_00) << 10) + second - 0xdc_00 + 0x1_00_00;
68
+ nextIndex += 1;
69
+ }
70
+ }
71
+ const row = lookup(codePoint);
72
+ const packed = transition(graphemeBreak(at.row), graphemeBreak(row), at.state);
73
+ at.state = packed >> 1;
74
+ at.nextCodePoint = codePoint;
75
+ at.nextIndex = nextIndex;
76
+ at.nextRow = row;
77
+ at.isBreak = (packed & 1) !== 0;
78
+ return true;
79
+ }
80
+ /** The width of the next grapheme cluster, leaving the cursor at its end. */
81
+ function clusterWidth(at) {
82
+ if (!advance(at)) {
83
+ return 0;
84
+ }
85
+ const standalone = rowWidth(at.row);
86
+ if (at.isBreak) {
87
+ return standalone;
88
+ }
89
+ let width = zeroInGrapheme(at.row) ? 0 : standalone;
90
+ let previousRow = at.row;
91
+ let previousState = at.state;
92
+ for (;;) {
93
+ if (!advance(at)) {
94
+ break;
95
+ }
96
+ switch (at.codePoint) {
97
+ case 0xfe_0f: {
98
+ if (emojiSelectorBase(previousRow)) {
99
+ width = 2;
100
+ }
101
+ break;
102
+ }
103
+ case 0xfe_0e: {
104
+ if (emojiSelectorBase(previousRow)) {
105
+ width = 1;
106
+ }
107
+ break;
108
+ }
109
+ case 0x20_0d: {
110
+ if (previousState === pictographicState && !at.isBreak) {
111
+ if (!advance(at) || at.isBreak) {
112
+ return width;
113
+ }
114
+ previousRow = at.row;
115
+ previousState = at.state;
116
+ continue;
117
+ }
118
+ break;
119
+ }
120
+ case 0x1_f3_fb:
121
+ case 0x1_f3_fc:
122
+ case 0x1_f3_fd:
123
+ case 0x1_f3_fe:
124
+ case 0x1_f3_ff: {
125
+ width = 2;
126
+ break;
127
+ }
128
+ default: {
129
+ if (previousState === regionalIndicatorState) {
130
+ width = 2;
131
+ }
132
+ else if (!zeroInGrapheme(at.row)) {
133
+ width += rowWidth(at.row);
134
+ }
135
+ }
136
+ }
137
+ if (at.isBreak) {
138
+ break;
139
+ }
140
+ previousRow = at.row;
141
+ previousState = at.state;
142
+ }
143
+ return width;
144
+ }
145
+ /** Whether the character at an index is printable ASCII that no non-ASCII character follows. */
146
+ function asciiAlone(text, index) {
147
+ const code = text.charCodeAt(index);
148
+ return (code >= 0x20 && code < 0x7f && (index + 1 >= text.length || text.charCodeAt(index + 1) < 0x80));
149
+ }
150
+ /**
151
+ * Each grapheme cluster of a string with the terminal columns it occupies. Printable ASCII that no
152
+ * non-ASCII character follows is its own one-column cluster. Any other run of clusters is read by
153
+ * one cursor, which starts at the run and carries the break state from cluster to cluster.
154
+ */
155
+ export function* graphemeWidths(text) {
156
+ let start = 0;
157
+ while (start < text.length) {
158
+ if (asciiAlone(text, start)) {
159
+ yield { end: start + 1, start, width: 1 };
160
+ start += 1;
161
+ continue;
162
+ }
163
+ const at = cursor(text, start);
164
+ do {
165
+ const width = clusterWidth(at);
166
+ yield { end: at.index, start, width };
167
+ start = at.index;
168
+ } while (start < text.length && !asciiAlone(text, start));
169
+ }
170
+ }
package/dist/types.d.ts CHANGED
@@ -185,7 +185,7 @@ export interface ViewContext {
185
185
  export interface View<Data> {
186
186
  render: (data: Readonly<Data>, context: ViewContext) => string;
187
187
  /** A view has one shape; the row view of Results is the other. */
188
- row?: never;
188
+ row?: undefined;
189
189
  }
190
190
  /**
191
191
  * A row view renders a sequence one row at a time.
@@ -197,7 +197,7 @@ export interface RowView<Row> {
197
197
  head?: (context: ViewContext) => string;
198
198
  tail?: (count: number, context: ViewContext) => string;
199
199
  /** A row view has one shape; the whole view of Rendered output is the other. */
200
- render?: never;
200
+ render?: undefined;
201
201
  }
202
202
  /** The views record of a value result: every entry renders the whole value. */
203
203
  export type ResultViews<Value> = Readonly<Record<string, View<Value>>>;
@@ -0,0 +1,27 @@
1
+ /*!
2
+ The tables derive from the Unicode Character Database.
3
+ Copyright © 1991-2025 Unicode, Inc. Licensed under the Unicode License v3,
4
+ in licenses/unicode-LICENSE.txt of @loomcli/core.
5
+
6
+ They are generated from the tables of @rockorager/uucode.
7
+ Copyright (c) 2026 Tim Culverhouse. Licensed under the MIT License,
8
+ in licenses/uucode-LICENSE.md of @loomcli/core.
9
+ */
10
+ /** The grapheme break state machine, indexed by state and two break properties. */
11
+ export declare const graphemeTable: {
12
+ breakStateCount: number;
13
+ breakTable: Uint8Array<ArrayBuffer>;
14
+ graphemeBreakPropertyCount: number;
15
+ };
16
+ /** Each code point's width and grapheme break property, in a three-stage lookup. */
17
+ export declare const widthTable: {
18
+ emojiVSFlag: number;
19
+ maxCodePoint: number;
20
+ stage1: Uint16Array<ArrayBuffer>;
21
+ stage1Shift: number;
22
+ stage2: Uint8Array<ArrayBuffer>;
23
+ stage2Mask: number;
24
+ stage3: Uint16Array<ArrayBuffer>;
25
+ widthMask: number;
26
+ zeroWidthFlag: number;
27
+ };