diff --git a/CLAUDE.md b/CLAUDE.md
index c9d71fe2..f147ab4b 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -216,6 +216,14 @@ native ``, no Acrylic or Accent opacity slider. Spec and plan:
Deliberately no semantic diagnostics: without a tsconfig or node_modules they would be noise.
Every language loads on demand (one Vite chunk each). Prose (`.txt`, `.log`, `.csv`, subtitles)
gets no gutter and no language. Token colours are the THEME's since #298 (`--p-code-*`).
+ **JSONC COMMENTS ARE COMMENTS** (#312; owner, 2026-10-07: "comments in jsonc arent read as
+ comments in prism"): the strict JSON grammar made every // line plain text under a squiggle.
+ `.jsonc`, `tsconfig(.*).json`, `jsconfig(.*).json`, `.eslintrc(.json)`, `.babelrc(.json)`,
+ `(.)devcontainer.json`, `wrangler/turbo/biome/deno.json` and every `.json` in a `.vscode`
+ folder go to `lib/jsoncMode.ts` (a tokenizer with the JSON grammar's own tags, comment tags,
+ brace folding) and `codeLint.jsoncErrors` (comments and trailing commas blanked to spaces,
+ offsets kept, then `JSON.parse`), so a missing comma is still underlined. `.json5` colours the
+ same with no linter (bare keys and single quotes). Plain `.json` stays strict. `jsonc` e2e.
**A FILE THAT GROWS** (2026-08-31): "Follow the file" appends new bytes as they are
written - a build log, an agent's transcript - and a file PAST THE 64MB CEILING now shows
its TAIL (2MB) instead of an apology. Both are READ-ONLY, and structurally so: a followed
diff --git a/package-lock.json b/package-lock.json
index 401e00ee..6abb6c33 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "prism",
- "version": "0.94.1",
+ "version": "0.94.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "prism",
- "version": "0.94.1",
+ "version": "0.94.2",
"license": "MIT",
"dependencies": {
"@codemirror/commands": "^6.11.0",
diff --git a/package.json b/package.json
index 64f2d77f..85404888 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "prism",
- "version": "0.94.1",
+ "version": "0.94.2",
"description": "A fast, universal media viewer for Windows.",
"author": "Max",
"license": "MIT",
diff --git a/src/renderer/src/components/CodeView.tsx b/src/renderer/src/components/CodeView.tsx
index 2a4ab00f..5dea1612 100644
--- a/src/renderer/src/components/CodeView.tsx
+++ b/src/renderer/src/components/CodeView.tsx
@@ -27,7 +27,7 @@ import { ContextMenu, type MenuItem } from './ContextMenu'
import { fileVerbs, tickIf } from '../lib/fileVerbs'
import { lintKeymap } from '@codemirror/lint'
import { isProse, langFor } from '../lib/codeLang'
-import { jsonLinter, syntaxLinter } from '../lib/codeLint'
+import { lintFor } from '../lib/codeLint'
import { prismCodeTheme } from '../lib/codeTheme'
import { setWrapPref, useWrapPref, wrapPref, wrapsFor, type WrapPref } from '../lib/codePrefs'
import { reloadAction, stampChanged, touchesFile, type Stamp } from '../lib/fileReload'
@@ -257,7 +257,7 @@ export function CodeView({
// match it. Runs on every path change, since the view outlives the file.
useEffect(() => {
let alive = true
- const lang = langFor(name)
+ const lang = langFor(name, path)
void Promise.all([
window.prism.readText(path),
lang ? lang.load() : Promise.resolve([]),
@@ -290,14 +290,9 @@ export function CodeView({
wrapComp.reconfigure(wrapFor(name, wrapPref())),
langComp.reconfigure(langExt),
// Squiggles need a grammar to be wrong against. A stream lexer has
- // none, so those languages get colour and no claims about errors.
- lintComp.reconfigure(
- /\.jsonc?$|\.json5$/i.test(name)
- ? [syntaxLinter, jsonLinter]
- : lang?.parsed
- ? syntaxLinter
- : []
- )
+ // none, so those languages get colour and no claims about errors;
+ // JSONC is the one lexed language with a checker of its own (#312).
+ lintComp.reconfigure(lintFor(lang))
]
})
report(body === disk ? null : body)
diff --git a/src/renderer/src/lib/codeLang.ts b/src/renderer/src/lib/codeLang.ts
index 7a6dc73a..be72c1ef 100644
--- a/src/renderer/src/lib/codeLang.ts
+++ b/src/renderer/src/lib/codeLang.ts
@@ -19,6 +19,8 @@ export type CodeLang = {
readonly name: string
/** True when the grammar can report syntax errors. */
readonly parsed: boolean
+ /** A checker beyond the grammar's error nodes: JSON.parse, or its JSONC form. */
+ readonly lint?: 'json' | 'jsonc'
readonly load: () => Promise
}
@@ -40,7 +42,21 @@ const JS = parsed('JavaScript', async () => (await import('@codemirror/lang-java
const JSX = parsed('JSX', async () => (await import('@codemirror/lang-javascript')).javascript({ jsx: true }))
const TS = parsed('TypeScript', async () => (await import('@codemirror/lang-javascript')).javascript({ typescript: true }))
const TSX = parsed('TSX', async () => (await import('@codemirror/lang-javascript')).javascript({ jsx: true, typescript: true }))
-const JSON_ = parsed('JSON', async () => (await import('@codemirror/lang-json')).json())
+const JSON_: CodeLang = {
+ ...parsed('JSON', async () => (await import('@codemirror/lang-json')).json()),
+ lint: 'json'
+}
+// JSON with comments (#312): the strict grammar read every // line as an error.
+// A tokenizer colours it and codeLint's jsoncErrors judges it.
+const JSONC: CodeLang = {
+ name: 'JSON with Comments',
+ parsed: false,
+ lint: 'jsonc',
+ load: async () => (await import('./jsoncMode')).jsonc()
+}
+// JSON5 has bare keys, single quotes and hex, which jsoncErrors would call
+// wrong: the same colours, and no claims about errors.
+const JSON5: CodeLang = { name: 'JSON5', parsed: false, load: JSONC.load }
const CSS = parsed('CSS', async () => (await import('@codemirror/lang-css')).css())
const HTML = parsed('HTML', async () => (await import('@codemirror/lang-html')).html())
const XML = parsed('XML', async () => (await import('@codemirror/lang-xml')).xml())
@@ -121,7 +137,7 @@ const COBOL = lexed('COBOL', async () => (await import('@codemirror/legacy-modes
const BY_EXT: Record = {
'.js': JS, '.mjs': JS, '.cjs': JS, '.jsx': JSX,
'.ts': TS, '.mts': TS, '.cts': TS, '.tsx': TSX,
- '.json': JSON_, '.jsonc': JSON_, '.json5': JSON_, '.ipynb': JSON_,
+ '.json': JSON_, '.jsonc': JSONC, '.json5': JSON5, '.ipynb': JSON_,
'.css': CSS, '.scss': SCSS, '.sass': SASS, '.less': LESS, '.styl': STYLUS,
'.html': HTML, '.xhtml': HTML, '.svelte': HTML, '.astro': HTML, '.vue': VUE,
'.xml': XML, '.svg': XML, '.svgz': XML,
@@ -189,8 +205,28 @@ const BY_NAME: Record = {
'.editorconfig': PROPERTIES,
'.env': PROPERTIES,
'.prettierrc': JSON_,
- '.eslintrc': JSON_,
- '.babelrc': JSON_
+ // The tools that read these allow comments in them, so JSONC (#312).
+ '.eslintrc': JSONC,
+ '.eslintrc.json': JSONC,
+ '.babelrc': JSONC,
+ '.babelrc.json': JSONC,
+ 'tsconfig.json': JSONC,
+ 'jsconfig.json': JSONC,
+ 'devcontainer.json': JSONC,
+ '.devcontainer.json': JSONC,
+ 'wrangler.json': JSONC,
+ 'turbo.json': JSONC,
+ 'biome.json': JSONC,
+ 'deno.json': JSONC
+}
+
+/** `tsconfig.app.json` and the like: the variants read the same way. */
+const JSONC_FAMILY = /^(?:tsconfig|jsconfig)\..+\.json$/
+
+/** The folder a path sits in, lower-cased, or '' without one. */
+function parentName(path: string): string {
+ const parts = path.split(/[\\/]/)
+ return (parts[parts.length - 2] ?? '').toLowerCase()
}
/**
@@ -198,10 +234,13 @@ const BY_NAME: Record = {
* Null is a real answer, not a failure: prose (`.txt`, `.log`) is meant to be
* plain, and an unmapped extension reads better uncoloured than mis-coloured.
*/
-export function langFor(name: string): CodeLang | null {
+export function langFor(name: string, path?: string): CodeLang | null {
const n = name.toLowerCase()
const byName = BY_NAME[n]
if (byName) return byName
+ if (JSONC_FAMILY.test(n)) return JSONC
+ // VS Code's own folder: settings, launch, tasks, extensions all take comments.
+ if (path && n.endsWith('.json') && parentName(path) === '.vscode') return JSONC
const ext = /\.[^.]*$/.exec(n)?.[0] ?? ''
return BY_EXT[ext] ?? null
}
diff --git a/src/renderer/src/lib/codeLint.ts b/src/renderer/src/lib/codeLint.ts
index 4fab759a..14cd0c12 100644
--- a/src/renderer/src/lib/codeLint.ts
+++ b/src/renderer/src/lib/codeLint.ts
@@ -1,6 +1,7 @@
import { ensureSyntaxTree, syntaxTree } from '@codemirror/language'
import { linter, type Diagnostic } from '@codemirror/lint'
-import type { EditorState } from '@codemirror/state'
+import type { EditorState, Extension } from '@codemirror/state'
+import type { CodeLang } from './codeLang'
// The red underline. Prism does not run a language server, so this is honest
// about what it knows: where a Lezer grammar failed to parse, and nothing more.
@@ -88,8 +89,93 @@ export function jsonErrors(text: string): Diagnostic[] {
}
}
+/**
+ * JSONC as plain JSON with the same offsets (#312): every comment and every
+ * trailing comma becomes spaces (line breaks kept), so `JSON.parse` judges
+ * what is left and a position it names is a position in the file. Strings are
+ * copied untouched, so a "//" inside a url stays. Exported for tests.
+ */
+export function blankJsonc(text: string): { json: string; unclosedComment: number | null } {
+ const out = text.split('')
+ const blank = (from: number, to: number): void => {
+ for (let i = from; i < to; i++) if (out[i] !== '\n' && out[i] !== '\r') out[i] = ' '
+ }
+ // The last comma seen, until something other than space shows whether a
+ // closer follows it (comments are already gone by then).
+ let comma = -1
+ let i = 0
+ while (i < text.length) {
+ const c = text[i]
+ if (c === '"') {
+ comma = -1
+ let j = i + 1
+ while (j < text.length && text[j] !== '"' && text[j] !== '\n') j += text[j] === '\\' ? 2 : 1
+ i = j + 1
+ continue
+ }
+ if (c === '/' && text[i + 1] === '/') {
+ let j = i
+ while (j < text.length && text[j] !== '\n') j++
+ blank(i, j)
+ i = j
+ continue
+ }
+ if (c === '/' && text[i + 1] === '*') {
+ const end = text.indexOf('*/', i + 2)
+ if (end < 0) {
+ blank(i, text.length)
+ return { json: out.join(''), unclosedComment: i }
+ }
+ blank(i, end + 2)
+ i = end + 2
+ continue
+ }
+ if (c === ',') comma = i
+ else if (c === '}' || c === ']') {
+ if (comma >= 0) out[comma] = ' '
+ comma = -1
+ } else if (!/\s/.test(c)) comma = -1
+ i++
+ }
+ return { json: out.join(''), unclosedComment: null }
+}
+
+/**
+ * JSON's rules less the two JSONC relaxes: comments and trailing commas are
+ * fine, a missing comma or an unclosed brace is still an error, said by
+ * `JSON.parse` at its real place in the file. Exported for tests.
+ */
+export function jsoncErrors(text: string): Diagnostic[] {
+ const { json, unclosedComment } = blankJsonc(text)
+ if (unclosedComment !== null) {
+ return [
+ {
+ from: unclosedComment,
+ to: Math.min(unclosedComment + 2, text.length),
+ severity: 'error' as const,
+ message: 'This comment is never closed'
+ }
+ ]
+ }
+ return jsonErrors(json)
+}
+
+/**
+ * Which squiggles a file gets. A grammar's own error nodes for a parsed
+ * language, plus `JSON.parse` for JSON; JSONC gets its own check and NEVER the
+ * grammar's (it has none that knows comments); a stream lexer gets nothing.
+ */
+export function lintFor(lang: CodeLang | null): Extension {
+ if (lang?.lint === 'jsonc') return jsoncLinter
+ if (lang?.lint === 'json') return [syntaxLinter, jsonLinter]
+ return lang?.parsed ? syntaxLinter : []
+}
+
/** Squiggles from the grammar. Only worth attaching to a parsed language. */
export const syntaxLinter = linter((view) => parseErrors(view.state), { delay: 300 })
/** Squiggles from JSON.parse, which explains itself better than the grammar can. */
export const jsonLinter = linter((view) => jsonErrors(view.state.doc.toString()), { delay: 300 })
+
+/** Squiggles for JSON with comments: JSON's errors, never its comments. */
+export const jsoncLinter = linter((view) => jsoncErrors(view.state.doc.toString()), { delay: 300 })
diff --git a/src/renderer/src/lib/jsonc.test.ts b/src/renderer/src/lib/jsonc.test.ts
new file mode 100644
index 00000000..fdb7f418
--- /dev/null
+++ b/src/renderer/src/lib/jsonc.test.ts
@@ -0,0 +1,154 @@
+import { ensureSyntaxTree, foldable } from '@codemirror/language'
+import { EditorState } from '@codemirror/state'
+import { classHighlighter, highlightTree } from '@lezer/highlight'
+import { describe, expect, it } from 'vitest'
+import { langFor } from './codeLang'
+import { blankJsonc, jsoncErrors, lintFor, jsoncLinter, syntaxLinter, jsonLinter } from './codeLint'
+
+// #312 (owner, 2026-10-07: "comments in jsonc arent read as comments in
+// prism"): every // line in a wrangler.jsonc was plain text under a squiggle.
+
+/** The owner's file, near enough: comments of both kinds and a trailing comma. */
+const WRANGLER = `/**
+ * For more details on how to configure Wrangler, refer to:
+ * https://developers.cloudflare.com/workers/wrangler/configuration/
+ */
+{
+ "$schema": "node_modules/wrangler/config-schema.json",
+ "name": "my-worker",
+ "main": "src/index.ts",
+ "compatibility_date": "2025-04-01",
+ // "compatibility_flags": ["nodejs_compat"],
+ "observability": {
+ "enabled": true, /* on by default */
+ },
+ "assets": { "directory": "./public/" },
+}
+`
+
+async function stateFor(name: string, doc: string, path?: string): Promise {
+ const lang = langFor(name, path)
+ return EditorState.create({ doc, extensions: lang ? [await lang.load()] : [] })
+}
+
+/** Each highlighted run as [text, classes]. */
+function runs(state: EditorState): Array<[string, string]> {
+ const tree = ensureSyntaxTree(state, state.doc.length, 5000)
+ if (!tree) throw new Error('no tree')
+ const out: Array<[string, string]> = []
+ highlightTree(tree, classHighlighter, (from, to, cls) => out.push([state.doc.sliceString(from, to), cls]))
+ return out
+}
+
+describe('which files are JSONC', () => {
+ it('maps .jsonc and the well-known JSON-with-comments names', () => {
+ for (const n of [
+ 'wrangler.jsonc', 'a.JSONC', 'tsconfig.json', 'tsconfig.app.json', 'tsconfig.node.json',
+ 'jsconfig.json', 'jsconfig.base.json', '.eslintrc.json', '.eslintrc', '.babelrc', '.babelrc.json',
+ 'devcontainer.json', '.devcontainer.json', 'wrangler.json', 'turbo.json', 'biome.json', 'deno.json'
+ ]) {
+ expect(langFor(n)?.lint, n).toBe('jsonc')
+ }
+ })
+
+ it('takes every .json in a .vscode folder, and only there', () => {
+ for (const n of ['settings.json', 'launch.json', 'tasks.json', 'extensions.json']) {
+ expect(langFor(n, `C:\\repo\\.vscode\\${n}`)?.lint, n).toBe('jsonc')
+ expect(langFor(n, `/home/u/repo/.vscode/${n}`)?.lint, n).toBe('jsonc')
+ expect(langFor(n, `C:\\repo\\config\\${n}`)?.lint, n).toBe('json')
+ }
+ })
+
+ it('keeps plain JSON strict', () => {
+ for (const n of ['package.json', 'a.json', 'tsconfigs.json', 'mytsconfig.json', 'composer.json']) {
+ expect(langFor(n)?.lint, n).toBe('json')
+ }
+ expect(langFor('a.ipynb')?.lint).toBe('json')
+ })
+
+ it('colours JSON5 the same way and makes no claims about it', () => {
+ expect(langFor('a.json5')?.name).toBe('JSON5')
+ expect(langFor('a.json5')?.lint).toBeUndefined()
+ expect(lintFor(langFor('a.json5'))).toEqual([])
+ })
+
+ it('gives each kind its checker', () => {
+ expect(lintFor(langFor('a.jsonc'))).toBe(jsoncLinter)
+ expect(lintFor(langFor('a.json'))).toEqual([syntaxLinter, jsonLinter])
+ expect(lintFor(langFor('a.py'))).toBe(syntaxLinter)
+ expect(lintFor(langFor('a.sh'))).toEqual([])
+ expect(lintFor(null)).toEqual([])
+ })
+})
+
+describe('JSONC colours', () => {
+ it('tags both comment kinds as comments', async () => {
+ const r = runs(await stateFor('wrangler.jsonc', WRANGLER))
+ const line = r.find(([t]) => t.startsWith('// "compatibility_flags"'))
+ expect(line?.[1]).toContain('tok-comment')
+ expect(r.find(([t]) => t === '/* on by default */')?.[1]).toContain('tok-comment')
+ // The block comment over four lines is comment on every line.
+ expect(r.filter(([t]) => t.includes('wrangler/configuration')).every(([, c]) => c.includes('tok-comment'))).toBe(true)
+ })
+
+ it('a // inside a string is a string, not a comment', async () => {
+ const r = runs(await stateFor('a.jsonc', '{ "url": "https://example.com" }'))
+ expect(r.find(([t]) => t === '"https://example.com"')?.[1]).toBe('tok-string')
+ })
+
+ it('colours JSON exactly as the JSON grammar does', async () => {
+ const doc = '{\n "a": 1.5,\n "b": [true, false, null, "s"],\n "c": { "d": -2e3 }\n}\n'
+ expect(runs(await stateFor('a.jsonc', doc))).toEqual(runs(await stateFor('a.json', doc)))
+ })
+
+ it('folds an object or array to its partner', async () => {
+ const state = await stateFor('wrangler.jsonc', WRANGLER)
+ ensureSyntaxTree(state, state.doc.length, 5000)
+ const lineOf = (text: string): { from: number; to: number } => {
+ for (let n = 1; n <= state.doc.lines; n++) {
+ const l = state.doc.line(n)
+ if (l.text.includes(text)) return l
+ }
+ throw new Error(text)
+ }
+ const obs = lineOf('"observability"')
+ const range = foldable(state, obs.from, obs.to)
+ expect(range).not.toBeNull()
+ expect(state.doc.sliceString(range!.from, range!.to)).toContain('"enabled"')
+ // A one-line object has nothing to fold.
+ const assets = lineOf('"assets"')
+ expect(foldable(state, assets.from, assets.to)).toBeNull()
+ })
+})
+
+describe('jsoncErrors', () => {
+ it('passes comments and trailing commas', () => {
+ expect(jsoncErrors(WRANGLER)).toEqual([])
+ expect(jsoncErrors('// only a comment\n')).toEqual([])
+ expect(jsoncErrors('[1, 2, /* x */ ,\n]')).not.toEqual([]) // two commas is still wrong
+ expect(jsoncErrors('[1, 2,\n// last\n]')).toEqual([])
+ })
+
+ it('keeps every offset, so an error lands where it is', () => {
+ const { json } = blankJsonc(WRANGLER)
+ expect(json.length).toBe(WRANGLER.length)
+ expect(json.split('\n').length).toBe(WRANGLER.split('\n').length)
+ expect(json).not.toContain('developers.cloudflare')
+ expect(blankJsonc('{"u": "a//b", "v": "/*"}').json).toBe('{"u": "a//b", "v": "/*"}')
+ })
+
+ it('still reports a missing comma, at the member that lacks it', () => {
+ const doc = '{\n // a comment\n "a": 1\n "b": 2\n}\n'
+ const [err] = jsoncErrors(doc)
+ expect(err).toBeDefined()
+ expect(doc.slice(err.from, err.to)).toBe('"')
+ expect(err.from).toBe(doc.indexOf('"b"'))
+ })
+
+ it('still reports an unclosed brace and an unclosed comment', () => {
+ expect(jsoncErrors('{\n "a": 1, // x\n').length).toBe(1)
+ const [c] = jsoncErrors('{ "a": 1 } /* never closed')
+ expect(c.message).toMatch(/never closed/)
+ expect(c.from).toBe(11)
+ })
+})
diff --git a/src/renderer/src/lib/jsoncMode.ts b/src/renderer/src/lib/jsoncMode.ts
new file mode 100644
index 00000000..d9146870
--- /dev/null
+++ b/src/renderer/src/lib/jsoncMode.ts
@@ -0,0 +1,104 @@
+import { foldService, matchBrackets, StreamLanguage, syntaxTree, type StreamParser } from '@codemirror/language'
+import type { Extension } from '@codemirror/state'
+
+// JSON with comments (#312; owner, 2026-10-07: "comments in jsonc arent read
+// as comments in prism"). tsconfig.json, .vscode/settings.json, wrangler.jsonc
+// and the like are JSONC: // and /* */ comments, and trailing commas, which
+// VS Code and TypeScript accept. The Lezer JSON grammar has no comment token,
+// so a // line came out as error nodes: plain-coloured text under a red
+// squiggle on every comment.
+//
+// This is a tokenizer, not a grammar: it colours with the SAME tags the Lezer
+// JSON grammar uses (so a JSONC file looks like a JSON file, plus comments in
+// the theme's comment colour), and errors come from `jsoncErrors` in
+// codeLint.ts, which knows the actual rules. It is lenient on purpose (single
+// quotes, bare keys, hex), so JSON5 colours with it too.
+
+type State = { depth: number; inBlock: boolean }
+
+/** The rest of a block comment, or of the line when it does not close here. */
+function blockRest(stream: { match: (p: RegExp) => unknown; skipToEnd: () => void }, state: State): string {
+ if (stream.match(/^[\s\S]*?\*\//)) state.inBlock = false
+ else stream.skipToEnd()
+ return 'blockComment'
+}
+
+export const jsoncParser: StreamParser = {
+ name: 'jsonc',
+ startState: () => ({ depth: 0, inBlock: false }),
+ copyState: (s) => ({ ...s }),
+ token(stream, state) {
+ if (state.inBlock) return blockRest(stream, state)
+ if (stream.eatSpace()) return null
+ if (stream.match('//')) {
+ stream.skipToEnd()
+ return 'lineComment'
+ }
+ if (stream.match('/*')) {
+ state.inBlock = true
+ return blockRest(stream, state)
+ }
+ const ch = stream.peek()
+ if (ch === '"' || ch === "'") {
+ stream.next()
+ let escaped = false
+ for (let c = stream.next(); c !== undefined; c = stream.next()) {
+ if (c === ch && !escaped) break
+ escaped = !escaped && c === '\\'
+ }
+ // A key is a string followed by a colon, which is how the grammar
+ // tells PropertyName from String too.
+ return stream.match(/^\s*:/, false) ? 'propertyName' : 'string'
+ }
+ if (stream.match(/^[+-]?(?:0x[0-9a-f]+|(?:\d+\.?\d*|\.\d+)(?:e[+-]?\d+)?|Infinity|NaN)/i)) return 'number'
+ if (stream.match(/^(?:true|false)\b/)) return 'bool'
+ if (stream.match(/^null\b/)) return 'null'
+ if (stream.match(/^[A-Za-z_$][\w$]*/)) return stream.match(/^\s*:/, false) ? 'propertyName' : null
+ stream.next()
+ if (ch === '{' || ch === '[') state.depth += 1
+ if (ch === '}' || ch === ']') state.depth = Math.max(0, state.depth - 1)
+ if (ch === '{' || ch === '}') return 'brace'
+ if (ch === '[' || ch === ']') return 'squareBracket'
+ if (ch === ',' || ch === ':') return 'separator'
+ return null
+ },
+ indent(state, textAfter, cx) {
+ const closes = /^\s*[}\]]/.test(textAfter) ? 1 : 0
+ return Math.max(0, state.depth - closes) * cx.unit
+ },
+ languageData: {
+ commentTokens: { line: '//', block: { open: '/*', close: '*/' } },
+ closeBrackets: { brackets: ['[', '{', '"'] }
+ }
+}
+
+const COMMENT = /Comment$/
+
+/**
+ * Folding, which a stream language does not get for free: a line whose last
+ * token (comments aside) opens a brace or bracket folds to its partner.
+ */
+const jsoncFold = foldService.of((state, lineStart, lineEnd) => {
+ let open = -1
+ syntaxTree(state).iterate({
+ from: lineStart,
+ to: lineEnd,
+ enter: (node) => {
+ if (node.from < lineStart || node.to > lineEnd || node.type.isTop) return
+ if (COMMENT.test(node.name)) return
+ const text = state.doc.sliceString(node.from, node.to)
+ open = text === '{' || text === '[' ? node.from : -1
+ }
+ })
+ if (open < 0) return null
+ const match = matchBrackets(state, open, 1)
+ if (!match?.matched || !match.end || match.end.from <= lineEnd) return null
+ return { from: open + 1, to: match.end.from }
+})
+
+export const jsoncLanguage = StreamLanguage.define(jsoncParser)
+
+/** The language and its folding, as the editor attaches them. */
+export function jsonc(): Extension {
+ return [jsoncLanguage, jsoncFold]
+}
diff --git a/tools/e2e/run.mjs b/tools/e2e/run.mjs
index 6cf1de6c..7341317c 100644
--- a/tools/e2e/run.mjs
+++ b/tools/e2e/run.mjs
@@ -3765,6 +3765,98 @@ async function codeScenario(fixtures) {
}
}
+/**
+ * JSONC comments are comments (#312; owner, 2026-10-07: "comments in jsonc
+ * arent read as comments in prism", with a wrangler.jsonc whose every // line
+ * was plain text under a red squiggle). Fails on the old build, where .jsonc
+ * went to the strict JSON grammar and JSON.parse.
+ */
+async function jsoncScenario(fixtures) {
+ console.log('JSONC comments read as comments')
+ const dir = join(fixtures, 'jsonc')
+ rmSync(dir, { recursive: true, force: true })
+ mkdirSync(dir, { recursive: true })
+ writeFileSync(
+ join(dir, 'wrangler.jsonc'),
+ [
+ '/**',
+ ' * For more details on how to configure Wrangler, refer to:',
+ ' * https://developers.cloudflare.com/workers/wrangler/configuration/',
+ ' */',
+ '{',
+ ' "$schema": "node_modules/wrangler/config-schema.json",',
+ ' "name": "my-worker",',
+ ' "main": "src/index.ts",',
+ ' "compatibility_date": "2025-04-01",',
+ ' // "compatibility_flags": ["nodejs_compat"],',
+ ' "observability": {',
+ ' "enabled": true',
+ ' },',
+ ' /**',
+ ' * Smart Placement',
+ ' */',
+ ' // "placement": { "mode": "smart" },',
+ ' "assets": { "directory": "./public/" },',
+ '}',
+ ''
+ ].join('\n')
+ )
+ // A well-known name with no .jsonc on it, and a trailing comma.
+ writeFileSync(
+ join(dir, 'tsconfig.json'),
+ '{\n // Base options\n "compilerOptions": {\n "strict": true, /* always */\n "target": "ES2022",\n },\n}\n'
+ )
+ // JSONC is not "anything goes": a missing comma is still an error.
+ writeFileSync(join(dir, 'zbroken.jsonc'), '{\n // fine\n "a": 1\n "b": 2\n}\n')
+
+ const { app, win } = await launch(join(dir, 'wrangler.jsonc'))
+ /** Every rendered span whose text is a comment, with its colour and the theme's. */
+ const comments = () =>
+ win.evaluate(() => {
+ const probe = document.createElement('span')
+ probe.style.color = 'var(--p-code-comment)'
+ document.querySelector('.cm-content').appendChild(probe)
+ const want = getComputedStyle(probe).color
+ probe.remove()
+ const spans = [...document.querySelectorAll('.cm-line span')]
+ .filter((s) => /^\s*(\/\/|\/\*|\*)/.test(s.textContent))
+ .map((s) => ({ text: s.textContent, color: getComputedStyle(s).color, italic: getComputedStyle(s).fontStyle }))
+ const plain = getComputedStyle(document.querySelector('.cm-line')).color
+ return { want, plain, spans }
+ })
+ try {
+ await win.waitForSelector('.cm-content', { timeout: 10000 })
+ await win.waitForFunction(() => document.querySelectorAll('.cm-line span').length > 5, undefined, { timeout: 10000 })
+ await sleep(1500) // past the linter's debounce, so absence means absence
+ ok((await win.locator('.cm-lintRange-error').count()) === 0, 'a wrangler.jsonc with comments has no squiggles')
+ const c = await comments()
+ ok(c.spans.length >= 6, `every comment line is its own token (got ${c.spans.length})`)
+ ok(c.want !== c.plain, `the theme's comment colour differs from plain text (${c.want} vs ${c.plain})`)
+ ok(
+ c.spans.every((s) => s.color === c.want),
+ `and every comment wears it (${c.spans.filter((s) => s.color !== c.want).map((s) => s.text).join(' | ') || 'all do'})`
+ )
+ ok(c.spans.every((s) => s.italic === 'italic'), 'in the comment style')
+ await win.screenshot({ path: join(SHOTS, 'jsonc.png') })
+
+ await win.click('[role="treeitem"]:has-text("tsconfig.json")')
+ await win.waitForFunction(() => (document.querySelector('.cm-content')?.textContent ?? '').includes('compilerOptions'), undefined, { timeout: 10000 })
+ await sleep(1500)
+ ok((await win.locator('.cm-lintRange-error').count()) === 0, 'tsconfig.json takes comments and a trailing comma too')
+ const t = await comments()
+ ok(t.spans.length >= 2 && t.spans.every((s) => s.color === t.want), 'and colours them as comments')
+
+ await win.click('[role="treeitem"]:has-text("zbroken.jsonc")')
+ await win.waitForFunction(() => (document.querySelector('.cm-content')?.textContent ?? '').includes('"b": 2'), undefined, { timeout: 10000 })
+ await win.waitForSelector('.cm-lintRange-error', { timeout: 10000 }).catch(() => {})
+ const marked = await win.locator('.cm-lintRange-error').allTextContents()
+ ok(marked.length === 1 && marked[0].startsWith('"'), `a missing comma in JSONC is still underlined, once (${JSON.stringify(marked)})`)
+ await win.screenshot({ path: join(SHOTS, 'jsonc-error.png') })
+ } finally {
+ await app.close()
+ }
+}
+
async function treeNavScenario(fixtures) {
console.log('tree navigation')
// Open inside code/, so the root has folders above and below the cursor.
@@ -16063,6 +16155,7 @@ await run(reloadScenario)
await run(tailScenario)
await run(hexScenario)
await run(codeScenario)
+await run(jsoncScenario)
await run(treeNavScenario)
await run(unsavedScenario)
await run(playerScenario)