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 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Native Cube + CLOUD-NATIVE TOOLBOX + JSON / YAML DIFF + Compare changes + JSON and YAML. + + + + + + + + + COMPARE · REVIEW · COPY · EXPORT + + 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 @@

Free tools for cloud-native work.

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.

Created and maintained by @@ -514,17 +525,19 @@

Free tools for cloud-native work.

- YAML & JSON export - +
+ YAML & JSON export + +

Kubernetes Manifest Builder

Build Deployment, StatefulSet, or DaemonSet workloads and export @@ -533,17 +546,19 @@

Kubernetes Manifest Builder

- Chart API v2 & v3 - +
+ Chart API v2 & v3 + +

Helm Chart Builder

Configure a workload and export a documented Helm chart API v2 or v3. @@ -551,17 +566,19 @@

Helm Chart Builder

- Fleet preview & guardrails - +
+ Fleet preview & guardrails + +

Argo CD ApplicationSet Studio

Simulate generators, preview Applications, and review fleet-wide @@ -570,17 +587,19 @@

Argo CD ApplicationSet Studio

- RBAC risk analysis - +
+ RBAC risk analysis + +

Kubernetes RBAC Explorer

Map effective permissions, test access, and surface risky RBAC grants. @@ -588,17 +607,19 @@

Kubernetes RBAC Explorer

- Visual CIDR planning - +
+ Visual CIDR planning + +

Visual Subnet Calculator

Split IPv4 CIDR blocks and share clear, visual subnet plans. @@ -606,31 +627,56 @@

Visual Subnet Calculator

- Runs locally in-browser - +
+ Formatting & validation + +

YAML & JSON Formatter

Validate, format, and convert configuration data without uploading it.

+ +
+ Configuration comparison + +
+

JSON & YAML Diff Checker

+

+ Compare JSON and YAML, review field changes, and match reordered + arrays or Kubernetes manifests. +

+ +
- Crontab generator & tester - +
+ Crontab generator & tester + +

Cron Schedule Builder

Generate and test cron expressions, preview runs across time zones, and export @@ -639,17 +685,19 @@

Cron Schedule Builder

- Public AWS modules - +
+ Public AWS modules + +

Terraform Modules

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 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + JSON Diff Checker – Compare JSON & YAML Online | Native Cube + + + + + + + + + + +

+ +

+ 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

+

JSON & YAML
Diff Checker

+
+

+ 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. +

+
+ + + +
+
+
01 / Compare

Spot the difference.

+
Browser only · No uploads
+
+ +
+ + +
+
+
+
+ Ready to compare + + 0 added + − 0 removed + ~ 0 changed +
+ +
+
+
+ + No changes + +
+ Differences are highlighted directly in your inputs. + +
+

Highlighted lines show changed fields; brighter text marks the edited values. Use Next / Previous or Alt + ↓ / ↑ to jump between changes. Input stays editable.

+
+ +
+
+
01

Original

+
+ + + + +
+
+
+ +
+ + +
+
+
Ready for inputTab: indent · Shift+Tab: leave
+ +
+ +
+
+
02

Updated

+
+ + + + +
+
+
+ +
+ + +
+
+
Ready for inputTab: indent · Shift+Tab: leave
+ +
+ +
+
+ + + + +
+

Object key order, indentation, and YAML comments are ignored. Array positions matter. Ctrl/⌘ + Enter compares.

+
+ +
Ready to compare

Paste two documents, then choose Compare documents.

+
+
+ +
+
+
02 / Review

A clear view of what changed.

+
+
+
+
—Total changes
+
—+ Added
+
—− Removed
+
—~ Changed
+
+
+ + +
+ Result layout +
+ + +
+
+ +
+

Review − original and + updated values. Highlighted text pinpoints edits. Reports include all changes; paths use JSON Pointer escaping.

+
+
Field changes− Original+ Updated
+ +

Your comparison will appear here.

+
+ +
+ +
+
03 / How to compare

Compare data.
Understand the change.

+
+
01

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.

+
02

Choose how items match

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.

+
03

Review and export

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.

+
+
+ +
+
+
+ 04 / Worked examples +

JSON and YAML diff examples.

+
+

+ 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. +

+
+
+
+
+
Original JSON

Before the API update

+
+
{
+  "service": "catalog-api",
+  "replicas": 2
+}
+
+
+
+
Updated JSON

A changed value and a new field

+
+
{
+  "replicas": 3,
+  "service": "catalog-api",
+  "debug": false
+}
+
+

JSON result: /replicas changes from 2 to 3, and /debug is added. Moving service creates no change.

+
+
+
Original YAML

Before the configuration update

+
+
# Current release
+service:
+  name: catalog-api
+  replicas: 2
+  image: catalog:1.0
+
+
+
+
Updated YAML

Compare values across reordered keys

+
+
# 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.

+
+
+ +
+
05 / Common questions

JSON and YAML comparison, explained.

Use this checker for API responses, Helm values, Docker Compose configuration, and Kubernetes manifest reviews.

+
+ What is a structural JSON diff? +

+ 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. +

+
+
+ Can I compare as I type and use keyboard shortcuts? +

+ 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. +

+
+
+ Can I compare YAML files or JSON against YAML? +

+ 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. +

+
+
+ How does ignoring array order work? +

+ 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. +

+
+
+ How do I compare Kubernetes YAML manifests? +

+ 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. +

+
+
+ How do I read the field paths? +

+ 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. +

+
+
+ Is this JSON diff checker private and free? +

+ 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. +

+
+
+ Are there file size or number precision limits? +

+ 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. +

+
+
+ + +
+ + +
+ + diff --git a/json-diff/source-map.js b/json-diff/source-map.js new file mode 100644 index 0000000..79eb9a6 --- /dev/null +++ b/json-diff/source-map.js @@ -0,0 +1,158 @@ +(function (root) { + "use strict"; + + const pointer = (path, key) => `${path}/${String(key).replace(/~/g, "~0").replace(/\//g, "~1")}`; + const decode = (key) => key.replace(/~1/g, "/").replace(/~0/g, "~"); + + function jsonLocations(source) { + const locations = new Map(); + let index = 0; + const space = () => { while (index < source.length && /\s/.test(source[index])) index++; }; + function string() { + const start = index++; + while (index < source.length) { + const char = source[index++]; + if (char === "\\") index++; + else if (char === '"') break; + } + return JSON.parse(source.slice(start, index)); + } + function walk(path, keyStart) { + space(); + const start = index; + if (source[index] === "{") { + index++; + space(); + while (source[index] !== "}") { + const childStart = index; + const key = string(); + space(); + index++; + walk(pointer(path, key), childStart); + space(); + if (source[index] !== ",") break; + index++; + space(); + } + index++; + } else if (source[index] === "[") { + index++; + space(); + let item = 0; + while (source[index] !== "]") { + walk(pointer(path, item++)); + space(); + if (source[index] !== ",") break; + index++; + } + index++; + } else if (source[index] === '"') string(); + else while (index < source.length && !/[\s,\]}]/.test(source[index])) index++; + locations.set(path, { start, end: index, keyStart: keyStart ?? start }); + } + walk(""); + return [locations]; + } + + function yamlLocations(source, yaml) { + const stack = []; + const roots = []; + yaml.loadAll(source, null, { + schema: yaml.JSON_SCHEMA, + listener(event, state) { + if (event === "open") stack.push({ start: state.position, children: [] }); + else { + const node = stack.pop(); + Object.assign(node, { end: state.position, kind: state.kind, value: state.result }); + if (stack.length) stack[stack.length - 1].children.push(node); + else roots.push(node); + } + }, + }); + function trimStart(start, end) { + while (start < end) { + if (/\s/.test(source[start])) start++; + else if (source[start] === "#") { + const newline = source.indexOf("\n", start); + start = newline < 0 ? end : newline + 1; + } else break; + } + return start; + } + return roots.map((node) => { + const locations = new Map(); + function visit(current, path, keyStart) { + const start = trimStart(current.start, current.end); + let end = current.end; + while (end > start && /\s/.test(source[end - 1])) end--; + locations.set(path, { start, end, keyStart: keyStart ?? start, alias: current.kind === null }); + // js-yaml can wrap a flow collection while probing for a block mapping. + let children = current.children; + while (children.length === 1 && children[0].value === current.value && children[0].kind === current.kind) children = children[0].children; + if (current.kind === "mapping") { + for (let index = 0; index + 1 < children.length; index += 2) { + const key = children[index]; + const value = children[index + 1]; + visit(value, pointer(path, String(key.value)), trimStart(key.start, key.end)); + } + } else if (current.kind === "sequence") { + children.forEach((child, index) => visit(child, pointer(path, index))); + } + } + visit(node, ""); + return locations; + }); + } + + // Build only after NativeDiff.parse has validated the input and its size limits. + function build(source, parsed, yaml = root.jsyaml) { + return parsed.format === "json" ? jsonLocations(source) : yamlLocations(source, yaml); + } + + function locate(locations, parsed, change, side, options = {}) { + if (!Object.prototype.hasOwnProperty.call(change, side)) return null; + const segments = change.path ? change.path.slice(1).split("/").map(decode) : []; + let documentIndex = 0; + let sourcePath = ""; + let value = parsed.documents[0]; + if (options.documents === "kubernetes") { + segments.shift(); + const identity = segments.shift(); + const array = parsed.documents.length === 1 && Array.isArray(value); + const resources = array ? value : parsed.documents; + const index = resources.findIndex((resource) => JSON.stringify([ + resource.apiVersion, resource.kind, resource.metadata.namespace || "", resource.metadata.name, + ]) === identity); + if (index < 0) return null; + if (array) sourcePath = pointer("", index); + else documentIndex = index; + value = resources[index]; + } else if (segments[0] === "documents" && (parsed.documents.length > 1 || options.multipleDocuments)) { + segments.shift(); + documentIndex = Number(segments.shift()); + value = parsed.documents[documentIndex]; + } + const map = locations[documentIndex]; + if (!map) return null; + let alias = map.get(sourcePath)?.alias ? map.get(sourcePath) : null; + for (let key of segments) { + if (Array.isArray(value)) { + if (options.arrayMode === "keyed" && key.startsWith(`${options.arrayKey || "name"}=`)) { + const field = options.arrayKey || "name"; + key = value.findIndex((item) => `${field}=${JSON.stringify(item[field])}` === key); + } + key = Number(key); + if (!Number.isInteger(key) || key < 0 || key >= value.length) return null; + } else if (!value || typeof value !== "object" || !Object.prototype.hasOwnProperty.call(value, key)) return null; + sourcePath = pointer(sourcePath, key); + value = value[key]; + if (map.get(sourcePath)?.alias) alias = map.get(sourcePath); + } + const range = map.get(sourcePath) || alias; + return range ? { ...range, documentIndex, sourcePath } : null; + } + + const api = { build, locate }; + if (typeof module !== "undefined" && module.exports) module.exports = api; + else root.NativeDiffSource = api; +})(typeof globalThis !== "undefined" ? globalThis : this); diff --git a/json-diff/styles.css b/json-diff/styles.css new file mode 100644 index 0000000..d1dc32e --- /dev/null +++ b/json-diff/styles.css @@ -0,0 +1,1228 @@ +/* Header, footer, editors and design tokens come from the formatter theme. */ +:root { + --green: #116b4c; + --control-line: #c6d8ff; + --control-hover: #d4e3ff; + --value-divider: #34485f; + --code-muted: #b7c7db; + --code-added: #9ce9c0; + --code-removed: #ffbbc6; +} + +[hidden] { + display: none !important; +} + +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; + border: 0; +} + +.breadcrumbs { + display: flex; + flex-wrap: wrap; + gap: 10px; + padding-top: 28px; + color: var(--muted); + font-size: 11px; +} + +.breadcrumbs a, +.workspace-note a, +.faq-section a { + color: var(--blue-dark); + text-underline-offset: 3px; +} + +.hero { + padding: 35px 0 34px; + gap: 50px; +} + +.hero h1 { + font-size: clamp(44px, 5.8vw, 80px); +} + +.workspace-toolbar, +.toolbar-actions, +.report-actions, +.results-heading, +.result-toolbar { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 16px; +} + +.workspace-toolbar { + margin: 24px 0 18px; + align-items: end; +} + +.workspace-toolbar > .select-field { + flex: 0 1 240px; +} + +.workspace-toolbar > .example-button { + min-height: 42px; +} + +.toolbar-actions { + margin-left: auto; + gap: 22px; + min-height: 42px; +} + +.editor-grid { + gap: 16px; +} + +.editor-heading { + flex-wrap: wrap; + gap: 8px; + padding: 12px; +} + +.editor-heading > .editor-actions { + gap: 14px; + flex-wrap: wrap; +} + +.editor-format select { + width: 128px; + max-width: 100%; + min-height: 34px; + padding: 6px 34px 6px 10px; + -webkit-appearance: none; + appearance: none; + color: var(--ink); + background-color: var(--paper); + background-image: var(--select-arrow); + background-position: right 12px center; + background-repeat: no-repeat; + background-size: 12px 8px; + border: 1px solid var(--line); + border-radius: 6px; + font-size: 11px; +} + +.editor-frame textarea:focus-visible { + outline: 2px solid #7bb0ff; + outline-offset: -3px; +} + +.line-numbers { + color: #a3b5ca; +} + +.editor-meta { + flex-wrap: wrap; + gap: 5px; +} + +.input-error { + margin: 0; + padding: 12px 14px; + color: var(--red); + background: var(--red-soft); + font: 12px/1.6 ui-monospace, SFMono-Regular, Menlo, monospace; + white-space: pre-wrap; + overflow-wrap: anywhere; +} + +.diff-settings { + display: flex; + flex-wrap: wrap; + align-items: end; + gap: 16px; + margin: 20px 0 12px; +} + +.diff-settings > .select-field { + flex: 1 1 200px; +} + +.diff-settings > .primary-button { + flex: 1 1 220px; + width: auto; +} + +.select-field input { + width: 100%; + min-width: 0; + min-height: 42px; + padding: 8px 11px; + background: var(--card-strong); + border: 1px solid var(--line-strong); + border-radius: 9px; + font-size: 12px; +} + +.workspace-note { + margin: 12px 0; + color: var(--muted); + font-size: 12px; + line-height: 1.65; +} + +.validation-panel p { + font-size: 12px; + overflow-wrap: anywhere; +} + +.results-section { + padding: 48px 0 0; + scroll-margin-top: 24px; +} + +.results-heading { + justify-content: space-between; + gap: 20px; +} + +.results-heading h2 { + margin: 9px 0 0; + font-size: clamp(23px, 3vw, 32px); + letter-spacing: -0.04em; +} + +.report-actions { + gap: 10px; +} + +.report-actions .example-button { + width: auto; + min-height: 38px; +} + +.summary-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 14px; + margin: 24px 0; +} + +.summary-item { + padding: 20px; + border: 1px solid var(--line); + border-radius: 12px; + background: var(--card-strong); +} + +.summary-item > span { + display: block; + margin-bottom: 9px; + font-size: 32px; + font-weight: 750; + letter-spacing: -0.05em; +} + +.summary-item > strong { + font-size: 12px; +} + +.summary-item.added { + color: var(--green); + background: var(--green-soft); + border-color: #b8decd; +} + +.summary-item.removed { + color: var(--red); + background: var(--red-soft); + border-color: #e5b9bf; +} + +.summary-item.changed { + color: var(--blue-dark); + background: var(--blue-soft); + border-color: #b9ceff; +} + +.result-toolbar { + align-items: end; + gap: 16px; +} + +.result-toolbar > .select-field { + flex: 0 1 180px; +} + +.result-toolbar > .path-search { + flex: 1 1 240px; +} + +.result-toolbar > .workspace-note { + flex-basis: 100%; + min-width: 0; + margin: 0; +} + +.result-view { + flex: 0 0 auto; +} + +.result-view .segmented-control span { + padding: 10px 14px; +} + +.diff-viewer { + overflow: hidden; + border: 1px solid var(--line-strong); + border-radius: 14px; + background: var(--card-strong); + box-shadow: 0 12px 32px rgba(23, 55, 94, 0.08); +} + +.diff-viewer-heading { + display: flex; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; + gap: 12px; + padding: 17px 20px; + color: var(--editor-ink); + background: var(--editor); + font-size: 13px; + font-weight: 750; +} + +.diff-legend { + display: flex; + flex-wrap: wrap; + gap: 20px; + font: 600 12px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace; +} + +.diff-legend > :first-child { + color: var(--code-removed); +} + +.diff-legend > :last-child { + color: var(--code-added); +} + +.diff-column-headings { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.diff-column-headings > span { + padding: 14px 20px; + font-size: 13px; + font-weight: 800; + border-bottom: 1px solid var(--line); +} + +.diff-column-headings > span + span { + border-left: 1px solid var(--line); +} + +.diff-column-headings small { + margin-left: 10px; + color: var(--muted); + font-size: 11px; + font-weight: 500; +} + +.diff-results { + display: block; +} + +.change-card { + min-width: 0; + overflow: hidden; + border-bottom: 1px solid var(--line-strong); +} + +.change-card:last-child { + border-bottom: 0; +} + +.change-heading { + display: flex; + align-items: center; + gap: 14px; + padding: 13px 18px 13px 15px; + border-bottom: 1px solid var(--line); + border-left: 3px solid var(--blue); + background: #f5f8fc; +} + +.change-card.added .change-heading { + border-left-color: var(--green); +} + +.change-card.removed .change-heading { + border-left-color: var(--red); +} + +.change-number { + flex: 0 0 24px; + color: var(--muted); + font: 600 11px/1.6 ui-monospace, SFMono-Regular, Menlo, monospace; +} + +.change-heading h3 { + flex: 1; + min-width: 0; + margin: 0; + overflow-wrap: anywhere; + font: 500 14px/1.6 ui-monospace, SFMono-Regular, Menlo, monospace; +} + +.field-path { + display: block; +} + +.path-parent { + color: var(--muted); +} + +.field-path strong { + color: var(--ink); + font-weight: 800; +} + +.resource-context { + display: block; + margin-bottom: 4px; + color: var(--blue-dark); + font: 700 12px/1.5 ui-sans-serif, system-ui, sans-serif; +} + +.change-label { + flex: 0 0 auto; + padding: 5px 9px; + border-radius: 6px; + font-size: 11px; + font-weight: 800; + white-space: nowrap; +} + +.change-card.added .change-label { + color: var(--green); + background: var(--green-soft); +} + +.change-card.removed .change-label { + color: var(--red); + background: var(--red-soft); +} + +.change-card.changed .change-label { + color: var(--blue-dark); + background: var(--blue-soft); +} + +.value-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.value-pane { + min-width: 0; + padding: 13px 18px 17px; + color: var(--editor-ink); +} + +.value-pane + .value-pane { + border-left: 1px solid var(--value-divider); +} + +.value-pane.before-value { + background: linear-gradient(90deg, rgba(179, 61, 74, 0.13), transparent), var(--editor); +} + +.value-pane.after-value { + background: linear-gradient(90deg, rgba(22, 128, 90, 0.15), transparent), var(--editor); +} + +.value-pane.value-absent { + background: #12243a; +} + +.value-label { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + margin: 0 0 8px 28px; + color: var(--code-muted); + font-size: 10px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.06em; +} + +.value-type { + padding: 2px 6px; + color: #c8d7e8; + background: #263a52; + border-radius: 4px; + font: 500 10px/1.4 ui-monospace, SFMono-Regular, Menlo, monospace; + text-transform: none; + letter-spacing: 0; +} + +.value-content { + display: grid; + grid-template-columns: 18px minmax(0, 1fr); + gap: 10px; +} + +.value-marker { + color: var(--code-removed); + font: 700 17px/1.55 ui-monospace, SFMono-Regular, Menlo, monospace; + user-select: none; +} + +.after-value .value-marker { + color: var(--code-added); +} + +.value-pane pre { + margin: 0; + max-height: 280px; + overflow: auto; + white-space: pre-wrap; + overflow-wrap: anywhere; + font: 14px/1.8 ui-monospace, SFMono-Regular, Menlo, monospace; + tab-size: 2; +} + +.value-absent pre { + color: #a8bbd2; + font-style: italic; +} + +.value-absent .value-marker { + color: #a8bbd2; +} + +.json-key { + color: #a8cfff; +} + +.json-string { + color: #b9e8cc; +} + +.json-number { + color: #f7d695; +} + +.json-boolean { + color: #dac0ff; +} + +.json-null { + color: #c4cfdf; +} + +.inline-change { + padding: 2px 0; + color: #fff; + background: #87394c; + border-radius: 3px; + font-weight: 800; + box-decoration-break: clone; + -webkit-box-decoration-break: clone; +} + +.after-value .inline-change { + background: #1a694f; +} + +.preview-notice { + color: var(--code-muted); + font: 12px/1.8 ui-sans-serif, system-ui, sans-serif; +} + +.empty-state { + margin: 0; + padding: 44px 20px; + text-align: center; + color: var(--muted); + font-size: 14px; + line-height: 1.7; +} + +.diff-viewer[data-view="unified"] .diff-column-headings { + display: none; +} + +.diff-viewer[data-view="unified"] .value-grid { + grid-template-columns: 1fr; +} + +.diff-viewer[data-view="unified"] .value-pane + .value-pane { + border-left: 0; + border-top: 1px solid var(--value-divider); +} + +.diff-viewer[data-view="unified"] .value-absent { + display: none; +} + +#more-button { + margin-top: 20px; +} + +.guide-grid code, +.examples-section code, +.faq-section code { + overflow-wrap: anywhere; + font-size: 0.95em; +} + +.example-grid pre { + min-height: 170px; +} + +.content-heading p { + overflow-wrap: anywhere; +} + +.faq-section details { + border-top: 1px solid var(--line); +} + +.faq-section details:last-child { + border-bottom: 1px solid var(--line); +} + +.faq-section summary { + padding: 22px 6px; + cursor: pointer; + font-size: 15px; + font-weight: 750; +} + +.faq-section summary:focus-visible { + outline: 3px solid var(--blue); + outline-offset: 2px; +} + +.faq-section details p { + max-width: 920px; + margin: 0; + padding: 0 6px 24px; + color: var(--muted); + font-size: 14px; + line-height: 1.8; +} + +@media (min-width: 901px) { + .editor-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + +} + +@media (max-width: 900px) { + .editor-grid { + grid-template-columns: 1fr; + } + +} + +@media (max-width: 600px) { + .hero { + padding: 28px 0 32px; + gap: 24px; + } + + .hero h1 { + font-size: clamp(42px, 11vw, 66px); + } + + .hero-copy { + font-size: 15px; + } + + .workspace-toolbar > .select-field { + flex-basis: 100%; + } + + .toolbar-actions { + margin-left: 0; + gap: 18px; + } + + .editor-heading { + flex-direction: row; + align-items: center; + } + + .editor-heading > .editor-actions { + width: 100%; + } + + .summary-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; + } + + .summary-item { + padding: 16px; + } + + .value-grid { + grid-template-columns: 1fr; + } + + .diff-column-headings { + display: none; + } + + .value-pane + .value-pane { + border-left: 0; + border-top: 1px solid var(--value-divider); + } + + .value-pane { + padding: 12px 14px 16px; + } + + .value-pane pre { + font-size: 13px; + } + + .change-heading { + flex-wrap: wrap; + gap: 8px; + padding: 12px 11px; + } + + .change-heading h3 { + font-size: 13px; + } + + .change-number { + flex-basis: 20px; + font-size: 10px; + } + + .change-label { + font-size: 10px; + padding: 4px 6px; + } + + .diff-viewer-heading { + padding: 14px; + } + + .diff-legend { + gap: 12px; + font-size: 11px; + } + + .result-view { + flex-basis: 100%; + } + + .result-toolbar > .select-field { + flex: 1 1 100%; + } + + .diff-settings > .select-field, + .diff-settings > .primary-button { + flex-basis: 100%; + } + + .results-section { + padding-top: 36px; + } + +} + +@media (prefers-reduced-motion: reduce) { + html { + scroll-behavior: auto; + } + + *, *::before, *::after { + transition: none !important; + } + +} + +@media (forced-colors: active) { + .editor-format select { + -webkit-appearance: auto; + appearance: auto; + background-image: none; + } + + .change-label, .inline-change { + border: 1px solid CanvasText; + } + + .change-heading { + border-left-color: CanvasText; + } + + .value-pane, .value-pane.value-absent { + color: CanvasText; + background: Canvas; + } + +} + +/* Editable source views: the text layer and highlights share exact metrics. */ +.tool-shell { + --source-font-size: 13px; + --source-line-height: 22px; +} + +.live-review-bar { + display: flex; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; + gap: 14px; + padding: 16px 18px; + background: var(--blue-soft); + border: 1px solid var(--control-line); + border-radius: 12px 12px 0 0; +} + +.live-change-counts { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 10px; +} + +.live-change-counts > strong { + margin-right: 8px; + font-size: 14px; + letter-spacing: -0.02em; +} + +.live-count { + padding: 5px 8px; + border-radius: 6px; + font: 700 11px/1.4 ui-monospace, SFMono-Regular, Menlo, monospace; + white-space: nowrap; +} + +.live-count.added { + color: var(--green); + background: var(--green-soft); +} + +.live-count.removed { + color: var(--red); + background: var(--red-soft); +} + +.live-count.changed { + color: var(--blue-dark); + background: var(--control-hover); +} + +.live-toggle { + display: inline-flex; + align-items: center; + gap: 8px; + font-size: 12px; + font-weight: 700; + cursor: pointer; +} + +.live-toggle input { + margin: 0; + width: 16px; + height: 16px; + accent-color: var(--blue); +} + +.change-navigation { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 16px; + padding: 13px 18px; + border: 1px solid var(--control-line); + border-top: 0; + border-radius: 0 0 12px 12px; + background: var(--card-strong); + scroll-margin-top: 16px; +} + +.change-navigation-actions { + display: inline-flex; + align-items: center; + gap: 8px; +} + +.navigation-button { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 7px; + min-height: 34px; + padding: 7px 10px; + color: var(--blue-dark); + background: var(--blue-soft); + border: 1px solid var(--control-line); + border-radius: 7px; + font-size: 11px; + font-weight: 750; +} + +.navigation-button:hover:not(:disabled) { + background: var(--control-hover); +} + +.change-position { + min-width: 82px; + color: var(--muted); + text-align: center; + font: 700 11px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace; +} + +.active-change { + flex: 1 1 240px; + min-width: 0; + color: var(--muted); + overflow-wrap: anywhere; + font-size: 11px; + line-height: 1.6; +} + +.source-highlight-help { + margin: 13px 0 18px; + color: var(--muted); + font-size: 11px; + line-height: 1.7; +} + +.editor-frame { + height: 380px; + grid-template-columns: 60px minmax(0, 1fr); +} + +.editor-surface { + position: relative; + min-width: 0; + height: 100%; + overflow: hidden; +} + +.editor-surface textarea, +.source-backdrop { + width: 100%; + height: 100%; + margin: 0; + padding: 18px 16px; + border: 0; + font: var(--source-font-size)/var(--source-line-height) ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + letter-spacing: normal; + tab-size: 2; + white-space: pre; +} + +.source-backdrop { + position: absolute; + inset: 0; + overflow: hidden; + color: var(--editor-ink); + background: var(--editor); + pointer-events: none; +} + +.source-backdrop code { + display: block; + box-sizing: content-box; + font: inherit; +} + +.editor-surface textarea { + position: relative; + z-index: 1; + display: block; + background: transparent; +} + +.editor-surface.has-backdrop textarea { + color: transparent; + -webkit-text-fill-color: transparent; + caret-color: #fff; +} + +.editor-surface textarea::selection { + color: transparent; + background: rgba(96, 155, 255, 0.36); +} + +.editor-surface textarea::placeholder { + color: #a8bbd0; + -webkit-text-fill-color: #a8bbd0; + opacity: 1; +} + +.editor-frame .line-numbers { + padding: 18px 6px; + font: 11px/var(--source-line-height) ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; +} + +.source-gutter-content { + display: block; +} + +.source-line { + display: block; + height: var(--source-line-height); + line-height: var(--source-line-height); +} + +.source-gutter-line { + display: grid; + grid-template-columns: 10px minmax(0, 1fr); + gap: 2px; + height: var(--source-line-height); + line-height: var(--source-line-height); +} + +.source-gutter-line > :first-child { + text-align: center; + font-weight: 800; +} + +.source-line.is-added { + background: rgba(22, 128, 90, 0.22); + box-shadow: inset 3px 0 #48bf8d; +} + +.source-line.is-removed { + background: rgba(179, 61, 74, 0.23); + box-shadow: inset 3px 0 #ed889c; +} + +.source-line.is-changed { + background: rgba(63, 112, 194, 0.23); + box-shadow: inset 3px 0 #8eb6ff; +} + +.source-gutter-line.is-added { + color: var(--code-added); +} + +.source-gutter-line.is-removed { + color: var(--code-removed); +} + +.source-gutter-line.is-changed { + color: #b5d3ff; +} + +.source-line.is-active { + outline: 1px solid #b7d2ff; + outline-offset: -1px; +} + +.source-gutter-line.is-active { + color: #fff; + background: #304d76; +} + +.source-token { + padding: 0; + border-radius: 2px; + color: #fff; + background: #256d53; + font: inherit; +} + +#before-highlight .source-token { + background: #8b3d51; +} + +.source-jump { + font-size: 10px; +} + +.change-card:focus-visible { + outline: 2px solid var(--blue); + outline-offset: -2px; +} + +@media (max-width: 900px) { + .editor-frame { + height: 320px; + } + +} + +@media (max-width: 600px) { + .tool-shell { + --source-font-size: 12px; + --source-line-height: 21px; + } + + .live-review-bar, .change-navigation { + padding: 13px 12px; + gap: 12px; + } + + .live-change-counts > strong { + flex-basis: 100%; + } + + .live-change-counts { + gap: 7px; + } + + .live-count { + font-size: 10px; + } + + .change-navigation-actions { + flex: 1 1 100%; + justify-content: space-between; + } + + .active-change { + flex-basis: 100%; + } + + .editor-frame { + height: 290px; + grid-template-columns: 56px minmax(0, 1fr); + } + + .editor-frame .line-numbers { + font-size: 10px; + } + + .editor-surface textarea, .source-backdrop { + padding-right: 12px; + padding-left: 12px; + } + + .source-jump { + margin-left: 28px; + } + +} + +@media (forced-colors: active) { + .source-backdrop { + display: none; + } + + .editor-surface.has-backdrop textarea { + color: CanvasText; + -webkit-text-fill-color: CanvasText; + caret-color: CanvasText; + } + + .editor-surface textarea::selection { + color: HighlightText; + background: Highlight; + } + + .source-gutter-line { + color: CanvasText; + } + + .live-count, .source-gutter-line.is-active { + border: 1px solid CanvasText; + } + +} + +.page-links { + display: flex; + flex-wrap: wrap; + gap: 8px 22px; + margin: -8px 0 28px; +} + +.page-links a { + padding: 5px 0; + color: var(--blue-dark); + font-size: 12px; + font-weight: 700; + text-underline-offset: 4px; +} + +.example-outcome { + grid-column: 1 / -1; + margin: -4px 0 14px; + color: var(--muted); + font-size: 13px; + line-height: 1.8; +} + +.example-outcome strong { + color: var(--ink); +} + +.faq-section { + padding-bottom: 56px; +} + +.related-tools { + padding: 64px 0 76px; + border-top: 1px solid var(--line); +} + +.related-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 18px; +} + +.related-grid a { + padding: 24px; + color: var(--ink); + background: var(--card-strong); + border: 1px solid var(--line); + border-radius: 12px; + text-decoration: none; +} + +.related-grid a:hover { + border-color: var(--blue); +} + +.related-grid a > span { + color: var(--blue-dark); + font-size: 11px; + font-weight: 750; +} + +.related-grid h3 { + margin: 18px 0 12px; + font-size: 17px; + line-height: 1.4; + letter-spacing: -0.025em; +} + +.related-grid h3 b { + color: var(--blue); +} + +.related-grid p { + margin: 0; + color: var(--muted); + font-size: 13px; + line-height: 1.8; +} + +@media (max-width: 840px) { + .related-grid { + grid-template-columns: 1fr; + } + +} diff --git a/scripts/json-diff.test.cjs b/scripts/json-diff.test.cjs new file mode 100644 index 0000000..f99e3fc --- /dev/null +++ b/scripts/json-diff.test.cjs @@ -0,0 +1,213 @@ +"use strict"; + +const test = require("node:test"); +const assert = require("node:assert/strict"); +const yaml = require("../yaml-formatter/vendor/js-yaml.min.js"); +const { parse, compare } = require("../json-diff/diff.js"); +const sourceMap = require("../json-diff/source-map.js"); +const json = (value) => parse(JSON.stringify(value), "json", yaml); +const yml = (source) => parse(source, "yaml", yaml); +const diff = (before, after, options) => compare(json(before), json(after), options); + +test("ignores object key order and reports precise added, removed and changed paths", () => { + assert.deepEqual(diff({ same: { b: 2, a: 1 }, removed: null, changed: 2 }, { changed: 3, added: false, same: { a: 1, b: 2 } }), [ + { type: "added", path: "/added", after: false }, + { type: "changed", path: "/changed", before: 2, after: 3 }, + { type: "removed", path: "/removed", before: null }, + ]); +}); + +test("preserves types, missing versus null, empty keys and root changes", () => { + for (const [before, after] of [[null, {}], [3, "3"], [false, 0], [[], {}], ["", null]]) { + assert.deepEqual(diff(before, after), [{ type: "changed", path: "", before, after }]); + } + assert.deepEqual(diff({}, { "": null }), [{ type: "added", path: "/", after: null }]); + assert.deepEqual(diff({ x: null }, {}), [{ type: "removed", path: "/x", before: null }]); +}); + +test("escapes JSON Pointer paths and handles prototype-looking property names safely", () => { + const before = parse('{"a/b~c":1,"__proto__":{"safe":true},"constructor":1}', "json"); + const after = parse('{"a/b~c":2,"__proto__":{"safe":false},"constructor":2}', "json"); + assert.deepEqual(compare(before, after).map((entry) => entry.path), ["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/__proto__/safe", "/a~1b~0c", "/constructor"]); + assert.equal({}.safe, undefined); +}); + +test("ordered arrays track positions and preserve added/removed container values", () => { + assert.deepEqual(diff([1, 2], [2, 1, { x: 3 }]), [ + { type: "changed", path: "/0", before: 1, after: 2 }, + { type: "changed", path: "/1", before: 2, after: 1 }, + { type: "added", path: "/2", after: { x: 3 } }, + ]); + assert.deepEqual(diff({ x: { a: 1, b: 2 } }, {}), [{ type: "removed", path: "/x", before: { a: 1, b: 2 } }]); +}); + +test("unordered arrays compare complete values recursively and retain duplicate counts", () => { + const options = { arrayMode: "unordered" }; + assert.deepEqual(diff([{ b: [1, 2], a: 1 }, 2, 2], [2, { a: 1, b: [2, 1] }, 2], options), []); + assert.deepEqual(diff([1, 1], [1], options), [{ type: "removed", path: "/1", before: 1 }]); + assert.deepEqual(diff(["1"], [1], options), [ + { type: "removed", path: "/0", before: "1" }, + { type: "added", path: "/0", after: 1 }, + ]); + assert.deepEqual(diff([{ id: 1, value: "old" }], [{ id: 1, value: "new" }], options).map((entry) => entry.type), ["removed", "added"]); +}); + +test("keyed arrays match reordered objects and preserve scalar identity types", () => { + const options = { arrayMode: "keyed", arrayKey: "id" }; + const changes = diff([{ id: 1, x: 2 }, { id: "1", x: 3 }], [{ id: "1", x: 3 }, { id: 1, x: 4 }], options); + assert.deepEqual(changes, [{ type: "changed", path: "/id=1/x", before: 2, after: 4 }]); + assert.deepEqual(diff([1, 2], [2, 1], options).length, 2); + assert.deepEqual(diff([], [], options), []); + assert.throws(() => diff([{ id: 1 }, { id: 1 }], [], options), /Duplicate array key/); + assert.throws(() => diff([{ x: 1 }], [{ id: 1 }], options), /unique.*id/); +}); + +test("YAML and JSON compare across formats, comments, anchors, booleans and dates", () => { + assert.deepEqual(compare(yml("# config\ndate: 2026-11-02\non: true\nbase: &base\n replicas: 2\ncopy: *base\n"), json({ copy: { replicas: 2 }, base: { replicas: 2 }, on: true, date: "2026-11-02" })), []); + assert.deepEqual(compare(yml("null"), json(null)), []); + assert.deepEqual(compare(yml("---\n"), json(null)), []); +}); + +test("automatic detection handles JSON scalars and YAML keys starting with literal words", () => { + for (const source of ['{"x":1}', '[1]', '"hello"', 'true', 'false', 'null', '-1.25e2']) { + assert.equal(parse(source, "auto", yaml).format, "json"); + } + for (const source of ["trueValue: yes", "nullish: value", "2026: value", "name: service"]) { + assert.equal(parse(source, "auto", yaml).format, "yaml"); + } + assert.equal(yml("{name: service}").documents[0].name, "service"); + assert.throws(() => parse("{name: service}", "auto", yaml)); +}); + +test("rejects malformed JSON and duplicate keys including escaped equivalent keys", () => { + for (const source of ['{"x":1,}', '{"x":}', '[,]', '[1 2]', 'true false', '{"x":1,"x":2}', '{"a":1,"\\u0061":2}', '{"nested":[{"x":1,"x":2}]}']) { + assert.throws(() => parse(source, "json"), undefined, source); + } + assert.deepEqual(parse('{"a":{"x":1},"b":{"x":2}}', "json").documents, [{ a: { x: 1 }, b: { x: 2 } }]); + assert.equal(parse('{"string":"a\\\"b\\\\c","a":1}', "json").documents[0].string, 'a"b\\c'); +}); + +test("rejects invalid YAML, duplicate mappings, custom tags and recursive aliases", () => { + for (const source of ['x: [1', 'x: 1\nx: 2', 'x: !custom value', 'x: &x\n child: *x', 'x: .inf', 'x: .nan']) { + assert.throws(() => yml(source), undefined, source); + } + assert.throws(() => parse("x: 1", "yaml", null), /parser could not load/); +}); + +test("multi-document YAML reports document positions without conflating a JSON array", () => { + assert.deepEqual(compare(yml("x: 1\n---\nx: 2"), yml("x: 1\n---\nx: 3\n---\nx: 4")), [ + { type: "changed", path: "/documents/1/x", before: 2, after: 3 }, + { type: "added", path: "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/documents/2", after: { x: 4 } }, + ]); + assert.notDeepEqual(compare(yml("x: 1\n---\nx: 2"), json([{ x: 1 }, { x: 2 }])), []); +}); + +const resource = (name, namespace, extra = {}) => ({ apiVersion: "v1", kind: "ConfigMap", metadata: { name, ...(namespace === undefined ? {} : { namespace }) }, ...extra }); +test("Kubernetes mode matches reordered YAML documents and JSON resource arrays", () => { + const first = resource("first", "production", { data: { x: "1" } }); + const second = resource("second", "production"); + const updated = resource("first", "production", { data: { x: "2" } }); + const before = yml([first, second].map((value) => yaml.dump(value)).join("---\n")); + const changes = compare(before, json([second, updated]), { documents: "kubernetes" }); + assert.equal(changes.length, 1); + assert.match(changes[0].path, /^\/resources\/.+\/data\/x$/); + assert.deepEqual([changes[0].before, changes[0].after], ["1", "2"]); + assert.deepEqual(compare(before, json([second, first]), { documents: "kubernetes" }), []); +}); + +test("Kubernetes matching rejects duplicates and distinguishes namespace and API version", () => { + const r = resource("app"); + assert.throws(() => diff([r, r], [], { documents: "kubernetes" }), /Duplicate resource identity/); + assert.throws(() => diff([{ kind: "Pod" }], [], { documents: "kubernetes" }), /apiVersion/); + assert.equal(diff([r], [resource("app", "default")], { documents: "kubernetes" }).length, 2); + assert.equal(diff([r], [{ ...r, apiVersion: "v2" }], { documents: "kubernetes" }).length, 2); +}); + +test("rejects oversized inputs, unsafe integers, deeply nested and excessive values", () => { + assert.throws(() => parse(" "), /Paste a document/); + assert.throws(() => parse('"' + 'x'.repeat(1024 * 1024) + '"', "json"), /1 MiB/); + assert.throws(() => parse('"' + '😀'.repeat(300000) + '"', "json"), /1 MiB/); + assert.throws(() => parse("9007199254740993", "json"), /safe range/); + assert.throws(() => parse("1e999", "json"), /finite/); + assert.equal(parse('"9007199254740993"', "json").documents[0], "9007199254740993"); + assert.throws(() => parse("[".repeat(82) + "0" + "]".repeat(82), "json"), /nesting depth/); + assert.throws(() => json(Array(50000).fill(0)), /expanded values/); + assert.throws(() => yml("text: &text " + "x".repeat(100000) + "\ncopies: [" + Array(50).fill("*text").join(",") + "]"), /Expanded YAML text/); +}); + +test("rejects reports over the change limit instead of presenting partial results", () => { + assert.throws(() => diff(Array(2001).fill(1), Array(2001).fill(2)), /More than 2,000 changes/); + assert.equal(diff(Array(2000).fill(1), Array(2000).fill(2)).length, 2000); +}); + +function locatedValues(original, updated, beforeFormat = "json", afterFormat = "json", options = {}) { + const parsed = { before: parse(original, beforeFormat, yaml), after: parse(updated, afterFormat, yaml) }; + const sources = { before: original, after: updated }; + const maps = Object.fromEntries(Object.entries(parsed).map(([side, value]) => [side, sourceMap.build(sources[side], value, yaml)])); + const settings = { ...options, multipleDocuments: Object.values(parsed).some((value) => value.documents.length > 1) }; + return compare(parsed.before, parsed.after, options).map((change) => ({ + path: change.path, + ...Object.fromEntries(["before", "after"].map((side) => { + const location = sourceMap.locate(maps[side], parsed[side], change, side, settings); + if (Object.prototype.hasOwnProperty.call(change, side)) assert.ok(location, `Missing ${side} source location for ${change.path}`); + return [side, location ? sources[side].slice(location.start, location.end) : null]; + })), + })); +} + +test("source highlights follow reordered JSON keys and distinguish repeated values", () => { + assert.deepEqual(locatedValues('{"same":2,"nested":{"x":2},"other":2}', '{"other":2,"nested":{"x":3},"same":2}'), [ + { path: "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/nested/x", before: "2", after: "3" }, + ]); + assert.deepEqual(locatedValues('{"a/b~c":"hello😀"}', '{"a/b~c":"hello😁"}'), [ + { path: "/a~1b~0c", before: '"hello😀"', after: '"hello😁"' }, + ]); +}); + +test("source locations cover YAML blocks, flow collections, empty values and escaped keys", () => { + const original = "items:\n - [a,b]\n - {x: 2, y: 2}\nempty:\n'key/with~escape': |\n original\n"; + const updated = "items:\n - [a,c]\n - {x: 3, y: 2}\nempty: false\n'key/with~escape': |\n updated\n"; + const values = locatedValues(original, updated, "yaml", "yaml"); + assert.deepEqual(values.map((item) => [item.path, item.before, item.after]), [ + ["/empty", "", "false"], + ["/items/0/1", "b", "c"], + ["/items/1/x", "2", "3"], + ["/key~1with~0escape", "|\n original", "|\n updated"], + ]); +}); + +test("source highlights respect keyed arrays and unordered duplicate counts", () => { + assert.deepEqual(locatedValues('[{"id":1,"x":2},{"id":2,"x":2}]', '[{"id":2,"x":2},{"id":1,"x":3}]', "json", "json", { arrayMode: "keyed", arrayKey: "id" }), [ + { path: "/id=1/x", before: "2", after: "3" }, + ]); + assert.deepEqual(locatedValues('[1,1,2]', '[2,1]', "json", "json", { arrayMode: "unordered" }), [ + { path: "/1", before: "1", after: null }, + ]); +}); + +test("source locations follow reordered Kubernetes documents across YAML and JSON", () => { + const first = resource("first", "production", { data: { x: "old" } }); + const second = resource("second", "production"); + const original = [first, second].map((value) => yaml.dump(value)).join("---\n"); + const updated = JSON.stringify([second, { ...first, data: { x: "new" } }], null, 2); + const values = locatedValues(original, updated, "yaml", "json", { documents: "kubernetes" }); + assert.equal(values.length, 1); + assert.deepEqual([values[0].before, values[0].after], ["old", '"new"']); +}); + +test("source highlights use alias references for inherited fields and preserve empty documents", () => { + const values = locatedValues('base: &base\n x: 1\ncopy: *base\n', 'base: &base\n x: 2\ncopy: *base\n', "yaml", "yaml"); + assert.deepEqual(values, [ + { path: "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/base/x", before: "1", after: "2" }, + { path: "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/copy/x", before: "*base", after: "*base" }, + ]); + assert.deepEqual(locatedValues('---\n---\nx: 1\n', '---\n---\nx: 2\n', "yaml", "yaml"), [ + { path: "/documents/1/x", before: "1", after: "2" }, + ]); +}); + +test("source highlights retain direct property names that resemble internal document paths", () => { + assert.deepEqual(locatedValues('{"documents":[1]}', '{"documents":[2]}'), [ + { path: "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/documents/0", before: "1", after: "2" }, + ]); +}); diff --git a/scripts/seo-audit.py b/scripts/seo-audit.py index ceb603e..30e3afc 100644 --- a/scripts/seo-audit.py +++ b/scripts/seo-audit.py @@ -179,16 +179,18 @@ def structured_entities(data): return [data] -def assert_crontab_seo(page, entities): - canonical = f"{SITE_URL}/crontab/" +def assert_tool_seo(page, entities, slug): + canonical = f"{SITE_URL}/{slug}/" by_type = {entity.get("@type"): entity for entity in entities if isinstance(entity.get("@type"), str)} required = {"WebPage", "WebApplication", "BreadcrumbList", "Person", "ImageObject"} missing = required - by_type.keys() if missing: - fail(f"crontab JSON-LD is missing entities: {', '.join(sorted(missing))}") + fail(f"{slug} JSON-LD is missing entities: {', '.join(sorted(missing))}") + if any(sum(entity.get("@type") == entity_type for entity in entities) != 1 for entity_type in required): + fail(f"{slug} must declare exactly one entity of each required type") identities = [by_type[entity_type].get("@id", "") for entity_type in required] if any(not identity.startswith(f"{canonical}#") for identity in identities) or len(set(identities)) != len(identities): - fail("crontab structured-data entities must have unique page-scoped IDs") + fail(f"{slug} structured-data entities must have unique page-scoped IDs") webpage = by_type["WebPage"] application = by_type["WebApplication"] breadcrumb = by_type["BreadcrumbList"] @@ -196,11 +198,11 @@ def assert_crontab_seo(page, entities): social_image = by_type["ImageObject"] title = "".join(page.title).strip() if not (webpage.get("name") == page.meta["og:title"] == page.meta["twitter:title"] == title): - fail("crontab page, social, and structured-data titles must agree") + fail(f"{slug} page, social, and structured-data titles must agree") if webpage.get("description") != page.meta["description"]: - fail("crontab structured-data description must match its meta description") + fail(f"{slug} structured-data description must match its meta description") if webpage.get("inLanguage", "").lower() != page.language.lower(): - fail("crontab structured-data language must match the HTML language") + fail(f"{slug} structured-data language must match the HTML language") for relationship, entity in [ ("mainEntity", application), ("breadcrumb", breadcrumb), @@ -208,28 +210,32 @@ def assert_crontab_seo(page, entities): ("primaryImageOfPage", social_image), ]: if webpage.get(relationship, {}).get("@id") != entity.get("@id"): - fail(f"crontab WebPage {relationship} must reference its actual entity") + fail(f"{slug} WebPage {relationship} must reference its actual entity") if application.get("mainEntityOfPage", {}).get("@id") != webpage.get("@id"): - fail("crontab application must reference its WebPage") + fail(f"{slug} application must reference its WebPage") if webpage.get("url") != canonical or application.get("url") != canonical: - fail("crontab structured-data URLs must use the clean canonical") + fail(f"{slug} structured-data URLs must use the clean canonical") offer = application.get("offers", {}) if application.get("isAccessibleForFree") is not True or offer.get("price") not in (0, "0"): - fail("crontab application must accurately describe its free access") + fail(f"{slug} application must accurately describe its free access") if social_image.get("url") != page.meta["og:image"] or application.get("image") != social_image.get("url"): - fail("crontab structured data must use the actual social image") + fail(f"{slug} structured data must use the actual social image") if (social_image.get("width"), social_image.get("height")) != (1200, 630): - fail("crontab structured-data social image dimensions are incorrect") + fail(f"{slug} structured-data social image dimensions are incorrect") if author.get("url") not in page.links: - fail("crontab structured-data author must have a visible attribution link") + fail(f"{slug} structured-data author must have a visible attribution link") expected_items = [ {"@type": "ListItem", "position": 1, "name": "Native Cube", "item": f"{SITE_URL}/"}, {"@type": "ListItem", "position": 2, "name": application.get("name"), "item": canonical}, ] if breadcrumb.get("itemListElement") != expected_items: - fail("crontab breadcrumb must identify the home page and current tool") + fail(f"{slug} breadcrumb must identify the home page and current tool") if not {"Breadcrumb", "On this page"}.issubset(page.navigation_labels): - fail("crontab must retain its visible breadcrumb and section navigation") + fail(f"{slug} must retain its visible breadcrumb and section navigation") + + +def assert_crontab_seo(page, entities): + assert_tool_seo(page, entities, "crontab") h1 = next((text.lower() for tag, text in page.headings if tag == "h1"), "") if "crontab" not in h1 or "generator" not in h1: fail("crontab must have a descriptive generator heading in the original HTML") @@ -239,6 +245,36 @@ def assert_crontab_seo(page, entities): fail("crontab must provide static examples for five-minute, hourly, and daily schedules") +def assert_json_diff_seo(page, entities): + assert_tool_seo(page, entities, "json-diff") + by_type = {entity.get("@type"): entity for entity in entities if isinstance(entity.get("@type"), str)} + application = by_type["WebApplication"] + webpage = by_type["WebPage"] + h1 = next((text for tag, text in page.headings if tag == "h1"), "") + if h1 != application.get("name"): + fail("json-diff application name must match its visible main heading") + if not (application.get("description") == page.meta["description"] == page.meta["og:description"] == page.meta["twitter:description"]): + fail("json-diff application, page, and social descriptions must agree") + if not all(term in "".join(page.title).lower() for term in ["json", "yaml", "diff"]): + fail("json-diff title must describe both supported formats and comparison") + if application.get("author", {}).get("@id") != by_type["Person"].get("@id"): + fail("json-diff application must identify its actual author") + if not isinstance(application.get("featureList"), list) or len(application["featureList"]) < 4: + fail("json-diff application must describe its comparison features") + if not webpage.get("dateModified"): + fail("json-diff must declare its actual modification date") + required_ids = {"comparison-workspace", "comparison-results", "guide-title", "examples-title", "faq-title", "related-tools-title"} + if not required_ids.issubset(page.ids): + fail("json-diff must retain its static guide, examples, FAQ, and related tools") + if not {"json-original", "json-updated", "yaml-original", "yaml-updated"}.issubset(page.examples): + fail("json-diff must retain original and updated examples in both formats") + canonical = f"{SITE_URL}/json-diff/" + targets = {urljoin(canonical, link) for link in page.links} + expected = {f"{SITE_URL}/{slug}/" for slug in ["yaml-formatter", "k8s-manifest-builder", "helm-chart-builder"]} + if not expected.issubset(targets): + fail("json-diff must retain links to related configuration tools") + + parsed_pages = {} page_entities = {} titles = set() @@ -308,6 +344,8 @@ def assert_crontab_seo(page, entities): crontab_url = PAGES[Path("crontab/index.html")] assert_crontab_seo(parsed_pages[crontab_url], page_entities[crontab_url]) +json_diff_url = PAGES[Path("json-diff/index.html")] +assert_json_diff_seo(parsed_pages[json_diff_url], page_entities[json_diff_url]) for canonical, page in parsed_pages.items(): if len(page.ids) != len(set(page.ids)): @@ -365,10 +403,10 @@ def assert_crontab_seo(page, entities): catalogue = json.loads((ROOT / "terraform-modules/modules.json").read_text()) if modified_date < date.fromisoformat(catalogue["synced_at"].split("T")[0]): fail("Terraform sitemap lastmod predates the published catalogue") - if location == crontab_url: + if location in {crontab_url, json_diff_url}: webpage = next(entity for entity in page_entities[location] if entity.get("@type") == "WebPage") if webpage.get("dateModified") != last_modified: - fail("crontab sitemap lastmod must match its WebPage dateModified") + fail(f"{location} sitemap lastmod must match its WebPage dateModified") robots = (ROOT / "robots.txt").read_text() if f"Sitemap: {SITE_URL}/sitemap.xml" not in robots: @@ -405,6 +443,13 @@ def assert_crontab_seo(page, entities): if crontab_url not in targets: fail(f"{source} must retain its related cron scheduling link") + +for source in ["index.html", "yaml-formatter/index.html"]: + page = parsed_pages[PAGES[Path(source)]] + targets = {urljoin(PAGES[Path(source)], link) for link in page.links} + if json_diff_url not in targets: + fail(f"{source} must retain its link to JSON and YAML comparison") + print( "SEO audit passed: " f"{len(PAGES)} canonical pages, social cards, sitemap, robots, " diff --git a/sitemap.xml b/sitemap.xml index b5b2e69..6719637 100644 --- a/sitemap.xml +++ b/sitemap.xml @@ -36,4 +36,8 @@ https://native-cube.com/crontab/ 2026-11-02 + + https://native-cube.com/json-diff/ + 2026-11-02 + diff --git a/yaml-formatter/index.html b/yaml-formatter/index.html index 54eb5e9..0c39cab 100644 --- a/yaml-formatter/index.html +++ b/yaml-formatter/index.html @@ -60,7 +60,7 @@ /> YAML & JSON Formatter and Validator | Native Cube - +