@nan0web/ui 1.9.0 → 1.11.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 (108) hide show
  1. package/README.md +97 -12
  2. package/package.json +54 -25
  3. package/src/App/Command/DepsCommand.js +3 -4
  4. package/src/Frame/Props.js +12 -18
  5. package/src/InterfaceTemplate/InterfaceTemplate.js +9 -7
  6. package/src/Model/index.js +86 -2
  7. package/src/StdIn.js +2 -6
  8. package/src/cli.js +1 -0
  9. package/src/core/Form/Form.js +8 -7
  10. package/src/core/Form/Message.js +1 -1
  11. package/src/core/GeneratorRunner.js +77 -7
  12. package/src/core/InputAdapter.js +3 -1
  13. package/src/core/Intent.js +214 -16
  14. package/src/core/IntentErrorModel.js +6 -1
  15. package/src/core/Message/Message.js +4 -7
  16. package/src/core/Message/OutputMessage.js +4 -9
  17. package/src/core/Stream.js +16 -5
  18. package/src/core/StreamEntry.js +20 -28
  19. package/src/core/index.js +2 -1
  20. package/src/domain/Content.js +196 -0
  21. package/src/domain/Document.js +17 -0
  22. package/src/domain/FooterModel.js +37 -19
  23. package/src/domain/HeaderModel.js +47 -21
  24. package/src/domain/HeroModel.js +24 -22
  25. package/src/domain/LayoutModel.js +43 -0
  26. package/src/domain/ModelAsApp.js +46 -0
  27. package/src/domain/SandboxModel.js +19 -16
  28. package/src/domain/app/GalleryCommand.js +53 -0
  29. package/src/domain/app/GalleryRenderIntent.js +77 -0
  30. package/src/domain/app/SnapshotAuditor.js +401 -0
  31. package/src/domain/app/SnapshotRunner.js +264 -0
  32. package/src/domain/app/UIApp.js +78 -0
  33. package/src/domain/components/BreadcrumbModel.js +10 -6
  34. package/src/domain/components/FeatureGridModel.js +62 -0
  35. package/src/domain/components/MarkdownModel.js +24 -0
  36. package/src/domain/components/ShellModel.js +243 -0
  37. package/src/domain/components/TableModel.js +10 -6
  38. package/src/domain/components/ToastModel.js +10 -6
  39. package/src/domain/components/index.js +3 -1
  40. package/src/domain/index.js +14 -4
  41. package/src/index.js +21 -2
  42. package/src/inspect.js +2 -0
  43. package/src/test/ScenarioAdapter.js +59 -0
  44. package/src/test/ScenarioTest.js +51 -0
  45. package/src/test/ScenarioTest.story.js +56 -0
  46. package/src/testing/CrashReporter.js +56 -0
  47. package/src/testing/GalleryGenerator.js +29 -0
  48. package/src/testing/LogicInspector.js +55 -0
  49. package/src/testing/SnapshotRunner.js +22 -0
  50. package/src/testing/SpecAdapter.js +115 -0
  51. package/src/testing/SpecRunner.js +121 -0
  52. package/src/testing/VisualAdapter.js +46 -0
  53. package/src/testing/index.js +7 -0
  54. package/src/testing/verifySnapshot.js +17 -0
  55. package/types/App/Command/DepsCommand.d.ts +0 -2
  56. package/types/Model/index.d.ts +56 -4
  57. package/types/StdIn.d.ts +3 -3
  58. package/types/cli.d.ts +1 -0
  59. package/types/core/Form/Form.d.ts +2 -2
  60. package/types/core/GeneratorRunner.d.ts +18 -1
  61. package/types/core/InputAdapter.d.ts +2 -1
  62. package/types/core/Intent.d.ts +232 -26
  63. package/types/core/IntentErrorModel.d.ts +4 -0
  64. package/types/core/Message/Message.d.ts +2 -2
  65. package/types/core/Message/OutputMessage.d.ts +0 -2
  66. package/types/core/index.d.ts +2 -1
  67. package/types/domain/Content.d.ts +340 -0
  68. package/types/domain/Document.d.ts +21 -0
  69. package/types/domain/FooterModel.d.ts +22 -12
  70. package/types/domain/HeaderModel.d.ts +36 -13
  71. package/types/domain/HeroModel.d.ts +19 -17
  72. package/types/domain/LayoutModel.d.ts +34 -0
  73. package/types/domain/ModelAsApp.d.ts +23 -0
  74. package/types/domain/SandboxModel.d.ts +10 -0
  75. package/types/domain/app/GalleryCommand.d.ts +55 -0
  76. package/types/domain/app/GalleryRenderIntent.d.ts +31 -0
  77. package/types/domain/app/SnapshotAuditor.d.ts +99 -0
  78. package/types/domain/app/SnapshotRunner.d.ts +45 -0
  79. package/types/domain/app/UIApp.d.ts +60 -0
  80. package/types/domain/components/BreadcrumbModel.d.ts +6 -8
  81. package/types/domain/components/FeatureGridModel.d.ts +50 -0
  82. package/types/domain/components/MarkdownModel.d.ts +19 -0
  83. package/types/domain/components/ShellModel.d.ts +56 -0
  84. package/types/domain/components/TableModel.d.ts +4 -0
  85. package/types/domain/components/ToastModel.d.ts +4 -0
  86. package/types/domain/components/index.d.ts +3 -0
  87. package/types/domain/index.d.ts +10 -4
  88. package/types/index.d.ts +19 -1
  89. package/types/inspect.d.ts +2 -0
  90. package/types/test/ScenarioAdapter.d.ts +43 -0
  91. package/types/test/ScenarioTest.d.ts +24 -0
  92. package/types/test/ScenarioTest.story.d.ts +1 -0
  93. package/types/testing/CrashReporter.d.ts +13 -0
  94. package/types/testing/GalleryGenerator.d.ts +1 -0
  95. package/types/testing/LogicInspector.d.ts +22 -0
  96. package/types/testing/SnapshotRunner.d.ts +7 -0
  97. package/types/testing/SpecAdapter.d.ts +57 -0
  98. package/types/testing/SpecRunner.d.ts +41 -0
  99. package/types/testing/VisualAdapter.d.ts +9 -0
  100. package/types/testing/index.d.ts +7 -0
  101. package/types/testing/verifySnapshot.d.ts +14 -0
  102. package/src/README.md.js +0 -436
  103. package/types/App/Command/Options.d.ts +0 -43
  104. package/types/App/Command/index.d.ts +0 -8
  105. package/types/App/User/Command/Options.d.ts +0 -34
  106. package/types/core/Message/InputMessage.d.ts +0 -71
  107. package/types/domain/components/HeroModel.d.ts +0 -24
  108. package/types/domain/components/ShowcaseAppModel.d.ts +0 -32
@@ -0,0 +1,53 @@
1
+ import { ModelAsApp } from '../ModelAsApp.js'
2
+ import { resolvePositionalArgs } from '@nan0web/ui-cli'
3
+ import SnapshotAuditor from './SnapshotAuditor.js'
4
+ import GalleryRenderIntent from './GalleryRenderIntent.js'
5
+ import { show, result } from '../../core/Intent.js'
6
+
7
+ export class GalleryCommand extends ModelAsApp {
8
+ static alias = 'gallery'
9
+
10
+ static UI = {
11
+ unknownAction: 'Unknown gallery action: {command}',
12
+ }
13
+
14
+ static action = {
15
+ type: 'string',
16
+ help: 'Command to run',
17
+ options: [SnapshotAuditor, GalleryRenderIntent],
18
+ default: SnapshotAuditor.alias || SnapshotAuditor.name,
19
+ positional: true,
20
+ }
21
+
22
+ /**
23
+ * @param {Partial<GalleryCommand> | Record<string, any>} [data={}]
24
+ * @param {import('@nan0web/types').ModelOptions} [options={}]
25
+ */
26
+ constructor(data = {}, options = {}) {
27
+ super(data, options)
28
+ /** @type {string} */ this.action
29
+ /** @type {string[]} */ this._positionals = []
30
+ }
31
+
32
+ async *run() {
33
+ const TargetAction = GalleryCommand.action.options.find(
34
+ (opt) => opt.alias === this.action || opt.name === this.action
35
+ )
36
+
37
+ if (!TargetAction) {
38
+ yield show(this._.t(GalleryCommand.UI.unknownAction, { command: this.action }), 'error')
39
+ return result({ status: 'error' })
40
+ }
41
+
42
+ // Pass remaining positionals down to the target action
43
+ const nextData = resolvePositionalArgs(
44
+ /** @type {any} */ (TargetAction),
45
+ this._positionals || [],
46
+ this
47
+ )
48
+ const intent = new TargetAction(nextData, this._)
49
+ return yield* intent.run()
50
+ }
51
+ }
52
+
53
+ export default GalleryCommand
@@ -0,0 +1,77 @@
1
+ import { Model } from '@nan0web/types'
2
+ import SnapshotRunner from './SnapshotRunner.js'
3
+
4
+ import { show, result } from '../../core/Intent.js'
5
+
6
+ export class GalleryRenderIntent extends Model {
7
+ static alias = 'render'
8
+
9
+ static UI = {
10
+ rendering: '📸 Rendering gallery from {dataDir} to {dir}',
11
+ success: '✅ Gallery render complete',
12
+ failed: '🚨 Gallery render failed: {error}',
13
+ }
14
+
15
+ static dataDir = {
16
+ type: 'string',
17
+ default: 'docs/data',
18
+ help: 'Path to source models directory'
19
+ }
20
+
21
+ static dir = {
22
+ type: 'string',
23
+ default: 'snapshots/core',
24
+ help: 'Path to output snapshots directory'
25
+ }
26
+
27
+ /**
28
+
29
+ * @param {Partial<GalleryRenderIntent> | Record<string, any>} [data={}]
30
+
31
+ * @param {import('@nan0web/types').ModelOptions} [options={}]
32
+
33
+ */
34
+
35
+ constructor(data = {}, options = {}) {
36
+ super(data, options)
37
+ /** @type {string} */ this.dataDir
38
+ /** @type {string} */ this.dir
39
+ }
40
+
41
+ async *run() {
42
+ yield show(this._.t(GalleryRenderIntent.UI.rendering, { dataDir: this.dataDir, dir: this.dir }))
43
+
44
+ const snapshotRunner = new SnapshotRunner({
45
+ dataDir: this.dataDir,
46
+ snapshotsDir: this.dir,
47
+ getCategory: (comp) => {
48
+ const groups = {
49
+ Actions: ['Button', 'Toggle'],
50
+ Forms: ['Input', 'Select', 'Slider', 'Autocomplete', 'Color', 'Shadow'],
51
+ Data: ['Accordion', 'Card', 'Sortable', 'Table', 'Tree', 'CodeBlock', 'Markdown', 'Badge'],
52
+ Feedback: ['Alert', 'Confirm', 'Modal', 'ProgressBar', 'Spinner', 'Toast'],
53
+ System: ['LangSelect', 'ThemeToggle'],
54
+ }
55
+ for (const [cat, comps] of Object.entries(groups)) {
56
+ if (comps.includes(comp)) return cat
57
+ }
58
+ return 'Other'
59
+ }
60
+ }, this._)
61
+
62
+ try {
63
+ const res = yield* snapshotRunner.run()
64
+ if (res.data && res.data.success) {
65
+ yield show(this._.t(GalleryRenderIntent.UI.success, {}))
66
+ } else {
67
+ yield show(this._.t(GalleryRenderIntent.UI.failed, { error: 'Audit failed' }), 'error')
68
+ return result({ status: 'error' })
69
+ }
70
+ } catch (error) {
71
+ yield show(this._.t(GalleryRenderIntent.UI.failed, { error: /** @type {Error} */ (error).message }), 'error')
72
+ return result({ status: 'error' })
73
+ }
74
+ }
75
+ }
76
+
77
+ export default GalleryRenderIntent
@@ -0,0 +1,401 @@
1
+ import { NaN0 } from '@nan0web/types'
2
+ import { AuditorModel } from '@nan0web/inspect'
3
+ import { progress, result, show } from '../../core/Intent.js'
4
+
5
+ /**
6
+ * SnapshotAuditor — Zero-Hallucination Snapshot Validation (Model-as-Schema v2).
7
+ * Parses snapshots without evaluating the app logic and detects artifacts.
8
+ *
9
+ * @extends {AuditorModel}
10
+ */
11
+ export class SnapshotAuditor extends AuditorModel {
12
+ static alias = 'audit'
13
+
14
+ static dir = {
15
+ type: 'string',
16
+ help: 'Target directory to audit snapshots in',
17
+ positional: true,
18
+ default: 'snapshots/core',
19
+ }
20
+
21
+ static data = {
22
+ type: 'string',
23
+ help: 'Directory to scan for dictionaries',
24
+ default: 'data',
25
+ }
26
+
27
+ /** @type {Object<string, string>} Messages for UI */
28
+ static UI = {
29
+ title: 'Snapshot Auditor',
30
+ description: 'Validates UI snapshots against hallucinations and localization leaks.',
31
+ icon: '📸',
32
+ starting: 'Auditing snapshots in {dir}',
33
+ noSnapshots: 'No snapshots found to audit in {dir}',
34
+ doneSuccess: 'All snapshots passed the audit.',
35
+ doneErrors: 'Gallery audit failed with errors. Check above.',
36
+ auditPassed: 'Audit passed: {file}',
37
+ auditFailed: 'Audit failed for {file}: {errors}',
38
+
39
+ errorGlitch: 'Filename "{filename}" has multiple consecutive separators (glitch detected).',
40
+ errorShort: 'Filename "{filename}" is too short.',
41
+ errorSyntax: 'Syntax Error: Failed to parse NaN0 file. {msg}',
42
+ errorArtifact: 'Path {path}: Critical artifact "{artifact}" found.',
43
+ errorRouting: 'Path {path}: Routing error "Path not found".',
44
+ errorUntranslated: 'Path {path}: Possible untranslated key found: "{str}"',
45
+ errorEnglishLeak: 'Path {path}: English word "{word}" found in "{locale}" locale.',
46
+ errorEmptyRender:
47
+ 'Path {path}.{key}: Snapshot is suspiciously empty (pure tag {compName} with NO properties or content).',
48
+ errorForeignLeak:
49
+ 'Path {path}: Word "{word}" belongs to "{foreign}" but is missing in "{locale}".',
50
+ }
51
+
52
+ /** @type {string[]} Common UI components that can be empty in render */
53
+ static EXEMPT_EMPTY = ['ui-spinner', 'ui-themetoggle', 'ui-langselect', 'ui-sortable']
54
+
55
+ /** @type {string[]} Critical JS artifacts to detect in snapshots */
56
+ static ARTIFACTS = ['[object Object]', 'undefined', 'NaN']
57
+
58
+ /** @type {string[]} Words to ignore across all languages */
59
+ static EXEMPT_WORDS = ['true', 'false', 'value', 'max', 'min', 'step', 'open', 'first', 'what', 'how', 'start', 'code', 'successfully', 'enter', 'with', 'system']
60
+
61
+ /** @type {RegExp} Pattern for suspicious filenames */
62
+ static SUSPICIOUS_FILENAME = /__|--/
63
+
64
+ /** @type {number} Minimum filename length */
65
+ static MIN_FILENAME_LENGTH = 3
66
+
67
+ /** @type {import('../../index.js').ModelAsAppOptions} */
68
+ _
69
+
70
+ /**
71
+ * @param {Partial<SnapshotAuditor> | Record<string, any>} [data={}]
72
+ * @param {Partial<import('@nan0web/types').ModelOptions>} [options={}]
73
+ */
74
+ constructor(data = {}, options = {}) {
75
+ super(data, options)
76
+ this._ = options
77
+ /** @type {string} Target directory to audit */ this.dir
78
+ /** @type {string} Directory to scan for dictionaries */ this.data
79
+ }
80
+
81
+ /**
82
+ * Extracts all valid words from an object into a Set.
83
+ * @param {any} obj Node to extract from.
84
+ * @param {Set<string>} set Set to populate.
85
+ */
86
+ static extractWords(obj, set) {
87
+ if (typeof obj === 'string') {
88
+ const words = obj.toLowerCase().split(/[\s,.:;!"'(){}\[\]\\/<>?=\-+_@&#*^|~`]+/)
89
+ for (const w of words) {
90
+ if (w.length > 2 && isNaN(Number(w))) set.add(w)
91
+ }
92
+ } else if (Array.isArray(obj)) {
93
+ for (const item of obj) SnapshotAuditor.extractWords(item, set)
94
+ } else if (obj && typeof obj === 'object') {
95
+ for (const val of Object.values(obj)) SnapshotAuditor.extractWords(val, set)
96
+ }
97
+ }
98
+
99
+ /**
100
+ * Scans data directories to build a word set for each language.
101
+ * @param {any} fsDb FileSystem DB.
102
+ * @param {string} data
103
+ * @returns {Promise<Record<string, Set<string>>>}
104
+ */
105
+ static async buildDictionaries(fsDb, data = 'data') {
106
+ /** @type {Record<string, Set<string>>} */
107
+ const dicts = {}
108
+
109
+ let entries = []
110
+ try {
111
+ let entriesList;
112
+ try {
113
+ entriesList = await fsDb.listDir(data)
114
+ } catch (e) {
115
+ if (/** @type {any} */ (e).code === 'ENOENT' && !data.startsWith('../')) {
116
+ entriesList = await fsDb.listDir('../' + data)
117
+ } else {
118
+ throw e;
119
+ }
120
+ }
121
+ for (const e of entriesList) entries.push(e)
122
+ } catch (e) {
123
+ return dicts
124
+ }
125
+
126
+ for (const entry of entries) {
127
+ if (entry.stat.isDirectory && entry.name !== '_') {
128
+ const lang = entry.name
129
+ if (!dicts[lang]) dicts[lang] = new Set()
130
+
131
+ const scanLang = async (dirPath) => {
132
+ let files = []
133
+ try {
134
+ const entries = await fsDb.listDir(dirPath)
135
+ for (const f of entries) files.push(f)
136
+ } catch (e) {
137
+ return
138
+ }
139
+
140
+ for (const f of files) {
141
+ if (f.stat.isDirectory) {
142
+ await scanLang(f.path)
143
+ } else {
144
+ try {
145
+ const _fsDb = /** @type {any} */ (fsDb)
146
+ const raw = _fsDb.FS ? await _fsDb.FS.loadTXT(_fsDb.location(f.path), '', true) : await fsDb.fetch(f.path)
147
+ SnapshotAuditor.extractWords(raw, dicts[lang])
148
+ } catch (e) {}
149
+ }
150
+ }
151
+ }
152
+ await scanLang(entry.path)
153
+ }
154
+ }
155
+ return dicts
156
+ }
157
+
158
+ /**
159
+ * Run the snapshot audit inside the target directory.
160
+ * @returns {AsyncGenerator<import('@nan0web/ui').Intent, any, any>}
161
+ */
162
+ async *run() {
163
+ const { t } = this._
164
+ const snapshotsDir = this.dir || '.'
165
+
166
+ yield show(t(SnapshotAuditor.UI.starting, { dir: snapshotsDir }))
167
+
168
+ const files = []
169
+
170
+ /** @type {import('@nan0web/db').DB} */
171
+ let fsDb = this._.db
172
+ if (fsDb && fsDb.mounts && fsDb.mounts.has('')) {
173
+ fsDb = /** @type {import('@nan0web/db').DB} */ (fsDb.mounts.get(''))
174
+ }
175
+
176
+ if (!fsDb) {
177
+ yield show('FS Database not provided to auditor', 'error')
178
+ return result({ success: false })
179
+ }
180
+
181
+ const findSnapshots = async (dir) => {
182
+ try {
183
+ let entries;
184
+ try {
185
+ entries = await fsDb.listDir(dir)
186
+ } catch (e) {
187
+ if (/** @type {any} */ (e).code === 'ENOENT' && !dir.startsWith('../')) {
188
+ entries = await fsDb.listDir('../' + dir)
189
+ } else {
190
+ throw e;
191
+ }
192
+ }
193
+ for (const entry of entries) {
194
+ if (entry.stat.isDirectory) {
195
+ await findSnapshots(entry.path)
196
+ } else if (entry.name.endsWith('.nan0') || entry.name.endsWith('.txt')) {
197
+ files.push(entry.path)
198
+ }
199
+ }
200
+ } catch (e) {
201
+ console.error('Error reading dir:', dir, e)
202
+ }
203
+ }
204
+
205
+ await findSnapshots(snapshotsDir)
206
+
207
+ if (files.length === 0) {
208
+ yield show(t(SnapshotAuditor.UI.noSnapshots, { dir: snapshotsDir }), 'error')
209
+ return result({ success: false })
210
+ }
211
+
212
+ // Preload all dictionaries into memory across all languages
213
+ const dictionaries = await SnapshotAuditor.buildDictionaries(fsDb, this.data || 'data')
214
+
215
+ // Process all files in parallel for hyper-speed
216
+ const auditPromises = files.map(async (file) => {
217
+ const segments = file.split('/')
218
+ const locale = segments[segments.indexOf('core') + 1] || 'uk'
219
+ const componentName = segments.pop() || ''
220
+
221
+ const _fsDb = /** @type {any} */ (fsDb)
222
+ const content = _fsDb.FS ? await _fsDb.FS.loadTXT(_fsDb.location(file), '', true) : await fsDb.fetch(file)
223
+ const textContent = typeof content === 'string' ? content : JSON.stringify(content)
224
+
225
+ return {
226
+ file,
227
+ audit: SnapshotAuditor.inspectText(textContent, locale, componentName, t, dictionaries),
228
+ }
229
+ })
230
+
231
+ const results = await Promise.all(auditPromises)
232
+ const allErrors = []
233
+ let hasErrors = false
234
+
235
+ for (const { file, audit } of results) {
236
+ const displayFile = file.startsWith('../') ? file.slice(3) : file
237
+ if (audit.score < 100) {
238
+ const errorMessages = audit.errors.join('; ')
239
+ yield show(t(SnapshotAuditor.UI.auditFailed, { file: displayFile, errors: errorMessages }), 'error')
240
+ allErrors.push(...audit.errors.map((e) => ({ file: displayFile, error: e })))
241
+ hasErrors = true
242
+ } else {
243
+ yield show(t(SnapshotAuditor.UI.auditPassed, { file: displayFile }), 'success')
244
+ }
245
+ }
246
+
247
+ if (hasErrors) {
248
+ yield show(t(SnapshotAuditor.UI.doneErrors, {}), 'error')
249
+ return result({ success: false, errors: allErrors })
250
+ }
251
+
252
+ yield show(t(SnapshotAuditor.UI.doneSuccess, {}), 'success')
253
+ return result({ success: true })
254
+ }
255
+
256
+ /**
257
+ * Inspects a single snapshot text.
258
+ * @param {string} content Content of the file.
259
+ * @param {string} locale Locale (uk, en).
260
+ * @param {string} filename Name of the file.
261
+ * @param {import('@nan0web/i18n').TFunction} t Translate function.
262
+ * @param {Record<string, Set<string>>} [dictionaries=undefined] Loaded dictionaries for mutual exclusion check.
263
+ * @returns {{ score: number, errors: string[] }}
264
+ */
265
+ static inspectText(content, locale, filename, t, dictionaries = undefined) {
266
+ const errors = []
267
+
268
+ if (filename) {
269
+ if (SnapshotAuditor.SUSPICIOUS_FILENAME.test(filename)) {
270
+ errors.push(t(SnapshotAuditor.UI.errorGlitch, { filename }))
271
+ }
272
+ if (filename.length < SnapshotAuditor.MIN_FILENAME_LENGTH) {
273
+ errors.push(t(SnapshotAuditor.UI.errorShort, { filename }))
274
+ }
275
+ }
276
+
277
+ let parsed
278
+ try {
279
+ parsed = NaN0.parse(content)
280
+ } catch (e) {
281
+ const msg = e instanceof Error ? e.message : String(e)
282
+ errors.push(t(SnapshotAuditor.UI.errorSyntax, { msg }))
283
+ return { score: 0, errors }
284
+ }
285
+
286
+ const context = { locale, errors, t, dictionaries }
287
+ SnapshotAuditor.checkNode(parsed, '$', context)
288
+
289
+ return {
290
+ score: errors.length === 0 ? 100 : Math.max(0, 100 - errors.length * 10),
291
+ errors,
292
+ }
293
+ }
294
+
295
+ /**
296
+ * Recursively checks a parsed node.
297
+ * @param {any} node Node.
298
+ * @param {string} path JSON path.
299
+ * @param {{ locale: string, errors: string[], t: import('@nan0web/i18n').TFunction, dictionaries?: Record<string, Set<string>> }} context Context.
300
+ */
301
+ static checkNode(node, path, context) {
302
+ if (typeof node === 'string') {
303
+ SnapshotAuditor.checkString(node, path, context)
304
+ } else if (Array.isArray(node)) {
305
+ node.forEach((item, i) => SnapshotAuditor.checkNode(item, `${path}[${i}]`, context))
306
+ } else if (node && typeof node === 'object') {
307
+ for (const [key, value] of Object.entries(node)) {
308
+ SnapshotAuditor.checkString(key, `${path}.key(${key})`, context)
309
+ SnapshotAuditor.checkNode(value, `${path}.${key}`, context)
310
+
311
+ if (key === 'render' && value && typeof value === 'object') {
312
+ for (const [compName, compProps] of Object.entries(value)) {
313
+ if (compProps && typeof compProps === 'object' && Object.keys(compProps).length === 0) {
314
+ if (!SnapshotAuditor.EXEMPT_EMPTY.includes(compName)) {
315
+ context.errors.push(
316
+ context.t(SnapshotAuditor.UI.errorEmptyRender, {
317
+ path,
318
+ key,
319
+ compName,
320
+ }),
321
+ )
322
+ }
323
+ }
324
+ }
325
+ }
326
+ }
327
+ }
328
+ }
329
+
330
+ /**
331
+ * Checks a string node.
332
+ * @param {string} str String.
333
+ * @param {string} path Path.
334
+ * @param {{ locale: string, errors: string[], t: import('@nan0web/i18n').TFunction, dictionaries?: Record<string, Set<string>> }} context Context.
335
+ */
336
+ static checkString(str, path, context) {
337
+ const { t, locale, errors } = context
338
+
339
+ for (const artifact of SnapshotAuditor.ARTIFACTS) {
340
+ if (str.includes(artifact)) {
341
+ // Special check for NaN to avoid false positives with NaN0 or NaN•
342
+ if (artifact === 'NaN' && (str.includes('NaN0') || str.includes('NaN•'))) continue
343
+ errors.push(t(SnapshotAuditor.UI.errorArtifact, { path, artifact }))
344
+ }
345
+ }
346
+
347
+ if (str.includes('Path not found')) errors.push(t(SnapshotAuditor.UI.errorRouting, { path }))
348
+
349
+ const isSystemProp =
350
+ path.includes('.variant') || path.includes('.key(') || path.includes('.ask')
351
+ if (!isSystemProp) {
352
+ const isDotNumber = /^-?\d+\.\d+$/.test(str)
353
+ const hasParens = str.includes('(') || str.includes(')')
354
+ const isEmail = str.includes('@')
355
+
356
+ if (
357
+ /\w+\.\w+/.test(str) &&
358
+ !str.includes('ui-') &&
359
+ !str.includes('http') &&
360
+ !isDotNumber &&
361
+ !hasParens &&
362
+ !isEmail
363
+ ) {
364
+ errors.push(t(SnapshotAuditor.UI.errorUntranslated, { path, str }))
365
+ }
366
+
367
+ if (context.dictionaries && context.dictionaries[locale]) {
368
+ const myWords = context.dictionaries[locale]
369
+ const words = str.toLowerCase().split(/[\s,.:;!"'(){}\[\]\\/<>?=\-+_@&#*^|~`]+/)
370
+
371
+ for (const word of words) {
372
+ if (word.length <= 2 || !isNaN(Number(word))) continue
373
+ if (SnapshotAuditor.EXEMPT_WORDS.includes(word)) continue
374
+ if (myWords.has(word)) continue
375
+
376
+ /** @type {string | false} */
377
+ let foundInForeign = false
378
+ for (const [otherLoc, otherSet] of Object.entries(context.dictionaries)) {
379
+ if (otherLoc !== locale && otherSet.has(word)) {
380
+ foundInForeign = otherLoc
381
+ break
382
+ }
383
+ }
384
+
385
+ if (foundInForeign) {
386
+ errors.push(
387
+ t(SnapshotAuditor.UI.errorForeignLeak, {
388
+ path,
389
+ word,
390
+ foreign: foundInForeign,
391
+ locale,
392
+ }),
393
+ )
394
+ }
395
+ }
396
+ }
397
+ }
398
+ }
399
+ }
400
+
401
+ export default SnapshotAuditor