@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
@@ -9,9 +9,9 @@
9
9
  */
10
10
 
11
11
  import type { SemanticNode } from '../core/types';
12
- import { extractRoleValue } from '../core/types';
13
- import type { CommandSchema, RoleSpec } from '../schema';
14
- import type { PatternGenLanguageProfile } from './pattern-generator';
12
+ import { extractRoleValue, extractValue } from '../core/types';
13
+ import type { CommandSchema } from '../schema';
14
+ import { sortRolesByWordOrder, type PatternGenLanguageProfile } from './pattern-generator';
15
15
 
16
16
  // =============================================================================
17
17
  // Renderer Interface
@@ -191,54 +191,197 @@ export function createSchemaRenderer(
191
191
  schemas: readonly CommandSchema[],
192
192
  profiles: readonly PatternGenLanguageProfile[]
193
193
  ): NaturalLanguageRenderer {
194
+ const tables = buildRenderTables(schemas, profiles);
195
+
196
+ return {
197
+ render(node: SemanticNode, language: string): string {
198
+ // An action with no schema still has a name — render that rather than
199
+ // nothing. `createDomainRenderer` returns null for this case instead.
200
+ return renderFromSchema(node, language, tables) ?? node.action;
201
+ },
202
+ };
203
+ }
204
+
205
+ // =============================================================================
206
+ // Domain renderer (hand-written cases + schema fallthrough)
207
+ // =============================================================================
208
+
209
+ /**
210
+ * Renders a SemanticNode, or returns `null` when the action cannot be rendered.
211
+ *
212
+ * The nullable counterpart to {@link NaturalLanguageRenderer}: `null` means "I
213
+ * do not know this action", which callers can act on. A domain that signals the
214
+ * same condition with a sentinel string forces consumers into string matching.
215
+ */
216
+ export type DomainRenderFn = (node: SemanticNode, language: string) => string | null;
217
+
218
+ /**
219
+ * Configuration for {@link createDomainRenderer}.
220
+ */
221
+ export interface DomainRendererConfig {
222
+ /** Schemas for this domain's commands, including any extensions. */
223
+ readonly schemas: readonly CommandSchema[];
224
+
225
+ /** Language profiles supplying keywords and role markers. */
226
+ readonly profiles: readonly PatternGenLanguageProfile[];
227
+
228
+ /**
229
+ * Hand-written renderers per action. These take absolute precedence over the
230
+ * schema-driven path, so a domain's existing output is preserved byte for byte.
231
+ *
232
+ * An override returning `null` means "this action cannot be rendered" and is
233
+ * passed straight through — it does not fall through to the schema path,
234
+ * since a hand-written renderer knows more about its action than the schema does.
235
+ */
236
+ readonly overrides?: Readonly<Record<string, DomainRenderFn>>;
237
+ }
238
+
239
+ /**
240
+ * Create a renderer that composes a domain's hand-written per-action renderers
241
+ * with the schema-driven fallback.
242
+ *
243
+ * Resolution order:
244
+ * 1. `overrides[node.action]` — the domain's own rendering, unchanged
245
+ * 2. schema-driven rendering — any action with a schema but no hand-written case
246
+ * 3. `null` — no override and no schema
247
+ *
248
+ * Step 2 is what makes a domain extensible: a consumer that adds a command
249
+ * schema (with per-language keywords in the profiles) gets correct word order,
250
+ * markers and keywords for free, without the domain package having to know
251
+ * about that command.
252
+ *
253
+ * @example
254
+ * ```typescript
255
+ * const render = createDomainRenderer({
256
+ * schemas: allSchemas,
257
+ * profiles: allProfiles,
258
+ * overrides: { select: renderSelect, insert: renderInsert },
259
+ * });
260
+ * render(node, 'ja'); // hand-written for `select`, schema-driven otherwise
261
+ * render(bogusNode, 'en'); // → null
262
+ * ```
263
+ */
264
+ export function createDomainRenderer(config: DomainRendererConfig): DomainRenderFn {
265
+ const { schemas, profiles, overrides } = config;
266
+
267
+ // Built on first render — a domain module can construct its renderer at import
268
+ // time without paying for table construction it may never use.
269
+ let tables: RenderTables | undefined;
270
+
271
+ return (node: SemanticNode, language: string): string | null => {
272
+ const override = overrides?.[node.action];
273
+ if (override) return override(node, language);
274
+
275
+ tables ??= buildRenderTables(schemas, profiles);
276
+ return renderFromSchema(node, language, tables);
277
+ };
278
+ }
279
+
280
+ // =============================================================================
281
+ // Shared schema-driven rendering
282
+ // =============================================================================
283
+
284
+ interface RenderTables {
285
+ readonly keywords: KeywordTable;
286
+ readonly markers: MarkerTable;
287
+ /** Marker side per role per language, from each profile's roleMarkers. */
288
+ readonly markerPositions: Record<string, Record<string, 'before' | 'after'>>;
289
+ readonly sovLanguages: ReadonlySet<string>;
290
+ readonly schemaMap: ReadonlyMap<string, CommandSchema>;
291
+ }
292
+
293
+ function buildRenderTables(
294
+ schemas: readonly CommandSchema[],
295
+ profiles: readonly PatternGenLanguageProfile[]
296
+ ): RenderTables {
194
297
  const { keywords, markers } = buildTablesFromProfiles(schemas, profiles);
195
298
  const { sovLanguages } = detectWordOrders(profiles);
299
+
300
+ const markerPositions: Record<string, Record<string, 'before' | 'after'>> = {};
301
+ for (const profile of profiles) {
302
+ for (const [role, markerDef] of Object.entries(profile.roleMarkers ?? {})) {
303
+ if (!markerDef.position) continue;
304
+ markerPositions[role] ??= {};
305
+ markerPositions[role][profile.code] = markerDef.position;
306
+ }
307
+ }
308
+
196
309
  const schemaMap = new Map<string, CommandSchema>();
197
310
  for (const s of schemas) schemaMap.set(s.action, s);
311
+ return { keywords, markers, markerPositions, sovLanguages, schemaMap };
312
+ }
198
313
 
199
- return {
200
- render(node: SemanticNode, language: string): string {
201
- const schema = schemaMap.get(node.action);
202
- if (!schema) return node.action;
203
-
204
- const keyword = lookupKeyword(keywords, node.action, language);
205
- const isSOV = sovLanguages.has(language);
206
-
207
- // Collect role parts in schema order
208
- const roleParts: Array<{ marker?: string; value: string; role: RoleSpec }> = [];
209
- for (const role of schema.roles) {
210
- const value = extractRoleValue(node, role.role);
211
- if (!value && !role.required) continue;
212
-
213
- const markerText =
214
- role.markerOverride?.[language] ?? markers[role.role]?.[language] ?? undefined;
215
-
216
- roleParts.push({
217
- ...(markerText != null && { marker: markerText }),
218
- value: value || '',
219
- role,
220
- });
221
- }
314
+ /**
315
+ * The one schema-driven rendering algorithm, shared by `createSchemaRenderer`
316
+ * and `createDomainRenderer` so the two can never drift.
317
+ *
318
+ * Returns `null` when the action has no schema.
319
+ */
320
+ function renderFromSchema(
321
+ node: SemanticNode,
322
+ language: string,
323
+ tables: RenderTables
324
+ ): string | null {
325
+ const schema = tables.schemaMap.get(node.action);
326
+ if (!schema) return null;
222
327
 
223
- const parts: string[] = [];
328
+ const keyword = lookupKeyword(tables.keywords, node.action, language);
329
+ const isSOV = tables.sovLanguages.has(language);
224
330
 
225
- if (isSOV) {
226
- // SOV: roles (with markers after values) then keyword
227
- for (const rp of roleParts) {
228
- if (rp.value) parts.push(rp.value);
229
- if (rp.marker) parts.push(rp.marker);
230
- }
231
- parts.push(keyword);
232
- } else {
233
- // SVO/VSO: keyword then roles (with markers before values)
234
- parts.push(keyword);
235
- for (const rp of roleParts) {
236
- if (rp.marker) parts.push(rp.marker);
237
- if (rp.value) parts.push(rp.value);
238
- }
239
- }
331
+ // Order roles by their declared positions, using the SAME comparator pattern
332
+ // generation uses. Rendering in declaration order instead would produce a
333
+ // surface the generated pattern cannot re-parse whenever the two disagree.
334
+ const ordered = sortRolesByWordOrder([...schema.roles], isSOV ? 'SOV' : 'SVO');
240
335
 
241
- return buildPhrase(...parts);
242
- },
243
- };
336
+ // Roles rendered before the verb, and (SOV only) after it.
337
+ const preVerb: string[] = [];
338
+ const postVerb: string[] = [];
339
+
340
+ for (const role of ordered) {
341
+ let value = extractRoleValue(node, role.role);
342
+ if (!value && role.default !== undefined) {
343
+ value = String(extractValue(role.default));
344
+ }
345
+ // A role with no value is skipped entirely — including its marker. Emitting
346
+ // the marker of an absent role produces dangling text ("analyze #content as",
347
+ // "#content として 分析"), which is never valid surface syntax. This applies to
348
+ // required roles too: a required role with no value is a malformed node, and
349
+ // a dangling marker is a worse rendering of it than simply leaving it out.
350
+ if (!value) continue;
351
+
352
+ if (role.quoteMultiword && /\s/.test(value) && !/^(["']).*\1$/.test(value)) {
353
+ value = `"${value}"`;
354
+ }
355
+
356
+ // Marker resolution, most specific first. `renderOverride` for this exact
357
+ // language is the most specific statement; `''` means "render this role
358
+ // bare" (a marker that exists only to help the parser). The `'*'` key is a
359
+ // blanket statement about the PROFILE default, so it ranks below a
360
+ // per-language markerOverride — which is how SQL's `get` renders bare in
361
+ // SVO languages while keeping its SOV source particles.
362
+ const markerText =
363
+ role.renderOverride?.[language] ??
364
+ role.markerOverride?.[language] ??
365
+ role.renderOverride?.['*'] ??
366
+ tables.markers[role.role]?.[language];
367
+
368
+ const markerPosition =
369
+ role.markerPositionOverride?.[language] ??
370
+ role.markerPosition ??
371
+ tables.markerPositions[role.role]?.[language] ??
372
+ (isSOV ? 'after' : 'before');
373
+
374
+ const bucket = isSOV && role.sovSlot === 'postVerb' ? postVerb : preVerb;
375
+ if (markerText && markerPosition === 'before') {
376
+ bucket.push(markerText, value);
377
+ } else if (markerText) {
378
+ bucket.push(value, markerText);
379
+ } else {
380
+ bucket.push(value);
381
+ }
382
+ }
383
+
384
+ return isSOV
385
+ ? buildPhrase(...preVerb, keyword, ...postVerb)
386
+ : buildPhrase(keyword, ...preVerb, ...postVerb);
244
387
  }
package/src/index.ts CHANGED
@@ -107,6 +107,8 @@ export type {
107
107
  KeywordTable,
108
108
  MarkerTable,
109
109
  RendererConfig,
110
+ DomainRenderFn,
111
+ DomainRendererConfig,
110
112
  } from './generation/renderer';
111
113
  export {
112
114
  lookupKeyword,
@@ -115,6 +117,7 @@ export {
115
117
  buildTablesFromProfiles,
116
118
  detectWordOrders,
117
119
  createSchemaRenderer,
120
+ createDomainRenderer,
118
121
  } from './generation/renderer';
119
122
 
120
123
  // Diagnostics
@@ -133,6 +136,8 @@ export type {
133
136
  DSLConfig,
134
137
  MultilingualDSL,
135
138
  CodeGenerator,
139
+ DomainExtension,
140
+ ExtensionVocabulary,
136
141
  ValidationResult,
137
142
  CompileResult,
138
143
  DomainDescriptor,
@@ -145,7 +150,10 @@ export type {
145
150
  DispatcherOptions,
146
151
  } from './api';
147
152
 
148
- export { DomainRegistry, CrossDomainDispatcher } from './api';
153
+ // `createMultilingualDSL` is the package's primary entry point, so it is
154
+ // exported by name rather than reaching consumers only through `export * from
155
+ // './api'` above.
156
+ export { createMultilingualDSL, DomainRegistry, CrossDomainDispatcher } from './api';
149
157
 
150
158
  // Re-export helper functions
151
159
  export {
@@ -348,6 +348,56 @@ export class LatinExtendedIdentifierExtractor implements ValueExtractor {
348
348
  }
349
349
  }
350
350
 
351
+ /**
352
+ * CSS selector extractor — keeps `#id` and `.class` a SINGLE token.
353
+ *
354
+ * Without it the sigil is split off as its own token and the role capture keeps
355
+ * only that sigil: `add .active to #button` parses with patient `"."` and
356
+ * destination `"#"`, silently, in every language. Five domain DSLs each carried
357
+ * a private copy of this class and four (learn, todo, sql, jsx) had none — this
358
+ * is the shared one; register it via `customExtractors`.
359
+ *
360
+ * The character after the sigil must be a letter, `_` or `-`: a CSS identifier
361
+ * cannot start with a digit, and refusing to claim a bare `.`/`#` leaves
362
+ * property access and other uses of those characters to the extractors that own
363
+ * them.
364
+ *
365
+ * The body is Unicode so diacritics survive (`.año`, not `.a`) but STOPS at Han,
366
+ * kana and Hangul. Those scripts are where the SOV languages write their
367
+ * particles, and a selector is written adjacent to them with no space:
368
+ * `#buttonに .activeを 追加` must yield `#button` + `に`, not a `#buttonに` that
369
+ * swallows the particle and takes the role marker with it.
370
+ */
371
+ const SELECTOR_BODY_CHAR = /[\p{L}\p{N}_-]/u;
372
+ const PARTICLE_SCRIPT_CHAR = /[\p{sc=Han}\p{sc=Hiragana}\p{sc=Katakana}\p{sc=Hangul}]/u;
373
+
374
+ function isSelectorBodyChar(char: string): boolean {
375
+ return SELECTOR_BODY_CHAR.test(char) && !PARTICLE_SCRIPT_CHAR.test(char);
376
+ }
377
+
378
+ export class CssSelectorExtractor implements ValueExtractor {
379
+ readonly name = 'css-selector';
380
+
381
+ canExtract(input: string, position: number): boolean {
382
+ const char = input[position];
383
+ if (char !== '#' && char !== '.') return false;
384
+ const next = input[position + 1];
385
+ if (next === undefined) return false;
386
+ return (
387
+ next === '_' || next === '-' || (/\p{L}/u.test(next) && !PARTICLE_SCRIPT_CHAR.test(next))
388
+ );
389
+ }
390
+
391
+ extract(input: string, position: number): ExtractionResult | null {
392
+ let end = position + 1;
393
+ while (end < input.length && isSelectorBodyChar(input[end])) {
394
+ end++;
395
+ }
396
+ if (end === position + 1) return null;
397
+ return { value: input.slice(position, end), length: end - position };
398
+ }
399
+ }
400
+
351
401
  /**
352
402
  * Whitespace extractor - handles spaces, tabs, newlines.
353
403
  */