diff --git a/apps/web/messages/af.json b/apps/web/messages/af.json index 2ccc468a..9c959f17 100644 --- a/apps/web/messages/af.json +++ b/apps/web/messages/af.json @@ -2644,7 +2644,9 @@ "dart": "Dart", "swift": "Swift (Codable)" }, - "editorLoading": "Laai redigeerder…" + "editorLoading": "Laai redigeerder…", + "detectedFormats": "Detected formats", + "download": "Download" }, "ImageToBase64": { "title": "Beeld na Base64", diff --git a/apps/web/messages/ar.json b/apps/web/messages/ar.json index c393e28f..decdef0c 100644 --- a/apps/web/messages/ar.json +++ b/apps/web/messages/ar.json @@ -2644,7 +2644,9 @@ "dart": "Dart", "swift": "Swift (Codable)" }, - "editorLoading": "جاري تحميل المحرر…" + "editorLoading": "جاري تحميل المحرر…", + "detectedFormats": "Detected formats", + "download": "Download" }, "ImageToBase64": { "title": "الصورة إلى Base64", diff --git a/apps/web/messages/ca.json b/apps/web/messages/ca.json index 63f39bd0..586f5fac 100644 --- a/apps/web/messages/ca.json +++ b/apps/web/messages/ca.json @@ -2644,7 +2644,9 @@ "dart": "Dart", "swift": "Swift (Codable)" }, - "editorLoading": "S'està carregant l'editor…" + "editorLoading": "S'està carregant l'editor…", + "detectedFormats": "Detected formats", + "download": "Download" }, "ImageToBase64": { "title": "Imatge a Base64", diff --git a/apps/web/messages/cs.json b/apps/web/messages/cs.json index 66cb138d..a2ccf526 100644 --- a/apps/web/messages/cs.json +++ b/apps/web/messages/cs.json @@ -2644,7 +2644,9 @@ "dart": "Dart", "swift": "Swift (Codable)" }, - "editorLoading": "Načítání editoru…" + "editorLoading": "Načítání editoru…", + "detectedFormats": "Detected formats", + "download": "Download" }, "ImageToBase64": { "title": "Obrázek do Base64", diff --git a/apps/web/messages/da.json b/apps/web/messages/da.json index 7daba98f..a13ce452 100644 --- a/apps/web/messages/da.json +++ b/apps/web/messages/da.json @@ -2644,7 +2644,9 @@ "dart": "Dart", "swift": "Swift (Codable)" }, - "editorLoading": "Indlæser editor…" + "editorLoading": "Indlæser editor…", + "detectedFormats": "Detected formats", + "download": "Download" }, "ImageToBase64": { "title": "Billede til Base64", diff --git a/apps/web/messages/de.json b/apps/web/messages/de.json index 14b3b134..cd2f6eb5 100644 --- a/apps/web/messages/de.json +++ b/apps/web/messages/de.json @@ -2644,7 +2644,9 @@ "dart": "Dart", "swift": "Swift (Codable)" }, - "editorLoading": "Editor wird geladen…" + "editorLoading": "Editor wird geladen…", + "detectedFormats": "Detected formats", + "download": "Download" }, "ImageToBase64": { "title": "Bild zu Base64", diff --git a/apps/web/messages/el.json b/apps/web/messages/el.json index 85a3770d..c6e02d8f 100644 --- a/apps/web/messages/el.json +++ b/apps/web/messages/el.json @@ -2644,7 +2644,9 @@ "dart": "Dart", "swift": "Swift (Codable)" }, - "editorLoading": "Φόρτωση επεξεργαστή…" + "editorLoading": "Φόρτωση επεξεργαστή…", + "detectedFormats": "Detected formats", + "download": "Download" }, "ImageToBase64": { "title": "Εικόνα στο Base64", diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json index f6ec24fc..7dcbd1ed 100644 --- a/apps/web/messages/en.json +++ b/apps/web/messages/en.json @@ -2390,7 +2390,9 @@ "csharp": "C# (System.Text.Json)", "dart": "Dart", "swift": "Swift (Codable)" - } + }, + "detectedFormats": "Detected formats", + "download": "Download" }, "SnippetManager": { "title": "Code Snippet Manager", diff --git a/apps/web/messages/es.json b/apps/web/messages/es.json index 6419427b..269ee10a 100644 --- a/apps/web/messages/es.json +++ b/apps/web/messages/es.json @@ -2644,7 +2644,9 @@ "dart": "Dart", "swift": "Swift (Codable)" }, - "editorLoading": "Cargando el editor…" + "editorLoading": "Cargando el editor…", + "detectedFormats": "Detected formats", + "download": "Download" }, "ImageToBase64": { "title": "Imagen a Base64", diff --git a/apps/web/messages/fa.json b/apps/web/messages/fa.json index 7e5fc04d..31a26e9c 100644 --- a/apps/web/messages/fa.json +++ b/apps/web/messages/fa.json @@ -2644,7 +2644,9 @@ "dart": "Dart", "swift": "Swift (Codable)" }, - "editorLoading": "در حال بارگذاری ویرایشگر…" + "editorLoading": "در حال بارگذاری ویرایشگر…", + "detectedFormats": "Detected formats", + "download": "Download" }, "ImageToBase64": { "title": "تصویر به Base64", diff --git a/apps/web/messages/fr.json b/apps/web/messages/fr.json index 5dfce7b9..b9056dda 100644 --- a/apps/web/messages/fr.json +++ b/apps/web/messages/fr.json @@ -2644,7 +2644,9 @@ "dart": "Dart", "swift": "Swift (Codable)" }, - "editorLoading": "Chargement de l’éditeur…" + "editorLoading": "Chargement de l’éditeur…", + "detectedFormats": "Detected formats", + "download": "Download" }, "ImageToBase64": { "title": "Image vers Base64", diff --git a/apps/web/messages/id.json b/apps/web/messages/id.json index 84fe890c..eeeae3b1 100644 --- a/apps/web/messages/id.json +++ b/apps/web/messages/id.json @@ -2644,7 +2644,9 @@ "dart": "Dart", "swift": "Swift (Codable)" }, - "editorLoading": "Memuat editor…" + "editorLoading": "Memuat editor…", + "detectedFormats": "Detected formats", + "download": "Download" }, "ImageToBase64": { "title": "Gambar ke Base64", diff --git a/apps/web/messages/it.json b/apps/web/messages/it.json index 58949a38..a6c52cd8 100644 --- a/apps/web/messages/it.json +++ b/apps/web/messages/it.json @@ -2644,7 +2644,9 @@ "dart": "Dart", "swift": "Swift (Codable)" }, - "editorLoading": "Caricamento editor…" + "editorLoading": "Caricamento editor…", + "detectedFormats": "Detected formats", + "download": "Download" }, "ImageToBase64": { "title": "Immagine su Base64", diff --git a/apps/web/messages/ja.json b/apps/web/messages/ja.json index 611039d7..c4002348 100644 --- a/apps/web/messages/ja.json +++ b/apps/web/messages/ja.json @@ -2644,7 +2644,9 @@ "dart": "Dart", "swift": "Swift (Codable)" }, - "editorLoading": "エディタを読み込み中…" + "editorLoading": "エディタを読み込み中…", + "detectedFormats": "Detected formats", + "download": "Download" }, "ImageToBase64": { "title": "画像をBase64に変換", diff --git a/apps/web/messages/ko.json b/apps/web/messages/ko.json index 51dfe6f0..cb2eac20 100644 --- a/apps/web/messages/ko.json +++ b/apps/web/messages/ko.json @@ -2644,7 +2644,9 @@ "dart": "Dart", "swift": "Swift (Codable)" }, - "editorLoading": "편집기 로드 중…" + "editorLoading": "편집기 로드 중…", + "detectedFormats": "Detected formats", + "download": "Download" }, "ImageToBase64": { "title": "이미지를 Base64로", diff --git a/apps/web/messages/ms.json b/apps/web/messages/ms.json index 96e17af7..4a9c050f 100644 --- a/apps/web/messages/ms.json +++ b/apps/web/messages/ms.json @@ -2644,7 +2644,9 @@ "dart": "Dart", "swift": "Swift (Codable)" }, - "editorLoading": "Memuatkan editor…" + "editorLoading": "Memuatkan editor…", + "detectedFormats": "Detected formats", + "download": "Download" }, "ImageToBase64": { "title": "Imej ke Base64", diff --git a/apps/web/messages/nb.json b/apps/web/messages/nb.json index b80f182b..c7266c35 100644 --- a/apps/web/messages/nb.json +++ b/apps/web/messages/nb.json @@ -2644,7 +2644,9 @@ "dart": "Dart", "swift": "Swift (Codable)" }, - "editorLoading": "Laster inn redigeringsprogrammet…" + "editorLoading": "Laster inn redigeringsprogrammet…", + "detectedFormats": "Detected formats", + "download": "Download" }, "ImageToBase64": { "title": "Bilde til Base64", diff --git a/apps/web/messages/nl.json b/apps/web/messages/nl.json index 48cfabe3..e8acc436 100644 --- a/apps/web/messages/nl.json +++ b/apps/web/messages/nl.json @@ -2644,7 +2644,9 @@ "dart": "Dart", "swift": "Swift (Codable)" }, - "editorLoading": "Editor laden…" + "editorLoading": "Editor laden…", + "detectedFormats": "Detected formats", + "download": "Download" }, "ImageToBase64": { "title": "Afbeelding naar Base64", diff --git a/apps/web/messages/pl.json b/apps/web/messages/pl.json index 0a90fcf0..9e95804c 100644 --- a/apps/web/messages/pl.json +++ b/apps/web/messages/pl.json @@ -2644,7 +2644,9 @@ "dart": "Dart", "swift": "Swift (Codable)" }, - "editorLoading": "Ładowanie edytora…" + "editorLoading": "Ładowanie edytora…", + "detectedFormats": "Detected formats", + "download": "Download" }, "ImageToBase64": { "title": "Obraz do Base64", diff --git a/apps/web/messages/pt-BR.json b/apps/web/messages/pt-BR.json index 7747d903..171d2cb8 100644 --- a/apps/web/messages/pt-BR.json +++ b/apps/web/messages/pt-BR.json @@ -2644,7 +2644,9 @@ "dart": "Dart", "swift": "Swift (Codable)" }, - "editorLoading": "Carregando o editor…" + "editorLoading": "Carregando o editor…", + "detectedFormats": "Detected formats", + "download": "Download" }, "ImageToBase64": { "title": "Imagem para Base64", diff --git a/apps/web/messages/pt.json b/apps/web/messages/pt.json index 18dc98d8..42fc9f2a 100644 --- a/apps/web/messages/pt.json +++ b/apps/web/messages/pt.json @@ -2644,7 +2644,9 @@ "dart": "Dart", "swift": "Swift (Codable)" }, - "editorLoading": "A carregar o editor…" + "editorLoading": "A carregar o editor…", + "detectedFormats": "Detected formats", + "download": "Download" }, "ImageToBase64": { "title": "Imagem para Base64", diff --git a/apps/web/messages/ru.json b/apps/web/messages/ru.json index 05ad664e..9fc7de7b 100644 --- a/apps/web/messages/ru.json +++ b/apps/web/messages/ru.json @@ -2644,7 +2644,9 @@ "dart": "Dart", "swift": "Swift (Codable)" }, - "editorLoading": "Загрузка редактора…" + "editorLoading": "Загрузка редактора…", + "detectedFormats": "Detected formats", + "download": "Download" }, "ImageToBase64": { "title": "Изображение в Base64", diff --git a/apps/web/messages/sv.json b/apps/web/messages/sv.json index 6ab85c0a..bc97807a 100644 --- a/apps/web/messages/sv.json +++ b/apps/web/messages/sv.json @@ -2644,7 +2644,9 @@ "dart": "Dart", "swift": "Swift (Codable)" }, - "editorLoading": "Läser in redigeraren…" + "editorLoading": "Läser in redigeraren…", + "detectedFormats": "Detected formats", + "download": "Download" }, "ImageToBase64": { "title": "Bild till Base64", diff --git a/apps/web/messages/tr.json b/apps/web/messages/tr.json index a12da0f9..62f7bc40 100644 --- a/apps/web/messages/tr.json +++ b/apps/web/messages/tr.json @@ -2644,7 +2644,9 @@ "dart": "Dart", "swift": "Swift (Codable)" }, - "editorLoading": "Editör yükleniyor…" + "editorLoading": "Editör yükleniyor…", + "detectedFormats": "Detected formats", + "download": "Download" }, "ImageToBase64": { "title": "Base64'e görüntü", diff --git a/apps/web/messages/uk.json b/apps/web/messages/uk.json index f26e72de..9b2b7fc0 100644 --- a/apps/web/messages/uk.json +++ b/apps/web/messages/uk.json @@ -2644,7 +2644,9 @@ "dart": "Dart", "swift": "Swift (Codable)" }, - "editorLoading": "Завантаження редактора…" + "editorLoading": "Завантаження редактора…", + "detectedFormats": "Detected formats", + "download": "Download" }, "ImageToBase64": { "title": "Зображення до Base64", diff --git a/apps/web/messages/vi.json b/apps/web/messages/vi.json index 8f08b722..02028ffb 100644 --- a/apps/web/messages/vi.json +++ b/apps/web/messages/vi.json @@ -2644,7 +2644,9 @@ "dart": "Dart", "swift": "Swift (Codable)" }, - "editorLoading": "Đang tải trình soạn thảo…" + "editorLoading": "Đang tải trình soạn thảo…", + "detectedFormats": "Detected formats", + "download": "Download" }, "ImageToBase64": { "title": "Hình ảnh tới Base64", diff --git a/apps/web/messages/zh.json b/apps/web/messages/zh.json index 75ac18e6..de2d5b00 100644 --- a/apps/web/messages/zh.json +++ b/apps/web/messages/zh.json @@ -2644,7 +2644,9 @@ "dart": "Dart", "swift": "Swift (Codable)" }, - "editorLoading": "正在加载编辑器…" + "editorLoading": "正在加载编辑器…", + "detectedFormats": "Detected formats", + "download": "Download" }, "ImageToBase64": { "title": "图像转 Base64", diff --git a/apps/web/src/components/environment-manager/environment-set-list.tsx b/apps/web/src/components/environment-manager/environment-set-list.tsx index f3dbfb36..81724036 100644 --- a/apps/web/src/components/environment-manager/environment-set-list.tsx +++ b/apps/web/src/components/environment-manager/environment-set-list.tsx @@ -9,7 +9,7 @@ import { Input } from "@/components/ui/input" import { Button } from "@/components/ui/button" import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" import { Badge } from "@/components/ui/badge" -import { Search, Copy, Pencil, Trash2, FileCode2, Eye, ArrowUpDown } from "lucide-react" +import { Search, Copy, Pencil, Trash2, FileCode2, Eye, ArrowUpDown, Check } from "lucide-react" import { toast } from "sonner" import { deleteEnvSetEntry } from "@/lib/environment-manager-api" import { formatDotEnv } from "@/lib/environment-manager-utils" @@ -60,6 +60,15 @@ import { ScrollArea } from "@/components/ui/scroll-area" type SortOrder = "updatedDesc" | "updatedAsc" | "nameAsc" +// ponytail: heuristic on free-text environment field, not an enum — false negatives just fall back to neutral styling +function envAccentClass(environment: string) { + const e = environment.toLowerCase() + if (/prod/.test(e)) return "border-l-destructive" + if (/stag|preprod|uat/.test(e)) return "border-l-amber-500" + if (/dev|local|test|qa/.test(e)) return "border-l-emerald-500" + return "border-l-border" +} + export function EnvironmentSetList() { const t = useTranslations("EnvironmentManager.list") const tToast = useTranslations("EnvironmentManager.toasts") @@ -166,9 +175,12 @@ export function EnvironmentSetList() { - setSortOrder("updatedDesc")}>{t("sort.updatedDesc")} - setSortOrder("updatedAsc")}>{t("sort.updatedAsc")} - setSortOrder("nameAsc")}>{t("sort.nameAsc")} + {(["updatedDesc", "updatedAsc", "nameAsc"] as const).map((order) => ( + setSortOrder(order)} className="justify-between gap-2"> + {t(`sort.${order}`)} + {sortOrder === order && } + + ))} @@ -191,7 +203,13 @@ export function EnvironmentSetList() {
{filtered.map((entry) => ( - + {entry.project} @@ -207,16 +225,19 @@ export function EnvironmentSetList() { {entry.tags.length > 0 && (
- {entry.tags.map((tag) => ( - setTagFilter(tag)} - > - {tag} - - ))} + {entry.tags.map((tag) => { + const active = tagFilter.toLowerCase().trim() === tag.toLowerCase() + return ( + setTagFilter(active ? "" : tag)} + > + {tag} + + ) + })}
)}

@@ -235,6 +256,7 @@ export function EnvironmentSetList() { variant="outline" size="icon" className="h-8 w-8" + aria-label={t("copyDotEnv")} onClick={() => copyToClipboard( formatDotEnv(entry.variables), @@ -254,6 +276,7 @@ export function EnvironmentSetList() { variant="outline" size="icon" className="h-8 w-8" + aria-label={t("view")} onClick={() => setViewingEntry(entry)} > @@ -268,6 +291,7 @@ export function EnvironmentSetList() { variant="ghost" size="icon" className="h-8 w-8" + aria-label={t("edit")} onClick={() => { setEditing(entry) setEditOpen(true) @@ -285,6 +309,7 @@ export function EnvironmentSetList() { variant="ghost" size="icon" className="h-8 w-8 text-destructive hover:text-destructive ml-auto" + aria-label={t("delete")} onClick={() => setDeleteEntry(entry)} > diff --git a/apps/web/src/components/json-schema-generator/json-schema-generator-layout.tsx b/apps/web/src/components/json-schema-generator/json-schema-generator-layout.tsx index cfdbbd54..7f87edad 100644 --- a/apps/web/src/components/json-schema-generator/json-schema-generator-layout.tsx +++ b/apps/web/src/components/json-schema-generator/json-schema-generator-layout.tsx @@ -4,7 +4,7 @@ import { useCallback, useMemo, useState } from 'react'; import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'; import { useIsMobile } from '@/components/hooks/use-mobile'; import { useTranslations } from 'next-intl'; -import { AlertCircle, Check, Copy, FileJson, Trash2 } from 'lucide-react'; +import { AlertCircle, Check, Copy, Download, FileJson, Trash2 } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Card } from '@/components/ui/card'; import { Label } from '@/components/ui/label'; @@ -17,13 +17,17 @@ import { } from '@/components/ui/select'; import { ScrollArea } from '@/components/ui/scroll-area'; import { cn } from '@/lib/utils'; +import { triggerDownload } from '@/lib/csv-excel-json-utils'; import { highlightSchemaOutput } from '@/lib/hljs-json-schema-output'; import { + collectFormats, generateFromSchema, inferSchema, OUTPUT_LANGUAGE_ORDER, type OutputLanguage, + type StringFormat, } from '@/lib/json-schema-generator'; +import { Badge } from '@/components/ui/badge'; import './json-schema-highlighter.css'; import { JsonSchemaInputEditor } from './json-schema-input-editor'; @@ -38,6 +42,18 @@ const defaultSample = `{ "scores": [9.5, 8.0] }`; +const FILE_EXT: Record = { + jsonSchema: 'json', + python: 'py', + typescript: 'ts', + go: 'go', + rust: 'rs', + java: 'java', + csharp: 'cs', + dart: 'dart', + swift: 'swift', +}; + export function JsonSchemaGeneratorLayout() { const t = useTranslations('JsonSchemaGenerator'); const isMobile = useIsMobile(); @@ -46,10 +62,10 @@ export function JsonSchemaGeneratorLayout() { const [language, setLanguage] = useState('python'); const { isCopied: copied, copyToClipboard } = useCopyToClipboard(); - const { output, error } = useMemo(() => { + const { output, error, formats } = useMemo(() => { const trimmed = input.trim(); if (!trimmed) { - return { output: '', error: null as string | null }; + return { output: '', error: null as string | null, formats: [] as [StringFormat, number][] }; } try { const parsed: unknown = JSON.parse(trimmed); @@ -57,10 +73,11 @@ export function JsonSchemaGeneratorLayout() { return { output: generateFromSchema(schema, language), error: null as string | null, + formats: [...collectFormats(schema).entries()], }; } catch (e) { const msg = e instanceof Error ? e.message : String(e); - return { output: '', error: msg }; + return { output: '', error: msg, formats: [] as [StringFormat, number][] }; } }, [input, language]); @@ -77,6 +94,14 @@ export function JsonSchemaGeneratorLayout() { }); }, [output, t, copyToClipboard]); + const handleDownload = useCallback(() => { + if (!output || error) return; + const ext = FILE_EXT[language]; + const filename = language === 'jsonSchema' ? 'schema.json' : `generated.${ext}`; + const mime = language === 'jsonSchema' ? 'application/json' : 'text/plain'; + triggerDownload(new Blob([output], { type: `${mime};charset=utf-8` }), filename); + }, [output, error, language]); + const handleClear = () => { setInput(''); }; @@ -129,6 +154,15 @@ export function JsonSchemaGeneratorLayout() { )} {t('copy')} +

@@ -176,10 +210,22 @@ export function JsonSchemaGeneratorLayout() { error && 'border-destructive/40' )} > -
+
+ {!error && formats.length > 0 && ( +
+ + {t('detectedFormats')} + + {formats.map(([fmt, count]) => ( + + {fmt}{count > 1 ? ` ×${count}` : ''} + + ))} +
+ )}
{error ? ( diff --git a/apps/web/src/lib/__tests__/json-schema-generator.test.ts b/apps/web/src/lib/__tests__/json-schema-generator.test.ts new file mode 100644 index 00000000..fc9b311b --- /dev/null +++ b/apps/web/src/lib/__tests__/json-schema-generator.test.ts @@ -0,0 +1,172 @@ +import { collectFormats, detectFormat, generateFromSchema, inferSchema, mergeNodes, toJsonSchemaDocument } from '../json-schema-generator'; + +describe('detectFormat', () => { + it('detects each format', () => { + expect(detectFormat('550e8400-e29b-41d4-a716-446655440000')).toBe('uuid'); + expect(detectFormat('2026-07-02T10:30:00Z')).toBe('date-time'); + expect(detectFormat('2026-07-02T10:30:00.5+05:30')).toBe('date-time'); + expect(detectFormat('2026-07-02')).toBe('date'); + expect(detectFormat('10:30:00')).toBe('time'); + expect(detectFormat('user@example.com')).toBe('email'); + expect(detectFormat('192.168.0.1')).toBe('ipv4'); + expect(detectFormat('https://example.com/x')).toBe('uri'); + }); + it('returns undefined for plain strings and near-misses', () => { + expect(detectFormat('hello world')).toBeUndefined(); + expect(detectFormat('2026-13-99')).toBeUndefined(); + expect(detectFormat('999.999.999.999')).toBeUndefined(); + expect(detectFormat('not-a-uuid-1234')).toBeUndefined(); + }); +}); + +describe('inferSchema format tagging', () => { + it('tags detected string formats', () => { + const node = inferSchema({ created: '2026-07-02T10:30:00Z', name: 'x' }); + expect(node).toMatchObject({ + kind: 'object', + fields: { + created: { node: { kind: 'scalar', t: 'string', format: 'date-time' } }, + name: { node: { kind: 'scalar', t: 'string' } }, + }, + }); + }); +}); + +describe('mergeNodes format handling', () => { + const strEmail = { kind: 'scalar', t: 'string', format: 'email' } as const; + const strDate = { kind: 'scalar', t: 'string', format: 'date' } as const; + const plainStr = { kind: 'scalar', t: 'string' } as const; + + it('keeps a shared format', () => { + expect(mergeNodes(strEmail, { ...strEmail })).toEqual(strEmail); + }); + it('drops format when two strings disagree', () => { + expect(mergeNodes(strEmail, strDate)).toEqual(plainStr); + }); + it('drops format when only one side has one', () => { + expect(mergeNodes(strEmail, plainStr)).toEqual(plainStr); + }); + it('array of mixed-format strings infers plain string item', () => { + const node = inferSchema(['a@b.com', '2026-07-02']); + expect(node).toEqual({ kind: 'array', item: plainStr }); + }); +}); + +describe('JSON Schema format output', () => { + it('emits format for detected strings', () => { + const doc = toJsonSchemaDocument( + inferSchema({ email: 'a@b.com', note: 'hi' }) + ) as any; + expect(doc.properties.email).toEqual({ type: 'string', format: 'email' }); + expect(doc.properties.note).toEqual({ type: 'string' }); + }); +}); + +describe('collectFormats', () => { + it('counts formats across nested and array shapes', () => { + const node = inferSchema({ + id: '550e8400-e29b-41d4-a716-446655440000', + owner: { email: 'a@b.com' }, + contacts: [{ email: 'c@d.com' }, { email: 'e@f.com' }], + created: '2026-07-02T00:00:00Z', + }); + const m = collectFormats(node); + expect(m.get('uuid')).toBe(1); + expect(m.get('email')).toBe(2); + expect(m.get('date-time')).toBe(1); + expect(m.has('date')).toBe(false); + }); +}); + +describe('Python format mapping', () => { + const node = inferSchema({ + created: '2026-07-02T00:00:00Z', + id: '550e8400-e29b-41d4-a716-446655440000', + email: 'a@b.com', + }); + const py = generateFromSchema(node, 'python'); + + it('maps date-time and uuid to stdlib types', () => { + expect(py).toContain('created: datetime'); + expect(py).toContain('id: UUID'); + }); + it('leaves email as str', () => { + expect(py).toContain('email: str'); + }); + it('adds the needed imports', () => { + expect(py).toContain('from datetime import datetime'); + expect(py).toContain('from uuid import UUID'); + }); +}); + +describe('format mapping across languages', () => { + const node = inferSchema({ + created: '2026-07-02T00:00:00Z', + day: '2026-07-02', + id: '550e8400-e29b-41d4-a716-446655440000', + email: 'a@b.com', + }); + + it('Go maps date-time to time.Time and imports time', () => { + const go = generateFromSchema(node, 'go'); + expect(go).toMatch(/Created\s+time\.Time/); + expect(go).toContain('"time"'); + expect(go).toMatch(/Email\s+string/); + }); + + it('Java maps to java.time and UUID', () => { + const java = generateFromSchema(node, 'java'); + expect(java).toContain('OffsetDateTime'); + expect(java).toContain('LocalDate'); + expect(java).toContain('UUID'); + expect(java).toContain('import java.time.*;'); + }); + + it('C# maps to BCL date/guid types', () => { + const cs = generateFromSchema(node, 'csharp'); + expect(cs).toContain('DateTimeOffset'); + expect(cs).toContain('DateOnly'); + expect(cs).toContain('Guid'); + expect(cs).toContain('using System;'); + }); + + it('Dart maps date-time to DateTime and parses it', () => { + const dart = generateFromSchema(node, 'dart'); + expect(dart).toContain('DateTime created'); + expect(dart).toContain('DateTime.parse('); + }); + + it('Swift maps date-time to Date and uuid to UUID', () => { + const sw = generateFromSchema(node, 'swift'); + expect(sw).toContain('created: Date'); + expect(sw).toContain('id: UUID'); + expect(sw).toContain('.iso8601'); + }); + + it('TypeScript leaves formats as string', () => { + const ts = generateFromSchema(node, 'typescript'); + expect(ts).toMatch(/created: string/); + expect(ts).toMatch(/id: string/); + }); +}); + +describe('format mapping in arrays and non-object roots', () => { + it('Dart parses date-time inside an array (no raw cast)', () => { + const dart = generateFromSchema(inferSchema({ days: ['2026-07-02T00:00:00Z'] }), 'dart'); + expect(dart).toContain('List'); + expect(dart).toContain('DateTime.parse(e as String)'); + expect(dart).not.toContain('e as DateTime'); + }); + + it('Go non-object root with a date-time does not import unused time', () => { + const go = generateFromSchema(inferSchema(['2026-07-02T00:00:00Z']), 'go'); + expect(go).toContain('type Root = json.RawMessage'); + expect(go).not.toContain('"time"'); + }); + + it('Go object root with a date-time still imports time', () => { + const go = generateFromSchema(inferSchema({ created: '2026-07-02T00:00:00Z' }), 'go'); + expect(go).toContain('"time"'); + expect(go).toMatch(/Created\s+time\.Time/); + }); +}); diff --git a/apps/web/src/lib/json-schema-generator.ts b/apps/web/src/lib/json-schema-generator.ts index 6249e0e9..4631f57b 100644 --- a/apps/web/src/lib/json-schema-generator.ts +++ b/apps/web/src/lib/json-schema-generator.ts @@ -29,16 +29,43 @@ export const OUTPUT_LANGUAGE_ORDER: readonly OutputLanguage[] = [ type ScalarJson = 'string' | 'number' | 'integer' | 'boolean' | 'null'; +export type StringFormat = + | 'date-time' + | 'date' + | 'time' + | 'email' + | 'uri' + | 'uuid' + | 'ipv4'; + export type SchemaNode = - | { kind: 'scalar'; t: ScalarJson } + | { kind: 'scalar'; t: ScalarJson; format?: StringFormat } | { kind: 'array'; item: SchemaNode } | { kind: 'object'; fields: Record } | { kind: 'union'; variants: SchemaNode[] }; +// Anchored, specific -> general. First match wins. +const FORMAT_PATTERNS: ReadonlyArray = [ + ['uuid', /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/], + ['date-time', /^\d{4}-\d{2}-\d{2}[Tt]\d{2}:\d{2}:\d{2}(\.\d+)?([Zz]|[+-]\d{2}:\d{2})$/], + ['date', /^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])$/], + ['time', /^([01]\d|2[0-3]):[0-5]\d:[0-5]\d(\.\d+)?$/], + ['email', /^[^\s@]+@[^\s@]+\.[^\s@]+$/], + ['ipv4', /^(25[0-5]|2[0-4]\d|1?\d?\d)(\.(25[0-5]|2[0-4]\d|1?\d?\d)){3}$/], + ['uri', /^[a-zA-Z][a-zA-Z0-9+.-]*:\/\/[^\s]+$/], +]; + +export function detectFormat(value: string): StringFormat | undefined { + for (const [fmt, re] of FORMAT_PATTERNS) { + if (re.test(value)) return fmt; + } + return undefined; +} + function fingerprint(n: SchemaNode): string { switch (n.kind) { case 'scalar': - return `s:${n.t}`; + return `s:${n.t}${n.format ? ':' + n.format : ''}`; case 'array': return `a:${fingerprint(n.item)}`; case 'object': { @@ -98,7 +125,12 @@ export function mergeNodes(a: SchemaNode, b: SchemaNode): SchemaNode { if (fingerprint(a) === fingerprint(b)) return a; if (a.kind === 'scalar' && b.kind === 'scalar') { - if (a.t === b.t) return a; + if (a.t === b.t) { + if (a.t === 'string' && a.format !== b.format) { + return { kind: 'scalar', t: 'string' }; + } + return a; + } if ( (a.t === 'integer' && b.t === 'number') || (a.t === 'number' && b.t === 'integer') @@ -132,7 +164,12 @@ export function inferSchema(value: unknown): SchemaNode { return { kind: 'array', item: acc }; } const ty = typeof value; - if (ty === 'string') return { kind: 'scalar', t: 'string' }; + if (ty === 'string') { + const format = detectFormat(value as string); + return format + ? { kind: 'scalar', t: 'string', format } + : { kind: 'scalar', t: 'string' }; + } if (ty === 'boolean') return { kind: 'scalar', t: 'boolean' }; if (ty === 'number') { const n = value as number; @@ -150,10 +187,35 @@ export function inferSchema(value: unknown): SchemaNode { return { kind: 'scalar', t: 'null' }; } +export function collectFormats(node: SchemaNode): Map { + const counts = new Map(); + const walk = (n: SchemaNode): void => { + switch (n.kind) { + case 'scalar': + if (n.format) counts.set(n.format, (counts.get(n.format) ?? 0) + 1); + return; + case 'array': + walk(n.item); + return; + case 'object': + for (const { node: child } of Object.values(n.fields)) walk(child); + return; + case 'union': + n.variants.forEach(walk); + return; + } + }; + walk(node); + return counts; +} + function jsonSchemaTypeFragment(node: SchemaNode): Record { switch (node.kind) { - case 'scalar': - return { type: node.t }; + case 'scalar': { + const frag: Record = { type: node.t }; + if (node.t === 'string' && node.format) frag.format = node.format; + return frag; + } case 'array': return { type: 'array', items: toJsonSchemaFragment(node.item) }; case 'object': { @@ -267,6 +329,13 @@ function csharpSafeProp(jsonKey: string): string { return goFieldName(jsonKey); } +const PY_FORMAT_TYPES: Partial> = { + 'date-time': { type: 'datetime', module: 'datetime' }, + date: { type: 'date', module: 'datetime' }, + time: { type: 'time', module: 'datetime' }, + uuid: { type: 'UUID', module: 'uuid' }, +}; + function scalarToPython(node: SchemaNode): string | null { if (node.kind !== 'scalar') return null; switch (node.t) { @@ -307,6 +376,10 @@ function emitPythonType( path: string[], defs: Map ): string { + if (node.kind === 'scalar' && node.t === 'string' && node.format) { + const m = PY_FORMAT_TYPES[node.format]; + if (m) return m.type; + } const scalar = scalarToPython(node); if (scalar !== null) { if (node.kind === 'scalar' && node.t === 'null') return 'None'; @@ -369,8 +442,22 @@ export function generatePython(node: SchemaNode): string { .sort(([a], [b]) => a.localeCompare(b)) .map(([, code]) => code) .join('\n\n\n'); - const header = `from __future__ import annotations + // Collect stdlib imports for mapped formats actually present. + const formats = collectFormats(node); + const byModule = new Map>(); + for (const fmt of formats.keys()) { + const m = PY_FORMAT_TYPES[fmt]; + if (!m) continue; + if (!byModule.has(m.module)) byModule.set(m.module, new Set()); + byModule.get(m.module)!.add(m.type); + } + const stdlibImports = [...byModule.entries()] + .sort(([a], [b]) => a.localeCompare(b)) + .map(([mod, names]) => `from ${mod} import ${[...names].sort().join(', ')}`) + .join('\n'); + const header = `from __future__ import annotations +${stdlibImports ? '\n' + stdlibImports + '\n' : ''} from typing import Any from pydantic import BaseModel, Field @@ -454,6 +541,9 @@ export function generateGo(node: SchemaNode): string { typeStr: optional ? `*${t}` : t, extraDeps: [] as string[], }); + if (n.kind === 'scalar' && n.t === 'string' && n.format === 'date-time') { + return { typeStr: optional ? '*time.Time' : 'time.Time', extraDeps: [] }; + } if (n.kind === 'scalar') { switch (n.t) { case 'string': @@ -514,10 +604,15 @@ export function generateGo(node: SchemaNode): string { walk(node, []); + const importBlock = + node.kind === 'object' && collectFormats(node).has('date-time') + ? 'import (\n\t"encoding/json"\n\t"time"\n)' + : 'import "encoding/json"'; + if (node.kind !== 'object') { return `package main -import "encoding/json" +${importBlock} // Root wraps non-object JSON root. type Root = json.RawMessage @@ -526,7 +621,7 @@ type Root = json.RawMessage return `package main -import "encoding/json" +${importBlock} ${structs.join('\n\n')} `; @@ -608,6 +703,16 @@ ${structs.join('\n\n')} export function generateJava(node: SchemaNode): string { function jType(n: SchemaNode, path: string[], optional: boolean): string { const wrap = (s: string) => (optional ? `Optional<${s}>` : s); + if (n.kind === 'scalar' && n.t === 'string' && n.format) { + const map: Partial> = { + 'date-time': 'OffsetDateTime', + date: 'LocalDate', + time: 'LocalTime', + uuid: 'UUID', + }; + const t = map[n.format]; + if (t) return wrap(t); + } if (n.kind === 'scalar') { switch (n.t) { case 'string': @@ -673,9 +778,12 @@ export function generateJava(node: SchemaNode): string { `; } + const needsTime = ['date-time', 'date', 'time'].some((f) => + collectFormats(node).has(f as StringFormat) + ); return `import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.databind.JsonNode; -import java.util.*; +${needsTime ? 'import java.time.*;\n' : ''}import java.util.*; ${classes.join('\n\n')} `; @@ -684,6 +792,16 @@ ${classes.join('\n\n')} export function generateCSharp(node: SchemaNode): string { function csType(n: SchemaNode, path: string[], optional: boolean): string { const wrap = (s: string) => (optional ? `${s}?` : s); + if (n.kind === 'scalar' && n.t === 'string' && n.format) { + const map: Partial> = { + 'date-time': 'DateTimeOffset', + date: 'DateOnly', + time: 'TimeOnly', + uuid: 'Guid', + }; + const t = map[n.format]; + if (t) return wrap(t); + } if (n.kind === 'scalar') { switch (n.t) { case 'string': @@ -751,7 +869,10 @@ public record Root(JsonElement Value); `; } - return `using System.Collections.Generic; + const needsSystem = ['date-time', 'date', 'time', 'uuid'].some((f) => + collectFormats(node).has(f as StringFormat) + ); + return `${needsSystem ? 'using System;\n' : ''}using System.Collections.Generic; using System.Text.Json.Serialization; ${classes.join('\n\n')} @@ -760,6 +881,9 @@ ${classes.join('\n\n')} export function generateDart(node: SchemaNode): string { function dType(n: SchemaNode, path: string[]): string { + if (n.kind === 'scalar' && n.t === 'string' && n.format === 'date-time') { + return 'DateTime'; + } if (n.kind === 'scalar') { switch (n.t) { case 'string': @@ -793,6 +917,9 @@ export function generateDart(node: SchemaNode): string { jsonAccess: string ): string { if (child.kind === 'scalar') { + if (child.t === 'string' && child.format === 'date-time') { + return `DateTime.parse(${jsonAccess} as String)`; + } switch (child.t) { case 'string': return `${jsonAccess} as String`; @@ -814,8 +941,7 @@ export function generateDart(node: SchemaNode): string { const itemName = typeNameForPath(itemPath); return `(${jsonAccess} as List).map((e) => ${itemName}.fromJson(e as Map)).toList()`; } - const inner = dType(item, itemPath); - return `(${jsonAccess} as List).map((e) => e as ${inner}).toList()`; + return `(${jsonAccess} as List).map((e) => ${dartParseExpr(item, itemPath, 'e')}).toList()`; } if (child.kind === 'object') { const nm = typeNameForPath(fieldPath); @@ -885,6 +1011,10 @@ ${fromJsonLines.join('\n')} export function generateSwift(node: SchemaNode): string { function swType(n: SchemaNode, path: string[], optional: boolean): string { const opt = optional ? '?' : ''; + if (n.kind === 'scalar' && n.t === 'string' && n.format) { + if (n.format === 'date-time') return `Date${opt}`; + if (n.format === 'uuid') return `UUID${opt}`; + } if (n.kind === 'scalar') { switch (n.t) { case 'string': @@ -951,9 +1081,14 @@ ${props.join('\n')} return `// Non-object JSON root: decode as Array, Dictionary, or a scalar using JSONSerialization.\n`; } + const hasDate = collectFormats(node).has('date-time'); return `import Foundation -// Note: fields inferred as mixed or null-only types use String as a placeholder — adjust before production use. +// Note: fields inferred as mixed or null-only types use String as a placeholder — adjust before production use.${ + hasDate + ? '\n// Date fields expect ISO 8601 — set JSONDecoder().dateDecodingStrategy = .iso8601.' + : '' + } ${structs.join('\n\n')} `;