@nan0web/ui 1.10.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 (101) hide show
  1. package/README.md +69 -3
  2. package/package.json +61 -29
  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 +61 -6
  7. package/src/StdIn.js +2 -6
  8. package/src/cli.js +1 -0
  9. package/src/core/GeneratorRunner.js +67 -7
  10. package/src/core/InputAdapter.js +3 -1
  11. package/src/core/Intent.js +200 -17
  12. package/src/core/Message/Message.js +4 -7
  13. package/src/core/Message/OutputMessage.js +4 -9
  14. package/src/core/StreamEntry.js +20 -28
  15. package/src/core/index.js +1 -0
  16. package/src/domain/Content.js +196 -0
  17. package/src/domain/Document.js +17 -0
  18. package/src/domain/FooterModel.js +37 -19
  19. package/src/domain/HeaderModel.js +47 -21
  20. package/src/domain/HeroModel.js +24 -22
  21. package/src/domain/LayoutModel.js +43 -0
  22. package/src/domain/ModelAsApp.js +46 -0
  23. package/src/domain/SandboxModel.js +19 -16
  24. package/src/domain/app/GalleryCommand.js +53 -0
  25. package/src/domain/app/GalleryRenderIntent.js +77 -0
  26. package/src/domain/app/SnapshotAuditor.js +401 -0
  27. package/src/domain/app/SnapshotRunner.js +264 -0
  28. package/src/domain/app/UIApp.js +78 -0
  29. package/src/domain/components/BreadcrumbModel.js +10 -6
  30. package/src/domain/components/FeatureGridModel.js +62 -0
  31. package/src/domain/components/MarkdownModel.js +24 -0
  32. package/src/domain/components/ShellModel.js +243 -0
  33. package/src/domain/components/TableModel.js +10 -6
  34. package/src/domain/components/ToastModel.js +10 -6
  35. package/src/domain/components/index.js +3 -1
  36. package/src/domain/index.js +14 -4
  37. package/src/index.js +21 -2
  38. package/src/inspect.js +2 -0
  39. package/src/test/ScenarioAdapter.js +59 -0
  40. package/src/test/ScenarioTest.js +51 -0
  41. package/src/test/ScenarioTest.story.js +56 -0
  42. package/src/testing/CrashReporter.js +56 -0
  43. package/src/testing/GalleryGenerator.js +15 -71
  44. package/src/testing/LogicInspector.js +3 -3
  45. package/src/testing/SnapshotRunner.js +22 -0
  46. package/src/testing/SpecAdapter.js +115 -0
  47. package/src/testing/SpecRunner.js +121 -0
  48. package/src/testing/VisualAdapter.js +24 -19
  49. package/src/testing/index.js +5 -1
  50. package/src/testing/verifySnapshot.js +17 -0
  51. package/types/App/Command/DepsCommand.d.ts +0 -2
  52. package/types/Model/index.d.ts +56 -62
  53. package/types/StdIn.d.ts +3 -3
  54. package/types/cli.d.ts +1 -0
  55. package/types/core/GeneratorRunner.d.ts +14 -1
  56. package/types/core/InputAdapter.d.ts +2 -1
  57. package/types/core/Intent.d.ts +209 -31
  58. package/types/core/Message/Message.d.ts +2 -2
  59. package/types/core/Message/OutputMessage.d.ts +0 -2
  60. package/types/core/index.d.ts +1 -0
  61. package/types/domain/Content.d.ts +340 -0
  62. package/types/domain/Document.d.ts +21 -0
  63. package/types/domain/FooterModel.d.ts +22 -12
  64. package/types/domain/HeaderModel.d.ts +36 -13
  65. package/types/domain/HeroModel.d.ts +19 -17
  66. package/types/domain/LayoutModel.d.ts +34 -0
  67. package/types/domain/ModelAsApp.d.ts +23 -0
  68. package/types/domain/SandboxModel.d.ts +10 -0
  69. package/types/domain/app/GalleryCommand.d.ts +55 -0
  70. package/types/domain/app/GalleryRenderIntent.d.ts +31 -0
  71. package/types/domain/app/SnapshotAuditor.d.ts +99 -0
  72. package/types/domain/app/SnapshotRunner.d.ts +45 -0
  73. package/types/domain/app/UIApp.d.ts +60 -0
  74. package/types/domain/components/BreadcrumbModel.d.ts +6 -8
  75. package/types/domain/components/FeatureGridModel.d.ts +50 -0
  76. package/types/domain/components/MarkdownModel.d.ts +19 -0
  77. package/types/domain/components/ShellModel.d.ts +56 -0
  78. package/types/domain/components/TableModel.d.ts +4 -0
  79. package/types/domain/components/ToastModel.d.ts +4 -0
  80. package/types/domain/components/index.d.ts +3 -0
  81. package/types/domain/index.d.ts +10 -4
  82. package/types/index.d.ts +19 -1
  83. package/types/inspect.d.ts +2 -0
  84. package/types/test/ScenarioAdapter.d.ts +43 -0
  85. package/types/test/ScenarioTest.d.ts +24 -0
  86. package/types/test/ScenarioTest.story.d.ts +1 -0
  87. package/types/testing/CrashReporter.d.ts +13 -0
  88. package/types/testing/SnapshotRunner.d.ts +7 -0
  89. package/types/testing/SpecAdapter.d.ts +57 -0
  90. package/types/testing/SpecRunner.d.ts +41 -0
  91. package/types/testing/VisualAdapter.d.ts +0 -6
  92. package/types/testing/index.d.ts +5 -1
  93. package/types/testing/verifySnapshot.d.ts +14 -0
  94. package/src/testing/SnapshotInspector.js +0 -84
  95. package/types/App/Command/Options.d.ts +0 -43
  96. package/types/App/Command/index.d.ts +0 -8
  97. package/types/App/User/Command/Options.d.ts +0 -34
  98. package/types/core/Message/InputMessage.d.ts +0 -71
  99. package/types/domain/components/HeroModel.d.ts +0 -24
  100. package/types/domain/components/ShowcaseAppModel.d.ts +0 -32
  101. package/types/testing/SnapshotInspector.d.ts +0 -17
@@ -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
@@ -0,0 +1,264 @@
1
+ import { Model } from '@nan0web/types'
2
+ import { LogicInspector } from '../../testing/LogicInspector.js'
3
+ import { VisualAdapter } from '../../testing/VisualAdapter.js'
4
+ import { result, show, render } from '../../core/Intent.js'
5
+ import SnapshotAuditor from './SnapshotAuditor.js'
6
+
7
+ /**
8
+ * SnapshotRunner — Zero-Hallucination Snapshot Generation & Audit (Model-as-Schema v2).
9
+ * Operates entirely through DB-FS abstraction without raw FS/Path hardcodes.
10
+ */
11
+ export class SnapshotRunner extends Model {
12
+ static UI = {
13
+ generating: '📸 Generating snapshots for {lang}/{comp}',
14
+ saved: '📸 Saved {file}',
15
+ auditFailed: '🚨 Audit failed for {file}: {errors}',
16
+ rootGallery:
17
+ '# 📸 Core Snapshots Gallery\n\n**Total Snapshots:** {count} | **Total Errors:** {errors}\n\n## Locales\n\n',
18
+ localeTitle: '🌍 Locale: {title}',
19
+ categoryTitle: '📂 Category: {title}',
20
+ backText: 'Back',
21
+ backLink: '[⬅ {text}](../index.md)',
22
+ galleryDescription: 'This gallery contains automatically generated interaction snapshots (Zero-Hallucination UI Core).',
23
+ }
24
+
25
+ static data = {
26
+ type: 'string',
27
+ help: 'Root directory containing locale folders with data.',
28
+ default: 'docs',
29
+ }
30
+
31
+ static snapshotsDir = {
32
+ type: 'string',
33
+ help: 'Directory where output text snapshots will be stored.',
34
+ default: 'snapshots/core',
35
+ }
36
+
37
+ /**
38
+ * @param {Partial<SnapshotRunner> | Record<string, any>} [data={}]
39
+ * @param {import('@nan0web/types').ModelOptions} [options={}]
40
+ */
41
+ constructor(data = {}, options = {}) {
42
+ super(data, options)
43
+ /** @type {string} Directory containing snapshots */ this.snapshotsDir
44
+ /** @type {string} Root data directory */ this.data
45
+ /** @type {(compName: string) => string} */ this.getCategory = (comp) => 'Components'
46
+ /** @type {(compName: string, varData: any) => AsyncGenerator<any>} */ this.createModelStream
47
+ }
48
+
49
+ get db() {
50
+ return /** @type {import('@nan0web/db').DB} */ (this._.db)
51
+ }
52
+
53
+ /**
54
+ * Recursive drop for directories via DB-FS.
55
+ * @param {string} uri
56
+ */
57
+ async dropRecursive(uri) {
58
+ let entries = []
59
+ try {
60
+ for await (const entry of this.db.readDir(uri)) entries.push(entry)
61
+ } catch (e) {
62
+ return // Dir doesn't exist
63
+ }
64
+ for (const entry of entries) {
65
+ if (entry.stat.isDirectory) {
66
+ await this.dropRecursive(entry.path)
67
+ } else {
68
+ await this.db.dropDocument(entry.path)
69
+ }
70
+ }
71
+ await this.db.dropDocument(uri)
72
+ }
73
+
74
+ async *run() {
75
+ const db = this.db
76
+ const t = this._.t || ((k) => k)
77
+
78
+ // Clean before generation
79
+ await this.dropRecursive(this.snapshotsDir)
80
+
81
+ const doc = (await this.db.fetch('index')) ?? {}
82
+
83
+ // Fetch languages
84
+ const langsData = (await this.db.fetch(`${this.data}/_/langs`)) || []
85
+ const langsIndex = {}
86
+ if (Array.isArray(langsData)) {
87
+ langsData.forEach((l) => {
88
+ if (l && l.locale) langsIndex[l.locale] = l.title || l.locale
89
+ })
90
+ }
91
+
92
+ const langs = []
93
+ for await (const entry of this.db.readDir(this.data)) {
94
+ if (entry.stat.isDirectory && entry.name !== '_' && entry.name !== 'site') langs.push(entry.name)
95
+ }
96
+
97
+ const galleryTree = {}
98
+ let globalErrors = 0
99
+ let globalCount = 0
100
+
101
+ for (const lang of langs) {
102
+ galleryTree[lang] = {}
103
+ const componentsBase = `${this.data}/${lang}/components`
104
+ const components = []
105
+ try {
106
+ for await (const entry of this.db.readDir(componentsBase)) {
107
+ if (!entry.isDirectory) components.push(entry.name)
108
+ }
109
+ } catch (e) {}
110
+
111
+ for (const file of components) {
112
+ const compName = file.replace(/\.[^/.]+$/, "")
113
+ const data = (await this.db.fetch(`${componentsBase}/${compName}`)) || {}
114
+
115
+ // Extract variations
116
+ const variations = data.content || []
117
+ const variationsData = []
118
+
119
+ for (let i = 0; i < variations.length; i++) {
120
+ const rawVar = variations[i]
121
+ let varData = rawVar[compName] !== undefined ? rawVar[compName] : rawVar
122
+
123
+ // Extract schema defaults
124
+ const schema = data['$' + compName] || {}
125
+ const defaultProps = {}
126
+ for (const [k, v] of Object.entries(schema)) {
127
+ if (v && v.default !== undefined) {
128
+ defaultProps[k] = v.default
129
+ }
130
+ }
131
+
132
+ // Merge defaults
133
+ if (typeof varData === 'object' && varData !== null) {
134
+ varData = { ...defaultProps, ...varData }
135
+ } else if (varData === true) {
136
+ varData = { ...defaultProps }
137
+ } else if (typeof varData === 'string' || typeof varData === 'number') {
138
+ varData = { ...defaultProps, content: String(varData) }
139
+ }
140
+
141
+ let varName = rawVar.content || rawVar.title || rawVar.message || `var${i + 1}`
142
+ if (typeof varName !== 'string') {
143
+ if (typeof varData.title === 'string') varName = varData.title
144
+ else if (typeof varData.content === 'string') varName = varData.content
145
+ else varName = `var${i + 1}`
146
+ }
147
+
148
+ const safeVarName = varName
149
+ .trim()
150
+ .toLowerCase()
151
+ .replace(/[./\\:]/g, '_')
152
+ .replace(/\s+/g, '_')
153
+ .replace(/_{2,}/g, '_')
154
+ .slice(0, 50)
155
+
156
+ // Build model stream
157
+ let intents
158
+ if (this.createModelStream) {
159
+ intents = await LogicInspector.capture(this.createModelStream(compName, varData))
160
+ } else {
161
+ const defaultModelStream = async function* () {
162
+ yield render(`ui-${compName.toLowerCase()}`, varData)
163
+ return result({})
164
+ }
165
+ intents = await LogicInspector.capture(defaultModelStream())
166
+ }
167
+
168
+ const snapshot = intents.map((it) => VisualAdapter.render(it)).join('\n')
169
+
170
+ const categoryPath = this.getCategory ? this.getCategory(compName) : 'Components'
171
+ const outPath = `${this.snapshotsDir}/${lang}/${categoryPath}/${compName}`
172
+
173
+ if (!galleryTree[lang][categoryPath]) galleryTree[lang][categoryPath] = {}
174
+ if (!galleryTree[lang][categoryPath][compName])
175
+ galleryTree[lang][categoryPath][compName] = { score: 100, errors: [] }
176
+
177
+ const filePath = `${outPath}/${safeVarName}.nan0`
178
+ await this.db.saveDocument(filePath, snapshot)
179
+
180
+ yield show(t(SnapshotRunner.UI.generating, { lang, comp: `${compName}/${safeVarName}` }))
181
+ variationsData.push({ safeVarName, snapshot })
182
+
183
+ // Instant Audit
184
+ const audit = SnapshotAuditor.inspectText(snapshot, lang, filePath, t)
185
+ if (audit.score < 100) {
186
+ galleryTree[lang][categoryPath][compName].score = Math.min(
187
+ galleryTree[lang][categoryPath][compName].score,
188
+ audit.score,
189
+ )
190
+ galleryTree[lang][categoryPath][compName].errors.push(...audit.errors)
191
+ globalErrors += audit.errors.length
192
+ yield show(
193
+ t(SnapshotRunner.UI.auditFailed, {
194
+ file: filePath,
195
+ errors: audit.errors.join('; '),
196
+ }),
197
+ 'error',
198
+ )
199
+ }
200
+ globalCount++
201
+ }
202
+
203
+ // Generate index.md for component
204
+ if (variationsData.length > 0) {
205
+ const categoryPath = this.getCategory ? this.getCategory(compName) : 'Components'
206
+ const outPath = `${this.snapshotsDir}/${lang}/${categoryPath}/${compName}`
207
+ const desc = t(SnapshotRunner.UI.galleryDescription, undefined)
208
+
209
+ const backPrefix = t(SnapshotRunner.UI.backLink, { text: t(SnapshotRunner.UI.backText, undefined) })
210
+ let markdown = `${backPrefix}\n\n# ${compName}\n\n> ${desc}\n\n`
211
+ for (const { safeVarName, snapshot } of variationsData) {
212
+ markdown += `## ${safeVarName}\n\n\`\`\`yaml\n${snapshot}\n\`\`\`\n\n`
213
+ }
214
+ await this.db.saveDocument(`${outPath}/index.md`, markdown)
215
+ }
216
+ }
217
+ }
218
+
219
+ // Generate top-level indexes
220
+ let rootMd = t(SnapshotRunner.UI.rootGallery, { count: globalCount, errors: globalErrors })
221
+
222
+ for (const lang of Object.keys(galleryTree)) {
223
+ const langTitle = langsIndex[lang] || lang
224
+ rootMd += `- [${langTitle}](./${lang}/index.md) — ${t(SnapshotRunner.UI.galleryDescription, undefined)}\n`
225
+
226
+ const backPrefix = t(SnapshotRunner.UI.backLink, { text: t(SnapshotRunner.UI.backText, undefined) })
227
+ let langMd = `${backPrefix}\n\n# ${t(SnapshotRunner.UI.localeTitle, {
228
+ title: langTitle,
229
+ })}\n\n`
230
+
231
+ for (const category of Object.keys(galleryTree[lang])) {
232
+ langMd += `## [${category}](./${category}/index.md)\n\n`
233
+
234
+ const backPrefix = t(SnapshotRunner.UI.backLink, { text: t(SnapshotRunner.UI.backText, undefined) })
235
+ let catMd = `${backPrefix}\n\n# ${t(SnapshotRunner.UI.categoryTitle, {
236
+ title: category,
237
+ })}\n\n`
238
+
239
+ for (const compName of Object.keys(galleryTree[lang][category])) {
240
+ const compData = galleryTree[lang][category][compName]
241
+ const status = compData.score === 100 ? '✅' : '❌'
242
+
243
+ langMd += `- [${compName}](./${category}/${compName}/index.md) ${status}\n`
244
+ catMd += `- [${compName}](./${compName}/index.md) ${status}\n`
245
+ if (compData.errors.length) {
246
+ catMd += ` - ${compData.errors.join('\\n - ')}\n`
247
+ }
248
+ }
249
+ langMd += '\n'
250
+
251
+ const catDir = `${this.snapshotsDir}/${lang}/${category}`
252
+ await this.db.saveDocument(`${catDir}/index.md`, catMd)
253
+ }
254
+
255
+ const langDir = `${this.snapshotsDir}/${lang}`
256
+ await this.db.saveDocument(`${langDir}/index.md`, langMd)
257
+ }
258
+
259
+ await this.db.saveDocument(`${this.snapshotsDir}/index.md`, rootMd)
260
+ return result({ success: globalErrors === 0, count: globalCount, errors: globalErrors })
261
+ }
262
+ }
263
+
264
+ export default SnapshotRunner