lutaml-jsonschema 0.1.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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +5 -0
- data/CODE_OF_CONDUCT.md +10 -0
- data/README.md +39 -0
- data/Rakefile +26 -0
- data/exe/lutaml-jsonschema +6 -0
- data/frontend/index.html +60 -0
- data/frontend/package-lock.json +2715 -0
- data/frontend/package.json +27 -0
- data/frontend/public/lutaml-logo-dark.svg +1 -0
- data/frontend/public/lutaml-logo-full-dark.svg +1 -0
- data/frontend/public/lutaml-logo-full-light.svg +1 -0
- data/frontend/public/lutaml-logo-light.svg +1 -0
- data/frontend/src/App.vue +80 -0
- data/frontend/src/__tests__/useBuilderField.test.ts +137 -0
- data/frontend/src/__tests__/useDefinitionResolver.test.ts +46 -0
- data/frontend/src/__tests__/useSchemaTypes.test.ts +219 -0
- data/frontend/src/app.ts +10 -0
- data/frontend/src/components/AppHeader.vue +152 -0
- data/frontend/src/components/AppSidebar.vue +427 -0
- data/frontend/src/components/DetailPanel.vue +403 -0
- data/frontend/src/components/SchemaBuilder.vue +543 -0
- data/frontend/src/components/SchemaStructure.vue +168 -0
- data/frontend/src/components/SearchModal.vue +275 -0
- data/frontend/src/composables/useBuilderField.ts +92 -0
- data/frontend/src/composables/useDefinitionResolver.ts +17 -0
- data/frontend/src/composables/useSchemaTypes.ts +152 -0
- data/frontend/src/composables/useSearch.ts +104 -0
- data/frontend/src/router.ts +14 -0
- data/frontend/src/stores/schemaStore.ts +118 -0
- data/frontend/src/stores/uiStore.ts +78 -0
- data/frontend/src/style.css +194 -0
- data/frontend/src/types.ts +70 -0
- data/frontend/src/views/HomeView.vue +396 -0
- data/frontend/tsconfig.json +20 -0
- data/frontend/vite.config.ts +28 -0
- data/lib/lutaml/jsonschema/base.rb +11 -0
- data/lib/lutaml/jsonschema/cli.rb +102 -0
- data/lib/lutaml/jsonschema/combiner.rb +54 -0
- data/lib/lutaml/jsonschema/configuration.rb +47 -0
- data/lib/lutaml/jsonschema/link.rb +25 -0
- data/lib/lutaml/jsonschema/property_entry.rb +15 -0
- data/lib/lutaml/jsonschema/reference_resolver.rb +74 -0
- data/lib/lutaml/jsonschema/schema.rb +205 -0
- data/lib/lutaml/jsonschema/schema_set.rb +217 -0
- data/lib/lutaml/jsonschema/spa/generator.rb +22 -0
- data/lib/lutaml/jsonschema/spa/metadata.rb +23 -0
- data/lib/lutaml/jsonschema/spa/output_strategy.rb +17 -0
- data/lib/lutaml/jsonschema/spa/spa_builder.rb +178 -0
- data/lib/lutaml/jsonschema/spa/spa_definition.rb +27 -0
- data/lib/lutaml/jsonschema/spa/spa_document.rb +23 -0
- data/lib/lutaml/jsonschema/spa/spa_property.rb +47 -0
- data/lib/lutaml/jsonschema/spa/spa_schema.rb +29 -0
- data/lib/lutaml/jsonschema/spa/spa_search_entry.rb +21 -0
- data/lib/lutaml/jsonschema/spa/vue_inlined_strategy.rb +53 -0
- data/lib/lutaml/jsonschema/version.rb +7 -0
- data/lib/lutaml/jsonschema.rb +29 -0
- data/sig/lutaml/jsonschema.rbs +6 -0
- metadata +163 -0
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div class="search-modal-overlay" @click.self="closeSearch">
|
|
3
|
+
<div class="search-modal">
|
|
4
|
+
<div class="search-input-wrapper">
|
|
5
|
+
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" class="search-icon">
|
|
6
|
+
<circle cx="8" cy="8" r="5.5" stroke="currentColor" stroke-width="1.5"/>
|
|
7
|
+
<path d="M12 12l4 4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
|
8
|
+
</svg>
|
|
9
|
+
<input
|
|
10
|
+
ref="inputRef"
|
|
11
|
+
v-model="search.query.value"
|
|
12
|
+
type="text"
|
|
13
|
+
class="search-input"
|
|
14
|
+
placeholder="Search schemas, properties, definitions..."
|
|
15
|
+
@keydown="handleKeydown"
|
|
16
|
+
/>
|
|
17
|
+
<button v-if="search.query.value" class="clear-btn" @click="search.query.value = ''">
|
|
18
|
+
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
|
|
19
|
+
<path d="M4 4l6 6M10 4l-6 6" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
|
20
|
+
</svg>
|
|
21
|
+
</button>
|
|
22
|
+
</div>
|
|
23
|
+
|
|
24
|
+
<div class="search-results" v-if="search.query.value">
|
|
25
|
+
<div v-if="search.isSearching.value" class="search-loading">
|
|
26
|
+
Searching...
|
|
27
|
+
</div>
|
|
28
|
+
<div v-else-if="!search.hasResults.value" class="search-empty">
|
|
29
|
+
No results found for "{{ search.query.value }}"
|
|
30
|
+
</div>
|
|
31
|
+
<div v-else class="search-results-list">
|
|
32
|
+
<div
|
|
33
|
+
v-for="(result, index) in search.results.value"
|
|
34
|
+
:key="result.id"
|
|
35
|
+
class="search-result"
|
|
36
|
+
:class="{ focused: focusedIndex === index }"
|
|
37
|
+
@click="search.selectResult(result)"
|
|
38
|
+
@mouseenter="focusedIndex = index"
|
|
39
|
+
>
|
|
40
|
+
<span class="result-icon">
|
|
41
|
+
<svg v-if="result.type === 'schema'" width="16" height="16" viewBox="0 0 16 16" fill="none">
|
|
42
|
+
<rect x="2" y="2" width="12" height="12" rx="2" stroke="currentColor" stroke-width="1.3"/>
|
|
43
|
+
<path d="M5 6h6M5 8h4M5 10h5" stroke="currentColor" stroke-width="1" stroke-linecap="round"/>
|
|
44
|
+
</svg>
|
|
45
|
+
<svg v-else-if="result.type === 'definition'" width="16" height="16" viewBox="0 0 16 16" fill="none">
|
|
46
|
+
<rect x="2" y="2" width="12" height="12" rx="2" stroke="currentColor" stroke-width="1.3" stroke-dasharray="3 2"/>
|
|
47
|
+
</svg>
|
|
48
|
+
<svg v-else width="16" height="16" viewBox="0 0 16 16" fill="none">
|
|
49
|
+
<circle cx="8" cy="8" r="5" stroke="currentColor" stroke-width="1.3"/>
|
|
50
|
+
</svg>
|
|
51
|
+
</span>
|
|
52
|
+
<div class="result-content">
|
|
53
|
+
<span class="result-name" v-html="highlightMatch(result.name)"></span>
|
|
54
|
+
<span class="result-schema">{{ result.schemaName }}</span>
|
|
55
|
+
</div>
|
|
56
|
+
<span :class="['badge', `badge-${result.type}`]">{{ result.type }}</span>
|
|
57
|
+
</div>
|
|
58
|
+
</div>
|
|
59
|
+
</div>
|
|
60
|
+
|
|
61
|
+
<div class="search-footer">
|
|
62
|
+
<div class="search-hints">
|
|
63
|
+
<span><kbd>↑</kbd><kbd>↓</kbd> Navigate</span>
|
|
64
|
+
<span><kbd>↵</kbd> Select</span>
|
|
65
|
+
<span><kbd>Esc</kbd> Close</span>
|
|
66
|
+
</div>
|
|
67
|
+
</div>
|
|
68
|
+
</div>
|
|
69
|
+
</div>
|
|
70
|
+
</template>
|
|
71
|
+
|
|
72
|
+
<script setup lang="ts">
|
|
73
|
+
import { ref, onMounted, nextTick } from 'vue'
|
|
74
|
+
import { useSearch } from '../composables/useSearch'
|
|
75
|
+
|
|
76
|
+
const search = useSearch()
|
|
77
|
+
const inputRef = ref<HTMLInputElement | null>(null)
|
|
78
|
+
const focusedIndex = ref(0)
|
|
79
|
+
|
|
80
|
+
onMounted(() => {
|
|
81
|
+
nextTick(() => inputRef.value?.focus())
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
function handleKeydown(e: KeyboardEvent) {
|
|
85
|
+
if (e.key === 'ArrowDown') {
|
|
86
|
+
e.preventDefault()
|
|
87
|
+
focusedIndex.value = Math.min(focusedIndex.value + 1, search.results.value.length - 1)
|
|
88
|
+
} else if (e.key === 'ArrowUp') {
|
|
89
|
+
e.preventDefault()
|
|
90
|
+
focusedIndex.value = Math.max(focusedIndex.value - 1, 0)
|
|
91
|
+
} else if (e.key === 'Enter' && search.results.value[focusedIndex.value]) {
|
|
92
|
+
e.preventDefault()
|
|
93
|
+
search.selectResult(search.results.value[focusedIndex.value])
|
|
94
|
+
} else if (e.key === 'Escape') {
|
|
95
|
+
search.closeSearch()
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function closeSearch() {
|
|
100
|
+
search.closeSearch()
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function highlightMatch(text: string): string {
|
|
104
|
+
const query = search.query.value
|
|
105
|
+
if (!query) return text
|
|
106
|
+
const regex = new RegExp(`(${query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})`, 'gi')
|
|
107
|
+
return text.replace(regex, '<mark>$1</mark>')
|
|
108
|
+
}
|
|
109
|
+
</script>
|
|
110
|
+
|
|
111
|
+
<style scoped>
|
|
112
|
+
.search-modal-overlay {
|
|
113
|
+
position: fixed;
|
|
114
|
+
inset: 0;
|
|
115
|
+
background: var(--bg-overlay);
|
|
116
|
+
display: flex;
|
|
117
|
+
align-items: flex-start;
|
|
118
|
+
justify-content: center;
|
|
119
|
+
padding-top: 10vh;
|
|
120
|
+
z-index: 1000;
|
|
121
|
+
animation: fadeIn var(--transition-fast);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
.search-modal {
|
|
125
|
+
width: 100%;
|
|
126
|
+
max-width: 560px;
|
|
127
|
+
background: var(--bg-elevated);
|
|
128
|
+
border-radius: var(--radius-xl);
|
|
129
|
+
box-shadow: var(--shadow-lg);
|
|
130
|
+
overflow: hidden;
|
|
131
|
+
animation: slideIn var(--transition-slow);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
.search-input-wrapper {
|
|
135
|
+
display: flex;
|
|
136
|
+
align-items: center;
|
|
137
|
+
gap: var(--space-3);
|
|
138
|
+
padding: var(--space-4);
|
|
139
|
+
border-bottom: 1px solid var(--border-light);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
.search-icon {
|
|
143
|
+
color: var(--text-muted);
|
|
144
|
+
flex-shrink: 0;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
.search-input {
|
|
148
|
+
flex: 1;
|
|
149
|
+
border: none;
|
|
150
|
+
background: none;
|
|
151
|
+
font-size: var(--text-base);
|
|
152
|
+
color: var(--text-primary);
|
|
153
|
+
outline: none;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
.search-input::placeholder {
|
|
157
|
+
color: var(--text-muted);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
.clear-btn {
|
|
161
|
+
padding: var(--space-1);
|
|
162
|
+
color: var(--text-muted);
|
|
163
|
+
border-radius: var(--radius-sm);
|
|
164
|
+
transition: all var(--transition-fast);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
.clear-btn:hover {
|
|
168
|
+
background: var(--bg-hover);
|
|
169
|
+
color: var(--text-primary);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
.search-results {
|
|
173
|
+
max-height: 400px;
|
|
174
|
+
overflow-y: auto;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
.search-loading,
|
|
178
|
+
.search-empty {
|
|
179
|
+
padding: var(--space-8);
|
|
180
|
+
text-align: center;
|
|
181
|
+
color: var(--text-muted);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
.search-results-list {
|
|
185
|
+
padding: var(--space-2);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
.search-result {
|
|
189
|
+
display: flex;
|
|
190
|
+
align-items: center;
|
|
191
|
+
gap: var(--space-3);
|
|
192
|
+
padding: var(--space-2) var(--space-3);
|
|
193
|
+
border-radius: var(--radius-md);
|
|
194
|
+
cursor: pointer;
|
|
195
|
+
transition: background var(--transition-fast);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
.search-result:hover,
|
|
199
|
+
.search-result.focused {
|
|
200
|
+
background: var(--bg-hover);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
.result-icon {
|
|
204
|
+
color: var(--text-muted);
|
|
205
|
+
display: flex;
|
|
206
|
+
align-items: center;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
.result-content {
|
|
210
|
+
flex: 1;
|
|
211
|
+
display: flex;
|
|
212
|
+
flex-direction: column;
|
|
213
|
+
gap: 2px;
|
|
214
|
+
min-width: 0;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
.result-name {
|
|
218
|
+
font-size: var(--text-sm);
|
|
219
|
+
font-weight: 500;
|
|
220
|
+
color: var(--text-primary);
|
|
221
|
+
white-space: nowrap;
|
|
222
|
+
overflow: hidden;
|
|
223
|
+
text-overflow: ellipsis;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
.result-name :deep(mark) {
|
|
227
|
+
background: var(--color-accent-alpha);
|
|
228
|
+
color: var(--color-accent);
|
|
229
|
+
padding: 0 2px;
|
|
230
|
+
border-radius: 2px;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
.result-schema {
|
|
234
|
+
font-size: var(--text-xs);
|
|
235
|
+
color: var(--text-muted);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
.badge-schema {
|
|
239
|
+
background: var(--badge-schema-bg);
|
|
240
|
+
color: var(--badge-schema);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
.badge-property {
|
|
244
|
+
background: var(--badge-property-bg);
|
|
245
|
+
color: var(--badge-property);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
.badge-definition {
|
|
249
|
+
background: var(--badge-definition-bg);
|
|
250
|
+
color: var(--badge-definition);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
.search-footer {
|
|
254
|
+
padding: var(--space-3) var(--space-4);
|
|
255
|
+
border-top: 1px solid var(--border-light);
|
|
256
|
+
background: var(--bg-secondary);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
.search-hints {
|
|
260
|
+
display: flex;
|
|
261
|
+
gap: var(--space-4);
|
|
262
|
+
font-size: var(--text-xs);
|
|
263
|
+
color: var(--text-muted);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
.search-hints kbd {
|
|
267
|
+
padding: 2px 5px;
|
|
268
|
+
font-family: var(--font-mono);
|
|
269
|
+
font-size: 10px;
|
|
270
|
+
background: var(--bg-elevated);
|
|
271
|
+
border: 1px solid var(--border-medium);
|
|
272
|
+
border-radius: var(--radius-sm);
|
|
273
|
+
margin-right: 2px;
|
|
274
|
+
}
|
|
275
|
+
</style>
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { resolveSchemaRef } from './useDefinitionResolver'
|
|
2
|
+
import {
|
|
3
|
+
primaryType,
|
|
4
|
+
initialValue,
|
|
5
|
+
arrayDefaultValue,
|
|
6
|
+
parsePropertyValue,
|
|
7
|
+
parseArrayItem,
|
|
8
|
+
} from './useSchemaTypes'
|
|
9
|
+
import type { SpaProperty, SpaSchema, SpaDefinition } from '../types'
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Typed field state for the SchemaBuilder.
|
|
13
|
+
* Each property in a schema becomes one BuilderField.
|
|
14
|
+
*/
|
|
15
|
+
export interface BuilderField {
|
|
16
|
+
prop: SpaProperty
|
|
17
|
+
included: boolean
|
|
18
|
+
isRequired: boolean
|
|
19
|
+
rawValue: string
|
|
20
|
+
expanded: boolean
|
|
21
|
+
resolvedDef: SpaDefinition | null
|
|
22
|
+
nestedJson: Record<string, unknown>
|
|
23
|
+
arrayItems: string[]
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Factory: create a BuilderField from a property definition.
|
|
28
|
+
* Resolves $ref, determines required status, initializes defaults.
|
|
29
|
+
*/
|
|
30
|
+
export function createField(
|
|
31
|
+
prop: SpaProperty,
|
|
32
|
+
requiredNames: string[],
|
|
33
|
+
schema: SpaSchema,
|
|
34
|
+
): BuilderField {
|
|
35
|
+
const isReq = requiredNames.includes(prop.name) || prop.required === true
|
|
36
|
+
const def = resolveSchemaRef(prop.ref, schema)
|
|
37
|
+
const isArray = primaryType(prop.type) === 'array'
|
|
38
|
+
|
|
39
|
+
return {
|
|
40
|
+
prop,
|
|
41
|
+
included: isReq,
|
|
42
|
+
isRequired: isReq,
|
|
43
|
+
rawValue: initialValue(prop),
|
|
44
|
+
expanded: false,
|
|
45
|
+
resolvedDef: def,
|
|
46
|
+
nestedJson: def ? buildDefaultJson(def.properties) : {},
|
|
47
|
+
arrayItems: isArray ? [arrayDefaultValue(prop.itemsType)] : [],
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Build a default JSON object from a list of properties.
|
|
53
|
+
* Uses initial values and type-based parsing.
|
|
54
|
+
*/
|
|
55
|
+
export function buildDefaultJson(properties: SpaProperty[]): Record<string, unknown> {
|
|
56
|
+
const obj: Record<string, unknown> = {}
|
|
57
|
+
for (const prop of properties) {
|
|
58
|
+
const t = primaryType(prop.type)
|
|
59
|
+
const isArray = t === 'array'
|
|
60
|
+
|
|
61
|
+
if (isArray) {
|
|
62
|
+
const items = [arrayDefaultValue(prop.itemsType)]
|
|
63
|
+
obj[prop.name] = items.map(item => parseArrayItem(item, prop.itemsType))
|
|
64
|
+
} else {
|
|
65
|
+
obj[prop.name] = parsePropertyValue(initialValue(prop), prop)
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return obj
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Check whether expanding this field would create a circular reference.
|
|
73
|
+
*/
|
|
74
|
+
export function isCircular(field: BuilderField, visited: ReadonlySet<string>): boolean {
|
|
75
|
+
return !!field.resolvedDef && visited.has(field.resolvedDef.name)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Parse a field's current state to its runtime JSON value.
|
|
80
|
+
* Handles nested objects (via nestedJson), arrays (via arrayItems),
|
|
81
|
+
* and primitive values (via parsePropertyValue).
|
|
82
|
+
*/
|
|
83
|
+
export function parseFieldValue(field: BuilderField): unknown {
|
|
84
|
+
const prop = field.prop
|
|
85
|
+
const t = primaryType(prop.type)
|
|
86
|
+
|
|
87
|
+
if (t === 'array') {
|
|
88
|
+
return field.arrayItems.map(item => parseArrayItem(item, prop.itemsType))
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return parsePropertyValue(field.rawValue, prop)
|
|
92
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { SpaSchema, SpaDefinition } from '../types'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Resolve a JSON Schema $ref string to its SpaDefinition.
|
|
5
|
+
* Handles: #/definitions/NAME and #/$defs/NAME patterns.
|
|
6
|
+
* Returns null if the ref is empty, malformed, or not found.
|
|
7
|
+
*/
|
|
8
|
+
export function resolveSchemaRef(ref: string | undefined, schema: SpaSchema): SpaDefinition | null {
|
|
9
|
+
if (!ref) return null
|
|
10
|
+
|
|
11
|
+
const match = ref.match(/^#\/(?:definitions|\$defs)\/([^/]+)$/)
|
|
12
|
+
if (match) {
|
|
13
|
+
return schema.definitions.find(d => d.name === match[1]) ?? null
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
return null
|
|
17
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import type { SpaProperty } from '../types'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Extract the primary JSON Schema type from a type string.
|
|
5
|
+
* Handles union types like "string,integer" by returning the first component.
|
|
6
|
+
*/
|
|
7
|
+
export function primaryType(type?: string): string {
|
|
8
|
+
const t = (type || '').split(',')[0].trim()
|
|
9
|
+
return t || 'any'
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Human-readable type display for the type badge.
|
|
14
|
+
* Shows array<> notation for arrays and format suffix for strings.
|
|
15
|
+
*/
|
|
16
|
+
export function displayType(prop: SpaProperty): string {
|
|
17
|
+
const t = primaryType(prop.type)
|
|
18
|
+
if (t === 'array' && prop.itemsType) return `array<${prop.itemsType}>`
|
|
19
|
+
if (prop.format) return `${t} (${prop.format})`
|
|
20
|
+
return t
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* HTML input type based on JSON Schema format keyword.
|
|
25
|
+
* Falls back to "text" for unrecognised formats.
|
|
26
|
+
*/
|
|
27
|
+
export function formatInputType(format?: string): string {
|
|
28
|
+
switch (format) {
|
|
29
|
+
case 'email': return 'email'
|
|
30
|
+
case 'uri':
|
|
31
|
+
case 'uri-reference': return 'url'
|
|
32
|
+
case 'date': return 'date'
|
|
33
|
+
case 'time': return 'time'
|
|
34
|
+
case 'date-time': return 'datetime-local'
|
|
35
|
+
default: return 'text'
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Default string value based on format when no example/default is available.
|
|
41
|
+
*/
|
|
42
|
+
export function formatDefault(format?: string): string {
|
|
43
|
+
switch (format) {
|
|
44
|
+
case 'uri':
|
|
45
|
+
case 'uri-reference': return 'https://example.com'
|
|
46
|
+
case 'date-time': return '2024-01-01T00:00:00Z'
|
|
47
|
+
case 'date': return '2024-01-01'
|
|
48
|
+
case 'time': return '00:00:00Z'
|
|
49
|
+
case 'email': return 'user@example.com'
|
|
50
|
+
case 'uuid': return '00000000-0000-0000-0000-000000000000'
|
|
51
|
+
default: return 'string'
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The first meaningful value for a property: examples[0], default, first enum,
|
|
57
|
+
* or a type-based fallback.
|
|
58
|
+
*/
|
|
59
|
+
export function initialValue(prop: SpaProperty): string {
|
|
60
|
+
if (prop.examples?.length) return prop.examples[0]
|
|
61
|
+
if (prop.default != null) return String(prop.default)
|
|
62
|
+
if (prop.enum?.length) return prop.enum[0]
|
|
63
|
+
|
|
64
|
+
const t = primaryType(prop.type)
|
|
65
|
+
switch (t) {
|
|
66
|
+
case 'integer': return '0'
|
|
67
|
+
case 'number': return '0.0'
|
|
68
|
+
case 'boolean': return 'false'
|
|
69
|
+
case 'string': return formatDefault(prop.format)
|
|
70
|
+
default: return ''
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* HTML input type for individual array item elements.
|
|
76
|
+
*/
|
|
77
|
+
export function arrayItemInputType(itemsType?: string): string {
|
|
78
|
+
switch (itemsType) {
|
|
79
|
+
case 'integer':
|
|
80
|
+
case 'number': return 'number'
|
|
81
|
+
case 'boolean': return 'checkbox'
|
|
82
|
+
default: return 'text'
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Default string value for a new array item.
|
|
88
|
+
*/
|
|
89
|
+
export function arrayDefaultValue(itemsType?: string): string {
|
|
90
|
+
switch (itemsType) {
|
|
91
|
+
case 'integer': return '0'
|
|
92
|
+
case 'number': return '0.0'
|
|
93
|
+
case 'boolean': return 'false'
|
|
94
|
+
default: return ''
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Parse a single array item string to its correct runtime type.
|
|
100
|
+
*/
|
|
101
|
+
export function parseArrayItem(item: string, itemsType?: string): unknown {
|
|
102
|
+
switch (itemsType) {
|
|
103
|
+
case 'integer': return parseInt(item, 10) || 0
|
|
104
|
+
case 'number': return parseFloat(item) || 0.0
|
|
105
|
+
case 'boolean': return item === 'true'
|
|
106
|
+
default: return item
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Check whether a property is an object (type=object or has a $ref without a type).
|
|
112
|
+
*/
|
|
113
|
+
export function isObjectProperty(prop: SpaProperty): boolean {
|
|
114
|
+
const t = primaryType(prop.type)
|
|
115
|
+
return t === 'object' || (!prop.type && !!prop.ref)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Check whether a property has constraints that should be displayed as chips.
|
|
120
|
+
*/
|
|
121
|
+
export function hasConstraints(prop: SpaProperty): boolean {
|
|
122
|
+
return !!(
|
|
123
|
+
(prop.enum?.length && isObjectProperty(prop)) ||
|
|
124
|
+
prop.pattern ||
|
|
125
|
+
prop.minimum != null ||
|
|
126
|
+
prop.maximum != null ||
|
|
127
|
+
prop.minLength != null ||
|
|
128
|
+
prop.maxLength != null ||
|
|
129
|
+
prop.default != null ||
|
|
130
|
+
prop.examples?.length
|
|
131
|
+
)
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Parse a property's raw string value to its correct runtime type.
|
|
136
|
+
* Handles enums, booleans, numbers, arrays, and objects.
|
|
137
|
+
*/
|
|
138
|
+
export function parsePropertyValue(rawValue: string, prop: SpaProperty): unknown {
|
|
139
|
+
const t = primaryType(prop.type)
|
|
140
|
+
if (prop.enum?.length) return rawValue
|
|
141
|
+
if (t === 'boolean') return rawValue === 'true'
|
|
142
|
+
if (t === 'integer') {
|
|
143
|
+
const n = parseInt(rawValue, 10)
|
|
144
|
+
return isNaN(n) ? 0 : n
|
|
145
|
+
}
|
|
146
|
+
if (t === 'number') {
|
|
147
|
+
const n = parseFloat(rawValue)
|
|
148
|
+
return isNaN(n) ? 0 : n
|
|
149
|
+
}
|
|
150
|
+
if (t === 'object' && !prop.ref) return {}
|
|
151
|
+
return rawValue
|
|
152
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { ref, computed, watch } from 'vue'
|
|
2
|
+
import { useSchemaStore } from '../stores/schemaStore'
|
|
3
|
+
import { useUiStore } from '../stores/uiStore'
|
|
4
|
+
|
|
5
|
+
interface SearchResult {
|
|
6
|
+
id: string
|
|
7
|
+
type: 'schema' | 'property' | 'definition'
|
|
8
|
+
name: string
|
|
9
|
+
schemaName: string
|
|
10
|
+
doc?: string
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function useSearch() {
|
|
14
|
+
const schemaStore = useSchemaStore()
|
|
15
|
+
const uiStore = useUiStore()
|
|
16
|
+
|
|
17
|
+
const query = ref('')
|
|
18
|
+
const results = ref<SearchResult[]>([])
|
|
19
|
+
const isSearching = ref(false)
|
|
20
|
+
let debounceTimer: ReturnType<typeof setTimeout> | null = null
|
|
21
|
+
|
|
22
|
+
function buildSearchEntries(): SearchResult[] {
|
|
23
|
+
const entries: SearchResult[] = []
|
|
24
|
+
for (const schema of schemaStore.schemas) {
|
|
25
|
+
entries.push({
|
|
26
|
+
id: `schema:${schema.name}`,
|
|
27
|
+
type: 'schema',
|
|
28
|
+
name: schema.title || schema.name,
|
|
29
|
+
schemaName: schema.name,
|
|
30
|
+
doc: schema.description,
|
|
31
|
+
})
|
|
32
|
+
for (const prop of schema.properties) {
|
|
33
|
+
entries.push({
|
|
34
|
+
id: `property:${schema.name}:${prop.name}`,
|
|
35
|
+
type: 'property',
|
|
36
|
+
name: prop.name,
|
|
37
|
+
schemaName: schema.name,
|
|
38
|
+
doc: prop.description,
|
|
39
|
+
})
|
|
40
|
+
}
|
|
41
|
+
for (const def of schema.definitions) {
|
|
42
|
+
entries.push({
|
|
43
|
+
id: `definition:${schema.name}:${def.name}`,
|
|
44
|
+
type: 'definition',
|
|
45
|
+
name: def.title || def.name,
|
|
46
|
+
schemaName: schema.name,
|
|
47
|
+
doc: def.description,
|
|
48
|
+
})
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return entries
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function search() {
|
|
55
|
+
if (!query.value.trim()) {
|
|
56
|
+
results.value = []
|
|
57
|
+
return
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
isSearching.value = true
|
|
61
|
+
const q = query.value.toLowerCase().trim()
|
|
62
|
+
const entries = buildSearchEntries()
|
|
63
|
+
|
|
64
|
+
results.value = entries.filter(e =>
|
|
65
|
+
e.name.toLowerCase().includes(q) ||
|
|
66
|
+
(e.doc && e.doc.toLowerCase().includes(q))
|
|
67
|
+
).slice(0, 50)
|
|
68
|
+
|
|
69
|
+
isSearching.value = false
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function debouncedSearch() {
|
|
73
|
+
if (debounceTimer) clearTimeout(debounceTimer)
|
|
74
|
+
debounceTimer = setTimeout(search, 150)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
watch(query, debouncedSearch)
|
|
78
|
+
|
|
79
|
+
const hasResults = computed(() => results.value.length > 0)
|
|
80
|
+
|
|
81
|
+
function selectResult(result: SearchResult) {
|
|
82
|
+
schemaStore.selectSchema(result.schemaName)
|
|
83
|
+
if (result.type === 'definition') {
|
|
84
|
+
schemaStore.selectDefinition(result.name)
|
|
85
|
+
}
|
|
86
|
+
uiStore.openDetailPanel()
|
|
87
|
+
closeSearch()
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function closeSearch() {
|
|
91
|
+
uiStore.closeSearch()
|
|
92
|
+
query.value = ''
|
|
93
|
+
results.value = []
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return {
|
|
97
|
+
query,
|
|
98
|
+
results,
|
|
99
|
+
isSearching,
|
|
100
|
+
hasResults,
|
|
101
|
+
selectResult,
|
|
102
|
+
closeSearch,
|
|
103
|
+
}
|
|
104
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { createRouter, createWebHashHistory } from 'vue-router'
|
|
2
|
+
|
|
3
|
+
const router = createRouter({
|
|
4
|
+
history: createWebHashHistory(),
|
|
5
|
+
routes: [
|
|
6
|
+
{
|
|
7
|
+
path: '/',
|
|
8
|
+
name: 'home',
|
|
9
|
+
component: () => import('./views/HomeView.vue'),
|
|
10
|
+
},
|
|
11
|
+
],
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
export default router
|