Repository navigation
Audit phases 0–2: remove dead Monaco stack, fix JSON corruption, add consent-gated analytics + CI - #11
Merged
Conversation
Phase 0 of the codebase audit: dead weight, stale config, and the two
correctness bugs that were cheap to fix alongside them.
Remove the Monaco editor stack. Nothing has imported it since the
CodeMirror migration, yet it still cost: a 24 MB copy into public/ on
every install and build, a beforeInteractive script on all 17 routes that
monkey-patched console.warn/console.error/window.onerror, two
dependencies, and a CSP 'unsafe-eval' allowance. Deletes
MonacoEditorWrapper, monacoLoader, suppress-monaco-warnings.js and
scripts/copy-monaco.mjs (with its prebuild/postinstall hooks). The
misnamed-but-live .monaco-editor-container class is renamed to
.editor-container rather than dropped. Clears all four pnpm audit
advisories, which came from Monaco's transitive dompurify, and shrinks
public/ from ~26 MB to 1.8 MB. The now-unused CSP allowance is left in
place with a comment; tightening the policy is its own change.
Fix Chinese language resolution. load: 'languageOnly' stripped the region
tag, so every Simplified-Chinese visitor resolved to the Traditional 'zh'
bundle and the entire zh-CN bundle was dead weight. Removing it restores
zh-CN, and explicit zh-Hans/zh-SG/zh-Hant aliases stop the other
script-tagged variants landing on the wrong script. Region-tagged
variants of every other language still resolve to their base language
(verified for es-MX, en-GB, nl-BE, hi-IN, ta-IN, ms-MY; pt-BR and fr
still fall back to English). 'zh' was also mislabelled 中文 (新加坡) 🇸🇬 —
Singapore uses Simplified — and is now 中文 (繁體) 🇹🇼.
Language detection now happens in exactly one place. I18nProvider
re-implemented it with a divergent list that omitted zh-CN, and the
unreferenced I18nProvider-BrowserFirst.tsx carried the same stale list.
LanguageSelector matches on resolvedLanguage, so a browser reporting
es-MX no longer renders Spanish while ticking English.
Stop the help page linking into redirects. Its "Dedicated tool pages"
grid pointed at six /tools/* URLs that all 301 to / or /tree; it now
renders from the seoViews catalog, so it cannot drift from the real
routes again.
Add GitHub Actions CI. pnpm check and the Playwright suite previously ran
only when a developer remembered; the pre-commit hook never ran e2e.
Also: StatsView's Arrays panel reused stats.noData ("No JSON data to
analyze") — adds a translated stats.noArrays to all 12 locales; drops the
Google Fonts preconnect/dns-prefetch hints, which opened TLS to Google on
every page although next/font self-hosts; adds lang="en" to global-error;
points the navbar's Open Source link at json-viewer rather than
json-formatter; prunes stale pnpm-workspace allowBuilds entries, the
spent Biome 2.5.7 exclusions, the biome.json schema version, and a
vercel.json functions block referencing a file that moved; deletes the
tracked LANGUAGE_TESTING.md / test-language.html / test-languages.sh rig
and rewrites .env.local.example around the one env var the code reads.
Verified: tsc clean, 51/51 unit tests, 12/12 e2e against a production
build, pnpm install --frozen-lockfile passes, pnpm audit --prod reports
no known vulnerabilities. ESLint 65 -> 64 warnings, Biome 240 -> 235, 0
errors throughout.
Note: the packageManager pin bump to pnpm@11.22.0 was already in the
working tree before this work and rides along here.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C5zaYdMzMZAVhvuLWQqKWB
Phase 1 of the codebase audit. Every item here is a way the app could
change, lose, or misreport a document without telling anyone.
Rewrite JSONFixer as a string-aware scanner. The old implementation was a
pipeline of regexes applied to the raw text with no notion of where a
string literal starts or ends, so it rewrote content *inside* strings.
`{"glob": "src/*.js", "other": "*/x", "b": 1,}` came back as
`{"glob":"srcx","b":1}` — a key silently dropped and a value mangled,
reported as a successful fix — because `/*` in one string and `*/` in
another looked like a block comment. Any document containing a URL failed
outright, since `//` was stripped as a line comment. A fuzz harness over
20,000 hazard-saturated documents corrupted 7,437 valid ones against the
old code and none against the new. The scanner is also single-pass, which
removes the quadratic comment-stripping: 380 KB of unclosed `/*` went from
818 ms to single-digit ms.
The fixer no longer invents values. `}`, `]` and `}{` used to "repair" to
an empty container, so Format would replace what the user typed with `{}`.
A document with no content to rescue is now an error; `"a": 1}` still
repairs to `{"a": 1}`.
Validation no longer runs the repair pipeline on every keystroke — it
parses. Repair happens on an explicit Format.
Format warns before it rewrites. The parse/stringify round trip is lossy
in ways users cannot see: integers past 2^53 lose digits, `1e999` becomes
null, `-0` becomes 0, duplicate keys collapse, long decimals truncate. A
new scanner detects these in the source text and warns first. It compares
the emitted text against the literal's exact value rather than thresholding
on MAX_SAFE_INTEGER, so it stays quiet on large integers that survive the
round trip unchanged.
Close the graph tooltip's HTML-injection sink. It concatenated raw JSON
keys and values into `.html()`, and production CSP allows inline script, so
a hostile key executed on hover. It builds DOM with `.text()` now.
Guard every recursive walker. V8's JSON.parse is iterative, so a deeply
nested document parsed fine and then blew the stack in each view. A shared
depth guard now refuses past 512 levels with a translated notice —
measured against a structuredClone ceiling of 2,139, which the tree
editor's write path depends on. StatusBar, which renders outside every
ErrorBoundary and so took the whole app down with it, is guarded and
wrapped.
Fix the search. The Path Pattern filter tested the regex against the
container's path rather than the candidate's, starting from the empty
string, so no real pattern could ever match. User regexes are now bounded
by an empirical cost probe plus an input clamp rather than a syntactic
denylist that `((a+))+$` and `(a|a)*$` walked straight through. Results are
capped with the cap disclosed, patterns compile once instead of per node,
and word counting uses a Map so the word "constructor" stops turning every
percentage into NaN.
Fix the tree editor. Focusing a string value and clicking away re-parsed
it, so "123" became the number 123 and "null" became null with no edit at
all. Renaming a key onto an existing sibling destroyed that sibling.
Paths were strings split on `.`/`[`/`]`, so a key containing any of those
collided with real nesting and edits silently misrouted. Rows are now
keyboard-operable with tree/treeitem semantics.
Fix the map. Objects in a `name` or `country` field crashed the whole view
into the ErrorBoundary; `{lat: 0, lng: 10}` was dropped by a truthiness
check; a Polygon's coordinates coerced into a bogus marker; no range
validation. Tile loading is now opt-in and names every provider the grant
covers, since the requested tiles are a function of the coordinates in the
user's private JSON.
Fix the diff. `key in left` consulted the prototype chain, so a key named
`constructor` was compared against `Object` and crashed the semantic
filter. Arrays are aligned by content, so one prepended item no longer
reports every following item as modified. Both cap fallbacks now return a
degraded flag that CompareWorkspace surfaces, instead of presenting a
coarser answer as the real one.
Falsy scalars are documents. `0`, `false`, `null` and `""` are valid JSON;
truthiness checks reported them invalid in the workbench, the status bar,
jsonUtils and all five views. Validity is now carried explicitly.
Tests: 51 -> 538, with eight new pure modules extracted to make the
behaviour testable (jsonFixer scanner, jsonSearch, jsonPath, geoDetect,
jsonWalk, jsonStats, jsonDocument, jsonLossy). tsc clean, ESLint 64 -> 48
warnings, Biome 235 -> 208, 0 errors throughout, 12/12 e2e against a
production build, all 12 locales at an identical 199 keys.
Known limitation, deliberately not fixed: Format is still lossy for the
cases above — it now warns rather than staying silent. A lossless
formatter is its own change.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C5zaYdMzMZAVhvuLWQqKWB
Phase 2 of the codebase audit: make the privacy posture match what the
site claims, and close the gap the dead Monaco stack left in the CSP.
Replace Google Analytics with Vercel Web Analytics. GA4 loaded
unconditionally whenever the measurement ID was set, dropping _ga cookies
before the visitor had any say — non-compliant with EU ePrivacy, which
requires consent before non-essential storage. Vercel Web Analytics sets
no cookies at all: visitors are identified by a hash of the incoming
request that is discarded after 24 hours, so there is nothing to consent
to and no banner to build. It is also first-party and same-origin
(/_vercel/insights/*), so no request leaves for Google.
Drop behavioural event tracking entirely. gtag.ts sent seven events —
format, compact, clear, copy, paste, tab switch, example used — recording
what people did with their JSON rather than that they visited. Nothing in
the product needs that. What remains is page views with country, referrer,
device and browser.
The trade-off, recorded deliberately: a 24-hour hash carries no cross-day
identity, so "visitors" means unique-per-period and new-vs-returning is
not available. Getting it back would need a persistent identifier, i.e. a
cookie and a consent banner.
Tighten the CSP now that everything it was propping up is gone:
- 'unsafe-eval' only Monaco's AMD loader ever needed it
- googletagmanager, five Google wildcards, replaced by one named
google-analytics, fallback host for the analytics script
analytics.google.com
- fonts.googleapis, next/font/google self-hosts at build time
fonts.gstatic
- img-src https: a blanket allowance letting any host serve
images; the two tile providers are named instead
- worker-src data: Monaco's worker stub
- *.tile.openstreetmap no map style has pointed there for some time
'unsafe-inline' is the one remaining relaxation; removing it needs a nonce,
which needs middleware, which would make these statically prerendered pages
dynamic. Left as its own decision.
Add e2e/csp.spec.ts, which pins the policy and loads all nine routes
watching for violations. Browsers report CSP failures to the console, not
to the test runner, so without this a regression is invisible — and the
allowances tend to creep back the first time something looks broken.
Storage access can no longer take the page down. localStorage was read
bare in four places; the property access itself throws SecurityError when
a browser is set to block site data, and every call site runs inside an
effect, so the root ErrorBoundary replaced the whole page with an error
panel over a remembered theme. A safeStorage helper degrades to "no stored
value" instead.
Route all 24 JSON-LD blocks through one JsonLd component. JSON.stringify
escapes for JSON, not HTML, so a value containing </script> would close the
block. Every schema is built from static literals today, so there was no
live injection path; the component keeps that true by construction rather
than by remembering across twelve files.
Make the privacy copy honest. The help page claimed "No servers, no
tracking, no data ever leaves your machine" and "nothing is ever uploaded"
while GA4 shipped. It now says the JSON is never uploaded — which is true
and is the part users care about — and that page visits are counted
anonymously without cookies.
Verified: tsc clean, 545 unit tests, 22/22 e2e including the new CSP suite,
ESLint 48 warnings / 0 errors.
Note: Web Analytics must be enabled for the project in the Vercel
dashboard; the component is inert until it is, and off-platform the script
route does not exist at all.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C5zaYdMzMZAVhvuLWQqKWB
Reinstates Google Analytics alongside Vercel Web Analytics. The previous commit replaced GA outright, which lost the audience metrics that were wanted — new vs returning users needs a persistent identifier, and no cookieless system can provide it. Both now run. GA4 answers new-vs-returning and country breakdowns and is free at any volume; Vercel Web Analytics needs no cookie and no consent, so it keeps counting through declines and blockers, and covers the Hobby-plan gap GA would otherwise leave. GA runs behind Google Consent Mode. `analytics_storage` starts denied, so the tag sends cookieless pings carrying no identifiers until the visitor accepts. The default is inlined in the document head rather than set from a component: it has to execute before the gtag script initialises, or GA writes its cookies in the gap and the default never applies. It reads the stored choice inline too, so a returning visitor who accepted is not downgraded to cookieless for the first few hundred milliseconds of every load. `next/script` with beforeInteractive is not usable here — outside pages/_document it carries no ordering guarantee, and Next lints against it. Verified against a build with a real measurement ID: `consent default` fires with analytics_storage denied, zero _ga cookies exist before the choice, and _ga plus _ga_<id> appear only after Accept, alongside a `consent update` granting storage. Declining leaves it cookieless. Behavioural events stay gone. The seven format / compact / clear / copy / paste / tab-switch / example-used events removed in the previous commit are not restored: they recorded what people did with their documents, which audience measurement does not need. Page views only. The banner reads consent through useSyncExternalStore rather than setting state in an effect, so it neither trips react-hooks/set-state-in-effect nor opts the component out of the React Compiler. Accept and Decline carry equal visual weight — until a choice is made GA is already cookieless, so declining costs the visitor nothing and should not look like the lesser option. Translated across all 12 locales (203 keys each). CSP regains the googletagmanager and google-analytics hosts, with the wildcard subdomains Google's own guidance calls for so EU regional collection endpoints are not blocked. Everything else stays tightened; the e2e CSP spec now asserts the required hosts are present as well as that the retired ones are gone, so neither direction can drift unnoticed. Adds e2e/consent.spec.ts, including a test that types a canary string into the editor and asserts no outbound request carries it — the JSON-stays-local promise, checked rather than stated. Verified: tsc clean, 545 unit tests, 25 e2e passing (2 skipped, they need a measurement ID at build time), ESLint 48 warnings / 0 errors. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01C5zaYdMzMZAVhvuLWQqKWB
Contributor
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
This branch was successfully deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Audit remediation, phases 0–2. Four commits, each independently reviewable.
Net effect on analytics — read this first
Two commits here look contradictory in isolation:
94ae256removes Google Analytics,3ace60arestores it. The net result is:analytics_storagestartsdenied).format,copy,paste,clear,tab_change,load_example,fix_json. Those recorded what people did with their JSON. Audience metrics only now: pageviews, countries, new vs returning.NEXT_PUBLIC_GA_MEASUREMENT_IDis unchanged and still set in Vercel. Without it, no GA and no consent banner render at all.Commits
a8f9a1a— remove dead Monaco stack, fix i18n resolution, add CIMonacoEditorWrapper.tsx,monacoLoader.ts,suppress-monaco-warnings.js, and thecopy-monacobuild step. CodeMirror 6 has been the only live editor. This is also what let'unsafe-eval'come out of the CSP — Monaco's AMD loader was the only thing that ever needed it.load: 'languageOnly'was truncating every regional tag to its base, sozh-Hans/zh-SG/zh-Hantall fell through to English. Replaced with an explicit alias map andresolvedLanguage. Verified against 15 realnavigator.languagevalues.pnpm check(Biome + ESLint + tsc + Vitest) and Playwright smoke tests.LANGUAGE_TESTING.md,test-language.html,test-languages.sh).2e0fd1c— stop the tool silently altering the user's JSONThe highest-severity finding in the audit.
JSONFixerrewrote content inside string literals:A glob pattern was destroyed and a key silently dropped, then written straight back into the editor by Format. Rewritten as a single string-aware
scanDocument()pass. Fuzzing over 20,000 hazard-saturated valid documents: 7,437 corrupted before, 0 after.Also added a guard against inventing data —
},],}{,{}}used to "repair" to{}, handing back a value the user never wrote. Structure-only input now errors honestly.Format is still lossy in ways
JSON.parse/stringifyinherently are (12345678901234567890loses precision,1e999→null,-0→0, duplicate keys collapse). Not fixed here, but it now warns instead of doing it silently (jsonLossy.ts). Flagged for a follow-up.94ae256/3ace60a— analytics and CSPConsentBanner.tsx+lib/consent.ts), translated across all 12 locales, both buttons at equal visual weight.<head>, not set from React.next/scriptbeforeInteractivecarries no ordering guarantee in the App Router and Next lints against it; if it lands late, GA writes cookies in the gap and the default never applies.useSyncExternalStorerather thansetState-in-effect, which would make the React Compiler skip the component.'unsafe-eval', Google Fonts hosts (next/font/googleself-hosts at build), blanketimg-src https:,worker-src data:, dead*.tile.openstreetmap. GA hosts use wildcards per Google's own CSP guidance so EU regional endpoints aren't blocked.<JsonLd>component that escapes<as a\u003cunicode escape, so no injected payload can break out of the script tag.localStorageaccess wrapped insafeStorage.ts— property access itself throwsSecurityErrorwhen site data is blocked.Verification
pnpm checkclean: 545 unit tests / 14 files, tsc clean, ESLint 48 warnings / 0 errors, Biome 211 warnings / 0 errors.NEXT_PUBLIC_GA_MEASUREMENT_IDpresent at build time.e2e/csp.spec.tssweeps all 9 routes including/map, which is the one view pulling third-party images at runtime — so theimg-srctightening is verified against the view most likely to break.G-VERIFY123:[]cookies before Accept,_ga+_ga_VERIFY123after, Decline stays cookieless.e2e/consent.spec.tstypes a canary string into the editor and asserts no outbound request carries it — "your JSON never leaves the browser" is now tested, not just claimed.pnpm install --frozen-lockfileclean at the bumpedpackageManagerpin (11.20.0 → 11.22.0, a pre-existing local change carried in here).Not included
Phases 3–6 (performance, SEO/metadata, structure/a11y, tests/docs) remain queued.
🤖 Generated with Claude Code
https://claude.ai/code/session_01C5zaYdMzMZAVhvuLWQqKWB