diff --git a/apps/vscode/esbuild.mjs b/apps/vscode/esbuild.mjs index b2f4d58..9e3f2b1 100644 --- a/apps/vscode/esbuild.mjs +++ b/apps/vscode/esbuild.mjs @@ -10,6 +10,7 @@ const isWatch = process.argv.includes("--watch"); const workspaceAliases = { "@visual-json/core": path.resolve(__dirname, "../../packages/@visual-json/core/src/index.ts"), "@visual-json/react": path.resolve(__dirname, "../../packages/@visual-json/react/src/index.ts"), + "@visual-json/yaml": path.resolve(__dirname, "../../packages/@visual-json/yaml/src/index.ts"), }; /** @type {import('esbuild').BuildOptions} */ diff --git a/apps/vscode/package.json b/apps/vscode/package.json index 6e015ab..bf3e9c9 100644 --- a/apps/vscode/package.json +++ b/apps/vscode/package.json @@ -29,6 +29,12 @@ }, { "filenamePattern": "*.jsonc" + }, + { + "filenamePattern": "*.yaml" + }, + { + "filenamePattern": "*.yml" } ], "priority": "option" @@ -40,7 +46,7 @@ "type": "webview", "id": "visualJson.panel", "name": "visual-json", - "when": "resourceLangId == json || resourceLangId == jsonc" + "when": "resourceLangId == json || resourceLangId == jsonc || resourceLangId == yaml" } ] }, @@ -62,6 +68,7 @@ "dependencies": { "@visual-json/core": "workspace:*", "@visual-json/react": "workspace:*", + "@visual-json/yaml": "workspace:*", "jsonc-parser": "^3.3.1" }, "devDependencies": { diff --git a/apps/vscode/src/custom-editor-provider.ts b/apps/vscode/src/custom-editor-provider.ts index a136536..101b4e9 100644 --- a/apps/vscode/src/custom-editor-provider.ts +++ b/apps/vscode/src/custom-editor-provider.ts @@ -1,12 +1,20 @@ import * as vscode from "vscode"; import { resolveSchema } from "@visual-json/core"; import { parse as parseJsonc } from "jsonc-parser"; +import { isYamlFile, parseYamlContent } from "@visual-json/yaml"; import { getWebviewHtml, type HostToWebviewMessage, type WebviewToHostMessage, } from "./webview-utils"; +function parseContent(text: string, filename: string): unknown { + if (isYamlFile(filename)) { + return parseYamlContent(text); + } + return parseJsonc(text); +} + export class VisualJsonEditorProvider implements vscode.CustomTextEditorProvider { @@ -32,9 +40,9 @@ export class VisualJsonEditorProvider ); let suppressNextEdit = false; + const filename = document.uri.path.split("/").pop() ?? "file.json"; const sendContent = () => { - const filename = document.uri.path.split("/").pop() ?? "file.json"; const msg: HostToWebviewMessage = { type: "setContent", json: document.getText(), @@ -68,8 +76,11 @@ export class VisualJsonEditorProvider } case "requestSchema": { try { - const parsed = parseJsonc(msg.json); - const schema = await resolveSchema(parsed, msg.filename); + const parsed = parseContent(msg.json, msg.filename); + const schema = await resolveSchema( + parsed as Parameters[0], + msg.filename, + ); const result: HostToWebviewMessage = { type: "schemaResult", schema, diff --git a/apps/vscode/src/extension.ts b/apps/vscode/src/extension.ts index c3345a4..f848b50 100644 --- a/apps/vscode/src/extension.ts +++ b/apps/vscode/src/extension.ts @@ -31,7 +31,8 @@ export function activate(context: vscode.ExtensionContext) { if ( activeEditor && (activeEditor.document.languageId === "json" || - activeEditor.document.languageId === "jsonc") + activeEditor.document.languageId === "jsonc" || + activeEditor.document.languageId === "yaml") ) { vscode.commands.executeCommand( "vscode.openWith", @@ -40,7 +41,7 @@ export function activate(context: vscode.ExtensionContext) { ); } else { vscode.window.showInformationMessage( - "Open a JSON file first to use visual-json.", + "Open a JSON or YAML file first to use visual-json.", ); } }), diff --git a/apps/vscode/src/panel-provider.ts b/apps/vscode/src/panel-provider.ts index 8e17a53..d039cf8 100644 --- a/apps/vscode/src/panel-provider.ts +++ b/apps/vscode/src/panel-provider.ts @@ -1,6 +1,7 @@ import * as vscode from "vscode"; import { resolveSchema } from "@visual-json/core"; import { parse as parseJsonc } from "jsonc-parser"; +import { isYamlFile, parseYamlContent } from "@visual-json/yaml"; import { getWebviewHtml, type HostToWebviewMessage, @@ -55,7 +56,9 @@ export class VisualJsonPanelProvider implements vscode.WebviewViewProvider { } case "requestSchema": { try { - const parsed = parseJsonc(msg.json); + const parsed = isYamlFile(msg.filename) + ? parseYamlContent(msg.json) + : parseJsonc(msg.json); const schema = await resolveSchema(parsed, msg.filename); const result: HostToWebviewMessage = { type: "schemaResult", @@ -109,7 +112,8 @@ export class VisualJsonPanelProvider implements vscode.WebviewViewProvider { if ( !editor || (editor.document.languageId !== "json" && - editor.document.languageId !== "jsonc") + editor.document.languageId !== "jsonc" && + editor.document.languageId !== "yaml") ) { this.currentDocumentUri = undefined; return; diff --git a/apps/vscode/webview/App.tsx b/apps/vscode/webview/App.tsx index 87b90b6..973466c 100644 --- a/apps/vscode/webview/App.tsx +++ b/apps/vscode/webview/App.tsx @@ -8,6 +8,11 @@ import { import type { JsonValue, JsonSchema } from "@visual-json/core"; import { JsonEditor } from "@visual-json/react"; import { parse as parseJsonc } from "jsonc-parser"; +import { + isYamlFile, + parseYamlContent, + stringifyYamlContent, +} from "@visual-json/yaml"; import { vscode } from "./vscode"; const VSCODE_THEME_STYLE: CSSProperties = { @@ -66,6 +71,7 @@ export function App() { const suppressEditRef = useRef(false); const lastJsonRef = useRef(""); const editTimerRef = useRef | null>(null); + const filenameRef = useRef("file.json"); useEffect(() => { const handler = (event: MessageEvent) => { @@ -79,7 +85,10 @@ export function App() { try { if (msg.json === lastJsonRef.current) return; lastJsonRef.current = msg.json; - const parsed = parseJsonc(msg.json); + filenameRef.current = msg.filename; + const parsed = isYamlFile(msg.filename) + ? parseYamlContent(msg.json) + : parseJsonc(msg.json); setJsonValue(parsed); setParseError(null); @@ -89,7 +98,9 @@ export function App() { filename: msg.filename, }); } catch (err) { - setParseError(err instanceof Error ? err.message : "Invalid JSON"); + setParseError( + err instanceof Error ? err.message : "Invalid content", + ); } break; } @@ -111,7 +122,9 @@ export function App() { setJsonValue(value); if (editTimerRef.current !== null) clearTimeout(editTimerRef.current); editTimerRef.current = setTimeout(() => { - const json = JSON.stringify(value, null, 2); + const json = isYamlFile(filenameRef.current) + ? stringifyYamlContent(value) + : JSON.stringify(value, null, 2); lastJsonRef.current = json; suppressEditRef.current = true; vscode.postMessage({ type: "edit", json }); @@ -122,7 +135,7 @@ export function App() { return (
!
-
Cannot parse JSON
+
Cannot parse file
{parseError}
); @@ -131,7 +144,7 @@ export function App() { if (jsonValue === null) { return (
- Loading JSON... + Loading...
); } diff --git a/apps/web/playwright.config.ts b/apps/web/playwright.config.ts index 64d076a..9c904ca 100644 --- a/apps/web/playwright.config.ts +++ b/apps/web/playwright.config.ts @@ -17,7 +17,8 @@ export default defineConfig({ }, ], webServer: { - command: "portless visual-json next dev --turbopack", + command: + "portless proxy start --no-tls -p 1355 && portless visual-json next dev --turbopack", url: "http://visual-json.localhost:1355", reuseExistingServer: !process.env.CI, }, diff --git a/packages/@visual-json/core/src/schema.ts b/packages/@visual-json/core/src/schema.ts index 905ab2c..21d4b63 100644 --- a/packages/@visual-json/core/src/schema.ts +++ b/packages/@visual-json/core/src/schema.ts @@ -17,6 +17,36 @@ const KNOWN_SCHEMAS: Record = { "nest-cli.json": "https://json.schemastore.org/nest-cli", "vercel.json": "https://openapi.vercel.sh/vercel.json", ".swcrc": "https://json.schemastore.org/swcrc", + + // YAML + "docker-compose.yml": "https://json.schemastore.org/docker-compose.json", + "docker-compose.yaml": "https://json.schemastore.org/docker-compose.json", + ".gitlab-ci.yml": "https://json.schemastore.org/gitlab-ci.json", + "mkdocs.yml": "https://json.schemastore.org/mkdocs-1.0.json", + ".pre-commit-config.yaml": + "https://json.schemastore.org/pre-commit-config.json", + "pubspec.yaml": "https://json.schemastore.org/pubspec.json", + ".eslintrc.yml": "https://json.schemastore.org/eslintrc.json", + ".eslintrc.yaml": "https://json.schemastore.org/eslintrc.json", + ".prettierrc.yml": "https://json.schemastore.org/prettierrc.json", + ".prettierrc.yaml": "https://json.schemastore.org/prettierrc.json", + ".travis.yml": "https://json.schemastore.org/travis.json", + "appveyor.yml": "https://json.schemastore.org/appveyor.json", + "cloudbuild.yaml": "https://json.schemastore.org/cloudbuild.json", + "serverless.yml": "https://json.schemastore.org/serverlessframework.json", + "serverless.yaml": "https://json.schemastore.org/serverlessframework.json", + "pnpm-workspace.yaml": "https://json.schemastore.org/pnpm-workspace.json", +}; + +const KNOWN_YAML_GLOBS: Record = { + ".github/workflows/*.yml": + "https://json.schemastore.org/github-workflow.json", + ".github/workflows/*.yaml": + "https://json.schemastore.org/github-workflow.json", + ".github/actions/*/action.yml": + "https://json.schemastore.org/github-action.json", + ".github/actions/*/action.yaml": + "https://json.schemastore.org/github-action.json", }; const MAX_SCHEMA_CACHE = 50; @@ -62,6 +92,16 @@ export async function resolveSchema( if (knownUrl) { return fetchSchema(knownUrl); } + + // Glob patterns for YAML files (e.g. .github/workflows/*.yml) + for (const [pattern, url] of Object.entries(KNOWN_YAML_GLOBS)) { + const regex = new RegExp( + "^" + pattern.replace(/\*/g, "[^/]+").replace(/\//g, "\\/") + "$", + ); + if (regex.test(filename)) { + return fetchSchema(url); + } + } } return null; diff --git a/packages/@visual-json/yaml/package.json b/packages/@visual-json/yaml/package.json new file mode 100644 index 0000000..039ec9a --- /dev/null +++ b/packages/@visual-json/yaml/package.json @@ -0,0 +1,42 @@ +{ + "name": "@visual-json/yaml", + "version": "0.0.1", + "license": "Apache-2.0", + "description": "YAML support for visual-json — parse, serialize, and schema-detect YAML files.", + "keywords": [ + "yaml", + "editor", + "visual-json" + ], + "publishConfig": { + "access": "public" + }, + "main": "./dist/index.js", + "module": "./dist/index.mjs", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.mjs", + "require": "./dist/index.js" + } + }, + "files": [ + "dist" + ], + "scripts": { + "build": "tsup", + "dev": "tsup --watch", + "check-types": "tsc --noEmit", + "lint": "eslint .", + "format": "prettier --write \"**/*.{ts,tsx}\"" + }, + "dependencies": { + "@visual-json/core": "workspace:*", + "yaml": "^2.7.1" + }, + "devDependencies": { + "tsup": "^8.5.1", + "typescript": "5.9.3" + } +} diff --git a/packages/@visual-json/yaml/src/__tests__/yaml.test.ts b/packages/@visual-json/yaml/src/__tests__/yaml.test.ts new file mode 100644 index 0000000..7c3e0d3 --- /dev/null +++ b/packages/@visual-json/yaml/src/__tests__/yaml.test.ts @@ -0,0 +1,89 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { + fromYaml, + toYaml, + parseYamlContent, + stringifyYamlContent, + isYamlFile, +} from "../index"; +import { resetIdCounter, toJson, findNodeByPath } from "@visual-json/core"; + +beforeEach(() => { + resetIdCounter(); +}); + +describe("isYamlFile", () => { + it("returns true for .yml", () => { + expect(isYamlFile("file.yml")).toBe(true); + }); + + it("returns true for .yaml", () => { + expect(isYamlFile("docker-compose.yaml")).toBe(true); + }); + + it("returns false for .json", () => { + expect(isYamlFile("file.json")).toBe(false); + }); + + it("returns false for no extension", () => { + expect(isYamlFile("Makefile")).toBe(false); + }); +}); + +describe("parseYamlContent / stringifyYamlContent round-trip", () => { + it("round-trips a flat object", () => { + const yaml = "name: app\nversion: 1.0.0\n"; + const value = parseYamlContent(yaml); + expect(value).toEqual({ name: "app", version: "1.0.0" }); + expect(stringifyYamlContent(value)).toBe(yaml); + }); + + it("round-trips an array", () => { + const value = parseYamlContent("- one\n- two\n- three\n"); + expect(value).toEqual(["one", "two", "three"]); + }); + + it("round-trips mixed types", () => { + const value = parseYamlContent( + "string: hello\nnumber: 42\nbool: true\nnull_val: null\n", + ); + expect(value).toEqual({ + string: "hello", + number: 42, + bool: true, + null_val: null, + }); + }); + + it("returns empty object for empty input", () => { + expect(parseYamlContent("")).toEqual({}); + }); +}); + +describe("fromYaml / toYaml round-trip", () => { + it("creates correct tree structure", () => { + const state = fromYaml("a:\n b: 1\n"); + const node = findNodeByPath(state, "/a/b"); + expect(node).toBeDefined(); + expect(node!.value).toBe(1); + }); + + it("indexes all nodes", () => { + const state = fromYaml("x: 1\ny:\n z: 2\n"); + // root + x + y + z = 4 + expect(state.nodesById.size).toBe(4); + }); + + it("round-trips a nested object", () => { + const state = fromYaml("server:\n host: localhost\n port: 3000\n"); + expect(toJson(state.root)).toEqual({ + server: { host: "localhost", port: 3000 }, + }); + }); + + it("serializes back to YAML", () => { + const yaml = "name: app\nversion: 1.0.0\n"; + const state = fromYaml(yaml); + expect(toYaml(state)).toBe(yaml); + }); +}); diff --git a/packages/@visual-json/yaml/src/index.ts b/packages/@visual-json/yaml/src/index.ts new file mode 100644 index 0000000..b94e28a --- /dev/null +++ b/packages/@visual-json/yaml/src/index.ts @@ -0,0 +1,38 @@ +import { parse, stringify } from "yaml"; +import type { JsonValue, TreeState } from "@visual-json/core"; +import { fromJson, toJson } from "@visual-json/core"; + +/** + * Returns true if the filename has a YAML extension. + */ +export function isYamlFile(filename: string): boolean { + return filename.endsWith(".yaml") || filename.endsWith(".yml"); +} + +/** + * Parse a YAML string to a plain JsonValue. + */ +export function parseYamlContent(text: string): JsonValue { + return (parse(text) as JsonValue) ?? {}; +} + +/** + * Serialize a plain JsonValue to a YAML string. + */ +export function stringifyYamlContent(value: JsonValue): string { + return stringify(value, { lineWidth: 0 }); +} + +/** + * Parse a YAML string into a TreeState. + */ +export function fromYaml(text: string): TreeState { + return fromJson(parseYamlContent(text)); +} + +/** + * Serialize a TreeState back to a YAML string. + */ +export function toYaml(state: TreeState): string { + return stringifyYamlContent(toJson(state.root)); +} diff --git a/packages/@visual-json/yaml/tsconfig.json b/packages/@visual-json/yaml/tsconfig.json new file mode 100644 index 0000000..583257f --- /dev/null +++ b/packages/@visual-json/yaml/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../../tsconfig.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/@visual-json/yaml/tsup.config.ts b/packages/@visual-json/yaml/tsup.config.ts new file mode 100644 index 0000000..781ca50 --- /dev/null +++ b/packages/@visual-json/yaml/tsup.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from "tsup"; + +export default defineConfig({ + entry: ["src/index.ts"], + format: ["cjs", "esm"], + dts: true, + sourcemap: true, + clean: true, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b579ded..7976e99 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -53,6 +53,9 @@ importers: '@visual-json/react': specifier: workspace:* version: link:../../packages/@visual-json/react + '@visual-json/yaml': + specifier: workspace:* + version: link:../../packages/@visual-json/yaml jsonc-parser: specifier: ^3.3.1 version: 3.3.1 @@ -411,6 +414,22 @@ importers: specifier: ^2.2.10 version: 2.2.12(typescript@5.9.3) + packages/@visual-json/yaml: + dependencies: + '@visual-json/core': + specifier: workspace:* + version: link:../core + yaml: + specifier: ^2.7.1 + version: 2.8.2 + devDependencies: + tsup: + specifier: ^8.5.1 + version: 8.5.1(@microsoft/api-extractor@7.57.6(@types/node@25.3.0))(jiti@2.6.1)(postcss@8.5.6)(typescript@5.9.3)(yaml@2.8.2) + typescript: + specifier: 5.9.3 + version: 5.9.3 + packages: '@ai-sdk/gateway@3.0.53':