From 6125307952e1186dc9df5f3c53ba484329307856 Mon Sep 17 00:00:00 2001 From: Lucian Fialho Date: Tue, 17 Mar 2026 23:25:25 -0300 Subject: [PATCH 1/5] feat: add YAML support Add YAML parsing, serialization, and schema resolution to the core package, and update the VS Code extension to handle .yaml/.yml files alongside JSON. Core changes: - Add fromYaml/toYaml adapters that reuse the existing tree model - Add jsonValueToYaml/yamlToJsonValue utility functions - Add YAML known schemas (Docker Compose, GitHub Actions, GitLab CI, etc.) - Add glob-based schema matching for workflow files VS Code extension changes: - Register custom editor for *.yaml and *.yml files - Detect format from filename and use appropriate parser/serializer - Update sidebar panel to sync with YAML files - Add yaml dependency for parsing Tests: - Add 10 tests covering YAML round-trips, tree structure, and utilities --- apps/vscode/package.json | 11 ++- apps/vscode/src/custom-editor-provider.ts | 28 ++++++- apps/vscode/src/extension.ts | 5 +- apps/vscode/src/panel-provider.ts | 10 ++- apps/vscode/webview/App.tsx | 23 +++-- packages/@visual-json/core/package.json | 3 +- .../core/src/__tests__/yaml.test.ts | 84 +++++++++++++++++++ packages/@visual-json/core/src/index.ts | 2 + packages/@visual-json/core/src/schema.ts | 40 +++++++++ packages/@visual-json/core/src/yaml.ts | 40 +++++++++ pnpm-lock.yaml | 6 ++ 11 files changed, 237 insertions(+), 15 deletions(-) create mode 100644 packages/@visual-json/core/src/__tests__/yaml.test.ts create mode 100644 packages/@visual-json/core/src/yaml.ts diff --git a/apps/vscode/package.json b/apps/vscode/package.json index 6e015ab..6ce6674 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,7 +68,8 @@ "dependencies": { "@visual-json/core": "workspace:*", "@visual-json/react": "workspace:*", - "jsonc-parser": "^3.3.1" + "jsonc-parser": "^3.3.1", + "yaml": "^2.7.1" }, "devDependencies": { "@types/react": "^19.2.14", diff --git a/apps/vscode/src/custom-editor-provider.ts b/apps/vscode/src/custom-editor-provider.ts index a136536..2ded4b7 100644 --- a/apps/vscode/src/custom-editor-provider.ts +++ b/apps/vscode/src/custom-editor-provider.ts @@ -1,12 +1,31 @@ import * as vscode from "vscode"; import { resolveSchema } from "@visual-json/core"; import { parse as parseJsonc } from "jsonc-parser"; +import { parse as parseYaml, stringify as stringifyYaml } from "yaml"; import { getWebviewHtml, type HostToWebviewMessage, type WebviewToHostMessage, } from "./webview-utils"; +function isYamlFile(filename: string): boolean { + return filename.endsWith(".yaml") || filename.endsWith(".yml"); +} + +function parseContent(text: string, filename: string): unknown { + if (isYamlFile(filename)) { + return parseYaml(text); + } + return parseJsonc(text); +} + +function serializeContent(value: unknown, filename: string): string { + if (isYamlFile(filename)) { + return stringifyYaml(value, { lineWidth: 0 }); + } + return JSON.stringify(value, null, 2); +} + export class VisualJsonEditorProvider implements vscode.CustomTextEditorProvider { @@ -32,9 +51,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 +87,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..bf3c488 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 { parse as parseYaml } from "yaml"; import { getWebviewHtml, type HostToWebviewMessage, @@ -55,7 +56,11 @@ export class VisualJsonPanelProvider implements vscode.WebviewViewProvider { } case "requestSchema": { try { - const parsed = parseJsonc(msg.json); + const isYaml = + msg.filename.endsWith(".yaml") || msg.filename.endsWith(".yml"); + const parsed = isYaml + ? parseYaml(msg.json) + : parseJsonc(msg.json); const schema = await resolveSchema(parsed, msg.filename); const result: HostToWebviewMessage = { type: "schemaResult", @@ -109,7 +114,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..ed5c65e 100644 --- a/apps/vscode/webview/App.tsx +++ b/apps/vscode/webview/App.tsx @@ -8,8 +8,13 @@ import { import type { JsonValue, JsonSchema } from "@visual-json/core"; import { JsonEditor } from "@visual-json/react"; import { parse as parseJsonc } from "jsonc-parser"; +import { parse as parseYaml, stringify as stringifyYaml } from "yaml"; import { vscode } from "./vscode"; +function isYamlFile(filename: string): boolean { + return filename.endsWith(".yaml") || filename.endsWith(".yml"); +} + const VSCODE_THEME_STYLE: CSSProperties = { "--vj-bg": "var(--vscode-editor-background, #1e1e1e)", "--vj-bg-panel": "var(--vscode-sideBar-background, #252526)", @@ -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) + ? parseYaml(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) + ? stringifyYaml(value, { lineWidth: 0 }) + : 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/packages/@visual-json/core/package.json b/packages/@visual-json/core/package.json index 1371a09..3649d1c 100644 --- a/packages/@visual-json/core/package.json +++ b/packages/@visual-json/core/package.json @@ -35,6 +35,7 @@ }, "devDependencies": { "tsup": "^8.5.1", - "typescript": "5.9.3" + "typescript": "5.9.3", + "yaml": "^2.8.2" } } diff --git a/packages/@visual-json/core/src/__tests__/yaml.test.ts b/packages/@visual-json/core/src/__tests__/yaml.test.ts new file mode 100644 index 0000000..1e0eb48 --- /dev/null +++ b/packages/@visual-json/core/src/__tests__/yaml.test.ts @@ -0,0 +1,84 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { fromYaml, toYaml, jsonValueToYaml, yamlToJsonValue } from "../yaml"; +import { resetIdCounter, toJson, findNodeByPath } from "../tree"; + +beforeEach(() => { + resetIdCounter(); +}); + +describe("fromYaml / toYaml round-trip", () => { + it("round-trips a flat object", async () => { + const yaml = "name: app\nversion: 1.0.0\n"; + const state = await fromYaml(yaml); + const result = await toYaml(state); + expect(result).toBe(yaml); + }); + + it("round-trips a nested object", async () => { + const yaml = "server:\n host: localhost\n port: 3000\n"; + const state = await fromYaml(yaml); + expect(toJson(state.root)).toEqual({ + server: { host: "localhost", port: 3000 }, + }); + }); + + it("round-trips an array", async () => { + const yaml = "- one\n- two\n- three\n"; + const state = await fromYaml(yaml); + expect(toJson(state.root)).toEqual(["one", "two", "three"]); + }); + + it("round-trips mixed types", async () => { + const yaml = "string: hello\nnumber: 42\nbool: true\nnull_val: null\n"; + const state = await fromYaml(yaml); + const value = toJson(state.root); + expect(value).toEqual({ + string: "hello", + number: 42, + bool: true, + null_val: null, + }); + }); + + it("handles empty YAML as empty object", async () => { + const state = await fromYaml(""); + expect(toJson(state.root)).toEqual({}); + }); +}); + +describe("tree structure from YAML", () => { + it("creates correct paths", async () => { + const yaml = "a:\n b: 1\n"; + const state = await fromYaml(yaml); + const node = findNodeByPath(state, "/a/b"); + expect(node).toBeDefined(); + expect(node!.value).toBe(1); + }); + + it("indexes all nodes", async () => { + const yaml = "x: 1\ny:\n z: 2\n"; + const state = await fromYaml(yaml); + // root + x + y + z = 4 + expect(state.nodesById.size).toBe(4); + }); +}); + +describe("jsonValueToYaml", () => { + it("serializes an object to YAML", async () => { + const result = await jsonValueToYaml({ name: "test", count: 5 }); + expect(result).toContain("name: test"); + expect(result).toContain("count: 5"); + }); +}); + +describe("yamlToJsonValue", () => { + it("parses YAML to a plain object", async () => { + const result = await yamlToJsonValue("name: test\ncount: 5\n"); + expect(result).toEqual({ name: "test", count: 5 }); + }); + + it("returns empty object for empty input", async () => { + const result = await yamlToJsonValue(""); + expect(result).toEqual({}); + }); +}); diff --git a/packages/@visual-json/core/src/index.ts b/packages/@visual-json/core/src/index.ts index be2c612..8b3b379 100644 --- a/packages/@visual-json/core/src/index.ts +++ b/packages/@visual-json/core/src/index.ts @@ -56,3 +56,5 @@ export { type DiffEntry, type DiffType, } from "./diff"; + +export { fromYaml, toYaml, jsonValueToYaml, yamlToJsonValue } from "./yaml"; 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/core/src/yaml.ts b/packages/@visual-json/core/src/yaml.ts new file mode 100644 index 0000000..5eeee44 --- /dev/null +++ b/packages/@visual-json/core/src/yaml.ts @@ -0,0 +1,40 @@ +import type { JsonValue, TreeState } from "./types"; +import { fromJson, toJson } from "./tree"; + +/** + * Parse a YAML string into a TreeState. + * + * Uses dynamic import so the `yaml` package is only loaded when YAML + * features are actually used — keeping the core bundle lightweight for + * JSON-only consumers. + */ +export async function fromYaml(yamlText: string): Promise { + const { parse } = await import("yaml"); + const value = parse(yamlText) as JsonValue; + return fromJson(value ?? {}); +} + +/** + * Serialize a TreeState back to a YAML string. + */ +export async function toYaml(state: TreeState): Promise { + const { stringify } = await import("yaml"); + const value = toJson(state.root); + return stringify(value, { lineWidth: 0 }); +} + +/** + * Convert a plain JsonValue to a YAML string. + */ +export async function jsonValueToYaml(value: JsonValue): Promise { + const { stringify } = await import("yaml"); + return stringify(value, { lineWidth: 0 }); +} + +/** + * Parse a YAML string to a plain JsonValue. + */ +export async function yamlToJsonValue(yamlText: string): Promise { + const { parse } = await import("yaml"); + return (parse(yamlText) as JsonValue) ?? {}; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b579ded..38520b8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -56,6 +56,9 @@ importers: jsonc-parser: specifier: ^3.3.1 version: 3.3.1 + yaml: + specifier: ^2.7.1 + version: 2.8.2 devDependencies: '@types/react': specifier: ^19.2.14 @@ -329,6 +332,9 @@ importers: typescript: specifier: 5.9.3 version: 5.9.3 + yaml: + specifier: ^2.8.2 + version: 2.8.2 packages/@visual-json/react: dependencies: From 20fe63605e55d49ead72c7a50957bc87c74a43d9 Mon Sep 17 00:00:00 2001 From: Lucian Fialho Date: Wed, 25 Mar 2026 18:53:26 -0300 Subject: [PATCH 2/5] refactor(yaml): move YAML support to dedicated @visual-json/yaml package - Create packages/@visual-json/yaml with fromYaml, toYaml, isYamlFile, parseYamlContent, and stringifyYamlContent (all synchronous) - Remove yaml.ts and yaml tests from @visual-json/core; remove yaml dep - Update vscode extension to import from @visual-json/yaml instead of direct yaml package (custom-editor-provider, panel-provider, App.tsx) - Add @visual-json/yaml alias to esbuild.mjs for both bundles - Add 12 unit tests covering isYamlFile, round-trips, and tree ops --- apps/vscode/esbuild.mjs | 1 + apps/vscode/package.json | 4 +- apps/vscode/src/custom-editor-provider.ts | 19 ++-- apps/vscode/src/panel-provider.ts | 8 +- apps/vscode/webview/App.tsx | 14 +-- packages/@visual-json/core/package.json | 3 +- .../core/src/__tests__/yaml.test.ts | 84 ----------------- packages/@visual-json/core/src/index.ts | 2 - packages/@visual-json/core/src/yaml.ts | 40 --------- packages/@visual-json/yaml/package.json | 42 +++++++++ .../yaml/src/__tests__/yaml.test.ts | 89 +++++++++++++++++++ packages/@visual-json/yaml/src/index.ts | 38 ++++++++ packages/@visual-json/yaml/tsconfig.json | 9 ++ packages/@visual-json/yaml/tsup.config.ts | 9 ++ pnpm-lock.yaml | 25 ++++-- 15 files changed, 226 insertions(+), 161 deletions(-) delete mode 100644 packages/@visual-json/core/src/__tests__/yaml.test.ts delete mode 100644 packages/@visual-json/core/src/yaml.ts create mode 100644 packages/@visual-json/yaml/package.json create mode 100644 packages/@visual-json/yaml/src/__tests__/yaml.test.ts create mode 100644 packages/@visual-json/yaml/src/index.ts create mode 100644 packages/@visual-json/yaml/tsconfig.json create mode 100644 packages/@visual-json/yaml/tsup.config.ts 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 6ce6674..bf3e9c9 100644 --- a/apps/vscode/package.json +++ b/apps/vscode/package.json @@ -68,8 +68,8 @@ "dependencies": { "@visual-json/core": "workspace:*", "@visual-json/react": "workspace:*", - "jsonc-parser": "^3.3.1", - "yaml": "^2.7.1" + "@visual-json/yaml": "workspace:*", + "jsonc-parser": "^3.3.1" }, "devDependencies": { "@types/react": "^19.2.14", diff --git a/apps/vscode/src/custom-editor-provider.ts b/apps/vscode/src/custom-editor-provider.ts index 2ded4b7..4cf66b3 100644 --- a/apps/vscode/src/custom-editor-provider.ts +++ b/apps/vscode/src/custom-editor-provider.ts @@ -1,31 +1,24 @@ import * as vscode from "vscode"; import { resolveSchema } from "@visual-json/core"; import { parse as parseJsonc } from "jsonc-parser"; -import { parse as parseYaml, stringify as stringifyYaml } from "yaml"; +import { + isYamlFile, + parseYamlContent, + stringifyYamlContent, +} from "@visual-json/yaml"; import { getWebviewHtml, type HostToWebviewMessage, type WebviewToHostMessage, } from "./webview-utils"; -function isYamlFile(filename: string): boolean { - return filename.endsWith(".yaml") || filename.endsWith(".yml"); -} - function parseContent(text: string, filename: string): unknown { if (isYamlFile(filename)) { - return parseYaml(text); + return parseYamlContent(text); } return parseJsonc(text); } -function serializeContent(value: unknown, filename: string): string { - if (isYamlFile(filename)) { - return stringifyYaml(value, { lineWidth: 0 }); - } - return JSON.stringify(value, null, 2); -} - export class VisualJsonEditorProvider implements vscode.CustomTextEditorProvider { diff --git a/apps/vscode/src/panel-provider.ts b/apps/vscode/src/panel-provider.ts index bf3c488..d039cf8 100644 --- a/apps/vscode/src/panel-provider.ts +++ b/apps/vscode/src/panel-provider.ts @@ -1,7 +1,7 @@ import * as vscode from "vscode"; import { resolveSchema } from "@visual-json/core"; import { parse as parseJsonc } from "jsonc-parser"; -import { parse as parseYaml } from "yaml"; +import { isYamlFile, parseYamlContent } from "@visual-json/yaml"; import { getWebviewHtml, type HostToWebviewMessage, @@ -56,10 +56,8 @@ export class VisualJsonPanelProvider implements vscode.WebviewViewProvider { } case "requestSchema": { try { - const isYaml = - msg.filename.endsWith(".yaml") || msg.filename.endsWith(".yml"); - const parsed = isYaml - ? parseYaml(msg.json) + const parsed = isYamlFile(msg.filename) + ? parseYamlContent(msg.json) : parseJsonc(msg.json); const schema = await resolveSchema(parsed, msg.filename); const result: HostToWebviewMessage = { diff --git a/apps/vscode/webview/App.tsx b/apps/vscode/webview/App.tsx index ed5c65e..973466c 100644 --- a/apps/vscode/webview/App.tsx +++ b/apps/vscode/webview/App.tsx @@ -8,13 +8,13 @@ import { import type { JsonValue, JsonSchema } from "@visual-json/core"; import { JsonEditor } from "@visual-json/react"; import { parse as parseJsonc } from "jsonc-parser"; -import { parse as parseYaml, stringify as stringifyYaml } from "yaml"; +import { + isYamlFile, + parseYamlContent, + stringifyYamlContent, +} from "@visual-json/yaml"; import { vscode } from "./vscode"; -function isYamlFile(filename: string): boolean { - return filename.endsWith(".yaml") || filename.endsWith(".yml"); -} - const VSCODE_THEME_STYLE: CSSProperties = { "--vj-bg": "var(--vscode-editor-background, #1e1e1e)", "--vj-bg-panel": "var(--vscode-sideBar-background, #252526)", @@ -87,7 +87,7 @@ export function App() { lastJsonRef.current = msg.json; filenameRef.current = msg.filename; const parsed = isYamlFile(msg.filename) - ? parseYaml(msg.json) + ? parseYamlContent(msg.json) : parseJsonc(msg.json); setJsonValue(parsed); setParseError(null); @@ -123,7 +123,7 @@ export function App() { if (editTimerRef.current !== null) clearTimeout(editTimerRef.current); editTimerRef.current = setTimeout(() => { const json = isYamlFile(filenameRef.current) - ? stringifyYaml(value, { lineWidth: 0 }) + ? stringifyYamlContent(value) : JSON.stringify(value, null, 2); lastJsonRef.current = json; suppressEditRef.current = true; diff --git a/packages/@visual-json/core/package.json b/packages/@visual-json/core/package.json index 3649d1c..1371a09 100644 --- a/packages/@visual-json/core/package.json +++ b/packages/@visual-json/core/package.json @@ -35,7 +35,6 @@ }, "devDependencies": { "tsup": "^8.5.1", - "typescript": "5.9.3", - "yaml": "^2.8.2" + "typescript": "5.9.3" } } diff --git a/packages/@visual-json/core/src/__tests__/yaml.test.ts b/packages/@visual-json/core/src/__tests__/yaml.test.ts deleted file mode 100644 index 1e0eb48..0000000 --- a/packages/@visual-json/core/src/__tests__/yaml.test.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { describe, it, expect, beforeEach } from "vitest"; -import { fromYaml, toYaml, jsonValueToYaml, yamlToJsonValue } from "../yaml"; -import { resetIdCounter, toJson, findNodeByPath } from "../tree"; - -beforeEach(() => { - resetIdCounter(); -}); - -describe("fromYaml / toYaml round-trip", () => { - it("round-trips a flat object", async () => { - const yaml = "name: app\nversion: 1.0.0\n"; - const state = await fromYaml(yaml); - const result = await toYaml(state); - expect(result).toBe(yaml); - }); - - it("round-trips a nested object", async () => { - const yaml = "server:\n host: localhost\n port: 3000\n"; - const state = await fromYaml(yaml); - expect(toJson(state.root)).toEqual({ - server: { host: "localhost", port: 3000 }, - }); - }); - - it("round-trips an array", async () => { - const yaml = "- one\n- two\n- three\n"; - const state = await fromYaml(yaml); - expect(toJson(state.root)).toEqual(["one", "two", "three"]); - }); - - it("round-trips mixed types", async () => { - const yaml = "string: hello\nnumber: 42\nbool: true\nnull_val: null\n"; - const state = await fromYaml(yaml); - const value = toJson(state.root); - expect(value).toEqual({ - string: "hello", - number: 42, - bool: true, - null_val: null, - }); - }); - - it("handles empty YAML as empty object", async () => { - const state = await fromYaml(""); - expect(toJson(state.root)).toEqual({}); - }); -}); - -describe("tree structure from YAML", () => { - it("creates correct paths", async () => { - const yaml = "a:\n b: 1\n"; - const state = await fromYaml(yaml); - const node = findNodeByPath(state, "/a/b"); - expect(node).toBeDefined(); - expect(node!.value).toBe(1); - }); - - it("indexes all nodes", async () => { - const yaml = "x: 1\ny:\n z: 2\n"; - const state = await fromYaml(yaml); - // root + x + y + z = 4 - expect(state.nodesById.size).toBe(4); - }); -}); - -describe("jsonValueToYaml", () => { - it("serializes an object to YAML", async () => { - const result = await jsonValueToYaml({ name: "test", count: 5 }); - expect(result).toContain("name: test"); - expect(result).toContain("count: 5"); - }); -}); - -describe("yamlToJsonValue", () => { - it("parses YAML to a plain object", async () => { - const result = await yamlToJsonValue("name: test\ncount: 5\n"); - expect(result).toEqual({ name: "test", count: 5 }); - }); - - it("returns empty object for empty input", async () => { - const result = await yamlToJsonValue(""); - expect(result).toEqual({}); - }); -}); diff --git a/packages/@visual-json/core/src/index.ts b/packages/@visual-json/core/src/index.ts index 8b3b379..be2c612 100644 --- a/packages/@visual-json/core/src/index.ts +++ b/packages/@visual-json/core/src/index.ts @@ -56,5 +56,3 @@ export { type DiffEntry, type DiffType, } from "./diff"; - -export { fromYaml, toYaml, jsonValueToYaml, yamlToJsonValue } from "./yaml"; diff --git a/packages/@visual-json/core/src/yaml.ts b/packages/@visual-json/core/src/yaml.ts deleted file mode 100644 index 5eeee44..0000000 --- a/packages/@visual-json/core/src/yaml.ts +++ /dev/null @@ -1,40 +0,0 @@ -import type { JsonValue, TreeState } from "./types"; -import { fromJson, toJson } from "./tree"; - -/** - * Parse a YAML string into a TreeState. - * - * Uses dynamic import so the `yaml` package is only loaded when YAML - * features are actually used — keeping the core bundle lightweight for - * JSON-only consumers. - */ -export async function fromYaml(yamlText: string): Promise { - const { parse } = await import("yaml"); - const value = parse(yamlText) as JsonValue; - return fromJson(value ?? {}); -} - -/** - * Serialize a TreeState back to a YAML string. - */ -export async function toYaml(state: TreeState): Promise { - const { stringify } = await import("yaml"); - const value = toJson(state.root); - return stringify(value, { lineWidth: 0 }); -} - -/** - * Convert a plain JsonValue to a YAML string. - */ -export async function jsonValueToYaml(value: JsonValue): Promise { - const { stringify } = await import("yaml"); - return stringify(value, { lineWidth: 0 }); -} - -/** - * Parse a YAML string to a plain JsonValue. - */ -export async function yamlToJsonValue(yamlText: string): Promise { - const { parse } = await import("yaml"); - return (parse(yamlText) as JsonValue) ?? {}; -} 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 38520b8..7976e99 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -53,12 +53,12 @@ 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 - yaml: - specifier: ^2.7.1 - version: 2.8.2 devDependencies: '@types/react': specifier: ^19.2.14 @@ -332,9 +332,6 @@ importers: typescript: specifier: 5.9.3 version: 5.9.3 - yaml: - specifier: ^2.8.2 - version: 2.8.2 packages/@visual-json/react: dependencies: @@ -417,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': From 5f701d838502e0a67bfa7c4a4884442a3139f9fc Mon Sep 17 00:00:00 2001 From: ctate <366502+ctate@users.noreply.github.com> Date: Wed, 8 Apr 2026 15:21:08 +0000 Subject: [PATCH 3/5] fix(vscode): remove unused stringifyYamlContent import --- apps/vscode/src/custom-editor-provider.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/apps/vscode/src/custom-editor-provider.ts b/apps/vscode/src/custom-editor-provider.ts index 4cf66b3..101b4e9 100644 --- a/apps/vscode/src/custom-editor-provider.ts +++ b/apps/vscode/src/custom-editor-provider.ts @@ -1,11 +1,7 @@ import * as vscode from "vscode"; import { resolveSchema } from "@visual-json/core"; import { parse as parseJsonc } from "jsonc-parser"; -import { - isYamlFile, - parseYamlContent, - stringifyYamlContent, -} from "@visual-json/yaml"; +import { isYamlFile, parseYamlContent } from "@visual-json/yaml"; import { getWebviewHtml, type HostToWebviewMessage, From 65fdfefcf3615e77ebb16f6acde60d82d50605df Mon Sep 17 00:00:00 2001 From: ctate <366502+ctate@users.noreply.github.com> Date: Wed, 8 Apr 2026 15:26:29 +0000 Subject: [PATCH 4/5] fix(web): pass --no-tls -p 1355 to portless in playwright config portless now requires sudo for TLS on port 443 by default, which breaks CI. Use --no-tls -p 1355 so the proxy starts without elevated privileges. --- apps/web/playwright.config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/playwright.config.ts b/apps/web/playwright.config.ts index 64d076a..cfe2860 100644 --- a/apps/web/playwright.config.ts +++ b/apps/web/playwright.config.ts @@ -17,7 +17,7 @@ export default defineConfig({ }, ], webServer: { - command: "portless visual-json next dev --turbopack", + command: "portless visual-json --no-tls -p 1355 next dev --turbopack", url: "http://visual-json.localhost:1355", reuseExistingServer: !process.env.CI, }, From d7b224f7efc14f6956bdd1182c19885c67e43e71 Mon Sep 17 00:00:00 2001 From: ctate <366502+ctate@users.noreply.github.com> Date: Wed, 8 Apr 2026 15:27:58 +0000 Subject: [PATCH 5/5] fix(web): start portless proxy before dev server in playwright config portless now requires sudo for TLS on port 443 by default, which breaks CI. Start the proxy with --no-tls -p 1355 before the dev server so it works without elevated privileges. --- apps/web/playwright.config.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/web/playwright.config.ts b/apps/web/playwright.config.ts index cfe2860..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 --no-tls -p 1355 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, },