@hyperfixi/patterns-reference 3.1.1 → 3.2.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.
Files changed (39) hide show
  1. package/CHANGELOG.md +145 -0
  2. package/README.md +47 -36
  3. package/data/engine-verification.json +40 -38
  4. package/data/patterns.db +0 -0
  5. package/data/patterns.db.stamp +1 -0
  6. package/dist/api/index.d.mts +2 -2
  7. package/dist/api/index.d.ts +2 -2
  8. package/dist/api/index.js +198 -98
  9. package/dist/api/index.mjs +197 -100
  10. package/dist/{index-6GkHj5yJ.d.mts → index-CoDUfq2P.d.mts} +39 -3
  11. package/dist/{index-6GkHj5yJ.d.ts → index-CoDUfq2P.d.ts} +39 -3
  12. package/dist/index.d.mts +87 -13
  13. package/dist/index.d.ts +87 -13
  14. package/dist/index.js +261 -144
  15. package/dist/index.mjs +258 -146
  16. package/dist/{llm-B5nGz8V1.d.mts → llm-CCCSw-yp.d.ts} +21 -10
  17. package/dist/{llm-rEdJQScF.d.ts → llm-PxnriEZ_.d.mts} +21 -10
  18. package/dist/sync/index.d.mts +1 -1
  19. package/dist/sync/index.d.ts +1 -1
  20. package/dist/sync/index.js +4 -1
  21. package/dist/sync/index.mjs +4 -1
  22. package/package.json +16 -16
  23. package/src/adapters/llm-adapter.ts +64 -42
  24. package/src/api/engine-filter.ts +37 -0
  25. package/src/api/llm.ts +54 -64
  26. package/src/api/patterns.ts +92 -30
  27. package/src/api/roles.ts +6 -4
  28. package/src/api/translations.ts +34 -27
  29. package/src/database/connection.ts +16 -4
  30. package/src/html-snippets.ts +39 -10
  31. package/src/index.ts +12 -2
  32. package/src/registry/patterns-provider.ts +3 -1
  33. package/src/sync/db-stamp.ts +7 -4
  34. package/src/sync/markup-attributes.ts +15 -0
  35. package/src/sync/verify-parses.ts +42 -0
  36. package/src/types/better-sqlite3.d.ts +2 -0
  37. package/src/types/index.ts +44 -2
  38. package/src/sync/span-mask.ts +0 -166
  39. package/src/sync/translation-checks.ts +0 -282
@@ -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
- /** Every hyperscript source found, in document order. */
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
- if (el.tagName === 'SCRIPT') {
104
- const type = el.getAttribute('type');
105
- if (type === 'text/hyperscript-template') {
106
- hyperfixiOnly = true;
107
- } else if (type === 'text/hyperscript' && el.textContent?.trim()) {
108
- snippets.push(el.textContent);
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
- verified: true,
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
  }));
@@ -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 i18n transformer +
47
- * dictionaries + grammar profiles (translation text), the semantic parser + profiles
48
- * (stored confidence + the language set), and the seed / sync scripts.
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
- join(pr, 'src', 'sync', 'span-mask.ts'),
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
 
@@ -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`: markup rows copied verbatim.
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
- getLLMExamples(prompt: string, language?: string, limit?: number): Promise<LLMExample[]>;
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>;
@@ -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
- }