@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/NOTICE +34 -0
- package/dist/application.js +47 -15
- package/dist/capture.d.ts +65 -0
- package/dist/capture.js +99 -0
- package/dist/command.js +73 -44
- package/dist/facts.d.ts +5 -0
- package/dist/facts.js +8 -1
- package/dist/globals.js +9 -7
- package/dist/glyphs.generated.js +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.js +1 -0
- package/dist/input-rules.d.ts +5 -1
- package/dist/input-rules.js +8 -1
- package/dist/inspect.d.ts +0 -8
- package/dist/inspect.js +5 -21
- package/dist/options.d.ts +7 -0
- package/dist/options.js +13 -7
- package/dist/plain.d.ts +52 -2
- package/dist/plain.js +228 -2
- package/dist/plugin-rules.d.ts +7 -1
- package/dist/plugin-rules.js +11 -2
- package/dist/plugin-settings.d.ts +18 -0
- package/dist/plugin-settings.js +38 -0
- package/dist/plugin.d.ts +6 -6
- package/dist/plugin.js +98 -45
- package/dist/sources.js +4 -4
- package/dist/style-layout.js +2 -2
- package/dist/style-width.d.ts +13 -0
- package/dist/style-width.js +170 -0
- package/dist/types.d.ts +2 -2
- package/dist/unicode.generated.d.ts +27 -0
- package/dist/unicode.generated.js +1036 -0
- package/dist/validation.d.ts +33 -12
- package/dist/validation.js +73 -28
- package/dist/view.d.ts +16 -11
- package/dist/view.js +13 -4
- package/licenses/unicode-LICENSE.txt +41 -0
- package/licenses/uucode-LICENSE.md +35 -0
- package/package.json +9 -5
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.
|
|
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
|
|
134
|
-
*
|
|
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`
|
|
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.
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
|
120
|
-
*
|
|
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
|
-
/**
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
229
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
571
|
-
*
|
|
572
|
-
*
|
|
573
|
-
*
|
|
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 =
|
|
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
|
|
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
|
|
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
|
|
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 };
|
package/dist/style-layout.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { graphemeWidths } from '
|
|
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?:
|
|
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?:
|
|
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
|
+
};
|