@hyperfixi/patterns-reference 3.1.1 → 3.3.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 +186 -1
- package/README.md +47 -36
- package/data/engine-verification.json +58 -65
- package/data/patterns.db +0 -0
- package/data/patterns.db.stamp +1 -0
- package/dist/api/index.d.mts +2 -2
- package/dist/api/index.d.ts +2 -2
- package/dist/api/index.js +198 -98
- package/dist/api/index.mjs +197 -100
- package/dist/{index-6GkHj5yJ.d.mts → index-CoDUfq2P.d.mts} +39 -3
- package/dist/{index-6GkHj5yJ.d.ts → index-CoDUfq2P.d.ts} +39 -3
- package/dist/index.d.mts +87 -13
- package/dist/index.d.ts +87 -13
- package/dist/index.js +261 -144
- package/dist/index.mjs +258 -146
- package/dist/{llm-B5nGz8V1.d.mts → llm-CCCSw-yp.d.ts} +21 -10
- package/dist/{llm-rEdJQScF.d.ts → llm-PxnriEZ_.d.mts} +21 -10
- package/dist/sync/index.d.mts +1 -1
- package/dist/sync/index.d.ts +1 -1
- package/dist/sync/index.js +4 -1
- package/dist/sync/index.mjs +4 -1
- package/package.json +16 -16
- package/src/adapters/llm-adapter.ts +64 -42
- package/src/api/engine-filter.ts +37 -0
- package/src/api/llm.ts +54 -64
- package/src/api/patterns.ts +92 -30
- package/src/api/roles.ts +6 -4
- package/src/api/translations.ts +34 -27
- package/src/database/connection.ts +16 -4
- package/src/html-snippets.ts +39 -10
- package/src/index.ts +12 -2
- package/src/registry/patterns-provider.ts +3 -1
- package/src/sync/db-stamp.ts +7 -4
- package/src/sync/markup-attributes.ts +15 -0
- package/src/sync/verify-parses.ts +42 -0
- package/src/types/better-sqlite3.d.ts +2 -0
- package/src/types/index.ts +44 -2
- package/src/sync/span-mask.ts +0 -166
- package/src/sync/translation-checks.ts +0 -282
package/src/html-snippets.ts
CHANGED
|
@@ -40,6 +40,8 @@ export interface MinimalElement {
|
|
|
40
40
|
tagName: string;
|
|
41
41
|
attributes: ArrayLike<MinimalAttr>;
|
|
42
42
|
textContent: string | null;
|
|
43
|
+
/** Read only on `<template>`, whose content `querySelectorAll` cannot reach. */
|
|
44
|
+
innerHTML?: string;
|
|
43
45
|
getAttribute(name: string): string | null;
|
|
44
46
|
}
|
|
45
47
|
|
|
@@ -53,7 +55,11 @@ export interface MinimalDocument {
|
|
|
53
55
|
}
|
|
54
56
|
|
|
55
57
|
export interface MarkupSnippets {
|
|
56
|
-
/**
|
|
58
|
+
/**
|
|
59
|
+
* Every hyperscript source found, in document order — including the `_`
|
|
60
|
+
* attributes inside component template bodies, which run once the
|
|
61
|
+
* component renders.
|
|
62
|
+
*/
|
|
57
63
|
snippets: string[];
|
|
58
64
|
/**
|
|
59
65
|
* The markup uses at least one attribute upstream `_hyperscript` has no
|
|
@@ -61,6 +67,12 @@ export interface MarkupSnippets {
|
|
|
61
67
|
* how its snippets parse.
|
|
62
68
|
*/
|
|
63
69
|
hyperfixiOnly: boolean;
|
|
70
|
+
/**
|
|
71
|
+
* Custom-element names the markup defines as template components. A
|
|
72
|
+
* component's behavior is its RENDER, which no parse of its snippets can
|
|
73
|
+
* check, so a verifier should instantiate each one.
|
|
74
|
+
*/
|
|
75
|
+
componentTags: string[];
|
|
64
76
|
}
|
|
65
77
|
|
|
66
78
|
/**
|
|
@@ -72,6 +84,13 @@ export interface MarkupSnippets {
|
|
|
72
84
|
* `sse-swap` event names, `ws-connect` URLs) are not hyperscript and carry no
|
|
73
85
|
* snippet to verify, but they do set `hyperfixiOnly`.
|
|
74
86
|
*
|
|
87
|
+
* Template components — `<script type="text/hyperscript-template"
|
|
88
|
+
* component="x">` (upstream's form, which upstream's official `component`
|
|
89
|
+
* extension implements and @hyperfixi/components also accepts) and
|
|
90
|
+
* `<template component="x">` — are NOT hyperfixi-only. Their bodies are markup
|
|
91
|
+
* the DOM walk cannot reach (script text; template content), so each is
|
|
92
|
+
* extracted recursively and its `_` sources join `snippets`.
|
|
93
|
+
*
|
|
75
94
|
* `doc` is any DOM `Document` — a jsdom window's, or the ambient one in a
|
|
76
95
|
* browser/jsdom test environment. Markup that fails to parse yields no
|
|
77
96
|
* snippets rather than throwing.
|
|
@@ -81,10 +100,11 @@ export function extractHyperscriptFromMarkup(doc: MinimalDocument, markup: strin
|
|
|
81
100
|
try {
|
|
82
101
|
container.innerHTML = markup;
|
|
83
102
|
} catch {
|
|
84
|
-
return { snippets: [], hyperfixiOnly: false };
|
|
103
|
+
return { snippets: [], hyperfixiOnly: false, componentTags: [] };
|
|
85
104
|
}
|
|
86
105
|
|
|
87
106
|
const snippets: string[] = [];
|
|
107
|
+
const componentTags: string[] = [];
|
|
88
108
|
let hyperfixiOnly = false;
|
|
89
109
|
|
|
90
110
|
for (const el of Array.from(container.querySelectorAll('*'))) {
|
|
@@ -100,15 +120,24 @@ export function extractHyperscriptFromMarkup(doc: MinimalDocument, markup: strin
|
|
|
100
120
|
}
|
|
101
121
|
}
|
|
102
122
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
123
|
+
const tag = el.getAttribute('component');
|
|
124
|
+
const isScriptTemplate =
|
|
125
|
+
el.tagName === 'SCRIPT' && el.getAttribute('type') === 'text/hyperscript-template';
|
|
126
|
+
if (tag && (isScriptTemplate || el.tagName === 'TEMPLATE')) {
|
|
127
|
+
componentTags.push(tag);
|
|
128
|
+
const body = isScriptTemplate ? (el.textContent ?? '') : (el.innerHTML ?? '');
|
|
129
|
+
const inner = extractHyperscriptFromMarkup(doc, body);
|
|
130
|
+
snippets.push(...inner.snippets);
|
|
131
|
+
componentTags.push(...inner.componentTags);
|
|
132
|
+
hyperfixiOnly ||= inner.hyperfixiOnly;
|
|
133
|
+
} else if (
|
|
134
|
+
el.tagName === 'SCRIPT' &&
|
|
135
|
+
el.getAttribute('type') === 'text/hyperscript' &&
|
|
136
|
+
el.textContent?.trim()
|
|
137
|
+
) {
|
|
138
|
+
snippets.push(el.textContent);
|
|
110
139
|
}
|
|
111
140
|
}
|
|
112
141
|
|
|
113
|
-
return { snippets, hyperfixiOnly };
|
|
142
|
+
return { snippets, hyperfixiOnly, componentTags };
|
|
114
143
|
}
|
package/src/index.ts
CHANGED
|
@@ -44,6 +44,7 @@ export type {
|
|
|
44
44
|
|
|
45
45
|
// LLM types
|
|
46
46
|
LLMExample,
|
|
47
|
+
ExampleOptions,
|
|
47
48
|
|
|
48
49
|
// Language documentation types
|
|
49
50
|
Command,
|
|
@@ -264,8 +265,8 @@ export function createPatternsReference(options?: ConnectionOptions): PatternsRe
|
|
|
264
265
|
verifyTranslation: translation => translations.verifyTranslation(translation, options),
|
|
265
266
|
|
|
266
267
|
// LLM
|
|
267
|
-
getLLMExamples: (prompt, language, limit) =>
|
|
268
|
-
llm.getLLMExamples(prompt, language, limit, options),
|
|
268
|
+
getLLMExamples: (prompt, language, limit, engine) =>
|
|
269
|
+
llm.getLLMExamples(prompt, language, limit, { ...options, engine }),
|
|
269
270
|
|
|
270
271
|
// Stats
|
|
271
272
|
getStats: () => patterns.getPatternStats(options),
|
|
@@ -283,6 +284,15 @@ export function createPatternsReference(options?: ConnectionOptions): PatternsRe
|
|
|
283
284
|
export { extractHyperscriptFromMarkup } from './html-snippets';
|
|
284
285
|
export type { MarkupSnippets } from './html-snippets';
|
|
285
286
|
|
|
287
|
+
/**
|
|
288
|
+
* The corpus writer's own locator for the `_="…"` bodies it translates in a
|
|
289
|
+
* markup row. Exported so the testing-framework's en-reference-preservation
|
|
290
|
+
* gate checks exactly the bodies the writer renders — not the broader set
|
|
291
|
+
* `extractHyperscriptFromMarkup` finds for whole files.
|
|
292
|
+
*/
|
|
293
|
+
export { findHyperscriptAttributes, isMarkupRow } from './sync/markup-attributes';
|
|
294
|
+
export type { AttributeSpan } from './sync/markup-attributes';
|
|
295
|
+
|
|
286
296
|
/**
|
|
287
297
|
* Version of the package.
|
|
288
298
|
*/
|
|
@@ -122,7 +122,9 @@ export class DatabasePatternsProvider implements PatternsSource {
|
|
|
122
122
|
command: p.primaryCommand,
|
|
123
123
|
language: language || 'en',
|
|
124
124
|
confidence: 1.0,
|
|
125
|
-
|
|
125
|
+
// The English source pattern: "verified" = some engine runs it
|
|
126
|
+
// (code_examples.engine, mechanically checked) — not a constant.
|
|
127
|
+
verified: p.engine !== null,
|
|
126
128
|
title: p.title,
|
|
127
129
|
category: p.category || undefined,
|
|
128
130
|
}));
|
package/src/sync/db-stamp.ts
CHANGED
|
@@ -43,9 +43,9 @@ function walkTsFiles(dir: string, acc: string[]): void {
|
|
|
43
43
|
}
|
|
44
44
|
|
|
45
45
|
/**
|
|
46
|
-
* The source files whose content determines `patterns.db`: the
|
|
47
|
-
*
|
|
48
|
-
*
|
|
46
|
+
* The source files whose content determines `patterns.db`: the semantic parser +
|
|
47
|
+
* renderer + profiles (translation text, stored confidence, the language set), the
|
|
48
|
+
* i18n sources, the seed / sync scripts, and the sync helpers the writer calls.
|
|
49
49
|
*/
|
|
50
50
|
function dbInputFiles(dbPath: string): string[] {
|
|
51
51
|
const root = repoRootFromDbPath(dbPath);
|
|
@@ -56,7 +56,10 @@ function dbInputFiles(dbPath: string): string[] {
|
|
|
56
56
|
for (const f of [
|
|
57
57
|
join(pr, 'scripts', 'init-db.ts'),
|
|
58
58
|
join(pr, 'scripts', 'sync-translations.ts'),
|
|
59
|
-
|
|
59
|
+
// Decides which markup `_` bodies are translated (and which stay English).
|
|
60
|
+
join(pr, 'src', 'sync', 'markup-attributes.ts'),
|
|
61
|
+
// Writes every row's `verified_parses`.
|
|
62
|
+
join(pr, 'src', 'sync', 'verify-parses.ts'),
|
|
60
63
|
// Committed engine-verification results are seeded into the engine
|
|
61
64
|
// column by init-db.ts, so they are DB input too.
|
|
62
65
|
join(pr, 'data', 'engine-verification.json'),
|
|
@@ -88,6 +88,21 @@ export function isMarkupRow(code: string): boolean {
|
|
|
88
88
|
return /^\s*<[a-zA-Z!]/.test(code);
|
|
89
89
|
}
|
|
90
90
|
|
|
91
|
+
/** `hx-live="…"` values are hyperscript too (the htmx-compat layer compiles them). */
|
|
92
|
+
const HX_LIVE_ATTRIBUTE = /(^|[\s"'])hx-live\s*=\s*(?:"([^"]*)"|'([^']*)')/g;
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* The hyperscript a stored row carries: the row itself, or — for a markup row —
|
|
96
|
+
* each `_` value (including those inside component template bodies) and each
|
|
97
|
+
* `hx-live` value. Markup with neither carries none.
|
|
98
|
+
*/
|
|
99
|
+
export function hyperscriptBodies(code: string): string[] {
|
|
100
|
+
if (!isMarkupRow(code)) return [code];
|
|
101
|
+
const bodies = findHyperscriptAttributes(code).map(span => span.body);
|
|
102
|
+
for (const match of code.matchAll(HX_LIVE_ATTRIBUTE)) bodies.push(match[2] ?? match[3] ?? '');
|
|
103
|
+
return bodies.filter(body => body.trim().length > 0);
|
|
104
|
+
}
|
|
105
|
+
|
|
91
106
|
/**
|
|
92
107
|
* Whether a parse carried the whole body: every non-whitespace character of the
|
|
93
108
|
* source reappears in its own English re-render.
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `pattern_translations.verified_parses`, MEASURED.
|
|
3
|
+
*
|
|
4
|
+
* The flag answers one question: does `@lokascript/semantic`'s parser accept
|
|
5
|
+
* this stored row in its own language? It used to be written as
|
|
6
|
+
* `language === 'en' ? 1 : 0` — never measured — so every consumer asking for
|
|
7
|
+
* "successfully parsed" translations (`getVerifiedTranslations`, the semantic
|
|
8
|
+
* `PatternsProvider`, `getSupportedLanguages()`, testing-framework's
|
|
9
|
+
* `--verified-only`) silently got English only. Its other writer, the since
|
|
10
|
+
* retired `validate-all --fix`, set it from bracket/quote balance without
|
|
11
|
+
* parsing. This is now the only definition; sync-translations, `npm run verify`
|
|
12
|
+
* and `verifyTranslation()` all call it.
|
|
13
|
+
*
|
|
14
|
+
* "Parses" is not "faithful": a parse can be non-null while dropping commands.
|
|
15
|
+
* Fidelity is the multilingual gate's job (its R0–R5 ratchets, against the
|
|
16
|
+
* English parse) and the en-reference-preservation gate's (the English parse
|
|
17
|
+
* against the source); engine validity is `code_examples.engine`'s.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { canParse } from '@lokascript/semantic';
|
|
21
|
+
import { hyperscriptBodies } from './markup-attributes';
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Whether every hyperscript body of a stored row parses in `language`.
|
|
25
|
+
*
|
|
26
|
+
* A row with no hyperscript at all (`sse-connect` wiring, a template with no
|
|
27
|
+
* `_`) has nothing that could parse and is NOT verified. A non-translatable row
|
|
28
|
+
* stores the same English markup in every language, and English is what the
|
|
29
|
+
* runtime reads from it, so its bodies parse as `en` whatever row it is.
|
|
30
|
+
*/
|
|
31
|
+
export function verifyParses(code: string, language: string, translatable = true): boolean {
|
|
32
|
+
const bodies = hyperscriptBodies(code);
|
|
33
|
+
if (bodies.length === 0) return false;
|
|
34
|
+
const parseLanguage = translatable ? language : 'en';
|
|
35
|
+
return bodies.every(body => {
|
|
36
|
+
try {
|
|
37
|
+
return canParse(body, parseLanguage);
|
|
38
|
+
} catch {
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
}
|
|
@@ -10,6 +10,7 @@ declare module 'better-sqlite3' {
|
|
|
10
10
|
prepare<T = unknown>(sql: string): Statement<T>;
|
|
11
11
|
exec(sql: string): this;
|
|
12
12
|
pragma(pragma: string, options?: { simple?: boolean }): unknown;
|
|
13
|
+
transaction<A extends unknown[], R>(fn: (...args: A) => R): (...args: A) => R;
|
|
13
14
|
close(): void;
|
|
14
15
|
}
|
|
15
16
|
|
|
@@ -37,6 +38,7 @@ declare module 'better-sqlite3' {
|
|
|
37
38
|
prepare<T = unknown>(sql: string): BetterSqlite3.Statement<T>;
|
|
38
39
|
exec(sql: string): this;
|
|
39
40
|
pragma(pragma: string, options?: { simple?: boolean }): unknown;
|
|
41
|
+
transaction<A extends unknown[], R>(fn: (...args: A) => R): (...args: A) => R;
|
|
40
42
|
close(): void;
|
|
41
43
|
}
|
|
42
44
|
|
package/src/types/index.ts
CHANGED
|
@@ -28,6 +28,12 @@ export interface Pattern {
|
|
|
28
28
|
tags: string[];
|
|
29
29
|
difficulty: 'beginner' | 'intermediate' | 'advanced';
|
|
30
30
|
engine: EngineCompat | null;
|
|
31
|
+
/**
|
|
32
|
+
* Whether the corpus writer translates this row. `false` rows are copied
|
|
33
|
+
* verbatim into every language (markup whose attribute names are resolved by
|
|
34
|
+
* vocab modules, or markup with no hyperscript at all).
|
|
35
|
+
*/
|
|
36
|
+
translatable: boolean;
|
|
31
37
|
createdAt: Date;
|
|
32
38
|
}
|
|
33
39
|
|
|
@@ -67,7 +73,8 @@ export type WordOrder = 'SVO' | 'SOV' | 'VSO' | 'V2';
|
|
|
67
73
|
* - `grammar-transform-no-reference`: `best` only — the i18n row because semantic
|
|
68
74
|
* cannot parse the ENGLISH source (a parser-coverage gap, not a render loss).
|
|
69
75
|
* - `keyword-substitute`: word-for-word fallback for a language with no grammar profile.
|
|
70
|
-
* - `original`: the English row; `non-translatable-identity`:
|
|
76
|
+
* - `original`: the English row; `non-translatable-identity`: a non-translatable row copied
|
|
77
|
+
* verbatim (the markup rows, and intercept-cache-strategies).
|
|
71
78
|
*/
|
|
72
79
|
export type TranslationMethod =
|
|
73
80
|
| 'semantic-render'
|
|
@@ -185,8 +192,25 @@ export interface LLMExample {
|
|
|
185
192
|
prompt: string;
|
|
186
193
|
completion: string;
|
|
187
194
|
qualityScore: number;
|
|
195
|
+
/** @deprecated 0 unless something calls trackExampleUsage() (see getMostUsedExamples). */
|
|
188
196
|
usageCount: number;
|
|
189
197
|
createdAt: Date;
|
|
198
|
+
/**
|
|
199
|
+
* The engine(s) verified to run this example's pattern
|
|
200
|
+
* (`code_examples.engine`). Never null in results: an example no engine
|
|
201
|
+
* runs is not served.
|
|
202
|
+
*/
|
|
203
|
+
engine: EngineCompat | null;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Options for the LLM-example getters. `engine` narrows to examples whose
|
|
208
|
+
* pattern runs on that engine — 'hyperscript' = both + upstream-only,
|
|
209
|
+
* 'lokascript' = both + hyperfixi-only, 'both' = both. With or without it,
|
|
210
|
+
* an example whose pattern NO engine runs is never returned.
|
|
211
|
+
*/
|
|
212
|
+
export interface ExampleOptions extends ConnectionOptions {
|
|
213
|
+
engine?: EngineCompat;
|
|
190
214
|
}
|
|
191
215
|
|
|
192
216
|
// =============================================================================
|
|
@@ -233,10 +257,22 @@ export interface RoleAlignmentResult {
|
|
|
233
257
|
// API Types
|
|
234
258
|
// =============================================================================
|
|
235
259
|
|
|
260
|
+
/** Honoured by searchPatterns and getAllPatterns; the page is taken after every filter. */
|
|
236
261
|
export interface SearchOptions {
|
|
262
|
+
/**
|
|
263
|
+
* Patterns usable in this language: a translation there that parses
|
|
264
|
+
* (`verified_parses`), or no hyperscript at all to translate. searchPatterns
|
|
265
|
+
* also matches its query against that translation.
|
|
266
|
+
*/
|
|
237
267
|
language?: string;
|
|
268
|
+
/** The pattern's category (`Pattern.category`). */
|
|
238
269
|
category?: string;
|
|
270
|
+
/** As inferred from the code (`Pattern.difficulty`). */
|
|
239
271
|
difficulty?: 'beginner' | 'intermediate' | 'advanced';
|
|
272
|
+
/**
|
|
273
|
+
* Patterns that run on this engine ('hyperscript' / 'lokascript' include
|
|
274
|
+
* 'both'); `null` = the patterns no engine runs; omitted = no filter.
|
|
275
|
+
*/
|
|
240
276
|
engine?: EngineCompat | null;
|
|
241
277
|
limit?: number;
|
|
242
278
|
offset?: number;
|
|
@@ -443,7 +479,13 @@ export interface PatternsReference {
|
|
|
443
479
|
verifyTranslation(translation: Translation): Promise<VerificationResult>;
|
|
444
480
|
|
|
445
481
|
// LLM support
|
|
446
|
-
|
|
482
|
+
/** Never returns an example no engine runs; `engine` narrows further (see ExampleOptions). */
|
|
483
|
+
getLLMExamples(
|
|
484
|
+
prompt: string,
|
|
485
|
+
language?: string,
|
|
486
|
+
limit?: number,
|
|
487
|
+
engine?: EngineCompat
|
|
488
|
+
): Promise<LLMExample[]>;
|
|
447
489
|
|
|
448
490
|
// Statistics
|
|
449
491
|
getStats(): Promise<PatternStats>;
|
package/src/sync/span-mask.ts
DELETED
|
@@ -1,166 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Span-aware masking for hyperscript code prior to translation.
|
|
3
|
-
*
|
|
4
|
-
* The grammar transformer and keyword-substitute fallback both treat hyperscript
|
|
5
|
-
* source as a flat token stream. They will happily reorder text inside HTML
|
|
6
|
-
* elements, translate words inside string literals, and shred bracket
|
|
7
|
-
* expressions like `[key is 'Escape']`. To avoid that, we replace
|
|
8
|
-
* non-translatable spans with opaque placeholder tokens before translation,
|
|
9
|
-
* then restore them after.
|
|
10
|
-
*
|
|
11
|
-
* Placeholders use the form `__HFXMSK_<idx>_<KIND>__`. Underscores on both
|
|
12
|
-
* sides make the placeholder a single "word" to JS regex `\b` boundaries —
|
|
13
|
-
* keyword substitution `\bword\b` cannot match a substring inside the
|
|
14
|
-
* placeholder, regardless of case-insensitivity flags.
|
|
15
|
-
*/
|
|
16
|
-
|
|
17
|
-
export type SpanKind =
|
|
18
|
-
| 'string-single'
|
|
19
|
-
| 'string-double'
|
|
20
|
-
| 'string-template'
|
|
21
|
-
| 'url'
|
|
22
|
-
| 'html-text'
|
|
23
|
-
| 'directive'
|
|
24
|
-
| 'bracket-expr'
|
|
25
|
-
| 'js-block';
|
|
26
|
-
|
|
27
|
-
export interface MaskedSpan {
|
|
28
|
-
kind: SpanKind;
|
|
29
|
-
original: string;
|
|
30
|
-
placeholder: string;
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
export interface MaskResult {
|
|
34
|
-
masked: string;
|
|
35
|
-
spans: MaskedSpan[];
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
const KIND_TAG: Record<SpanKind, string> = {
|
|
39
|
-
'string-single': 'STR1',
|
|
40
|
-
'string-double': 'STR2',
|
|
41
|
-
'string-template': 'STRT',
|
|
42
|
-
url: 'URL',
|
|
43
|
-
'html-text': 'TEXT',
|
|
44
|
-
directive: 'DIR',
|
|
45
|
-
'bracket-expr': 'BRK',
|
|
46
|
-
'js-block': 'JS',
|
|
47
|
-
};
|
|
48
|
-
|
|
49
|
-
function makePlaceholder(kind: SpanKind, idx: number): string {
|
|
50
|
-
return `__HFXMSK_${idx}_${KIND_TAG[kind]}__`;
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
const PLACEHOLDER_RE = /__HFXMSK_(\d+)_(?:STR1|STR2|STRT|URL|TEXT|DIR|BRK|JS)__/g;
|
|
54
|
-
|
|
55
|
-
/**
|
|
56
|
-
* Detect non-translatable spans in hyperscript code and replace them with
|
|
57
|
-
* opaque placeholders. The detection order is significant: earlier rules
|
|
58
|
-
* consume characters that later rules would otherwise misinterpret.
|
|
59
|
-
*
|
|
60
|
-
* 1. Template literals (consume `${...}` interpolation contents)
|
|
61
|
-
* 2. Double-quoted strings (consume HTML attribute values)
|
|
62
|
-
* 3. Single-quoted strings (escaping possessive `'s`)
|
|
63
|
-
* 4. HTML inner text (between `>` and `<` with non-whitespace)
|
|
64
|
-
* 5. Bracket expressions (event filters like `[key is 'Escape']`)
|
|
65
|
-
* 6. URL-like tokens (paths and protocol://host)
|
|
66
|
-
* 7. Component template directives (`#if`, `#end`, `#for`, ...)
|
|
67
|
-
*/
|
|
68
|
-
export function maskSpans(input: string): MaskResult {
|
|
69
|
-
let working = input;
|
|
70
|
-
const spans: MaskedSpan[] = [];
|
|
71
|
-
|
|
72
|
-
const record = (kind: SpanKind, original: string): string => {
|
|
73
|
-
const placeholder = makePlaceholder(kind, spans.length);
|
|
74
|
-
spans.push({ kind, original, placeholder });
|
|
75
|
-
return placeholder;
|
|
76
|
-
};
|
|
77
|
-
|
|
78
|
-
// 0. Embedded JS blocks: `js(args) ... end` opens a raw-JavaScript escape
|
|
79
|
-
// hatch inside hyperscript. Its body must be passed through verbatim;
|
|
80
|
-
// the grammar transformer would otherwise tokenize JavaScript as if
|
|
81
|
-
// it were hyperscript and silently drop unrecognized tokens. Mask this
|
|
82
|
-
// first because the body can contain any other span kind.
|
|
83
|
-
working = working.replace(/\bjs\([^)]*\)[\s\S]*?\bend\b/g, m => record('js-block', m));
|
|
84
|
-
|
|
85
|
-
// 1. Template literals — match outermost backtick body with escape support.
|
|
86
|
-
// The body may contain `${...}` interpolations; treat the whole literal
|
|
87
|
-
// as one opaque span. Inner code stays English but is structurally safe.
|
|
88
|
-
working = working.replace(/`(?:\\.|[^`\\])*`/g, m => record('string-template', m));
|
|
89
|
-
|
|
90
|
-
// 2. Double-quoted strings. This also captures HTML attribute values so
|
|
91
|
-
// `component="my-layout"` cannot have `my` translated.
|
|
92
|
-
working = working.replace(/"(?:\\.|[^"\\])*"/g, m => record('string-double', m));
|
|
93
|
-
|
|
94
|
-
// 3. Single-quoted strings, but NOT possessive `'s` (preceded by a word char).
|
|
95
|
-
// The lookbehind ensures `me's value 'foo'` matches only `'foo'`.
|
|
96
|
-
working = working.replace(/(?<!\w)'(?:\\.|[^'\\])*'/g, m => record('string-single', m));
|
|
97
|
-
|
|
98
|
-
// 4. HTML inner text content. Match a non-whitespace text run between
|
|
99
|
-
// a `>` and the next `<`, on the SAME line. Newline confinement is
|
|
100
|
-
// critical: without it the regex would span multiple element boundaries
|
|
101
|
-
// and consume nested tags as if they were text. The middle character
|
|
102
|
-
// class `[^<\n\s]` ensures at least one printable non-`<` char exists
|
|
103
|
-
// so pure indentation between elements is not masked.
|
|
104
|
-
// The `(?<!\/)` lookbehind excludes a `>` that closes a SELF-CLOSING
|
|
105
|
-
// selector literal (`<button/>`): hyperscript between two selector
|
|
106
|
-
// literals (`… last <button/> in .modal focus first <button/> …`) is
|
|
107
|
-
// real code, not element inner text — masking it hid the whole segment
|
|
108
|
-
// from the transformer, which then emitted it untranslated (the
|
|
109
|
-
// focus-trap `focus first` leak).
|
|
110
|
-
working = working.replace(
|
|
111
|
-
/(?<!\/)>([^<\n]*[^<\n\s][^<\n]*)</g,
|
|
112
|
-
(_m, text) => `>${record('html-text', text)}<`
|
|
113
|
-
);
|
|
114
|
-
|
|
115
|
-
// 5. Bracket expressions. Mask the entire `[...]` payload as one unit so
|
|
116
|
-
// SOV/VSO reordering cannot move tokens out of the brackets. Non-nested
|
|
117
|
-
// is fine for the current corpus; bracket-inside-bracket isn't used.
|
|
118
|
-
working = working.replace(/\[[^\]]*\]/g, m => record('bracket-expr', m));
|
|
119
|
-
|
|
120
|
-
// 6. URL-like tokens. Two forms:
|
|
121
|
-
// - protocol://host (http, https, ws, wss, file)
|
|
122
|
-
// - /path (whitespace-bounded, not preceded by a word char to avoid
|
|
123
|
-
// matching arithmetic like `1/2`)
|
|
124
|
-
// The path form matches a bare `/` too (precache root) when followed
|
|
125
|
-
// by non-word boundary. Path chars: word, dash, dot, slash, glob, query.
|
|
126
|
-
working = working.replace(/(?:https?|wss?|file):\/\/[^\s,)]+|(?<![\w<])\/[\w\-./*?=&]*/g, m =>
|
|
127
|
-
record('url', m)
|
|
128
|
-
);
|
|
129
|
-
|
|
130
|
-
// 7. Component template directives. Mask the `#keyword` token only — the
|
|
131
|
-
// expression after it (e.g. `^user.admin`) is left translatable, since
|
|
132
|
-
// those are typically variable references that pass through unchanged.
|
|
133
|
-
working = working.replace(/#(?:if|else|elif|end|for|each)\b/g, m => record('directive', m));
|
|
134
|
-
|
|
135
|
-
return { masked: working, spans };
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
/**
|
|
139
|
-
* Restore placeholders to their original spans. Iterates to a fixed point so
|
|
140
|
-
* that an `original` field containing another placeholder (which shouldn't
|
|
141
|
-
* happen with the current detection rules, but is cheap insurance) is fully
|
|
142
|
-
* resolved.
|
|
143
|
-
*/
|
|
144
|
-
export function unmaskSpans(translated: string, spans: MaskedSpan[]): string {
|
|
145
|
-
let result = translated;
|
|
146
|
-
let prev: string;
|
|
147
|
-
do {
|
|
148
|
-
prev = result;
|
|
149
|
-
result = result.replace(PLACEHOLDER_RE, (m, idx) => {
|
|
150
|
-
const span = spans[Number(idx)];
|
|
151
|
-
return span ? span.original : m;
|
|
152
|
-
});
|
|
153
|
-
} while (result !== prev);
|
|
154
|
-
return result;
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
/**
|
|
158
|
-
* Convenience wrapper: mask, run a transform on the masked surface, then
|
|
159
|
-
* unmask. The transform receives only the masked string and must preserve
|
|
160
|
-
* placeholders (which it will, since they look like ordinary identifiers).
|
|
161
|
-
*/
|
|
162
|
-
export function withMaskedSpans(input: string, transform: (masked: string) => string): string {
|
|
163
|
-
const { masked, spans } = maskSpans(input);
|
|
164
|
-
const transformed = transform(masked);
|
|
165
|
-
return unmaskSpans(transformed, spans);
|
|
166
|
-
}
|