@nan0web/ui 1.9.0 → 1.10.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 +29 -10
- package/package.json +10 -13
- package/src/Model/index.js +32 -3
- package/src/core/Form/Form.js +8 -7
- package/src/core/Form/Message.js +1 -1
- package/src/core/GeneratorRunner.js +10 -0
- package/src/core/Intent.js +17 -2
- package/src/core/IntentErrorModel.js +6 -1
- package/src/core/Stream.js +16 -5
- package/src/core/index.js +1 -1
- package/src/testing/GalleryGenerator.js +85 -0
- package/src/testing/LogicInspector.js +55 -0
- package/src/testing/SnapshotInspector.js +84 -0
- package/src/testing/VisualAdapter.js +41 -0
- package/src/testing/index.js +3 -0
- package/types/Model/index.d.ts +62 -4
- package/types/core/Form/Form.d.ts +2 -2
- package/types/core/GeneratorRunner.d.ts +4 -0
- package/types/core/Intent.d.ts +31 -3
- package/types/core/IntentErrorModel.d.ts +4 -0
- package/types/core/index.d.ts +1 -1
- package/types/testing/GalleryGenerator.d.ts +1 -0
- package/types/testing/LogicInspector.d.ts +22 -0
- package/types/testing/SnapshotInspector.d.ts +17 -0
- package/types/testing/VisualAdapter.d.ts +15 -0
- package/types/testing/index.d.ts +3 -0
- package/src/README.md.js +0 -436
package/README.md
CHANGED
|
@@ -90,9 +90,9 @@ const form = new UiForm({
|
|
|
90
90
|
message: 'Hello!',
|
|
91
91
|
},
|
|
92
92
|
})
|
|
93
|
-
const errors = form.validate()
|
|
94
|
-
console.info(errors.
|
|
95
|
-
console.info(errors.
|
|
93
|
+
const { isValid, errors } = form.validate()
|
|
94
|
+
console.info(Object.keys(errors).length) // ← 1
|
|
95
|
+
console.info(errors.email) // ← Invalid email format
|
|
96
96
|
```
|
|
97
97
|
### Components
|
|
98
98
|
|
|
@@ -148,18 +148,37 @@ const frame = new Frame({
|
|
|
148
148
|
const rendered = frame.render()
|
|
149
149
|
console.info(rendered.includes('Frame content')) // ← true
|
|
150
150
|
```
|
|
151
|
-
### Models
|
|
151
|
+
### Domain Models (v1.9.0)
|
|
152
152
|
|
|
153
|
-
|
|
153
|
+
v1.9.0 introduces a comprehensive set of domain models for layout and components.
|
|
154
|
+
These models follow the **Model-as-Schema** pattern.
|
|
154
155
|
|
|
155
|
-
|
|
156
|
+
#### Layout Models
|
|
157
|
+
- `HeaderModel` — title, logo, navigation actions
|
|
158
|
+
- `FooterModel` — copyright, version, social links
|
|
159
|
+
- `HeroModel` — prominent call-to-action
|
|
156
160
|
|
|
157
|
-
|
|
161
|
+
#### Component Models
|
|
162
|
+
- `PricingModel` — plans with features and prices
|
|
163
|
+
- `CommentModel` & `TestimonialModel` — social proof
|
|
164
|
+
- `StatsModel` — data visualizations
|
|
165
|
+
- `TimelineModel` — event history
|
|
166
|
+
|
|
167
|
+
How to use the new Header and Hero models?
|
|
158
168
|
```js
|
|
159
169
|
import { Model } from '@nan0web/ui'
|
|
160
|
-
const
|
|
161
|
-
|
|
162
|
-
|
|
170
|
+
const { HeaderModel, HeroModel } = Model
|
|
171
|
+
const header = new HeaderModel({
|
|
172
|
+
title: 'NaN•Web',
|
|
173
|
+
logo: '/logo.svg',
|
|
174
|
+
actions: [{ title: 'Docs', href: '/docs' }],
|
|
175
|
+
})
|
|
176
|
+
const hero = new HeroModel({
|
|
177
|
+
title: 'One Logic — Many UI',
|
|
178
|
+
actions: [{ title: 'Get Started', href: '/start' }],
|
|
179
|
+
})
|
|
180
|
+
console.info(header.title) // ← NaN•Web
|
|
181
|
+
console.info(hero.actions[0].title) // ← Get Started
|
|
163
182
|
```
|
|
164
183
|
### Testing UI
|
|
165
184
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nan0web/ui",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.10.0",
|
|
4
4
|
"description": "NaN•Web UI. One application logic (algorithm) and many UI.",
|
|
5
5
|
"main": "src/index.js",
|
|
6
6
|
"types": "types/index.d.ts",
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
"files": [
|
|
9
9
|
"src/**/*.js",
|
|
10
10
|
"!src/**/*.test.js",
|
|
11
|
+
"!src/README.md.js",
|
|
11
12
|
"types/**/*.d.ts"
|
|
12
13
|
],
|
|
13
14
|
"exports": {
|
|
@@ -15,17 +16,10 @@
|
|
|
15
16
|
"import": "./src/index.js",
|
|
16
17
|
"types": "./types/index.d.ts"
|
|
17
18
|
},
|
|
18
|
-
"./components":
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
"./core": {
|
|
23
|
-
"import": "./src/core/index.js",
|
|
24
|
-
"types": "./types/core/index.d.ts"
|
|
25
|
-
},
|
|
26
|
-
"./domain": {
|
|
27
|
-
"import": "./src/domain/index.js"
|
|
28
|
-
},
|
|
19
|
+
"./components": "./src/Component/index.js",
|
|
20
|
+
"./core": "./src/core/index.js",
|
|
21
|
+
"./domain": "./src/domain/index.js",
|
|
22
|
+
"./testing": "./src/testing/index.js",
|
|
29
23
|
"./cli-app": "./apps/cli/src/index.js",
|
|
30
24
|
"./mobile-app": "./apps/mobile/src/index.js",
|
|
31
25
|
"./web-app": "./apps/web/src/App.jsx"
|
|
@@ -73,10 +67,13 @@
|
|
|
73
67
|
"test:release": "node --test \"releases/**/*.test.js\"",
|
|
74
68
|
"test:status": "nan0test status --hide-name",
|
|
75
69
|
"test:play": "node --test --test-timeout=3333 \"play/**/*.test.js\"",
|
|
70
|
+
"test:snapshots": "node src/testing/GalleryGenerator.js",
|
|
71
|
+
"test:gallery": "npm run test:snapshots && npx nan0gallery --dir=snapshots/core",
|
|
72
|
+
"test:audit": "node --test src/test/gallery_audit.test.js",
|
|
76
73
|
"test:ssg": "node --test --test-timeout=5000 \"src/test/ssg.test.js\"",
|
|
77
74
|
"test:e2e": "playwright test --ignore-snapshots test/e2e/components.spec.js test/e2e/debug-label.spec.js",
|
|
78
75
|
"test:e2e:slow": "E2E_SLOW=1 playwright test test/e2e/visual.spec.js",
|
|
79
|
-
"test:all": "npm run test && npm run test:docs && npm run test:play && npm run test:e2e && npm run knip",
|
|
76
|
+
"test:all": "npm run test && npm run test:docs && npm run test:play && npm run test:gallery && npm run test:audit && npm run test:e2e && npm run knip",
|
|
80
77
|
"knip": "knip --production",
|
|
81
78
|
"precommit": "npm test",
|
|
82
79
|
"prepush": "npm test",
|
package/src/Model/index.js
CHANGED
|
@@ -1,7 +1,36 @@
|
|
|
1
1
|
import User from './User/User.js'
|
|
2
|
+
import * as DomainModels from '../domain/index.js'
|
|
2
3
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
export default {
|
|
4
|
+
const Model = {
|
|
6
5
|
User,
|
|
6
|
+
...DomainModels,
|
|
7
7
|
}
|
|
8
|
+
|
|
9
|
+
export { User }
|
|
10
|
+
export const {
|
|
11
|
+
HeaderModel,
|
|
12
|
+
FooterModel,
|
|
13
|
+
HeroModel,
|
|
14
|
+
ButtonModel,
|
|
15
|
+
ConfirmModel,
|
|
16
|
+
InputModel,
|
|
17
|
+
SpinnerModel,
|
|
18
|
+
TableModel,
|
|
19
|
+
ToastModel,
|
|
20
|
+
SelectModel,
|
|
21
|
+
AutocompleteModel,
|
|
22
|
+
TreeModel,
|
|
23
|
+
TabsModel,
|
|
24
|
+
AccordionModel,
|
|
25
|
+
GalleryModel,
|
|
26
|
+
PriceModel,
|
|
27
|
+
PricingModel,
|
|
28
|
+
CommentModel,
|
|
29
|
+
TestimonialModel,
|
|
30
|
+
StatsItemModel,
|
|
31
|
+
StatsModel,
|
|
32
|
+
TimelineItemModel,
|
|
33
|
+
TimelineModel,
|
|
34
|
+
} = DomainModels
|
|
35
|
+
|
|
36
|
+
export default Model
|
package/src/core/Form/Form.js
CHANGED
|
@@ -106,10 +106,11 @@ export default class UIForm extends FormMessage {
|
|
|
106
106
|
/**
|
|
107
107
|
* Validates the entire form.
|
|
108
108
|
*
|
|
109
|
-
* @returns {
|
|
109
|
+
* @returns {any}
|
|
110
110
|
*/
|
|
111
111
|
validate() {
|
|
112
|
-
|
|
112
|
+
/** @type {any} */
|
|
113
|
+
const errors = {}
|
|
113
114
|
let isValid = true
|
|
114
115
|
|
|
115
116
|
this.fields.forEach((field) => {
|
|
@@ -120,7 +121,7 @@ export default class UIForm extends FormMessage {
|
|
|
120
121
|
field.required &&
|
|
121
122
|
(fieldValue === '' || fieldValue === null || fieldValue === undefined)
|
|
122
123
|
) {
|
|
123
|
-
errors
|
|
124
|
+
errors[field.name] = 'This field is required'
|
|
124
125
|
isValid = false
|
|
125
126
|
return
|
|
126
127
|
}
|
|
@@ -132,14 +133,12 @@ export default class UIForm extends FormMessage {
|
|
|
132
133
|
)
|
|
133
134
|
|
|
134
135
|
if (!fieldValid) {
|
|
135
|
-
|
|
136
|
-
errors.set(key, err)
|
|
137
|
-
}
|
|
136
|
+
Object.assign(errors, fieldErrors)
|
|
138
137
|
isValid = false
|
|
139
138
|
}
|
|
140
139
|
})
|
|
141
140
|
|
|
142
|
-
return errors
|
|
141
|
+
return { isValid, errors }
|
|
143
142
|
}
|
|
144
143
|
|
|
145
144
|
/**
|
|
@@ -239,6 +238,8 @@ export default class UIForm extends FormMessage {
|
|
|
239
238
|
*/
|
|
240
239
|
toJSON() {
|
|
241
240
|
return {
|
|
241
|
+
id: this.id,
|
|
242
|
+
type: this.type,
|
|
242
243
|
time: new Date(this.time).toISOString(),
|
|
243
244
|
title: this.title,
|
|
244
245
|
fields: this.fields.map((f) => (f.toJSON ? f.toJSON() : f)),
|
package/src/core/Form/Message.js
CHANGED
|
@@ -14,7 +14,7 @@ export default class FormMessage extends UiMessage {
|
|
|
14
14
|
* @param {Object} [input={}] - Message properties.
|
|
15
15
|
*/
|
|
16
16
|
constructor(input = {}) {
|
|
17
|
-
super(input)
|
|
17
|
+
super({ type: 'form', ...input })
|
|
18
18
|
const { data = {}, schema = {} } = input
|
|
19
19
|
|
|
20
20
|
// Store data and schema for easy access
|
|
@@ -23,6 +23,8 @@ import { IntentErrorModel } from './IntentErrorModel.js'
|
|
|
23
23
|
* Handler for 'progress' intents. Optional (defaults to no-op).
|
|
24
24
|
* @property {(intent: import('./Intent.js').LogIntent) => void | Promise<void>} [log]
|
|
25
25
|
* Handler for 'log' intents. Optional (defaults to no-op).
|
|
26
|
+
* @property {(intent: import('./Intent.js').RenderIntent) => void | Promise<void>} [render]
|
|
27
|
+
* Handler for 'render' intents (visual component injection). Optional.
|
|
26
28
|
* @property {(intent: import('./Intent.js').ResultIntent) => void | Promise<void>} [result]
|
|
27
29
|
* Handler for the final 'result'. Optional (defaults to no-op).
|
|
28
30
|
*/
|
|
@@ -206,6 +208,14 @@ export async function runGenerator(generator, handlers, options = {}) {
|
|
|
206
208
|
break
|
|
207
209
|
}
|
|
208
210
|
|
|
211
|
+
case 'render': {
|
|
212
|
+
if (handlers.render) {
|
|
213
|
+
await handlers.render(intent)
|
|
214
|
+
}
|
|
215
|
+
nextVal = undefined
|
|
216
|
+
break
|
|
217
|
+
}
|
|
218
|
+
|
|
209
219
|
default:
|
|
210
220
|
throw IntentErrorModel.error('unhandled_intent', { type: /** @type {any} */ (intent).type })
|
|
211
221
|
}
|
package/src/core/Intent.js
CHANGED
|
@@ -58,9 +58,18 @@ import { IntentErrorModel } from './IntentErrorModel.js'
|
|
|
58
58
|
* @property {*} data - The raw result data (JSON-serializable).
|
|
59
59
|
*/
|
|
60
60
|
|
|
61
|
+
/**
|
|
62
|
+
* Model requests rendering of a pure UI component (Header, Footer, Static Map).
|
|
63
|
+
* No response expected from the logic loop.
|
|
64
|
+
* @typedef {Object} RenderIntent
|
|
65
|
+
* @property {'render'} type
|
|
66
|
+
* @property {string} component - Component name (e.g. 'App.Layout.Header').
|
|
67
|
+
* @property {object} props - Static props for the component.
|
|
68
|
+
*/
|
|
69
|
+
|
|
61
70
|
/**
|
|
62
71
|
* Union of all possible yielded intents.
|
|
63
|
-
* @typedef {AskIntent | ProgressIntent | LogIntent} Intent
|
|
72
|
+
* @typedef {AskIntent | ProgressIntent | LogIntent | RenderIntent} Intent
|
|
64
73
|
*/
|
|
65
74
|
|
|
66
75
|
// ─── Response Types (Adapter → Model) ───
|
|
@@ -95,7 +104,7 @@ import { IntentErrorModel } from './IntentErrorModel.js'
|
|
|
95
104
|
* @typedef {AskResponse | AbortResponse | undefined} IntentResponse
|
|
96
105
|
*/
|
|
97
106
|
|
|
98
|
-
export const INTENT_TYPES = /** @type {const} */ (['ask', 'progress', 'log'])
|
|
107
|
+
export const INTENT_TYPES = /** @type {const} */ (['ask', 'progress', 'log', 'render'])
|
|
99
108
|
|
|
100
109
|
/**
|
|
101
110
|
* Detects if a value is a Model-as-Schema class (has static fields with `help`).
|
|
@@ -143,6 +152,11 @@ export function validateIntent(intent) {
|
|
|
143
152
|
throw IntentErrorModel.error('intent_missing_message', { type: intent.type })
|
|
144
153
|
}
|
|
145
154
|
}
|
|
155
|
+
if (intent.type === 'render') {
|
|
156
|
+
if (typeof intent.component !== 'string' || !intent.component) {
|
|
157
|
+
throw IntentErrorModel.error('render_missing_component')
|
|
158
|
+
}
|
|
159
|
+
}
|
|
146
160
|
return true
|
|
147
161
|
}
|
|
148
162
|
|
|
@@ -166,4 +180,5 @@ export const ask = (field, schema) => {
|
|
|
166
180
|
|
|
167
181
|
export const progress = (message) => ({ type: 'progress', message })
|
|
168
182
|
export const log = (level, message, data = {}) => ({ type: 'log', level, message, ...data })
|
|
183
|
+
export const render = (component, props = {}) => ({ type: 'render', component, props })
|
|
169
184
|
export const result = (data) => ({ type: 'result', data })
|
|
@@ -39,7 +39,12 @@ export class IntentErrorModel {
|
|
|
39
39
|
|
|
40
40
|
static intent_missing_message = {
|
|
41
41
|
help: 'Progress and Log intents require a message',
|
|
42
|
-
error: '
|
|
42
|
+
error: "'{type}' intent requires a \"message\" string",
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
static render_missing_component = {
|
|
46
|
+
help: 'Render intent requires a component name',
|
|
47
|
+
error: 'Render intent requires a non-empty "component" string',
|
|
43
48
|
}
|
|
44
49
|
|
|
45
50
|
// ─── Runner Contract Errors ───
|
package/src/core/Stream.js
CHANGED
|
@@ -44,19 +44,30 @@ export default class UIStream {
|
|
|
44
44
|
static async process(signal, generatorFn, onProgress, onError, onComplete) {
|
|
45
45
|
const iter = generatorFn()
|
|
46
46
|
|
|
47
|
+
/** @type {Promise<never>} */
|
|
48
|
+
const abortPromise = new Promise((_, reject) => {
|
|
49
|
+
const onAbort = () => reject(new DOMException('The operation was aborted', 'AbortError'))
|
|
50
|
+
if (signal.aborted) return onAbort()
|
|
51
|
+
signal.addEventListener('abort', onAbort, { once: true })
|
|
52
|
+
})
|
|
53
|
+
|
|
47
54
|
try {
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
55
|
+
while (true) {
|
|
56
|
+
const { value: item, done } = await Promise.race([iter.next(), abortPromise])
|
|
57
|
+
|
|
58
|
+
if (done) {
|
|
59
|
+
if (item) onComplete?.(item)
|
|
60
|
+
break
|
|
51
61
|
}
|
|
52
62
|
|
|
53
63
|
if (item.done) {
|
|
54
64
|
onComplete?.(item)
|
|
55
65
|
break
|
|
56
|
-
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (item.error) {
|
|
57
69
|
onError?.(item.error, item)
|
|
58
70
|
} else {
|
|
59
|
-
// Intermediate results
|
|
60
71
|
onProgress?.(null, item)
|
|
61
72
|
}
|
|
62
73
|
}
|
package/src/core/index.js
CHANGED
|
@@ -41,7 +41,7 @@ export {
|
|
|
41
41
|
export { default as Flow } from './Flow.js'
|
|
42
42
|
|
|
43
43
|
// OLMUI Generator Engine — Intent-based Model→Adapter contract
|
|
44
|
-
export { validateIntent, ask, progress, log, result, INTENT_TYPES, isModelSchema } from './Intent.js'
|
|
44
|
+
export { validateIntent, ask, progress, log, render, result, INTENT_TYPES, isModelSchema } from './Intent.js'
|
|
45
45
|
export { IntentErrorModel } from './IntentErrorModel.js'
|
|
46
46
|
export { runGenerator } from './GeneratorRunner.js'
|
|
47
47
|
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import fs from 'fs'
|
|
2
|
+
import path from 'path'
|
|
3
|
+
import { fileURLToPath } from 'url'
|
|
4
|
+
import yaml from 'js-yaml'
|
|
5
|
+
import { LogicInspector } from './LogicInspector.js'
|
|
6
|
+
import { VisualAdapter } from './VisualAdapter.js'
|
|
7
|
+
import * as Models from '../domain/index.js'
|
|
8
|
+
|
|
9
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
|
10
|
+
const rootDir = path.resolve(__dirname, '../../')
|
|
11
|
+
const dataDir = path.resolve(rootDir, 'docs/data')
|
|
12
|
+
const snapshotsDir = path.resolve(rootDir, 'snapshots/core')
|
|
13
|
+
|
|
14
|
+
// Clean before generation
|
|
15
|
+
if (fs.existsSync(snapshotsDir)) fs.rmSync(snapshotsDir, { recursive: true, force: true })
|
|
16
|
+
fs.mkdirSync(snapshotsDir, { recursive: true })
|
|
17
|
+
|
|
18
|
+
const groups = {
|
|
19
|
+
Actions: ['Button', 'Toggle'],
|
|
20
|
+
Forms: ['Input', 'Select', 'Slider', 'Autocomplete', 'Color', 'Shadow'],
|
|
21
|
+
Data: ['Accordion', 'Card', 'Sortable', 'Table', 'Tree', 'CodeBlock', 'Markdown', 'Badge'],
|
|
22
|
+
Feedback: ['Alert', 'Confirm', 'Modal', 'ProgressBar', 'Spinner', 'Toast'],
|
|
23
|
+
System: ['LangSelect', 'ThemeToggle'],
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function getCategory(comp) {
|
|
27
|
+
for (const [cat, comps] of Object.entries(groups)) {
|
|
28
|
+
if (comps.includes(comp)) return cat
|
|
29
|
+
}
|
|
30
|
+
return 'Other'
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async function generate() {
|
|
34
|
+
const langs = fs.readdirSync(dataDir).filter(d => fs.statSync(path.join(dataDir, d)).isDirectory() && d !== '_')
|
|
35
|
+
|
|
36
|
+
for (const lang of langs) {
|
|
37
|
+
const langDir = path.join(dataDir, lang)
|
|
38
|
+
const components = fs.readdirSync(langDir).filter(f => f.endsWith('.yaml'))
|
|
39
|
+
|
|
40
|
+
for (const file of components) {
|
|
41
|
+
const compName = file.replace('.yaml', '')
|
|
42
|
+
const category = getCategory(compName)
|
|
43
|
+
const text = fs.readFileSync(path.join(langDir, file), 'utf-8')
|
|
44
|
+
const data = yaml.load(text)
|
|
45
|
+
|
|
46
|
+
// Variations are in data.content
|
|
47
|
+
const variations = data.content || []
|
|
48
|
+
|
|
49
|
+
for (let i = 0; i < variations.length; i++) {
|
|
50
|
+
const varData = variations[i][compName] || variations[i]
|
|
51
|
+
|
|
52
|
+
// Get variation name from 'content', 'title', 'message', or fallback to index
|
|
53
|
+
let varName = varData.content || varData.title || varData.message || `var${i + 1}`
|
|
54
|
+
if (typeof varName !== 'string') varName = `var${i + 1}`
|
|
55
|
+
|
|
56
|
+
// Clean filename: allow Ukrainian, but replace spaces/special chars with single underscore
|
|
57
|
+
const safeVarName = varName
|
|
58
|
+
.trim()
|
|
59
|
+
.toLowerCase()
|
|
60
|
+
.replace(/[./\\:]/g, '_') // Replace paths/dots
|
|
61
|
+
.replace(/\s+/g, '_') // Replace spaces
|
|
62
|
+
.replace(/_{2,}/g, '_') // No double underscores
|
|
63
|
+
.slice(0, 50) // Max length
|
|
64
|
+
|
|
65
|
+
// Logic Capture
|
|
66
|
+
/** @type {() => AsyncGenerator<import('../core/Intent.js').Intent, import('../core/Intent.js').ResultIntent, any>} */
|
|
67
|
+
const modelStream = async function* () {
|
|
68
|
+
yield { type: 'render', component: `ui-${compName.toLowerCase()}`, props: varData }
|
|
69
|
+
return { type: 'result', data: { ok: true } }
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const intents = await LogicInspector.capture(modelStream())
|
|
73
|
+
const snapshot = intents.map(it => VisualAdapter.render(it)).join('\n')
|
|
74
|
+
|
|
75
|
+
const outPath = path.join(snapshotsDir, lang, category, compName)
|
|
76
|
+
if (!fs.existsSync(outPath)) fs.mkdirSync(outPath, { recursive: true })
|
|
77
|
+
|
|
78
|
+
fs.writeFileSync(path.join(outPath, `${safeVarName}.txt`), snapshot)
|
|
79
|
+
console.log(`📸 Generated snapshot for ${lang}/${category}/${compName}/${safeVarName}`)
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
generate().catch(console.error)
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { runGenerator } from '../core/GeneratorRunner.js'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* LogicInspector
|
|
5
|
+
*
|
|
6
|
+
* Базовий клас для захоплення "Логічних зліпків" (Intent Stream) будь-яких моделей OLMUI.
|
|
7
|
+
* Дозволяє виконувати чисто-логічне тестування без прив'язки до рендерингу.
|
|
8
|
+
*/
|
|
9
|
+
export class LogicInspector {
|
|
10
|
+
/**
|
|
11
|
+
* Виконує генератор моделі та записує послідовність усіх інтенцій.
|
|
12
|
+
* @param {AsyncGenerator<import('../core/Intent.js').Intent, import('../core/Intent.js').ResultIntent, import('../core/Intent.js').IntentResponse>} modelStream - результат виклику model.run()
|
|
13
|
+
* @param {object} options
|
|
14
|
+
* @param {Array<any> | ((locale: string) => Array<any>)} [options.inputs] - черга вхідних значень для askIntent
|
|
15
|
+
* @param {string} [options.locale] - локаль для тестів
|
|
16
|
+
* @param {function} [options.t] - функція перекладу
|
|
17
|
+
* @returns {Promise<Array<any>>} Intent Stream Log
|
|
18
|
+
*/
|
|
19
|
+
static async capture(modelStream, { inputs = [], locale = 'uk', t = (k) => k } = {}) {
|
|
20
|
+
const intents = []
|
|
21
|
+
let inputIdx = 0
|
|
22
|
+
const resolvedInputs = typeof inputs === 'function' ? inputs(locale) : inputs
|
|
23
|
+
|
|
24
|
+
const recordingAdapter = {
|
|
25
|
+
/** @param {import('../core/Intent.js').AskIntent} i */
|
|
26
|
+
ask: async (i) => {
|
|
27
|
+
const value = resolvedInputs[inputIdx++]
|
|
28
|
+
const entry = { type: 'ask', field: i.field, schema: i.schema, input: value }
|
|
29
|
+
intents.push(entry)
|
|
30
|
+
return { value }
|
|
31
|
+
},
|
|
32
|
+
/** @param {import('../core/Intent.js').LogIntent} i */
|
|
33
|
+
log: async (i) => {
|
|
34
|
+
intents.push({ type: 'log', level: i.level || 'info', message: i.message })
|
|
35
|
+
},
|
|
36
|
+
/** @param {import('../core/Intent.js').ProgressIntent} i */
|
|
37
|
+
progress: async (i) => {
|
|
38
|
+
intents.push({ type: 'progress', message: i.message })
|
|
39
|
+
},
|
|
40
|
+
/** @param {import('../core/Intent.js').RenderIntent} i */
|
|
41
|
+
render: async (i) => {
|
|
42
|
+
intents.push({ type: 'render', component: i.component, props: i.props })
|
|
43
|
+
},
|
|
44
|
+
/** @param {import('../core/Intent.js').ResultIntent} i */
|
|
45
|
+
result: async (i) => {
|
|
46
|
+
intents.push({ type: 'result', data: i.data })
|
|
47
|
+
},
|
|
48
|
+
t
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Викликаємо базовий раннер з нашим записуючим адаптером
|
|
52
|
+
await runGenerator(modelStream, recordingAdapter)
|
|
53
|
+
return intents
|
|
54
|
+
}
|
|
55
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import fs from 'fs'
|
|
2
|
+
import path from 'path'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* SnapshotInspector
|
|
6
|
+
*
|
|
7
|
+
* Рев'ювер для автоматичної перевірки Snapshot-зліпків на наявність артефактів,
|
|
8
|
+
* неперекладених ключів та структурних помилок.
|
|
9
|
+
* Реалізує правила "Zero-Hallucination Snapshot Validation".
|
|
10
|
+
*/
|
|
11
|
+
export class SnapshotInspector {
|
|
12
|
+
/**
|
|
13
|
+
* Перевіряє вміст одного снепшоту.
|
|
14
|
+
* @param {string} content - Текстовий вміст .txt файлу галереї
|
|
15
|
+
* @param {string} locale - Локаль (uk, en)
|
|
16
|
+
* @param {string} [filename] - Ім'я файлу для перевірки на "глюки" (підкреслення)
|
|
17
|
+
* @returns {object} { score, errors }
|
|
18
|
+
*/
|
|
19
|
+
static inspect(content, locale = 'uk', filename = '') {
|
|
20
|
+
const errors = []
|
|
21
|
+
const lines = content.split('\n')
|
|
22
|
+
|
|
23
|
+
// 0. Перевірка імені файлу (на прохання архітектора)
|
|
24
|
+
if (filename) {
|
|
25
|
+
if (/__|--/.test(filename)) {
|
|
26
|
+
errors.push(`Filename "${filename}" has multiple consecutive separators (glitch detected).`)
|
|
27
|
+
}
|
|
28
|
+
if (filename.length < 3) {
|
|
29
|
+
errors.push(`Filename "${filename}" is too short.`)
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
lines.forEach((line, index) => {
|
|
34
|
+
const lineNum = index + 1
|
|
35
|
+
const trimmed = line.trim()
|
|
36
|
+
|
|
37
|
+
// 1. Неперекладені i18n ключі (крапки в словах)
|
|
38
|
+
// Виключаємо імена компонентів, атрибути RENDER-тегів, та числа з крапкою (версії, координати)
|
|
39
|
+
const isAttribute = trimmed.includes('="')
|
|
40
|
+
const isDotNumber = /^-?\d+\.\d+$/.test(trimmed)
|
|
41
|
+
|
|
42
|
+
if (/\w+\.\w+/.test(line) && !line.includes('ui-') && !line.includes('http') && !isAttribute && !isDotNumber) {
|
|
43
|
+
errors.push(`Line ${lineNum}: Possible untranslated key found: "${trimmed}"`)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// 2. Технічні артефакти
|
|
47
|
+
if (line.includes('[object Object]')) {
|
|
48
|
+
errors.push(`Line ${lineNum}: Critical artifact "[object Object]" found.`)
|
|
49
|
+
}
|
|
50
|
+
if (line.includes('undefined')) {
|
|
51
|
+
errors.push(`Line ${lineNum}: Critical artifact "undefined" found.`)
|
|
52
|
+
}
|
|
53
|
+
if (line.includes('NaN')) {
|
|
54
|
+
errors.push(`Line ${lineNum}: Critical artifact "NaN" found.`)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// 3. Англійські слова в українській локалі (базові)
|
|
58
|
+
if (locale === 'uk') {
|
|
59
|
+
const enWords = ['Back', 'Select', 'Cancel', 'Submit', 'Confirm', 'Delete', 'Information', 'Success', 'Warning', 'Error']
|
|
60
|
+
enWords.forEach(word => {
|
|
61
|
+
const regex = new RegExp(`\\b${word}\\b`, 'i')
|
|
62
|
+
// Перевіряємо тільки якщо це не частина тегу <ui-...> або атрибут
|
|
63
|
+
if (regex.test(line)) {
|
|
64
|
+
const isTag = line.includes(`<ui-${word.toLowerCase()}`) || line.includes(`[RENDER] <ui-`)
|
|
65
|
+
const isAsk = line.includes(`[ASK] ${word}`)
|
|
66
|
+
if (!isTag && !isAsk && !isAttribute) {
|
|
67
|
+
errors.push(`Line ${lineNum}: English word "${word}" found in "uk" locale.`)
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
})
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// 4. Помилки роутингу
|
|
74
|
+
if (line.includes('🚨 Path not found')) {
|
|
75
|
+
errors.push(`Line ${lineNum}: Routing error "Path not found".`)
|
|
76
|
+
}
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
return {
|
|
80
|
+
score: errors.length === 0 ? 100 : Math.max(0, 100 - errors.length * 10),
|
|
81
|
+
errors
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* VisualAdapter (Base)
|
|
3
|
+
*
|
|
4
|
+
* Базовий клас для візуальної трансформації інтенцій OLMUI.
|
|
5
|
+
* Використовувана у @nan0web/ui як фундамент для спеціалізованих рендерерів.
|
|
6
|
+
*/
|
|
7
|
+
export class VisualAdapter {
|
|
8
|
+
/**
|
|
9
|
+
* Конвертує одну інтенцію у просте текстове представлення.
|
|
10
|
+
* @param {object} intent - Intent entry from LogicInspector
|
|
11
|
+
* @param {function} [t] - i18n translate function
|
|
12
|
+
* @returns {string} Raw description
|
|
13
|
+
*/
|
|
14
|
+
static render(intent, t = (k) => k) {
|
|
15
|
+
switch (intent.type) {
|
|
16
|
+
case 'ask':
|
|
17
|
+
return `[ASK] ${intent.field}: ${intent.input !== undefined ? intent.input : '...'}`
|
|
18
|
+
case 'progress':
|
|
19
|
+
return `[PROGRESS] ${intent.message || ''}`
|
|
20
|
+
case 'log':
|
|
21
|
+
return `[LOG ${intent.level?.toUpperCase() || 'INFO'}] ${typeof intent.message === 'object' ? JSON.stringify(intent.message) : intent.message}`
|
|
22
|
+
case 'render': {
|
|
23
|
+
const { content, ...propsData } = intent.props || {}
|
|
24
|
+
const props = Object.entries(propsData)
|
|
25
|
+
.map(([k, v]) => ` ${k}="${typeof v === 'object' ? JSON.stringify(v) : v}"`)
|
|
26
|
+
.join('\n')
|
|
27
|
+
|
|
28
|
+
if (content) {
|
|
29
|
+
const attrs = props ? `\n${props}\n` : ' '
|
|
30
|
+
return `[RENDER] <${intent.component}${attrs}>${content}</${intent.component}>`
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return `[RENDER] <${intent.component}${props ? '\n' + props + '\n' : ''}>`
|
|
34
|
+
}
|
|
35
|
+
case 'result':
|
|
36
|
+
return `[RESULT] ${JSON.stringify(intent.data)}`
|
|
37
|
+
default:
|
|
38
|
+
return `[UNKNOWN: ${intent.type}] ${JSON.stringify(intent)}`
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
package/types/Model/index.d.ts
CHANGED
|
@@ -1,6 +1,64 @@
|
|
|
1
1
|
export { User };
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
export
|
|
2
|
+
export const HeaderModel: typeof DomainModels.HeaderModel;
|
|
3
|
+
export const FooterModel: typeof DomainModels.FooterModel;
|
|
4
|
+
export const HeroModel: typeof DomainModels.HeroModel;
|
|
5
|
+
export const ButtonModel: typeof DomainModels.ButtonModel;
|
|
6
|
+
export const ConfirmModel: typeof DomainModels.ConfirmModel;
|
|
7
|
+
export const InputModel: typeof DomainModels.InputModel;
|
|
8
|
+
export const SpinnerModel: typeof DomainModels.SpinnerModel;
|
|
9
|
+
export const TableModel: typeof DomainModels.TableModel;
|
|
10
|
+
export const ToastModel: typeof DomainModels.ToastModel;
|
|
11
|
+
export const SelectModel: typeof DomainModels.SelectModel;
|
|
12
|
+
export const AutocompleteModel: typeof DomainModels.AutocompleteModel;
|
|
13
|
+
export const TreeModel: typeof DomainModels.TreeModel;
|
|
14
|
+
export const TabsModel: typeof DomainModels.TabsModel;
|
|
15
|
+
export const AccordionModel: typeof DomainModels.AccordionModel;
|
|
16
|
+
export const GalleryModel: typeof DomainModels.GalleryModel;
|
|
17
|
+
export const PriceModel: typeof DomainModels.PriceModel;
|
|
18
|
+
export const PricingModel: typeof DomainModels.PricingModel;
|
|
19
|
+
export const CommentModel: typeof DomainModels.CommentModel;
|
|
20
|
+
export const TestimonialModel: typeof DomainModels.TestimonialModel;
|
|
21
|
+
export const StatsItemModel: typeof DomainModels.StatsItemModel;
|
|
22
|
+
export const StatsModel: typeof DomainModels.StatsModel;
|
|
23
|
+
export const TimelineItemModel: typeof DomainModels.TimelineItemModel;
|
|
24
|
+
export const TimelineModel: typeof DomainModels.TimelineModel;
|
|
25
|
+
export default Model;
|
|
6
26
|
import User from './User/User.js';
|
|
27
|
+
import * as DomainModels from '../domain/index.js';
|
|
28
|
+
declare const Model: {
|
|
29
|
+
SandboxModel: typeof DomainModels.SandboxModel;
|
|
30
|
+
ShowcaseAppModel: typeof DomainModels.ShowcaseAppModel;
|
|
31
|
+
Navigation: typeof DomainModels.Navigation;
|
|
32
|
+
Language: any;
|
|
33
|
+
HeaderModel: typeof DomainModels.HeaderModel;
|
|
34
|
+
FooterModel: typeof DomainModels.FooterModel;
|
|
35
|
+
HeroModel: typeof DomainModels.HeroModel;
|
|
36
|
+
ButtonModel: typeof DomainModels.ButtonModel;
|
|
37
|
+
ConfirmModel: typeof DomainModels.ConfirmModel;
|
|
38
|
+
InputModel: typeof DomainModels.InputModel;
|
|
39
|
+
SpinnerModel: typeof DomainModels.SpinnerModel;
|
|
40
|
+
TableModel: typeof DomainModels.TableModel;
|
|
41
|
+
ToastModel: typeof DomainModels.ToastModel;
|
|
42
|
+
SelectModel: typeof DomainModels.SelectModel;
|
|
43
|
+
AutocompleteModel: typeof DomainModels.AutocompleteModel;
|
|
44
|
+
TreeModel: typeof DomainModels.TreeModel;
|
|
45
|
+
TabsModel: typeof DomainModels.TabsModel;
|
|
46
|
+
AccordionModel: typeof DomainModels.AccordionModel;
|
|
47
|
+
GalleryModel: typeof DomainModels.GalleryModel;
|
|
48
|
+
PriceModel: typeof DomainModels.PriceModel;
|
|
49
|
+
PricingModel: typeof DomainModels.PricingModel;
|
|
50
|
+
CommentModel: typeof DomainModels.CommentModel;
|
|
51
|
+
TestimonialModel: typeof DomainModels.TestimonialModel;
|
|
52
|
+
StatsItemModel: typeof DomainModels.StatsItemModel;
|
|
53
|
+
StatsModel: typeof DomainModels.StatsModel;
|
|
54
|
+
TimelineItemModel: typeof DomainModels.TimelineItemModel;
|
|
55
|
+
TimelineModel: typeof DomainModels.TimelineModel;
|
|
56
|
+
HeaderVisibilityModel: typeof DomainModels.HeaderVisibilityModel;
|
|
57
|
+
HeaderConfigModel: typeof DomainModels.HeaderConfigModel;
|
|
58
|
+
FooterVisibilityModel: typeof DomainModels.FooterVisibilityModel;
|
|
59
|
+
FooterConfigModel: typeof DomainModels.FooterConfigModel;
|
|
60
|
+
EmptyStateModel: typeof DomainModels.EmptyStateModel;
|
|
61
|
+
BannerModel: typeof DomainModels.BannerModel;
|
|
62
|
+
ProfileDropdownModel: typeof DomainModels.ProfileDropdownModel;
|
|
63
|
+
User: typeof User;
|
|
64
|
+
};
|
|
@@ -89,9 +89,9 @@ export default class UIForm extends FormMessage {
|
|
|
89
89
|
/**
|
|
90
90
|
* Validates the entire form.
|
|
91
91
|
*
|
|
92
|
-
* @returns {
|
|
92
|
+
* @returns {any}
|
|
93
93
|
*/
|
|
94
|
-
validate():
|
|
94
|
+
validate(): any;
|
|
95
95
|
/**
|
|
96
96
|
* Validates a single field.
|
|
97
97
|
*
|
|
@@ -32,6 +32,10 @@ export type AdapterHandlers = {
|
|
|
32
32
|
* Handler for 'log' intents. Optional (defaults to no-op).
|
|
33
33
|
*/
|
|
34
34
|
log?: ((intent: import("./Intent.js").LogIntent) => void | Promise<void>) | undefined;
|
|
35
|
+
/**
|
|
36
|
+
* Handler for 'render' intents (visual component injection). Optional.
|
|
37
|
+
*/
|
|
38
|
+
render?: ((intent: import("./Intent.js").RenderIntent) => void | Promise<void>) | undefined;
|
|
35
39
|
/**
|
|
36
40
|
* Handler for the final 'result'. Optional (defaults to no-op).
|
|
37
41
|
*/
|
package/types/core/Intent.d.ts
CHANGED
|
@@ -53,9 +53,17 @@ export function validateIntent(intent: any): intent is Intent;
|
|
|
53
53
|
* @property {'result'} type
|
|
54
54
|
* @property {*} data - The raw result data (JSON-serializable).
|
|
55
55
|
*/
|
|
56
|
+
/**
|
|
57
|
+
* Model requests rendering of a pure UI component (Header, Footer, Static Map).
|
|
58
|
+
* No response expected from the logic loop.
|
|
59
|
+
* @typedef {Object} RenderIntent
|
|
60
|
+
* @property {'render'} type
|
|
61
|
+
* @property {string} component - Component name (e.g. 'App.Layout.Header').
|
|
62
|
+
* @property {object} props - Static props for the component.
|
|
63
|
+
*/
|
|
56
64
|
/**
|
|
57
65
|
* Union of all possible yielded intents.
|
|
58
|
-
* @typedef {AskIntent | ProgressIntent | LogIntent} Intent
|
|
66
|
+
* @typedef {AskIntent | ProgressIntent | LogIntent | RenderIntent} Intent
|
|
59
67
|
*/
|
|
60
68
|
/**
|
|
61
69
|
* Response to an AskIntent. Adapter provides the collected value.
|
|
@@ -82,7 +90,7 @@ export function validateIntent(intent: any): intent is Intent;
|
|
|
82
90
|
* Union of all possible responses an Adapter can send back via iterator.next().
|
|
83
91
|
* @typedef {AskResponse | AbortResponse | undefined} IntentResponse
|
|
84
92
|
*/
|
|
85
|
-
export const INTENT_TYPES: readonly ["ask", "progress", "log"];
|
|
93
|
+
export const INTENT_TYPES: readonly ["ask", "progress", "log", "render"];
|
|
86
94
|
export function ask(field: string, schema: object | Function): AskIntent;
|
|
87
95
|
export function progress(message: any): {
|
|
88
96
|
type: string;
|
|
@@ -93,6 +101,11 @@ export function log(level: any, message: any, data?: {}): {
|
|
|
93
101
|
level: any;
|
|
94
102
|
message: any;
|
|
95
103
|
};
|
|
104
|
+
export function render(component: any, props?: {}): {
|
|
105
|
+
type: string;
|
|
106
|
+
component: any;
|
|
107
|
+
props: {};
|
|
108
|
+
};
|
|
96
109
|
export function result(data: any): {
|
|
97
110
|
type: string;
|
|
98
111
|
data: any;
|
|
@@ -186,10 +199,25 @@ export type ResultIntent = {
|
|
|
186
199
|
*/
|
|
187
200
|
data: any;
|
|
188
201
|
};
|
|
202
|
+
/**
|
|
203
|
+
* Model requests rendering of a pure UI component (Header, Footer, Static Map).
|
|
204
|
+
* No response expected from the logic loop.
|
|
205
|
+
*/
|
|
206
|
+
export type RenderIntent = {
|
|
207
|
+
type: "render";
|
|
208
|
+
/**
|
|
209
|
+
* - Component name (e.g. 'App.Layout.Header').
|
|
210
|
+
*/
|
|
211
|
+
component: string;
|
|
212
|
+
/**
|
|
213
|
+
* - Static props for the component.
|
|
214
|
+
*/
|
|
215
|
+
props: object;
|
|
216
|
+
};
|
|
189
217
|
/**
|
|
190
218
|
* Union of all possible yielded intents.
|
|
191
219
|
*/
|
|
192
|
-
export type Intent = AskIntent | ProgressIntent | LogIntent;
|
|
220
|
+
export type Intent = AskIntent | ProgressIntent | LogIntent | RenderIntent;
|
|
193
221
|
/**
|
|
194
222
|
* Response to an AskIntent. Adapter provides the collected value.
|
|
195
223
|
* The value MUST conform to the type described in the requested FieldSchema.
|
package/types/core/index.d.ts
CHANGED
|
@@ -13,4 +13,4 @@ import UIForm from './Form/Form.js';
|
|
|
13
13
|
export { UIStream, UIStream as UiStream, StreamEntry, StreamEntry as UiStreamEntry, UIForm, UIForm as UiForm };
|
|
14
14
|
export { default as Error, CancelError } from "./Error/index.js";
|
|
15
15
|
export { runFlow, flow, View, Prompt, Stream, Alert, Toast, Badge, Text, Table, Input, Select, Confirm, Multiselect, Mask, Password, Spinner, Progress, default as Flow } from "./Flow.js";
|
|
16
|
-
export { validateIntent, ask, progress, log, result, INTENT_TYPES, isModelSchema } from "./Intent.js";
|
|
16
|
+
export { validateIntent, ask, progress, log, render, result, INTENT_TYPES, isModelSchema } from "./Intent.js";
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LogicInspector
|
|
3
|
+
*
|
|
4
|
+
* Базовий клас для захоплення "Логічних зліпків" (Intent Stream) будь-яких моделей OLMUI.
|
|
5
|
+
* Дозволяє виконувати чисто-логічне тестування без прив'язки до рендерингу.
|
|
6
|
+
*/
|
|
7
|
+
export class LogicInspector {
|
|
8
|
+
/**
|
|
9
|
+
* Виконує генератор моделі та записує послідовність усіх інтенцій.
|
|
10
|
+
* @param {AsyncGenerator<import('../core/Intent.js').Intent, import('../core/Intent.js').ResultIntent, import('../core/Intent.js').IntentResponse>} modelStream - результат виклику model.run()
|
|
11
|
+
* @param {object} options
|
|
12
|
+
* @param {Array<any> | ((locale: string) => Array<any>)} [options.inputs] - черга вхідних значень для askIntent
|
|
13
|
+
* @param {string} [options.locale] - локаль для тестів
|
|
14
|
+
* @param {function} [options.t] - функція перекладу
|
|
15
|
+
* @returns {Promise<Array<any>>} Intent Stream Log
|
|
16
|
+
*/
|
|
17
|
+
static capture(modelStream: AsyncGenerator<import("../core/Intent.js").Intent, import("../core/Intent.js").ResultIntent, import("../core/Intent.js").IntentResponse>, { inputs, locale, t }?: {
|
|
18
|
+
inputs?: any[] | ((locale: string) => Array<any>) | undefined;
|
|
19
|
+
locale?: string | undefined;
|
|
20
|
+
t?: Function | undefined;
|
|
21
|
+
}): Promise<Array<any>>;
|
|
22
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SnapshotInspector
|
|
3
|
+
*
|
|
4
|
+
* Рев'ювер для автоматичної перевірки Snapshot-зліпків на наявність артефактів,
|
|
5
|
+
* неперекладених ключів та структурних помилок.
|
|
6
|
+
* Реалізує правила "Zero-Hallucination Snapshot Validation".
|
|
7
|
+
*/
|
|
8
|
+
export class SnapshotInspector {
|
|
9
|
+
/**
|
|
10
|
+
* Перевіряє вміст одного снепшоту.
|
|
11
|
+
* @param {string} content - Текстовий вміст .txt файлу галереї
|
|
12
|
+
* @param {string} locale - Локаль (uk, en)
|
|
13
|
+
* @param {string} [filename] - Ім'я файлу для перевірки на "глюки" (підкреслення)
|
|
14
|
+
* @returns {object} { score, errors }
|
|
15
|
+
*/
|
|
16
|
+
static inspect(content: string, locale?: string, filename?: string): object;
|
|
17
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* VisualAdapter (Base)
|
|
3
|
+
*
|
|
4
|
+
* Базовий клас для візуальної трансформації інтенцій OLMUI.
|
|
5
|
+
* Використовувана у @nan0web/ui як фундамент для спеціалізованих рендерерів.
|
|
6
|
+
*/
|
|
7
|
+
export class VisualAdapter {
|
|
8
|
+
/**
|
|
9
|
+
* Конвертує одну інтенцію у просте текстове представлення.
|
|
10
|
+
* @param {object} intent - Intent entry from LogicInspector
|
|
11
|
+
* @param {function} [t] - i18n translate function
|
|
12
|
+
* @returns {string} Raw description
|
|
13
|
+
*/
|
|
14
|
+
static render(intent: object, t?: Function): string;
|
|
15
|
+
}
|
package/src/README.md.js
DELETED
|
@@ -1,436 +0,0 @@
|
|
|
1
|
-
import { describe, it, before, beforeEach } from 'node:test'
|
|
2
|
-
import assert from 'node:assert/strict'
|
|
3
|
-
import FS from '@nan0web/db-fs'
|
|
4
|
-
import { NoConsole } from '@nan0web/log'
|
|
5
|
-
import { DatasetParser, DocsParser, runSpawn } from '@nan0web/test'
|
|
6
|
-
import { Frame, Model, OutputMessage, View, FormInput, UiMessage, UiForm } from './index.js'
|
|
7
|
-
import { Welcome } from './Component/index.js'
|
|
8
|
-
|
|
9
|
-
const fs = new FS()
|
|
10
|
-
let pkg
|
|
11
|
-
|
|
12
|
-
// Load package.json once before tests
|
|
13
|
-
before(async () => {
|
|
14
|
-
const doc = await fs.loadDocument('package.json', {})
|
|
15
|
-
pkg = doc || {}
|
|
16
|
-
})
|
|
17
|
-
|
|
18
|
-
let console = new NoConsole()
|
|
19
|
-
|
|
20
|
-
beforeEach((info) => {
|
|
21
|
-
console = new NoConsole()
|
|
22
|
-
})
|
|
23
|
-
|
|
24
|
-
// Core test suite that also serves as the source for README generation.
|
|
25
|
-
// The block comments inside each `it` block are extracted to build
|
|
26
|
-
// the final `README.md`. Keeping the comments here ensures the
|
|
27
|
-
// documentation stays close to the code.
|
|
28
|
-
function testRender() {
|
|
29
|
-
/**
|
|
30
|
-
* @docs
|
|
31
|
-
* # @nan0web/ui
|
|
32
|
-
*
|
|
33
|
-
* 🏴 [English](./README.md) | 🇺🇦 [Українською](./docs/uk/README.md)
|
|
34
|
-
*
|
|
35
|
-
* <!-- %PACKAGE_STATUS% -->
|
|
36
|
-
*
|
|
37
|
-
* A lightweight, agnostic UI framework designed with the **nan0web philosophy**
|
|
38
|
-
* — one application logic, many UI implementations.
|
|
39
|
-
*
|
|
40
|
-
* This library provides core classes and utilities for building structured user interfaces.
|
|
41
|
-
* It supports:
|
|
42
|
-
*
|
|
43
|
-
* - Messaging (Input/Output)
|
|
44
|
-
* - Forms with validation
|
|
45
|
-
* - Progress tracking
|
|
46
|
-
* - Component rendering
|
|
47
|
-
* - View management with Frame rendering
|
|
48
|
-
* - App structure with core and user apps
|
|
49
|
-
*
|
|
50
|
-
* Built to work in sync or async, terminal-based or web-based apps,
|
|
51
|
-
* focusing on type safety, minimalism, and pure JavaScript design.
|
|
52
|
-
*
|
|
53
|
-
* ## Installation
|
|
54
|
-
*/
|
|
55
|
-
it('How to install with npm?', () => {
|
|
56
|
-
/**
|
|
57
|
-
* ```bash
|
|
58
|
-
* npm install @nan0web/ui
|
|
59
|
-
* ```
|
|
60
|
-
*/
|
|
61
|
-
assert.equal(pkg.name, '@nan0web/ui')
|
|
62
|
-
})
|
|
63
|
-
/**
|
|
64
|
-
* @docs
|
|
65
|
-
*/
|
|
66
|
-
it('How to install with pnpm?', () => {
|
|
67
|
-
/**
|
|
68
|
-
* ```bash
|
|
69
|
-
* pnpm add @nan0web/ui
|
|
70
|
-
* ```
|
|
71
|
-
*/
|
|
72
|
-
assert.equal(pkg.name, '@nan0web/ui')
|
|
73
|
-
})
|
|
74
|
-
/**
|
|
75
|
-
* @docs
|
|
76
|
-
*/
|
|
77
|
-
it('How to install with yarn?', () => {
|
|
78
|
-
/**
|
|
79
|
-
* ```bash
|
|
80
|
-
* yarn add @nan0web/ui
|
|
81
|
-
* ```
|
|
82
|
-
*/
|
|
83
|
-
assert.equal(pkg.name, '@nan0web/ui')
|
|
84
|
-
})
|
|
85
|
-
|
|
86
|
-
/**
|
|
87
|
-
* @docs
|
|
88
|
-
* ## Concepts & Architecture
|
|
89
|
-
*
|
|
90
|
-
* ### Message Flow
|
|
91
|
-
*
|
|
92
|
-
* UI communication is built around messages:
|
|
93
|
-
*
|
|
94
|
-
* - **`UiMessage`** – abstract message base class
|
|
95
|
-
* - **`OutputMessage`** – system output (content, error, priority)
|
|
96
|
-
*
|
|
97
|
-
* Messages are simple, serializable data containers. They help build
|
|
98
|
-
* decoupled communication systems between UI components.
|
|
99
|
-
*/
|
|
100
|
-
it('How to create input and output messages?', () => {
|
|
101
|
-
//import { InputMessage, OutputMessage } from '@nan0web/ui'
|
|
102
|
-
|
|
103
|
-
const input = UiMessage.from({ body: 'Hello User' })
|
|
104
|
-
const output = OutputMessage.from({ content: ['Welcome to @nan0web/ui'] })
|
|
105
|
-
console.info(input) // ← Message { body: "Hello User", head: {}, id: "....", type: "" }
|
|
106
|
-
console.info(String(output)) // ← Welcome to @nan0web/ui
|
|
107
|
-
assert.deepStrictEqual(console.output()[0][1].body, 'Hello User')
|
|
108
|
-
assert.deepStrictEqual(console.output()[0][1].head, {})
|
|
109
|
-
assert.deepStrictEqual(console.output()[0][1].type, '')
|
|
110
|
-
assert.ok(console.output()[0][1].id)
|
|
111
|
-
assert.ok(console.output()[1][1].endsWith('Welcome to @nan0web/ui'))
|
|
112
|
-
})
|
|
113
|
-
|
|
114
|
-
/**
|
|
115
|
-
* @docs
|
|
116
|
-
* ### Forms
|
|
117
|
-
*
|
|
118
|
-
* `UiForm` supports field definitions, data management, and schema validation.
|
|
119
|
-
* Every form includes a title, fields, and current state.
|
|
120
|
-
*
|
|
121
|
-
* Field types include:
|
|
122
|
-
*
|
|
123
|
-
* - `text`
|
|
124
|
-
* - `email`
|
|
125
|
-
* - `number`
|
|
126
|
-
* - `select`
|
|
127
|
-
* - `checkbox`
|
|
128
|
-
* - `textarea`
|
|
129
|
-
*/
|
|
130
|
-
it('How to define and validate a UiForm?', () => {
|
|
131
|
-
//import { UiForm } from '@nan0web/ui'
|
|
132
|
-
|
|
133
|
-
const form = new UiForm({
|
|
134
|
-
title: 'Contact Form',
|
|
135
|
-
fields: [
|
|
136
|
-
FormInput.from({ name: 'email', label: 'Email Address', type: 'email', required: true }),
|
|
137
|
-
FormInput.from({
|
|
138
|
-
name: 'message',
|
|
139
|
-
label: 'Your Message',
|
|
140
|
-
type: 'textarea',
|
|
141
|
-
required: true,
|
|
142
|
-
}),
|
|
143
|
-
],
|
|
144
|
-
state: {
|
|
145
|
-
email: 'invalid-email',
|
|
146
|
-
message: 'Hello!',
|
|
147
|
-
},
|
|
148
|
-
})
|
|
149
|
-
|
|
150
|
-
const errors = form.validate()
|
|
151
|
-
console.info(errors.size) // ← 1
|
|
152
|
-
console.info(errors.get('email')) // ← Invalid email format
|
|
153
|
-
|
|
154
|
-
assert.equal(console.output()[0][1], 1)
|
|
155
|
-
assert.equal(console.output()[1][1], 'Invalid email format')
|
|
156
|
-
})
|
|
157
|
-
|
|
158
|
-
/**
|
|
159
|
-
* @docs
|
|
160
|
-
* ### Components
|
|
161
|
-
*
|
|
162
|
-
* Components render data as frame-ready output.
|
|
163
|
-
*
|
|
164
|
-
* - `Welcome` – greets user by name
|
|
165
|
-
* - `Process` – shows progress bar and time
|
|
166
|
-
*/
|
|
167
|
-
it('How to render the Welcome component?', () => {
|
|
168
|
-
//import { Welcome } from '@nan0web/ui'
|
|
169
|
-
|
|
170
|
-
const frame = Welcome({ user: { name: 'Alice' } })
|
|
171
|
-
const firstLine = frame[0].join('')
|
|
172
|
-
console.info(firstLine) // ← Welcome Alice!
|
|
173
|
-
assert.equal(console.output()[0][1], 'Welcome Alice!')
|
|
174
|
-
})
|
|
175
|
-
|
|
176
|
-
/**
|
|
177
|
-
* @docs
|
|
178
|
-
* ### View Manager
|
|
179
|
-
*
|
|
180
|
-
* `View` combines components and renders frames.
|
|
181
|
-
*
|
|
182
|
-
* Every view has:
|
|
183
|
-
*
|
|
184
|
-
* - Locale – formatted text, numbers, currency
|
|
185
|
-
* - StdIn / StdOut – input/output streams
|
|
186
|
-
* - Frame – output buffer with visual properties
|
|
187
|
-
*/
|
|
188
|
-
it('How to render frame with View?', () => {
|
|
189
|
-
//import { View } from '@nan0web/ui'
|
|
190
|
-
|
|
191
|
-
const view = new View()
|
|
192
|
-
view.render(1)(['Hello, world'])
|
|
193
|
-
console.info(String(view.frame)) // ← "\rHello, world"
|
|
194
|
-
assert.ok(String(view.frame).includes('Hello, world'))
|
|
195
|
-
})
|
|
196
|
-
|
|
197
|
-
/**
|
|
198
|
-
* @docs
|
|
199
|
-
* ### Frame Rendering
|
|
200
|
-
*
|
|
201
|
-
* `Frame` manages visual rendering with width and height limits.
|
|
202
|
-
* Useful for fixed-size terminals or UI blocks.
|
|
203
|
-
*
|
|
204
|
-
* Render methods:
|
|
205
|
-
*
|
|
206
|
-
* - `APPEND` – adds content after previous frame
|
|
207
|
-
* - `REPLACE` – erases and replaces full frame area
|
|
208
|
-
* - `VISIBLE` – renders only visible part of frame
|
|
209
|
-
*/
|
|
210
|
-
it('How to create a Frame with fixed size?', () => {
|
|
211
|
-
//import { Frame } from '@nan0web/ui'
|
|
212
|
-
|
|
213
|
-
const frame = new Frame({
|
|
214
|
-
value: [['Frame content']],
|
|
215
|
-
width: 20,
|
|
216
|
-
height: 5,
|
|
217
|
-
renderMethod: Frame.RenderMethod.APPEND,
|
|
218
|
-
})
|
|
219
|
-
|
|
220
|
-
const rendered = frame.render()
|
|
221
|
-
console.info(rendered.includes('Frame content')) // ← true
|
|
222
|
-
assert.ok(rendered.includes('Frame content'))
|
|
223
|
-
})
|
|
224
|
-
it('How to create a Frame with different render methods?', () => {
|
|
225
|
-
//import { Frame } from '@nan0web/ui'
|
|
226
|
-
|
|
227
|
-
const frame = new Frame({
|
|
228
|
-
value: [['Frame content']],
|
|
229
|
-
width: 20,
|
|
230
|
-
height: 5,
|
|
231
|
-
})
|
|
232
|
-
|
|
233
|
-
frame.renderMethod = Frame.RenderMethod.REPLACE
|
|
234
|
-
const renderedReplace = frame.render()
|
|
235
|
-
assert.ok(renderedReplace.includes('Frame content'))
|
|
236
|
-
|
|
237
|
-
frame.renderMethod = Frame.RenderMethod.VISIBLE
|
|
238
|
-
const renderedVisible = frame.render()
|
|
239
|
-
assert.ok(renderedVisible.includes('Frame content'))
|
|
240
|
-
})
|
|
241
|
-
|
|
242
|
-
/**
|
|
243
|
-
* @docs
|
|
244
|
-
* ### Models
|
|
245
|
-
*
|
|
246
|
-
* UI models are plain data objects managed by `Model` classes.
|
|
247
|
-
*
|
|
248
|
-
* - `User` – user data
|
|
249
|
-
*/
|
|
250
|
-
it('How to use a User model?', () => {
|
|
251
|
-
//import { Model } from '@nan0web/ui'
|
|
252
|
-
|
|
253
|
-
const user = new Model.User({ name: 'Charlie', email: 'charlie@example.com' })
|
|
254
|
-
console.info(user.name) // ← Charlie
|
|
255
|
-
console.info(user.email) // ← charlie@example.com
|
|
256
|
-
assert.equal(user.name, 'Charlie')
|
|
257
|
-
assert.equal(user.email, 'charlie@example.com')
|
|
258
|
-
})
|
|
259
|
-
|
|
260
|
-
/**
|
|
261
|
-
* @docs
|
|
262
|
-
* ### Testing UI
|
|
263
|
-
*
|
|
264
|
-
* Core unit-tested to ensure stability in different environments.
|
|
265
|
-
*
|
|
266
|
-
* All components, adapters, and models are designed to be testable
|
|
267
|
-
* with minimal setup.
|
|
268
|
-
*/
|
|
269
|
-
it('How to test UI components with assertions?', () => {
|
|
270
|
-
//import { Welcome } from '@nan0web/ui'
|
|
271
|
-
|
|
272
|
-
const output = Welcome({ user: { name: 'Test' } })
|
|
273
|
-
console.info(output) // ← Welcome Test!
|
|
274
|
-
assert.deepStrictEqual(console.output()[0][1], [
|
|
275
|
-
['Welcome', ' ', 'Test', '!'],
|
|
276
|
-
['What can we do today great?'],
|
|
277
|
-
[''],
|
|
278
|
-
])
|
|
279
|
-
})
|
|
280
|
-
|
|
281
|
-
/**
|
|
282
|
-
* @docs
|
|
283
|
-
* ### Master IDE (Component Sandbox)
|
|
284
|
-
*
|
|
285
|
-
* The Master IDE (OlmuiInspector) provides a unified environment for testing and documenting
|
|
286
|
-
* web components across platforms. It supports:
|
|
287
|
-
*
|
|
288
|
-
* - **NaN0 Spec** — a concise YAML-based shorthand for declaring component variations.
|
|
289
|
-
* - **OlmuiInspector** — unified UI for exploring component models and props.
|
|
290
|
-
* - **Live Preview** — real-time rendering of component states.
|
|
291
|
-
* - **i18n UI** — fully localized interface (UK/EN) for global developers.
|
|
292
|
-
* - **Theme Editor** — Bootstrap-like CSS variable system with live preview.
|
|
293
|
-
*
|
|
294
|
-
* It follows the **Olmui** core pattern: *One Logic — Many UI* (same manifest powers both CLI and Web).
|
|
295
|
-
*
|
|
296
|
-
* #### Theme Editor (CSS Variables)
|
|
297
|
-
*
|
|
298
|
-
* Professional-grade theming with live preview. Supports:
|
|
299
|
-
*
|
|
300
|
-
* - **Palette**: primary, secondary, success, warning, danger, info
|
|
301
|
-
* - **Geometry**: border-radius (sm/md/lg/pill/circle), spacing (sm/md/lg)
|
|
302
|
-
* - **Type-safe inputs**: `type="color"` for colors, number inputs for dimensions
|
|
303
|
-
*
|
|
304
|
-
* #### Component Rendering Architecture
|
|
305
|
-
*
|
|
306
|
-
* The IDE handles data transformation between YAML models and web components:
|
|
307
|
-
*
|
|
308
|
-
* - **Table**: `rows[][] + columns[]` → `data[]` (array of objects)
|
|
309
|
-
* - **Tree**: `data` → `items` mapping with 4-level taxonomy
|
|
310
|
-
* - **Markdown**: Raw markdown → HTML via `_md2html()` converter
|
|
311
|
-
* - **ProgressBar**: Tag alias (`ui-progress-bar` → `ui-progress`), variant colors
|
|
312
|
-
* - **LangSelect**: `string[]` → `{code,title}[]` conversion
|
|
313
|
-
* - **Hyphenated props**: Auto `camelCase` conversion (`show-label` → `showLabel`)
|
|
314
|
-
*
|
|
315
|
-
* #### NaN0 Spec (YAML)
|
|
316
|
-
*
|
|
317
|
-
* Concise format for defining variations:
|
|
318
|
-
*/
|
|
319
|
-
it('How to define a component variation using NaN0 Spec?', () => {
|
|
320
|
-
/**
|
|
321
|
-
* ```yaml
|
|
322
|
-
* - Button: Primary
|
|
323
|
-
* $variant: brand
|
|
324
|
-
* $outline: true
|
|
325
|
-
* ```
|
|
326
|
-
*/
|
|
327
|
-
assert.ok(pkg.name === '@nan0web/ui')
|
|
328
|
-
})
|
|
329
|
-
|
|
330
|
-
/**
|
|
331
|
-
* @docs
|
|
332
|
-
* #### Documentation Site
|
|
333
|
-
*
|
|
334
|
-
* The IDE includes an auto-generated documentation site.
|
|
335
|
-
* HTML pages are generated from `ide.html` template via `generate-pages.js`:
|
|
336
|
-
*
|
|
337
|
-
* - Per-language pages (`/uk/Data/Table.html`, `/en/Feedback/Alert.html`)
|
|
338
|
-
* - SEO-optimized with `<title>` and `<meta>` per component
|
|
339
|
-
* - Category-based URL routing (`/Data/`, `/Feedback/`, `/Forms/`, `/Actions/`, `/System/`)
|
|
340
|
-
* - i18n navbar with `data-i18n` attributes
|
|
341
|
-
*/
|
|
342
|
-
it('How to run the documentation site?', () => {
|
|
343
|
-
/**
|
|
344
|
-
* ```bash
|
|
345
|
-
* npm run docs:dev
|
|
346
|
-
* ```
|
|
347
|
-
*/
|
|
348
|
-
assert.ok(pkg.scripts?.['docs:dev'])
|
|
349
|
-
})
|
|
350
|
-
|
|
351
|
-
/**
|
|
352
|
-
* @docs
|
|
353
|
-
* ## Playground Demos
|
|
354
|
-
*
|
|
355
|
-
* The library includes rich playground demos:
|
|
356
|
-
*
|
|
357
|
-
* - [Registration Form](./play/registration.form.js)
|
|
358
|
-
* - [Currency Exchange](./play/currency.exchange.js)
|
|
359
|
-
* - [Mobile Top-up](./play/topup.telephone.js)
|
|
360
|
-
* - [Language Selector](./play/language.form.js)
|
|
361
|
-
*
|
|
362
|
-
* Run to explore live functionality:
|
|
363
|
-
*/
|
|
364
|
-
it('How to run the playground?', async () => {
|
|
365
|
-
/**
|
|
366
|
-
* ```bash
|
|
367
|
-
* # Clone repository and run playground
|
|
368
|
-
* git clone https://github.com/nan0web/ui.git
|
|
369
|
-
* cd ui
|
|
370
|
-
* npm install
|
|
371
|
-
* npm run play
|
|
372
|
-
* ```
|
|
373
|
-
*/
|
|
374
|
-
assert.ok(String(pkg.scripts?.play).includes('node play'))
|
|
375
|
-
const response = await runSpawn('git', ['remote', 'get-url', 'origin'])
|
|
376
|
-
assert.ok(response.code === 0, 'git command fails (e.g., not in a git repo)')
|
|
377
|
-
assert.ok(response.text.trim().endsWith(':nan0web/ui.git'))
|
|
378
|
-
})
|
|
379
|
-
|
|
380
|
-
/**
|
|
381
|
-
* @docs
|
|
382
|
-
* ## API Documentation
|
|
383
|
-
*
|
|
384
|
-
* Detailed API docs are available in each class JSDoc.
|
|
385
|
-
* Explore:
|
|
386
|
-
*
|
|
387
|
-
* - [Messages](./src/core/Message/)
|
|
388
|
-
* - [Forms](./src/core/Form/)
|
|
389
|
-
* - [Stream](./src/core/Stream.js)
|
|
390
|
-
* - [Components](./src/Component/)
|
|
391
|
-
* - [View](./src/View/)
|
|
392
|
-
* - [App](./src/App/)
|
|
393
|
-
* - [Models](./src/Model/)
|
|
394
|
-
*
|
|
395
|
-
* ## Project Architecture & Specs
|
|
396
|
-
*
|
|
397
|
-
* How the universal block spec is designed? - [check Universal Blocks Spec (`project.md`)](./project.md)
|
|
398
|
-
*
|
|
399
|
-
* ## Contributing
|
|
400
|
-
*/
|
|
401
|
-
it('How to contribute? - [check here](./CONTRIBUTING.md)', async () => {
|
|
402
|
-
assert.equal(pkg.scripts?.precommit, 'npm test')
|
|
403
|
-
assert.equal(pkg.scripts?.prepush, 'npm test')
|
|
404
|
-
assert.equal(pkg.scripts?.prepare, 'husky')
|
|
405
|
-
const str = await fs.loadDocumentAs('.txt', 'CONTRIBUTING.md')
|
|
406
|
-
assert.ok(str.includes('# Contributing'))
|
|
407
|
-
})
|
|
408
|
-
|
|
409
|
-
/**
|
|
410
|
-
* @docs
|
|
411
|
-
* ## License
|
|
412
|
-
*/
|
|
413
|
-
it('How to license ISC? - [check here](./LICENSE)', async () => {
|
|
414
|
-
/** @docs */
|
|
415
|
-
const text = await fs.loadDocumentAs('.txt', 'LICENSE')
|
|
416
|
-
assert.ok(text.includes('ISC'))
|
|
417
|
-
})
|
|
418
|
-
}
|
|
419
|
-
|
|
420
|
-
describe('README.md testing', testRender)
|
|
421
|
-
|
|
422
|
-
describe('Rendering README.md', async () => {
|
|
423
|
-
let text = ''
|
|
424
|
-
const format = new Intl.NumberFormat('en-US').format
|
|
425
|
-
const parser = new DocsParser()
|
|
426
|
-
const source = await fs.loadDocument('src/README.md.js')
|
|
427
|
-
text = String(parser.decode(source))
|
|
428
|
-
await fs.saveDocument('README.md', { content: text })
|
|
429
|
-
const dataset = DatasetParser.parse(text, pkg?.name ?? '@nan0web/ui')
|
|
430
|
-
await fs.saveDocument('.datasets/README.dataset.jsonl', dataset)
|
|
431
|
-
|
|
432
|
-
it(`document is rendered in README.md [${format(Buffer.byteLength(text))}b]`, async () => {
|
|
433
|
-
const text = await fs.loadDocumentAs('.txt', 'README.md')
|
|
434
|
-
assert.ok(text.includes('## License'))
|
|
435
|
-
})
|
|
436
|
-
})
|