@escape-game-over/atlas 0.1.18 → 0.1.20

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.
@@ -1,10 +1,6 @@
1
1
  /**
2
- * A markup contract: the attributes a template writes and a script reads,
3
- * named once, typed on both sides.
4
- *
5
- * In `astro/` because the reading half takes elements. The writing half runs in
6
- * an Astro template at build time, and both halves come off the one object —
7
- * which is the entire point.
2
+ * A markup contract: the attributes a template writes and a script reads, named
3
+ * once and typed on both sides.
8
4
  *
9
5
  * ```ts
10
6
  * // network-markup.ts, imported by the template and by the script
@@ -23,52 +19,129 @@
23
19
  * }
24
20
  * ```
25
21
  *
26
- * **Why it exists.** A client script and the template it enhances agree on
27
- * attribute names, and nothing checks the agreement. `data-filter-regoin` in a
28
- * template and `dataset.filterRegion` in a script compile, build and ship; the
29
- * facet never matches, and the page reads as a filter with no results rather
30
- * than as a typo. A shared constant pins the name of one attribute. This pins
31
- * all of them, and their values with them:
22
+ * **Why.** A script and the template it enhances agree on attribute names, and
23
+ * nothing checks the agreement: `data-filter-regoin` in one and
24
+ * `dataset.filterRegion` in the other compile, build and ship, and the facet
25
+ * just never matches. Here names are derived rather than spelled, values are
26
+ * typed on the way in, and parsed on the way out — a missing or malformed
27
+ * attribute throws, naming the element, instead of reading as an empty string.
32
28
  *
33
- * - **Names are derived, never spelled.** `data-network-row` marks the element
34
- * and `data-network-row-status` holds a field. Neither side writes either
35
- * string, so a rename is one edit, and a field that does not exist is a
36
- * compile error in the template and in the script alike.
37
- * - **Values are typed on the way in.** `attrs` takes each field's own type, so
38
- * a missing required field, or a status outside its list, fails `astro check`.
39
- * - **And checked on the way out.** A script cannot trust markup it did not
40
- * write — a page cached from before a deploy, a template that spread the wrong
41
- * role — so `read` parses every field and throws, naming the element and the
42
- * attribute, rather than handing back an `undefined` that filters to nothing.
29
+ * `component` below binds a set of roles to one custom element, which is what
30
+ * most consumers want. See docs/client-scripts.md.
31
+ */
32
+
33
+ import type { Digit, Letter } from "../types.ts";
34
+
35
+ /**
36
+ * The names below become attributes, and a malformed one is not a small
37
+ * mistake: `data-store row` is refused by `setAttribute`, `[data-store row]`
38
+ * is refused by `querySelectorAll`, and a tag without a hyphen is refused by
39
+ * `customElements.define` — each of them in the reader's browser, long after
40
+ * the page was built. They are literals in a source file, so the rules are
41
+ * spelled as types and the mistake is an error in the editor instead.
43
42
  *
44
- * The rule in docs/client-scripts.md still holds, and this is the reading of it
45
- * that admits a lookup. It finds only what carries its own marker, writes only
46
- * its own attributes, and has no opinion about classes, `aria` or `hidden`. The
47
- * names come from the project: nothing in this package has to be matched.
43
+ * Per character rather than by pattern, in the way `i18n/placeholders.ts`
44
+ * checks a placeholder's name. A widened `string` — from a variable rather than
45
+ * a literal — is accepted: there is nothing to check.
48
46
  */
47
+ type WordChar = Lowercase<Letter> | Digit;
48
+
49
+ /** `store`, `row2`: one lowercase word. */
50
+ type IsWord<S extends string> = S extends `${infer Head}${infer Tail}`
51
+ ? Head extends WordChar
52
+ ? Tail extends ""
53
+ ? true
54
+ : IsWord<Tail>
55
+ : false
56
+ : false;
57
+
58
+ /** `store-row`, `go-faq`: lowercase words joined by single hyphens. */
59
+ type IsHyphenated<S extends string> = S extends `${infer Head}-${infer Rest}`
60
+ ? IsWord<Head> extends true
61
+ ? IsHyphenated<Rest>
62
+ : false
63
+ : IsWord<S>;
64
+
65
+ /** Letters and digits, in either case: what follows a field's first letter. */
66
+ type IsAlphanumeric<S extends string> = S extends `${infer Head}${infer Tail}`
67
+ ? Head extends Letter | Lowercase<Letter> | Digit
68
+ ? Tail extends ""
69
+ ? true
70
+ : IsAlphanumeric<Tail>
71
+ : false
72
+ : false;
73
+
74
+ /** `blockedLabel`: a lowercase letter, then letters and digits. */
75
+ type IsCamelCase<S extends string> = S extends `${infer Head}${infer Tail}`
76
+ ? Head extends Lowercase<Letter>
77
+ ? Tail extends ""
78
+ ? true
79
+ : IsAlphanumeric<Tail>
80
+ : false
81
+ : false;
82
+
83
+ export interface MalformedMarkupName<N extends string> {
84
+ readonly __MALFORMED_MARKUP_NAME__: `"${N}" cannot be a role name: it becomes an attribute, so it must be lowercase words joined by single hyphens, as in "store-row"`;
85
+ }
86
+
87
+ export interface MalformedTagName<N extends string> {
88
+ readonly __MALFORMED_TAG_NAME__: `"${N}" cannot be a custom element name: lowercase words with at least one hyphen, as in "go-faq"`;
89
+ }
90
+
91
+ export interface MalformedKey<N extends string> {
92
+ readonly __MALFORMED_KEY__: `"${N}" cannot be a field or role name: it becomes part of an attribute, so it must be camelCase, as in "blockedLabel"`;
93
+ }
94
+
95
+ /** Nothing extra for a good name, and an impossible shape for a bad one. */
96
+ type CheckedMarkupName<N extends string> = string extends N
97
+ ? unknown
98
+ : IsHyphenated<N> extends true
99
+ ? unknown
100
+ : MalformedMarkupName<N>;
101
+
102
+ type CheckedTagName<N extends string> = string extends N
103
+ ? unknown
104
+ : IsHyphenated<N> extends true
105
+ ? N extends `${string}-${string}`
106
+ ? unknown
107
+ : MalformedTagName<N>
108
+ : MalformedTagName<N>;
109
+
110
+ /** The same, for every key of a fields or roles object. */
111
+ type CheckedKeys<T> = {
112
+ [K in keyof T]: K extends string
113
+ ? IsCamelCase<K> extends true
114
+ ? unknown
115
+ : MalformedKey<K>
116
+ : never;
117
+ };
49
118
 
50
119
  /**
51
120
  * What one field is, as data rather than a constructor — the choice `filters`
52
- * makes, for the same reason: builder functions would put names as general as
53
- * `text` into every module that imports this.
121
+ * makes, and for the same reason.
54
122
  *
55
123
  * - `text` — any string, the empty one included.
56
- * - `number` — finite only; `NaN` and the infinities have no attribute form
57
- * that reads back as themselves.
58
- * - `list` — several words, space-separated, as `class` is. No item may be
59
- * empty or contain whitespace, which is checked when it is written.
60
- * - `choice` — one of `of`. Out-of-list values fail on both sides: at compile
61
- * time in the template, at run time when read.
62
- * - `flag` — present or absent. Off is the attribute left out, not `"false"`,
124
+ * - `number` — finite only.
125
+ * - `list` — several words, space-separated, as `class` is.
126
+ * - `template` — a sentence with `{placeholders}` in it, read back as the
127
+ * function that fills them. A template that never says one of its own
128
+ * `params` fails the build, rather than shipping a brace to a reader.
129
+ * - `choice` — one of `of`, checked at compile time and again when read.
130
+ * - `flag` — present or absent. Off is the attribute left out, never `"false"`,
63
131
  * so a stylesheet can select it with a bare `[data-…]`.
64
132
  *
65
- * `optional` lets a field be left off, and reads back `undefined` when it was.
66
- * A `flag` has no `optional`: absent is already one of its two values.
133
+ * `optional` lets a field be left off, and reads back `undefined`. A `flag` has
134
+ * no `optional`: absent is already one of its two values.
67
135
  */
68
136
  export type MarkupField =
69
137
  | { readonly kind: "text"; readonly optional?: boolean }
70
138
  | { readonly kind: "number"; readonly optional?: boolean }
71
139
  | { readonly kind: "list"; readonly optional?: boolean }
140
+ | {
141
+ readonly kind: "template";
142
+ readonly params: readonly [string, ...string[]];
143
+ readonly optional?: boolean;
144
+ }
72
145
  | {
73
146
  readonly kind: "choice";
74
147
  readonly of: readonly [string, ...string[]];
@@ -79,16 +152,36 @@ export type MarkupField =
79
152
  /** The fields of one role, named by the caller. */
80
153
  export type MarkupFields = Readonly<Record<string, MarkupField>>;
81
154
 
82
- /** The value a field holds when present. */
155
+ /**
156
+ * A role that carries nothing: `search: marker`, where `search: {}` reads like
157
+ * options somebody forgot to fill in. Most roles are these — they name an
158
+ * element the script has to find, and hold no values.
159
+ */
160
+ export const marker = {} as const;
161
+
162
+ /** What `attrs` takes for a field, and what lands in the attribute. */
83
163
  type Held<F extends MarkupField> = F extends { kind: "text" }
84
164
  ? string
85
165
  : F extends { kind: "number" }
86
166
  ? number
87
167
  : F extends { kind: "list" }
88
168
  ? readonly string[]
89
- : F extends { kind: "choice"; of: readonly (infer V)[] }
90
- ? V
91
- : boolean;
169
+ : F extends { kind: "template" }
170
+ ? string
171
+ : F extends { kind: "choice"; of: readonly (infer V)[] }
172
+ ? V
173
+ : boolean;
174
+
175
+ /**
176
+ * What a script reads back — the same, except that a `template` arrives as the
177
+ * function that fills it, so no script spells a placeholder.
178
+ */
179
+ type Read<F extends MarkupField> = F extends {
180
+ kind: "template";
181
+ params: readonly (infer P)[];
182
+ }
183
+ ? (values: Record<P & string, string>) => string
184
+ : Held<F>;
92
185
 
93
186
  /** Whether a template may leave the field out. */
94
187
  type Omittable<F extends MarkupField> = F extends { kind: "flag" }
@@ -99,8 +192,8 @@ type Omittable<F extends MarkupField> = F extends { kind: "flag" }
99
192
 
100
193
  /** One field's value as a script reads it. */
101
194
  export type MarkupValue<F extends MarkupField> = F extends { optional: true }
102
- ? Held<F> | undefined
103
- : Held<F>;
195
+ ? Read<F> | undefined
196
+ : Read<F>;
104
197
 
105
198
  /** Every field's value, as a script reads it. */
106
199
  export type MarkupValues<F extends MarkupFields> = {
@@ -115,10 +208,8 @@ type Flatten<T> = { [K in keyof T]: T[K] };
115
208
 
116
209
  /**
117
210
  * What a template hands `attrs`: every required field, and any of the rest.
118
- *
119
- * The optional ones accept `undefined` as well as being left out, so a value
120
- * that is itself optional — `store.state` — can be passed straight through
121
- * without a conditional spread, whatever `exactOptionalPropertyTypes` says.
211
+ * The optional ones also accept `undefined`, so a value that is itself optional
212
+ * — `store.state` — passes straight through.
122
213
  */
123
214
  export type MarkupInput<F extends MarkupFields> = Flatten<
124
215
  { readonly [K in RequiredKeys<F>]: Held<F[K]> } & {
@@ -135,29 +226,19 @@ export interface Marked<T extends Element, F extends MarkupFields> {
135
226
  }
136
227
 
137
228
  export interface Markup<F extends MarkupFields> {
138
- /**
139
- * `[data-<name>]`: what finds this role.
140
- *
141
- * For the lookups the methods below do not cover — `closest`, a
142
- * `matches` in an event handler — so those do not spell it either.
143
- */
229
+ /** `[data-<name>]`: what finds this role, for the lookups below and `closest`. */
144
230
  readonly selector: string;
145
231
 
146
232
  /**
147
- * The attribute one field lives in.
148
- *
149
- * For the rare consumer that needs the name as a string, such as a
150
- * `MutationObserver`'s `attributeFilter`. A stylesheet cannot use it —
151
- * Tailwind has to see its selectors as literals in the source — so a
152
- * styling hook still spells the attribute once, in a class; see the docs.
233
+ * The attribute one field lives in, for a consumer that needs the name as a
234
+ * string — a `MutationObserver`'s `attributeFilter`. A stylesheet cannot use
235
+ * it: Tailwind only sees selectors written as literals.
153
236
  */
154
237
  attribute(field: keyof F & string): string;
155
238
 
156
239
  /**
157
- * The attributes to spread onto an element, in a template.
158
- *
159
- * Always includes the marker. A role with nothing required may be called
160
- * with no argument at all.
240
+ * The attributes to spread onto an element, in a template. Always includes
241
+ * the marker; a role with nothing required takes no argument at all.
161
242
  */
162
243
  attrs(
163
244
  ...values: [RequiredKeys<F>] extends [never]
@@ -166,12 +247,9 @@ export interface Markup<F extends MarkupFields> {
166
247
  ): Record<string, string>;
167
248
 
168
249
  /**
169
- * One element's values, parsed.
170
- *
171
- * Throws when the element does not carry this role, or when any field is
172
- * missing or malformed — naming the element and the attribute, because an
173
- * `undefined` returned here would only resurface later as a list that
174
- * silently matches nothing.
250
+ * One element's values, parsed. Throws when the element does not carry this
251
+ * role, or when a field is missing or malformed — an `undefined` here would
252
+ * only resurface later as a list that silently matches nothing.
175
253
  */
176
254
  read(element: Element): MarkupValues<F>;
177
255
 
@@ -185,28 +263,13 @@ export interface Markup<F extends MarkupFields> {
185
263
  require<T extends Element = HTMLElement>(root: ParentNode): Marked<T, F>;
186
264
 
187
265
  /**
188
- * Writes some of this role's fields back onto an element.
189
- *
190
- * For state a stylesheet reads — a dropdown that is blocked, a card that is
191
- * selected — so the script sets it through the same names the template
192
- * wrote. A field set to `undefined`, or a flag set to `false`, removes the
193
- * attribute; fields left out of `values` are not touched.
266
+ * Writes some of this role's fields back onto an element, for state a
267
+ * stylesheet reads. A field set to `undefined`, or a flag set to `false`,
268
+ * removes the attribute; fields left out are not touched.
194
269
  */
195
270
  write(element: Element, values: Partial<MarkupInput<F>>): void;
196
271
  }
197
272
 
198
- /** Lowercase words joined by single hyphens: what follows `data-`. */
199
- const NAME = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;
200
-
201
- /**
202
- * camelCase, so the hyphenated form below is unambiguous.
203
- *
204
- * `aB` → `a-b` is one-to-one only while a field cannot contain a hyphen of its
205
- * own: allowing `a-b` too would give two fields one attribute, and each write
206
- * would erase the other.
207
- */
208
- const FIELD = /^[a-z][a-zA-Z0-9]*$/;
209
-
210
273
  const hyphenate = (field: string): string =>
211
274
  field.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`);
212
275
 
@@ -217,10 +280,9 @@ const show = (value: unknown): string =>
217
280
  /**
218
281
  * One field's value as its attribute, or `undefined` to leave it off.
219
282
  *
220
- * Checked although the types already were: a template can reach here with a
221
- * value typed as `string` that came from an env var or a cast, and this runs at
222
- * build time, where a throw fails the build instead of shipping a page whose
223
- * script will throw on every visit.
283
+ * Checked although the types were: a value can reach here from a cast or an env
284
+ * var, and this runs at build time, where a throw fails the build instead of
285
+ * shipping a page whose script throws on every visit.
224
286
  */
225
287
  function encode(
226
288
  field: MarkupField,
@@ -247,6 +309,19 @@ function encode(
247
309
  return typeof value === "string" && field.of.includes(value)
248
310
  ? value
249
311
  : refuse(`expected one of ${field.of.join(", ")}`);
312
+ case "template": {
313
+ if (typeof value !== "string") {
314
+ return refuse("it is a template field");
315
+ }
316
+ for (const param of field.params) {
317
+ if (!value.includes(`{${param}}`)) {
318
+ refuse(
319
+ `it never says {${param}}, so that value would have nowhere to go`
320
+ );
321
+ }
322
+ }
323
+ return value;
324
+ }
250
325
  case "list": {
251
326
  if (!Array.isArray(value)) return refuse("it is a list field");
252
327
  for (const item of value) {
@@ -297,45 +372,35 @@ function decode(
297
372
  return raw;
298
373
  case "list":
299
374
  return raw.split(/\s+/).filter((item) => item !== "");
375
+ case "template":
376
+ return (values: Record<string, string>) =>
377
+ field.params.reduce(
378
+ (text, param) =>
379
+ text.replaceAll(`{${param}}`, values[param] ?? ""),
380
+ raw
381
+ );
300
382
  }
301
383
  }
302
384
 
303
385
  /**
304
- * Declares one role: a marker attribute, and a typed attribute per field.
386
+ * One role, built from a name the caller has already vouched for.
305
387
  *
306
- * `name` becomes `data-<name>`, and each field `data-<name>-<field>` with the
307
- * field hyphenated. Both are checked here, at construction, because a name that
308
- * is not a valid attribute would otherwise surface as a `querySelector` syntax
309
- * error in the browser, far from the line that caused it.
310
- *
311
- * Two roles whose names make one a prefix of the other can collide:
312
- * `markup("card")` with a field `title` and `markup("card-title")` both write
313
- * `data-card-title`. Name roles so they do not.
388
+ * Not exported: `markup` is this with the name checked, and `component` builds
389
+ * its names from a tag it checked itself, so neither can reach here with
390
+ * something malformed.
314
391
  */
315
- export function markup<const F extends MarkupFields = Record<never, never>>(
392
+ function role<const F extends MarkupFields>(
316
393
  name: string,
317
- fields?: F
394
+ fields: F
318
395
  ): Markup<F> {
319
- if (!NAME.test(name)) {
320
- throw new Error(
321
- `markup(${show(name)}): a name is lowercase words joined by single hyphens, as in "store-row"`
322
- );
323
- }
324
-
325
396
  const marker = `data-${name}`;
326
397
  const selector = `[${marker}]`;
327
398
 
328
- const entries = Object.entries(fields ?? {}).map(([key, field]) => {
329
- if (!FIELD.test(key)) {
330
- throw new Error(
331
- `markup(${show(name)}): the field ${show(key)} must be camelCase — it becomes the last part of the attribute`
332
- );
333
- }
334
- return { key, field, attribute: `${marker}-${hyphenate(key)}` };
335
- });
336
-
337
- const describe = (element: Element): string =>
338
- `<${element.localName} ${marker}>`;
399
+ const entries = Object.entries(fields).map(([key, field]) => ({
400
+ key,
401
+ field,
402
+ attribute: `${marker}-${hyphenate(key)}`,
403
+ }));
339
404
 
340
405
  const read = (element: Element): MarkupValues<F> => {
341
406
  if (!element.hasAttribute(marker)) {
@@ -354,7 +419,7 @@ export function markup<const F extends MarkupFields = Record<never, never>>(
354
419
  } catch (error) {
355
420
  const reason =
356
421
  error instanceof Error ? error.message : String(error);
357
- throw new Error(`${describe(element)}: ${reason}`, {
422
+ throw new Error(`<${element.localName} ${marker}>: ${reason}`, {
358
423
  cause: error,
359
424
  });
360
425
  }
@@ -413,8 +478,8 @@ export function markup<const F extends MarkupFields = Record<never, never>>(
413
478
  },
414
479
 
415
480
  write(element, values) {
416
- // Checked like `read` is: writing a role's fields onto an element
417
- // that is not that role leaves it half-marked, and nothing reads it.
481
+ // Checked like `read`: writing a role's fields onto an element that
482
+ // is not that role leaves it half-marked, and nothing reads it.
418
483
  if (!element.hasAttribute(marker)) {
419
484
  throw new Error(
420
485
  `<${element.localName}> does not carry ${marker}, so its fields cannot be written to it`
@@ -431,6 +496,21 @@ export function markup<const F extends MarkupFields = Record<never, never>>(
431
496
  };
432
497
  }
433
498
 
499
+ /**
500
+ * Declares one role: `data-<name>` marks the element, `data-<name>-<field>`
501
+ * holds each field.
502
+ *
503
+ * Two roles whose names make one a prefix of the other collide: `markup("card")`
504
+ * with a field `title`, and `markup("card-title")`, both write `data-card-title`.
505
+ * `component` below refuses that; here, name them so it cannot happen.
506
+ */
507
+ export function markup<
508
+ const N extends string,
509
+ const F extends MarkupFields & CheckedKeys<F> = Record<never, never>,
510
+ >(name: N & CheckedMarkupName<N>, fields?: F): Markup<F> {
511
+ return role(name, (fields ?? {}) as F);
512
+ }
513
+
434
514
  /** A component's roles, by name: each is the fields of one `markup` role. */
435
515
  export type ComponentRoles = Readonly<Record<string, MarkupFields>>;
436
516
 
@@ -444,9 +524,6 @@ export type Component<Tag extends string, R extends ComponentRoles> = {
444
524
  define(element: CustomElementConstructor): void;
445
525
  } & { readonly [K in keyof R]: Markup<R[K]> };
446
526
 
447
- /** A custom element name, as far as a pattern can check it: needs a hyphen. */
448
- const TAG = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)+$/;
449
-
450
527
  /**
451
528
  * A custom element and the roles inside it, with lookups scoped to one
452
529
  * instance.
@@ -466,8 +543,8 @@ const TAG = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)+$/;
466
543
  * - **Names.** Every attribute is `data-<tag>-<role>[-<field>]`. The browser
467
544
  * refuses to define one tag twice, so two components cannot share an
468
545
  * attribute, and clashes inside one component are refused here.
469
- * - **Scope.** A lookup returns only elements owned by the same instance as
470
- * its root — the owner being the nearest ancestor with this tag. A nested
546
+ * - **Scope.** A lookup returns only elements owned by the same instance as its
547
+ * root — the owner being the nearest ancestor with this tag. A nested
471
548
  * instance keeps its elements to itself, and the root may be any element
472
549
  * inside the instance, not only the instance itself.
473
550
  *
@@ -476,14 +553,9 @@ const TAG = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)+$/;
476
553
  */
477
554
  export function component<
478
555
  const Tag extends string,
479
- const R extends ComponentRoles & { readonly [K in Reserved]?: never },
480
- >(tag: Tag, roles: R): Component<Tag, R> {
481
- if (!TAG.test(tag)) {
482
- throw new Error(
483
- `component(${show(tag)}): a custom element name is lowercase words with at least one hyphen, as in "go-faq"`
484
- );
485
- }
486
-
556
+ const R extends ComponentRoles &
557
+ CheckedKeys<R> & { readonly [K in Reserved]?: never },
558
+ >(tag: Tag & CheckedTagName<Tag>, roles: R): Component<Tag, R> {
487
559
  // Every attribute the component writes, so two that coincide are refused
488
560
  // rather than left to overwrite each other.
489
561
  const claimed = new Map<string, string>();
@@ -504,24 +576,21 @@ export function component<
504
576
  : null;
505
577
 
506
578
  const scoped = (
507
- role: string,
579
+ name: string,
508
580
  fields: MarkupFields
509
581
  ): Markup<MarkupFields> => {
510
- if (role === "tag" || role === "define") {
511
- throw new Error(
512
- `component(${show(tag)}): ${show(role)} is taken by the component itself`
513
- );
514
- }
515
- if (!FIELD.test(role)) {
582
+ // A compile error already, and cheap to keep: these two would overwrite
583
+ // the component's own keys and leave nothing to define the element with.
584
+ if (name === "tag" || name === "define") {
516
585
  throw new Error(
517
- `component(${show(tag)}): the role ${show(role)} must be camelCase — it becomes part of the attribute`
586
+ `component(${show(tag)}): ${show(name)} is taken by the component itself`
518
587
  );
519
588
  }
520
589
 
521
- const base = markup(`${tag}-${hyphenate(role)}`, fields);
522
- claim(base.selector.slice(1, -1), `the role ${show(role)}`);
590
+ const base = role(`${tag}-${hyphenate(name)}`, fields);
591
+ claim(base.selector.slice(1, -1), `the role ${show(name)}`);
523
592
  for (const field of Object.keys(fields)) {
524
- claim(base.attribute(field), `${role}.${field}`);
593
+ claim(base.attribute(field), `${name}.${field}`);
525
594
  }
526
595
 
527
596
  // Filtered before anything is read, so a malformed element in a nested
@@ -567,8 +636,8 @@ export function component<
567
636
  define: (element: CustomElementConstructor) =>
568
637
  customElements.define(tag, element),
569
638
  };
570
- for (const [role, fields] of Object.entries(roles)) {
571
- built[role] = scoped(role, fields);
639
+ for (const [name, fields] of Object.entries(roles)) {
640
+ built[name] = scoped(name, fields);
572
641
  }
573
642
  return built as Component<Tag, R>;
574
643
  }