@platformos/platformos-check-common 0.0.11 → 0.0.13

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 (140) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/CLAUDE.md +150 -0
  3. package/dist/AugmentedPlatformOSDocset.js +1 -0
  4. package/dist/AugmentedPlatformOSDocset.js.map +1 -1
  5. package/dist/checks/circular-render/index.d.ts +2 -0
  6. package/dist/checks/circular-render/index.js +164 -0
  7. package/dist/checks/circular-render/index.js.map +1 -0
  8. package/dist/checks/deprecated-filter/index.js +15 -0
  9. package/dist/checks/deprecated-filter/index.js.map +1 -1
  10. package/dist/checks/duplicate-content-for-arguments/index.js +1 -1
  11. package/dist/checks/duplicate-content-for-arguments/index.js.map +1 -1
  12. package/dist/checks/graphql/index.d.ts +1 -0
  13. package/dist/checks/graphql/index.js +20 -7
  14. package/dist/checks/graphql/index.js.map +1 -1
  15. package/dist/checks/index.d.ts +1 -1
  16. package/dist/checks/index.js +6 -0
  17. package/dist/checks/index.js.map +1 -1
  18. package/dist/checks/invalid-hash-assign-target/index.js +4 -3
  19. package/dist/checks/invalid-hash-assign-target/index.js.map +1 -1
  20. package/dist/checks/missing-content-for-arguments/index.js +1 -1
  21. package/dist/checks/missing-content-for-arguments/index.js.map +1 -1
  22. package/dist/checks/missing-page/index.d.ts +2 -0
  23. package/dist/checks/missing-page/index.js +73 -0
  24. package/dist/checks/missing-page/index.js.map +1 -0
  25. package/dist/checks/missing-partial/index.js +31 -31
  26. package/dist/checks/missing-partial/index.js.map +1 -1
  27. package/dist/checks/missing-render-partial-arguments/index.d.ts +2 -0
  28. package/dist/checks/missing-render-partial-arguments/index.js +37 -0
  29. package/dist/checks/missing-render-partial-arguments/index.js.map +1 -0
  30. package/dist/checks/nested-graphql-query/index.d.ts +2 -0
  31. package/dist/checks/nested-graphql-query/index.js +146 -0
  32. package/dist/checks/nested-graphql-query/index.js.map +1 -0
  33. package/dist/checks/pagination-size/index.js +1 -1
  34. package/dist/checks/pagination-size/index.js.map +1 -1
  35. package/dist/checks/translation-key-exists/index.js +16 -19
  36. package/dist/checks/translation-key-exists/index.js.map +1 -1
  37. package/dist/checks/translation-utils.d.ts +20 -0
  38. package/dist/checks/translation-utils.js +51 -0
  39. package/dist/checks/translation-utils.js.map +1 -0
  40. package/dist/checks/undefined-object/index.js +35 -13
  41. package/dist/checks/undefined-object/index.js.map +1 -1
  42. package/dist/checks/unknown-property/index.js +75 -10
  43. package/dist/checks/unknown-property/index.js.map +1 -1
  44. package/dist/checks/unknown-property/property-shape.js +14 -1
  45. package/dist/checks/unknown-property/property-shape.js.map +1 -1
  46. package/dist/checks/unrecognized-content-for-arguments/index.js +1 -1
  47. package/dist/checks/unrecognized-content-for-arguments/index.js.map +1 -1
  48. package/dist/checks/unused-assign/index.js +23 -1
  49. package/dist/checks/unused-assign/index.js.map +1 -1
  50. package/dist/checks/unused-translation-key/index.d.ts +4 -0
  51. package/dist/checks/unused-translation-key/index.js +85 -0
  52. package/dist/checks/unused-translation-key/index.js.map +1 -0
  53. package/dist/checks/valid-content-for-argument-types/index.js +1 -1
  54. package/dist/checks/valid-content-for-argument-types/index.js.map +1 -1
  55. package/dist/checks/valid-render-partial-argument-types/index.js +2 -1
  56. package/dist/checks/valid-render-partial-argument-types/index.js.map +1 -1
  57. package/dist/checks/variable-name/index.js +4 -0
  58. package/dist/checks/variable-name/index.js.map +1 -1
  59. package/dist/context-utils.d.ts +2 -1
  60. package/dist/context-utils.js +31 -1
  61. package/dist/context-utils.js.map +1 -1
  62. package/dist/doc-generator/DocBlockGenerator.d.ts +16 -0
  63. package/dist/doc-generator/DocBlockGenerator.js +464 -0
  64. package/dist/doc-generator/DocBlockGenerator.js.map +1 -0
  65. package/dist/doc-generator/index.d.ts +1 -0
  66. package/dist/doc-generator/index.js +6 -0
  67. package/dist/doc-generator/index.js.map +1 -0
  68. package/dist/frontmatter/index.d.ts +59 -0
  69. package/dist/frontmatter/index.js +301 -0
  70. package/dist/frontmatter/index.js.map +1 -0
  71. package/dist/index.d.ts +3 -1
  72. package/dist/index.js +6 -1
  73. package/dist/index.js.map +1 -1
  74. package/dist/liquid-doc/arguments.js +9 -0
  75. package/dist/liquid-doc/arguments.js.map +1 -1
  76. package/dist/liquid-doc/utils.d.ts +10 -2
  77. package/dist/liquid-doc/utils.js +26 -1
  78. package/dist/liquid-doc/utils.js.map +1 -1
  79. package/dist/path.d.ts +1 -1
  80. package/dist/path.js +3 -1
  81. package/dist/path.js.map +1 -1
  82. package/dist/to-schema.d.ts +1 -1
  83. package/dist/tsconfig.tsbuildinfo +1 -1
  84. package/dist/types.d.ts +8 -1
  85. package/dist/types.js.map +1 -1
  86. package/dist/url-helpers.d.ts +55 -0
  87. package/dist/url-helpers.js +334 -0
  88. package/dist/url-helpers.js.map +1 -0
  89. package/dist/utils/block.js.map +1 -1
  90. package/dist/utils/index.d.ts +1 -0
  91. package/dist/utils/index.js +1 -0
  92. package/dist/utils/index.js.map +1 -1
  93. package/dist/utils/levenshtein.d.ts +3 -0
  94. package/dist/utils/levenshtein.js +39 -0
  95. package/dist/utils/levenshtein.js.map +1 -0
  96. package/package.json +2 -2
  97. package/src/AugmentedPlatformOSDocset.ts +1 -0
  98. package/src/checks/deprecated-filter/index.spec.ts +41 -1
  99. package/src/checks/deprecated-filter/index.ts +17 -0
  100. package/src/checks/graphql/index.spec.ts +173 -0
  101. package/src/checks/graphql/index.ts +21 -10
  102. package/src/checks/index.ts +6 -0
  103. package/src/checks/invalid-hash-assign-target/index.spec.ts +26 -0
  104. package/src/checks/invalid-hash-assign-target/index.ts +6 -4
  105. package/src/checks/missing-page/index.spec.ts +755 -0
  106. package/src/checks/missing-page/index.ts +89 -0
  107. package/src/checks/missing-partial/index.spec.ts +361 -0
  108. package/src/checks/missing-partial/index.ts +39 -47
  109. package/src/checks/missing-render-partial-arguments/index.spec.ts +74 -0
  110. package/src/checks/missing-render-partial-arguments/index.ts +44 -0
  111. package/src/checks/nested-graphql-query/index.spec.ts +175 -0
  112. package/src/checks/nested-graphql-query/index.ts +203 -0
  113. package/src/checks/parser-blocking-script/index.spec.ts +7 -3
  114. package/src/checks/translation-key-exists/index.spec.ts +79 -2
  115. package/src/checks/translation-key-exists/index.ts +18 -27
  116. package/src/checks/translation-utils.ts +63 -0
  117. package/src/checks/undefined-object/index.spec.ts +153 -19
  118. package/src/checks/undefined-object/index.ts +43 -19
  119. package/src/checks/unknown-property/index.spec.ts +133 -0
  120. package/src/checks/unknown-property/index.ts +84 -10
  121. package/src/checks/unknown-property/property-shape.ts +15 -1
  122. package/src/checks/unused-assign/index.spec.ts +75 -1
  123. package/src/checks/unused-assign/index.ts +26 -1
  124. package/src/checks/unused-doc-param/index.spec.ts +4 -2
  125. package/src/checks/valid-doc-param-types/index.spec.ts +1 -1
  126. package/src/checks/valid-render-partial-argument-types/index.spec.ts +24 -1
  127. package/src/checks/valid-render-partial-argument-types/index.ts +3 -2
  128. package/src/checks/variable-name/index.spec.ts +10 -1
  129. package/src/checks/variable-name/index.ts +5 -0
  130. package/src/context-utils.ts +33 -1
  131. package/src/frontmatter/index.ts +344 -0
  132. package/src/index.ts +6 -0
  133. package/src/liquid-doc/arguments.ts +9 -0
  134. package/src/liquid-doc/utils.ts +26 -2
  135. package/src/path.ts +2 -0
  136. package/src/types.ts +9 -1
  137. package/src/url-helpers.spec.ts +241 -0
  138. package/src/url-helpers.ts +363 -0
  139. package/src/utils/index.ts +1 -0
  140. package/src/utils/levenshtein.ts +41 -0
@@ -0,0 +1,363 @@
1
+ import {
2
+ NodeTypes,
3
+ LiquidHtmlNode,
4
+ HtmlElement,
5
+ HtmlVoidElement,
6
+ LiquidVariableOutput,
7
+ LiquidVariable,
8
+ LiquidVariableLookup,
9
+ LiquidString,
10
+ LiquidFilter,
11
+ LiquidTag,
12
+ LiquidTagAssign,
13
+ AssignMarkup,
14
+ NamedTags,
15
+ AttrDoubleQuoted,
16
+ AttrSingleQuoted,
17
+ AttrUnquoted,
18
+ TextNode,
19
+ } from '@platformos/liquid-html-parser';
20
+
21
+ /**
22
+ * Shared URL extraction and HTTP method detection helpers
23
+ * for route-aware checks and LSP features.
24
+ *
25
+ * These helpers depend on liquid-html-parser AST types and live in
26
+ * platformos-check-common (not platformos-common) to keep the lower-level
27
+ * package free of parser dependencies. The pure RouteTable/slug logic
28
+ * remains in platformos-common.
29
+ */
30
+
31
+ /** Extract the `children` array from a node if it has one (block tags, elements). */
32
+ function getTraversableChildren(node: LiquidHtmlNode): LiquidHtmlNode[] | null {
33
+ if ('children' in node) {
34
+ const children = (node as { children: unknown }).children;
35
+ if (Array.isArray(children)) return children as LiquidHtmlNode[];
36
+ }
37
+ return null;
38
+ }
39
+
40
+ /** Extract the `markup` array from a node if it is an array ({% liquid %} tags). */
41
+ function getTraversableMarkup(node: LiquidHtmlNode): LiquidHtmlNode[] | null {
42
+ if ('markup' in node) {
43
+ const markup = (node as { markup: unknown }).markup;
44
+ if (Array.isArray(markup)) return markup as LiquidHtmlNode[];
45
+ }
46
+ return null;
47
+ }
48
+
49
+ const SKIP_PREFIXES = ['http://', 'https://', '//', 'mailto:', 'tel:', 'javascript:', 'data:', '#'];
50
+
51
+ export function shouldSkipUrl(url: string): boolean {
52
+ if (url === '' || url === '#') return true;
53
+ const lower = url.toLowerCase();
54
+ return SKIP_PREFIXES.some((prefix) => lower.startsWith(prefix));
55
+ }
56
+
57
+ export type ValuedAttrNode = AttrDoubleQuoted | AttrSingleQuoted | AttrUnquoted;
58
+
59
+ export function isValuedAttrNode(node: LiquidHtmlNode): node is ValuedAttrNode {
60
+ return (
61
+ node.type === NodeTypes.AttrDoubleQuoted ||
62
+ node.type === NodeTypes.AttrSingleQuoted ||
63
+ node.type === NodeTypes.AttrUnquoted
64
+ );
65
+ }
66
+
67
+ export function getAttrName(attr: ValuedAttrNode): string | null {
68
+ if (attr.name.length !== 1) return null;
69
+ if (attr.name[0].type !== NodeTypes.TextNode) return null;
70
+ return (attr.name[0] as TextNode).value.toLowerCase();
71
+ }
72
+
73
+ export function getStaticAttrValue(attr: ValuedAttrNode): string | null {
74
+ if (attr.value.length !== 1) return null;
75
+ if (attr.value[0].type !== NodeTypes.TextNode) return null;
76
+ return (attr.value[0] as TextNode).value;
77
+ }
78
+
79
+ /**
80
+ * Check if a LiquidVariableOutput node contains exactly `context.location.host`.
81
+ */
82
+ function isContextLocationHost(node: LiquidHtmlNode): boolean {
83
+ if (node.type !== NodeTypes.LiquidVariableOutput) return false;
84
+ const { markup } = node as LiquidVariableOutput;
85
+ const raw = typeof markup === 'string' ? markup : markup.rawSource;
86
+ return raw.trim() === 'context.location.host';
87
+ }
88
+
89
+ /**
90
+ * Get the simple variable name from a LiquidVariableOutput node.
91
+ * Returns the name if it's a plain variable lookup (e.g. `{{ url }}`) with no
92
+ * filters or nested lookups. Returns null otherwise.
93
+ */
94
+ function getSimpleVariableName(node: LiquidHtmlNode): string | null {
95
+ if (node.type !== NodeTypes.LiquidVariableOutput) return null;
96
+ const { markup } = node as LiquidVariableOutput;
97
+ if (typeof markup === 'string') return null;
98
+ const variable = markup as LiquidVariable;
99
+ if (variable.filters.length > 0) return null;
100
+ if (variable.expression.type !== NodeTypes.VariableLookup) return null;
101
+ const lookup = variable.expression as LiquidVariableLookup;
102
+ if (lookup.name === null || lookup.lookups.length > 0) return null;
103
+ return lookup.name;
104
+ }
105
+
106
+ /**
107
+ * Evaluate a filter argument to either a static string or `:_liquid_` placeholder.
108
+ */
109
+ function evaluateFilterArg(filter: LiquidFilter): string | null {
110
+ if (filter.args.length !== 1) return null;
111
+ const arg = filter.args[0];
112
+ if (arg.type === NodeTypes.String) return (arg as LiquidString).value;
113
+ if (arg.type === NodeTypes.VariableLookup) return ':_liquid_';
114
+ return null;
115
+ }
116
+
117
+ /**
118
+ * Resolve an assign markup's RHS to a URL pattern string.
119
+ * Handles string literals with `append` and `prepend` filters.
120
+ * Variable arguments to append/prepend become `:_liquid_` placeholders.
121
+ * Returns null if the value can't be statically resolved to a URL pattern.
122
+ */
123
+ export function resolveAssignToUrlPattern(markup: AssignMarkup): string | null {
124
+ if (markup.operator !== '=') return null;
125
+ const value = markup.value;
126
+ if (value.type !== NodeTypes.LiquidVariable) return null;
127
+
128
+ const variable = value as LiquidVariable;
129
+ let result: string;
130
+
131
+ // Base expression must be a string literal or a variable lookup
132
+ if (variable.expression.type === NodeTypes.String) {
133
+ result = (variable.expression as LiquidString).value;
134
+ } else if (variable.expression.type === NodeTypes.VariableLookup) {
135
+ result = ':_liquid_';
136
+ } else {
137
+ return null;
138
+ }
139
+
140
+ // Apply append/prepend filters in order
141
+ for (const filter of variable.filters) {
142
+ const arg = evaluateFilterArg(filter);
143
+ if (arg === null) return null;
144
+
145
+ if (filter.name === 'append') {
146
+ result = result + arg;
147
+ } else if (filter.name === 'prepend') {
148
+ result = arg + result;
149
+ } else {
150
+ // Unknown filter — can't determine the result
151
+ return null;
152
+ }
153
+ }
154
+
155
+ return normalizeUrlPattern(result);
156
+ }
157
+
158
+ /**
159
+ * Normalize a raw URL string into a matchable URL pattern.
160
+ * Strips query/fragment, validates segments, returns null if not analyzable.
161
+ */
162
+ function normalizeUrlPattern(raw: string): string | null {
163
+ let url = raw;
164
+
165
+ // Handle self-referencing absolute URLs
166
+ url = url.replace(/^https?:\/\/:_context_host_\//, '/');
167
+ url = url.replace(/^\/\/:_context_host_\//, '/');
168
+
169
+ // Must start with / to be a relative URL we can check
170
+ if (!url.startsWith('/')) return null;
171
+
172
+ // Strip query string and fragment
173
+ const queryIdx = url.indexOf('?');
174
+ if (queryIdx !== -1) url = url.slice(0, queryIdx);
175
+ const hashIdx = url.indexOf('#');
176
+ if (hashIdx !== -1) url = url.slice(0, hashIdx);
177
+
178
+ // If any segment has :_liquid_ mixed with other text, skip
179
+ const segments = url.split('/').filter((s) => s.length > 0);
180
+ if (segments.some((s) => s.includes(':_liquid_') && s !== ':_liquid_')) return null;
181
+
182
+ return url;
183
+ }
184
+
185
+ /**
186
+ * Extract a URL pattern from an attribute value.
187
+ * Returns null if the URL cannot be analyzed (contains Liquid tags or is fully dynamic).
188
+ *
189
+ * Static text becomes literal path segments.
190
+ * Each {{ expression }} becomes `:_liquid_` (matches one path segment).
191
+ * {{ context.location.host }} is recognized and stripped from absolute self-referencing URLs.
192
+ *
193
+ * When a `variableMap` is provided, a single `{{ varName }}` in the attribute
194
+ * will be resolved through the map (from tracked {% assign %} statements).
195
+ */
196
+ export function extractUrlPattern(
197
+ attr: ValuedAttrNode,
198
+ variableMap?: Map<string, string>,
199
+ ): string | null {
200
+ // Check if the entire attribute is a single variable that we can resolve
201
+ if (variableMap && attr.value.length === 1) {
202
+ const varName = getSimpleVariableName(attr.value[0]);
203
+ if (varName !== null && variableMap.has(varName)) {
204
+ return variableMap.get(varName)!;
205
+ }
206
+ }
207
+
208
+ const parts: string[] = [];
209
+ let hasStatic = false;
210
+
211
+ for (const node of attr.value) {
212
+ if (node.type === NodeTypes.TextNode) {
213
+ parts.push((node as TextNode).value);
214
+ hasStatic = true;
215
+ } else if (node.type === NodeTypes.LiquidVariableOutput) {
216
+ parts.push(isContextLocationHost(node) ? ':_context_host_' : ':_liquid_');
217
+ } else {
218
+ // Liquid tags — unpredictable structure
219
+ return null;
220
+ }
221
+ }
222
+
223
+ // Fully dynamic (no static text at all)
224
+ if (!hasStatic) return null;
225
+
226
+ return normalizeUrlPattern(parts.join(''));
227
+ }
228
+
229
+ function isHtmlVoidElement(node: LiquidHtmlNode): node is HtmlVoidElement {
230
+ return node.type === NodeTypes.HtmlVoidElement;
231
+ }
232
+
233
+ /**
234
+ * Recursively scan a form subtree for <input type="hidden" name="_method" value="...">
235
+ * Returns the _method value if found, or null.
236
+ */
237
+ function findMethodOverride(formNode: HtmlElement): string | null {
238
+ const stack: LiquidHtmlNode[] = [...formNode.children];
239
+
240
+ while (stack.length > 0) {
241
+ const child = stack.pop()!;
242
+
243
+ if (isHtmlVoidElement(child) && child.name === 'input') {
244
+ const attrs = (child.attributes as LiquidHtmlNode[]).filter(isValuedAttrNode);
245
+ const typeAttr = attrs.find((a) => getAttrName(a) === 'type');
246
+ const nameAttr = attrs.find((a) => getAttrName(a) === 'name');
247
+ const valueAttr = attrs.find((a) => getAttrName(a) === 'value');
248
+
249
+ if (!typeAttr || !nameAttr || !valueAttr) continue;
250
+
251
+ const typeVal = getStaticAttrValue(typeAttr);
252
+ const nameVal = getStaticAttrValue(nameAttr);
253
+ if (typeVal?.toLowerCase() !== 'hidden' || nameVal !== '_method') continue;
254
+
255
+ const methodVal = getStaticAttrValue(valueAttr);
256
+ if (methodVal) return methodVal.toLowerCase();
257
+
258
+ // Liquid value for _method — can't determine method
259
+ return null;
260
+ }
261
+
262
+ // Recurse into elements that have children
263
+ if (child.type === NodeTypes.HtmlElement) {
264
+ const el = child as HtmlElement;
265
+ for (const grandchild of el.children) {
266
+ stack.push(grandchild);
267
+ }
268
+ }
269
+ }
270
+
271
+ return null;
272
+ }
273
+
274
+ /**
275
+ * Determine the effective HTTP method for a <form> element,
276
+ * accounting for _method hidden input overrides.
277
+ * Returns null if the method can't be statically determined.
278
+ */
279
+ export function getEffectiveMethod(formNode: HtmlElement): string | null {
280
+ const methodAttr = (formNode.attributes as LiquidHtmlNode[]).find(
281
+ (a) => isValuedAttrNode(a) && getAttrName(a) === 'method',
282
+ ) as ValuedAttrNode | undefined;
283
+
284
+ let formMethod = 'get';
285
+ if (methodAttr) {
286
+ const val = getStaticAttrValue(methodAttr);
287
+ if (val === null) return null; // Liquid in method attr — skip
288
+ formMethod = val.toLowerCase();
289
+ }
290
+
291
+ if (formMethod === 'post') {
292
+ const override = findMethodOverride(formNode);
293
+ if (override !== null) return override;
294
+ return formMethod;
295
+ }
296
+
297
+ return formMethod;
298
+ }
299
+
300
+ /**
301
+ * If the given node is a `{% assign %}` tag whose RHS resolves to a URL pattern,
302
+ * returns `{ name, urlPattern }`. Otherwise returns null.
303
+ *
304
+ * Shared by `buildVariableMap` (full AST walk) and `MissingPage` (incremental
305
+ * visitor), so the assign-to-URL resolution logic lives in one place.
306
+ */
307
+ export function tryExtractAssignUrl(
308
+ node: LiquidHtmlNode,
309
+ ): { name: string; urlPattern: string } | null {
310
+ if (node.type !== NodeTypes.LiquidTag || (node as LiquidTag).name !== NamedTags.assign) {
311
+ return null;
312
+ }
313
+ const markup = (node as LiquidTagAssign).markup as AssignMarkup;
314
+ if (markup.lookups.length > 0) return null;
315
+ const urlPattern = resolveAssignToUrlPattern(markup);
316
+ if (urlPattern === null) return null;
317
+ return { name: markup.name, urlPattern };
318
+ }
319
+
320
+ /**
321
+ * Walk an AST subtree and collect {% assign %} variable mappings that resolve to URL patterns.
322
+ * Not scope-aware: assigns inside {% if %} / {% for %} blocks are tracked even though they
323
+ * may not be in scope when the href is evaluated. This is an acceptable trade-off — the
324
+ * alternative (full scope analysis) would add significant complexity for marginal accuracy gains.
325
+ *
326
+ * Uses document-order traversal so that reassignments correctly overwrite earlier values.
327
+ *
328
+ * @param beforeOffset When provided, only assigns whose position.end is before this
329
+ * offset are included. Used by the LSP definition provider so that each cursor
330
+ * position sees only the assigns that precede it.
331
+ */
332
+ export function buildVariableMap(
333
+ children: LiquidHtmlNode[],
334
+ beforeOffset?: number,
335
+ ): Map<string, string> {
336
+ const variableMap = new Map<string, string>();
337
+
338
+ function walk(nodes: LiquidHtmlNode[]): void {
339
+ for (const node of nodes) {
340
+ // Apply the beforeOffset cutoff only to assign nodes themselves, not to block
341
+ // containers. A block tag ({% if %}, {% for %}, HTML element, etc.) may start
342
+ // before the cursor but end after it — we must still recurse into its children
343
+ // to find any assigns that precede the cursor within that block.
344
+ const extracted = tryExtractAssignUrl(node);
345
+ if (extracted) {
346
+ if (beforeOffset === undefined || node.position.end <= beforeOffset) {
347
+ variableMap.set(extracted.name, extracted.urlPattern);
348
+ }
349
+ }
350
+
351
+ // Recurse into children (block tags like {% if %}, {% for %})
352
+ const children = getTraversableChildren(node);
353
+ if (children) walk(children);
354
+
355
+ // Recurse into markup arrays ({% liquid %} block contains assigns in markup)
356
+ const markupArray = getTraversableMarkup(node);
357
+ if (markupArray) walk(markupArray);
358
+ }
359
+ }
360
+
361
+ walk(children);
362
+ return variableMap;
363
+ }
@@ -5,3 +5,4 @@ export * from './error';
5
5
  export * from './types';
6
6
  export * from './memo';
7
7
  export * from './indexBy';
8
+ export * from './levenshtein';
@@ -0,0 +1,41 @@
1
+ export function levenshtein(a: string, b: string): number {
2
+ const dp: number[][] = Array.from({ length: a.length + 1 }, (_, i) =>
3
+ Array.from({ length: b.length + 1 }, (_, j) => (i === 0 ? j : j === 0 ? i : 0)),
4
+ );
5
+ for (let i = 1; i <= a.length; i++) {
6
+ for (let j = 1; j <= b.length; j++) {
7
+ dp[i][j] =
8
+ a[i - 1] === b[j - 1]
9
+ ? dp[i - 1][j - 1]
10
+ : 1 + Math.min(dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1]);
11
+ }
12
+ }
13
+ return dp[a.length][b.length];
14
+ }
15
+
16
+ export function flattenTranslationKeys(obj: Record<string, any>, prefix = ''): string[] {
17
+ const keys: string[] = [];
18
+ for (const [k, v] of Object.entries(obj)) {
19
+ const full = prefix ? `${prefix}.${k}` : k;
20
+ if (typeof v === 'object' && v !== null) {
21
+ keys.push(...flattenTranslationKeys(v, full));
22
+ } else {
23
+ keys.push(full);
24
+ }
25
+ }
26
+ return keys;
27
+ }
28
+
29
+ export function findNearestKeys(
30
+ missingKey: string,
31
+ allKeys: string[],
32
+ maxDistance = 3,
33
+ maxResults = 3,
34
+ ): string[] {
35
+ return allKeys
36
+ .map((key) => ({ key, distance: levenshtein(missingKey, key) }))
37
+ .filter(({ distance }) => distance <= maxDistance)
38
+ .sort((a, b) => a.distance - b.distance)
39
+ .slice(0, maxResults)
40
+ .map(({ key }) => key);
41
+ }