@lokascript/framework 2.8.0 → 2.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/CHANGELOG.md +393 -0
  2. package/dist/api/create-dsl.d.ts +93 -1
  3. package/dist/api/create-dsl.d.ts.map +1 -1
  4. package/dist/api/domain-registry.d.ts +5 -3
  5. package/dist/api/domain-registry.d.ts.map +1 -1
  6. package/dist/api/index.js +232 -18
  7. package/dist/api/index.js.map +1 -1
  8. package/dist/core/index.js +26 -6
  9. package/dist/core/index.js.map +1 -1
  10. package/dist/core/tokenization/base-tokenizer.d.ts +15 -3
  11. package/dist/core/tokenization/base-tokenizer.d.ts.map +1 -1
  12. package/dist/core/tokenization/char-classifiers.d.ts +2 -2
  13. package/dist/core/tokenization/index.js +26 -6
  14. package/dist/core/tokenization/index.js.map +1 -1
  15. package/dist/core/tokenization/token-utils.d.ts +15 -0
  16. package/dist/core/tokenization/token-utils.d.ts.map +1 -1
  17. package/dist/generation/index.js +73 -47
  18. package/dist/generation/index.js.map +1 -1
  19. package/dist/generation/pattern-generator.d.ts +8 -1
  20. package/dist/generation/pattern-generator.d.ts.map +1 -1
  21. package/dist/generation/renderer.d.ts +53 -1
  22. package/dist/generation/renderer.d.ts.map +1 -1
  23. package/dist/index.cjs +302 -115
  24. package/dist/index.cjs.map +1 -1
  25. package/dist/index.d.ts +4 -4
  26. package/dist/index.d.ts.map +1 -1
  27. package/dist/index.js +298 -115
  28. package/dist/index.js.map +1 -1
  29. package/dist/interfaces/value-extractor.d.ts +5 -0
  30. package/dist/interfaces/value-extractor.d.ts.map +1 -1
  31. package/dist/multilingual/index.js +25 -6
  32. package/dist/multilingual/index.js.map +1 -1
  33. package/package.json +4 -3
  34. package/src/api/create-dsl.test.ts +11 -0
  35. package/src/api/create-dsl.ts +278 -9
  36. package/src/api/domain-registry.ts +15 -10
  37. package/src/api/extensions.test.ts +322 -0
  38. package/src/core/tokenization/base-tokenizer.ts +23 -8
  39. package/src/core/tokenization/char-classifiers.ts +2 -2
  40. package/src/core/tokenization/css-selector-extractor.test.ts +67 -0
  41. package/src/core/tokenization/token-utils.ts +18 -0
  42. package/src/generation/domain-renderer.test.ts +172 -0
  43. package/src/generation/pattern-generator.test.ts +102 -0
  44. package/src/generation/pattern-generator.ts +27 -19
  45. package/src/generation/renderer.test.ts +243 -4
  46. package/src/generation/renderer.ts +188 -45
  47. package/src/index.ts +9 -1
  48. package/src/interfaces/value-extractor.ts +50 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lokascript/framework",
3
- "version": "2.8.0",
3
+ "version": "2.9.1",
4
4
  "description": "Generic framework for building multilingual DSLs with semantic parsing and grammar transformation",
5
5
  "type": "module",
6
6
  "main": "dist/index.cjs",
@@ -96,7 +96,7 @@
96
96
  "author": "LokaScript Contributors",
97
97
  "license": "MIT",
98
98
  "dependencies": {
99
- "@lokascript/intent": "^2.8.0"
99
+ "@lokascript/intent": "^2.9.1"
100
100
  },
101
101
  "devDependencies": {
102
102
  "@types/node": "^20.0.0",
@@ -109,7 +109,8 @@
109
109
  "dist",
110
110
  "src",
111
111
  "LICENSE",
112
- "README.md"
112
+ "README.md",
113
+ "CHANGELOG.md"
113
114
  ],
114
115
  "repository": {
115
116
  "type": "git",
@@ -279,6 +279,17 @@ describe('MultilingualDSL: Explicit Syntax Support', () => {
279
279
  const result = dsl.translate('[select columns:name source:users]', 'en', 'ja');
280
280
  expect(result).toBe('[select columns:name source:users]');
281
281
  });
282
+
283
+ // The test DSL configures no `grammarProfile`, which is legal for
284
+ // parse/validate/compile but not for translate. The error must name the
285
+ // field to set rather than leaving the caller with "No profile found".
286
+ it('throws an error naming grammarProfile when the profile is missing', () => {
287
+ const dsl = createTestDSL();
288
+ expect(() => dsl.translate('select name from users', 'en', 'ja')).toThrow(/grammarProfile/);
289
+ expect(() => dsl.translate('select name from users', 'en', 'ja')).toThrow(
290
+ /parse\/validate\/compile do not need it/
291
+ );
292
+ });
282
293
  });
283
294
 
284
295
  // ---------------------------------------------------------------------------
@@ -6,6 +6,7 @@ import type { SemanticNode, LanguageTokenizer, LanguagePattern } from '../core/t
6
6
  import type { CommandSchema } from '../schema';
7
7
  import { PatternMatcher, type PatternMatcherProfile } from '../core/pattern-matching';
8
8
  import { generatePattern, type PatternGenLanguageProfile } from '../generation/pattern-generator';
9
+ import { createDomainRenderer, type DomainRenderFn } from '../generation/renderer';
9
10
  import { GrammarTransformer, type LanguageProfile as GrammarProfile } from '../grammar';
10
11
  import {
11
12
  type Dictionary,
@@ -31,7 +32,12 @@ export interface LanguageConfig {
31
32
  readonly tokenizer: LanguageTokenizer;
32
33
  /** Language profile for pattern generation */
33
34
  readonly patternProfile: PatternGenLanguageProfile;
34
- /** Language profile for grammar transformation (optional) */
35
+ /**
36
+ * Language profile for grammar transformation.
37
+ *
38
+ * Optional for `parse()`, `validate()` and `compile()`, but **required for
39
+ * `translate()`** — omitting it makes `translate()` throw for this language.
40
+ */
35
41
  readonly grammarProfile?: GrammarProfile;
36
42
  }
37
43
 
@@ -42,6 +48,77 @@ export interface CodeGenerator {
42
48
  generate(node: SemanticNode): string;
43
49
  }
44
50
 
51
+ /**
52
+ * Per-language vocabulary for a command added via {@link DomainExtension}.
53
+ *
54
+ * This is the same shape a language profile uses, narrowed to the parts a
55
+ * single new command needs.
56
+ */
57
+ export interface ExtensionVocabulary {
58
+ /** The command's verb in this language. */
59
+ readonly keyword: PatternGenLanguageProfile['keywords'][string];
60
+ /**
61
+ * Markers for roles this language does not already cover in its profile.
62
+ * Usually unnecessary: role markers are shared across a domain's commands.
63
+ */
64
+ readonly roleMarkers?: PatternGenLanguageProfile['roleMarkers'];
65
+ }
66
+
67
+ /**
68
+ * A command added to a DSL from outside the package that defines it.
69
+ *
70
+ * Supplying a schema plus one vocabulary entry per language is enough to parse,
71
+ * render and translate the command in every one of them — word order, role
72
+ * markers and keyword placement all follow from the schema and the language
73
+ * profiles. `render` and `generate` are only needed when the command's surface
74
+ * form or output cannot be derived.
75
+ *
76
+ * @example
77
+ * ```typescript
78
+ * const research: DomainExtension = {
79
+ * schema: defineCommand({
80
+ * action: 'research',
81
+ * roles: [
82
+ * defineRole({ role: 'patient', required: true, expectedTypes: ['expression'] }),
83
+ * defineRole({
84
+ * role: 'source', required: true, expectedTypes: ['expression'],
85
+ * markerOverride: { en: 'from', ja: 'から', ar: 'من' },
86
+ * }),
87
+ * ],
88
+ * }),
89
+ * vocabulary: {
90
+ * en: { keyword: { primary: 'research' } },
91
+ * ja: { keyword: { primary: '調査' } },
92
+ * ar: { keyword: { primary: 'ابحث' } },
93
+ * },
94
+ * };
95
+ *
96
+ * const llm = createLLMDSL({ extensions: [research] });
97
+ * llm.parse('research "climate" from #wiki', 'en');
98
+ * llm.render(node, 'ja'); // → '"climate" #wiki から 調査'
99
+ * ```
100
+ */
101
+ export interface DomainExtension {
102
+ /** Schema for the new command. Its action must not collide with an existing one. */
103
+ readonly schema: CommandSchema;
104
+
105
+ /**
106
+ * Vocabulary per language code. Codes must be configured on the DSL; an
107
+ * unknown code is a configuration error, not a silent no-op.
108
+ *
109
+ * A subset is allowed — a command can be added in six of a DSL's eleven
110
+ * languages. The languages left out simply do not parse it, so vocabulary
111
+ * can be filled in over time rather than all at once.
112
+ */
113
+ readonly vocabulary: Readonly<Record<string, ExtensionVocabulary>>;
114
+
115
+ /** Custom rendering for this action; takes precedence over the schema-driven path. */
116
+ readonly render?: DomainRenderFn;
117
+
118
+ /** Custom code generation for this action; takes precedence over the DSL's generator. */
119
+ readonly generate?: (node: SemanticNode) => string;
120
+ }
121
+
45
122
  /**
46
123
  * DSL configuration with dependency injection support.
47
124
  */
@@ -69,6 +146,19 @@ export interface DSLConfig {
69
146
  /** Code generator for compilation (default: none) */
70
147
  readonly codeGenerator?: CodeGenerator;
71
148
 
149
+ /**
150
+ * The domain's natural-language renderer, consulted by {@link MultilingualDSL.render}.
151
+ * Actions it returns `null` for fall through to the schema-driven renderer.
152
+ */
153
+ readonly renderer?: DomainRenderFn;
154
+
155
+ /**
156
+ * Commands added from outside this package. Their schemas and vocabulary are
157
+ * merged into the DSL before pattern generation, so they parse, render and
158
+ * compile like built-in commands.
159
+ */
160
+ readonly extensions?: readonly DomainExtension[];
161
+
72
162
  // === Options ===
73
163
 
74
164
  /** Auto-generate patterns from schemas (default: true) */
@@ -118,6 +208,16 @@ export interface MultilingualDSL {
118
208
  // Translation
119
209
  translate(input: string, fromLanguage: string, toLanguage: string): string;
120
210
 
211
+ /**
212
+ * Render a parsed node back to natural language.
213
+ *
214
+ * Returns `null` when the action cannot be rendered — no custom renderer, no
215
+ * domain renderer, and no schema. Optional so that third-party implementers
216
+ * of this interface are not broken by its addition; `createMultilingualDSL`
217
+ * always provides it.
218
+ */
219
+ render?(node: SemanticNode, language: string): string | null;
220
+
121
221
  // Language support
122
222
  getSupportedLanguages(): string[];
123
223
  }
@@ -129,9 +229,17 @@ class DSLRegistry {
129
229
  private patterns = new Map<string, LanguagePattern[]>();
130
230
  private tokenizers = new Map<string, LanguageTokenizer>();
131
231
  private schemas: readonly CommandSchema[];
132
-
133
- constructor(config: DSLConfig) {
232
+ private partiallyTranslatedActions: ReadonlySet<string>;
233
+
234
+ /**
235
+ * @param partiallyTranslatedActions Actions permitted to lack a keyword in
236
+ * some configured languages — extension commands, which may be added for a
237
+ * subset of the DSL's languages. A built-in command missing a keyword stays
238
+ * an error, since that is a domain-authoring mistake.
239
+ */
240
+ constructor(config: DSLConfig, partiallyTranslatedActions: ReadonlySet<string> = new Set()) {
134
241
  this.schemas = config.schemas;
242
+ this.partiallyTranslatedActions = partiallyTranslatedActions;
135
243
 
136
244
  // Register each language
137
245
  for (const lang of config.languages) {
@@ -146,6 +254,12 @@ class DSLRegistry {
146
254
  // Generate patterns for this language
147
255
  const patterns: LanguagePattern[] = [];
148
256
  for (const schema of this.schemas) {
257
+ if (
258
+ this.partiallyTranslatedActions.has(schema.action) &&
259
+ !lang.patternProfile.keywords[schema.action]
260
+ ) {
261
+ continue; // this extension command was not translated into this language
262
+ }
149
263
  const pattern = generatePattern(schema, lang.patternProfile);
150
264
  patterns.push(pattern);
151
265
  }
@@ -173,16 +287,42 @@ class MultilingualDSLImpl implements MultilingualDSL {
173
287
  private registry: DSLRegistry;
174
288
  private matcher: PatternMatcher;
175
289
  private transformer: GrammarTransformer;
290
+ private profileProvider: ProfileProvider;
176
291
  private codeGenerator?: CodeGenerator;
177
292
  private schemaLookup: SchemaLookup;
178
-
179
- constructor(config: DSLConfig, registry: DSLRegistry, transformer: GrammarTransformer) {
293
+ private domainRenderer?: DomainRenderFn;
294
+ private extensionRenderers: Map<string, DomainRenderFn>;
295
+ private schemaFallbackRenderer: DomainRenderFn;
296
+
297
+ constructor(
298
+ config: DSLConfig,
299
+ registry: DSLRegistry,
300
+ transformer: GrammarTransformer,
301
+ profileProvider: ProfileProvider
302
+ ) {
180
303
  this.registry = registry;
181
304
  this.matcher = new PatternMatcher();
182
305
  this.transformer = transformer;
306
+ this.profileProvider = profileProvider;
183
307
  if (config.codeGenerator) {
184
308
  this.codeGenerator = config.codeGenerator;
185
309
  }
310
+ if (config.renderer) {
311
+ this.domainRenderer = config.renderer;
312
+ }
313
+
314
+ this.extensionRenderers = new Map();
315
+ for (const extension of config.extensions ?? []) {
316
+ if (extension.render) this.extensionRenderers.set(extension.schema.action, extension.render);
317
+ }
318
+
319
+ // Last resort for render(): covers extension commands with no custom
320
+ // renderer, and any built-in action the domain renderer declines. Lazy, so
321
+ // a DSL that never renders pays nothing.
322
+ this.schemaFallbackRenderer = createDomainRenderer({
323
+ schemas: config.schemas,
324
+ profiles: config.languages.map(l => l.patternProfile),
325
+ });
186
326
 
187
327
  // Build SchemaLookup from config schemas for explicit syntax validation
188
328
  const schemaMap = new Map(config.schemas.map(s => [s.action, s]));
@@ -266,6 +406,22 @@ class MultilingualDSLImpl implements MultilingualDSL {
266
406
  translate(input: string, fromLanguage: string, toLanguage: string): string {
267
407
  // Explicit syntax is language-agnostic — return unchanged
268
408
  if (isExplicitSyntax(input)) return input;
409
+
410
+ // translate() is the only path that needs grammar profiles; parse/validate/
411
+ // compile work without them. `grammarProfile` is optional on LanguageConfig,
412
+ // so omitting it fails here rather than at construction — name the missing
413
+ // field instead of leaving the transformer's bare "No profile found".
414
+ for (const language of [fromLanguage, toLanguage]) {
415
+ if (!this.profileProvider.getProfile(language)) {
416
+ throw new Error(
417
+ `translate() requires a grammar profile for language "${language}", but none is ` +
418
+ `configured. Set 'grammarProfile' on the LanguageConfig for "${language}" in ` +
419
+ `createMultilingualDSL() (parse/validate/compile do not need it), or inject a ` +
420
+ `custom 'profileProvider'.`
421
+ );
422
+ }
423
+ }
424
+
269
425
  // Use injected grammar transformer
270
426
  return this.transformer.transform(input, fromLanguage, toLanguage);
271
427
  }
@@ -299,6 +455,24 @@ class MultilingualDSLImpl implements MultilingualDSL {
299
455
  }
300
456
  }
301
457
 
458
+ render(node: SemanticNode, language: string): string | null {
459
+ // 1. An extension's own renderer, if it supplied one
460
+ const extensionRenderer = this.extensionRenderers.get(node.action);
461
+ if (extensionRenderer) {
462
+ const rendered = extensionRenderer(node, language);
463
+ if (rendered != null) return rendered;
464
+ }
465
+
466
+ // 2. The domain's renderer — its hand-written cases are authoritative
467
+ if (this.domainRenderer) {
468
+ const rendered = this.domainRenderer(node, language);
469
+ if (rendered != null) return rendered;
470
+ }
471
+
472
+ // 3. Schema-driven, which is how extension commands render by default
473
+ return this.schemaFallbackRenderer(node, language);
474
+ }
475
+
302
476
  getSupportedLanguages(): string[] {
303
477
  return this.registry.getSupportedLanguages();
304
478
  }
@@ -369,12 +543,107 @@ function createDefaultProfileProvider(config: DSLConfig): ProfileProvider {
369
543
  return new InMemoryProfileProvider(profiles);
370
544
  }
371
545
 
546
+ /**
547
+ * Fold extensions into the config so that everything downstream — pattern
548
+ * generation, the dictionary, explicit-syntax lookup, rendering — sees
549
+ * extension commands exactly as it sees built-in ones.
550
+ *
551
+ * Done up front rather than by mutating a live DSL: `DSLRegistry` generates
552
+ * every language's patterns in its constructor, so a command registered later
553
+ * would compile and render but silently fail to parse.
554
+ */
555
+ function applyExtensions(config: DSLConfig): DSLConfig {
556
+ const extensions = config.extensions;
557
+ if (!extensions || extensions.length === 0) return config;
558
+
559
+ const configuredLanguages = new Set(config.languages.map(l => l.code));
560
+ const actions = new Set(config.schemas.map(s => s.action));
561
+
562
+ for (const extension of extensions) {
563
+ const { action } = extension.schema;
564
+ if (actions.has(action)) {
565
+ throw new Error(
566
+ `Extension command "${action}" collides with a command this DSL already defines. ` +
567
+ `Pick a different action name.`
568
+ );
569
+ }
570
+ actions.add(action);
571
+
572
+ for (const code of Object.keys(extension.vocabulary)) {
573
+ if (!configuredLanguages.has(code)) {
574
+ throw new Error(
575
+ `Extension command "${action}" supplies vocabulary for language "${code}", which is ` +
576
+ `not configured on this DSL. Configured languages: ` +
577
+ `${[...configuredLanguages].join(', ')}.`
578
+ );
579
+ }
580
+ }
581
+ }
582
+
583
+ const schemas = [...config.schemas, ...extensions.map(e => e.schema)];
584
+
585
+ const languages = config.languages.map(lang => {
586
+ const keywords = { ...lang.patternProfile.keywords };
587
+ let roleMarkers = lang.patternProfile.roleMarkers;
588
+
589
+ for (const extension of extensions) {
590
+ const vocabulary = extension.vocabulary[lang.code];
591
+ if (!vocabulary) continue;
592
+ keywords[extension.schema.action] = { ...vocabulary.keyword };
593
+ if (vocabulary.roleMarkers) {
594
+ roleMarkers = { ...roleMarkers, ...vocabulary.roleMarkers };
595
+ }
596
+ }
597
+
598
+ return {
599
+ ...lang,
600
+ patternProfile: {
601
+ ...lang.patternProfile,
602
+ keywords,
603
+ ...(roleMarkers !== undefined && { roleMarkers }),
604
+ },
605
+ };
606
+ });
607
+
608
+ // Extension code generators dispatch ahead of the domain's own generator,
609
+ // whose `default` branch would otherwise throw on an action it never knew.
610
+ const extensionGenerators = new Map(
611
+ extensions.filter(e => e.generate).map(e => [e.schema.action, e.generate!])
612
+ );
613
+ const baseGenerator = config.codeGenerator;
614
+ const codeGenerator: CodeGenerator | undefined =
615
+ extensionGenerators.size > 0
616
+ ? {
617
+ generate(node: SemanticNode): string {
618
+ const generate = extensionGenerators.get(node.action);
619
+ if (generate) return generate(node);
620
+ if (!baseGenerator) {
621
+ throw new Error(`No code generator for action "${node.action}"`);
622
+ }
623
+ return baseGenerator.generate(node);
624
+ },
625
+ }
626
+ : baseGenerator;
627
+
628
+ return {
629
+ ...config,
630
+ schemas,
631
+ languages,
632
+ ...(codeGenerator !== undefined && { codeGenerator }),
633
+ };
634
+ }
635
+
372
636
  export function createMultilingualDSL(config: DSLConfig): MultilingualDSL {
637
+ // Extensions must be folded in before anything reads the config
638
+ const effectiveConfig = applyExtensions(config);
639
+ const extensionActions = new Set((config.extensions ?? []).map(e => e.schema.action));
640
+
373
641
  // Create or use provided dictionary
374
- const dictionary = config.dictionary ?? createDefaultDictionary(config);
642
+ const dictionary = effectiveConfig.dictionary ?? createDefaultDictionary(effectiveConfig);
375
643
 
376
644
  // Create or use provided profile provider
377
- const profileProvider = config.profileProvider ?? createDefaultProfileProvider(config);
645
+ const profileProvider =
646
+ effectiveConfig.profileProvider ?? createDefaultProfileProvider(effectiveConfig);
378
647
 
379
648
  // Create grammar transformer with injected dependencies
380
649
  const transformer = new GrammarTransformer({
@@ -383,6 +652,6 @@ export function createMultilingualDSL(config: DSLConfig): MultilingualDSL {
383
652
  });
384
653
 
385
654
  // Create registry and implementation
386
- const registry = new DSLRegistry(config);
387
- return new MultilingualDSLImpl(config, registry, transformer);
655
+ const registry = new DSLRegistry(effectiveConfig, extensionActions);
656
+ return new MultilingualDSLImpl(effectiveConfig, registry, transformer, profileProvider);
388
657
  }
@@ -30,9 +30,8 @@
30
30
  * ```
31
31
  */
32
32
 
33
- import type { SemanticNode } from '../core/types';
34
33
  import type { MultilingualDSL, CompileResult, ValidationResult } from './create-dsl';
35
- import type { NaturalLanguageRenderer } from '../generation/renderer';
34
+ import type { NaturalLanguageRenderer, DomainRenderFn } from '../generation/renderer';
36
35
  import type { CommandSchema } from '../schema/command-schema';
37
36
  import type { SchemaLookup } from '../ir/types';
38
37
  import type { Diagnostic } from '../generation/diagnostics';
@@ -82,11 +81,12 @@ export interface DomainDescriptor {
82
81
  /**
83
82
  * Factory to lazily create the renderer.
84
83
  * Required for translate_{name} tool. Called once on first use.
84
+ *
85
+ * The function form may return `null` for an action it cannot render; the
86
+ * translate tool reports that as an unsuccessful translation.
85
87
  */
86
88
  readonly getRenderer?: () =>
87
- | NaturalLanguageRenderer
88
- | ((node: SemanticNode, language: string) => string)
89
- | Promise<NaturalLanguageRenderer | ((node: SemanticNode, language: string) => string)>;
89
+ NaturalLanguageRenderer | DomainRenderFn | Promise<NaturalLanguageRenderer | DomainRenderFn>;
90
90
 
91
91
  /**
92
92
  * Which standard tools to generate. Default: all four.
@@ -154,10 +154,7 @@ export interface MCPToolResponse {
154
154
  export class DomainRegistry {
155
155
  private descriptors = new Map<string, DomainDescriptor>();
156
156
  private dslCache = new Map<string, MultilingualDSL>();
157
- private rendererCache = new Map<
158
- string,
159
- NaturalLanguageRenderer | ((node: SemanticNode, language: string) => string)
160
- >();
157
+ private rendererCache = new Map<string, NaturalLanguageRenderer | DomainRenderFn>();
161
158
 
162
159
  /**
163
160
  * Register a domain.
@@ -361,7 +358,7 @@ export class DomainRegistry {
361
358
 
362
359
  private async getRenderer(
363
360
  descriptor: DomainDescriptor
364
- ): Promise<NaturalLanguageRenderer | ((node: SemanticNode, language: string) => string) | null> {
361
+ ): Promise<NaturalLanguageRenderer | DomainRenderFn | null> {
365
362
  if (!descriptor.getRenderer) return null;
366
363
 
367
364
  const cached = this.rendererCache.get(descriptor.name);
@@ -466,6 +463,14 @@ export class DomainRegistry {
466
463
  } catch {
467
464
  // Fall through with rendered = null
468
465
  }
466
+ } else if (dsl.render) {
467
+ // A descriptor without an explicit renderer can still render: the DSL
468
+ // knows its own domain renderer and its schema fallback.
469
+ try {
470
+ rendered = dsl.render(node, to);
471
+ } catch {
472
+ // Fall through with rendered = null
473
+ }
469
474
  }
470
475
 
471
476
  const roles: Record<string, unknown> = {};