Paste or open two files
Place your original JSON or YAML on the left and the updated version on the right. Compare JSON to YAML directly. Choose the format explicitly for ambiguous YAML flow syntax or scalar values.
diff --git a/.github/workflows/lighthouse.yml b/.github/workflows/lighthouse.yml index 1a82d10..b0df957 100644 --- a/.github/workflows/lighthouse.yml +++ b/.github/workflows/lighthouse.yml @@ -27,6 +27,9 @@ jobs: - name: Test cron parsing and schedule evaluation run: node --test scripts/crontab.test.cjs + - name: Test JSON and YAML comparison + run: node --test scripts/json-diff.test.cjs + - name: Validate HTML uses: Cyb3r-Jak3/html5validator-action@v7.2.0 with: @@ -51,6 +54,7 @@ jobs: kubernetes-rbac-explorer/index.html visual-subnet-calculator/index.html yaml-formatter/index.html + json-diff/index.html crontab/index.html terraform-modules/index.html fail: true @@ -76,5 +80,6 @@ jobs: http://127.0.0.1:8080/kubernetes-rbac-explorer/ http://127.0.0.1:8080/visual-subnet-calculator/ http://127.0.0.1:8080/yaml-formatter/ + http://127.0.0.1:8080/json-diff/ http://127.0.0.1:8080/crontab/ http://127.0.0.1:8080/terraform-modules/ diff --git a/README.md b/README.md index 41032d0..bafc8f1 100644 --- a/README.md +++ b/README.md @@ -91,6 +91,50 @@ The shorter `/json-formatter/` alias redirects to this tool. YAML parsing and serialization use the vendored `js-yaml` 4.1.0 browser bundle, distributed under the MIT license included with the tool. +## JSON & YAML Diff Checker + +Open `/json-diff/` to compare JSON, JSON-compatible YAML, or one of each entirely +in the browser. Structural comparison ignores object key order, formatting, and +YAML comments, and reports added, removed, and changed values by field path. +Arrays can compare by position, as unordered multisets, or by a unique direct +object field. Kubernetes mode matches multi-document YAML or JSON resource +arrays by API version, kind, namespace, and name. + +The page reuses the formatter's header/footer markup, theme stylesheet, and +vendored MIT-licensed js-yaml parser. Inputs are never uploaded or persisted. +File import, individual format selection, formatting, swapping, filtering, +copying, and complete JSON report downloads work locally. Editing inputs or +comparison options invalidates the previous report immediately. Live comparison +refreshes after a short typing pause and can be switched off for manual reviews. +Editable source views highlight the affected lines and values, with matching +gutter markers, live counts, Previous/Next navigation, and Alt+Up/Down shortcuts. +Source locations follow JSON/YAML structure, keyed arrays, aliases, and reordered +Kubernetes resources. Only the visible editor lines are rendered in the highlight +layer so long documents remain practical to navigate. +Results offer side-by-side and unified layouts, syntax coloring, inline value +highlights, and readable Kubernetes resource labels alongside the original +report paths. + +Each input is limited to 1 MiB, 80 nesting levels, and 50,000 expanded values; +expanded text is capped at 4 MiB and reports at 2,000 changes. Duplicate mapping +keys, recursive aliases, non-finite numbers, and unsafe integers are rejected. +Numbers otherwise use JavaScript precision. Match paths are descriptive report +labels, not JSON Patch operations. Large value previews are shortened; exports +retain complete values. + +Run the comparison regression tests: + +```sh +node --test scripts/json-diff.test.cjs +``` + +The page includes indexable JSON and YAML examples, instructions, FAQs, section +navigation, related-tool links, and connected page/application/author/image +structured data. `python3 scripts/seo-audit.py` checks metadata agreement, +canonical URLs, schema relationships, examples, incoming links, and sitemap +dates. Follow the JSON diff section in [`SEARCH_CONSOLE.md`](./SEARCH_CONSOLE.md) +after deployment to inspect indexing and measure search performance. + ## Kubernetes RBAC Explorer Open `/kubernetes-rbac-explorer/` to turn Kubernetes Roles and bindings into @@ -226,6 +270,7 @@ python3 -m http.server 8080 - `http://localhost:8080/kubernetes-rbac-explorer/` - `http://localhost:8080/visual-subnet-calculator/` - `http://localhost:8080/yaml-formatter/` +- `http://localhost:8080/json-diff/` - `http://localhost:8080/crontab/` - `http://localhost:8080/terraform-modules/` diff --git a/SEARCH_CONSOLE.md b/SEARCH_CONSOLE.md index 08b658b..ca1ec71 100644 --- a/SEARCH_CONSOLE.md +++ b/SEARCH_CONSOLE.md @@ -37,6 +37,7 @@ invented or replaced with a placeholder. - `https://native-cube.com/kubernetes-rbac-explorer/` - `https://native-cube.com/visual-subnet-calculator/` - `https://native-cube.com/yaml-formatter/` + - `https://native-cube.com/json-diff/` - `https://native-cube.com/crontab/` - `https://native-cube.com/terraform-modules/` 4. Do not submit `/json-formatter/`; it is a redirect alias. @@ -173,6 +174,35 @@ workflow. Shared schedules remain URL fragments and retain the same clean canonical, following Google's [canonicalization guidance](https://developers.google.com/search/docs/crawling-indexing/consolidate-duplicate-urls). +## Check and measure the JSON and YAML diff page + +After publishing `/json-diff/`: + +1. Inspect `https://native-cube.com/json-diff/` with **Test live URL**. Confirm an + indexable HTTP 200 response and the exact canonical URL with its trailing + slash. Submit that canonical URL for indexing after the live checks pass. +2. Check the original HTML and the rendered page. The heading, comparison + instructions, JSON and YAML examples, FAQ, related tools, and ordinary + navigation links are static HTML. Only comparison and highlighting require + JavaScript. Confirm that the live editor still works on desktop and mobile. +3. Validate the linked `WebPage`, `WebApplication`, `BreadcrumbList`, `Person`, + and `ImageObject` entities with the + [Schema Markup Validator](https://validator.schema.org/) and + [Rich Results Test](https://search.google.com/test/rich-results). Keep the + feature list accurate. The page does not publish ratings or reviews; do not + invent them to remove rich-result eligibility warnings. +4. In the Search Console performance report, filter to the exact page URL. + Monitor impressions, clicks, CTR, and position for JSON diff, JSON compare, + YAML diff, compare YAML files, and Kubernetes manifest comparison queries. + These are query groups to investigate, not verified search volumes or + ranking predictions. Check actual indexing and selected canonical in Search + Console; passing local checks cannot establish either. +5. Keep `WebPage.dateModified` and sitemap `lastmod` aligned when content changes. + The local SEO audit enforces their agreement, page and social metadata, + schema relationships, static examples, and incoming links from the homepage + and formatter. Run `python3 scripts/check-redirects.py` after deployment to + verify the published redirects and canonical destinations. + ## Keep sitemap dates accurate Update a page's `lastmod` when its public content or metadata changes. The diff --git a/assets/social/json-diff.png b/assets/social/json-diff.png new file mode 100644 index 0000000..096b713 Binary files /dev/null and b/assets/social/json-diff.png differ diff --git a/assets/social/json-diff.svg b/assets/social/json-diff.svg new file mode 100644 index 0000000..8edfe32 --- /dev/null +++ b/assets/social/json-diff.svg @@ -0,0 +1,47 @@ + diff --git a/index.html b/index.html index dd9eba5..dc635ac 100644 --- a/index.html +++ b/index.html @@ -9,7 +9,7 @@ /> @@ -62,7 +62,7 @@ "name": "Native Cube", "alternateName": ["Native Cube Tools", "native-cube.com"], "url": "https://native-cube.com/", - "description": "Free browser-based cloud-native tools for Kubernetes, Helm, Argo CD, cron schedules, subnet planning, and YAML formatting, plus public AWS Terraform modules.", + "description": "Free browser-based cloud-native tools for Kubernetes, Helm, Argo CD, cron schedules, subnet planning, YAML formatting, and JSON comparison, plus public AWS Terraform modules.", "creator": { "@type": "Person", "name": "Marcin Cuber", @@ -293,7 +293,7 @@ position: relative; display: flex; width: min(100%, 340px); - aspect-ratio: 1; + min-height: 340px; min-width: 0; flex: 0 1 340px; flex-direction: column; @@ -331,25 +331,36 @@ 0 28px 62px rgba(23, 55, 94, 0.15); } + .tool-card-heading { + display: grid; + grid-template-columns: minmax(0, 1fr) 64px; + align-items: start; + gap: 14px; + flex-shrink: 0; + margin-bottom: 24px; + } + .tag { position: relative; z-index: 1; align-self: flex-start; display: inline-flex; + justify-self: start; + max-width: 100%; padding: 6px 10px; color: var(--green); background: var(--mint); border-radius: 999px; font-size: 12px; font-weight: 750; + line-height: 1.4; + overflow-wrap: anywhere; } .tool-cube { - position: absolute; - top: 26px; - right: 26px; - width: 74px; - height: 74px; + width: 64px; + height: 64px; + justify-self: end; opacity: 0.12; filter: drop-shadow(0 8px 10px rgba(37, 99, 235, 0.14)); transition: @@ -499,7 +510,7 @@
Generate Kubernetes manifests and Helm charts, preview Argo CD fleets, review RBAC access, visually plan IPv4 subnets, test cron schedules, - format configuration, and discover reusable Terraform modules. + format and compare configuration, and discover reusable Terraform modules.
Build Deployment, StatefulSet, or DaemonSet workloads and export @@ -533,17 +546,19 @@
Configure a workload and export a documented Helm chart API v2 or v3. @@ -551,17 +566,19 @@
Simulate generators, preview Applications, and review fleet-wide @@ -570,17 +587,19 @@
Map effective permissions, test access, and surface risky RBAC grants. @@ -588,17 +607,19 @@
Split IPv4 CIDR blocks and share clear, visual subnet plans. @@ -606,31 +627,56 @@
Validate, format, and convert configuration data without uploading it.
+ ++ Compare JSON and YAML, review field changes, and match reordered + arrays or Kubernetes manifests. +
+ + - Crontab generator & tester - +Generate and test cron expressions, preview runs across time zones, and export @@ -639,17 +685,19 @@
Explore reusable AWS Terraform modules for Kubernetes, security, diff --git a/json-diff/app.js b/json-diff/app.js new file mode 100644 index 0000000..d67267a --- /dev/null +++ b/json-diff/app.js @@ -0,0 +1,614 @@ +(() => { + "use strict"; + + const byId = (id) => document.getElementById(id); + const sides = ["before", "after"]; + const fileVersions = { before: 0, after: 0 }; + const changeSymbols = { added: "+", removed: "−", changed: "~" }; + const samples = { + json: [ + '{\n "service": "catalog-api",\n "replicas": 2,\n "ports": [8080, 8443],\n "environment": {\n "LOG_LEVEL": "info",\n "LEGACY_MODE": true\n }\n}', + '{\n "replicas": 3,\n "service": "catalog-api",\n "ports": [8080, 8443, 9090],\n "environment": {\n "LOG_LEVEL": "debug",\n "FEATURE_SEARCH": true\n }\n}', + ], + yaml: [ + "# Original service configuration\nservice:\n name: catalog-api\n replicas: 2\n image: catalog:1.0\n ports: [8080, 8443]\n debug: false\n", + "# Updated service configuration\nservice:\n name: catalog-api\n replicas: 3\n image: catalog:1.1\n ports: [8080, 8443]\n cache:\n enabled: true\n", + ], + kubernetes: [ + "apiVersion: apps/v1\nkind: Deployment\nmetadata:\n name: catalog-api\n namespace: production\nspec:\n replicas: 2\n selector:\n matchLabels:\n app: catalog\n template:\n metadata:\n labels:\n app: catalog\n spec:\n containers:\n - name: api\n image: catalog:1.0\n---\napiVersion: v1\nkind: Service\nmetadata:\n name: catalog-api\n namespace: production\nspec:\n selector:\n app: catalog\n ports:\n - name: http\n port: 80\n targetPort: 8080\n", + "apiVersion: v1\nkind: Service\nmetadata:\n name: catalog-api\n namespace: production\nspec:\n selector:\n app: catalog\n ports:\n - name: http\n port: 80\n targetPort: 8080\n---\napiVersion: apps/v1\nkind: Deployment\nmetadata:\n name: catalog-api\n namespace: production\nspec:\n replicas: 3\n selector:\n matchLabels:\n app: catalog\n template:\n metadata:\n labels:\n app: catalog\n spec:\n containers:\n - name: api\n image: catalog:1.1\n", + ], + }; + let report = null; + let visibleLimit = 100; + let toastTimer; + let comparisonTimer; + let activeChange = -1; + let composing = false; + const editorStates = Object.fromEntries(sides.map((side) => [side, { + lines: [""], offsets: [0], markers: new Map(), targets: [], + }])); + + function lineAt(offsets, position) { + let low = 0; + let high = offsets.length; + while (low + 1 < high) { + const middle = Math.floor((low + high) / 2); + if (offsets[middle] <= position) low = middle; + else high = middle; + } + return low; + } + + function paintEditor(side) { + const editor = byId(`${side}-editor`); + const state = editorStates[side]; + const lineHeight = parseFloat(getComputedStyle(editor).lineHeight); + const first = Math.max(0, Math.floor(editor.scrollTop / lineHeight) - 6); + const last = Math.min(state.lines.length, first + Math.ceil(editor.clientHeight / lineHeight) + 14); + const content = document.createDocumentFragment(); + const gutter = document.createElement("span"); + gutter.className = "source-gutter-content"; + gutter.style.paddingTop = `${first * lineHeight}px`; + gutter.style.paddingBottom = `${(state.lines.length - last) * lineHeight}px`; + for (let index = first; index < last; index++) { + const text = state.lines[index]; + const markers = state.markers.get(index) || []; + const active = markers.some((marker) => marker.index === activeChange); + const type = markers.find((marker) => marker.index === activeChange)?.type || markers[0]?.type; + const line = document.createElement("span"); + line.className = `source-line${type ? ` is-${type}` : ""}${active ? " is-active" : ""}`; + line.dataset.line = index + 1; + const boundaries = [...new Set([0, text.length, ...markers.flatMap((marker) => [marker.start, marker.end])])].sort((a, b) => a - b); + for (let part = 0; part < boundaries.length - 1; part++) { + const start = boundaries[part]; + const end = boundaries[part + 1]; + const marker = markers.find((item) => item.start <= start && item.end >= end && item.end > item.start); + if (marker) { + const mark = document.createElement("mark"); + mark.className = `source-token token-${marker.type}`; + mark.textContent = text.slice(start, end); + line.append(mark); + } else line.append(document.createTextNode(text.slice(start, end))); + } + content.append(line); + const number = document.createElement("span"); + number.className = `source-gutter-line${type ? ` is-${type}` : ""}${active ? " is-active" : ""}`; + const symbol = document.createElement("span"); + symbol.textContent = type ? { added: "+", removed: "−", changed: "~" }[type] : ""; + const digits = document.createElement("span"); + digits.textContent = index + 1; + number.append(symbol, digits); + gutter.append(number); + } + const code = byId(`${side}-highlight`); + code.style.paddingTop = `${first * lineHeight}px`; + code.style.paddingBottom = `${(state.lines.length - last) * lineHeight}px`; + const style = getComputedStyle(editor); + code.style.minWidth = `${editor.scrollWidth - parseFloat(style.paddingLeft) - parseFloat(style.paddingRight)}px`; + code.replaceChildren(content); + byId(`${side}-lines`).replaceChildren(gutter); + byId(`${side}-lines`).scrollTop = editor.scrollTop; + const backdrop = byId(`${side}-backdrop`); + backdrop.scrollTop = editor.scrollTop; + backdrop.scrollLeft = editor.scrollLeft; + editor.closest(".editor-surface").classList.add("has-backdrop"); + } + + function clearSourceHighlights() { + activeChange = -1; + sides.forEach((side) => { + editorStates[side].markers.clear(); + editorStates[side].targets = []; + paintEditor(side); + }); + ["previous-change", "next-change", "review-change"].forEach((id) => { byId(id).disabled = true; }); + byId("change-position").textContent = "No current comparison"; + byId("active-change").textContent = "Differences are highlighted directly in your inputs."; + byId("active-change").removeAttribute("title"); + } + + function updateSummary(counts = null) { + Object.keys(changeSymbols).forEach((type) => { + const value = counts ? counts[type].toLocaleString() : "—"; + byId(`${type}-count`).textContent = value; + byId(`live-${type}`).textContent = `${changeSymbols[type]} ${value} ${type}`; + }); + byId("total-count").textContent = counts + ? Object.values(counts).reduce((total, value) => total + value, 0).toLocaleString() + : "—"; + } + + function highlightSources(parsed) { + const sources = Object.fromEntries(sides.map((side) => [side, byId(`${side}-editor`).value])); + const locations = Object.fromEntries(sides.map((side) => [side, window.NativeDiffSource.build(sources[side], parsed[side])])); + const settings = { ...report.options, multipleDocuments: sides.some((side) => parsed[side].documents.length > 1) }; + report.changes.forEach((change, index) => { + const ranges = Object.fromEntries(sides.map((side) => [side, window.NativeDiffSource.locate(locations[side], parsed[side], change, side, settings)])); + sides.forEach((side) => { + const range = ranges[side]; + if (!range) return; + const state = editorStates[side]; + let start = change.type === "changed" ? range.start : range.keyStart; + let end = range.end; + const other = side === "before" ? "after" : "before"; + if (change.type === "changed" && typeof change.before === "string" && typeof change.after === "string" && ranges[other] && !range.alias && !ranges[other].alias) { + const lexemes = Object.fromEntries(sides.map((name) => [name, sources[name].slice(ranges[name].start, ranges[name].end)])); + const relative = textDifference(lexemes.before, lexemes.after, side); + start = range.start + relative[0]; + end = range.start + relative[1]; + } + if (range.start === range.end) { start = range.keyStart; end = range.end; } + state.targets[index] = { ...range, focusStart: start }; + const first = lineAt(state.offsets, range.keyStart); + const last = lineAt(state.offsets, Math.max(range.keyStart, range.end - 1)); + for (let line = first; line <= last; line++) { + if (!state.markers.has(line)) state.markers.set(line, []); + const columnStart = Math.min(state.lines[line].length, Math.max(0, start - state.offsets[line])); + const columnEnd = Math.min(state.lines[line].length, Math.max(0, end - state.offsets[line])); + state.markers.get(line).push({ type: change.type, index, start: columnStart, end: columnEnd }); + } + }); + }); + sides.forEach(paintEditor); + byId("change-position").textContent = report.changes.length ? `${report.changes.length} changes` : "Documents match"; + byId("previous-change").disabled = byId("next-change").disabled = !report.changes.length; + byId("active-change").textContent = report.changes.length ? "Use Next / Previous to inspect matching fields in both editors." : "No structural differences with the selected options."; + } + + function activateChange(index, reveal = false) { + if (!report?.changes.length) return; + activeChange = (index + report.changes.length) % report.changes.length; + sides.forEach((side) => { + const editor = byId(`${side}-editor`); + const state = editorStates[side]; + const range = state.targets[activeChange]; + if (range) { + const focusStart = range.focusStart ?? range.start; + const line = lineAt(state.offsets, focusStart); + const style = getComputedStyle(editor); + const lineHeight = parseFloat(style.lineHeight); + editor.scrollTop = Math.max(0, (line - 3) * lineHeight); + const column = focusStart - state.offsets[line]; + const context = document.createElement("canvas").getContext("2d"); + context.font = `${style.fontSize} ${style.fontFamily}`; + const prefixWidth = context.measureText(state.lines[line].slice(0, column).replace(/\t/g, " ")).width; + editor.scrollLeft = Math.max(0, prefixWidth - editor.clientWidth / 2); + } + paintEditor(side); + }); + const change = report.changes[activeChange]; + byId("change-position").textContent = `${activeChange + 1} / ${report.changes.length}`; + byId("active-change").textContent = `${{ added: "+ Added", removed: "− Removed", changed: "~ Changed" }[change.type]} · ${fieldPath(change.path).textContent}`; + byId("active-change").title = change.path || "(root)"; + byId("review-change").disabled = false; + if (reveal) { + document.querySelector(".change-navigation").scrollIntoView({ block: "start", behavior: "instant" }); + byId("next-change").focus({ preventScroll: true }); + } + } + + function toast(message) { + clearTimeout(toastTimer); + byId("toast").textContent = message; + byId("toast").classList.add("is-visible"); + toastTimer = setTimeout(() => byId("toast").classList.remove("is-visible"), 4000); + } + + function status(state, title, message) { + byId("validation-panel").className = `validation-panel is-${state}`; + byId("validation-title").textContent = title; + byId("validation-message").textContent = message; + } + + function empty(message) { + const paragraph = document.createElement("p"); + paragraph.className = "empty-state"; + paragraph.textContent = message; + byId("diff-results").replaceChildren(paragraph); + } + + function invalidate(schedule = true) { + clearTimeout(comparisonTimer); + report = null; + clearSourceHighlights(); + byId("copy-button").disabled = true; + byId("download-button").disabled = true; + byId("more-button").hidden = true; + updateSummary(); + byId("visible-count").textContent = ""; + empty("Inputs or options changed. Compare again to refresh the results."); + status("pending", "Ready to compare", "Choose Compare documents or press Ctrl/⌘ + Enter to review these inputs."); + byId("live-total").textContent = "Waiting for comparison"; + if (schedule && !composing && byId("live-compare").checked && sides.every((side) => byId(`${side}-editor`).value.trim())) { + byId("live-total").textContent = "Updating differences…"; + comparisonTimer = setTimeout(compare, 450); + } + } + + function clearError(side) { + byId(`${side}-error`).hidden = true; + byId(`${side}-error`).textContent = ""; + byId(`${side}-editor`).removeAttribute("aria-invalid"); + byId(`${side}-editor`).closest(".editor-card").classList.remove("has-error"); + } + + function showError(side, error) { + byId(`${side}-error`).textContent = error.message; + byId(`${side}-error`).hidden = false; + byId(`${side}-editor`).setAttribute("aria-invalid", "true"); + byId(`${side}-editor`).closest(".editor-card").classList.add("has-error"); + } + + function updateEditor(side) { + const editor = byId(`${side}-editor`); + const lines = editor.value.split("\n").length; + const state = editorStates[side]; + state.lines = editor.value.split("\n"); + state.offsets = []; + let offset = 0; + state.lines.forEach((line) => { state.offsets.push(offset); offset += line.length + 1; }); + byId(`${side}-stats`).textContent = `${lines.toLocaleString()} lines · ${editor.value.length.toLocaleString()} characters`; + clearError(side); + } + + function options() { + return { + arrayMode: byId("array-mode").value, + arrayKey: byId("array-key").value.trim(), + documents: byId("document-mode").value, + }; + } + + function updateOptions() { + const mode = byId("array-mode").value; + byId("array-key-field").hidden = mode !== "keyed"; + const descriptions = { + ordered: "Array positions matter.", + unordered: "Array order is ignored at every level; duplicate counts still matter. Edited items appear as removed and added.", + keyed: "Object arrays match by a unique direct field; primitive and mixed arrays compare by position.", + }; + const documents = byId("document-mode").value === "kubernetes" + ? " Resources match by API version, kind, exact namespace and name." + : " Multiple YAML documents compare by position."; + byId("mode-description").textContent = `Object key order, indentation, and YAML comments are ignored. ${descriptions[mode]}${documents} Ctrl/⌘ + Enter compares.`; + } + + function changedRange(change, side, text) { + if (change.type !== "changed") return null; + if (typeof change.before !== "string" || typeof change.after !== "string") return [0, text.length]; + return textDifference(JSON.stringify(change.before), JSON.stringify(change.after), side); + } + + function textDifference(before, after, side) { + const original = Array.from(before); + const updated = Array.from(after); + let start = 0; + let end = 0; + while (start < Math.min(original.length, updated.length) && original[start] === updated[start]) start++; + while (end < Math.min(original.length, updated.length) - start && original[original.length - end - 1] === updated[updated.length - end - 1]) end++; + const points = side === "before" ? original : updated; + return [points.slice(0, start).join("").length, points.slice(0, points.length - end).join("").length]; + } + + function appendHighlighted(parent, text, offset, range) { + const start = range ? Math.max(0, range[0] - offset) : 0; + const end = range ? Math.min(text.length, range[1] - offset) : 0; + if (end <= start) { + parent.append(document.createTextNode(text)); + return; + } + parent.append(document.createTextNode(text.slice(0, start))); + const mark = document.createElement("mark"); + mark.className = "inline-change"; + mark.textContent = text.slice(start, end); + parent.append(mark, document.createTextNode(text.slice(end))); + } + + function highlightJson(text, range) { + const fragment = document.createDocumentFragment(); + const tokens = /("(?:\\.|[^"\\])*")(\s*:)?|(-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?)|\b(true|false|null)\b/g; + let offset = 0; + for (const match of text.matchAll(tokens)) { + appendHighlighted(fragment, text.slice(offset, match.index), offset, range); + const token = document.createElement("span"); + token.className = `json-token ${match[2] ? "json-key" : match[1] ? "json-string" : match[3] ? "json-number" : match[4] === "null" ? "json-null" : "json-boolean"}`; + appendHighlighted(token, match[0], match.index, range); + fragment.append(token); + offset = match.index + match[0].length; + } + appendHighlighted(fragment, text.slice(offset), offset, range); + return fragment; + } + + function valuePane(change, side) { + const pane = document.createElement("div"); + const present = Object.prototype.hasOwnProperty.call(change, side); + pane.className = `value-pane ${side}-value${present ? "" : " value-absent"}`; + const label = document.createElement("div"); + label.className = "value-label"; + const source = document.createElement("span"); + source.textContent = side === "before" ? "Original" : "Updated"; + const type = document.createElement("span"); + type.className = "value-type"; + type.textContent = present ? change[side] === null ? "null" : Array.isArray(change[side]) ? "array" : typeof change[side] : "absent"; + label.append(source, type); + const content = document.createElement("div"); + content.className = "value-content"; + const marker = document.createElement("span"); + marker.className = "value-marker"; + marker.setAttribute("aria-hidden", "true"); + marker.textContent = present ? side === "before" ? "−" : "+" : "·"; + const value = document.createElement("pre"); + const fullValue = present ? JSON.stringify(change[side], null, 2) : "Field not present"; + const preview = fullValue.slice(0, 20000); + if (present) value.append(highlightJson(preview, changedRange(change, side, fullValue))); + else value.textContent = fullValue; + if (fullValue.length > 20000) { + const notice = document.createElement("span"); + notice.className = "preview-notice"; + notice.textContent = "\n… Preview shortened. The complete value is in the exported report."; + value.append(notice); + } + content.append(marker, value); + pane.append(label, content); + return pane; + } + + function fieldPath(path) { + const title = document.createElement("h3"); + title.title = path || "(root)"; + let displayPath = path; + if (report.options.documents === "kubernetes" && path.startsWith("/resources/")) { + const parts = path.split("/"); + try { + const [, kind, namespace, name] = JSON.parse(parts[2].replace(/~1/g, "/").replace(/~0/g, "~")); + const resource = document.createElement("span"); + resource.className = "resource-context"; + resource.textContent = `${kind} · ${namespace ? `${namespace}/` : ""}${name}`; + title.append(resource); + displayPath = parts.length > 3 ? `/${parts.slice(3).join("/")}` : "(resource)"; + } catch { /* Keep the original pointer if it is not a resource identity. */ } + } + const pathText = document.createElement("span"); + pathText.className = "field-path"; + const separator = displayPath.lastIndexOf("/"); + const prefix = document.createElement("span"); + prefix.className = "path-parent"; + prefix.textContent = displayPath.slice(0, separator + 1); + const field = document.createElement("strong"); + field.textContent = displayPath.slice(separator + 1) || (displayPath ? "(empty key)" : "(root)"); + pathText.append(prefix, field); + title.append(pathText); + return title; + } + + function render() { + if (!report) return; + const filter = byId("change-filter").value; + const path = byId("path-filter").value.toLowerCase(); + const changes = report.changes.filter((change) => (filter === "all" || change.type === filter) && (change.path || "(root)").toLowerCase().includes(path)); + const fragment = document.createDocumentFragment(); + const labels = { added: "+ Added", removed: "− Removed", changed: "~ Changed" }; + changes.slice(0, visibleLimit).forEach((change, index) => { + const card = document.createElement("article"); + card.className = `change-card ${change.type}`; + card.tabIndex = -1; + const originalIndex = report.changes.indexOf(change); + card.dataset.changeIndex = originalIndex; + const heading = document.createElement("div"); + heading.className = "change-heading"; + const number = document.createElement("span"); + number.className = "change-number"; + number.textContent = String(index + 1).padStart(2, "0"); + number.setAttribute("aria-label", `Change ${index + 1}`); + const label = document.createElement("span"); + label.className = "change-label"; + label.textContent = labels[change.type]; + const title = fieldPath(change.path); + heading.append(number, title, label); + const reveal = document.createElement("button"); + reveal.className = "text-button source-jump"; + reveal.type = "button"; + reveal.append(document.createTextNode("Show in inputs ")); + const arrow = document.createElement("span"); + arrow.textContent = "↑"; + arrow.setAttribute("aria-hidden", "true"); + reveal.append(arrow); + reveal.setAttribute("aria-label", `Show in inputs: ${change.path || "root change"}`); + reveal.addEventListener("click", () => activateChange(originalIndex, true)); + heading.append(reveal); + const grid = document.createElement("div"); + grid.className = "value-grid"; + grid.append(valuePane(change, "before"), valuePane(change, "after")); + card.append(heading, grid); + fragment.append(card); + }); + byId("diff-results").replaceChildren(fragment); + if (!changes.length) empty(report.changes.length ? "No changes match these filters." : "No structural differences. The documents match with the selected comparison options."); + byId("visible-count").textContent = `${Math.min(visibleLimit, changes.length)} of ${changes.length} matching changes`; + byId("more-button").hidden = changes.length <= visibleLimit; + } + + function compare() { + invalidate(false); + const parsed = {}; + for (const side of sides) { + clearError(side); + try { + parsed[side] = window.NativeDiff.parse(byId(`${side}-editor`).value, byId(`${side}-format`).value); + } catch (error) { + showError(side, error); + } + } + if (!parsed.before || !parsed.after) { + byId("live-total").textContent = "Check input syntax"; + status("error", "Check your input", "The highlighted editor explains what to fix. For YAML flow syntax, choose YAML explicitly."); + empty("Fix the input errors, then compare again."); + return; + } + try { + const settings = options(); + if (settings.arrayMode === "keyed" && !settings.arrayKey) throw new Error("Enter the direct field name to match object arrays, such as name or id."); + const changes = window.NativeDiff.compare(parsed.before, parsed.after, settings); + const counts = { added: 0, removed: 0, changed: 0 }; + changes.forEach((change) => counts[change.type]++); + report = { + tool: "Native Cube JSON & YAML Diff Checker", + version: 1, + options: settings, + inputs: Object.fromEntries(sides.map((side) => [side, { format: parsed[side].format, documents: parsed[side].documents.length }])), + summary: { total: changes.length, ...counts }, + changes, + }; + updateSummary(counts); + byId("live-total").textContent = changes.length ? `${changes.length} change${changes.length === 1 ? "" : "s"} found` : "Documents match"; + highlightSources(parsed); + byId("copy-button").disabled = false; + byId("download-button").disabled = false; + const inputSummary = sides.map((side) => `${side === "before" ? "Original" : "Updated"}: ${parsed[side].format.toUpperCase()}, ${parsed[side].documents.length} document${parsed[side].documents.length === 1 ? "" : "s"}`).join(" · "); + status(changes.length ? "idle" : "success", changes.length ? `${changes.length} structural change${changes.length === 1 ? "" : "s"} found` : "The documents match", `${inputSummary}. Compared locally using the selected options.`); + visibleLimit = 100; + render(); + } catch (error) { + report = null; + clearSourceHighlights(); + updateSummary(); + byId("copy-button").disabled = byId("download-button").disabled = true; + byId("live-total").textContent = "Check comparison options"; + status("error", "Comparison needs attention", error.message); + empty("Adjust your inputs or comparison options, then try again."); + } + } + + function loadSample() { + const example = byId("example-select").value; + sides.forEach((side, index) => { + fileVersions[side]++; + byId(`${side}-editor`).value = samples[example][index]; + byId(`${side}-format`).value = example === "json" ? "json" : "yaml"; + updateEditor(side); + }); + byId("array-mode").value = "ordered"; + byId("document-mode").value = example === "kubernetes" ? "kubernetes" : "position"; + byId("change-filter").value = "all"; + byId("path-filter").value = ""; + updateOptions(); + compare(); + } + + sides.forEach((side) => { + const editor = byId(`${side}-editor`); + editor.addEventListener("input", () => { fileVersions[side]++; updateEditor(side); invalidate(); }); + editor.addEventListener("scroll", () => paintEditor(side)); + editor.addEventListener("compositionstart", () => { composing = true; clearTimeout(comparisonTimer); }); + editor.addEventListener("compositionend", () => { composing = false; updateEditor(side); invalidate(); }); + editor.addEventListener("keydown", (event) => { + if (event.key === "Tab" && !event.shiftKey) { + event.preventDefault(); + editor.setRangeText(" ", editor.selectionStart, editor.selectionEnd, "end"); + editor.dispatchEvent(new Event("input")); + } + }); + byId(`${side}-format`).addEventListener("change", () => { clearError(side); invalidate(); }); + byId(`${side}-file`).addEventListener("change", async (event) => { + const file = event.target.files[0]; + event.target.value = ""; + if (!file) return; + const version = ++fileVersions[side]; + try { + if (file.size > window.NativeDiff.MAX_SOURCE) throw new Error("Each input is limited to 1 MiB."); + const source = await file.text(); + if (fileVersions[side] !== version) return; + editor.value = source; + byId(`${side}-format`).value = /\.ya?ml$/i.test(file.name) ? "yaml" : /\.json$/i.test(file.name) ? "json" : "auto"; + updateEditor(side); + invalidate(); + toast(`${side === "before" ? "Original" : "Updated"} file opened locally.`); + } catch (error) { toast(error.message); } + }); + }); + + document.querySelectorAll("[data-open]").forEach((button) => button.addEventListener("click", () => byId(`${button.dataset.open}-file`).click())); + document.querySelectorAll("[data-format]").forEach((button) => button.addEventListener("click", () => { + const side = button.dataset.format; + try { + const parsed = window.NativeDiff.parse(byId(`${side}-editor`).value, byId(`${side}-format`).value); + byId(`${side}-editor`).value = parsed.format === "json" + ? JSON.stringify(parsed.documents[0], null, 2) + : parsed.documents.map((value) => window.jsyaml.dump(value, { schema: window.jsyaml.JSON_SCHEMA, lineWidth: -1, indent: 2 })).join("---\n"); + byId(`${side}-format`).value = parsed.format; + fileVersions[side]++; + updateEditor(side); + invalidate(); + toast("Formatted locally. YAML comments are omitted."); + } catch (error) { showError(side, error); status("error", "Could not format this input", error.message); } + })); + + byId("compare-button").addEventListener("click", compare); + byId("live-compare").addEventListener("change", () => { + clearTimeout(comparisonTimer); + if (byId("live-compare").checked) compare(); + }); + byId("previous-change").addEventListener("click", () => activateChange(activeChange < 0 ? -1 : activeChange - 1)); + byId("next-change").addEventListener("click", () => activateChange(activeChange + 1)); + byId("review-change").addEventListener("click", () => { + if (!report || activeChange < 0) return; + byId("change-filter").value = "all"; + byId("path-filter").value = ""; + visibleLimit = Math.max(100, activeChange + 1); + render(); + const row = byId("diff-results").querySelector(`[data-change-index="${activeChange}"]`); + row?.scrollIntoView({ block: "center", behavior: "instant" }); + row?.focus({ preventScroll: true }); + }); + window.addEventListener("resize", () => sides.forEach(paintEditor)); + byId("sample-button").addEventListener("click", loadSample); + byId("swap-button").addEventListener("click", () => { + for (const suffix of ["editor", "format"]) { + const before = byId(`before-${suffix}`); + const after = byId(`after-${suffix}`); + [before.value, after.value] = [after.value, before.value]; + } + sides.forEach((side) => { fileVersions[side]++; updateEditor(side); }); + invalidate(); + }); + byId("clear-button").addEventListener("click", () => { + sides.forEach((side) => { fileVersions[side]++; byId(`${side}-editor`).value = ""; updateEditor(side); }); + invalidate(); + byId("before-editor").focus(); + }); + ["array-mode", "array-key", "document-mode"].forEach((id) => byId(id).addEventListener(id === "array-key" ? "input" : "change", () => { updateOptions(); invalidate(); })); + ["change-filter", "path-filter"].forEach((id) => byId(id).addEventListener(id === "path-filter" ? "input" : "change", () => { visibleLimit = 100; render(); })); + document.querySelectorAll('input[name="result-view"]').forEach((input) => input.addEventListener("change", () => { + byId("diff-viewer").dataset.view = input.value; + })); + byId("more-button").addEventListener("click", () => { visibleLimit += 100; render(); }); + byId("copy-button").addEventListener("click", async () => { + if (!report) return; + try { + await navigator.clipboard.writeText(JSON.stringify(report, null, 2)); + toast("Complete JSON report copied."); + } catch { toast("Clipboard is unavailable. Download the JSON report instead."); } + }); + byId("download-button").addEventListener("click", () => { + if (!report) return; + const url = URL.createObjectURL(new Blob([JSON.stringify(report, null, 2) + "\n"], { type: "application/json" })); + const link = document.createElement("a"); + link.href = url; + link.download = "native-cube-json-yaml-diff.json"; + document.body.append(link); + link.click(); + link.remove(); + setTimeout(() => URL.revokeObjectURL(url), 1000); + toast("Complete JSON report downloaded."); + }); + document.addEventListener("keydown", (event) => { + if ((event.ctrlKey || event.metaKey) && event.key === "Enter") { event.preventDefault(); compare(); } + if (event.altKey && ["ArrowDown", "ArrowUp"].includes(event.key) && report?.changes.length) { + event.preventDefault(); + activateChange(event.key === "ArrowDown" ? activeChange + 1 : activeChange < 0 ? -1 : activeChange - 1); + } + }); + loadSample(); +})(); diff --git a/json-diff/diff.js b/json-diff/diff.js new file mode 100644 index 0000000..d1a2a1e --- /dev/null +++ b/json-diff/diff.js @@ -0,0 +1,223 @@ +(function (root) { + "use strict"; + + const MAX_SOURCE = 1024 * 1024; + const MAX_DEPTH = 80; + const MAX_NODES = 50000; + const MAX_CHANGES = 2000; + const MAX_EXPANDED_TEXT = 4 * 1024 * 1024; + const own = (value, key) => Object.prototype.hasOwnProperty.call(value, key); + const pointer = (path, key) => `${path}/${String(key).replace(/~/g, "~0").replace(/\//g, "~1")}`; + const kind = (value) => value === null ? "null" : Array.isArray(value) ? "array" : typeof value; + + // JSON.parse silently accepts duplicate keys. Check them before parsing values. + function parseJson(source) { + let index = 0; + const space = () => { while (/\s/.test(source[index] || "") && index < source.length) index++; }; + function string() { + const start = index++; + while (index < source.length) { + const char = source[index++]; + if (char === "\\") index++; + else if (char === '"') return JSON.parse(source.slice(start, index)); + } + throw new Error("Unterminated JSON string."); + } + function walk(depth) { + if (depth > MAX_DEPTH) throw new Error(`Maximum nesting depth is ${MAX_DEPTH}.`); + space(); + if (source[index] === "{") { + index++; + space(); + const keys = new Set(); + if (source[index] === "}") { index++; return; } + while (index < source.length) { + if (source[index] !== '"') break; + const key = string(); + if (keys.has(key)) throw new Error(`Duplicate JSON key ${JSON.stringify(key)}.`); + keys.add(key); + space(); + if (source[index++] !== ":") break; + walk(depth + 1); + space(); + if (source[index] === "}") { index++; return; } + if (source[index++] !== ",") break; + space(); + } + } else if (source[index] === "[") { + index++; + space(); + if (source[index] === "]") { index++; return; } + while (index < source.length) { + walk(depth + 1); + space(); + if (source[index] === "]") { index++; return; } + if (source[index++] !== ",") break; + } + } else if (source[index] === '"') { + string(); + } else { + while (index < source.length && !/[\s,\]}]/.test(source[index])) index++; + } + } + walk(0); + return JSON.parse(source); + } + + function validate(documents) { + let nodes = 0; + let textSize = 0; + const ancestors = new Set(); + function visit(value, depth) { + if (typeof value === "string") textSize += value.length; + if (value && typeof value === "object") textSize += Object.keys(value).join("").length; + if (textSize > MAX_EXPANDED_TEXT) throw new Error("Expanded YAML text exceeds 4 MiB. Reduce aliases or repeated values."); + if (++nodes > MAX_NODES) throw new Error(`Input exceeds ${MAX_NODES.toLocaleString("en-US")} expanded values. Reduce the document or YAML aliases.`); + if (depth > MAX_DEPTH) throw new Error(`Maximum nesting depth is ${MAX_DEPTH}.`); + if (typeof value === "number" && (!Number.isFinite(value) || (Number.isInteger(value) && !Number.isSafeInteger(value)))) { + throw new Error("Numbers must be finite and integers must be within JavaScript's safe range. Quote large identifiers to compare them exactly."); + } + if (value === null || ["string", "boolean", "number"].includes(typeof value)) return; + if (typeof value !== "object") throw new Error("Only JSON-compatible YAML values are supported."); + if (ancestors.has(value)) throw new Error("Recursive YAML aliases cannot be compared."); + ancestors.add(value); + Object.values(value).forEach((child) => visit(child, depth + 1)); + ancestors.delete(value); + } + documents.forEach((value) => visit(value, 0)); + } + + function parse(source, format = "auto", yaml = root.jsyaml) { + if (!source.trim()) throw new Error("Paste a document or open a file first. Use null, {} or [] for an empty value."); + if (source.length > MAX_SOURCE || new TextEncoder().encode(source).length > MAX_SOURCE) throw new Error("Each input is limited to 1 MiB."); + const detected = format === "auto" ? (/^[{\["]/.test(source.trimStart()) || /^(?:true|false|null|-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?)$/.test(source.trim()) ? "json" : "yaml") : format; + let documents; + if (detected === "json") { + documents = [parseJson(source)]; + } else { + if (!yaml) throw new Error("The YAML parser could not load. Reload this page to try again."); + documents = yaml.loadAll(source, null, { + schema: yaml.JSON_SCHEMA, + onWarning(warning) { throw warning; }, + }); + if (!documents.length) throw new Error("No YAML document found."); + // An empty YAML document has the same value as an explicit null. + documents = documents.map((value) => value === undefined ? null : value); + } + validate(documents); + return { documents, format: detected }; + } + + function canonical(value, unordered) { + if (Array.isArray(value)) { + const items = value.map((item) => canonical(item, unordered)); + if (unordered) items.sort(); + return `[${items.join(",")}]`; + } + if (value !== null && typeof value === "object") { + return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${canonical(value[key], unordered)}`).join(",")}}`; + } + return JSON.stringify(value); + } + + function resourceKey(value) { + if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("Kubernetes matching requires resource objects in every document."); + const { apiVersion, kind: resourceKind, metadata } = value; + if (typeof apiVersion !== "string" || typeof resourceKind !== "string" || !metadata || typeof metadata.name !== "string" || (metadata.namespace !== undefined && typeof metadata.namespace !== "string")) { + throw new Error("Each Kubernetes document needs apiVersion, kind and metadata.name, plus a string namespace if supplied."); + } + return JSON.stringify([apiVersion, resourceKind, metadata.namespace || "", metadata.name]); + } + + function keyed(items, keyFor, label) { + const values = new Map(); + items.forEach((item) => { + const key = keyFor(item); + if (values.has(key)) throw new Error(`Duplicate ${label}: ${key}. Keys must be unique for matching.`); + values.set(key, item); + }); + return values; + } + + function compare(before, after, options = {}) { + validate(before.documents); + validate(after.documents); + const changes = []; + const arrayMode = options.arrayMode || "ordered"; + const arrayKey = options.arrayKey || "name"; + function add(type, path, oldValue, newValue) { + if (changes.length >= MAX_CHANGES) throw new Error(`More than ${MAX_CHANGES.toLocaleString("en-US")} changes. Compare smaller inputs to see a complete report.`); + const entry = { type, path }; + if (type !== "added") entry.before = oldValue; + if (type !== "removed") entry.after = newValue; + changes.push(entry); + } + function compareMaps(left, right, path) { + [...new Set([...left.keys(), ...right.keys()])].sort().forEach((key) => { + const childPath = pointer(path, key); + if (!left.has(key)) add("added", childPath, undefined, right.get(key)); + else if (!right.has(key)) add("removed", childPath, left.get(key)); + else visit(left.get(key), right.get(key), childPath); + }); + } + function visit(left, right, path) { + if (left === right) return; + if (kind(left) !== kind(right) || left === null || typeof left !== "object") { + add("changed", path, left, right); + } else if (Array.isArray(left)) { + if (arrayMode === "unordered") { + const remaining = new Map(); + right.forEach((item, index) => { + const key = canonical(item, true); + if (!remaining.has(key)) remaining.set(key, []); + remaining.get(key).push(index); + }); + const matched = new Set(); + left.forEach((item, index) => { + const candidates = remaining.get(canonical(item, true)); + if (candidates?.length) matched.add(candidates.pop()); + else add("removed", pointer(path, index), item); + }); + right.forEach((item, index) => { + if (!matched.has(index)) add("added", pointer(path, index), undefined, item); + }); + } else if (arrayMode === "keyed" && [...left, ...right].every((item) => kind(item) === "object")) { + const getKey = (item) => { + if (!own(item, arrayKey) || !["string", "number", "boolean"].includes(typeof item[arrayKey])) throw new Error(`Object arrays at ${path || "(root)"} need a unique string, number or boolean '${arrayKey}' field in every item.`); + return `${arrayKey}=${JSON.stringify(item[arrayKey])}`; + }; + compareMaps(keyed(left, getKey, "array key"), keyed(right, getKey, "array key"), path); + } else { + for (let index = 0; index < Math.max(left.length, right.length); index++) { + if (index >= left.length) add("added", pointer(path, index), undefined, right[index]); + else if (index >= right.length) add("removed", pointer(path, index), left[index]); + else visit(left[index], right[index], pointer(path, index)); + } + } + } else { + compareMaps(new Map(Object.entries(left)), new Map(Object.entries(right)), path); + } + } + + if (options.documents === "kubernetes") { + const resources = (docs) => docs.length === 1 && Array.isArray(docs[0]) ? docs[0] : docs; + compareMaps(keyed(resources(before.documents), resourceKey, "resource identity"), keyed(resources(after.documents), resourceKey, "resource identity"), "/resources"); + } else if (before.documents.length === 1 && after.documents.length === 1) { + visit(before.documents[0], after.documents[0], ""); + } else { + const left = before.documents; + const right = after.documents; + for (let index = 0; index < Math.max(left.length, right.length); index++) { + const path = pointer("/documents", index); + if (index >= left.length) add("added", path, undefined, right[index]); + else if (index >= right.length) add("removed", path, left[index]); + else visit(left[index], right[index], path); + } + } + return changes; + } + + const api = { parse, compare, MAX_SOURCE }; + if (typeof module !== "undefined" && module.exports) module.exports = api; + else root.NativeDiff = api; +})(typeof globalThis !== "undefined" ? globalThis : this); diff --git a/json-diff/index.html b/json-diff/index.html new file mode 100644 index 0000000..1c44974 --- /dev/null +++ b/json-diff/index.html @@ -0,0 +1,535 @@ + + +
+ + + + + + + + + + + + + + + + + + + + + + ++ Two journeys, one shared future. Your contribution helps my + daughter pursue higher education and helps me continue creating thoughtful, + accessible open-source tools—free for everyone to use. +
+ +Compare JSON / YAML online
++ Compare JSON or YAML files with live highlights for added, removed, + and changed values. Match arrays and Kubernetes resources, then export + a clear report. Your data stays in your browser. +
+Highlighted lines show changed fields; brighter text marks the edited values. Use Next / Previous or Alt + ↓ / ↑ to jump between changes. Input stays editable.
+Object key order, indentation, and YAML comments are ignored. Array positions matter. Ctrl/⌘ + Enter compares.
+Paste two documents, then choose Compare documents.
Review − original and + updated values. Highlighted text pinpoints edits. Reports include all changes; paths use JSON Pointer escaping.
+Your comparison will appear here.
Place your original JSON or YAML on the left and the updated version on the right. Compare JSON to YAML directly. Choose the format explicitly for ambiguous YAML flow syntax or scalar values.
Compare arrays by position, ignore their order, or match object arrays by a unique field such as id or name. Kubernetes identity matches resources across reordered YAML documents.
See differences highlighted in your inputs as you type. Jump between changes with Next / Previous, or review the detailed results below. Copy or download the complete JSON report. Nothing is uploaded or saved between visits.
+ Compare an API response or a configuration file. Each pair below + produces two changes; object key order and YAML comments do not + create extra differences. +
+{
+ "service": "catalog-api",
+ "replicas": 2
+}
+ {
+ "replicas": 3,
+ "service": "catalog-api",
+ "debug": false
+}
+ JSON result: /replicas changes from 2 to 3, and /debug is added. Moving service creates no change.
# Current release
+service:
+ name: catalog-api
+ replicas: 2
+ image: catalog:1.0
+ # Next release
+service:
+ image: catalog:1.1
+ name: catalog-api
+ replicas: 3
+ YAML result: /service/image and /service/replicas change. The comment and key order are ignored.
Use this checker for API responses, Helm values, Docker Compose configuration, and Kubernetes manifest reviews.
+ A structural diff compares parsed values and reports the fields that were added,
+ removed, or changed. It ignores formatting and object key order. A number such as
+ 3 differs from the string "3"; a missing field differs from a
+ field containing null. Added and removed objects count as one change at
+ their parent path.
+
+ Yes. “Compare as I type” refreshes the highlights after a short pause. Switch it off for + manual comparison. Press Ctrl/⌘ + Enter to compare, and + Alt + ↓/↑ to move between changes. Tab inserts two + spaces in an editor; Shift+Tab leaves it. “Show in inputs” links each detailed result + back to its source. +
++ Yes. Each input has its own format selector. JSON-compatible YAML values, anchors, + aliases, and multi-document streams are supported. YAML comments, quoting style, and + whitespace are not compared. Dates remain strings; custom tags and recursive aliases are + rejected. When an alias inherits a changed value, its reference is highlighted alongside + the anchor. For a formatting-only review, use your editor’s text diff. Use the YAML & JSON Formatter to convert or validate a single + document. +
+
+ Items match by their complete value, including nested arrays compared without order.
+ Duplicate counts are preserved: [1, 1] differs from [1].
+ Editing an item produces a removal and an addition in this mode. To track field edits
+ within object arrays, choose “Match objects by field” and enter a unique direct field
+ name. Object arrays require that field on every item; primitive and mixed arrays still
+ compare by position.
+
+ Choose Kubernetes identity to match resources by apiVersion,
+ kind, metadata.namespace, and metadata.name.
+ Paste multi-document YAML or a JSON array of resources. Duplicate identities are
+ rejected. Missing namespaces remain distinct from an explicit default
+ namespace, and server-generated fields are included. This is an offline file comparison,
+ not a live cluster diff. You can create drafts with the Kubernetes Manifest Builder or Helm Chart Builder.
+
+ Normal paths use JSON Pointer notation: /service/ports/0 identifies the
+ first port. A literal ~ in a key is escaped as ~0, and a slash
+ as ~1. An empty path denotes the root value. Multi-document comparisons add
+ a /documents/0 prefix. Matched arrays and Kubernetes resources use
+ descriptive identity segments instead of indexes; these paths are report labels and
+ cannot be used as JSON Patch operations.
+
+ Yes. Parsing, comparison, file reading, and report creation happen locally. Your input + is not uploaded, stored, or placed in a URL. No account is needed. Copying or + downloading a report explicitly creates a copy containing the changed values, so review + it before sharing. +
++ Each input is limited to 1 MiB, 80 nesting levels, and 50,000 expanded values. Expanded + text is capped at 4 MiB, and reports support up to 2,000 changes. Numbers use JavaScript + precision; non-finite values and unsafe integers are rejected. Quote large identifiers + or high-precision decimal values to compare them exactly. Duplicate JSON or YAML mapping + keys are rejected to avoid hiding overwritten values. +
+