@lokascript/framework 2.8.0 → 2.9.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/CHANGELOG.md +393 -0
- package/dist/api/create-dsl.d.ts +93 -1
- package/dist/api/create-dsl.d.ts.map +1 -1
- package/dist/api/domain-registry.d.ts +5 -3
- package/dist/api/domain-registry.d.ts.map +1 -1
- package/dist/api/index.js +232 -18
- package/dist/api/index.js.map +1 -1
- package/dist/core/index.js +26 -6
- package/dist/core/index.js.map +1 -1
- package/dist/core/tokenization/base-tokenizer.d.ts +15 -3
- package/dist/core/tokenization/base-tokenizer.d.ts.map +1 -1
- package/dist/core/tokenization/index.js +26 -6
- package/dist/core/tokenization/index.js.map +1 -1
- package/dist/core/tokenization/token-utils.d.ts +15 -0
- package/dist/core/tokenization/token-utils.d.ts.map +1 -1
- package/dist/generation/index.js +73 -47
- package/dist/generation/index.js.map +1 -1
- package/dist/generation/pattern-generator.d.ts +8 -1
- package/dist/generation/pattern-generator.d.ts.map +1 -1
- package/dist/generation/renderer.d.ts +53 -1
- package/dist/generation/renderer.d.ts.map +1 -1
- package/dist/index.cjs +302 -115
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.ts +4 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +298 -115
- package/dist/index.js.map +1 -1
- package/dist/interfaces/value-extractor.d.ts +5 -0
- package/dist/interfaces/value-extractor.d.ts.map +1 -1
- package/dist/multilingual/index.js +25 -6
- package/dist/multilingual/index.js.map +1 -1
- package/package.json +4 -3
- package/src/api/create-dsl.test.ts +11 -0
- package/src/api/create-dsl.ts +278 -9
- package/src/api/domain-registry.ts +15 -10
- package/src/api/extensions.test.ts +322 -0
- package/src/core/tokenization/base-tokenizer.ts +23 -8
- package/src/core/tokenization/css-selector-extractor.test.ts +67 -0
- package/src/core/tokenization/token-utils.ts +18 -0
- package/src/generation/domain-renderer.test.ts +172 -0
- package/src/generation/pattern-generator.test.ts +102 -0
- package/src/generation/pattern-generator.ts +27 -19
- package/src/generation/renderer.test.ts +243 -4
- package/src/generation/renderer.ts +188 -45
- package/src/index.ts +9 -1
- 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
|
|
14
|
-
import type
|
|
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
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
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
|
-
|
|
328
|
+
const keyword = lookupKeyword(tables.keywords, node.action, language);
|
|
329
|
+
const isSOV = tables.sovLanguages.has(language);
|
|
224
330
|
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
*/
|