diff --git a/README.md b/README.md index 33c4b6b..c87686e 100644 --- a/README.md +++ b/README.md @@ -15,3 +15,50 @@ yarn ``` yarn add @nitrots/nitro-renderer ``` + +## JSON / JSON5 configuration parser + +Every configuration file and gamedata file loaded by the renderer (figuredata, +furnidata, productdata, effectmap, avatar actions, etc.) goes through +`@nitrots/utils` → `JsonParser.ts`. The parser supports three modes, selected at +the **host build time** through the compile-time constant `__NITRO_JSON_MODE__`: + +| Mode | Behaviour | +|----------|---------------------------------------------------------------------------| +| `legacy` | Strict `JSON.parse` only. Comments / trailing commas raise a clear error. | +| `json5` | `JSON5.parse` only. Accepts comments, trailing commas, single quotes. | +| `auto` | Try strict JSON first, fall back to JSON5. Default when the flag is unset.| + +URL hints are still honoured: files ending in `.json5` (or served with a +`application/json5` content-type) always go through JSON5, regardless of mode. + +### Wiring the flag into a host + +The renderer does **not** ship its own build for the flag — the host application +(typically [Nitro V3](https://github.com/duckietm/Nitro-V3.git)) defines it via +its bundler. Example with Vite: + +```js +// vite.config.mjs in the host +export default defineConfig({ + define: { + __NITRO_JSON_MODE__: JSON.stringify('json5') // or 'legacy' / 'auto' + } +}); +``` + +If the constant is not defined the parser falls back to `auto`, which preserves +the original behaviour of older releases — so existing hosts keep working +without any change. + +### Using the parser directly + +```ts +import { parseConfigJson, fetchConfigJson } from '@nitrots/utils'; + +const data = parseConfigJson(rawText, '/configuration/ui-config.json'); +const data2 = await fetchConfigJson('/configuration/ui-config.json5'); +``` + +Errors carry the source URL and, in `legacy` mode, a hint about switching to +JSON5 — making misconfigurations easy to diagnose in production logs. diff --git a/packages/utils/src/JsonParser.ts b/packages/utils/src/JsonParser.ts index 8770523..c812d63 100644 --- a/packages/utils/src/JsonParser.ts +++ b/packages/utils/src/JsonParser.ts @@ -1,8 +1,24 @@ import JSON5 from 'json5'; +declare const __NITRO_JSON_MODE__: 'legacy' | 'json5' | 'auto' | undefined; + const JSON5_EXTENSION = /\.json5(?:[?#]|$)/i; const JSON5_MIME = /(?:application|text)\/(?:json5|x-json5)/i; +const resolveJsonMode = (): 'legacy' | 'json5' | 'auto' => +{ + try + { + if(typeof __NITRO_JSON_MODE__ !== 'undefined' && __NITRO_JSON_MODE__) + { + if(__NITRO_JSON_MODE__ === 'legacy' || __NITRO_JSON_MODE__ === 'json5' || __NITRO_JSON_MODE__ === 'auto') return __NITRO_JSON_MODE__; + } + } + catch {} + + return 'auto'; +}; + const looksLikeJson5Url = (url: string): boolean => !!url && JSON5_EXTENSION.test(url); const looksLikeJson5ContentType = (contentType: string): boolean => !!contentType && JSON5_MIME.test(contentType); @@ -18,13 +34,34 @@ const formatParseError = (sourceUrl: string, strictError: unknown, json5Error: u return `Failed to parse JSON/JSON5${ source } — JSON5: ${ json5Message } (strict JSON: ${ strictMessage })`; }; +const formatStrictError = (sourceUrl: string, err: unknown): string => +{ + const message = (err as Error)?.message || String(err); + const source = sourceUrl ? ` in "${ sourceUrl }"` : ''; + + return `Failed to parse strict JSON${ source } — ${ message } (build is in 'legacy' mode; switch to JSON5 mode via 'yarn configure' to accept comments/trailing commas)`; +}; + export const parseConfigJson = (text: string, sourceUrl: string = ''): T => { if(text === null || text === undefined) throw new Error(`Empty response${ sourceUrl ? ` for "${ sourceUrl }"` : '' }`); const trimmed = text.length > 0 ? text : ''; + const mode = resolveJsonMode(); - if(looksLikeJson5Url(sourceUrl)) + if(mode === 'legacy') + { + try + { + return JSON.parse(trimmed) as T; + } + catch(err) + { + throw new Error(formatStrictError(sourceUrl, err)); + } + } + + if(mode === 'json5' || looksLikeJson5Url(sourceUrl)) { try { @@ -62,8 +99,9 @@ export const parseConfigJsonFromResponse = async (response: Response, s const contentType = response.headers?.get?.('content-type') || ''; const text = await response.text(); const url = sourceUrl || (response as any).url || ''; + const mode = resolveJsonMode(); - if(looksLikeJson5ContentType(contentType) && !looksLikeJson5Url(url)) + if(mode === 'auto' && looksLikeJson5ContentType(contentType) && !looksLikeJson5Url(url)) { try {