diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..df6d9a0 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,4 @@ +install.sh text eol=lf +install.mjs text eol=lf +*.sh text eol=lf +*.bat text eol=crlf diff --git a/.gitignore b/.gitignore index 208d8f3..e7bee96 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..cbfe365 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,163 @@ - If using NodeJS < 18 remove `--openssl-legacy-provider` from the package.json scripts - [Yarn](https://yarnpkg.com/) `npm i yarn -g` -## Installation +## Quick install (recommended) + +The repository ships a cross-platform installer that performs the full setup +in one go: prerequisites check, renderer clone & link, dependency install, +config copy, JSON parsing mode selection, URL prompt with validation, and the +production build. + +After cloning Nitro V3, from its root run: + +``` +# Windows +install.bat + +# Linux / macOS +./install.sh +``` + +Both wrappers just exec `node install.mjs`, so you can also invoke it directly: + +``` +node install.mjs +``` + +The installer walks through these steps: + +``` +[1/9] Check prerequisites (node >= 18, yarn, git) +[2/9] Clone Nitro_Render_V3 +[3/9] Setup renderer (yarn install + yarn link) +[4/9] Setup client (yarn install + yarn link "@nitrots/nitro-renderer") +[5/9] Copy public/configuration/*.example -> *.json +[6/9] Choose JSON parsing mode (json5 recommended) -> writes .nitro-build.json +[7/9] Configure URLs (interactive, validated) +[8/9] Build (yarn build) +[9/9] Summary +``` + +### Headless / CI runs + +Every step can be driven from flags so the installer can be used in pipelines: + +``` +node install.mjs --non-interactive \ + --json-mode=json5 \ + --socket-url=wss://example.com/ws \ + --api-url=https://example.com \ + --asset-url=https://example.com/nitro-assets/ \ + --image-library-url=https://example.com/c_images \ + --hof-furni-url=https://example.com/hof_furni \ + --camera-url=https://example.com/camera \ + --thumbnails-url=https://example.com/thumbnails \ + --habbopages-url=/habbopages \ + --api-base-url=https://example.com \ + --plain-config-base-url=https://example.com/configuration \ + --plain-gamedata-base-url=https://example.com/gamedata \ + --skip-link +``` + +Useful workflow flags: + +- `--non-interactive` / `--skip-prompts` — keep example defaults unless a URL override is passed +- `--json-mode=` — pick the parser without the JSON mode prompt +- `--skip-build`, `--skip-clone`, `--skip-link` — re-runs without redoing those steps +- `--help` — full flag reference and per-key URL flags + +`install.mjs` is idempotent: re-running it keeps any `*.json` config files +that already exist and only patches the URL keys you pass on the CLI. + +## Splitting gamedata + +The renderer can load gamedata files (FigureData, FurnitureData, FigureMap, +EffectMap, ProductData, HabboAvatarActions, ExternalTexts, UITexts) either as +a single legacy JSON/JSON5 file or as a **directory of small files** organised +in three tiers: `core/` (vendor baseline), `custom/` (your additions / overrides), +`seasonal/` (date-bound content such as Christmas or Easter). + +The split layout is much easier to maintain — you edit a small focused file +instead of a 43 MB FurnitureData.json — and lets you keep vendor and operator +content cleanly separated. + +### Directory layout + +``` +nitro-assets/gamedata/furnidata/ + manifest.json5 # { "tiers": ["core", "custom", "seasonal"] } + core/ + manifest.json5 # { "files": ["floor-001.json5", ..., "wall-001.json5"] } + floor-001.json5 + floor-002.json5 + wall-001.json5 + custom/ # OPTIONAL — created by you + manifest.json5 # { "files": ["my-rares.json5"] } + my-rares.json5 + seasonal/ # OPTIONAL — created by you + manifest.json5 + xmas-2026.json5 +``` + +Each tier is loaded in order. Within a tier, files load in the order listed in +its `manifest.json5`. Items in later layers override items in earlier layers +when they share the same identifier (`id`, `classname`, `name`, or the +top-level key for flat dictionaries). + +### Generating the `core/` tier from a legacy file + +Use the bundled CLI splitter: + +``` +node scripts/split-gamedata.mjs \ + --input ~/legacy-gamedata/FurnitureData.json \ + --output ~/nitro-assets/gamedata/furnidata +``` + +It auto-detects the gamedata type from the file's top-level keys and applies +the strategy that makes the most sense: + +| Type | Split strategy | +|----------------------|---------------------------------------------| +| EffectMap | one file per effect `type` (dance, fx, ...) | +| FigureData | one `palettes.json5` + one file per setType | +| FigureMap | chunks of `libraries` (default 500/file) | +| FurnitureData | floor / wall, chunks of `furnitype` (300) | +| HabboAvatarActions | grouped by `state` (or single file if ≤1) | +| ProductData | chunks of products (default 500) | +| ExternalTexts/UITexts| grouped by key prefix (e.g. `gamecenter.*`) | + +Useful flags: `--type=` to force the type, `--chunk-size=N` to override +the default chunk size, `--json` to emit standard JSON instead of JSON5, +`--force` to overwrite an existing output directory. Full reference: + +``` +node scripts/split-gamedata.mjs --help +``` + +We only ship the `core/` tier with vendor baselines — `custom/` and `seasonal/` +are operator-owned: create their manifests when you need them and the loader +picks them up automatically. + +### Pointing the renderer at a directory + +In `public/configuration/renderer-config.json`, replace the legacy file URL +with the directory URL (note the trailing slash — that's how the loader +detects split mode): + +```json5 +{ + // single file (legacy, still supported): + "furnidata.url": "https://example.com/nitro-assets/gamedata/FurnitureData.json", + + // directory (split mode): + "furnidata.url": "https://example.com/nitro-assets/gamedata/furnidata/", +} +``` + +Both styles work; you can migrate one gamedata file at a time. + +## Installation (manual) - First you should open terminal and navigate to the folder where you want to clone Nitro and Nitro-Renderer - Clone Nitro (Expl. C:\Github\) @@ -31,6 +187,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/install.bat b/install.bat new file mode 100644 index 0000000..46cbf0c --- /dev/null +++ b/install.bat @@ -0,0 +1,2 @@ +@echo off +node "%~dp0install.mjs" %* diff --git a/install.mjs b/install.mjs new file mode 100644 index 0000000..186b595 --- /dev/null +++ b/install.mjs @@ -0,0 +1,556 @@ +#!/usr/bin/env node +import { spawn } from 'node:child_process'; +import { copyFile, readFile, writeFile } from 'node:fs/promises'; +import { existsSync } from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { platform } from 'node:os'; +import * as readline from 'node:readline/promises'; +import { stdin as input, stdout as output } from 'node:process'; + +const ROOT = dirname(fileURLToPath(import.meta.url)); +const RENDERER_REPO_URL = 'https://github.com/duckietm/Nitro_Render_V3.git'; +const RENDERER_DIR = resolve(ROOT, '..', 'Nitro_Render_V3'); +const CONFIG_DIR = join(ROOT, 'public', 'configuration'); +const NITRO_BUILD_FILE = join(ROOT, '.nitro-build.json'); +const IS_WINDOWS = platform() === 'win32'; +const MIN_NODE_MAJOR = 18; +const VALID_JSON_MODES = ['json5', 'legacy', 'auto']; +const DEFAULT_JSON_MODE = 'json5'; + +const KEY_SPECS = { + 'socket.url': { type: 'url', schemes: ['ws:', 'wss:'], flag: 'socket-url' }, + 'api.url': { type: 'url', schemes: ['http:', 'https:'], flag: 'api-url' }, + 'asset.url': { type: 'url', schemes: ['http:', 'https:'], flag: 'asset-url' }, + 'image.library.url': { type: 'url', schemes: ['http:', 'https:'], flag: 'image-library-url' }, + 'hof.furni.url': { type: 'url', schemes: ['http:', 'https:'], flag: 'hof-furni-url' }, + 'camera.url': { type: 'url', schemes: ['http:', 'https:'], flag: 'camera-url' }, + 'thumbnails.url': { type: 'url', schemes: ['http:', 'https:'], flag: 'thumbnails-url' }, + 'url.prefix': { type: 'pathOrUrl', schemes: ['http:', 'https:'], flag: 'url-prefix' }, + 'habbopages.url': { type: 'pathOrUrl', schemes: ['http:', 'https:'], flag: 'habbopages-url' }, + 'apiBaseUrl': { type: 'url', schemes: ['http:', 'https:'], flag: 'api-base-url' }, + 'plainConfigBaseUrl': { type: 'url', schemes: ['http:', 'https:'], flag: 'plain-config-base-url' }, + 'plainGamedataBaseUrl': { type: 'url', schemes: ['http:', 'https:'], flag: 'plain-gamedata-base-url' } +}; + +const FLAG_TO_KEY = Object.fromEntries( + Object.entries(KEY_SPECS).map(([key, spec]) => [spec.flag, key]) +); + +const CONFIG_FILES = [ + { + example: 'renderer-config.example', + target: 'renderer-config.json', + keys: ['socket.url', 'api.url', 'asset.url', 'image.library.url', 'hof.furni.url'] + }, + { + example: 'ui-config.example', + target: 'ui-config.json', + keys: ['camera.url', 'thumbnails.url', 'url.prefix', 'habbopages.url'] + }, + { + example: 'client-mode.example', + target: 'client-mode.json', + keys: ['apiBaseUrl', 'plainConfigBaseUrl', 'plainGamedataBaseUrl'] + } +]; + +const STEPS = [ + 'Check prerequisites', + 'Clone Nitro_Render_V3', + 'Setup renderer (yarn install + yarn link)', + 'Setup client (yarn install + yarn link)', + 'Copy config files', + 'Choose JSON parsing mode', + 'Configure URLs', + 'Build (yarn build)', + 'Summary' +]; + +let currentStep = 0; +let activeReadline = null; +const summary = { + rendererCloned: false, + rendererSkipped: false, + configsCreated: [], + configsKept: [], + configsPatched: [], + jsonMode: null, + jsonModeSource: null, + buildRan: false, + buildSkipped: false +}; + +const useColor = !process.env.NO_COLOR && process.stdout.isTTY; +const c = { + reset: useColor ? '\x1b[0m' : '', + bold: useColor ? '\x1b[1m' : '', + dim: useColor ? '\x1b[2m' : '', + red: useColor ? '\x1b[31m' : '', + green: useColor ? '\x1b[32m' : '', + yellow: useColor ? '\x1b[33m' : '', + cyan: useColor ? '\x1b[36m' : '' +}; + +function info(msg) { console.log(c.cyan + '[i]' + c.reset + ' ' + msg); } +function ok(msg) { console.log(c.green + '[+]' + c.reset + ' ' + msg); } +function warn(msg) { console.log(c.yellow + '[!]' + c.reset + ' ' + msg); } +function err(msg) { console.error(c.red + '[x]' + c.reset + ' ' + msg); } + +function step(label) { + currentStep += 1; + const sep = '----------------------------------------------------------------'; + console.log(''); + console.log(c.dim + sep + c.reset); + console.log(c.bold + '[' + currentStep + '/' + STEPS.length + '] ' + label + c.reset); + console.log(c.dim + sep + c.reset); +} + +function runShell(cmdString, cwd) { + return new Promise((resolveFn, rejectFn) => { + const child = spawn(cmdString, { shell: true, cwd, stdio: 'inherit' }); + child.on('error', e => rejectFn(new Error("'" + cmdString + "' (cwd: " + cwd + ") failed to start: " + e.message))); + child.on('exit', code => { + if (code === 0) resolveFn(); + else rejectFn(new Error("'" + cmdString + "' (cwd: " + cwd + ") exited with code " + code)); + }); + }); +} + +function runCapture(cmdString) { + return new Promise((resolveFn, rejectFn) => { + const child = spawn(cmdString, { shell: true, stdio: ['ignore', 'pipe', 'pipe'] }); + let stdout = ''; + let stderr = ''; + child.stdout.on('data', d => { stdout += d.toString(); }); + child.stderr.on('data', d => { stderr += d.toString(); }); + child.on('error', e => rejectFn(new Error("'" + cmdString + "' failed to start: " + e.message))); + child.on('exit', code => { + if (code === 0) resolveFn(stdout.trim()); + else rejectFn(new Error("'" + cmdString + "' exited with code " + code + (stderr ? ': ' + stderr.trim() : ''))); + }); + }); +} + +function validateValue(value, spec) { + if (value === '' || value === undefined || value === null) { + if (spec.type === 'pathOrUrl') return { valid: true }; + return { valid: false, error: 'value cannot be empty' }; + } + if (spec.type === 'pathOrUrl' && value.startsWith('/')) { + return { valid: true }; + } + let parsed; + try { + parsed = new URL(value); + } catch { + return { valid: false, error: 'not a valid URL' }; + } + if (spec.schemes.length > 0 && !spec.schemes.includes(parsed.protocol)) { + const allowed = spec.schemes.map(s => s.replace(':', '')).join(', '); + return { valid: false, error: 'scheme must be one of: ' + allowed }; + } + return { valid: true }; +} + +function parseArgs() { + const opts = { + interactive: true, + skipBuild: false, + skipClone: false, + skipLink: false, + help: false, + jsonMode: null, + urlOverrides: {} + }; + const builtinFlags = new Set([ + '--non-interactive', '--skip-prompts', + '--skip-build', '--skip-clone', '--skip-link', + '--help', '-h' + ]); + for (const arg of process.argv.slice(2)) { + if (builtinFlags.has(arg)) { + switch (arg) { + case '--non-interactive': + case '--skip-prompts': opts.interactive = false; break; + case '--skip-build': opts.skipBuild = true; break; + case '--skip-clone': opts.skipClone = true; break; + case '--skip-link': opts.skipLink = true; break; + case '--help': + case '-h': opts.help = true; break; + } + continue; + } + const eq = arg.indexOf('='); + if (arg.startsWith('--') && eq > 2) { + const flagName = arg.slice(2, eq); + const value = arg.slice(eq + 1); + if (flagName === 'json-mode') { + if (!VALID_JSON_MODES.includes(value)) { + warn('Invalid --json-mode=' + value + ' (expected: ' + VALID_JSON_MODES.join(', ') + '), ignored'); + continue; + } + opts.jsonMode = value; + continue; + } + const key = FLAG_TO_KEY[flagName]; + if (key) { + opts.urlOverrides[key] = value; + continue; + } + } + warn('Unknown flag: ' + arg + ' (ignored)'); + } + return opts; +} + +function printUsage() { + const flagList = Object.entries(KEY_SPECS) + .map(([key, spec]) => ' --' + spec.flag + '=' + ' '.repeat(Math.max(1, 32 - spec.flag.length)) + 'Set ' + key) + .join('\n'); + console.log([ + 'Nitro-V3 cross-platform installer', + '', + 'Usage: node install.mjs [flags]', + '', + 'Workflow flags:', + ' --non-interactive, --skip-prompts Keep default URLs unless overridden by --=', + ' --json-mode= Choose the JSON parsing mode without prompting', + ' --skip-build Skip the final yarn build', + ' --skip-clone Skip cloning Nitro_Render_V3', + ' --skip-link Skip yarn link calls (useful when re-running)', + ' --help, -h Show this help and exit', + '', + 'URL override flags (override interactive prompts; combine with --non-interactive for fully automated runs):', + flagList, + '', + 'Steps performed:', + ' 1. Check Node >= ' + MIN_NODE_MAJOR + ', yarn, git', + ' 2. Clone Nitro_Render_V3 to ../Nitro_Render_V3', + ' 3. yarn install + yarn link in the renderer', + ' 4. yarn install + yarn link "@nitrots/nitro-renderer" in this project', + ' 5. Copy public/configuration/*.example -> *.json (keeps existing files)', + ' 6. Choose JSON parsing mode (json5 recommended) -> writes .nitro-build.json', + ' 7. Prompt for URLs and patch the JSON config files', + ' 8. yarn build (honours the JSON mode chosen at step 6)', + '' + ].join('\n')); +} + +async function checkPrereqs() { + const nodeVer = process.versions.node; + const major = parseInt(nodeVer.split('.')[0], 10); + if (Number.isNaN(major) || major < MIN_NODE_MAJOR) { + throw new Error('Node >= ' + MIN_NODE_MAJOR + ' required (you have v' + nodeVer + '). Install from https://nodejs.org/'); + } + ok('Node v' + nodeVer); + + try { + const v = await runCapture('yarn --version'); + ok('yarn ' + v); + } catch { + const hint = IS_WINDOWS ? 'npm i -g yarn' : 'sudo npm i -g yarn'; + throw new Error('yarn not found on PATH. Install with: ' + hint); + } + + try { + const v = await runCapture('git --version'); + ok(v); + } catch { + const hint = IS_WINDOWS ? 'winget install Git.Git' : 'sudo apt-get install git (or your distro equivalent)'; + throw new Error('git not found on PATH. Install with: ' + hint); + } +} + +async function cloneRenderer(opts) { + if (opts.skipClone) { info('--skip-clone: not cloning Nitro_Render_V3'); summary.rendererSkipped = true; return; } + if (existsSync(RENDERER_DIR)) { + warn('Nitro_Render_V3 already exists at ' + RENDERER_DIR + ' - skipping clone (yarn install/link will still run).'); + summary.rendererSkipped = true; + return; + } + await runShell('git clone ' + RENDERER_REPO_URL + ' "' + RENDERER_DIR + '"', dirname(RENDERER_DIR)); + summary.rendererCloned = true; + ok('Cloned Nitro_Render_V3 to ' + RENDERER_DIR); +} + +async function setupRenderer(opts) { + if (!existsSync(RENDERER_DIR)) { + throw new Error('Renderer directory not found: ' + RENDERER_DIR + '. Re-run without --skip-clone or clone it manually.'); + } + await runShell('yarn install', RENDERER_DIR); + if (opts.skipLink) { info('--skip-link: skipping yarn link in renderer'); return; } + try { + await runShell('yarn link', RENDERER_DIR); + } catch (e) { + warn('yarn link in renderer failed (likely already linked): ' + e.message); + } +} + +async function setupClient(opts) { + await runShell('yarn install', ROOT); + if (opts.skipLink) { info('--skip-link: skipping yarn link in client'); return; } + try { + await runShell('yarn link "@nitrots/nitro-renderer"', ROOT); + } catch (e) { + warn('yarn link "@nitrots/nitro-renderer" failed (likely already linked): ' + e.message); + } +} + +async function writeJsonMode(mode) { + const payload = { jsonMode: mode, configuredAt: new Date().toISOString() }; + await writeFile(NITRO_BUILD_FILE, JSON.stringify(payload, null, 2) + '\n', 'utf8'); +} + +async function chooseJsonMode(opts) { + if (opts.jsonMode) { + await writeJsonMode(opts.jsonMode); + summary.jsonMode = opts.jsonMode; + summary.jsonModeSource = 'CLI (--json-mode)'; + ok('JSON mode set to ' + opts.jsonMode + ' (from --json-mode)'); + return; + } + + let existing = null; + if (existsSync(NITRO_BUILD_FILE)) { + try { + const raw = await readFile(NITRO_BUILD_FILE, 'utf8'); + const parsed = JSON.parse(raw); + if (VALID_JSON_MODES.includes(parsed?.jsonMode)) existing = parsed.jsonMode; + } catch {} + } + + if (!opts.interactive) { + const mode = existing || DEFAULT_JSON_MODE; + await writeJsonMode(mode); + summary.jsonMode = mode; + summary.jsonModeSource = existing ? 'existing .nitro-build.json' : 'default (non-interactive)'; + info('--non-interactive: JSON mode = ' + mode + (existing ? ' (preserved)' : ' (default)')); + return; + } + + info('Pick how configuration files (renderer-config, ui-config, gamedata) are parsed.'); + info(' 1) JSON5 (recommended - accepts comments, trailing commas, single quotes)'); + info(' 2) JSON (legacy strict - only standard JSON valid)'); + if (existing) info(' Current value in .nitro-build.json: ' + existing); + + const rl = readline.createInterface({ input, output }); + activeReadline = rl; + let chosen = null; + try { + while (chosen === null) { + const defaultLabel = existing || '1=JSON5'; + const answer = (await rl.question(' Choice [' + defaultLabel + ']: ')).trim().toLowerCase(); + if (answer.length === 0) { + chosen = existing || DEFAULT_JSON_MODE; + } else if (answer === '1' || answer === 'json5' || answer === 'y' || answer === 'yes') { + chosen = 'json5'; + } else if (answer === '2' || answer === 'json' || answer === 'legacy' || answer === 'n' || answer === 'no') { + chosen = 'legacy'; + } else if (answer === 'auto') { + chosen = 'auto'; + } else { + warn('Invalid choice. Enter 1, 2, json5, json, legacy, or auto.'); + } + } + } finally { + activeReadline = null; + rl.close(); + } + + await writeJsonMode(chosen); + summary.jsonMode = chosen; + summary.jsonModeSource = 'interactive prompt'; + ok('JSON mode set to ' + chosen + ' -> wrote .nitro-build.json'); + if (chosen === 'legacy') { + warn('Legacy mode is strict: config files must be valid standard JSON (no comments, no trailing commas).'); + } +} + +async function copyConfigs() { + for (const entry of CONFIG_FILES) { + const src = join(CONFIG_DIR, entry.example); + const dst = join(CONFIG_DIR, entry.target); + if (!existsSync(src)) { + throw new Error('Missing example file: ' + src); + } + if (existsSync(dst)) { + warn(entry.target + ' already exists - keeping existing file (URL overrides will still patch it).'); + summary.configsKept.push(entry.target); + } else { + await copyFile(src, dst); + ok('Created ' + entry.target); + summary.configsCreated.push(entry.target); + } + } +} + +async function applyOverridesNonInteractive(opts) { + for (const entry of CONFIG_FILES) { + const dst = join(CONFIG_DIR, entry.target); + const raw = await readFile(dst, 'utf8'); + let obj; + try { + obj = JSON.parse(raw); + } catch (e) { + throw new Error('Could not parse ' + entry.target + ' as JSON: ' + e.message); + } + let changed = false; + for (const key of entry.keys) { + if (Object.prototype.hasOwnProperty.call(opts.urlOverrides, key)) { + const value = opts.urlOverrides[key]; + const result = validateValue(value, KEY_SPECS[key]); + if (!result.valid) { + throw new Error('Invalid value for --' + KEY_SPECS[key].flag + '=' + JSON.stringify(value) + ': ' + result.error); + } + if (obj[key] !== value) { + obj[key] = value; + changed = true; + } + } + } + if (changed) { + await writeFile(dst, JSON.stringify(obj, null, 4) + '\n'); + ok('Updated ' + entry.target + ' (from CLI flags)'); + summary.configsPatched.push(entry.target); + } + } +} + +async function promptConfigs(opts) { + const overrideKeys = Object.keys(opts.urlOverrides); + if (!opts.interactive) { + if (overrideKeys.length > 0) { + info('--non-interactive with ' + overrideKeys.length + ' URL override(s); applying without prompts'); + await applyOverridesNonInteractive(opts); + } else { + info('--non-interactive: keeping URL values from .example defaults'); + } + return; + } + info('Press Enter to keep the current value shown in [brackets]. URLs are validated.'); + if (overrideKeys.length > 0) { + info('CLI overrides take precedence and skip prompts: ' + overrideKeys.map(k => '--' + KEY_SPECS[k].flag).join(', ')); + } + const rl = readline.createInterface({ input, output }); + activeReadline = rl; + try { + for (const entry of CONFIG_FILES) { + const dst = join(CONFIG_DIR, entry.target); + const raw = await readFile(dst, 'utf8'); + let obj; + try { + obj = JSON.parse(raw); + } catch (e) { + throw new Error('Could not parse ' + entry.target + ' as JSON: ' + e.message); + } + console.log('\n ' + c.bold + entry.target + c.reset); + let changed = false; + for (const key of entry.keys) { + const spec = KEY_SPECS[key]; + if (Object.prototype.hasOwnProperty.call(opts.urlOverrides, key)) { + const value = opts.urlOverrides[key]; + const result = validateValue(value, spec); + if (!result.valid) { + throw new Error('Invalid value for --' + spec.flag + '=' + JSON.stringify(value) + ': ' + result.error); + } + if (obj[key] !== value) { obj[key] = value; changed = true; } + console.log(' ' + c.dim + key + ' = ' + value + ' (from --' + spec.flag + ')' + c.reset); + continue; + } + const current = obj[key] === undefined ? '' : String(obj[key]); + while (true) { + const answer = await rl.question(' ' + key + ' [' + current + ']: '); + const trimmed = answer.trim(); + if (trimmed.length === 0) break; + if (trimmed === current) break; + const result = validateValue(trimmed, spec); + if (!result.valid) { + warn('Invalid: ' + result.error + '. Try again or press Enter to keep current.'); + continue; + } + obj[key] = trimmed; + changed = true; + break; + } + } + if (changed) { + await writeFile(dst, JSON.stringify(obj, null, 4) + '\n'); + ok('Updated ' + entry.target); + summary.configsPatched.push(entry.target); + } else { + info('No changes to ' + entry.target); + } + } + } finally { + activeReadline = null; + rl.close(); + } +} + +async function runBuild(opts) { + if (opts.skipBuild) { info('--skip-build: skipping yarn build'); summary.buildSkipped = true; return; } + await runShell('yarn build', ROOT); + summary.buildRan = true; +} + +function printSummary() { + const distPath = join(ROOT, 'dist'); + console.log(''); + console.log(c.bold + '================================================================' + c.reset); + console.log(c.bold + ' Installation summary' + c.reset); + console.log(c.bold + '================================================================' + c.reset); + console.log(' Renderer: ' + RENDERER_DIR + (summary.rendererCloned ? ' (cloned)' : ' (already present)')); + if (summary.configsCreated.length) console.log(' Created: ' + summary.configsCreated.join(', ')); + if (summary.configsKept.length) console.log(' Kept: ' + summary.configsKept.join(', ')); + if (summary.configsPatched.length) console.log(' Patched: ' + summary.configsPatched.join(', ')); + if (summary.jsonMode) console.log(' JSON mode: ' + summary.jsonMode + (summary.jsonModeSource ? ' (' + summary.jsonModeSource + ')' : '')); + if (summary.buildRan) console.log(' Build: ' + c.green + 'OK' + c.reset + ' -> ' + distPath); + else if (summary.buildSkipped) console.log(' Build: skipped'); + console.log(''); + console.log(' Next steps:'); + console.log(' - Development: yarn start'); + if (summary.buildRan) { + console.log(' - Production: deploy the contents of ' + distPath + ' to your webserver'); + } else { + console.log(' - Production: yarn build, then deploy ' + distPath); + } + console.log(c.bold + '================================================================' + c.reset); +} + +async function main() { + const opts = parseArgs(); + if (opts.help) { printUsage(); process.exit(0); } + + console.log(c.bold + 'Nitro-V3 installer' + c.reset + ' (' + (IS_WINDOWS ? 'Windows' : platform()) + ')'); + console.log('Project root: ' + ROOT); + + step(STEPS[0]); await checkPrereqs(); + step(STEPS[1]); await cloneRenderer(opts); + step(STEPS[2]); await setupRenderer(opts); + step(STEPS[3]); await setupClient(opts); + step(STEPS[4]); await copyConfigs(); + step(STEPS[5]); await chooseJsonMode(opts); + step(STEPS[6]); await promptConfigs(opts); + step(STEPS[7]); await runBuild(opts); + step(STEPS[8]); printSummary(); +} + +process.on('SIGINT', () => { + if (activeReadline) { + try { activeReadline.close(); } catch {} + activeReadline = null; + } + const label = STEPS[currentStep - 1] || 'startup'; + console.error(''); + warn('Aborted at step ' + currentStep + ' (' + label + ')'); + process.exit(130); +}); + +main().catch(e => { + const label = STEPS[currentStep - 1] || 'startup'; + err(''); + err('Step ' + currentStep + ' (' + label + ') failed:'); + err(' ' + e.message); + process.exit(1); +}); diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..e75c44b --- /dev/null +++ b/install.sh @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +set -e +DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +exec node "$DIR/install.mjs" "$@" diff --git a/package.json b/package.json index 04eca1b..6c8a5a2 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,9 @@ "homepage": ".", "private": true, "scripts": { - "prebuild": "node scripts/write-asset-loader.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 --host", "build": "vite build && node scripts/minify-dist.mjs", "build:prod": "npx browserslist@latest --update-db && yarn build", diff --git a/scripts/configure-json.mjs b/scripts/configure-json.mjs new file mode 100644 index 0000000..5677096 --- /dev/null +++ b/scripts/configure-json.mjs @@ -0,0 +1,102 @@ +#!/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('Configuration files (renderer-config, ui-config, gamedata)\ncan be parsed in two ways:\n\n'); + process.stdout.write(' 1) JSON5 (recommended — accepts comments, trailing commas,\n single quotes, unquoted identifiers)\n'); + process.stdout.write(' 2) JSON (legacy strict — only standard valid JSON)\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('Choice [1=JSON5]: ', answer => + { + const normalized = normalizeAnswer(answer); + if(normalized === null) + { + process.stdout.write(' ↳ Invalid response. Please enter 1, 2, json5 or json.\n'); + return ask(); + } + rl.close(); + resolveFn(normalized); + }); + }; + ask(); +}); +const main = async () => +{ + const existing = readExisting(); + if(ifMissing && existing) + { + process.stdout.write(`[configure-json] mode already configured: ${ existing.jsonMode } (skip)\n`); + return; + } + if(nonInteractive) + { + const mode = existing?.jsonMode || DEFAULT_MODE; + writeChoice(mode); + process.stdout.write(`[configure-json] non-interactive — saved: ${ mode }\n`); + return; + } + printBanner(); + if(existing) process.stdout.write(`Current mode: ${ existing.jsonMode }\n\n`); + const choice = await promptUser(); + writeChoice(choice); + process.stdout.write(`\n✓ Saved to .nitro-build.json — mode: ${ choice }\n`); + if(choice === 'legacy') + { + process.stdout.write(' Warning: config files must be strict valid JSON\n (no comments, no trailing commas).\n'); + } + else + { + process.stdout.write(' JSON5 active: you can use comments, trailing commas and single quotes\n in configuration files.\n'); + } + process.stdout.write('\n To change mode in the future: yarn configure\n\n'); +}; +main().catch(err => +{ + process.stderr.write(`[configure-json] error: ${ err?.message || err }\n`); + process.exit(1); +}); \ No newline at end of file diff --git a/scripts/split-gamedata.mjs b/scripts/split-gamedata.mjs new file mode 100644 index 0000000..efeef7f --- /dev/null +++ b/scripts/split-gamedata.mjs @@ -0,0 +1,357 @@ +#!/usr/bin/env node +import { existsSync, mkdirSync, readFileSync, writeFileSync, rmSync } from 'node:fs'; +import { dirname, basename, resolve, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const HELP = ` +Nitro V3 — gamedata splitter + +Takes a legacy single-file gamedata JSON (EffectMap, FigureData, FigureMap, +FurnitureData, HabboAvatarActions, ProductData, ExternalTexts, UITexts) and +produces the directory layout consumed by the split-aware loader: + + / + manifest.json5 tier order (defaults to core/custom/seasonal) + core/ + manifest.json5 files list, in load order + .json5 + .json5 + ... + +Custom and seasonal tiers are NOT generated — those are operator-owned and +will be auto-discovered by the loader if their manifest.json5 exists. + +Usage: + node scripts/split-gamedata.mjs --input --output [flags] + +Required: + --input Path to the legacy JSON (or JSON5) file + --output Target directory (will contain core/, manifest.json5) + +Optional: + --type Force the gamedata type (effectmap, figuredata, + figuremap, furnidata, avatar-actions, productdata, + external-texts, ui-texts). Default: auto-detect + --chunk-size Items per chunk for the bucket-based splits. + Defaults: figuremap=500, furnidata=300, productdata=500, + external-texts/ui-texts split by prefix instead + --json (or --legacy) Emit standard JSON instead of JSON5 (no comments) + --force Overwrite the output directory if it already exists + --help, -h Show this help + +Examples: + node scripts/split-gamedata.mjs \\ + --input ~/gamedata/FurnitureData.json \\ + --output ~/nitro-assets/gamedata/furnidata + + node scripts/split-gamedata.mjs \\ + --input ./EffectMap.json --output ./effectmap --chunk-size 50 + +After splitting, point your renderer-config at the directory (note the +trailing slash): + + "furnidata.url": "https://example.com/nitro-assets/gamedata/furnidata/" +`; + +const args = process.argv.slice(2); +const opts = { + input: null, + output: null, + type: null, + chunkSize: null, + asJson5: true, + force: false, + help: false +}; + +for (let i = 0; i < args.length; i++) { + const a = args[i]; + if (a === '--help' || a === '-h') opts.help = true; + else if (a === '--input') opts.input = args[++i]; + else if (a === '--output') opts.output = args[++i]; + else if (a === '--type') opts.type = args[++i]; + else if (a === '--chunk-size') opts.chunkSize = parseInt(args[++i], 10); + else if (a === '--json' || a === '--legacy') opts.asJson5 = false; + else if (a === '--json5') opts.asJson5 = true; + else if (a === '--force') opts.force = true; + else if (a.startsWith('--input=')) opts.input = a.slice('--input='.length); + else if (a.startsWith('--output=')) opts.output = a.slice('--output='.length); + else if (a.startsWith('--type=')) opts.type = a.slice('--type='.length); + else if (a.startsWith('--chunk-size=')) opts.chunkSize = parseInt(a.slice('--chunk-size='.length), 10); + else { + process.stderr.write(`Unknown flag: ${ a }\n`); + process.exit(2); + } +} + +if (opts.help) { + process.stdout.write(HELP); + process.exit(0); +} + +if (!opts.input || !opts.output) { + process.stderr.write('Missing --input or --output. Use --help for usage.\n'); + process.exit(2); +} + +if (!existsSync(opts.input)) { + process.stderr.write(`Input file not found: ${ opts.input }\n`); + process.exit(1); +} + +const detectType = (data) => { + if (!data || typeof data !== 'object') return null; + const keys = new Set(Object.keys(data)); + if (keys.has('roomitemtypes') || keys.has('wallitemtypes')) return 'furnidata'; + if (keys.has('palettes') && keys.has('setTypes')) return 'figuredata'; + if (keys.has('libraries')) return 'figuremap'; + if (keys.has('effects')) return 'effectmap'; + if (keys.has('actions')) return 'avatar-actions'; + if (keys.has('productdata')) return 'productdata'; + // Flat dict heuristic: many top-level scalar keys with dots → texts + if (keys.size > 30 && Object.values(data).every(v => typeof v === 'string')) { + const sampleKey = Object.keys(data)[0] || ''; + return sampleKey.includes('.') ? 'external-texts' : 'ui-texts'; + } + return null; +}; + +const splitArrayInChunks = (arr, size) => { + const chunks = []; + for (let i = 0; i < arr.length; i += size) chunks.push(arr.slice(i, i + size)); + return chunks; +}; + +const splitByPrefix = (dict, maxPerBucket = 800) => { + const buckets = new Map(); + for (const key of Object.keys(dict)) { + const prefix = key.split('.')[0] || '_other'; + if (!buckets.has(prefix)) buckets.set(prefix, {}); + buckets.get(prefix)[key] = dict[key]; + } + // Merge small buckets into a shared one to reduce manifest noise + const out = []; + const small = {}; + let smallCount = 0; + for (const [name, content] of buckets) { + const size = Object.keys(content).length; + if (size < 50) { + Object.assign(small, content); + smallCount += size; + } else { + // split further if oversized + if (size > maxPerBucket) { + const entries = Object.entries(content); + const slices = splitArrayInChunks(entries, maxPerBucket); + slices.forEach((slice, idx) => { + const part = {}; + for (const [k, v] of slice) part[k] = v; + out.push([ `${ name }-${ String(idx + 1).padStart(3, '0') }`, part ]); + }); + } else { + out.push([ name, content ]); + } + } + } + if (smallCount > 0) out.push([ '_misc', small ]); + return out; +}; + +const sanitizeName = (name) => name.replace(/[^a-zA-Z0-9_-]/g, '_').toLowerCase(); + +const ext = () => opts.asJson5 ? 'json5' : 'json'; + +const writePart = (filePath, data, headerComment) => { + const body = JSON.stringify(data, null, 4); + const out = opts.asJson5 && headerComment + ? `// ${ headerComment }\n${ body }\n` + : `${ body }\n`; + writeFileSync(filePath, out, 'utf8'); +}; + +const buildSplits = (type, data) => { + switch (type) { + case 'effectmap': { + const effects = data.effects || []; + const byType = new Map(); + for (const e of effects) { + const t = sanitizeName(e.type || 'other'); + if (!byType.has(t)) byType.set(t, []); + byType.get(t).push(e); + } + return Array.from(byType, ([ t, items ]) => ({ + name: `effects-${ t }.${ ext() }`, + comment: `Effects of type "${ t }" (${ items.length } items)`, + content: { effects: items } + })); + } + case 'figuredata': { + const parts = []; + if (data.palettes?.length) { + parts.push({ + name: `palettes.${ ext() }`, + comment: `Color palettes (${ data.palettes.length })`, + content: { palettes: data.palettes } + }); + } + for (const st of (data.setTypes || [])) { + const t = sanitizeName(st.type); + parts.push({ + name: `settype-${ t }.${ ext() }`, + comment: `setType "${ st.type }" (paletteId=${ st.paletteId })`, + content: { setTypes: [ st ] } + }); + } + return parts; + } + case 'figuremap': { + const libs = data.libraries || []; + const size = opts.chunkSize || 500; + const chunks = splitArrayInChunks(libs, size); + return chunks.map((chunk, idx) => ({ + name: `libraries-${ String(idx + 1).padStart(3, '0') }.${ ext() }`, + comment: `libraries ${ idx * size + 1 }..${ idx * size + chunk.length } of ${ libs.length }`, + content: { libraries: chunk } + })); + } + case 'furnidata': { + const parts = []; + const size = opts.chunkSize || 300; + const floor = data.roomitemtypes?.furnitype || []; + const wall = data.wallitemtypes?.furnitype || []; + const floorChunks = splitArrayInChunks(floor, size); + floorChunks.forEach((chunk, idx) => { + parts.push({ + name: `floor-${ String(idx + 1).padStart(3, '0') }.${ ext() }`, + comment: `Floor furniture ${ idx * size + 1 }..${ idx * size + chunk.length } of ${ floor.length }`, + content: { roomitemtypes: { furnitype: chunk } } + }); + }); + const wallChunks = splitArrayInChunks(wall, size); + wallChunks.forEach((chunk, idx) => { + parts.push({ + name: `wall-${ String(idx + 1).padStart(3, '0') }.${ ext() }`, + comment: `Wall furniture ${ idx * size + 1 }..${ idx * size + chunk.length } of ${ wall.length }`, + content: { wallitemtypes: { furnitype: chunk } } + }); + }); + return parts; + } + case 'avatar-actions': { + const actions = data.actions || []; + const byState = new Map(); + for (const a of actions) { + const s = sanitizeName(a.state || 'other'); + if (!byState.has(s)) byState.set(s, []); + byState.get(s).push(a); + } + if (byState.size <= 1) { + return [ { + name: `actions.${ ext() }`, + comment: `All avatar actions (${ actions.length })`, + content: { actions } + } ]; + } + return Array.from(byState, ([ s, items ]) => ({ + name: `actions-${ s }.${ ext() }`, + comment: `Actions in state "${ s }" (${ items.length })`, + content: { actions: items } + })); + } + case 'productdata': { + const products = data.productdata?.product || []; + const size = opts.chunkSize || 500; + const chunks = splitArrayInChunks(products, size); + return chunks.map((chunk, idx) => ({ + name: `products-${ String(idx + 1).padStart(3, '0') }.${ ext() }`, + comment: `Products ${ idx * size + 1 }..${ idx * size + chunk.length } of ${ products.length }`, + content: { productdata: { product: chunk } } + })); + } + case 'external-texts': + case 'ui-texts': { + const buckets = splitByPrefix(data, opts.chunkSize || 800); + return buckets.map(([ name, content ]) => ({ + name: `${ sanitizeName(name) }.${ ext() }`, + comment: `${ name } (${ Object.keys(content).length } keys)`, + content + })); + } + default: + throw new Error(`Unknown gamedata type: ${ type }. Use --type to force one.`); + } +}; + +const main = () => { + const raw = readFileSync(opts.input, 'utf8'); + + let data; + try { + data = JSON.parse(raw); + } catch { + try { + const JSON5 = require('json5'); + data = JSON5.parse(raw); + } catch (e) { + process.stderr.write(`Could not parse ${ opts.input } as JSON nor JSON5: ${ e.message }\n`); + process.exit(1); + } + } + + const type = opts.type || detectType(data); + if (!type) { + process.stderr.write('Could not auto-detect gamedata type. Pass --type=. See --help.\n'); + process.exit(1); + } + + const outDir = resolve(opts.output); + const coreDir = join(outDir, 'core'); + + if (existsSync(outDir)) { + if (!opts.force) { + process.stderr.write(`Output directory already exists: ${ outDir }. Use --force to overwrite.\n`); + process.exit(1); + } + rmSync(outDir, { recursive: true, force: true }); + } + mkdirSync(coreDir, { recursive: true }); + + const parts = buildSplits(type, data); + if (!parts.length) { + process.stderr.write(`No content produced for type ${ type }. Input may be empty.\n`); + process.exit(1); + } + + for (const part of parts) { + writePart(join(coreDir, part.name), part.content, part.comment); + } + + const coreManifest = { + files: parts.map(p => p.name) + }; + const coreManifestBody = opts.asJson5 + ? `// Auto-generated by split-gamedata.mjs from ${ basename(opts.input) }\n// Type: ${ type } — ${ parts.length } files, ${ parts.reduce((n, p) => n + JSON.stringify(p.content).length, 0).toLocaleString() } chars total\n${ JSON.stringify(coreManifest, null, 4) }\n` + : `${ JSON.stringify(coreManifest, null, 4) }\n`; + writeFileSync(join(coreDir, `manifest.${ ext() }`), coreManifestBody, 'utf8'); + + const rootManifest = { tiers: [ 'core', 'custom', 'seasonal' ] }; + const rootManifestBody = opts.asJson5 + ? `// Root manifest — load order of tiers (later overrides earlier by id/classname).\n// Drop a custom/manifest.${ ext() } and/or seasonal/manifest.${ ext() } to add\n// override tiers without touching core/.\n${ JSON.stringify(rootManifest, null, 4) }\n` + : `${ JSON.stringify(rootManifest, null, 4) }\n`; + writeFileSync(join(outDir, `manifest.${ ext() }`), rootManifestBody, 'utf8'); + + process.stdout.write([ + `[split-gamedata] ${ type } -> ${ outDir }`, + ` ${ parts.length } file(s) in core/`, + ` tiers: core (always loaded), custom (optional), seasonal (optional)`, + ` point renderer-config at: ${ outDir.replace(/\\/g, '/') }/`, + '' + ].join('\n')); +}; + +try { + main(); +} catch (e) { + process.stderr.write(`[split-gamedata] ${ e.message }\n`); + process.exit(1); +} diff --git a/src/bootstrap.ts b/src/bootstrap.ts index beaca06..82a8f70 100644 --- a/src/bootstrap.ts +++ b/src/bootstrap.ts @@ -2,6 +2,22 @@ import { GetConfiguration } from '@nitrots/nitro-renderer'; 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"]'); @@ -78,16 +94,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 f4501bb..1b6899f 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'; import sirv from 'sirv'; @@ -73,6 +73,28 @@ const ReactCompilerConfig = { target: '19' }; +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: [ @@ -85,6 +107,9 @@ export default defineConfig({ }), nitroAssetsServer() ], + define: { + __NITRO_JSON_MODE__: JSON.stringify(nitroJsonMode) + }, server: { fs: { allow: [