diff --git a/.gitignore b/.gitignore index 90a9bfe..b2f21a6 100644 --- a/.gitignore +++ b/.gitignore @@ -29,6 +29,9 @@ Thumbs.db .env .claude/ +# Per-deploy build configuration (yarn configure) +/.nitro-build.json + # Local runtime config copies /public/configuration/renderer-config.json /public/configuration/ui-config.json diff --git a/README.md b/README.md index bc91d90..1b00a3d 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,71 @@ - `yarn build` <== the final step to build the DIST folder this is where your browser needs to point / or upload this to your /client if you do the compile on a other machine (preferd) - You can override any variable by passing it to `NitroConfig` in the index.html +## JSON / JSON5 configuration mode + +Starting with this version of Nitro V3, you can choose how the client parses the +configuration files (`renderer-config.json`, `ui-config.json`, `client-mode.json`, +and the gamedata JSONs served by the renderer): + +- **JSON5** (recommended) — accepts comments, trailing commas, single quotes + and unquoted identifiers. Easier to maintain, especially in `ui-config.json` + where you may want inline notes. +- **JSON (legacy strict)** — only valid standard JSON is accepted. Any comment + or trailing comma will fail the load with a clear error. + +### Picking a mode + +The first time you run `yarn start` or `yarn build`, an interactive prompt asks +which mode to use: + +``` +════════════════════════════════════════════════════════════ + Nitro V3 — JSON mode configuration +════════════════════════════════════════════════════════════ + + 1) JSON5 (recommended) + 2) JSON (legacy strict) + +Scelta [1=JSON5]: +``` + +Your choice is stored in `.nitro-build.json` at the project root (gitignored, so +each deployment keeps its own setting). Subsequent builds reuse it silently. + +### Changing the mode later + +Run the prompt again at any time: + +``` +yarn configure +``` + +You can also set the mode without interaction (useful in CI / scripts): + +``` +# one-shot override for a single build +NITRO_JSON_MODE=legacy yarn build +NITRO_JSON_MODE=json5 yarn build + +# write the choice persistently +echo '{"jsonMode":"legacy"}' > .nitro-build.json +``` + +The recognized values are `legacy`, `json5`, and `auto` (auto = try strict JSON +first, fall back to JSON5 — equivalent to the original Render V3 behaviour). + +### How it propagates + +The chosen mode is injected at build time as the compile-time constant +`__NITRO_JSON_MODE__`. It is honoured by: + +- `src/bootstrap.ts` when loading `client-mode.json` +- `@nitrots/utils` → `JsonParser.ts` in Render V3, used for every config file + and every gamedata JSON loaded by the renderer + +In `legacy` mode, an invalid file produces a clear error that suggests switching +to JSON5; nothing is silently coerced. + ## Usage - To use Nitro you need `.nitro` assets generated, see [nitro-converter](https://git.krews.org/nitro/nitro-converter) for instructions diff --git a/package.json b/package.json index f31d0c6..3aaca07 100644 --- a/package.json +++ b/package.json @@ -4,9 +4,11 @@ "homepage": ".", "private": true, "scripts": { - "prebuild": "node scripts/write-asset-loader.mjs", - "start": "vite --host", - "build": "vite build && node scripts/minify-dist.mjs", + "configure": "node scripts/configure-json.mjs", + "prestart": "node scripts/configure-json.mjs --if-missing", + "prebuild": "node scripts/configure-json.mjs --if-missing && node scripts/write-asset-loader.mjs", + "start": "vite --base=/nitro/ --host", + "build": "vite build --base=/nitro/ && node scripts/minify-dist.mjs", "build:prod": "npx browserslist@latest --update-db && yarn build", "eslint": "eslint ./src" }, diff --git a/public/configuration/asset-loader.js b/public/configuration/asset-loader.js index 19e2307..7483733 100644 --- a/public/configuration/asset-loader.js +++ b/public/configuration/asset-loader.js @@ -44,6 +44,11 @@ return new URL(".", source); }; + const getDeployBase = () => { + try { return new URL("..", getBase()); } + catch { return new URL("/", location.href); } + }; + const withCacheBust = (url) => { url.searchParams.set("v", Date.now().toString(36)); return url; @@ -71,9 +76,14 @@ const resolveAssetCandidates = (path) => { const base = getBase(); + const deploy = getDeployBase(); const normalized = path.replace(/^\.\//, ""); const file = normalized.split("/").pop(); + const relative = normalized.replace(/^\//, ""); const urls = [ + new URL("src/assets/" + file, deploy), + new URL("assets/" + file, deploy), + new URL(relative, deploy), new URL("./src/assets/" + file, base), new URL("./assets/" + file, base), new URL("/src/assets/" + file, base.origin), @@ -205,7 +215,10 @@ const fetchManifest = async () => { const base = getBase(); + const deploy = getDeployBase(); const candidates = [ + new URL(".vite/manifest.json", deploy), + new URL("manifest.json", deploy), new URL(".vite/manifest.json", base.origin + "/"), new URL("manifest.json", base.origin + "/"), new URL(".vite/manifest.json", base), @@ -221,7 +234,11 @@ const json = await response.json(); if(json && typeof json === "object") { debug("loader: manifest from " + candidate.href); - return { manifest: json, base: new URL(".", candidate.href) }; + let manifestBase = new URL(".", candidate.href); + if(/\/\.vite\/manifest\.json$/.test(candidate.pathname)) { + manifestBase = new URL("..", manifestBase); + } + return { manifest: json, base: manifestBase }; } } catch {} } @@ -247,18 +264,24 @@ const resolveManifestPath = (manifestBase, file) => { if(/^https?:\/\//i.test(file)) return file; if(file.startsWith("/")) return file; - return new URL(file, manifestBase.origin + "/").pathname; + return new URL(file, manifestBase).pathname; }; const isLoaderUrl = (href) => /(?:^|\/)bootstrap\.js(?:$|\?|#)/i.test(href) || /(?:^|\/)asset-loader\.js(?:$|\?|#)/i.test(href); const fetchEntryFromIndexHtml = async () => { const base = getBase(); + const deploy = getDeployBase(); const candidates = [ + new URL("index.html", deploy), + new URL("./", deploy), new URL("/index.html", base.origin + "/"), new URL("/", base.origin + "/") ]; + const seen = new Set(); for(const candidate of candidates) { + if(seen.has(candidate.href)) continue; + seen.add(candidate.href); try { const response = await fetch(withCacheBust(new URL(candidate.href)), { cache: "no-store" }); if(!response.ok) continue; diff --git a/public/configuration/bootstrap.js b/public/configuration/bootstrap.js index 997602f..13c1d8f 100644 --- a/public/configuration/bootstrap.js +++ b/public/configuration/bootstrap.js @@ -1,17 +1,4 @@ (() => { - const API_BASE = "http://localhost:2096"; - - const ensureMobileViewport = () => { - let viewport = document.querySelector('meta[name="viewport"]'); - if(!viewport) { - viewport = document.createElement("meta"); - viewport.name = "viewport"; - document.head.appendChild(viewport); - } - viewport.content = "width=device-width, initial-scale=1, viewport-fit=cover"; - }; - - ensureMobileViewport(); const FALLBACK_API_BASE = ""; const getBase = () => { diff --git a/scripts/configure-json.mjs b/scripts/configure-json.mjs new file mode 100644 index 0000000..6dfa846 --- /dev/null +++ b/scripts/configure-json.mjs @@ -0,0 +1,120 @@ +#!/usr/bin/env node +import { existsSync, readFileSync, writeFileSync } from 'fs'; +import { resolve } from 'path'; +import { fileURLToPath } from 'url'; +import readline from 'readline'; + +const SCRIPT_DIR = fileURLToPath(new URL('.', import.meta.url)); +const PROJECT_ROOT = resolve(SCRIPT_DIR, '..'); +const CONFIG_FILE = resolve(PROJECT_ROOT, '.nitro-build.json'); +const VALID_MODES = new Set(['legacy', 'json5']); +const DEFAULT_MODE = 'json5'; + +const args = process.argv.slice(2); +const ifMissing = args.includes('--if-missing'); +const nonInteractive = args.includes('--non-interactive') || !process.stdin.isTTY; + +const readExisting = () => +{ + if(!existsSync(CONFIG_FILE)) return null; + + try + { + const raw = readFileSync(CONFIG_FILE, 'utf8'); + const parsed = JSON.parse(raw); + if(parsed && VALID_MODES.has(parsed.jsonMode)) return parsed; + } + catch {} + + return null; +}; + +const writeChoice = (mode) => +{ + const payload = { + jsonMode: mode, + configuredAt: new Date().toISOString() + }; + writeFileSync(CONFIG_FILE, `${ JSON.stringify(payload, null, 2) }\n`, 'utf8'); +}; + +const printBanner = () => +{ + const line = '═'.repeat(60); + process.stdout.write(`\n${ line }\n Nitro V3 — JSON mode configuration\n${ line }\n\n`); + process.stdout.write('I file di configurazione (renderer-config, ui-config, gamedata)\npossono essere parsati in due modi:\n\n'); + process.stdout.write(' 1) JSON5 (consigliato — accetta commenti, trailing comma,\n single quote, identifier non quotati)\n'); + process.stdout.write(' 2) JSON (legacy strict — solo JSON valido standard)\n\n'); +}; + +const normalizeAnswer = (raw) => +{ + const v = (raw || '').trim().toLowerCase(); + if(!v || v === '1' || v === 'json5' || v === 'y' || v === 'yes') return 'json5'; + if(v === '2' || v === 'json' || v === 'legacy' || v === 'n' || v === 'no') return 'legacy'; + return null; +}; + +const promptUser = () => new Promise(resolveFn => +{ + const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); + + const ask = () => + { + rl.question('Scelta [1=JSON5]: ', answer => + { + const normalized = normalizeAnswer(answer); + if(normalized === null) + { + process.stdout.write(' ↳ Risposta non valida. Inserisci 1, 2, json5 o json.\n'); + return ask(); + } + rl.close(); + resolveFn(normalized); + }); + }; + + ask(); +}); + +const main = async () => +{ + const existing = readExisting(); + + if(ifMissing && existing) + { + process.stdout.write(`[configure-json] modalità già configurata: ${ existing.jsonMode } (skip)\n`); + return; + } + + if(nonInteractive) + { + const mode = existing?.jsonMode || DEFAULT_MODE; + writeChoice(mode); + process.stdout.write(`[configure-json] non interattivo — salvato: ${ mode }\n`); + return; + } + + printBanner(); + if(existing) process.stdout.write(`Modalità corrente: ${ existing.jsonMode }\n\n`); + + const choice = await promptUser(); + writeChoice(choice); + + process.stdout.write(`\n✓ Salvato in .nitro-build.json — modalità: ${ choice }\n`); + if(choice === 'legacy') + { + process.stdout.write(' Attenzione: i file di config devono essere JSON valido stretto\n (no commenti, no trailing comma).\n'); + } + else + { + process.stdout.write(' JSON5 attivo: puoi usare commenti, trailing comma e single quote\n nei file di configurazione.\n'); + } + process.stdout.write('\n Per cambiare modalità in futuro: yarn configure\n\n'); +}; + +main().catch(err => +{ + process.stderr.write(`[configure-json] errore: ${ err?.message || err }\n`); + process.exit(1); +}); diff --git a/src/bootstrap.ts b/src/bootstrap.ts index 71406fe..7646c5d 100644 --- a/src/bootstrap.ts +++ b/src/bootstrap.ts @@ -1,6 +1,22 @@ import JSON5 from 'json5'; import { configFileUrl, getClientMode, installSecureFetch } from './secure-assets'; +declare const __NITRO_JSON_MODE__: 'legacy' | 'json5' | 'auto' | undefined; + +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 ensureMobileViewport = () => { let viewport = document.querySelector('meta[name="viewport"]'); @@ -76,16 +92,28 @@ const loadClientMode = async () => if(!response.ok) throw new Error(`HTTP ${ response.status }`); const text = await response.text(); + const mode = resolveJsonMode(); - try + if(mode === 'legacy') { (window as any).__nitroClientMode = JSON.parse(text); } - catch + else if(mode === 'json5') { (window as any).__nitroClientMode = JSON5.parse(text); } - setBootDebug('boot: client-mode loaded'); + else + { + try + { + (window as any).__nitroClientMode = JSON.parse(text); + } + catch + { + (window as any).__nitroClientMode = JSON5.parse(text); + } + } + setBootDebug(`boot: client-mode loaded (mode=${ mode })`); } catch(error) { diff --git a/vite.config.mjs b/vite.config.mjs index b8c3177..994cf7f 100644 --- a/vite.config.mjs +++ b/vite.config.mjs @@ -1,5 +1,5 @@ import react from '@vitejs/plugin-react'; -import { existsSync } from 'fs'; +import { existsSync, readFileSync } from 'fs'; import { resolve } from 'path'; import { defineConfig } from 'vite'; @@ -7,9 +7,34 @@ const legacyRendererRoot = resolve(__dirname, '..', 'renderer'); const currentRendererRoot = resolve(__dirname, '..', 'Nitro_Render_V3'); const rendererRoot = existsSync(currentRendererRoot) ? currentRendererRoot : legacyRendererRoot; +const resolveJsonMode = () => +{ + const envOverride = process.env.NITRO_JSON_MODE; + if(envOverride === 'legacy' || envOverride === 'json5' || envOverride === 'auto') return envOverride; + + const configFile = resolve(__dirname, '.nitro-build.json'); + if(existsSync(configFile)) + { + try + { + const parsed = JSON.parse(readFileSync(configFile, 'utf8')); + if(parsed?.jsonMode === 'legacy' || parsed?.jsonMode === 'json5' || parsed?.jsonMode === 'auto') return parsed.jsonMode; + } + catch {} + } + + return 'auto'; +}; + +const nitroJsonMode = resolveJsonMode(); +process.stdout.write(`[vite] __NITRO_JSON_MODE__ = ${ nitroJsonMode }\n`); + export default defineConfig({ base: process.env.VITE_BASE || './', plugins: [ react() ], + define: { + __NITRO_JSON_MODE__: JSON.stringify(nitroJsonMode) + }, server: { fs: { allow: [ @@ -29,6 +54,7 @@ export default defineConfig({ alias: { '@': resolve(__dirname, 'src'), '~': resolve(__dirname, 'node_modules'), + '@nitrots/nitro-renderer': resolve(rendererRoot, 'index.ts'), '@nitrots/api': resolve(rendererRoot, 'packages/api/src/index.ts'), '@nitrots/assets': resolve(rendererRoot, 'packages/assets/src/index.ts'), '@nitrots/avatar': resolve(rendererRoot, 'packages/avatar/src/index.ts'),