@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.
- package/README.md +97 -12
- package/package.json +54 -25
- package/src/App/Command/DepsCommand.js +3 -4
- package/src/Frame/Props.js +12 -18
- package/src/InterfaceTemplate/InterfaceTemplate.js +9 -7
- package/src/Model/index.js +86 -2
- package/src/StdIn.js +2 -6
- package/src/cli.js +1 -0
- package/src/core/Form/Form.js +8 -7
- package/src/core/Form/Message.js +1 -1
- package/src/core/GeneratorRunner.js +77 -7
- package/src/core/InputAdapter.js +3 -1
- package/src/core/Intent.js +214 -16
- package/src/core/IntentErrorModel.js +6 -1
- package/src/core/Message/Message.js +4 -7
- package/src/core/Message/OutputMessage.js +4 -9
- package/src/core/Stream.js +16 -5
- package/src/core/StreamEntry.js +20 -28
- package/src/core/index.js +2 -1
- package/src/domain/Content.js +196 -0
- package/src/domain/Document.js +17 -0
- package/src/domain/FooterModel.js +37 -19
- package/src/domain/HeaderModel.js +47 -21
- package/src/domain/HeroModel.js +24 -22
- package/src/domain/LayoutModel.js +43 -0
- package/src/domain/ModelAsApp.js +46 -0
- package/src/domain/SandboxModel.js +19 -16
- package/src/domain/app/GalleryCommand.js +53 -0
- package/src/domain/app/GalleryRenderIntent.js +77 -0
- package/src/domain/app/SnapshotAuditor.js +401 -0
- package/src/domain/app/SnapshotRunner.js +264 -0
- package/src/domain/app/UIApp.js +78 -0
- package/src/domain/components/BreadcrumbModel.js +10 -6
- package/src/domain/components/FeatureGridModel.js +62 -0
- package/src/domain/components/MarkdownModel.js +24 -0
- package/src/domain/components/ShellModel.js +243 -0
- package/src/domain/components/TableModel.js +10 -6
- package/src/domain/components/ToastModel.js +10 -6
- package/src/domain/components/index.js +3 -1
- package/src/domain/index.js +14 -4
- package/src/index.js +21 -2
- package/src/inspect.js +2 -0
- package/src/test/ScenarioAdapter.js +59 -0
- package/src/test/ScenarioTest.js +51 -0
- package/src/test/ScenarioTest.story.js +56 -0
- package/src/testing/CrashReporter.js +56 -0
- package/src/testing/GalleryGenerator.js +29 -0
- package/src/testing/LogicInspector.js +55 -0
- package/src/testing/SnapshotRunner.js +22 -0
- package/src/testing/SpecAdapter.js +115 -0
- package/src/testing/SpecRunner.js +121 -0
- package/src/testing/VisualAdapter.js +46 -0
- package/src/testing/index.js +7 -0
- package/src/testing/verifySnapshot.js +17 -0
- package/types/App/Command/DepsCommand.d.ts +0 -2
- package/types/Model/index.d.ts +56 -4
- package/types/StdIn.d.ts +3 -3
- package/types/cli.d.ts +1 -0
- package/types/core/Form/Form.d.ts +2 -2
- package/types/core/GeneratorRunner.d.ts +18 -1
- package/types/core/InputAdapter.d.ts +2 -1
- package/types/core/Intent.d.ts +232 -26
- package/types/core/IntentErrorModel.d.ts +4 -0
- package/types/core/Message/Message.d.ts +2 -2
- package/types/core/Message/OutputMessage.d.ts +0 -2
- package/types/core/index.d.ts +2 -1
- package/types/domain/Content.d.ts +340 -0
- package/types/domain/Document.d.ts +21 -0
- package/types/domain/FooterModel.d.ts +22 -12
- package/types/domain/HeaderModel.d.ts +36 -13
- package/types/domain/HeroModel.d.ts +19 -17
- package/types/domain/LayoutModel.d.ts +34 -0
- package/types/domain/ModelAsApp.d.ts +23 -0
- package/types/domain/SandboxModel.d.ts +10 -0
- package/types/domain/app/GalleryCommand.d.ts +55 -0
- package/types/domain/app/GalleryRenderIntent.d.ts +31 -0
- package/types/domain/app/SnapshotAuditor.d.ts +99 -0
- package/types/domain/app/SnapshotRunner.d.ts +45 -0
- package/types/domain/app/UIApp.d.ts +60 -0
- package/types/domain/components/BreadcrumbModel.d.ts +6 -8
- package/types/domain/components/FeatureGridModel.d.ts +50 -0
- package/types/domain/components/MarkdownModel.d.ts +19 -0
- package/types/domain/components/ShellModel.d.ts +56 -0
- package/types/domain/components/TableModel.d.ts +4 -0
- package/types/domain/components/ToastModel.d.ts +4 -0
- package/types/domain/components/index.d.ts +3 -0
- package/types/domain/index.d.ts +10 -4
- package/types/index.d.ts +19 -1
- package/types/inspect.d.ts +2 -0
- package/types/test/ScenarioAdapter.d.ts +43 -0
- package/types/test/ScenarioTest.d.ts +24 -0
- package/types/test/ScenarioTest.story.d.ts +1 -0
- package/types/testing/CrashReporter.d.ts +13 -0
- package/types/testing/GalleryGenerator.d.ts +1 -0
- package/types/testing/LogicInspector.d.ts +22 -0
- package/types/testing/SnapshotRunner.d.ts +7 -0
- package/types/testing/SpecAdapter.d.ts +57 -0
- package/types/testing/SpecRunner.d.ts +41 -0
- package/types/testing/VisualAdapter.d.ts +9 -0
- package/types/testing/index.d.ts +7 -0
- package/types/testing/verifySnapshot.d.ts +14 -0
- package/src/README.md.js +0 -436
- package/types/App/Command/Options.d.ts +0 -43
- package/types/App/Command/index.d.ts +0 -8
- package/types/App/User/Command/Options.d.ts +0 -34
- package/types/core/Message/InputMessage.d.ts +0 -71
- package/types/domain/components/HeroModel.d.ts +0 -24
- 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
|