assign-gingerly 0.0.84 → 0.0.85

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.
@@ -9,7 +9,7 @@ export class ScopedParserRegistry {
9
9
  * Register a parser with a given name
10
10
  * Resolves any pending waiters for this parser
11
11
  * @param name - The name to register the parser under
12
- * @param parser - The parser function
12
+ * @param parser - The parser function or class constructor
13
13
  */
14
14
  register(name, parser) {
15
15
  if (this.parsers.has(name)) {
@@ -26,7 +26,7 @@ export class ScopedParserRegistry {
26
26
  /**
27
27
  * Get a parser by name
28
28
  * @param name - The name of the parser
29
- * @returns The parser function or undefined if not found
29
+ * @returns The parser function, class constructor, or undefined if not found
30
30
  */
31
31
  get(name) {
32
32
  return this.parsers.get(name);
@@ -1,11 +1,11 @@
1
- import { ParserFunction } from './types/assign-gingerly/types';
1
+ import { ParserFunction, AttrParserConstructor } from './types/assign-gingerly/types';
2
2
 
3
3
  /**
4
4
  * Registry for parsers scoped to a synthesizer element (be-hive, htmx-container, etc.)
5
5
  * Enables lazy-loading of complex parsers with Promise-based waiting
6
6
  */
7
7
  export class ScopedParserRegistry {
8
- private parsers = new Map<string, ParserFunction>();
8
+ private parsers = new Map<string, ParserFunction | AttrParserConstructor>();
9
9
  private pendingWaits = new Map<string, Array<{
10
10
  resolve: () => void;
11
11
  reject: (error: Error) => void;
@@ -15,9 +15,9 @@ export class ScopedParserRegistry {
15
15
  * Register a parser with a given name
16
16
  * Resolves any pending waiters for this parser
17
17
  * @param name - The name to register the parser under
18
- * @param parser - The parser function
18
+ * @param parser - The parser function or class constructor
19
19
  */
20
- register(name: string, parser: ParserFunction): void {
20
+ register(name: string, parser: ParserFunction | AttrParserConstructor): void {
21
21
  if (this.parsers.has(name)) {
22
22
  console.warn(`Parser "${name}" already registered in scoped registry, overwriting`);
23
23
  }
@@ -35,9 +35,9 @@ export class ScopedParserRegistry {
35
35
  /**
36
36
  * Get a parser by name
37
37
  * @param name - The name of the parser
38
- * @returns The parser function or undefined if not found
38
+ * @returns The parser function, class constructor, or undefined if not found
39
39
  */
40
- get(name: string): ParserFunction | undefined {
40
+ get(name: string): ParserFunction | AttrParserConstructor | undefined {
41
41
  return this.parsers.get(name);
42
42
  }
43
43
 
package/SplitParser.js ADDED
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Escapes regex metacharacters so a string delimiter is treated literally.
3
+ */
4
+ function escapeRegex(str) {
5
+ return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
6
+ }
7
+ /**
8
+ * Built-in class parser that splits an attribute value into an array.
9
+ * Registered under the name 'splitter' in globalParserRegistry.
10
+ */
11
+ export class SplitParser {
12
+ delimiter;
13
+ trim;
14
+ skipEmpty;
15
+ dedupe;
16
+ constructor(options) {
17
+ const opts = options ?? {};
18
+ const rawDelimiter = opts.delimiter;
19
+ if (rawDelimiter === undefined) {
20
+ this.delimiter = /\s+/;
21
+ }
22
+ else if (typeof rawDelimiter === 'string') {
23
+ this.delimiter = rawDelimiter === '' ? /(?:)/ : new RegExp(escapeRegex(rawDelimiter));
24
+ }
25
+ else {
26
+ this.delimiter = new RegExp(rawDelimiter.pattern, rawDelimiter.flags ?? '');
27
+ }
28
+ this.trim = opts.trim ?? true;
29
+ this.skipEmpty = opts.skipEmpty ?? true;
30
+ this.dedupe = opts.dedupe ?? false;
31
+ }
32
+ parse(v, _context) {
33
+ if (v === null || v === '') {
34
+ return [];
35
+ }
36
+ let parts = v.split(this.delimiter);
37
+ if (this.trim) {
38
+ parts = parts.map((s) => s.trim());
39
+ }
40
+ if (this.skipEmpty) {
41
+ parts = parts.filter((s) => s !== '');
42
+ }
43
+ if (this.dedupe) {
44
+ parts = [...new Set(parts)];
45
+ }
46
+ return parts;
47
+ }
48
+ }
package/SplitParser.ts ADDED
@@ -0,0 +1,89 @@
1
+ import { AttrParser, ParserContext } from './types/assign-gingerly/types';
2
+
3
+ /**
4
+ * Options for SplitParser
5
+ */
6
+ export interface SplitParserOptions {
7
+ /**
8
+ * Delimiter used to split the attribute value.
9
+ * - String: treated as a literal separator (regex specials are escaped)
10
+ * - Object: { pattern: string; flags?: string } builds a RegExp directly
11
+ * - Default: /\s+/
12
+ */
13
+ delimiter?: string | { pattern: string; flags?: string };
14
+
15
+ /**
16
+ * Whether to trim each split part.
17
+ * Default: true
18
+ */
19
+ trim?: boolean;
20
+
21
+ /**
22
+ * Whether to skip empty strings after splitting/trimming.
23
+ * Default: true
24
+ */
25
+ skipEmpty?: boolean;
26
+
27
+ /**
28
+ * Whether to remove duplicate values.
29
+ * Default: false
30
+ */
31
+ dedupe?: boolean;
32
+ }
33
+
34
+ /**
35
+ * Escapes regex metacharacters so a string delimiter is treated literally.
36
+ */
37
+ function escapeRegex(str: string): string {
38
+ return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
39
+ }
40
+
41
+ /**
42
+ * Built-in class parser that splits an attribute value into an array.
43
+ * Registered under the name 'splitter' in globalParserRegistry.
44
+ */
45
+ export class SplitParser implements AttrParser {
46
+ private delimiter: RegExp;
47
+ private trim: boolean;
48
+ private skipEmpty: boolean;
49
+ private dedupe: boolean;
50
+
51
+ constructor(options?: SplitParserOptions) {
52
+ const opts = options ?? {};
53
+ const rawDelimiter = opts.delimiter;
54
+
55
+ if (rawDelimiter === undefined) {
56
+ this.delimiter = /\s+/;
57
+ } else if (typeof rawDelimiter === 'string') {
58
+ this.delimiter = rawDelimiter === '' ? /(?:)/ : new RegExp(escapeRegex(rawDelimiter));
59
+ } else {
60
+ this.delimiter = new RegExp(rawDelimiter.pattern, rawDelimiter.flags ?? '');
61
+ }
62
+
63
+ this.trim = opts.trim ?? true;
64
+ this.skipEmpty = opts.skipEmpty ?? true;
65
+ this.dedupe = opts.dedupe ?? false;
66
+ }
67
+
68
+ parse(v: string | null, _context?: ParserContext): any {
69
+ if (v === null || v === '') {
70
+ return [];
71
+ }
72
+
73
+ let parts = v.split(this.delimiter);
74
+
75
+ if (this.trim) {
76
+ parts = parts.map((s) => s.trim());
77
+ }
78
+
79
+ if (this.skipEmpty) {
80
+ parts = parts.filter((s) => s !== '');
81
+ }
82
+
83
+ if (this.dedupe) {
84
+ parts = [...new Set(parts)];
85
+ }
86
+
87
+ return parts;
88
+ }
89
+ }
@@ -7,6 +7,8 @@
7
7
 
8
8
  - **[plus-minus](https://github.com/bahrus/plus-minus)** -- Expand / Collapse component - More robust examples of dynamic DOM manipulation with the help of roundabout configuration. Also demonstrates use of the DX libraries to get typing intellisense help.
9
9
 
10
+ - **[side-burger](https://github.com/bahrus/side-burger)** -- Side Drawer component with menu.
11
+
10
12
  ## Step 4
11
13
 
12
14
  Add the following additional dependencies in package.json:
@@ -379,7 +381,7 @@ Work is underway to improve the DX a bit, but for now:
379
381
 
380
382
  ```JS
381
383
  {
382
- delay:10, //milliseconds
384
+ delay:100, //milliseconds
383
385
  ifAllOf: ['expanded'],
384
386
  assign: {
385
387
  set($.querySelector('a').focus()).to({}),
@@ -387,6 +389,69 @@ Work is underway to improve the DX a bit, but for now:
387
389
  },
388
390
  ```
389
391
 
392
+ ## How can I set externally specified elements to inert?
393
+
394
+ This is implemented with the [side-burger](https://github.com/bahrus/side-burger) custom element, to see the full context.
395
+
396
+ Suppose we define a property on the custom element, "inertTarget" which allows the developer to specify css matches from the root document to set to inert when the sidebar is open.
397
+
398
+ ```JS
399
+
400
+ // kept separate because "smoothOver" destroys typechecking
401
+ /** @type Merges<AP> */
402
+ const merges = [
403
+ ...
404
+ {
405
+ ifAllOf: [props.clone, props.inertTarget, props.open],
406
+ ...doAssign(
407
+ set(props.inertTargetElements).to($.ownerDocument.querySelectorAll($.inertTarget)),
408
+ set($.inertTargetElements.Each.inert).to(true)
409
+ )
410
+ },
411
+ {
412
+ ifAllOf: [props.clone, props.inertTarget],
413
+ ifNoneOf: [props.open],
414
+ ...doAssign(
415
+ set($.inertTargetElements.Each.inert).to(false)
416
+ )
417
+ }
418
+ ];
419
+
420
+ /** @type {AttrPatterns<AP>} */
421
+ const withAttrs = {
422
+ ...
423
+ [props.inertTarget]: 'inert-target',
424
+ [`_${props.inertTarget}`]: {
425
+ mapsTo: props.inertTarget,
426
+ }
427
+ }
428
+
429
+ /**
430
+ * @type {RoundaboutOptions<AP, Actions, AP, 'click' | 'keydown'>}
431
+ */
432
+ const raConfig = {
433
+ weakRef: {
434
+ ...
435
+ listProperties: [props.inertTargetElements],
436
+ ...
437
+ },
438
+
439
+ assignOptions: {
440
+ akaMethods: {
441
+ ...
442
+ '🧺': m['🧺'], //querySelectorAll
443
+ },
444
+ substitutions: {
445
+ inertTarget: '?.inertTarget'
446
+ }
447
+ },
448
+ ...
449
+ merges: smoothOver(merges),
450
+ ...
451
+ };
452
+ ```
453
+
454
+
390
455
  ## Step 8
391
456
 
392
457
  Run `node el-maker.mjs` (or `npm run build-el-maker` if your `package.json` includes a watch script) to regenerate `el-maker.json`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "assign-gingerly",
3
- "version": "0.0.84",
3
+ "version": "0.0.85",
4
4
  "description": "This package provides a utility function for carefully merging one object into another.",
5
5
  "homepage": "https://github.com/bahrus/assign-gingerly#readme",
6
6
  "bugs": {
@@ -51,6 +51,10 @@
51
51
  "./parserRegistry.js": {
52
52
  "default": "./parserRegistry.js"
53
53
  },
54
+ "./SplitParser.js": {
55
+ "default": "./SplitParser.js",
56
+ "types": "./SplitParser.ts"
57
+ },
54
58
  "./parseWithAttrs.js": {
55
59
  "default": "./parseWithAttrs.js",
56
60
  "types": "./parseWithAttrs.ts"
package/parseWithAttrs.js CHANGED
@@ -3,19 +3,61 @@ import { resolveTemplate } from './resolve/resolveTemplate.js';
3
3
  // Module-level cache for parsed attribute values
4
4
  // Structure: Map<configKey, Map<attrValue, parsedValue>>
5
5
  const parseCache = new Map();
6
+ /**
7
+ * Detects whether a value is a class-based parser constructor.
8
+ * Class parsers must have a `parse` method on their prototype.
9
+ */
10
+ function isAttrParserConstructor(value) {
11
+ return typeof value === 'function' && !!value.prototype && 'parse' in value.prototype;
12
+ }
13
+ /**
14
+ * Instantiates a class parser and returns a wrapper function that calls its parse method.
15
+ * A new instance is created for each attribute parse (no cross-call caching).
16
+ */
17
+ function instantiateClassParser(ParserCtor, options) {
18
+ const instance = new ParserCtor(options);
19
+ return (attrValue, context) => instance.parse(attrValue, context);
20
+ }
21
+ /**
22
+ * Looks up a named parser in the scoped (if available) then global registry.
23
+ */
24
+ function lookupNamedParser(name, synthesizerElement) {
25
+ // Check scoped registry first (if synthesizerElement provided)
26
+ if (synthesizerElement) {
27
+ const scopedRegistry = getParserRegistry(synthesizerElement);
28
+ const scopedParser = scopedRegistry.get(name);
29
+ if (scopedParser) {
30
+ return scopedParser;
31
+ }
32
+ }
33
+ // Fallback to global registry
34
+ const globalParser = globalParserRegistry.get(name);
35
+ if (globalParser) {
36
+ return globalParser;
37
+ }
38
+ // Not found in either registry
39
+ throw new Error(`Parser "${name}" not found. ` +
40
+ `Checked ${synthesizerElement ? 'scoped registry and ' : ''}global registry.\n` +
41
+ `Ensure the parser is registered via:\n` +
42
+ `- <script type="emc-parser" src="..." parser-name="${name}">\n` +
43
+ `- registerParser(synthesizerElement, "${name}", parserFn)\n` +
44
+ `- globalParserRegistry.register("${name}", parserFn)`);
45
+ }
6
46
  /**
7
47
  * Resolves a parser specification to an actual parser function
8
48
  * Supports:
9
49
  * - Inline functions (direct use)
10
- * - Named parsers from scoped registry (if synthesizerElement provided)
11
- * - Named parsers from global registry (fallback)
50
+ * - Named parser functions or class constructors from scoped/global registry (string form)
51
+ * - Named class parsers with options from the object form { name, options }
52
+ * - Custom element static methods via tuple form [elementName, methodName]
12
53
  *
13
- * @param parserSpec - Parser function or string reference
54
+ * @param parserSpec - Parser function, string reference, tuple, or object reference
14
55
  * @param synthesizerElement - Optional synthesizer element for scoped parser lookup
56
+ * @param parserOptions - Optional constructor options when a string resolves to a class parser
15
57
  * @returns The resolved parser function
16
58
  * @throws Error if parser cannot be resolved
17
59
  */
18
- function resolveParser(parserSpec, synthesizerElement) {
60
+ function resolveParser(parserSpec, synthesizerElement, parserOptions) {
19
61
  // Undefined - no parser specified
20
62
  if (parserSpec === undefined) {
21
63
  return undefined;
@@ -24,31 +66,61 @@ function resolveParser(parserSpec, synthesizerElement) {
24
66
  if (typeof parserSpec === 'function') {
25
67
  return parserSpec;
26
68
  }
69
+ // Object form: { name, options }
70
+ if (parserSpec !== null &&
71
+ typeof parserSpec === 'object' &&
72
+ !Array.isArray(parserSpec) &&
73
+ 'name' in parserSpec) {
74
+ const { name, options } = parserSpec;
75
+ const registered = lookupNamedParser(name, synthesizerElement);
76
+ if (isAttrParserConstructor(registered)) {
77
+ return instantiateClassParser(registered, options);
78
+ }
79
+ return registered;
80
+ }
27
81
  // String reference - resolve from scoped or global registry
28
82
  if (typeof parserSpec === 'string') {
29
- // Check scoped registry first (if synthesizerElement provided)
30
- if (synthesizerElement) {
31
- const scopedRegistry = getParserRegistry(synthesizerElement);
32
- const scopedParser = scopedRegistry.get(parserSpec);
33
- if (scopedParser) {
34
- return scopedParser;
35
- }
83
+ const registered = lookupNamedParser(parserSpec, synthesizerElement);
84
+ if (isAttrParserConstructor(registered)) {
85
+ return instantiateClassParser(registered, parserOptions);
86
+ }
87
+ return registered;
88
+ }
89
+ // Tuple reference: [elementName, methodName]
90
+ if (Array.isArray(parserSpec) && parserSpec.length === 2) {
91
+ const [elementName, methodName] = parserSpec;
92
+ const Ctr = customElements.get(elementName);
93
+ if (!Ctr) {
94
+ throw new Error(`Cannot resolve parser [${elementName}, ${methodName}]: custom element "${elementName}" not found`);
36
95
  }
37
- // Fallback to global registry
38
- const globalParser = globalParserRegistry.get(parserSpec);
39
- if (globalParser) {
40
- return globalParser;
96
+ const method = Ctr[methodName];
97
+ if (typeof method !== 'function') {
98
+ throw new Error(`Cannot resolve parser [${elementName}, ${methodName}]: static method "${methodName}" not found on custom element "${elementName}"`);
41
99
  }
42
- // Not found in either registry
43
- throw new Error(`Parser "${parserSpec}" not found. ` +
44
- `Checked ${synthesizerElement ? 'scoped registry and ' : ''}global registry.\n` +
45
- `Ensure the parser is registered via:\n` +
46
- `- <script type="emc-parser" src="..." parser-name="${parserSpec}">\n` +
47
- `- registerParser(synthesizerElement, "${parserSpec}", parserFn)\n` +
48
- `- globalParserRegistry.register("${parserSpec}", parserFn)`);
100
+ return method.bind(Ctr);
49
101
  }
50
102
  return undefined;
51
103
  }
104
+ /**
105
+ * Serializes parser options for use in cache keys.
106
+ * RegExp values are normalized to their string representation.
107
+ */
108
+ function serializeParserOptions(options) {
109
+ if (options === undefined || options === null) {
110
+ return '';
111
+ }
112
+ try {
113
+ return JSON.stringify(options, (_key, value) => {
114
+ if (value instanceof RegExp) {
115
+ return value.toString();
116
+ }
117
+ return value;
118
+ });
119
+ }
120
+ catch (_e) {
121
+ return String(options);
122
+ }
123
+ }
52
124
  /**
53
125
  * Creates a cache key from an AttrConfig
54
126
  * Includes instanceOf and parser identifier to ensure correct cache hits
@@ -63,7 +135,19 @@ function getCacheKey(config) {
63
135
  parserStr = 'builtin';
64
136
  }
65
137
  else if (typeof config.parser === 'string') {
66
- parserStr = `named:${config.parser}`;
138
+ const optionsStr = serializeParserOptions(config.parserOptions);
139
+ parserStr = `named:${config.parser}|options:${optionsStr}`;
140
+ }
141
+ else if (config.parser !== null &&
142
+ typeof config.parser === 'object' &&
143
+ !Array.isArray(config.parser) &&
144
+ 'name' in config.parser) {
145
+ const ref = config.parser;
146
+ const optionsStr = serializeParserOptions(ref.options);
147
+ parserStr = `named:${ref.name}|options:${optionsStr}`;
148
+ }
149
+ else if (Array.isArray(config.parser)) {
150
+ parserStr = `tuple:${config.parser[0]},${config.parser[1]}`;
67
151
  }
68
152
  else {
69
153
  parserStr = 'custom';
@@ -328,7 +412,7 @@ export function parseWithAttrs(element, attrPatterns, allowUnprefixed, spawnCont
328
412
  }
329
413
  // For Boolean without valIfNull, fall through to parser
330
414
  else {
331
- const parser = resolveParser(config.parser, synthesizerElement) || getDefaultParser(config.instanceOf);
415
+ const parser = resolveParser(config.parser, synthesizerElement, config.parserOptions) || getDefaultParser(config.instanceOf);
332
416
  const parsedValue = callParser(parser, attrValue, parserContext);
333
417
  const mapsTo = config.mapsTo ?? (key === 'base' ? '.' : key);
334
418
  result[mapsTo] = parsedValue;
@@ -336,7 +420,7 @@ export function parseWithAttrs(element, attrPatterns, allowUnprefixed, spawnCont
336
420
  continue;
337
421
  }
338
422
  // Attribute exists - parse normally
339
- const parser = resolveParser(config.parser, synthesizerElement) || getDefaultParser(config.instanceOf);
423
+ const parser = resolveParser(config.parser, synthesizerElement, config.parserOptions) || getDefaultParser(config.instanceOf);
340
424
  // Use cache if parseCache is specified
341
425
  const parsedValue = config.parseCache
342
426
  ? parseWithCache(attrValue, config, parser, parserContext)