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.
Files changed (59) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +5 -0
  3. data/CODE_OF_CONDUCT.md +10 -0
  4. data/README.md +39 -0
  5. data/Rakefile +26 -0
  6. data/exe/lutaml-jsonschema +6 -0
  7. data/frontend/index.html +60 -0
  8. data/frontend/package-lock.json +2715 -0
  9. data/frontend/package.json +27 -0
  10. data/frontend/public/lutaml-logo-dark.svg +1 -0
  11. data/frontend/public/lutaml-logo-full-dark.svg +1 -0
  12. data/frontend/public/lutaml-logo-full-light.svg +1 -0
  13. data/frontend/public/lutaml-logo-light.svg +1 -0
  14. data/frontend/src/App.vue +80 -0
  15. data/frontend/src/__tests__/useBuilderField.test.ts +137 -0
  16. data/frontend/src/__tests__/useDefinitionResolver.test.ts +46 -0
  17. data/frontend/src/__tests__/useSchemaTypes.test.ts +219 -0
  18. data/frontend/src/app.ts +10 -0
  19. data/frontend/src/components/AppHeader.vue +152 -0
  20. data/frontend/src/components/AppSidebar.vue +427 -0
  21. data/frontend/src/components/DetailPanel.vue +403 -0
  22. data/frontend/src/components/SchemaBuilder.vue +543 -0
  23. data/frontend/src/components/SchemaStructure.vue +168 -0
  24. data/frontend/src/components/SearchModal.vue +275 -0
  25. data/frontend/src/composables/useBuilderField.ts +92 -0
  26. data/frontend/src/composables/useDefinitionResolver.ts +17 -0
  27. data/frontend/src/composables/useSchemaTypes.ts +152 -0
  28. data/frontend/src/composables/useSearch.ts +104 -0
  29. data/frontend/src/router.ts +14 -0
  30. data/frontend/src/stores/schemaStore.ts +118 -0
  31. data/frontend/src/stores/uiStore.ts +78 -0
  32. data/frontend/src/style.css +194 -0
  33. data/frontend/src/types.ts +70 -0
  34. data/frontend/src/views/HomeView.vue +396 -0
  35. data/frontend/tsconfig.json +20 -0
  36. data/frontend/vite.config.ts +28 -0
  37. data/lib/lutaml/jsonschema/base.rb +11 -0
  38. data/lib/lutaml/jsonschema/cli.rb +102 -0
  39. data/lib/lutaml/jsonschema/combiner.rb +54 -0
  40. data/lib/lutaml/jsonschema/configuration.rb +47 -0
  41. data/lib/lutaml/jsonschema/link.rb +25 -0
  42. data/lib/lutaml/jsonschema/property_entry.rb +15 -0
  43. data/lib/lutaml/jsonschema/reference_resolver.rb +74 -0
  44. data/lib/lutaml/jsonschema/schema.rb +205 -0
  45. data/lib/lutaml/jsonschema/schema_set.rb +217 -0
  46. data/lib/lutaml/jsonschema/spa/generator.rb +22 -0
  47. data/lib/lutaml/jsonschema/spa/metadata.rb +23 -0
  48. data/lib/lutaml/jsonschema/spa/output_strategy.rb +17 -0
  49. data/lib/lutaml/jsonschema/spa/spa_builder.rb +178 -0
  50. data/lib/lutaml/jsonschema/spa/spa_definition.rb +27 -0
  51. data/lib/lutaml/jsonschema/spa/spa_document.rb +23 -0
  52. data/lib/lutaml/jsonschema/spa/spa_property.rb +47 -0
  53. data/lib/lutaml/jsonschema/spa/spa_schema.rb +29 -0
  54. data/lib/lutaml/jsonschema/spa/spa_search_entry.rb +21 -0
  55. data/lib/lutaml/jsonschema/spa/vue_inlined_strategy.rb +53 -0
  56. data/lib/lutaml/jsonschema/version.rb +7 -0
  57. data/lib/lutaml/jsonschema.rb +29 -0
  58. data/sig/lutaml/jsonschema.rbs +6 -0
  59. metadata +163 -0
@@ -0,0 +1,543 @@
1
+ <template>
2
+ <div class="builder-layout">
3
+ <div class="builder-fields">
4
+ <div v-for="field in fields" :key="field.prop.name" class="field-row">
5
+ <div class="field-main">
6
+ <input
7
+ type="checkbox"
8
+ :checked="field.included"
9
+ :disabled="field.isRequired"
10
+ class="field-check"
11
+ @change="toggleField(field, ($event.target as HTMLInputElement).checked)"
12
+ />
13
+ <span class="field-name font-mono" :class="{ dimmed: !field.included }">{{ field.prop.name }}</span>
14
+ <span class="field-type-badge">{{ displayType(field.prop) }}</span>
15
+ <span v-if="field.isRequired" class="req-badge">required</span>
16
+
17
+ <div class="field-control">
18
+ <!-- Enum -->
19
+ <select
20
+ v-if="field.prop.enum?.length && !isObjectProperty(field.prop)"
21
+ v-model="field.rawValue"
22
+ class="ctrl-select"
23
+ :disabled="!field.included"
24
+ >
25
+ <option v-for="e in field.prop.enum" :key="e" :value="e">{{ e }}</option>
26
+ </select>
27
+
28
+ <!-- Boolean -->
29
+ <label v-else-if="primaryType(field.prop.type) === 'boolean'" class="ctrl-toggle">
30
+ <input type="checkbox" :checked="field.rawValue === 'true'" :disabled="!field.included"
31
+ @change="field.rawValue = ($event.target as HTMLInputElement).checked ? 'true' : 'false'" />
32
+ <span class="toggle-label">{{ field.rawValue }}</span>
33
+ </label>
34
+
35
+ <!-- Integer -->
36
+ <input
37
+ v-else-if="primaryType(field.prop.type) === 'integer'"
38
+ type="number" step="1"
39
+ :value="field.rawValue"
40
+ :disabled="!field.included"
41
+ class="ctrl-number"
42
+ @input="field.rawValue = ($event.target as HTMLInputElement).value"
43
+ />
44
+
45
+ <!-- Number -->
46
+ <input
47
+ v-else-if="primaryType(field.prop.type) === 'number'"
48
+ type="number" step="any"
49
+ :value="field.rawValue"
50
+ :disabled="!field.included"
51
+ class="ctrl-number"
52
+ @input="field.rawValue = ($event.target as HTMLInputElement).value"
53
+ />
54
+
55
+ <!-- Object with $ref: expand button -->
56
+ <button
57
+ v-else-if="isObjectProperty(field.prop) && field.resolvedDef"
58
+ class="btn btn-ghost ctrl-expand"
59
+ :disabled="!field.included"
60
+ @click="field.expanded = !field.expanded"
61
+ >
62
+ <span class="chevron" :class="{ expanded: field.expanded }">&#9654;</span>
63
+ {{ field.resolvedDef.title || field.resolvedDef.name }}
64
+ <span class="text-muted">{{ field.resolvedDef.properties.length }} props</span>
65
+ </button>
66
+
67
+ <!-- Object without $ref -->
68
+ <span v-else-if="isObjectProperty(field.prop)" class="ctrl-static">{"..."}</span>
69
+
70
+ <!-- Array with itemsType -->
71
+ <div v-else-if="primaryType(field.prop.type) === 'array'" class="ctrl-array">
72
+ <div v-for="(item, idx) in field.arrayItems" :key="idx" class="array-item-row">
73
+ <input
74
+ :type="arrayItemInputType(field.prop.itemsType)"
75
+ v-model="field.arrayItems[idx]"
76
+ class="ctrl-text"
77
+ :disabled="!field.included"
78
+ />
79
+ <button
80
+ v-if="field.arrayItems.length > 1"
81
+ class="btn btn-ghost btn-xs"
82
+ :disabled="!field.included"
83
+ @click="field.arrayItems.splice(idx, 1)"
84
+ title="Remove item"
85
+ >x</button>
86
+ </div>
87
+ <button
88
+ class="btn btn-ghost btn-xs"
89
+ :disabled="!field.included"
90
+ @click="field.arrayItems.push(arrayDefaultValue(field.prop.itemsType))"
91
+ >+ Add</button>
92
+ </div>
93
+
94
+ <!-- String (default) -->
95
+ <input
96
+ v-else
97
+ :type="formatInputType(field.prop.format)"
98
+ :value="field.rawValue"
99
+ :disabled="!field.included"
100
+ class="ctrl-text"
101
+ @input="field.rawValue = ($event.target as HTMLInputElement).value"
102
+ />
103
+ </div>
104
+ </div>
105
+
106
+ <div v-if="field.prop.description" class="field-desc text-secondary">{{ field.prop.description }}</div>
107
+
108
+ <div v-if="hasConstraints(field.prop)" class="field-constraints">
109
+ <span v-if="field.prop.enum?.length && isObjectProperty(field.prop)" class="constraint-chip">enum: {{ field.prop.enum.join(' | ') }}</span>
110
+ <span v-if="field.prop.pattern" class="constraint-chip font-mono">/{{ field.prop.pattern }}/</span>
111
+ <span v-if="field.prop.minimum != null" class="constraint-chip">min: {{ field.prop.minimum }}</span>
112
+ <span v-if="field.prop.maximum != null" class="constraint-chip">max: {{ field.prop.maximum }}</span>
113
+ <span v-if="field.prop.minLength != null" class="constraint-chip">minLength: {{ field.prop.minLength }}</span>
114
+ <span v-if="field.prop.maxLength != null" class="constraint-chip">maxLength: {{ field.prop.maxLength }}</span>
115
+ <span v-if="field.prop.default != null" class="constraint-chip">default: {{ field.prop.default }}</span>
116
+ <span v-if="field.prop.examples?.length" class="constraint-chip">e.g. {{ field.prop.examples.join(', ') }}</span>
117
+ </div>
118
+
119
+ <!-- Nested object builder -->
120
+ <div v-if="field.expanded && field.included && field.resolvedDef && !isCircular(field, visited)" class="nested-section">
121
+ <div class="nested-header">
122
+ <span class="nested-title">{{ field.resolvedDef.title || field.resolvedDef.name }}</span>
123
+ <span class="nested-meta text-muted">
124
+ {{ field.resolvedDef.type || 'object' }}
125
+ <span v-if="field.resolvedDef.properties.length">{{ field.resolvedDef.properties.length }} properties</span>
126
+ <span v-if="field.resolvedDef.required?.length">{{ field.resolvedDef.required.length }} required</span>
127
+ </span>
128
+ </div>
129
+ <SchemaBuilder
130
+ :properties="field.resolvedDef.properties"
131
+ :required="field.resolvedDef.required"
132
+ :schema="schema"
133
+ :visited="new Set([...visited, field.resolvedDef.name])"
134
+ @update:json="(v: Record<string, unknown>) => { field.nestedJson = v }"
135
+ />
136
+ </div>
137
+
138
+ <!-- Circular reference label -->
139
+ <div v-else-if="isCircular(field, visited)" class="circular-ref-label">
140
+ <span class="circular-badge">Circular</span>
141
+ {{ field.resolvedDef?.name }}
142
+ </div>
143
+ </div>
144
+
145
+ <div v-if="!fields.length" class="empty-hint">
146
+ <p class="text-muted">No properties defined.</p>
147
+ </div>
148
+ </div>
149
+
150
+ <div class="builder-preview">
151
+ <div class="preview-inner">
152
+ <div class="preview-toolbar">
153
+ <span class="toolbar-label text-muted">JSON Preview</span>
154
+ <button class="btn btn-ghost btn-sm" @click="copyJson">
155
+ {{ copied ? 'Copied!' : 'Copy' }}
156
+ </button>
157
+ </div>
158
+ <pre class="json-block"><code>{{ outputJson }}</code></pre>
159
+ </div>
160
+ </div>
161
+ </div>
162
+ </template>
163
+
164
+ <script setup lang="ts">
165
+ import { ref, computed, watch } from 'vue'
166
+ import type { SpaProperty, SpaSchema } from '../types'
167
+ import {
168
+ primaryType,
169
+ displayType,
170
+ formatInputType,
171
+ arrayItemInputType,
172
+ arrayDefaultValue,
173
+ isObjectProperty,
174
+ hasConstraints,
175
+ } from '../composables/useSchemaTypes'
176
+ import {
177
+ createField,
178
+ isCircular,
179
+ parseFieldValue,
180
+ } from '../composables/useBuilderField'
181
+ import type { BuilderField } from '../composables/useBuilderField'
182
+
183
+ const props = withDefaults(defineProps<{
184
+ properties: SpaProperty[]
185
+ required: string[]
186
+ schema: SpaSchema
187
+ visited?: Set<string>
188
+ }>(), {
189
+ visited: () => new Set<string>(),
190
+ })
191
+
192
+ const emit = defineEmits<{
193
+ 'update:json': [value: Record<string, unknown>]
194
+ }>()
195
+
196
+ const copied = ref(false)
197
+
198
+ const fields = ref<BuilderField[]>(props.properties.map(p => createField(p, props.required, props.schema)))
199
+
200
+ function toggleField(field: BuilderField, checked: boolean) {
201
+ field.included = checked
202
+ if (!checked) field.expanded = false
203
+ }
204
+
205
+ const outputJson = computed(() => {
206
+ const obj: Record<string, unknown> = {}
207
+ for (const field of fields.value) {
208
+ if (!field.included) continue
209
+ const t = primaryType(field.prop.type)
210
+ if ((t === 'object' || (!field.prop.type && field.prop.ref)) && field.resolvedDef) {
211
+ obj[field.prop.name] = field.nestedJson
212
+ } else {
213
+ obj[field.prop.name] = parseFieldValue(field)
214
+ }
215
+ }
216
+ return JSON.stringify(obj, null, 2)
217
+ })
218
+
219
+ const outputObj = computed(() => {
220
+ try { return JSON.parse(outputJson.value) } catch { return {} }
221
+ })
222
+
223
+ watch(outputObj, (v) => { emit('update:json', v) }, { immediate: true, deep: true })
224
+
225
+ async function copyJson() {
226
+ try {
227
+ await navigator.clipboard.writeText(outputJson.value)
228
+ copied.value = true
229
+ setTimeout(() => { copied.value = false }, 2000)
230
+ } catch { /* noop */ }
231
+ }
232
+ </script>
233
+
234
+ <style scoped>
235
+ .builder-layout {
236
+ display: grid;
237
+ grid-template-columns: 1fr 1fr;
238
+ gap: var(--space-4);
239
+ align-items: start;
240
+ }
241
+
242
+ .builder-fields {
243
+ display: flex;
244
+ flex-direction: column;
245
+ gap: 2px;
246
+ }
247
+
248
+ .field-row {
249
+ padding: var(--space-2) var(--space-3);
250
+ border-radius: var(--radius-md);
251
+ }
252
+
253
+ .field-row:hover {
254
+ background: var(--bg-hover);
255
+ }
256
+
257
+ .field-main {
258
+ display: flex;
259
+ align-items: center;
260
+ gap: var(--space-2);
261
+ flex-wrap: wrap;
262
+ }
263
+
264
+ .field-check {
265
+ accent-color: var(--color-primary);
266
+ flex-shrink: 0;
267
+ cursor: pointer;
268
+ }
269
+
270
+ .field-check:disabled {
271
+ cursor: default;
272
+ }
273
+
274
+ .field-name {
275
+ font-weight: 600;
276
+ font-size: var(--text-sm);
277
+ min-width: 100px;
278
+ }
279
+
280
+ .field-name.dimmed {
281
+ opacity: 0.35;
282
+ }
283
+
284
+ .field-type-badge {
285
+ font-size: 11px;
286
+ font-weight: 500;
287
+ background: var(--badge-schema-bg);
288
+ color: var(--badge-schema);
289
+ padding: 1px 6px;
290
+ border-radius: var(--radius-sm);
291
+ flex-shrink: 0;
292
+ }
293
+
294
+ .req-badge {
295
+ font-size: 10px;
296
+ font-weight: 600;
297
+ text-transform: uppercase;
298
+ color: var(--badge-required);
299
+ background: var(--badge-required-bg);
300
+ padding: 1px 5px;
301
+ border-radius: 2px;
302
+ flex-shrink: 0;
303
+ }
304
+
305
+ .field-control {
306
+ flex: 1;
307
+ min-width: 120px;
308
+ max-width: 240px;
309
+ }
310
+
311
+ .ctrl-text,
312
+ .ctrl-number,
313
+ .ctrl-select {
314
+ width: 100%;
315
+ padding: 3px 8px;
316
+ font-size: var(--text-sm);
317
+ font-family: var(--font-mono);
318
+ background: var(--bg-primary);
319
+ border: 1px solid var(--border-light);
320
+ border-radius: var(--radius-sm);
321
+ color: var(--text-primary);
322
+ }
323
+
324
+ .ctrl-text:disabled,
325
+ .ctrl-number:disabled,
326
+ .ctrl-select:disabled {
327
+ opacity: 0.35;
328
+ }
329
+
330
+ .ctrl-text:focus,
331
+ .ctrl-number:focus,
332
+ .ctrl-select:focus {
333
+ outline: none;
334
+ border-color: var(--color-primary);
335
+ }
336
+
337
+ .ctrl-toggle {
338
+ display: flex;
339
+ align-items: center;
340
+ gap: var(--space-2);
341
+ font-size: var(--text-sm);
342
+ cursor: pointer;
343
+ }
344
+
345
+ .ctrl-toggle input {
346
+ accent-color: var(--color-primary);
347
+ }
348
+
349
+ .toggle-label {
350
+ font-family: var(--font-mono);
351
+ color: var(--text-secondary);
352
+ }
353
+
354
+ .ctrl-expand {
355
+ display: flex;
356
+ align-items: center;
357
+ gap: var(--space-1);
358
+ font-size: var(--text-xs);
359
+ color: var(--color-primary);
360
+ }
361
+
362
+ .ctrl-static {
363
+ font-size: var(--text-sm);
364
+ font-family: var(--font-mono);
365
+ color: var(--text-muted);
366
+ }
367
+
368
+ .chevron {
369
+ font-size: 10px;
370
+ transition: transform var(--transition-fast);
371
+ }
372
+
373
+ .chevron.expanded {
374
+ transform: rotate(90deg);
375
+ }
376
+
377
+ .field-desc {
378
+ font-size: var(--text-xs);
379
+ line-height: var(--leading-normal);
380
+ margin-top: var(--space-1);
381
+ margin-left: 22px;
382
+ }
383
+
384
+ .field-constraints {
385
+ display: flex;
386
+ flex-wrap: wrap;
387
+ gap: var(--space-1);
388
+ margin-top: var(--space-1);
389
+ margin-left: 22px;
390
+ }
391
+
392
+ .constraint-chip {
393
+ font-size: 11px;
394
+ color: var(--text-muted);
395
+ background: var(--bg-secondary);
396
+ padding: 1px 5px;
397
+ border-radius: var(--radius-sm);
398
+ }
399
+
400
+ .nested-section {
401
+ margin-left: 22px;
402
+ margin-top: var(--space-2);
403
+ padding: var(--space-3);
404
+ border: 1px solid var(--border-light);
405
+ border-radius: var(--radius-md);
406
+ background: var(--bg-secondary);
407
+ }
408
+
409
+ .nested-header {
410
+ display: flex;
411
+ align-items: center;
412
+ gap: var(--space-2);
413
+ margin-bottom: var(--space-3);
414
+ padding-bottom: var(--space-2);
415
+ border-bottom: 1px solid var(--border-light);
416
+ }
417
+
418
+ .nested-title {
419
+ font-weight: 600;
420
+ font-size: var(--text-sm);
421
+ color: var(--color-primary);
422
+ }
423
+
424
+ .nested-meta {
425
+ font-size: var(--text-xs);
426
+ }
427
+
428
+ .builder-preview {
429
+ position: sticky;
430
+ top: var(--space-4);
431
+ }
432
+
433
+ .preview-inner {
434
+ display: flex;
435
+ flex-direction: column;
436
+ gap: var(--space-2);
437
+ }
438
+
439
+ .preview-toolbar {
440
+ display: flex;
441
+ align-items: center;
442
+ justify-content: space-between;
443
+ }
444
+
445
+ .toolbar-label {
446
+ font-size: var(--text-xs);
447
+ }
448
+
449
+ .btn-sm {
450
+ font-size: var(--text-xs);
451
+ padding: var(--space-1) var(--space-2);
452
+ }
453
+
454
+ .json-block {
455
+ background: var(--bg-secondary);
456
+ border: 1px solid var(--border-light);
457
+ border-radius: var(--radius-md);
458
+ padding: var(--space-4);
459
+ overflow-x: auto;
460
+ font-size: var(--text-sm);
461
+ line-height: var(--leading-relaxed);
462
+ margin: 0;
463
+ max-height: 70vh;
464
+ overflow-y: auto;
465
+ }
466
+
467
+ .json-block code {
468
+ font-family: var(--font-mono);
469
+ color: var(--text-primary);
470
+ }
471
+
472
+ .empty-hint {
473
+ padding: var(--space-8);
474
+ text-align: center;
475
+ }
476
+
477
+ /* Editable arrays */
478
+ .ctrl-array {
479
+ display: flex;
480
+ flex-direction: column;
481
+ gap: var(--space-1);
482
+ }
483
+
484
+ .array-item-row {
485
+ display: flex;
486
+ align-items: center;
487
+ gap: var(--space-1);
488
+ }
489
+
490
+ .array-item-row input {
491
+ flex: 1;
492
+ }
493
+
494
+ .btn-xs {
495
+ font-size: 11px;
496
+ padding: 2px 6px;
497
+ border: none;
498
+ background: transparent;
499
+ color: var(--text-muted);
500
+ cursor: pointer;
501
+ }
502
+
503
+ .btn-xs:hover:not(:disabled) {
504
+ color: var(--text-primary);
505
+ }
506
+
507
+ .btn-xs:disabled {
508
+ opacity: 0.35;
509
+ cursor: default;
510
+ }
511
+
512
+ /* Circular reference */
513
+ .circular-ref-label {
514
+ display: flex;
515
+ align-items: center;
516
+ gap: var(--space-2);
517
+ font-size: var(--text-sm);
518
+ color: var(--text-muted);
519
+ padding: var(--space-2) var(--space-3);
520
+ margin-left: 22px;
521
+ }
522
+
523
+ .circular-badge {
524
+ font-size: 10px;
525
+ font-weight: 600;
526
+ text-transform: uppercase;
527
+ color: #b45309;
528
+ background: #fef3c7;
529
+ padding: 1px 5px;
530
+ border-radius: 2px;
531
+ }
532
+
533
+ /* Responsive */
534
+ @media (max-width: 768px) {
535
+ .builder-layout {
536
+ grid-template-columns: 1fr;
537
+ }
538
+ .builder-preview {
539
+ position: static;
540
+ order: -1;
541
+ }
542
+ }
543
+ </style>
@@ -0,0 +1,168 @@
1
+ <template>
2
+ <div class="schema-structure">
3
+ <div v-for="prop in properties" :key="prop.name" class="prop-row">
4
+ <div class="prop-main">
5
+ <span class="prop-dot" :class="{ required: isRequired(prop.name) }" :title="isRequired(prop.name) ? 'Required' : 'Optional'"></span>
6
+ <span class="prop-name font-mono">{{ prop.name }}</span>
7
+ <span class="prop-type-badge">{{ displayType(prop) }}</span>
8
+ <span v-if="prop.format" class="prop-format-badge">{{ prop.format }}</span>
9
+ <span v-if="isRequired(prop.name)" class="req-badge">required</span>
10
+ <span v-if="prop.deprecated" class="dep-badge">deprecated</span>
11
+ </div>
12
+ <div v-if="prop.description" class="prop-desc text-secondary">{{ prop.description }}</div>
13
+ <div v-if="hasDetails(prop)" class="prop-details">
14
+ <span v-if="prop.enum?.length" class="detail-chip">enum: {{ prop.enum.join(' | ') }}</span>
15
+ <span v-if="prop.pattern" class="detail-chip font-mono">/{{ prop.pattern }}/</span>
16
+ <span v-if="prop.minimum != null" class="detail-chip">min: {{ prop.minimum }}</span>
17
+ <span v-if="prop.maximum != null" class="detail-chip">max: {{ prop.maximum }}</span>
18
+ <span v-if="prop.minLength != null" class="detail-chip">minLength: {{ prop.minLength }}</span>
19
+ <span v-if="prop.maxLength != null" class="detail-chip">maxLength: {{ prop.maxLength }}</span>
20
+ <span v-if="prop.default != null" class="detail-chip">default: {{ prop.default }}</span>
21
+ <span v-if="prop.examples?.length" class="detail-chip">e.g. {{ prop.examples.join(', ') }}</span>
22
+ <span v-if="prop.ref" class="detail-chip ref-link">{{ prop.ref }}</span>
23
+ </div>
24
+ </div>
25
+ <div v-if="!properties.length" class="empty-state">
26
+ <p class="text-muted">No properties defined.</p>
27
+ </div>
28
+ </div>
29
+ </template>
30
+
31
+ <script setup lang="ts">
32
+ import type { SpaProperty } from '../types'
33
+
34
+ const props = defineProps<{
35
+ properties: SpaProperty[]
36
+ required: string[]
37
+ }>()
38
+
39
+ function isRequired(name: string) {
40
+ return props.required.includes(name)
41
+ }
42
+
43
+ function displayType(prop: SpaProperty) {
44
+ if (!prop.type) return 'any'
45
+ if (prop.type === 'array' && prop.itemsType) return `array<${prop.itemsType}>`
46
+ return prop.type
47
+ }
48
+
49
+ function hasDetails(prop: SpaProperty) {
50
+ return !!(prop.enum?.length || prop.pattern ||
51
+ prop.minimum != null || prop.maximum != null ||
52
+ prop.minLength != null || prop.maxLength != null ||
53
+ prop.default != null || prop.ref || prop.examples?.length)
54
+ }
55
+ </script>
56
+
57
+ <style scoped>
58
+ .schema-structure {
59
+ display: flex;
60
+ flex-direction: column;
61
+ gap: 2px;
62
+ }
63
+
64
+ .prop-row {
65
+ padding: var(--space-3) var(--space-4);
66
+ border-radius: var(--radius-md);
67
+ transition: background var(--transition-fast);
68
+ }
69
+
70
+ .prop-row:hover {
71
+ background: var(--bg-hover);
72
+ }
73
+
74
+ .prop-main {
75
+ display: flex;
76
+ align-items: center;
77
+ gap: var(--space-2);
78
+ flex-wrap: wrap;
79
+ }
80
+
81
+ .prop-dot {
82
+ width: 6px;
83
+ height: 6px;
84
+ border-radius: 50%;
85
+ background: var(--border-medium);
86
+ flex-shrink: 0;
87
+ }
88
+
89
+ .prop-dot.required {
90
+ background: var(--color-primary);
91
+ }
92
+
93
+ .prop-name {
94
+ font-weight: 600;
95
+ font-size: var(--text-sm);
96
+ color: var(--text-primary);
97
+ }
98
+
99
+ .prop-type-badge {
100
+ font-size: 11px;
101
+ font-weight: 500;
102
+ background: var(--badge-schema-bg);
103
+ color: var(--badge-schema);
104
+ padding: 1px 6px;
105
+ border-radius: var(--radius-sm);
106
+ }
107
+
108
+ .prop-format-badge {
109
+ font-size: 11px;
110
+ background: var(--bg-secondary);
111
+ color: var(--text-muted);
112
+ padding: 1px 5px;
113
+ border-radius: var(--radius-sm);
114
+ }
115
+
116
+ .req-badge {
117
+ font-size: 10px;
118
+ font-weight: 600;
119
+ text-transform: uppercase;
120
+ letter-spacing: 0.03em;
121
+ color: var(--badge-required);
122
+ background: var(--badge-required-bg);
123
+ padding: 1px 5px;
124
+ border-radius: 2px;
125
+ }
126
+
127
+ .dep-badge {
128
+ font-size: 10px;
129
+ font-weight: 600;
130
+ text-transform: uppercase;
131
+ color: var(--badge-deprecated);
132
+ background: var(--badge-deprecated-bg);
133
+ padding: 1px 5px;
134
+ border-radius: 2px;
135
+ }
136
+
137
+ .prop-desc {
138
+ font-size: var(--text-sm);
139
+ line-height: var(--leading-normal);
140
+ margin-top: var(--space-1);
141
+ margin-left: 16px;
142
+ }
143
+
144
+ .prop-details {
145
+ display: flex;
146
+ flex-wrap: wrap;
147
+ gap: var(--space-1);
148
+ margin-top: var(--space-1);
149
+ margin-left: 16px;
150
+ }
151
+
152
+ .detail-chip {
153
+ font-size: 11px;
154
+ color: var(--text-muted);
155
+ background: var(--bg-secondary);
156
+ padding: 1px 5px;
157
+ border-radius: var(--radius-sm);
158
+ }
159
+
160
+ .ref-link {
161
+ color: var(--color-accent);
162
+ }
163
+
164
+ .empty-state {
165
+ padding: var(--space-8);
166
+ text-align: center;
167
+ }
168
+ </style>