diff --git a/.changeset/green-scroll-editor.md b/.changeset/green-scroll-editor.md
deleted file mode 100644
index f2ef13b..0000000
--- a/.changeset/green-scroll-editor.md
+++ /dev/null
@@ -1,9 +0,0 @@
----
-"@druid-ui/component": minor
-"@druid-ui/config-editor": minor
-"@druid-ui/host": minor
-"@druid-ui/plattform": minor
-"@druid-ui/vite": patch
----
-
-Add a manifest-driven, lossless server configuration editor with raw and WASM parity, automatically discovered typed fields, Java properties, INI, Unreal tuple, key-value, JSON, and XML-property adapters, template/default fallbacks, secret-safe editing, conflict-aware saves, accessible host attributes, and reliable external-source hot reload.
diff --git a/.changeset/retire-config-editor-v3.md b/.changeset/retire-config-editor-v3.md
new file mode 100644
index 0000000..8eb71dd
--- /dev/null
+++ b/.changeset/retire-config-editor-v3.md
@@ -0,0 +1,7 @@
+---
+"@druid-ui/plattform": major
+---
+
+Retire the standalone `@druid-ui/config-editor` package and remove the
+`saveFileToDeploymentIfMatch` platform API. Move configuration-editor
+integrations to the inline editor shipped with Druid Scrolls.
diff --git a/README.md b/README.md
index 6e18235..0c7ef11 100644
--- a/README.md
+++ b/README.md
@@ -138,10 +138,9 @@ npx gen-types # generates types for your extension
- [Simple Extended Example](./examples/simple-extended) - Component with custom extensions
- [Simple No-Sandbox Example](./examples/simple-no-sandbox) - Development without WASM overhead
- [Simple Extended No-Sandbox Example](./examples/simple-extended-no-sandbox) - Extended component in raw mode
-- [Configuration Editor Platform Example](./examples/config-editor-plattform) - Manifest-driven server configuration UI in raw development and sandboxed WASM modes
-## Server Configuration Editor
+## Configuration Editor Migration
-`@druid-ui/config-editor` turns a strict JSON manifest into an accessible admin UI for Java properties, INI/Unreal INI, key-value, JSON, and raw configuration files. It preserves comments, ordering, unknown keys, and line endings; masks sensitive values; validates typed fields; reports restart impact; and verifies conflict-safe saves through the Druid platform file extension.
-
-Use raw mode for the fast development loop, then build and test the same entry point as WASM before release. See `examples/config-editor-plattform` for the complete host and fixture setup.
+`@druid-ui/config-editor` is retired in Druid UI v3, along with the
+`saveFileToDeploymentIfMatch` platform API. Use the inline configuration editor
+staged with Druid Scrolls for server configuration editing.
diff --git a/examples/README.md b/examples/README.md
index fb790dd..7caec15 100644
--- a/examples/README.md
+++ b/examples/README.md
@@ -53,15 +53,6 @@ This directory contains various examples demonstrating different features and us
### Platform Examples
-#### [config-editor-plattform](./config-editor-plattform/)
-
-**Generic game-server configuration editor**
-
-- Uses one manifest-driven UI across server families and versions
-- Demonstrates fast raw HMR and production WASM sandbox modes
-- Preserves unknown options and masks sensitive values
-- Best for: Scroll private admin UIs
-
#### [druid-plattform](./druid-plattform/)
**Full platform integration**
diff --git a/examples/config-editor-plattform/fixtures/config-editor.manifest.json b/examples/config-editor-plattform/fixtures/config-editor.manifest.json
deleted file mode 100644
index c56207d..0000000
--- a/examples/config-editor-plattform/fixtures/config-editor.manifest.json
+++ /dev/null
@@ -1,73 +0,0 @@
-{
- "version": 1,
- "server": {
- "family": "minecraft",
- "displayName": "Minecraft Server",
- "appVersion": "1.21.7"
- },
- "files": [
- {
- "path": "server.properties",
- "format": "java-properties",
- "label": "server.properties",
- "description": "Minecraft dedicated-server settings.",
- "sections": [
- {
- "id": "server",
- "label": "Server identity",
- "fields": [
- {
- "key": "motd",
- "label": "Message of the day",
- "description": "Text shown in the multiplayer server list.",
- "documentation": "https://minecraft.wiki/w/Server.properties",
- "type": "string"
- },
- {
- "key": "max-players",
- "label": "Max players",
- "description": "Maximum number of simultaneous players.",
- "documentation": "https://minecraft.wiki/w/Server.properties",
- "type": "integer",
- "min": 1,
- "max": 1000,
- "restartRequired": true
- },
- {
- "key": "online-mode",
- "label": "Online mode",
- "description": "Verify connecting players with Minecraft account services.",
- "documentation": "https://minecraft.wiki/w/Server.properties",
- "type": "boolean",
- "restartRequired": true
- },
- {
- "key": "difficulty",
- "label": "Difficulty",
- "description": "Default world difficulty.",
- "documentation": "https://minecraft.wiki/w/Server.properties",
- "type": "enum",
- "values": ["peaceful", "easy", "normal", "hard"],
- "restartRequired": true
- }
- ]
- },
- {
- "id": "remote",
- "label": "Remote administration",
- "fields": [
- {
- "key": "rcon.password",
- "label": "RCON password",
- "description": "Password used by remote administration clients.",
- "documentation": "https://minecraft.wiki/w/Server.properties",
- "type": "secret",
- "sensitive": true,
- "restartRequired": true
- }
- ]
- }
- ]
- }
- ]
-}
diff --git a/examples/config-editor-plattform/fixtures/server.properties b/examples/config-editor-plattform/fixtures/server.properties
deleted file mode 100644
index 3008e5c..0000000
--- a/examples/config-editor-plattform/fixtures/server.properties
+++ /dev/null
@@ -1,7 +0,0 @@
-#Minecraft server properties
-motd=Druid development server
-max-players=20
-online-mode=true
-difficulty=normal
-rcon.password=fixture-secret
-future-option=preserved
diff --git a/examples/config-editor-plattform/index.html b/examples/config-editor-plattform/index.html
deleted file mode 100644
index 628e0b2..0000000
--- a/examples/config-editor-plattform/index.html
+++ /dev/null
@@ -1,16 +0,0 @@
-
-
-
-
-
- Druid configuration editor
-
-
-
-
-
-
-
diff --git a/examples/config-editor-plattform/package.json b/examples/config-editor-plattform/package.json
deleted file mode 100644
index fbdb5d0..0000000
--- a/examples/config-editor-plattform/package.json
+++ /dev/null
@@ -1,27 +0,0 @@
-{
- "name": "config-editor-plattform",
- "private": true,
- "version": "0.0.0",
- "type": "module",
- "scripts": {
- "dev": "vite",
- "build": "npm run build:raw:public && npm run build:wasm:public && vite build",
- "build:raw": "druid-ui-build --wit=wit/druid-plattform.wit --world-name=druid-plattform src/app.tsx --raw dist",
- "build:wasm": "druid-ui-build --wit=wit/druid-plattform.wit --world-name=druid-plattform src/app.tsx dist",
- "build:raw:public": "druid-ui-build --wit=wit/druid-plattform.wit --world-name=druid-plattform src/app.tsx --raw public",
- "build:wasm:public": "druid-ui-build --wit=wit/druid-plattform.wit --world-name=druid-plattform src/app.tsx public"
- },
- "dependencies": {
- "@druid-ui/component": "*",
- "@druid-ui/config-editor": "*",
- "@druid-ui/host": "*",
- "@druid-ui/plattform": "*",
- "@druid-ui/vite": "*"
- },
- "devDependencies": {
- "@druid-ui/build": "*",
- "typescript": "~5.8.3",
- "vite": "^7.1.7",
- "vite-plugin-wasm": "^3.5.0"
- }
-}
diff --git a/examples/config-editor-plattform/public/app.bundled-raw.js b/examples/config-editor-plattform/public/app.bundled-raw.js
deleted file mode 100644
index 4916297..0000000
--- a/examples/config-editor-plattform/public/app.bundled-raw.js
+++ /dev/null
@@ -1,2505 +0,0 @@
-var __create = Object.create;
-var __defProp = Object.defineProperty;
-var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
-var __getOwnPropNames = Object.getOwnPropertyNames;
-var __getProtoOf = Object.getPrototypeOf;
-var __hasOwnProp = Object.prototype.hasOwnProperty;
-var __commonJS = (cb, mod) => function __require() {
- return mod || (0, cb[__getOwnPropNames(cb)[0]])((mod = { exports: {} }).exports, mod), mod.exports;
-};
-var __copyProps = (to, from, except, desc) => {
- if (from && typeof from === "object" || typeof from === "function") {
- for (let key of __getOwnPropNames(from))
- if (!__hasOwnProp.call(to, key) && key !== except)
- __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
- }
- return to;
-};
-var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
- // If the importer is in node compatibility mode or this is not an ESM
- // file that has been converted to a CommonJS file using a Babel-
- // compatible transform (i.e. "__esModule" has not been set), then set
- // "default" to the CommonJS "module.exports" for node compatibility.
- isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
- mod
-));
-
-// druid-ext:druid:ui/ui
-var require_ui = __commonJS({
- "druid-ext:druid:ui/ui"(exports, module) {
- module.exports = window["druid-extension"]["druid:ui/ui"];
- }
-});
-
-// druid-ext:druid:ui/utils
-var require_utils = __commonJS({
- "druid-ext:druid:ui/utils"(exports, module) {
- module.exports = window["druid-extension"]["druid:ui/utils"];
- }
-});
-
-// druid-ext:druid:ui/plattform
-var require_plattform = __commonJS({
- "druid-ext:druid:ui/plattform"(exports, module) {
- module.exports = window["druid-extension"]["druid:ui/plattform"];
- }
-});
-
-// ../../packages/config-editor/src/manifest.ts
-var CONFIG_FORMATS = /* @__PURE__ */ new Set([
- "java-properties",
- "ini",
- "unreal-ini",
- "key-value",
- "json",
- "xml-properties",
- "raw"
-]);
-var FIELD_TYPES = /* @__PURE__ */ new Set([
- "string",
- "integer",
- "number",
- "boolean",
- "enum",
- "secret"
-]);
-var DOTTED_VERSION = /^\d+(?:\.\d+)*$/;
-var URL_SCHEME = /^[a-z][a-z\d+.-]*:/i;
-var WINDOWS_DRIVE = /^[a-z]:\//i;
-var fail = (message) => {
- throw new TypeError(`Invalid config editor manifest: ${message}`);
-};
-var objectValue = (value, name) => {
- if (typeof value !== "object" || value === null || Array.isArray(value)) {
- return fail(`${name} must be an object.`);
- }
- return value;
-};
-var stringValue = (value, name) => {
- if (typeof value !== "string" || value.trim().length === 0) {
- return fail(`${name} must be a non-empty string.`);
- }
- return value;
-};
-var optionalString = (value, name) => {
- if (value === void 0) return void 0;
- return stringValue(value, name);
-};
-var optionalBoolean = (value, name) => {
- if (value === void 0) return void 0;
- if (typeof value !== "boolean") return fail(`${name} must be a boolean.`);
- return value;
-};
-var optionalNumber = (value, name) => {
- if (value === void 0) return void 0;
- if (typeof value !== "number" || !Number.isFinite(value)) {
- return fail(`${name} must be a finite number.`);
- }
- return value;
-};
-var arrayValue = (value, name) => {
- if (!Array.isArray(value)) return fail(`${name} must be an array.`);
- return value;
-};
-var assertVersion = (value, name) => {
- if (!DOTTED_VERSION.test(value)) {
- fail(`${name} must be a dotted numeric version.`);
- }
-};
-var assertSafePath = (path) => {
- const segments = path.split("/");
- const unsafe = path.startsWith("/") || path.includes("\\") || path.includes("\0") || URL_SCHEME.test(path) || WINDOWS_DRIVE.test(path) || segments.some((segment) => segment === "" || segment === "." || segment === "..");
- if (unsafe) fail(`file path "${path}" must be a safe relative path.`);
-};
-var validateServer = (value) => {
- const server = objectValue(value, "server");
- return {
- family: stringValue(server["family"], "server.family"),
- displayName: stringValue(server["displayName"], "server.displayName"),
- ...server["appVersion"] === void 0 ? {} : { appVersion: stringValue(server["appVersion"], "server.appVersion") }
- };
-};
-var validateField = (value, location) => {
- const field = objectValue(value, location);
- const type = stringValue(field["type"], `${location}.type`);
- if (!FIELD_TYPES.has(type)) {
- fail(`${location}.type "${type}" is unsupported.`);
- }
- const min = optionalNumber(field["min"], `${location}.min`);
- const max = optionalNumber(field["max"], `${location}.max`);
- if (min !== void 0 && max !== void 0 && min > max) {
- fail(`${location} minimum cannot exceed its maximum.`);
- }
- if ((min !== void 0 || max !== void 0) && type !== "integer" && type !== "number") {
- fail(`${location} numeric ranges require an integer or number field.`);
- }
- let values;
- if (field["values"] !== void 0) {
- values = arrayValue(field["values"], `${location}.values`).map(
- (entry, index) => stringValue(entry, `${location}.values[${index}]`)
- );
- if (new Set(values).size !== values.length) {
- fail(`${location}.values contains duplicate enum values.`);
- }
- }
- if (type === "enum" && (!values || values.length === 0)) {
- fail(`${location} enum values must contain at least one value.`);
- }
- if (type !== "enum" && values !== void 0) {
- fail(`${location}.values is only valid for enum fields.`);
- }
- const pattern = optionalString(field["pattern"], `${location}.pattern`);
- if (pattern !== void 0) {
- try {
- new RegExp(pattern);
- } catch {
- fail(`${location}.pattern must be a valid regular expression.`);
- }
- }
- const since = optionalString(field["since"], `${location}.since`);
- const until = optionalString(field["until"], `${location}.until`);
- if (since !== void 0) assertVersion(since, `${location}.since`);
- if (until !== void 0) assertVersion(until, `${location}.until`);
- if (since !== void 0 && until !== void 0 && compareVersions(since, until) > 0) {
- fail(`${location}.since cannot be later than .until.`);
- }
- const rawDefaultValue = field["defaultValue"];
- if (rawDefaultValue !== void 0 && rawDefaultValue !== null && typeof rawDefaultValue !== "string" && typeof rawDefaultValue !== "number" && typeof rawDefaultValue !== "boolean") {
- fail(`${location}.defaultValue must be a scalar value.`);
- }
- const result = {
- key: stringValue(field["key"], `${location}.key`),
- label: stringValue(field["label"], `${location}.label`),
- description: stringValue(field["description"], `${location}.description`),
- documentation: stringValue(
- field["documentation"],
- `${location}.documentation`
- ),
- type
- };
- if (values !== void 0) result.values = values;
- if (min !== void 0) result.min = min;
- if (max !== void 0) result.max = max;
- if (pattern !== void 0) result.pattern = pattern;
- if (rawDefaultValue !== void 0) {
- result.defaultValue = rawDefaultValue;
- }
- if (field["sensitive"] !== void 0) {
- result.sensitive = optionalBoolean(field["sensitive"], `${location}.sensitive`);
- }
- if (field["restartRequired"] !== void 0) {
- result.restartRequired = optionalBoolean(
- field["restartRequired"],
- `${location}.restartRequired`
- );
- }
- if (since !== void 0) result.since = since;
- if (until !== void 0) result.until = until;
- return result;
-};
-var validateSection = (value, location) => {
- const section = objectValue(value, location);
- const fields = arrayValue(section["fields"], `${location}.fields`).map(
- (field, index) => validateField(field, `${location}.fields[${index}]`)
- );
- if (fields.length === 0) fail(`${location}.fields must not be empty.`);
- const result = {
- id: stringValue(section["id"], `${location}.id`),
- label: stringValue(section["label"], `${location}.label`),
- fields
- };
- if (section["description"] !== void 0) {
- result.description = stringValue(
- section["description"],
- `${location}.description`
- );
- }
- return result;
-};
-var validateFile = (value, location) => {
- const file = objectValue(value, location);
- const path = stringValue(file["path"], `${location}.path`);
- assertSafePath(path);
- const format = stringValue(file["format"], `${location}.format`);
- if (!CONFIG_FORMATS.has(format)) {
- fail(`${location}.format "${format}" is unsupported.`);
- }
- const sections = arrayValue(file["sections"], `${location}.sections`).map(
- (section, index) => validateSection(section, `${location}.sections[${index}]`)
- );
- const sectionIds = /* @__PURE__ */ new Set();
- const fieldKeys = /* @__PURE__ */ new Set();
- for (const section of sections) {
- if (sectionIds.has(section.id)) {
- fail(`${location} contains duplicate section id "${section.id}".`);
- }
- sectionIds.add(section.id);
- for (const field of section.fields) {
- if (fieldKeys.has(field.key)) {
- fail(`${location} contains duplicate field key "${field.key}".`);
- }
- fieldKeys.add(field.key);
- }
- }
- const result = {
- path,
- format,
- label: stringValue(file["label"], `${location}.label`),
- sections
- };
- if (file["description"] !== void 0) {
- result.description = stringValue(
- file["description"],
- `${location}.description`
- );
- }
- if (file["documentation"] !== void 0) {
- result.documentation = stringValue(
- file["documentation"],
- `${location}.documentation`
- );
- }
- return result;
-};
-var validateManifest = (value) => {
- const manifest = objectValue(value, "manifest");
- if (manifest["version"] !== 1) fail("version must be 1.");
- const files = arrayValue(manifest["files"], "files").map(
- (file, index) => validateFile(file, `files[${index}]`)
- );
- if (files.length === 0) fail("files must not be empty.");
- const paths = /* @__PURE__ */ new Set();
- for (const file of files) {
- if (paths.has(file.path)) fail(`files contains duplicate path "${file.path}".`);
- paths.add(file.path);
- }
- return { version: 1, server: validateServer(manifest["server"]), files };
-};
-var compareVersions = (left, right) => {
- const leftParts = left.split(".").map(Number);
- const rightParts = right.split(".").map(Number);
- const length = Math.max(leftParts.length, rightParts.length);
- for (let index = 0; index < length; index += 1) {
- const difference = (leftParts[index] ?? 0) - (rightParts[index] ?? 0);
- if (difference !== 0) return difference < 0 ? -1 : 1;
- }
- return 0;
-};
-
-// ../../packages/config-editor/src/adapters/line-document.ts
-var splitSourceLines = (source) => {
- const lines = [];
- let start = 0;
- while (start < source.length) {
- const newline = source.indexOf("\n", start);
- if (newline === -1) {
- lines.push({ raw: source.slice(start), ending: "", start, end: source.length });
- break;
- }
- const hasCarriageReturn = newline > start && source[newline - 1] === "\r";
- const rawEnd = hasCarriageReturn ? newline - 1 : newline;
- lines.push({
- raw: source.slice(start, rawEnd),
- ending: hasCarriageReturn ? "\r\n" : "\n",
- start,
- end: rawEnd
- });
- start = newline + 1;
- }
- return lines;
-};
-var preferredLineEnding = (lines) => lines.find((line) => line.ending !== "")?.ending || "\n";
-var hasFinalNewline = (source) => source.endsWith("\n");
-
-// ../../packages/config-editor/src/adapters/java-properties.ts
-var trailingBackslashes = (value) => {
- let count = 0;
- for (let index = value.length - 1; index >= 0 && value[index] === "\\"; index -= 1) {
- count += 1;
- }
- return count;
-};
-var logicalLineAt = (lines, firstLine) => {
- let text = "";
- const offsets = [];
- let lineIndex = firstLine;
- let continued = false;
- for (; lineIndex < lines.length; lineIndex += 1) {
- const line = lines[lineIndex];
- const trim = continued ? line.raw.match(/^[ \t\f]*/)?.[0].length ?? 0 : 0;
- const content = line.raw.slice(trim);
- const continues = trailingBackslashes(content) % 2 === 1;
- const logicalContent = continues ? content.slice(0, -1) : content;
- for (let index = 0; index < logicalContent.length; index += 1) {
- text += logicalContent[index];
- offsets.push(line.start + trim + index);
- }
- continued = continues;
- if (!continues) break;
- }
- const lastLine = Math.min(lineIndex, lines.length - 1);
- return {
- text,
- offsets,
- end: lines[lastLine]?.end ?? 0,
- firstLine,
- lastLine
- };
-};
-var isEscaped = (value, index) => {
- let slashes = 0;
- for (let cursor = index - 1; cursor >= 0 && value[cursor] === "\\"; cursor -= 1) {
- slashes += 1;
- }
- return slashes % 2 === 1;
-};
-var decodeEscapes = (value) => {
- let decoded = "";
- for (let index = 0; index < value.length; index += 1) {
- const character = value[index];
- if (character !== "\\" || index === value.length - 1) {
- decoded += character;
- continue;
- }
- const escaped = value[index += 1];
- if (escaped === "t") decoded += " ";
- else if (escaped === "n") decoded += "\n";
- else if (escaped === "r") decoded += "\r";
- else if (escaped === "f") decoded += "\f";
- else if (escaped === "u") {
- while (value[index + 1] === "u") index += 1;
- const hexadecimal = value.slice(index + 1, index + 5);
- if (/^[\da-f]{4}$/i.test(hexadecimal)) {
- decoded += String.fromCharCode(Number.parseInt(hexadecimal, 16));
- index += 4;
- } else {
- decoded += "u";
- }
- } else decoded += escaped;
- }
- return decoded;
-};
-var parseEntry = (logical) => {
- const { text } = logical;
- let start = 0;
- while (start < text.length && /[ \t\f]/.test(text[start])) start += 1;
- if (start === text.length || text[start] === "#" || text[start] === "!") {
- return void 0;
- }
- let keyEnd = start;
- while (keyEnd < text.length) {
- const character = text[keyEnd];
- if (!isEscaped(text, keyEnd) && (character === "=" || character === ":" || /[ \t\f]/.test(character))) {
- break;
- }
- keyEnd += 1;
- }
- let valueStart = keyEnd;
- while (valueStart < text.length && /[ \t\f]/.test(text[valueStart])) {
- valueStart += 1;
- }
- if (valueStart < text.length && (text[valueStart] === "=" || text[valueStart] === ":")) {
- valueStart += 1;
- }
- while (valueStart < text.length && /[ \t\f]/.test(text[valueStart])) {
- valueStart += 1;
- }
- return {
- key: decodeEscapes(text.slice(start, keyEnd)),
- value: decodeEscapes(text.slice(valueStart)),
- valueSpan: {
- start: logical.offsets[valueStart] ?? logical.end,
- end: logical.end
- },
- firstLine: logical.firstLine,
- lastLine: logical.lastLine
- };
-};
-var classifyNodes = (lines, entries) => {
- const firstLines = new Map(entries.map((entry) => [entry.firstLine, entry]));
- const continuationLines = /* @__PURE__ */ new Set();
- for (const entry of entries) {
- for (let index = entry.firstLine + 1; index <= entry.lastLine; index += 1) {
- continuationLines.add(index);
- }
- }
- return lines.map((line, index) => {
- const entry = firstLines.get(index);
- if (entry) {
- return {
- ...line,
- kind: "entry",
- key: entry.key,
- value: entry.value,
- valueSpan: entry.valueSpan
- };
- }
- if (continuationLines.has(index)) return { ...line, kind: "continuation" };
- const trimmed = line.raw.trimStart();
- if (trimmed === "") return { ...line, kind: "blank" };
- if (trimmed.startsWith("#") || trimmed.startsWith("!")) {
- return { ...line, kind: "comment" };
- }
- return { ...line, kind: "invalid" };
- });
-};
-var parseJavaProperties = (source) => {
- const lines = splitSourceLines(source);
- const entries = [];
- for (let lineIndex = 0; lineIndex < lines.length; lineIndex += 1) {
- const logical = logicalLineAt(lines, lineIndex);
- const entry = parseEntry(logical);
- if (entry) entries.push(entry);
- lineIndex = logical.lastLine;
- }
- return {
- format: "java-properties",
- source,
- nodes: classifyNodes(lines, entries),
- entries,
- issues: [],
- lineEnding: preferredLineEnding(lines),
- finalNewline: hasFinalNewline(source)
- };
-};
-var escapeKey = (value) => value.replace(/[\\ \t\f=:#!]/g, (character) => {
- if (character === " ") return "\\t";
- if (character === "\f") return "\\f";
- return `\\${character}`;
-});
-var escapeValue = (value) => {
- const text = value === null ? "" : String(value);
- return text.replace(/[\\\t\n\r\f]/g, (character) => {
- if (character === "\\") return "\\\\";
- if (character === " ") return "\\t";
- if (character === "\n") return "\\n";
- if (character === "\r") return "\\r";
- return "\\f";
- }).replace(/^ +/, (spaces) => "\\ ".repeat(spaces.length));
-};
-var effectiveEntry = (document, key) => {
- for (let index = document.entries.length - 1; index >= 0; index -= 1) {
- const entry = document.entries[index];
- if (entry.key === key) return entry;
- }
- return void 0;
-};
-var appendEntry = (document, key, value) => {
- const entry = `${escapeKey(key)}=${escapeValue(value)}`;
- if (document.source === "") return entry;
- if (document.finalNewline) return `${document.source}${entry}${document.lineEnding}`;
- return `${document.source}${document.lineEnding}${entry}`;
-};
-var javaPropertiesAdapter = {
- parse: parseJavaProperties,
- entries(document) {
- const entries = /* @__PURE__ */ new Map();
- for (const entry of document.entries) entries.set(entry.key, entry.value);
- return [...entries].map(([key, value]) => ({ key, value }));
- },
- get(document, key) {
- return effectiveEntry(document, key)?.value;
- },
- getAll(document, key) {
- return document.entries.filter((entry) => entry.key === key).map((entry) => entry.value);
- },
- getAllRaw(document, key) {
- return document.entries.filter((entry) => entry.key === key).map((entry) => document.source.slice(entry.valueSpan.start, entry.valueSpan.end));
- },
- set(document, key, value) {
- const entry = effectiveEntry(document, key);
- if (!entry) return parseJavaProperties(appendEntry(document, key, value));
- const source = document.source.slice(0, entry.valueSpan.start) + escapeValue(value) + document.source.slice(entry.valueSpan.end);
- return parseJavaProperties(source);
- },
- setAll(document, key, values) {
- const entries = document.entries.filter((entry) => entry.key === key);
- if (entries.length !== values.length) {
- throw new Error(`Expected ${entries.length} values for Java property "${key}".`);
- }
- let source = document.source;
- for (let index = entries.length - 1; index >= 0; index -= 1) {
- const entry = entries[index];
- source = source.slice(0, entry.valueSpan.start) + escapeValue(values[index]) + source.slice(entry.valueSpan.end);
- }
- return parseJavaProperties(source);
- },
- setAllRaw(document, key, values) {
- const entries = document.entries.filter((entry) => entry.key === key);
- if (entries.length !== values.length) {
- throw new Error(`Expected ${entries.length} raw values for Java property "${key}".`);
- }
- let source = document.source;
- for (let index = entries.length - 1; index >= 0; index -= 1) {
- const entry = entries[index];
- source = source.slice(0, entry.valueSpan.start) + values[index] + source.slice(entry.valueSpan.end);
- }
- return parseJavaProperties(source);
- },
- validate(document, schema) {
- void schema;
- return [...document.issues];
- },
- serialize(document) {
- return document.source;
- }
-};
-
-// ../../packages/config-editor/src/adapters/ini.ts
-var inlineCommentAt = (value) => {
- let quote;
- for (let index = 0; index < value.length; index += 1) {
- const character = value[index];
- if (quote) {
- if (quote === "'" && value.startsWith("'\\''", index)) {
- index += 3;
- continue;
- }
- if (character === quote && value[index - 1] !== "\\") quote = void 0;
- continue;
- }
- if (character === '"' || character === "'") {
- quote = character;
- continue;
- }
- const precededBySpace = index > 0 && /\s/.test(value[index - 1]);
- if (!precededBySpace) continue;
- if (character === ";" || character === "#") return index;
- if (character === "/" && value[index + 1] === "/") return index;
- }
- return value.length;
-};
-var decodeQuotedValue = (value, quote) => {
- if (quote === '"') return value.replace(/\\(["\\])/g, "$1");
- if (quote === "'") return value.replace(/'\\''/g, "'");
- return value;
-};
-var parseEntryLine = (raw, start, section, line, format) => {
- let keyStart = 0;
- while (keyStart < raw.length && /[ \t]/.test(raw[keyStart])) keyStart += 1;
- if (keyStart === raw.length) return void 0;
- let keyEnd = keyStart;
- while (keyEnd < raw.length && raw[keyEnd] !== "=" && raw[keyEnd] !== ":" && !/[ \t]/.test(raw[keyEnd])) {
- keyEnd += 1;
- }
- if (keyEnd === keyStart) return void 0;
- let valueStart = keyEnd;
- while (valueStart < raw.length && /[ \t]/.test(raw[valueStart])) valueStart += 1;
- if (raw[valueStart] === "=" || raw[valueStart] === ":") valueStart += 1;
- while (valueStart < raw.length && /[ \t]/.test(raw[valueStart])) valueStart += 1;
- const comment = inlineCommentAt(raw.slice(valueStart));
- let valueEnd = valueStart + comment;
- while (valueEnd > valueStart && /[ \t]/.test(raw[valueEnd - 1])) valueEnd -= 1;
- if (format === "key-value" && raw[valueEnd - 1] === ";") {
- valueEnd -= 1;
- while (valueEnd > valueStart && /[ \t]/.test(raw[valueEnd - 1])) valueEnd -= 1;
- }
- const quote = raw[valueStart];
- let valueQuote;
- if ((quote === '"' || quote === "'") && valueEnd > valueStart && raw[valueEnd - 1] === quote) {
- valueQuote = quote;
- valueStart += 1;
- valueEnd -= 1;
- }
- const localKey = raw.slice(keyStart, keyEnd).trim();
- return {
- key: section ? `${section}.${localKey}` : localKey,
- value: decodeQuotedValue(raw.slice(valueStart, valueEnd), valueQuote),
- valueSpan: { start: start + valueStart, end: start + valueEnd },
- line,
- ...valueQuote ? { quote: valueQuote } : {}
- };
-};
-var tupleEntries = (entry) => {
- if (!entry.value.startsWith("(") || !entry.value.endsWith(")")) return [];
- const inner = entry.value.slice(1, -1);
- const segments = [];
- let segmentStart = 0;
- let depth = 0;
- let quote;
- for (let index = 0; index <= inner.length; index += 1) {
- const character = inner[index];
- if (quote) {
- if (character === quote && inner[index - 1] !== "\\") quote = void 0;
- continue;
- }
- if (character === '"' || character === "'") {
- quote = character;
- continue;
- }
- if (character === "(" || character === "[" || character === "{") depth += 1;
- else if (character === ")" || character === "]" || character === "}") depth -= 1;
- if (character === "," && depth === 0 || index === inner.length) {
- segments.push({ start: segmentStart, end: index });
- segmentStart = index + 1;
- }
- }
- const result = [];
- for (const segment of segments) {
- const raw = inner.slice(segment.start, segment.end);
- let equals = -1;
- depth = 0;
- quote = void 0;
- for (let index = 0; index < raw.length; index += 1) {
- const character = raw[index];
- if (quote) {
- if (character === quote && raw[index - 1] !== "\\") quote = void 0;
- continue;
- }
- if (character === '"' || character === "'") quote = character;
- else if (character === "(" || character === "[" || character === "{") depth += 1;
- else if (character === ")" || character === "]" || character === "}") depth -= 1;
- else if (character === "=" && depth === 0) {
- equals = index;
- break;
- }
- }
- if (equals < 1) continue;
- const key = raw.slice(0, equals).trim();
- let localStart = equals + 1;
- while (localStart < raw.length && /\s/.test(raw[localStart])) localStart += 1;
- let localEnd = raw.length;
- while (localEnd > localStart && /\s/.test(raw[localEnd - 1])) localEnd -= 1;
- const valueQuote = raw[localStart];
- let preservedQuote;
- if ((valueQuote === '"' || valueQuote === "'") && localEnd > localStart && raw[localEnd - 1] === valueQuote) {
- preservedQuote = valueQuote;
- localStart += 1;
- localEnd -= 1;
- }
- const absoluteStart = entry.valueSpan.start + 1 + segment.start + localStart;
- result.push({
- key: `${entry.key}.${key}`,
- value: decodeQuotedValue(raw.slice(localStart, localEnd), preservedQuote),
- valueSpan: {
- start: absoluteStart,
- end: absoluteStart + localEnd - localStart
- },
- line: entry.line,
- ...preservedQuote ? { quote: preservedQuote } : {}
- });
- }
- return result;
-};
-var parseIni = (source, format) => {
- const lines = splitSourceLines(source);
- const entries = [];
- const sections = [];
- const nodes = [];
- let section;
- for (let index = 0; index < lines.length; index += 1) {
- const line = lines[index];
- const trimmed = line.raw.trimStart();
- if (trimmed === "") {
- nodes.push({ ...line, kind: "blank" });
- continue;
- }
- if (trimmed.startsWith(";") || trimmed.startsWith("#") || trimmed.startsWith("//")) {
- nodes.push({ ...line, kind: "comment" });
- continue;
- }
- const sectionMatch = format === "key-value" ? null : trimmed.match(/^\[([^\]]+)](?:\s*(?:[;#].*)?)$/);
- if (sectionMatch) {
- section = sectionMatch[1].trim();
- sections.push({ name: section, line: index });
- nodes.push({ ...line, kind: "section" });
- continue;
- }
- const entry = parseEntryLine(line.raw, line.start, section, index, format);
- if (entry) {
- entries.push(entry);
- if (format === "unreal-ini") {
- const children = tupleEntries(entry);
- if (children.length > 0) {
- entry.container = true;
- entries.push(...children);
- }
- }
- nodes.push({
- ...line,
- kind: "entry",
- key: entry.key,
- value: entry.value,
- valueSpan: entry.valueSpan
- });
- } else nodes.push({ ...line, kind: "invalid" });
- }
- return {
- format,
- source,
- nodes,
- entries,
- sections,
- issues: [],
- lineEnding: preferredLineEnding(lines),
- finalNewline: hasFinalNewline(source)
- };
-};
-var effectiveEntry2 = (document, key) => {
- for (let index = document.entries.length - 1; index >= 0; index -= 1) {
- const entry = document.entries[index];
- if (entry.key === key) return entry;
- }
- return void 0;
-};
-var formatValue = (value, quote) => {
- let formatted = (value === null ? "" : String(value)).replace(
- /[\r\n]/g,
- (character) => character === "\r" ? "\\r" : "\\n"
- );
- if (quote === '"') formatted = formatted.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
- else if (quote === "'") formatted = formatted.replace(/'/g, "'\\''");
- return formatted;
-};
-var appendIniEntry = (document, key, value) => {
- const split = document.format === "key-value" ? -1 : key.lastIndexOf(".");
- const section = split < 0 ? void 0 : key.slice(0, split);
- const localKey = split < 0 ? key : key.slice(split + 1);
- const assignment = `${localKey}=${formatValue(value)}`;
- const prefix = document.source === "" || document.finalNewline ? "" : document.lineEnding;
- const suffix = document.finalNewline ? document.lineEnding : "";
- if (!section || document.sections.some((candidate) => candidate.name === section)) {
- return `${document.source}${prefix}${assignment}${suffix}`;
- }
- const sectionHeader = `[${section}]${document.lineEnding}`;
- return `${document.source}${prefix}${sectionHeader}${assignment}${suffix}`;
-};
-var createIniAdapter = (format) => ({
- parse(source) {
- return parseIni(source, format);
- },
- entries(document) {
- const entries = /* @__PURE__ */ new Map();
- for (const entry of document.entries) {
- if (!entry.container) entries.set(entry.key, entry.value);
- }
- return [...entries].map(([key, value]) => ({ key, value }));
- },
- get(document, key) {
- return effectiveEntry2(document, key)?.value;
- },
- getAll(document, key) {
- return document.entries.filter((entry) => !entry.container && entry.key === key).map((entry) => entry.value);
- },
- getAllRaw(document, key) {
- return document.entries.filter((entry) => !entry.container && entry.key === key).map((entry) => document.source.slice(entry.valueSpan.start, entry.valueSpan.end));
- },
- set(document, key, value) {
- const entry = effectiveEntry2(document, key);
- if (!entry) return parseIni(appendIniEntry(document, key, value), format);
- return parseIni(
- document.source.slice(0, entry.valueSpan.start) + formatValue(value, entry.quote) + document.source.slice(entry.valueSpan.end),
- format
- );
- },
- setAll(document, key, values) {
- const entries = document.entries.filter(
- (entry) => !entry.container && entry.key === key
- );
- if (entries.length !== values.length) {
- throw new Error(`Expected ${entries.length} values for setting "${key}".`);
- }
- let source = document.source;
- for (let index = entries.length - 1; index >= 0; index -= 1) {
- const entry = entries[index];
- source = source.slice(0, entry.valueSpan.start) + formatValue(values[index], entry.quote) + source.slice(entry.valueSpan.end);
- }
- return parseIni(source, format);
- },
- setAllRaw(document, key, values) {
- const entries = document.entries.filter(
- (entry) => !entry.container && entry.key === key
- );
- if (entries.length !== values.length) {
- throw new Error(`Expected ${entries.length} raw values for setting "${key}".`);
- }
- let source = document.source;
- for (let index = entries.length - 1; index >= 0; index -= 1) {
- const entry = entries[index];
- source = source.slice(0, entry.valueSpan.start) + values[index] + source.slice(entry.valueSpan.end);
- }
- return parseIni(source, format);
- },
- validate(document, schema) {
- void schema;
- return [...document.issues];
- },
- serialize(document) {
- return document.source;
- }
-});
-var iniAdapter = createIniAdapter("ini");
-var unrealIniAdapter = createIniAdapter("unreal-ini");
-var keyValueAdapter = createIniAdapter("key-value");
-
-// ../../packages/config-editor/src/adapters/json.ts
-var JsonParseError = class extends Error {
- position;
- constructor(position, message) {
- super(message);
- this.position = position;
- }
-};
-var pointerSegment = (value) => value.replace(/~/g, "~0").replace(/\//g, "~1");
-var locationAt = (source, position) => {
- const before = source.slice(0, position);
- const lines = before.split("\n");
- return { line: lines.length, column: lines.at(-1).length + 1 };
-};
-var jsonNodes = (source) => splitSourceLines(source).map((line) => ({
- ...line,
- kind: line.raw.trim() === "" ? "blank" : "invalid"
-}));
-var parseJson = (source) => {
- const scalars = /* @__PURE__ */ new Map();
- const scalarOccurrences = /* @__PURE__ */ new Map();
- const containers = /* @__PURE__ */ new Map();
- let cursor = 0;
- const addScalar = (pointer, scalar) => {
- scalars.set(pointer, scalar);
- const occurrences = scalarOccurrences.get(pointer) ?? [];
- occurrences.push(scalar);
- scalarOccurrences.set(pointer, occurrences);
- };
- const fail2 = (message) => {
- throw new JsonParseError(cursor, message);
- };
- const skipWhitespace = () => {
- while (cursor < source.length && /\s/.test(source[cursor])) cursor += 1;
- };
- const consume = (character) => {
- if (source[cursor] !== character) fail2(`Expected "${character}".`);
- cursor += 1;
- };
- const parseString = () => {
- const start = cursor;
- consume('"');
- while (cursor < source.length) {
- const character = source[cursor];
- if (character === '"') {
- cursor += 1;
- const token = source.slice(start, cursor);
- try {
- return { value: JSON.parse(token), span: { start, end: cursor } };
- } catch {
- fail2("Invalid JSON string.");
- }
- }
- if (character === "\\") {
- cursor += 2;
- continue;
- }
- if (character.charCodeAt(0) < 32) fail2("Unescaped control character.");
- cursor += 1;
- }
- return fail2("Unterminated JSON string.");
- };
- const parseValue = (pointer) => {
- skipWhitespace();
- const start = cursor;
- const character = source[cursor];
- if (character === '"') {
- const string = parseString();
- addScalar(pointer, { value: string.value, span: string.span });
- return;
- }
- if (character === "{") {
- const open = cursor;
- cursor += 1;
- skipWhitespace();
- if (source[cursor] === "}") {
- containers.set(pointer, { kind: "object", open, close: cursor });
- cursor += 1;
- return;
- }
- while (cursor < source.length) {
- skipWhitespace();
- if (source[cursor] !== '"') fail2("Expected an object key.");
- const key = parseString().value;
- skipWhitespace();
- consume(":");
- parseValue(`${pointer}/${pointerSegment(key)}`);
- skipWhitespace();
- if (source[cursor] === "}") {
- containers.set(pointer, { kind: "object", open, close: cursor });
- cursor += 1;
- return;
- }
- consume(",");
- }
- fail2("Unterminated JSON object.");
- }
- if (character === "[") {
- const open = cursor;
- cursor += 1;
- skipWhitespace();
- if (source[cursor] === "]") {
- containers.set(pointer, { kind: "array", open, close: cursor });
- cursor += 1;
- return;
- }
- let index = 0;
- while (cursor < source.length) {
- parseValue(`${pointer}/${index}`);
- index += 1;
- skipWhitespace();
- if (source[cursor] === "]") {
- containers.set(pointer, { kind: "array", open, close: cursor });
- cursor += 1;
- return;
- }
- consume(",");
- }
- fail2("Unterminated JSON array.");
- }
- const remainder = source.slice(cursor);
- const literal = remainder.match(/^(?:true|false|null)(?![\w])/);
- if (literal) {
- cursor += literal[0].length;
- addScalar(pointer, {
- value: JSON.parse(literal[0]),
- span: { start, end: cursor }
- });
- return;
- }
- const number = remainder.match(/^-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?/);
- if (number) {
- cursor += number[0].length;
- const unsafeInteger = /^-?\d+$/.test(number[0]) && !Number.isSafeInteger(Number(number[0]));
- addScalar(pointer, {
- value: unsafeInteger ? number[0] : Number(number[0]),
- span: { start, end: cursor },
- ...unsafeInteger ? { unsafeInteger: true } : {}
- });
- return;
- }
- fail2("Expected a JSON scalar, object, or array.");
- };
- try {
- parseValue("");
- skipWhitespace();
- if (cursor !== source.length) fail2("Unexpected content after the JSON value.");
- return {
- format: "json",
- source,
- nodes: jsonNodes(source),
- issues: [],
- scalars,
- scalarOccurrences,
- containers
- };
- } catch (error) {
- if (!(error instanceof JsonParseError)) throw error;
- const location = locationAt(source, error.position);
- return {
- format: "json",
- source,
- nodes: jsonNodes(source),
- issues: [
- {
- code: "invalid-json",
- message: error.message,
- severity: "error",
- ...location
- }
- ],
- scalars: /* @__PURE__ */ new Map(),
- scalarOccurrences: /* @__PURE__ */ new Map(),
- containers: /* @__PURE__ */ new Map()
- };
- }
-};
-var decodePointerSegment = (value) => value.replace(/~1/g, "/").replace(/~0/g, "~");
-var insertJsonProperty = (document, pointer, value) => {
- const separator = pointer.lastIndexOf("/");
- if (separator < 0) throw new Error(`JSON pointer "${pointer}" does not exist.`);
- const parentPointer = pointer.slice(0, separator);
- const property = decodePointerSegment(pointer.slice(separator + 1));
- const parent = document.containers.get(parentPointer);
- if (!parent || parent.kind !== "object") {
- throw new Error(`JSON pointer "${pointer}" does not exist.`);
- }
- const content = document.source.slice(parent.open + 1, parent.close);
- const contentEnd = parent.close - (content.match(/\s*$/)?.[0].length ?? 0);
- const closingLine = document.source.slice(0, parent.close).split("\n").at(-1) ?? "";
- const closingIndent = closingLine.match(/^\s*/)?.[0] ?? "";
- const serialized = `${JSON.stringify(property)}: ${JSON.stringify(value)}`;
- const newline = document.source.includes("\r\n") ? "\r\n" : "\n";
- let insertion;
- if (content.trim() === "") {
- insertion = `${newline}${closingIndent} ${serialized}${newline}${closingIndent}`;
- return parseJson(
- document.source.slice(0, parent.open + 1) + insertion + document.source.slice(parent.close)
- );
- }
- const multiline = content.includes("\n");
- if (!multiline) insertion = `, ${serialized}`;
- else {
- const existingLine = document.source.slice(0, contentEnd).split("\n").at(-1) ?? "";
- const childIndent = existingLine.match(/^\s*/)?.[0] ?? `${closingIndent} `;
- insertion = `,${newline}${childIndent}${serialized}`;
- }
- return parseJson(
- document.source.slice(0, contentEnd) + insertion + document.source.slice(contentEnd)
- );
-};
-var jsonAdapter = {
- parse: parseJson,
- entries(document) {
- return [...document.scalars].map(([key, scalar]) => ({
- key,
- value: scalar.value
- }));
- },
- get(document, key) {
- return document.scalars.get(key)?.value;
- },
- getAll(document, key) {
- return (document.scalarOccurrences.get(key) ?? []).map((scalar) => scalar.value);
- },
- getAllRaw(document, key) {
- return (document.scalarOccurrences.get(key) ?? []).map(
- (scalar) => document.source.slice(scalar.span.start, scalar.span.end)
- );
- },
- set(document, key, value) {
- if (document.issues.length > 0) throw new Error("Invalid JSON cannot be edited in form mode.");
- const scalar = document.scalars.get(key);
- if (!scalar) {
- if (document.containers.has(key)) throw new Error(`JSON pointer "${key}" is not a scalar.`);
- return insertJsonProperty(document, key, value);
- }
- const serialized = scalar.unsafeInteger && typeof value === "string" && /^-?\d+$/.test(value) ? value : JSON.stringify(value);
- return parseJson(
- document.source.slice(0, scalar.span.start) + serialized + document.source.slice(scalar.span.end)
- );
- },
- setAll(document, key, values) {
- const occurrences = document.scalarOccurrences.get(key) ?? [];
- const expected = occurrences.length;
- if (values.length !== expected) {
- throw new Error(`Expected ${expected} values for JSON pointer "${key}".`);
- }
- let source = document.source;
- for (let index = occurrences.length - 1; index >= 0; index -= 1) {
- const scalar = occurrences[index];
- const value = values[index];
- const serialized = scalar.unsafeInteger && typeof value === "string" && /^-?\d+$/.test(value) ? value : JSON.stringify(value);
- source = source.slice(0, scalar.span.start) + serialized + source.slice(scalar.span.end);
- }
- return parseJson(source);
- },
- setAllRaw(document, key, values) {
- const occurrences = document.scalarOccurrences.get(key) ?? [];
- const expected = occurrences.length;
- if (values.length !== expected) {
- throw new Error(`Expected ${expected} raw values for JSON pointer "${key}".`);
- }
- let source = document.source;
- for (let index = occurrences.length - 1; index >= 0; index -= 1) {
- const scalar = occurrences[index];
- source = source.slice(0, scalar.span.start) + values[index] + source.slice(scalar.span.end);
- }
- return parseJson(source);
- },
- validate(document, schema) {
- void schema;
- return [...document.issues];
- },
- serialize(document) {
- return document.source;
- }
-};
-
-// ../../packages/config-editor/src/adapters/raw.ts
-var parseRaw = (source) => {
- const nodes = splitSourceLines(source).map((line) => ({
- ...line,
- kind: line.raw.trim() === "" ? "blank" : "invalid"
- }));
- return { format: "raw", source, nodes, issues: [] };
-};
-var rawAdapter = {
- parse: parseRaw,
- entries() {
- return [];
- },
- get() {
- return void 0;
- },
- getAll() {
- return [];
- },
- getAllRaw() {
- return [];
- },
- set(_document, _key, _value) {
- throw new Error("Raw mode does not support typed field writes.");
- },
- setAll(document, _key, values) {
- if (values.length > 0) throw new Error("Raw mode does not support typed field writes.");
- return document;
- },
- setAllRaw(document, _key, values) {
- if (values.length > 0) throw new Error("Raw mode does not support typed field writes.");
- return document;
- },
- validate(document, schema) {
- void schema;
- return [...document.issues];
- },
- serialize(document) {
- return document.source;
- }
-};
-
-// ../../packages/config-editor/src/adapters/xml-properties.ts
-var decodeXml = (value) => value.replace(/&(?:#x[\da-f]+|#\d+|amp|quot|apos|lt|gt);/gi, (entity) => {
- const token = entity.slice(1, -1);
- if (token.toLowerCase().startsWith("#x")) {
- return String.fromCodePoint(Number.parseInt(token.slice(2), 16));
- }
- if (token.startsWith("#")) {
- return String.fromCodePoint(Number.parseInt(token.slice(1), 10));
- }
- return { amp: "&", quot: '"', apos: "'", lt: "<", gt: ">" }[token.toLowerCase()];
-});
-var encodeXml = (value, quote) => {
- let encoded = (value === null ? "" : String(value)).replace(/&/g, "&").replace(//g, ">");
- encoded = quote === '"' ? encoded.replace(/"/g, """) : encoded.replace(/'/g, "'");
- return encoded;
-};
-var parseXmlProperties = (source) => {
- const properties = [];
- const propertyPattern = /]*>/gi;
- for (const match of source.matchAll(propertyPattern)) {
- const tag = match[0];
- const tagStart = match.index;
- const attributes = /* @__PURE__ */ new Map();
- const attributePattern = /\b(name|value)\s*=\s*(["'])(.*?)\2/gi;
- for (const attribute of tag.matchAll(attributePattern)) {
- const quote = attribute[2];
- const rawValue = attribute[3];
- const rawStart = tagStart + attribute.index + attribute[0].indexOf(quote) + 1;
- attributes.set(attribute[1].toLowerCase(), {
- value: decodeXml(rawValue),
- span: { start: rawStart, end: rawStart + rawValue.length },
- quote
- });
- }
- const name = attributes.get("name");
- const value = attributes.get("value");
- if (name && value) {
- properties.push({
- key: name.value,
- value: value.value,
- valueSpan: value.span,
- quote: value.quote
- });
- }
- }
- const nodes = splitSourceLines(source).map((line) => ({
- ...line,
- kind: line.raw.trim() === "" ? "blank" : "invalid"
- }));
- return { format: "xml-properties", source, nodes, issues: [], properties };
-};
-var effectiveProperty = (document, key) => {
- for (let index = document.properties.length - 1; index >= 0; index -= 1) {
- const property = document.properties[index];
- if (property.key === key) return property;
- }
- return void 0;
-};
-var xmlPropertiesAdapter = {
- parse: parseXmlProperties,
- entries(document) {
- const entries = /* @__PURE__ */ new Map();
- for (const property of document.properties) entries.set(property.key, property.value);
- return [...entries].map(([key, value]) => ({ key, value }));
- },
- get(document, key) {
- return effectiveProperty(document, key)?.value;
- },
- getAll(document, key) {
- return document.properties.filter((property) => property.key === key).map((property) => property.value);
- },
- getAllRaw(document, key) {
- return document.properties.filter((property) => property.key === key).map((property) => document.source.slice(property.valueSpan.start, property.valueSpan.end));
- },
- set(document, key, value) {
- const property = effectiveProperty(document, key);
- if (!property) throw new Error(`XML property "${key}" does not exist.`);
- return parseXmlProperties(
- document.source.slice(0, property.valueSpan.start) + encodeXml(value, property.quote) + document.source.slice(property.valueSpan.end)
- );
- },
- setAll(document, key, values) {
- const properties = document.properties.filter((property) => property.key === key);
- if (properties.length !== values.length) {
- throw new Error(`Expected ${properties.length} values for XML property "${key}".`);
- }
- let source = document.source;
- for (let index = properties.length - 1; index >= 0; index -= 1) {
- const property = properties[index];
- source = source.slice(0, property.valueSpan.start) + encodeXml(values[index], property.quote) + source.slice(property.valueSpan.end);
- }
- return parseXmlProperties(source);
- },
- setAllRaw(document, key, values) {
- const properties = document.properties.filter((property) => property.key === key);
- if (properties.length !== values.length) {
- throw new Error(`Expected ${properties.length} raw values for XML property "${key}".`);
- }
- let source = document.source;
- for (let index = properties.length - 1; index >= 0; index -= 1) {
- const property = properties[index];
- source = source.slice(0, property.valueSpan.start) + values[index] + source.slice(property.valueSpan.end);
- }
- return parseXmlProperties(source);
- },
- validate(document, schema) {
- void schema;
- return [...document.issues];
- },
- serialize(document) {
- return document.source;
- }
-};
-
-// ../../packages/config-editor/src/adapters/registry.ts
-var createAdapterRegistry = () => /* @__PURE__ */ new Map([
- ["java-properties", javaPropertiesAdapter],
- ["ini", iniAdapter],
- ["unreal-ini", unrealIniAdapter],
- ["key-value", keyValueAdapter],
- ["json", jsonAdapter],
- ["xml-properties", xmlPropertiesAdapter],
- ["raw", rawAdapter]
-]);
-
-// ../../packages/config-editor/src/validation.ts
-var issue = (field, code, message) => ({
- code,
- message,
- severity: "error",
- fieldKey: field.key
-});
-var scalarText = (input) => {
- if (input === null) return "";
- if (typeof input === "string") return input;
- if (typeof input === "number" || typeof input === "boolean") return String(input);
- throw new TypeError("Value must be a scalar.");
-};
-var coerceFieldValue = (field, input) => {
- if (field.type === "string" || field.type === "secret" || field.type === "enum") {
- return scalarText(input);
- }
- if (field.type === "boolean") {
- if (input === true || input === false) return input;
- if (input === "true") return true;
- if (input === "false") return false;
- throw new TypeError("Value must be true or false.");
- }
- if (typeof input === "number") {
- if (!Number.isFinite(input)) throw new TypeError("Value must be a finite number.");
- if (field.type === "integer" && !Number.isSafeInteger(input)) {
- throw new TypeError("Value must be an integer.");
- }
- return input;
- }
- const text = scalarText(input).trim();
- if (field.type === "integer") {
- if (!/^[+-]?\d+$/.test(text)) throw new TypeError("Value must be an integer.");
- const value2 = Number(text);
- if (!Number.isSafeInteger(value2)) throw new TypeError("Value must be an integer.");
- return value2;
- }
- if (!/^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?$/.test(text)) {
- throw new TypeError("Value must be a finite number.");
- }
- const value = Number(text);
- if (!Number.isFinite(value)) throw new TypeError("Value must be a finite number.");
- return value;
-};
-var validateField2 = (field, value) => {
- const issues = [];
- if (field.type === "integer" && (typeof value !== "number" || !Number.isSafeInteger(value))) {
- issues.push(issue(field, "type", "Value must be an integer."));
- return issues;
- }
- if (field.type === "number" && (typeof value !== "number" || !Number.isFinite(value))) {
- issues.push(issue(field, "type", "Value must be a finite number."));
- return issues;
- }
- if (field.type === "boolean" && typeof value !== "boolean") {
- issues.push(issue(field, "type", "Value must be true or false."));
- return issues;
- }
- if ((field.type === "string" || field.type === "secret" || field.type === "enum") && typeof value !== "string") {
- issues.push(issue(field, "type", "Value must be text."));
- return issues;
- }
- if (typeof value === "number") {
- const below = field.min !== void 0 && value < field.min;
- const above = field.max !== void 0 && value > field.max;
- if (below || above) {
- let message;
- if (field.min !== void 0 && field.max !== void 0) {
- message = `Value must be between ${field.min} and ${field.max} (inclusive).`;
- } else if (field.min !== void 0) message = `Value must be at least ${field.min}.`;
- else message = `Value must be at most ${field.max}.`;
- issues.push(issue(field, "range", message));
- }
- }
- if (field.type === "enum" && typeof value === "string" && !field.values?.includes(value)) {
- issues.push(
- issue(field, "enum", `Value must be one of: ${(field.values ?? []).join(", ")}.`)
- );
- }
- if (field.pattern !== void 0 && typeof value === "string") {
- const pattern = new RegExp(`^(?:${field.pattern})$`);
- if (!pattern.test(value)) {
- issues.push(issue(field, "pattern", "Value does not match the required format."));
- }
- }
- return issues;
-};
-
-// ../../packages/config-editor/src/store.ts
-var MASKED_SECRET = "\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022";
-var allFields = (schema) => schema.sections.flatMap((section) => section.fields);
-var sensitiveKey = (key) => {
- const normalized = key.replace(/([a-z\d])([A-Z])/g, "$1.$2").toLowerCase();
- const segments = normalized.split(/[^a-z\d]+/).filter(Boolean);
- const sensitiveSegments = /* @__PURE__ */ new Set([
- "password",
- "passwd",
- "pass",
- "secret",
- "token",
- "apikey",
- "privatekey",
- "gslt",
- "steampass"
- ]);
- return segments.some((segment) => sensitiveSegments.has(segment)) || /(?:password|passwd|steampass|api[-_.]?key|private[-_.]?key|gslt)/i.test(key);
-};
-var inferredType = (value) => {
- if (typeof value === "boolean") return "boolean";
- if (typeof value === "number") return Number.isInteger(value) ? "integer" : "number";
- if (typeof value !== "string") return "string";
- if (/^(?:true|false)$/i.test(value)) return "boolean";
- return "string";
-};
-var humanizeKey = (key) => {
- const leaf = key.split(/[./]/).at(-1) ?? key;
- const label = leaf.replace(/[-_]+/g, " ").replace(/([a-z\d])([A-Z])/g, "$1 $2");
- return label.charAt(0).toUpperCase() + label.slice(1);
-};
-var schemaWithDetectedFields = (declaredSchema, entries) => {
- const declaredKeys = new Set(allFields(declaredSchema).map((field) => field.key));
- const detectedFields = [];
- for (const entry of entries) {
- if (entry.key === "" || declaredKeys.has(entry.key)) continue;
- const sensitive = sensitiveKey(entry.key);
- detectedFields.push({
- key: entry.key,
- label: humanizeKey(entry.key),
- description: `Setting discovered in ${declaredSchema.label}.`,
- documentation: declaredSchema.documentation ?? `Configuration file ${declaredSchema.path}`,
- type: sensitive ? "secret" : inferredType(entry.value),
- sensitive,
- restartRequired: true
- });
- }
- if (detectedFields.length === 0) return declaredSchema;
- const detectedSection = {
- id: "detected-settings",
- label: "Detected settings",
- description: "Settings discovered from the loaded configuration file.",
- fields: detectedFields
- };
- return {
- ...declaredSchema,
- sections: [...declaredSchema.sections, detectedSection]
- };
-};
-var valuesEqual = (left, right) => Object.is(left, right);
-var displayValue = (field, value) => {
- if ((field.sensitive || field.type === "secret") && value !== void 0 && value !== "") {
- return MASKED_SECRET;
- }
- return value === null || value === void 0 ? "" : String(value);
-};
-var deepFreezeSnapshot = (snapshot) => {
- for (const field of Object.values(snapshot.fields)) {
- for (const fieldIssue of field.issues) Object.freeze(fieldIssue);
- Object.freeze(field.issues);
- Object.freeze(field);
- }
- for (const change of snapshot.changes) Object.freeze(change);
- for (const editorIssue of snapshot.issues) Object.freeze(editorIssue);
- Object.freeze(snapshot.fields);
- Object.freeze(snapshot.changes);
- Object.freeze(snapshot.issues);
- return Object.freeze(snapshot);
-};
-var ConfigEditorStore = class _ConfigEditorStore {
- declaredSchema;
- schemaValue;
- adapter;
- originalDocument;
- workingDocument;
- fields;
- constructor(schema, document, adapter) {
- this.declaredSchema = schema;
- this.adapter = adapter;
- this.schemaValue = schemaWithDetectedFields(schema, adapter.entries(document));
- this.originalDocument = document;
- this.workingDocument = document;
- this.fields = this.readFields(document, document);
- }
- get schema() {
- return this.schemaValue;
- }
- static fromLoadedFile(schema, document) {
- if (schema.format !== document.format) {
- throw new TypeError(
- `Schema format "${schema.format}" does not match document format "${document.format}".`
- );
- }
- const adapter = createAdapterRegistry().get(schema.format);
- if (!adapter) throw new TypeError(`No adapter registered for "${schema.format}".`);
- return new _ConfigEditorStore(schema, document, adapter);
- }
- readValue(document, field) {
- const raw = this.adapter.get(document, field.key);
- if (raw === void 0) return { value: void 0, issues: [] };
- try {
- const value = coerceFieldValue(field, raw);
- return { value, issues: validateField2(field, value) };
- } catch (error) {
- return {
- value: void 0,
- issues: [
- {
- code: "type",
- message: error instanceof Error ? error.message : "Invalid value.",
- severity: "error",
- fieldKey: field.key,
- filePath: this.schema.path
- }
- ]
- };
- }
- }
- readFields(original, working) {
- return new Map(
- allFields(this.schema).map((field) => {
- const originalResult = this.readValue(original, field);
- const workingResult = this.readValue(working, field);
- return [
- field.key,
- {
- schema: field,
- original: originalResult.value,
- current: workingResult.value,
- displayValue: displayValue(field, workingResult.value),
- issues: workingResult.issues
- }
- ];
- })
- );
- }
- setDisplayValue(key, input) {
- const state = this.fields.get(key);
- if (!state) throw new TypeError(`Unknown configuration field "${key}".`);
- const sensitive = state.schema.sensitive || state.schema.type === "secret";
- if (sensitive && input === MASKED_SECRET) {
- state.displayValue = MASKED_SECRET;
- state.issues = [];
- return;
- }
- state.displayValue = input;
- let value;
- try {
- value = coerceFieldValue(state.schema, input);
- } catch (error) {
- state.issues = [
- {
- code: "type",
- message: error instanceof Error ? error.message : "Invalid value.",
- severity: "error",
- fieldKey: key,
- filePath: this.schema.path
- }
- ];
- return;
- }
- const issues = validateField2(state.schema, value).map((fieldIssue) => ({
- ...fieldIssue,
- filePath: this.schema.path
- }));
- state.issues = issues;
- if (issues.length > 0) return;
- this.workingDocument = this.adapter.set(this.workingDocument, key, value);
- state.current = value;
- if (sensitive) state.displayValue = MASKED_SECRET;
- }
- serializeSelectedFile() {
- return this.adapter.serialize(this.workingDocument);
- }
- serializeForDisplay() {
- let displayDocument = this.workingDocument;
- for (const field of allFields(this.schema)) {
- if (!(field.sensitive || field.type === "secret")) continue;
- const values = this.adapter.getAll(displayDocument, field.key);
- if (values.length === 0) continue;
- displayDocument = this.adapter.setAll(
- displayDocument,
- field.key,
- values.map(() => MASKED_SECRET)
- );
- }
- return this.adapter.serialize(displayDocument);
- }
- validateSerializedSource(source) {
- const document = this.adapter.parse(source);
- const issues = this.adapter.validate(document, this.schema);
- for (const field of allFields(this.schema)) {
- issues.push(...this.readValue(document, field).issues);
- }
- return issues;
- }
- loadedSource() {
- return this.adapter.serialize(this.originalDocument);
- }
- replaceWorkingSource(source) {
- let document = this.adapter.parse(source);
- for (const field of allFields(this.schema)) {
- if (!(field.sensitive || field.type === "secret")) continue;
- const displayedValues = this.adapter.getAll(document, field.key);
- if (!displayedValues.includes(MASKED_SECRET)) continue;
- const currentSecrets = this.adapter.getAllRaw(this.workingDocument, field.key);
- if (displayedValues.length !== currentSecrets.length) {
- throw new Error(
- `Secret occurrences for "${field.key}" cannot be added or removed in raw mode.`
- );
- }
- document = this.adapter.setAllRaw(
- document,
- field.key,
- displayedValues.map(
- (value, index) => value === MASKED_SECRET ? currentSecrets[index] : this.adapter.getAllRaw(document, field.key)[index]
- )
- );
- }
- this.workingDocument = document;
- this.schemaValue = schemaWithDetectedFields(
- this.declaredSchema,
- this.adapter.entries(document)
- );
- this.fields = this.readFields(this.originalDocument, this.workingDocument);
- }
- acceptSavedSource(source) {
- const document = this.adapter.parse(source);
- this.originalDocument = document;
- this.workingDocument = document;
- this.schemaValue = schemaWithDetectedFields(
- this.declaredSchema,
- this.adapter.entries(document)
- );
- this.fields = this.readFields(document, document);
- }
- snapshot() {
- const fields = {};
- const changes = [];
- const issues = [...this.workingDocument.issues];
- const documentDirty = this.adapter.serialize(this.workingDocument) !== this.adapter.serialize(this.originalDocument);
- for (const [key, state] of this.fields) {
- const sensitive = Boolean(state.schema.sensitive || state.schema.type === "secret");
- const dirty = !valuesEqual(state.current, state.original);
- const fieldIssues = state.issues.map((fieldIssue) => ({ ...fieldIssue }));
- const fieldSnapshot = {
- key,
- label: state.schema.label,
- type: state.schema.type,
- displayValue: state.displayValue,
- sensitive,
- dirty,
- issues: fieldIssues,
- ...!sensitive && state.current !== void 0 ? { value: state.current } : {}
- };
- fields[key] = fieldSnapshot;
- issues.push(...fieldIssues);
- if (dirty) {
- changes.push({
- key,
- label: state.schema.label,
- before: sensitive ? null : state.original,
- after: sensitive ? null : state.current,
- sensitive,
- restartRequired: Boolean(state.schema.restartRequired)
- });
- }
- }
- const unstructuredChanges = documentDirty && changes.length === 0;
- return deepFreezeSnapshot({
- filePath: this.schema.path,
- fields,
- changes,
- issues,
- dirty: documentDirty,
- unstructuredChanges,
- restartRequired: unstructuredChanges || changes.some((change) => change.restartRequired)
- });
- }
-};
-
-// ../../packages/config-editor/src/fingerprint.ts
-var SHA256_CONSTANTS = [
- 1116352408,
- 1899447441,
- 3049323471,
- 3921009573,
- 961987163,
- 1508970993,
- 2453635748,
- 2870763221,
- 3624381080,
- 310598401,
- 607225278,
- 1426881987,
- 1925078388,
- 2162078206,
- 2614888103,
- 3248222580,
- 3835390401,
- 4022224774,
- 264347078,
- 604807628,
- 770255983,
- 1249150122,
- 1555081692,
- 1996064986,
- 2554220882,
- 2821834349,
- 2952996808,
- 3210313671,
- 3336571891,
- 3584528711,
- 113926993,
- 338241895,
- 666307205,
- 773529912,
- 1294757372,
- 1396182291,
- 1695183700,
- 1986661051,
- 2177026350,
- 2456956037,
- 2730485921,
- 2820302411,
- 3259730800,
- 3345764771,
- 3516065817,
- 3600352804,
- 4094571909,
- 275423344,
- 430227734,
- 506948616,
- 659060556,
- 883997877,
- 958139571,
- 1322822218,
- 1537002063,
- 1747873779,
- 1955562222,
- 2024104815,
- 2227730452,
- 2361852424,
- 2428436474,
- 2756734187,
- 3204031479,
- 3329325298
-];
-var rotateRight = (value, bits) => value >>> bits | value << 32 - bits;
-var utf8Bytes = (source) => {
- const bytes = [];
- for (const character of source) {
- const point = character.codePointAt(0);
- if (point <= 127) bytes.push(point);
- else if (point <= 2047) {
- bytes.push(192 | point >>> 6, 128 | point & 63);
- } else if (point <= 65535) {
- bytes.push(
- 224 | point >>> 12,
- 128 | point >>> 6 & 63,
- 128 | point & 63
- );
- } else {
- bytes.push(
- 240 | point >>> 18,
- 128 | point >>> 12 & 63,
- 128 | point >>> 6 & 63,
- 128 | point & 63
- );
- }
- }
- return bytes;
-};
-var sha256 = (source) => {
- const bytes = utf8Bytes(source);
- const bitLength = bytes.length * 8;
- bytes.push(128);
- while (bytes.length % 64 !== 56) bytes.push(0);
- const high = Math.floor(bitLength / 4294967296);
- const low = bitLength >>> 0;
- for (let shift = 24; shift >= 0; shift -= 8) bytes.push(high >>> shift & 255);
- for (let shift = 24; shift >= 0; shift -= 8) bytes.push(low >>> shift & 255);
- const hash = [
- 1779033703,
- 3144134277,
- 1013904242,
- 2773480762,
- 1359893119,
- 2600822924,
- 528734635,
- 1541459225
- ];
- const words = new Array(64).fill(0);
- for (let offset = 0; offset < bytes.length; offset += 64) {
- for (let index = 0; index < 16; index += 1) {
- const cursor = offset + index * 4;
- words[index] = (bytes[cursor] << 24 | bytes[cursor + 1] << 16 | bytes[cursor + 2] << 8 | bytes[cursor + 3]) >>> 0;
- }
- for (let index = 16; index < 64; index += 1) {
- const previous15 = words[index - 15];
- const previous2 = words[index - 2];
- const sigma0 = rotateRight(previous15, 7) ^ rotateRight(previous15, 18) ^ previous15 >>> 3;
- const sigma1 = rotateRight(previous2, 17) ^ rotateRight(previous2, 19) ^ previous2 >>> 10;
- words[index] = words[index - 16] + sigma0 + words[index - 7] + sigma1 >>> 0;
- }
- let [a, b, c, d, e, f, g, h] = hash;
- for (let index = 0; index < 64; index += 1) {
- const sum1 = rotateRight(e, 6) ^ rotateRight(e, 11) ^ rotateRight(e, 25);
- const choose = e & f ^ ~e & g;
- const temporary1 = h + sum1 + choose + SHA256_CONSTANTS[index] + words[index] >>> 0;
- const sum0 = rotateRight(a, 2) ^ rotateRight(a, 13) ^ rotateRight(a, 22);
- const majority = a & b ^ a & c ^ b & c;
- const temporary2 = sum0 + majority >>> 0;
- h = g;
- g = f;
- f = e;
- e = d + temporary1 >>> 0;
- d = c;
- c = b;
- b = a;
- a = temporary1 + temporary2 >>> 0;
- }
- hash[0] = hash[0] + a >>> 0;
- hash[1] = hash[1] + b >>> 0;
- hash[2] = hash[2] + c >>> 0;
- hash[3] = hash[3] + d >>> 0;
- hash[4] = hash[4] + e >>> 0;
- hash[5] = hash[5] + f >>> 0;
- hash[6] = hash[6] + g >>> 0;
- hash[7] = hash[7] + h >>> 0;
- }
- return hash.map((word) => word.toString(16).padStart(8, "0")).join("");
-};
-var fingerprint = async (source) => sha256(source);
-
-// ../../packages/config-editor/src/gateway.ts
-var MISSING_FILE_FINGERPRINT = "missing";
-var isMissingFileError = (error) => {
- const message = error instanceof Error ? error.message : String(error);
- return /(?:\b404\b|not[ -]?found|missing)/i.test(message);
-};
-var withMissingFileFallback = (gateway2, suffixes = [".scroll_template", ".default"]) => {
- const resolvedPaths = /* @__PURE__ */ new Map();
- const fallbackSuffixes = typeof suffixes === "string" ? [suffixes] : suffixes;
- return {
- async load(path) {
- try {
- const content = await gateway2.load(path);
- resolvedPaths.set(path, path);
- return content;
- } catch (error) {
- if (!isMissingFileError(error)) throw error;
- const resolved = resolvedPaths.get(path);
- if (resolved && resolved !== path) {
- try {
- return await gateway2.load(resolved);
- } catch (fallbackError) {
- if (!isMissingFileError(fallbackError)) throw fallbackError;
- }
- }
- for (const suffix of fallbackSuffixes) {
- const fallback = `${path}${suffix}`;
- if (fallback === resolved) continue;
- try {
- const content = await gateway2.load(fallback);
- resolvedPaths.set(path, fallback);
- return content;
- } catch (fallbackError) {
- if (!isMissingFileError(fallbackError)) throw fallbackError;
- }
- }
- throw new Error(`Missing configuration file: ${path}`);
- }
- },
- async save(path, content, expectedFingerprint) {
- const resolved = resolvedPaths.get(path) ?? path;
- if (resolved !== path) {
- const result = await gateway2.save(
- path,
- content,
- MISSING_FILE_FINGERPRINT
- );
- if (result.status === "saved") resolvedPaths.set(path, path);
- return result;
- }
- return await gateway2.save(resolved, content, expectedFingerprint);
- }
- };
-};
-var schemaForPath = (manifest, path) => {
- const schema = path ? manifest.files.find((candidate) => candidate.path === path) : manifest.files[0];
- if (!schema) throw new TypeError(path ? `Manifest has no file "${path}".` : "Manifest has no files.");
- return schema;
-};
-var loadEditor = async (gateway2, manifest, path) => {
- const schema = schemaForPath(manifest, path);
- const adapter = createAdapterRegistry().get(schema.format);
- if (!adapter) throw new TypeError(`No adapter registered for "${schema.format}".`);
- const source = await gateway2.load(schema.path);
- return ConfigEditorStore.fromLoadedFile(schema, adapter.parse(source));
-};
-var saveSelectedFile = async (store, gateway2) => {
- if (store.snapshot().issues.some((issue2) => issue2.severity === "error")) {
- throw new Error("Configuration has validation errors and cannot be saved.");
- }
- const content = store.serializeSelectedFile();
- const serializedIssues = store.validateSerializedSource(content);
- if (serializedIssues.some((issue2) => issue2.severity === "error")) {
- throw new Error("Serialized configuration failed validation.");
- }
- const expectedFingerprint = await fingerprint(store.loadedSource());
- const result = await gateway2.save(store.schema.path, content, expectedFingerprint);
- if (result.status === "conflict") return result;
- const contentFingerprint = await fingerprint(content);
- if (result.fingerprint !== contentFingerprint) {
- throw new Error("Save verification failed: gateway fingerprint does not match content.");
- }
- const remote = await gateway2.load(store.schema.path);
- const remoteFingerprint = await fingerprint(remote);
- if (remoteFingerprint !== result.fingerprint || remote !== content) {
- throw new Error("Save verification failed: reloaded content differs from the saved content.");
- }
- store.acceptSavedSource(remote);
- return result;
-};
-
-// ../../packages/component/dist/index.js
-var import_ui = __toESM(require_ui(), 1);
-var import_ui2 = __toESM(require_ui(), 1);
-var import_utils = __toESM(require_utils(), 1);
-var import_ui3 = __toESM(require_ui(), 1);
-var createAsyncBridge = (onSettled = () => void 0, trace = () => void 0) => {
- const pending = /* @__PURE__ */ new Map();
- const early = /* @__PURE__ */ new Map();
- const settle = (operation, result) => {
- if (result.tag === "ok") operation.resolve(result.val);
- else operation.reject(new Error(String(result.val)));
- onSettled();
- };
- const complete = (id, result) => {
- trace(`Async callback received for id: ${id} with result: ${result.tag}`);
- const operation = pending.get(id);
- if (!operation) {
- early.set(id, result);
- return;
- }
- pending.delete(id);
- settle(operation, result);
- };
- const wrap = (fn) => (...args) => new Promise((resolve, reject) => {
- const id = fn(...args);
- const operation = {
- resolve: (value) => resolve(value),
- reject
- };
- const earlyResult = early.get(id);
- if (earlyResult) {
- early.delete(id);
- settle(operation, earlyResult);
- return;
- }
- pending.set(id, operation);
- });
- return { complete, wrap };
-};
-var lowerPropertyValue = (value) => value === void 0 || value === null ? void 0 : String(value);
-var callbackMap = {};
-function emit(nodeid, event, e) {
- (0, import_ui2.log)(`Emit called for nodeid: ${nodeid}, event: ${event}`);
- const callbacks = callbackMap[nodeid];
- const result = callbacks?.[event]?.(e);
- if (result instanceof Promise) {
- result.then(() => (0, import_ui2.rerender)());
- }
-}
-var registerHooks = (id, fnresult) => {
- switch (true) {
- case !!fnresult.init:
- (0, import_ui2.setHook)(id, "init");
- callbackMap[id] = {
- ...callbackMap[id],
- init: fnresult.init
- };
- break;
- }
-};
-var createDFunc = (dfunc2) => {
- return (tag, props, ...children) => {
- children = children.flat();
- if (typeof tag !== "string") {
- if (typeof tag === "function") {
- const fnresult = tag(props);
- if (fnresult?.view) {
- const id3 = fnresult.view(props);
- registerHooks(id3, fnresult);
- return id3;
- } else {
- return tag(props);
- }
- }
- const id2 = tag.view(props);
- registerHooks(id2, tag);
- return id2;
- }
- const ps = { prop: [], on: [] };
- const cbObj = {};
- if (props) {
- for (const [key, value] of Object.entries(props)) {
- if (value instanceof Function) {
- const eventKey = key.startsWith("on") ? key.slice(2).toLowerCase() : key;
- cbObj[eventKey] = value;
- ps.on.push(eventKey);
- } else {
- const loweredValue = lowerPropertyValue(value);
- if (loweredValue === void 0) continue;
- if (typeof value === "boolean") {
- ps.prop.push({ key, value: loweredValue });
- continue;
- }
- ps.prop.push({ key, value: loweredValue });
- }
- }
- }
- const id = dfunc2(
- tag,
- ps,
- children.filter((c) => typeof c !== "boolean").map((c) => String(c))
- );
- callbackMap[id] = {
- ...callbackMap[id],
- ...cbObj
- };
- return id;
- };
-};
-var asyncBridge = createAsyncBridge(
- () => (0, import_ui2.rerender)(),
- (message) => (0, import_ui2.log)(message)
-);
-var asyncCallback = asyncBridge.complete;
-var rawAsyncToPromise = asyncBridge.wrap;
-var createComponent = (j) => ({
- init: (ctx) => j(ctx),
- emit,
- asyncComplete: asyncCallback
-});
-var d2 = createDFunc(import_ui.d);
-
-// ../../packages/config-editor/src/copy.ts
-var copy = {
- appTitle: "Server configuration",
- loading: "Loading configuration\u2026",
- formTab: "Form",
- rawTab: "Raw",
- filesHeading: "Configuration files",
- inspectorHeading: "Changes & validation",
- noChanges: "No pending changes",
- rawChanged: "Raw configuration changed",
- unknownKeys: "Unknown keys: Preserved",
- restartRequired: "Restart required",
- stopBeforeSave: "Stop the server before saving configuration files that it may rewrite.",
- noRestartRequired: "No restart required",
- save: "Save changes",
- saving: "Saving\u2026",
- saved: "Saved and verified",
- conflict: "Remote file changed. Reload before saving.",
- rawLabel: "Raw configuration",
- validationHeading: "Validation",
- valid: "Configuration is valid",
- secretChanged: "Secret changed",
- fileFormat: "Format"
-};
-
-// ../../packages/config-editor/src/styles.ts
-var EDITOR_STYLES = `
-:host {
- display: block;
- width: 100%;
- --druid-bg: #07100d;
- --druid-panel: #101b17;
- --druid-panel-raised: #15231d;
- --druid-border: #294137;
- --druid-border-strong: #3f6655;
- --druid-text: #f4f7f5;
- --druid-muted: #9eb0a8;
- --druid-accent: #a8ef9c;
- --druid-accent-ink: #10210f;
- --druid-warning: #f0c96b;
- --druid-error: #ff8b7f;
- color: var(--druid-text);
- font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
- color-scheme: dark;
-}
-* { box-sizing: border-box; }
-button, input, select, textarea { font: inherit; }
-button, input, select, textarea, a { outline: none; }
-button:focus-visible, input:focus-visible, select:focus-visible, textarea:focus-visible, a:focus-visible {
- box-shadow: 0 0 0 3px rgba(168, 239, 156, .28);
- border-color: var(--druid-accent);
-}
-.config-editor { min-height: 640px; background: radial-gradient(circle at 75% 0%, #173326 0, transparent 32%), var(--druid-bg); color: var(--druid-text); }
-.editor-header { min-height: 76px; display: flex; align-items: center; justify-content: space-between; gap: 20px; padding: 16px 22px; border-bottom: 1px solid var(--druid-border); }
-.eyebrow { color: var(--druid-accent); font-size: 11px; font-weight: 800; letter-spacing: .14em; text-transform: uppercase; }
-.editor-title { margin: 3px 0 0; font-size: clamp(20px, 2.4vw, 30px); line-height: 1.1; }
-.server-version { color: var(--druid-muted); font-size: 13px; }
-.editor-grid { display: grid; grid-template-columns: minmax(210px, 250px) minmax(420px, 1fr) minmax(260px, 320px); min-height: 564px; }
-.file-rail, .inspector { background: rgba(11, 21, 17, .8); }
-.file-rail { padding: 18px 14px; border-right: 1px solid var(--druid-border); }
-.rail-heading, .inspector h2 { margin: 0 0 12px; font-size: 12px; color: var(--druid-muted); letter-spacing: .08em; text-transform: uppercase; }
-.file-list { display: grid; gap: 8px; }
-.file-button { width: 100%; min-height: 52px; display: grid; gap: 3px; padding: 10px 12px; text-align: left; border: 1px solid transparent; border-radius: 10px; background: transparent; color: var(--druid-text); cursor: pointer; }
-.file-button:hover { background: var(--druid-panel); }
-.file-button[aria-current="true"] { border-color: var(--druid-border-strong); background: var(--druid-panel-raised); }
-.file-path { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 700; }
-.file-format { color: var(--druid-muted); font-size: 12px; text-transform: uppercase; }
-.editor-main { min-width: 0; padding: 18px 22px 92px; }
-.tabs { display: inline-flex; gap: 4px; padding: 4px; margin-bottom: 18px; border: 1px solid var(--druid-border); border-radius: 10px; background: #0b1511; }
-.tab { min-height: 40px; min-width: 92px; border: 0; border-radius: 7px; background: transparent; color: var(--druid-muted); cursor: pointer; }
-.tab[aria-selected="true"] { background: var(--druid-panel-raised); color: var(--druid-text); }
-.form-editor { display: grid; gap: 18px; }
-.field-section { min-width: 0; margin: 0; padding: 18px; border: 1px solid var(--druid-border); border-radius: 14px; background: rgba(16, 27, 23, .84); }
-.field-section legend { padding: 0 8px; color: var(--druid-accent); font-weight: 800; }
-.section-description { margin: 0 0 16px; color: var(--druid-muted); font-size: 13px; }
-.field-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; }
-.field-control { min-width: 0; display: grid; align-content: start; gap: 7px; }
-.field-control label { font-size: 13px; font-weight: 750; }
-.field-description { min-height: 34px; margin: 0; color: var(--druid-muted); font-size: 12px; line-height: 1.45; }
-.field-input { width: 100%; min-height: 44px; padding: 10px 12px; border: 1px solid var(--druid-border); border-radius: 9px; background: #09130f; color: var(--druid-text); }
-.boolean-control { min-height: 44px; display: flex; align-items: center; gap: 10px; padding: 8px 11px; border: 1px solid var(--druid-border); border-radius: 9px; background: #09130f; }
-.boolean-control input { width: 20px; height: 20px; accent-color: var(--druid-accent); }
-.field-meta { display: flex; flex-wrap: wrap; gap: 8px; color: var(--druid-muted); font-size: 11px; }
-.restart-chip { color: var(--druid-warning); }
-.field-error { margin: 0; color: var(--druid-error); font-size: 12px; }
-.raw-editor { display: grid; gap: 10px; }
-.raw-textarea { width: 100%; min-height: 440px; resize: vertical; padding: 16px; border: 1px solid var(--druid-border); border-radius: 12px; background: #050b08; color: #dce8e1; font: 13px/1.55 "Cascadia Code", "SFMono-Regular", Consolas, monospace; tab-size: 2; }
-.inspector { display: flex; flex-direction: column; gap: 18px; padding: 18px; border-left: 1px solid var(--druid-border); }
-.status-card { padding: 14px; border: 1px solid var(--druid-border); border-radius: 12px; background: var(--druid-panel); }
-.status-card p { margin: 4px 0; color: var(--druid-muted); font-size: 13px; }
-.change-list, .issue-list { display: grid; gap: 8px; margin: 0; padding: 0; list-style: none; }
-.change-item { padding: 10px; border: 1px solid var(--druid-border); border-radius: 9px; background: #0b1511; font-size: 13px; }
-.change-value { display: block; margin-top: 4px; color: var(--druid-muted); word-break: break-word; }
-.issue-item { color: var(--druid-error); font-size: 12px; }
-.action-bar { position: sticky; bottom: 0; display: flex; align-items: center; justify-content: space-between; gap: 16px; margin: auto 0 0; padding-top: 14px; background: linear-gradient(transparent, rgba(11, 21, 17, .98) 32%); }
-.save-button { min-height: 46px; padding: 0 18px; border: 0; border-radius: 9px; background: var(--druid-accent); color: var(--druid-accent-ink); font-weight: 850; cursor: pointer; }
-.save-button:disabled { opacity: .45; cursor: not-allowed; }
-.action-status { color: var(--druid-muted); font-size: 12px; }
-.error-shell { margin: 20px; padding: 18px; border: 1px solid var(--druid-error); border-radius: 12px; background: #28120f; color: #ffd8d2; }
-@media (max-width: 900px) {
- .editor-grid { grid-template-columns: 190px minmax(0, 1fr); }
- .inspector { grid-column: 1 / -1; border-left: 0; border-top: 1px solid var(--druid-border); }
-}
-@media (max-width: 560px) {
- .config-editor { min-height: 100%; }
- .editor-header { align-items: flex-start; padding: 14px 16px; }
- .editor-grid { display: block; }
- .file-rail { padding: 12px 16px; border-right: 0; border-bottom: 1px solid var(--druid-border); overflow-x: auto; }
- .file-list { display: flex; width: max-content; }
- .file-button { width: 190px; }
- .editor-main { padding: 16px 16px 28px; }
- .tabs { display: flex; }
- .tab { flex: 1; }
- .field-section { padding: 14px; }
- .field-grid { grid-template-columns: 1fr; }
- .raw-textarea { min-height: 360px; }
- .inspector { padding: 16px; }
- .action-bar { position: static; }
- .save-button { width: 100%; }
-}
-@media (prefers-reduced-motion: reduce) {
- *, *::before, *::after { scroll-behavior: auto !important; transition: none !important; animation: none !important; }
-}
-`;
-
-// ../../packages/component/dist/jsx-runtime.js
-function jsx(type, props) {
- const { children, ...rest } = props || {};
- if (children !== void 0) {
- return d2(type, rest, children);
- }
- return d2(type, rest);
-}
-var jsxs = jsx;
-var Fragment = Symbol.for("react.fragment");
-
-// ../../packages/config-editor/src/components/ActionBar.tsx
-var ActionBar = ({ dirty, invalid, saving, status, onSave }) => /* @__PURE__ */ jsxs("div", { class: "action-bar", children: [
- /* @__PURE__ */ jsx("span", { class: "action-status", "aria-live": "polite", children: status }),
- /* @__PURE__ */ jsx(
- "button",
- {
- type: "button",
- class: "save-button",
- disabled: !dirty || invalid || saving,
- onClick: () => onSave(),
- children: saving ? copy.saving : copy.save
- }
- )
-] });
-
-// ../../packages/config-editor/src/components/FileRail.tsx
-var FileRail = ({ manifest, selectedPath, onSelect }) => /* @__PURE__ */ jsxs("nav", { class: "file-rail", "aria-label": copy.filesHeading, children: [
- /* @__PURE__ */ jsx("h2", { class: "rail-heading", children: copy.filesHeading }),
- /* @__PURE__ */ jsx("div", { class: "file-list", children: manifest.files.map((file) => /* @__PURE__ */ jsxs(
- "button",
- {
- type: "button",
- class: "file-button",
- "aria-current": file.path === selectedPath ? "true" : "false",
- onClick: () => onSelect(file.path),
- children: [
- /* @__PURE__ */ jsx("span", { class: "file-path", children: file.label }),
- /* @__PURE__ */ jsx("span", { class: "file-format", children: file.format })
- ]
- }
- )) })
-] });
-
-// ../../packages/config-editor/src/components/FieldControl.tsx
-var FieldControl = ({ field, state, onChange }) => {
- const inputId = `config-field-${field.key.replace(/[^a-zA-Z0-9_-]/g, "-")}`;
- const descriptionId = `${inputId}-description`;
- const input = field.type === "enum" ? /* @__PURE__ */ jsx(
- "select",
- {
- id: inputId,
- class: "field-input",
- "aria-label": field.label,
- "aria-describedby": descriptionId,
- "aria-invalid": state.issues.length > 0 ? "true" : "false",
- value: state.displayValue,
- onChange: (event) => onChange(field.key, event.value()),
- children: (field.values ?? []).map((value) => /* @__PURE__ */ jsx("option", { value, selected: state.displayValue === value, children: value }))
- }
- ) : field.type === "boolean" ? /* @__PURE__ */ jsxs("span", { class: "boolean-control", children: [
- /* @__PURE__ */ jsx(
- "input",
- {
- id: inputId,
- type: "checkbox",
- "aria-label": field.label,
- "aria-describedby": descriptionId,
- checked: state.displayValue === "true",
- onChange: (event) => onChange(field.key, event.checked() ? "true" : "false")
- }
- ),
- /* @__PURE__ */ jsx("span", { children: state.displayValue === "true" ? "Enabled" : "Disabled" })
- ] }) : /* @__PURE__ */ jsx(
- "input",
- {
- id: inputId,
- class: "field-input",
- "aria-label": field.label,
- "aria-describedby": descriptionId,
- type: field.type === "secret" || field.sensitive ? "password" : field.type === "integer" || field.type === "number" ? "number" : "text",
- step: field.type === "integer" ? "1" : field.type === "number" ? "any" : void 0,
- min: field.min,
- max: field.max,
- value: state.displayValue,
- "aria-invalid": state.issues.length > 0 ? "true" : "false",
- autocomplete: field.type === "secret" || field.sensitive ? "new-password" : "off",
- onInput: (event) => onChange(field.key, event.value())
- }
- );
- return /* @__PURE__ */ jsxs("div", { class: "field-control", children: [
- /* @__PURE__ */ jsx("label", { for: inputId, children: field.label }),
- /* @__PURE__ */ jsx("p", { id: descriptionId, class: "field-description", children: field.description }),
- input,
- /* @__PURE__ */ jsxs("div", { class: "field-meta", children: [
- field.restartRequired ? /* @__PURE__ */ jsx("span", { class: "restart-chip", children: copy.restartRequired }) : false,
- /* @__PURE__ */ jsx("span", { children: field.key })
- ] }),
- state.issues.map((issue2) => /* @__PURE__ */ jsx("p", { class: "field-error", role: "alert", children: issue2.message }))
- ] });
-};
-
-// ../../packages/config-editor/src/components/FormEditor.tsx
-var FormEditor = ({ schema, snapshot, onChange }) => /* @__PURE__ */ jsx("div", { class: "form-editor", children: schema.sections.map((section) => /* @__PURE__ */ jsxs("fieldset", { class: "field-section", children: [
- /* @__PURE__ */ jsx("legend", { children: section.label }),
- section.description ? /* @__PURE__ */ jsx("p", { class: "section-description", children: section.description }) : false,
- /* @__PURE__ */ jsx("div", { class: "field-grid", children: section.fields.map((field) => {
- const state = snapshot.fields[field.key];
- return state ? /* @__PURE__ */ jsx(FieldControl, { field, state, onChange }) : false;
- }) })
-] })) });
-
-// ../../packages/config-editor/src/components/Inspector.tsx
-var printable = (value) => value === void 0 ? "Not set" : value === null ? "Protected" : String(value);
-var Inspector = ({ snapshot }) => /* @__PURE__ */ jsxs("aside", { class: "inspector", "aria-label": copy.inspectorHeading, children: [
- /* @__PURE__ */ jsxs("section", { children: [
- /* @__PURE__ */ jsx("h2", { children: copy.inspectorHeading }),
- snapshot.unstructuredChanges ? /* @__PURE__ */ jsx("div", { class: "status-card", children: /* @__PURE__ */ jsx("p", { children: copy.rawChanged }) }) : snapshot.changes.length === 0 ? /* @__PURE__ */ jsx("div", { class: "status-card", children: /* @__PURE__ */ jsx("p", { children: copy.noChanges }) }) : /* @__PURE__ */ jsx("ul", { class: "change-list", children: snapshot.changes.map((change) => /* @__PURE__ */ jsxs("li", { class: "change-item", children: [
- /* @__PURE__ */ jsx("strong", { children: change.label }),
- /* @__PURE__ */ jsx("span", { class: "change-value", children: change.sensitive ? copy.secretChanged : `${printable(change.before)} \u2192 ${printable(change.after)}` })
- ] })) })
- ] }),
- /* @__PURE__ */ jsxs("section", { class: "status-card", children: [
- /* @__PURE__ */ jsx("h2", { children: copy.validationHeading }),
- snapshot.issues.length === 0 ? /* @__PURE__ */ jsx("p", { children: copy.valid }) : /* @__PURE__ */ jsx("ul", { class: "issue-list", "aria-live": "polite", children: snapshot.issues.map((issue2) => /* @__PURE__ */ jsx("li", { class: "issue-item", children: issue2.message })) }),
- /* @__PURE__ */ jsx("p", { children: copy.unknownKeys }),
- /* @__PURE__ */ jsx("p", { children: snapshot.restartRequired ? copy.restartRequired : copy.noRestartRequired }),
- snapshot.restartRequired ? /* @__PURE__ */ jsx("p", { children: copy.stopBeforeSave }) : false
- ] })
-] });
-
-// ../../packages/config-editor/src/components/RawEditor.tsx
-var RawEditor = ({ source, onChange }) => /* @__PURE__ */ jsxs("div", { class: "raw-editor", children: [
- /* @__PURE__ */ jsx("label", { for: "config-raw-source", children: copy.rawLabel }),
- /* @__PURE__ */ jsx(
- "textarea",
- {
- id: "config-raw-source",
- class: "raw-textarea",
- "aria-label": copy.rawLabel,
- spellcheck: "false",
- value: source,
- onInput: (event) => onChange(event.value())
- }
- )
-] });
-
-// ../../packages/config-editor/src/components/EditorApp.tsx
-var EditorApp = ({
- manifest,
- store,
- snapshot,
- mode,
- saving,
- status,
- onMode,
- onSelect,
- onField,
- onRaw,
- onSave
-}) => /* @__PURE__ */ jsxs("div", { class: "config-editor", children: [
- /* @__PURE__ */ jsx("style", { children: EDITOR_STYLES }),
- /* @__PURE__ */ jsxs("header", { class: "editor-header", children: [
- /* @__PURE__ */ jsxs("div", { children: [
- /* @__PURE__ */ jsx("div", { class: "eyebrow", children: "Druid Admin UI" }),
- /* @__PURE__ */ jsx("h1", { class: "editor-title", children: manifest.server.displayName })
- ] }),
- /* @__PURE__ */ jsx("div", { class: "server-version", children: manifest.server.appVersion ? `Version ${manifest.server.appVersion}` : copy.appTitle })
- ] }),
- /* @__PURE__ */ jsxs("div", { class: "editor-grid", children: [
- /* @__PURE__ */ jsx(
- FileRail,
- {
- manifest,
- selectedPath: store.schema.path,
- onSelect
- }
- ),
- /* @__PURE__ */ jsxs("main", { class: "editor-main", children: [
- /* @__PURE__ */ jsxs("div", { class: "tabs", role: "tablist", "aria-label": "Editor mode", children: [
- /* @__PURE__ */ jsx(
- "button",
- {
- type: "button",
- class: "tab",
- role: "tab",
- disabled: store.schema.sections.length === 0,
- "aria-disabled": store.schema.sections.length === 0 ? "true" : "false",
- "aria-selected": mode === "form" ? "true" : "false",
- onClick: () => onMode("form"),
- children: copy.formTab
- }
- ),
- /* @__PURE__ */ jsx(
- "button",
- {
- type: "button",
- class: "tab",
- role: "tab",
- "aria-selected": mode === "raw" ? "true" : "false",
- onClick: () => onMode("raw"),
- children: copy.rawTab
- }
- )
- ] }),
- mode === "form" ? /* @__PURE__ */ jsx(FormEditor, { schema: store.schema, snapshot, onChange: onField }) : /* @__PURE__ */ jsx(RawEditor, { source: store.serializeForDisplay(), onChange: onRaw }),
- /* @__PURE__ */ jsx(
- ActionBar,
- {
- dirty: snapshot.dirty,
- invalid: snapshot.issues.some((issue2) => issue2.severity === "error"),
- saving,
- status,
- onSave
- }
- )
- ] }),
- /* @__PURE__ */ jsx(Inspector, { snapshot })
- ] })
-] });
-var createConfigEditorComponent = ({
- manifestPath,
- gateway: gateway2
-}) => {
- let manifest;
- const stores = /* @__PURE__ */ new Map();
- let selectedPath = "";
- let mode = "form";
- let loading = false;
- let saving = false;
- let status = "";
- let errorMessage = "";
- const selectFile = async (path) => {
- if (!manifest) return;
- const previousPath = selectedPath;
- status = copy.loading;
- (0, import_ui3.rerender)();
- try {
- let store = stores.get(path);
- if (!store) {
- store = await loadEditor(gateway2, manifest, path);
- stores.set(path, store);
- }
- selectedPath = path;
- mode = store.schema.sections.length === 0 ? "raw" : "form";
- status = "";
- } catch (error) {
- selectedPath = previousPath;
- status = error instanceof Error ? error.message : String(error);
- if (!stores.has(previousPath)) throw error;
- } finally {
- (0, import_ui3.rerender)();
- }
- };
- const initialise = async () => {
- loading = true;
- try {
- manifest = validateManifest(JSON.parse(await gateway2.load(manifestPath)));
- selectedPath = manifest.files[0].path;
- await selectFile(selectedPath);
- } catch (error) {
- errorMessage = error instanceof Error ? error.message : String(error);
- } finally {
- loading = false;
- (0, import_ui3.rerender)();
- }
- };
- const save = async () => {
- const store = stores.get(selectedPath);
- if (!store) return;
- saving = true;
- status = copy.saving;
- try {
- const result = await saveSelectedFile(store, gateway2);
- status = result.status === "saved" ? copy.saved : copy.conflict;
- } catch (error) {
- status = error instanceof Error ? error.message : String(error);
- } finally {
- saving = false;
- (0, import_ui3.rerender)();
- }
- };
- return createComponent(() => {
- if (!loading && !manifest && !errorMessage) void initialise();
- if (errorMessage) {
- return /* @__PURE__ */ jsxs("div", { class: "config-editor", children: [
- /* @__PURE__ */ jsx("style", { children: EDITOR_STYLES }),
- /* @__PURE__ */ jsx("div", { class: "error-shell", role: "alert", children: errorMessage })
- ] });
- }
- const store = stores.get(selectedPath);
- if (!manifest || !store) {
- return /* @__PURE__ */ jsxs("div", { class: "config-editor", children: [
- /* @__PURE__ */ jsx("style", { children: EDITOR_STYLES }),
- /* @__PURE__ */ jsx("div", { class: "status-card", "aria-live": "polite", children: copy.loading })
- ] });
- }
- const snapshot = store.snapshot();
- return /* @__PURE__ */ jsx(
- EditorApp,
- {
- manifest,
- store,
- snapshot,
- mode,
- saving,
- status,
- onMode: (nextMode) => {
- mode = nextMode;
- },
- onSelect: selectFile,
- onField: (key, value) => {
- store.setDisplayValue(key, value);
- status = "";
- },
- onRaw: (source) => {
- store.replaceWorkingSource(source);
- status = "";
- },
- onSave: save
- }
- );
- });
-};
-
-// ../../packages/plattform/dist/index.js
-var import_plattform = __toESM(require_plattform(), 1);
-var request = rawAsyncToPromise(import_plattform.request);
-var loadFileFromDeployment = rawAsyncToPromise(
- import_plattform.loadFileFromDeployment
-);
-var saveFileToDeployment = rawAsyncToPromise(import_plattform.saveFileToDeployment);
-var saveFileToDeploymentIfMatch = rawAsyncToPromise(
- import_plattform.saveFileToDeploymentIfMatch
-);
-
-// src/app.tsx
-var gateway = withMissingFileFallback({
- async load(path) {
- return await loadFileFromDeployment(path);
- },
- async save(path, content, expectedFingerprint) {
- return JSON.parse(
- await saveFileToDeploymentIfMatch(path, content, expectedFingerprint)
- );
- }
-});
-var component = createConfigEditorComponent({
- manifestPath: "private/config-editor.manifest.json",
- gateway
-});
-export {
- component
-};
diff --git a/examples/config-editor-plattform/public/app.bundled.js b/examples/config-editor-plattform/public/app.bundled.js
deleted file mode 100644
index 6359b48..0000000
--- a/examples/config-editor-plattform/public/app.bundled.js
+++ /dev/null
@@ -1,2463 +0,0 @@
-// ../../packages/config-editor/src/manifest.ts
-var CONFIG_FORMATS = /* @__PURE__ */ new Set([
- "java-properties",
- "ini",
- "unreal-ini",
- "key-value",
- "json",
- "xml-properties",
- "raw"
-]);
-var FIELD_TYPES = /* @__PURE__ */ new Set([
- "string",
- "integer",
- "number",
- "boolean",
- "enum",
- "secret"
-]);
-var DOTTED_VERSION = /^\d+(?:\.\d+)*$/;
-var URL_SCHEME = /^[a-z][a-z\d+.-]*:/i;
-var WINDOWS_DRIVE = /^[a-z]:\//i;
-var fail = (message) => {
- throw new TypeError(`Invalid config editor manifest: ${message}`);
-};
-var objectValue = (value, name) => {
- if (typeof value !== "object" || value === null || Array.isArray(value)) {
- return fail(`${name} must be an object.`);
- }
- return value;
-};
-var stringValue = (value, name) => {
- if (typeof value !== "string" || value.trim().length === 0) {
- return fail(`${name} must be a non-empty string.`);
- }
- return value;
-};
-var optionalString = (value, name) => {
- if (value === void 0) return void 0;
- return stringValue(value, name);
-};
-var optionalBoolean = (value, name) => {
- if (value === void 0) return void 0;
- if (typeof value !== "boolean") return fail(`${name} must be a boolean.`);
- return value;
-};
-var optionalNumber = (value, name) => {
- if (value === void 0) return void 0;
- if (typeof value !== "number" || !Number.isFinite(value)) {
- return fail(`${name} must be a finite number.`);
- }
- return value;
-};
-var arrayValue = (value, name) => {
- if (!Array.isArray(value)) return fail(`${name} must be an array.`);
- return value;
-};
-var assertVersion = (value, name) => {
- if (!DOTTED_VERSION.test(value)) {
- fail(`${name} must be a dotted numeric version.`);
- }
-};
-var assertSafePath = (path) => {
- const segments = path.split("/");
- const unsafe = path.startsWith("/") || path.includes("\\") || path.includes("\0") || URL_SCHEME.test(path) || WINDOWS_DRIVE.test(path) || segments.some((segment) => segment === "" || segment === "." || segment === "..");
- if (unsafe) fail(`file path "${path}" must be a safe relative path.`);
-};
-var validateServer = (value) => {
- const server = objectValue(value, "server");
- return {
- family: stringValue(server["family"], "server.family"),
- displayName: stringValue(server["displayName"], "server.displayName"),
- ...server["appVersion"] === void 0 ? {} : { appVersion: stringValue(server["appVersion"], "server.appVersion") }
- };
-};
-var validateField = (value, location) => {
- const field = objectValue(value, location);
- const type = stringValue(field["type"], `${location}.type`);
- if (!FIELD_TYPES.has(type)) {
- fail(`${location}.type "${type}" is unsupported.`);
- }
- const min = optionalNumber(field["min"], `${location}.min`);
- const max = optionalNumber(field["max"], `${location}.max`);
- if (min !== void 0 && max !== void 0 && min > max) {
- fail(`${location} minimum cannot exceed its maximum.`);
- }
- if ((min !== void 0 || max !== void 0) && type !== "integer" && type !== "number") {
- fail(`${location} numeric ranges require an integer or number field.`);
- }
- let values;
- if (field["values"] !== void 0) {
- values = arrayValue(field["values"], `${location}.values`).map(
- (entry, index) => stringValue(entry, `${location}.values[${index}]`)
- );
- if (new Set(values).size !== values.length) {
- fail(`${location}.values contains duplicate enum values.`);
- }
- }
- if (type === "enum" && (!values || values.length === 0)) {
- fail(`${location} enum values must contain at least one value.`);
- }
- if (type !== "enum" && values !== void 0) {
- fail(`${location}.values is only valid for enum fields.`);
- }
- const pattern = optionalString(field["pattern"], `${location}.pattern`);
- if (pattern !== void 0) {
- try {
- new RegExp(pattern);
- } catch {
- fail(`${location}.pattern must be a valid regular expression.`);
- }
- }
- const since = optionalString(field["since"], `${location}.since`);
- const until = optionalString(field["until"], `${location}.until`);
- if (since !== void 0) assertVersion(since, `${location}.since`);
- if (until !== void 0) assertVersion(until, `${location}.until`);
- if (since !== void 0 && until !== void 0 && compareVersions(since, until) > 0) {
- fail(`${location}.since cannot be later than .until.`);
- }
- const rawDefaultValue = field["defaultValue"];
- if (rawDefaultValue !== void 0 && rawDefaultValue !== null && typeof rawDefaultValue !== "string" && typeof rawDefaultValue !== "number" && typeof rawDefaultValue !== "boolean") {
- fail(`${location}.defaultValue must be a scalar value.`);
- }
- const result = {
- key: stringValue(field["key"], `${location}.key`),
- label: stringValue(field["label"], `${location}.label`),
- description: stringValue(field["description"], `${location}.description`),
- documentation: stringValue(
- field["documentation"],
- `${location}.documentation`
- ),
- type
- };
- if (values !== void 0) result.values = values;
- if (min !== void 0) result.min = min;
- if (max !== void 0) result.max = max;
- if (pattern !== void 0) result.pattern = pattern;
- if (rawDefaultValue !== void 0) {
- result.defaultValue = rawDefaultValue;
- }
- if (field["sensitive"] !== void 0) {
- result.sensitive = optionalBoolean(field["sensitive"], `${location}.sensitive`);
- }
- if (field["restartRequired"] !== void 0) {
- result.restartRequired = optionalBoolean(
- field["restartRequired"],
- `${location}.restartRequired`
- );
- }
- if (since !== void 0) result.since = since;
- if (until !== void 0) result.until = until;
- return result;
-};
-var validateSection = (value, location) => {
- const section = objectValue(value, location);
- const fields = arrayValue(section["fields"], `${location}.fields`).map(
- (field, index) => validateField(field, `${location}.fields[${index}]`)
- );
- if (fields.length === 0) fail(`${location}.fields must not be empty.`);
- const result = {
- id: stringValue(section["id"], `${location}.id`),
- label: stringValue(section["label"], `${location}.label`),
- fields
- };
- if (section["description"] !== void 0) {
- result.description = stringValue(
- section["description"],
- `${location}.description`
- );
- }
- return result;
-};
-var validateFile = (value, location) => {
- const file = objectValue(value, location);
- const path = stringValue(file["path"], `${location}.path`);
- assertSafePath(path);
- const format = stringValue(file["format"], `${location}.format`);
- if (!CONFIG_FORMATS.has(format)) {
- fail(`${location}.format "${format}" is unsupported.`);
- }
- const sections = arrayValue(file["sections"], `${location}.sections`).map(
- (section, index) => validateSection(section, `${location}.sections[${index}]`)
- );
- const sectionIds = /* @__PURE__ */ new Set();
- const fieldKeys = /* @__PURE__ */ new Set();
- for (const section of sections) {
- if (sectionIds.has(section.id)) {
- fail(`${location} contains duplicate section id "${section.id}".`);
- }
- sectionIds.add(section.id);
- for (const field of section.fields) {
- if (fieldKeys.has(field.key)) {
- fail(`${location} contains duplicate field key "${field.key}".`);
- }
- fieldKeys.add(field.key);
- }
- }
- const result = {
- path,
- format,
- label: stringValue(file["label"], `${location}.label`),
- sections
- };
- if (file["description"] !== void 0) {
- result.description = stringValue(
- file["description"],
- `${location}.description`
- );
- }
- if (file["documentation"] !== void 0) {
- result.documentation = stringValue(
- file["documentation"],
- `${location}.documentation`
- );
- }
- return result;
-};
-var validateManifest = (value) => {
- const manifest = objectValue(value, "manifest");
- if (manifest["version"] !== 1) fail("version must be 1.");
- const files = arrayValue(manifest["files"], "files").map(
- (file, index) => validateFile(file, `files[${index}]`)
- );
- if (files.length === 0) fail("files must not be empty.");
- const paths = /* @__PURE__ */ new Set();
- for (const file of files) {
- if (paths.has(file.path)) fail(`files contains duplicate path "${file.path}".`);
- paths.add(file.path);
- }
- return { version: 1, server: validateServer(manifest["server"]), files };
-};
-var compareVersions = (left, right) => {
- const leftParts = left.split(".").map(Number);
- const rightParts = right.split(".").map(Number);
- const length = Math.max(leftParts.length, rightParts.length);
- for (let index = 0; index < length; index += 1) {
- const difference = (leftParts[index] ?? 0) - (rightParts[index] ?? 0);
- if (difference !== 0) return difference < 0 ? -1 : 1;
- }
- return 0;
-};
-
-// ../../packages/config-editor/src/adapters/line-document.ts
-var splitSourceLines = (source) => {
- const lines = [];
- let start = 0;
- while (start < source.length) {
- const newline = source.indexOf("\n", start);
- if (newline === -1) {
- lines.push({ raw: source.slice(start), ending: "", start, end: source.length });
- break;
- }
- const hasCarriageReturn = newline > start && source[newline - 1] === "\r";
- const rawEnd = hasCarriageReturn ? newline - 1 : newline;
- lines.push({
- raw: source.slice(start, rawEnd),
- ending: hasCarriageReturn ? "\r\n" : "\n",
- start,
- end: rawEnd
- });
- start = newline + 1;
- }
- return lines;
-};
-var preferredLineEnding = (lines) => lines.find((line) => line.ending !== "")?.ending || "\n";
-var hasFinalNewline = (source) => source.endsWith("\n");
-
-// ../../packages/config-editor/src/adapters/java-properties.ts
-var trailingBackslashes = (value) => {
- let count = 0;
- for (let index = value.length - 1; index >= 0 && value[index] === "\\"; index -= 1) {
- count += 1;
- }
- return count;
-};
-var logicalLineAt = (lines, firstLine) => {
- let text = "";
- const offsets = [];
- let lineIndex = firstLine;
- let continued = false;
- for (; lineIndex < lines.length; lineIndex += 1) {
- const line = lines[lineIndex];
- const trim = continued ? line.raw.match(/^[ \t\f]*/)?.[0].length ?? 0 : 0;
- const content = line.raw.slice(trim);
- const continues = trailingBackslashes(content) % 2 === 1;
- const logicalContent = continues ? content.slice(0, -1) : content;
- for (let index = 0; index < logicalContent.length; index += 1) {
- text += logicalContent[index];
- offsets.push(line.start + trim + index);
- }
- continued = continues;
- if (!continues) break;
- }
- const lastLine = Math.min(lineIndex, lines.length - 1);
- return {
- text,
- offsets,
- end: lines[lastLine]?.end ?? 0,
- firstLine,
- lastLine
- };
-};
-var isEscaped = (value, index) => {
- let slashes = 0;
- for (let cursor = index - 1; cursor >= 0 && value[cursor] === "\\"; cursor -= 1) {
- slashes += 1;
- }
- return slashes % 2 === 1;
-};
-var decodeEscapes = (value) => {
- let decoded = "";
- for (let index = 0; index < value.length; index += 1) {
- const character = value[index];
- if (character !== "\\" || index === value.length - 1) {
- decoded += character;
- continue;
- }
- const escaped = value[index += 1];
- if (escaped === "t") decoded += " ";
- else if (escaped === "n") decoded += "\n";
- else if (escaped === "r") decoded += "\r";
- else if (escaped === "f") decoded += "\f";
- else if (escaped === "u") {
- while (value[index + 1] === "u") index += 1;
- const hexadecimal = value.slice(index + 1, index + 5);
- if (/^[\da-f]{4}$/i.test(hexadecimal)) {
- decoded += String.fromCharCode(Number.parseInt(hexadecimal, 16));
- index += 4;
- } else {
- decoded += "u";
- }
- } else decoded += escaped;
- }
- return decoded;
-};
-var parseEntry = (logical) => {
- const { text } = logical;
- let start = 0;
- while (start < text.length && /[ \t\f]/.test(text[start])) start += 1;
- if (start === text.length || text[start] === "#" || text[start] === "!") {
- return void 0;
- }
- let keyEnd = start;
- while (keyEnd < text.length) {
- const character = text[keyEnd];
- if (!isEscaped(text, keyEnd) && (character === "=" || character === ":" || /[ \t\f]/.test(character))) {
- break;
- }
- keyEnd += 1;
- }
- let valueStart = keyEnd;
- while (valueStart < text.length && /[ \t\f]/.test(text[valueStart])) {
- valueStart += 1;
- }
- if (valueStart < text.length && (text[valueStart] === "=" || text[valueStart] === ":")) {
- valueStart += 1;
- }
- while (valueStart < text.length && /[ \t\f]/.test(text[valueStart])) {
- valueStart += 1;
- }
- return {
- key: decodeEscapes(text.slice(start, keyEnd)),
- value: decodeEscapes(text.slice(valueStart)),
- valueSpan: {
- start: logical.offsets[valueStart] ?? logical.end,
- end: logical.end
- },
- firstLine: logical.firstLine,
- lastLine: logical.lastLine
- };
-};
-var classifyNodes = (lines, entries) => {
- const firstLines = new Map(entries.map((entry) => [entry.firstLine, entry]));
- const continuationLines = /* @__PURE__ */ new Set();
- for (const entry of entries) {
- for (let index = entry.firstLine + 1; index <= entry.lastLine; index += 1) {
- continuationLines.add(index);
- }
- }
- return lines.map((line, index) => {
- const entry = firstLines.get(index);
- if (entry) {
- return {
- ...line,
- kind: "entry",
- key: entry.key,
- value: entry.value,
- valueSpan: entry.valueSpan
- };
- }
- if (continuationLines.has(index)) return { ...line, kind: "continuation" };
- const trimmed = line.raw.trimStart();
- if (trimmed === "") return { ...line, kind: "blank" };
- if (trimmed.startsWith("#") || trimmed.startsWith("!")) {
- return { ...line, kind: "comment" };
- }
- return { ...line, kind: "invalid" };
- });
-};
-var parseJavaProperties = (source) => {
- const lines = splitSourceLines(source);
- const entries = [];
- for (let lineIndex = 0; lineIndex < lines.length; lineIndex += 1) {
- const logical = logicalLineAt(lines, lineIndex);
- const entry = parseEntry(logical);
- if (entry) entries.push(entry);
- lineIndex = logical.lastLine;
- }
- return {
- format: "java-properties",
- source,
- nodes: classifyNodes(lines, entries),
- entries,
- issues: [],
- lineEnding: preferredLineEnding(lines),
- finalNewline: hasFinalNewline(source)
- };
-};
-var escapeKey = (value) => value.replace(/[\\ \t\f=:#!]/g, (character) => {
- if (character === " ") return "\\t";
- if (character === "\f") return "\\f";
- return `\\${character}`;
-});
-var escapeValue = (value) => {
- const text = value === null ? "" : String(value);
- return text.replace(/[\\\t\n\r\f]/g, (character) => {
- if (character === "\\") return "\\\\";
- if (character === " ") return "\\t";
- if (character === "\n") return "\\n";
- if (character === "\r") return "\\r";
- return "\\f";
- }).replace(/^ +/, (spaces) => "\\ ".repeat(spaces.length));
-};
-var effectiveEntry = (document, key) => {
- for (let index = document.entries.length - 1; index >= 0; index -= 1) {
- const entry = document.entries[index];
- if (entry.key === key) return entry;
- }
- return void 0;
-};
-var appendEntry = (document, key, value) => {
- const entry = `${escapeKey(key)}=${escapeValue(value)}`;
- if (document.source === "") return entry;
- if (document.finalNewline) return `${document.source}${entry}${document.lineEnding}`;
- return `${document.source}${document.lineEnding}${entry}`;
-};
-var javaPropertiesAdapter = {
- parse: parseJavaProperties,
- entries(document) {
- const entries = /* @__PURE__ */ new Map();
- for (const entry of document.entries) entries.set(entry.key, entry.value);
- return [...entries].map(([key, value]) => ({ key, value }));
- },
- get(document, key) {
- return effectiveEntry(document, key)?.value;
- },
- getAll(document, key) {
- return document.entries.filter((entry) => entry.key === key).map((entry) => entry.value);
- },
- getAllRaw(document, key) {
- return document.entries.filter((entry) => entry.key === key).map((entry) => document.source.slice(entry.valueSpan.start, entry.valueSpan.end));
- },
- set(document, key, value) {
- const entry = effectiveEntry(document, key);
- if (!entry) return parseJavaProperties(appendEntry(document, key, value));
- const source = document.source.slice(0, entry.valueSpan.start) + escapeValue(value) + document.source.slice(entry.valueSpan.end);
- return parseJavaProperties(source);
- },
- setAll(document, key, values) {
- const entries = document.entries.filter((entry) => entry.key === key);
- if (entries.length !== values.length) {
- throw new Error(`Expected ${entries.length} values for Java property "${key}".`);
- }
- let source = document.source;
- for (let index = entries.length - 1; index >= 0; index -= 1) {
- const entry = entries[index];
- source = source.slice(0, entry.valueSpan.start) + escapeValue(values[index]) + source.slice(entry.valueSpan.end);
- }
- return parseJavaProperties(source);
- },
- setAllRaw(document, key, values) {
- const entries = document.entries.filter((entry) => entry.key === key);
- if (entries.length !== values.length) {
- throw new Error(`Expected ${entries.length} raw values for Java property "${key}".`);
- }
- let source = document.source;
- for (let index = entries.length - 1; index >= 0; index -= 1) {
- const entry = entries[index];
- source = source.slice(0, entry.valueSpan.start) + values[index] + source.slice(entry.valueSpan.end);
- }
- return parseJavaProperties(source);
- },
- validate(document, schema) {
- void schema;
- return [...document.issues];
- },
- serialize(document) {
- return document.source;
- }
-};
-
-// ../../packages/config-editor/src/adapters/ini.ts
-var inlineCommentAt = (value) => {
- let quote;
- for (let index = 0; index < value.length; index += 1) {
- const character = value[index];
- if (quote) {
- if (quote === "'" && value.startsWith("'\\''", index)) {
- index += 3;
- continue;
- }
- if (character === quote && value[index - 1] !== "\\") quote = void 0;
- continue;
- }
- if (character === '"' || character === "'") {
- quote = character;
- continue;
- }
- const precededBySpace = index > 0 && /\s/.test(value[index - 1]);
- if (!precededBySpace) continue;
- if (character === ";" || character === "#") return index;
- if (character === "/" && value[index + 1] === "/") return index;
- }
- return value.length;
-};
-var decodeQuotedValue = (value, quote) => {
- if (quote === '"') return value.replace(/\\(["\\])/g, "$1");
- if (quote === "'") return value.replace(/'\\''/g, "'");
- return value;
-};
-var parseEntryLine = (raw, start, section, line, format) => {
- let keyStart = 0;
- while (keyStart < raw.length && /[ \t]/.test(raw[keyStart])) keyStart += 1;
- if (keyStart === raw.length) return void 0;
- let keyEnd = keyStart;
- while (keyEnd < raw.length && raw[keyEnd] !== "=" && raw[keyEnd] !== ":" && !/[ \t]/.test(raw[keyEnd])) {
- keyEnd += 1;
- }
- if (keyEnd === keyStart) return void 0;
- let valueStart = keyEnd;
- while (valueStart < raw.length && /[ \t]/.test(raw[valueStart])) valueStart += 1;
- if (raw[valueStart] === "=" || raw[valueStart] === ":") valueStart += 1;
- while (valueStart < raw.length && /[ \t]/.test(raw[valueStart])) valueStart += 1;
- const comment = inlineCommentAt(raw.slice(valueStart));
- let valueEnd = valueStart + comment;
- while (valueEnd > valueStart && /[ \t]/.test(raw[valueEnd - 1])) valueEnd -= 1;
- if (format === "key-value" && raw[valueEnd - 1] === ";") {
- valueEnd -= 1;
- while (valueEnd > valueStart && /[ \t]/.test(raw[valueEnd - 1])) valueEnd -= 1;
- }
- const quote = raw[valueStart];
- let valueQuote;
- if ((quote === '"' || quote === "'") && valueEnd > valueStart && raw[valueEnd - 1] === quote) {
- valueQuote = quote;
- valueStart += 1;
- valueEnd -= 1;
- }
- const localKey = raw.slice(keyStart, keyEnd).trim();
- return {
- key: section ? `${section}.${localKey}` : localKey,
- value: decodeQuotedValue(raw.slice(valueStart, valueEnd), valueQuote),
- valueSpan: { start: start + valueStart, end: start + valueEnd },
- line,
- ...valueQuote ? { quote: valueQuote } : {}
- };
-};
-var tupleEntries = (entry) => {
- if (!entry.value.startsWith("(") || !entry.value.endsWith(")")) return [];
- const inner = entry.value.slice(1, -1);
- const segments = [];
- let segmentStart = 0;
- let depth = 0;
- let quote;
- for (let index = 0; index <= inner.length; index += 1) {
- const character = inner[index];
- if (quote) {
- if (character === quote && inner[index - 1] !== "\\") quote = void 0;
- continue;
- }
- if (character === '"' || character === "'") {
- quote = character;
- continue;
- }
- if (character === "(" || character === "[" || character === "{") depth += 1;
- else if (character === ")" || character === "]" || character === "}") depth -= 1;
- if (character === "," && depth === 0 || index === inner.length) {
- segments.push({ start: segmentStart, end: index });
- segmentStart = index + 1;
- }
- }
- const result = [];
- for (const segment of segments) {
- const raw = inner.slice(segment.start, segment.end);
- let equals = -1;
- depth = 0;
- quote = void 0;
- for (let index = 0; index < raw.length; index += 1) {
- const character = raw[index];
- if (quote) {
- if (character === quote && raw[index - 1] !== "\\") quote = void 0;
- continue;
- }
- if (character === '"' || character === "'") quote = character;
- else if (character === "(" || character === "[" || character === "{") depth += 1;
- else if (character === ")" || character === "]" || character === "}") depth -= 1;
- else if (character === "=" && depth === 0) {
- equals = index;
- break;
- }
- }
- if (equals < 1) continue;
- const key = raw.slice(0, equals).trim();
- let localStart = equals + 1;
- while (localStart < raw.length && /\s/.test(raw[localStart])) localStart += 1;
- let localEnd = raw.length;
- while (localEnd > localStart && /\s/.test(raw[localEnd - 1])) localEnd -= 1;
- const valueQuote = raw[localStart];
- let preservedQuote;
- if ((valueQuote === '"' || valueQuote === "'") && localEnd > localStart && raw[localEnd - 1] === valueQuote) {
- preservedQuote = valueQuote;
- localStart += 1;
- localEnd -= 1;
- }
- const absoluteStart = entry.valueSpan.start + 1 + segment.start + localStart;
- result.push({
- key: `${entry.key}.${key}`,
- value: decodeQuotedValue(raw.slice(localStart, localEnd), preservedQuote),
- valueSpan: {
- start: absoluteStart,
- end: absoluteStart + localEnd - localStart
- },
- line: entry.line,
- ...preservedQuote ? { quote: preservedQuote } : {}
- });
- }
- return result;
-};
-var parseIni = (source, format) => {
- const lines = splitSourceLines(source);
- const entries = [];
- const sections = [];
- const nodes = [];
- let section;
- for (let index = 0; index < lines.length; index += 1) {
- const line = lines[index];
- const trimmed = line.raw.trimStart();
- if (trimmed === "") {
- nodes.push({ ...line, kind: "blank" });
- continue;
- }
- if (trimmed.startsWith(";") || trimmed.startsWith("#") || trimmed.startsWith("//")) {
- nodes.push({ ...line, kind: "comment" });
- continue;
- }
- const sectionMatch = format === "key-value" ? null : trimmed.match(/^\[([^\]]+)](?:\s*(?:[;#].*)?)$/);
- if (sectionMatch) {
- section = sectionMatch[1].trim();
- sections.push({ name: section, line: index });
- nodes.push({ ...line, kind: "section" });
- continue;
- }
- const entry = parseEntryLine(line.raw, line.start, section, index, format);
- if (entry) {
- entries.push(entry);
- if (format === "unreal-ini") {
- const children = tupleEntries(entry);
- if (children.length > 0) {
- entry.container = true;
- entries.push(...children);
- }
- }
- nodes.push({
- ...line,
- kind: "entry",
- key: entry.key,
- value: entry.value,
- valueSpan: entry.valueSpan
- });
- } else nodes.push({ ...line, kind: "invalid" });
- }
- return {
- format,
- source,
- nodes,
- entries,
- sections,
- issues: [],
- lineEnding: preferredLineEnding(lines),
- finalNewline: hasFinalNewline(source)
- };
-};
-var effectiveEntry2 = (document, key) => {
- for (let index = document.entries.length - 1; index >= 0; index -= 1) {
- const entry = document.entries[index];
- if (entry.key === key) return entry;
- }
- return void 0;
-};
-var formatValue = (value, quote) => {
- let formatted = (value === null ? "" : String(value)).replace(
- /[\r\n]/g,
- (character) => character === "\r" ? "\\r" : "\\n"
- );
- if (quote === '"') formatted = formatted.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
- else if (quote === "'") formatted = formatted.replace(/'/g, "'\\''");
- return formatted;
-};
-var appendIniEntry = (document, key, value) => {
- const split = document.format === "key-value" ? -1 : key.lastIndexOf(".");
- const section = split < 0 ? void 0 : key.slice(0, split);
- const localKey = split < 0 ? key : key.slice(split + 1);
- const assignment = `${localKey}=${formatValue(value)}`;
- const prefix = document.source === "" || document.finalNewline ? "" : document.lineEnding;
- const suffix = document.finalNewline ? document.lineEnding : "";
- if (!section || document.sections.some((candidate) => candidate.name === section)) {
- return `${document.source}${prefix}${assignment}${suffix}`;
- }
- const sectionHeader = `[${section}]${document.lineEnding}`;
- return `${document.source}${prefix}${sectionHeader}${assignment}${suffix}`;
-};
-var createIniAdapter = (format) => ({
- parse(source) {
- return parseIni(source, format);
- },
- entries(document) {
- const entries = /* @__PURE__ */ new Map();
- for (const entry of document.entries) {
- if (!entry.container) entries.set(entry.key, entry.value);
- }
- return [...entries].map(([key, value]) => ({ key, value }));
- },
- get(document, key) {
- return effectiveEntry2(document, key)?.value;
- },
- getAll(document, key) {
- return document.entries.filter((entry) => !entry.container && entry.key === key).map((entry) => entry.value);
- },
- getAllRaw(document, key) {
- return document.entries.filter((entry) => !entry.container && entry.key === key).map((entry) => document.source.slice(entry.valueSpan.start, entry.valueSpan.end));
- },
- set(document, key, value) {
- const entry = effectiveEntry2(document, key);
- if (!entry) return parseIni(appendIniEntry(document, key, value), format);
- return parseIni(
- document.source.slice(0, entry.valueSpan.start) + formatValue(value, entry.quote) + document.source.slice(entry.valueSpan.end),
- format
- );
- },
- setAll(document, key, values) {
- const entries = document.entries.filter(
- (entry) => !entry.container && entry.key === key
- );
- if (entries.length !== values.length) {
- throw new Error(`Expected ${entries.length} values for setting "${key}".`);
- }
- let source = document.source;
- for (let index = entries.length - 1; index >= 0; index -= 1) {
- const entry = entries[index];
- source = source.slice(0, entry.valueSpan.start) + formatValue(values[index], entry.quote) + source.slice(entry.valueSpan.end);
- }
- return parseIni(source, format);
- },
- setAllRaw(document, key, values) {
- const entries = document.entries.filter(
- (entry) => !entry.container && entry.key === key
- );
- if (entries.length !== values.length) {
- throw new Error(`Expected ${entries.length} raw values for setting "${key}".`);
- }
- let source = document.source;
- for (let index = entries.length - 1; index >= 0; index -= 1) {
- const entry = entries[index];
- source = source.slice(0, entry.valueSpan.start) + values[index] + source.slice(entry.valueSpan.end);
- }
- return parseIni(source, format);
- },
- validate(document, schema) {
- void schema;
- return [...document.issues];
- },
- serialize(document) {
- return document.source;
- }
-});
-var iniAdapter = createIniAdapter("ini");
-var unrealIniAdapter = createIniAdapter("unreal-ini");
-var keyValueAdapter = createIniAdapter("key-value");
-
-// ../../packages/config-editor/src/adapters/json.ts
-var JsonParseError = class extends Error {
- position;
- constructor(position, message) {
- super(message);
- this.position = position;
- }
-};
-var pointerSegment = (value) => value.replace(/~/g, "~0").replace(/\//g, "~1");
-var locationAt = (source, position) => {
- const before = source.slice(0, position);
- const lines = before.split("\n");
- return { line: lines.length, column: lines.at(-1).length + 1 };
-};
-var jsonNodes = (source) => splitSourceLines(source).map((line) => ({
- ...line,
- kind: line.raw.trim() === "" ? "blank" : "invalid"
-}));
-var parseJson = (source) => {
- const scalars = /* @__PURE__ */ new Map();
- const scalarOccurrences = /* @__PURE__ */ new Map();
- const containers = /* @__PURE__ */ new Map();
- let cursor = 0;
- const addScalar = (pointer, scalar) => {
- scalars.set(pointer, scalar);
- const occurrences = scalarOccurrences.get(pointer) ?? [];
- occurrences.push(scalar);
- scalarOccurrences.set(pointer, occurrences);
- };
- const fail2 = (message) => {
- throw new JsonParseError(cursor, message);
- };
- const skipWhitespace = () => {
- while (cursor < source.length && /\s/.test(source[cursor])) cursor += 1;
- };
- const consume = (character) => {
- if (source[cursor] !== character) fail2(`Expected "${character}".`);
- cursor += 1;
- };
- const parseString = () => {
- const start = cursor;
- consume('"');
- while (cursor < source.length) {
- const character = source[cursor];
- if (character === '"') {
- cursor += 1;
- const token = source.slice(start, cursor);
- try {
- return { value: JSON.parse(token), span: { start, end: cursor } };
- } catch {
- fail2("Invalid JSON string.");
- }
- }
- if (character === "\\") {
- cursor += 2;
- continue;
- }
- if (character.charCodeAt(0) < 32) fail2("Unescaped control character.");
- cursor += 1;
- }
- return fail2("Unterminated JSON string.");
- };
- const parseValue = (pointer) => {
- skipWhitespace();
- const start = cursor;
- const character = source[cursor];
- if (character === '"') {
- const string = parseString();
- addScalar(pointer, { value: string.value, span: string.span });
- return;
- }
- if (character === "{") {
- const open = cursor;
- cursor += 1;
- skipWhitespace();
- if (source[cursor] === "}") {
- containers.set(pointer, { kind: "object", open, close: cursor });
- cursor += 1;
- return;
- }
- while (cursor < source.length) {
- skipWhitespace();
- if (source[cursor] !== '"') fail2("Expected an object key.");
- const key = parseString().value;
- skipWhitespace();
- consume(":");
- parseValue(`${pointer}/${pointerSegment(key)}`);
- skipWhitespace();
- if (source[cursor] === "}") {
- containers.set(pointer, { kind: "object", open, close: cursor });
- cursor += 1;
- return;
- }
- consume(",");
- }
- fail2("Unterminated JSON object.");
- }
- if (character === "[") {
- const open = cursor;
- cursor += 1;
- skipWhitespace();
- if (source[cursor] === "]") {
- containers.set(pointer, { kind: "array", open, close: cursor });
- cursor += 1;
- return;
- }
- let index = 0;
- while (cursor < source.length) {
- parseValue(`${pointer}/${index}`);
- index += 1;
- skipWhitespace();
- if (source[cursor] === "]") {
- containers.set(pointer, { kind: "array", open, close: cursor });
- cursor += 1;
- return;
- }
- consume(",");
- }
- fail2("Unterminated JSON array.");
- }
- const remainder = source.slice(cursor);
- const literal = remainder.match(/^(?:true|false|null)(?![\w])/);
- if (literal) {
- cursor += literal[0].length;
- addScalar(pointer, {
- value: JSON.parse(literal[0]),
- span: { start, end: cursor }
- });
- return;
- }
- const number = remainder.match(/^-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?/);
- if (number) {
- cursor += number[0].length;
- const unsafeInteger = /^-?\d+$/.test(number[0]) && !Number.isSafeInteger(Number(number[0]));
- addScalar(pointer, {
- value: unsafeInteger ? number[0] : Number(number[0]),
- span: { start, end: cursor },
- ...unsafeInteger ? { unsafeInteger: true } : {}
- });
- return;
- }
- fail2("Expected a JSON scalar, object, or array.");
- };
- try {
- parseValue("");
- skipWhitespace();
- if (cursor !== source.length) fail2("Unexpected content after the JSON value.");
- return {
- format: "json",
- source,
- nodes: jsonNodes(source),
- issues: [],
- scalars,
- scalarOccurrences,
- containers
- };
- } catch (error) {
- if (!(error instanceof JsonParseError)) throw error;
- const location = locationAt(source, error.position);
- return {
- format: "json",
- source,
- nodes: jsonNodes(source),
- issues: [
- {
- code: "invalid-json",
- message: error.message,
- severity: "error",
- ...location
- }
- ],
- scalars: /* @__PURE__ */ new Map(),
- scalarOccurrences: /* @__PURE__ */ new Map(),
- containers: /* @__PURE__ */ new Map()
- };
- }
-};
-var decodePointerSegment = (value) => value.replace(/~1/g, "/").replace(/~0/g, "~");
-var insertJsonProperty = (document, pointer, value) => {
- const separator = pointer.lastIndexOf("/");
- if (separator < 0) throw new Error(`JSON pointer "${pointer}" does not exist.`);
- const parentPointer = pointer.slice(0, separator);
- const property = decodePointerSegment(pointer.slice(separator + 1));
- const parent = document.containers.get(parentPointer);
- if (!parent || parent.kind !== "object") {
- throw new Error(`JSON pointer "${pointer}" does not exist.`);
- }
- const content = document.source.slice(parent.open + 1, parent.close);
- const contentEnd = parent.close - (content.match(/\s*$/)?.[0].length ?? 0);
- const closingLine = document.source.slice(0, parent.close).split("\n").at(-1) ?? "";
- const closingIndent = closingLine.match(/^\s*/)?.[0] ?? "";
- const serialized = `${JSON.stringify(property)}: ${JSON.stringify(value)}`;
- const newline = document.source.includes("\r\n") ? "\r\n" : "\n";
- let insertion;
- if (content.trim() === "") {
- insertion = `${newline}${closingIndent} ${serialized}${newline}${closingIndent}`;
- return parseJson(
- document.source.slice(0, parent.open + 1) + insertion + document.source.slice(parent.close)
- );
- }
- const multiline = content.includes("\n");
- if (!multiline) insertion = `, ${serialized}`;
- else {
- const existingLine = document.source.slice(0, contentEnd).split("\n").at(-1) ?? "";
- const childIndent = existingLine.match(/^\s*/)?.[0] ?? `${closingIndent} `;
- insertion = `,${newline}${childIndent}${serialized}`;
- }
- return parseJson(
- document.source.slice(0, contentEnd) + insertion + document.source.slice(contentEnd)
- );
-};
-var jsonAdapter = {
- parse: parseJson,
- entries(document) {
- return [...document.scalars].map(([key, scalar]) => ({
- key,
- value: scalar.value
- }));
- },
- get(document, key) {
- return document.scalars.get(key)?.value;
- },
- getAll(document, key) {
- return (document.scalarOccurrences.get(key) ?? []).map((scalar) => scalar.value);
- },
- getAllRaw(document, key) {
- return (document.scalarOccurrences.get(key) ?? []).map(
- (scalar) => document.source.slice(scalar.span.start, scalar.span.end)
- );
- },
- set(document, key, value) {
- if (document.issues.length > 0) throw new Error("Invalid JSON cannot be edited in form mode.");
- const scalar = document.scalars.get(key);
- if (!scalar) {
- if (document.containers.has(key)) throw new Error(`JSON pointer "${key}" is not a scalar.`);
- return insertJsonProperty(document, key, value);
- }
- const serialized = scalar.unsafeInteger && typeof value === "string" && /^-?\d+$/.test(value) ? value : JSON.stringify(value);
- return parseJson(
- document.source.slice(0, scalar.span.start) + serialized + document.source.slice(scalar.span.end)
- );
- },
- setAll(document, key, values) {
- const occurrences = document.scalarOccurrences.get(key) ?? [];
- const expected = occurrences.length;
- if (values.length !== expected) {
- throw new Error(`Expected ${expected} values for JSON pointer "${key}".`);
- }
- let source = document.source;
- for (let index = occurrences.length - 1; index >= 0; index -= 1) {
- const scalar = occurrences[index];
- const value = values[index];
- const serialized = scalar.unsafeInteger && typeof value === "string" && /^-?\d+$/.test(value) ? value : JSON.stringify(value);
- source = source.slice(0, scalar.span.start) + serialized + source.slice(scalar.span.end);
- }
- return parseJson(source);
- },
- setAllRaw(document, key, values) {
- const occurrences = document.scalarOccurrences.get(key) ?? [];
- const expected = occurrences.length;
- if (values.length !== expected) {
- throw new Error(`Expected ${expected} raw values for JSON pointer "${key}".`);
- }
- let source = document.source;
- for (let index = occurrences.length - 1; index >= 0; index -= 1) {
- const scalar = occurrences[index];
- source = source.slice(0, scalar.span.start) + values[index] + source.slice(scalar.span.end);
- }
- return parseJson(source);
- },
- validate(document, schema) {
- void schema;
- return [...document.issues];
- },
- serialize(document) {
- return document.source;
- }
-};
-
-// ../../packages/config-editor/src/adapters/raw.ts
-var parseRaw = (source) => {
- const nodes = splitSourceLines(source).map((line) => ({
- ...line,
- kind: line.raw.trim() === "" ? "blank" : "invalid"
- }));
- return { format: "raw", source, nodes, issues: [] };
-};
-var rawAdapter = {
- parse: parseRaw,
- entries() {
- return [];
- },
- get() {
- return void 0;
- },
- getAll() {
- return [];
- },
- getAllRaw() {
- return [];
- },
- set(_document, _key, _value) {
- throw new Error("Raw mode does not support typed field writes.");
- },
- setAll(document, _key, values) {
- if (values.length > 0) throw new Error("Raw mode does not support typed field writes.");
- return document;
- },
- setAllRaw(document, _key, values) {
- if (values.length > 0) throw new Error("Raw mode does not support typed field writes.");
- return document;
- },
- validate(document, schema) {
- void schema;
- return [...document.issues];
- },
- serialize(document) {
- return document.source;
- }
-};
-
-// ../../packages/config-editor/src/adapters/xml-properties.ts
-var decodeXml = (value) => value.replace(/&(?:#x[\da-f]+|#\d+|amp|quot|apos|lt|gt);/gi, (entity) => {
- const token = entity.slice(1, -1);
- if (token.toLowerCase().startsWith("#x")) {
- return String.fromCodePoint(Number.parseInt(token.slice(2), 16));
- }
- if (token.startsWith("#")) {
- return String.fromCodePoint(Number.parseInt(token.slice(1), 10));
- }
- return { amp: "&", quot: '"', apos: "'", lt: "<", gt: ">" }[token.toLowerCase()];
-});
-var encodeXml = (value, quote) => {
- let encoded = (value === null ? "" : String(value)).replace(/&/g, "&").replace(//g, ">");
- encoded = quote === '"' ? encoded.replace(/"/g, """) : encoded.replace(/'/g, "'");
- return encoded;
-};
-var parseXmlProperties = (source) => {
- const properties = [];
- const propertyPattern = /]*>/gi;
- for (const match of source.matchAll(propertyPattern)) {
- const tag = match[0];
- const tagStart = match.index;
- const attributes = /* @__PURE__ */ new Map();
- const attributePattern = /\b(name|value)\s*=\s*(["'])(.*?)\2/gi;
- for (const attribute of tag.matchAll(attributePattern)) {
- const quote = attribute[2];
- const rawValue = attribute[3];
- const rawStart = tagStart + attribute.index + attribute[0].indexOf(quote) + 1;
- attributes.set(attribute[1].toLowerCase(), {
- value: decodeXml(rawValue),
- span: { start: rawStart, end: rawStart + rawValue.length },
- quote
- });
- }
- const name = attributes.get("name");
- const value = attributes.get("value");
- if (name && value) {
- properties.push({
- key: name.value,
- value: value.value,
- valueSpan: value.span,
- quote: value.quote
- });
- }
- }
- const nodes = splitSourceLines(source).map((line) => ({
- ...line,
- kind: line.raw.trim() === "" ? "blank" : "invalid"
- }));
- return { format: "xml-properties", source, nodes, issues: [], properties };
-};
-var effectiveProperty = (document, key) => {
- for (let index = document.properties.length - 1; index >= 0; index -= 1) {
- const property = document.properties[index];
- if (property.key === key) return property;
- }
- return void 0;
-};
-var xmlPropertiesAdapter = {
- parse: parseXmlProperties,
- entries(document) {
- const entries = /* @__PURE__ */ new Map();
- for (const property of document.properties) entries.set(property.key, property.value);
- return [...entries].map(([key, value]) => ({ key, value }));
- },
- get(document, key) {
- return effectiveProperty(document, key)?.value;
- },
- getAll(document, key) {
- return document.properties.filter((property) => property.key === key).map((property) => property.value);
- },
- getAllRaw(document, key) {
- return document.properties.filter((property) => property.key === key).map((property) => document.source.slice(property.valueSpan.start, property.valueSpan.end));
- },
- set(document, key, value) {
- const property = effectiveProperty(document, key);
- if (!property) throw new Error(`XML property "${key}" does not exist.`);
- return parseXmlProperties(
- document.source.slice(0, property.valueSpan.start) + encodeXml(value, property.quote) + document.source.slice(property.valueSpan.end)
- );
- },
- setAll(document, key, values) {
- const properties = document.properties.filter((property) => property.key === key);
- if (properties.length !== values.length) {
- throw new Error(`Expected ${properties.length} values for XML property "${key}".`);
- }
- let source = document.source;
- for (let index = properties.length - 1; index >= 0; index -= 1) {
- const property = properties[index];
- source = source.slice(0, property.valueSpan.start) + encodeXml(values[index], property.quote) + source.slice(property.valueSpan.end);
- }
- return parseXmlProperties(source);
- },
- setAllRaw(document, key, values) {
- const properties = document.properties.filter((property) => property.key === key);
- if (properties.length !== values.length) {
- throw new Error(`Expected ${properties.length} raw values for XML property "${key}".`);
- }
- let source = document.source;
- for (let index = properties.length - 1; index >= 0; index -= 1) {
- const property = properties[index];
- source = source.slice(0, property.valueSpan.start) + values[index] + source.slice(property.valueSpan.end);
- }
- return parseXmlProperties(source);
- },
- validate(document, schema) {
- void schema;
- return [...document.issues];
- },
- serialize(document) {
- return document.source;
- }
-};
-
-// ../../packages/config-editor/src/adapters/registry.ts
-var createAdapterRegistry = () => /* @__PURE__ */ new Map([
- ["java-properties", javaPropertiesAdapter],
- ["ini", iniAdapter],
- ["unreal-ini", unrealIniAdapter],
- ["key-value", keyValueAdapter],
- ["json", jsonAdapter],
- ["xml-properties", xmlPropertiesAdapter],
- ["raw", rawAdapter]
-]);
-
-// ../../packages/config-editor/src/validation.ts
-var issue = (field, code, message) => ({
- code,
- message,
- severity: "error",
- fieldKey: field.key
-});
-var scalarText = (input) => {
- if (input === null) return "";
- if (typeof input === "string") return input;
- if (typeof input === "number" || typeof input === "boolean") return String(input);
- throw new TypeError("Value must be a scalar.");
-};
-var coerceFieldValue = (field, input) => {
- if (field.type === "string" || field.type === "secret" || field.type === "enum") {
- return scalarText(input);
- }
- if (field.type === "boolean") {
- if (input === true || input === false) return input;
- if (input === "true") return true;
- if (input === "false") return false;
- throw new TypeError("Value must be true or false.");
- }
- if (typeof input === "number") {
- if (!Number.isFinite(input)) throw new TypeError("Value must be a finite number.");
- if (field.type === "integer" && !Number.isSafeInteger(input)) {
- throw new TypeError("Value must be an integer.");
- }
- return input;
- }
- const text = scalarText(input).trim();
- if (field.type === "integer") {
- if (!/^[+-]?\d+$/.test(text)) throw new TypeError("Value must be an integer.");
- const value2 = Number(text);
- if (!Number.isSafeInteger(value2)) throw new TypeError("Value must be an integer.");
- return value2;
- }
- if (!/^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?$/.test(text)) {
- throw new TypeError("Value must be a finite number.");
- }
- const value = Number(text);
- if (!Number.isFinite(value)) throw new TypeError("Value must be a finite number.");
- return value;
-};
-var validateField2 = (field, value) => {
- const issues = [];
- if (field.type === "integer" && (typeof value !== "number" || !Number.isSafeInteger(value))) {
- issues.push(issue(field, "type", "Value must be an integer."));
- return issues;
- }
- if (field.type === "number" && (typeof value !== "number" || !Number.isFinite(value))) {
- issues.push(issue(field, "type", "Value must be a finite number."));
- return issues;
- }
- if (field.type === "boolean" && typeof value !== "boolean") {
- issues.push(issue(field, "type", "Value must be true or false."));
- return issues;
- }
- if ((field.type === "string" || field.type === "secret" || field.type === "enum") && typeof value !== "string") {
- issues.push(issue(field, "type", "Value must be text."));
- return issues;
- }
- if (typeof value === "number") {
- const below = field.min !== void 0 && value < field.min;
- const above = field.max !== void 0 && value > field.max;
- if (below || above) {
- let message;
- if (field.min !== void 0 && field.max !== void 0) {
- message = `Value must be between ${field.min} and ${field.max} (inclusive).`;
- } else if (field.min !== void 0) message = `Value must be at least ${field.min}.`;
- else message = `Value must be at most ${field.max}.`;
- issues.push(issue(field, "range", message));
- }
- }
- if (field.type === "enum" && typeof value === "string" && !field.values?.includes(value)) {
- issues.push(
- issue(field, "enum", `Value must be one of: ${(field.values ?? []).join(", ")}.`)
- );
- }
- if (field.pattern !== void 0 && typeof value === "string") {
- const pattern = new RegExp(`^(?:${field.pattern})$`);
- if (!pattern.test(value)) {
- issues.push(issue(field, "pattern", "Value does not match the required format."));
- }
- }
- return issues;
-};
-
-// ../../packages/config-editor/src/store.ts
-var MASKED_SECRET = "\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022";
-var allFields = (schema) => schema.sections.flatMap((section) => section.fields);
-var sensitiveKey = (key) => {
- const normalized = key.replace(/([a-z\d])([A-Z])/g, "$1.$2").toLowerCase();
- const segments = normalized.split(/[^a-z\d]+/).filter(Boolean);
- const sensitiveSegments = /* @__PURE__ */ new Set([
- "password",
- "passwd",
- "pass",
- "secret",
- "token",
- "apikey",
- "privatekey",
- "gslt",
- "steampass"
- ]);
- return segments.some((segment) => sensitiveSegments.has(segment)) || /(?:password|passwd|steampass|api[-_.]?key|private[-_.]?key|gslt)/i.test(key);
-};
-var inferredType = (value) => {
- if (typeof value === "boolean") return "boolean";
- if (typeof value === "number") return Number.isInteger(value) ? "integer" : "number";
- if (typeof value !== "string") return "string";
- if (/^(?:true|false)$/i.test(value)) return "boolean";
- return "string";
-};
-var humanizeKey = (key) => {
- const leaf = key.split(/[./]/).at(-1) ?? key;
- const label = leaf.replace(/[-_]+/g, " ").replace(/([a-z\d])([A-Z])/g, "$1 $2");
- return label.charAt(0).toUpperCase() + label.slice(1);
-};
-var schemaWithDetectedFields = (declaredSchema, entries) => {
- const declaredKeys = new Set(allFields(declaredSchema).map((field) => field.key));
- const detectedFields = [];
- for (const entry of entries) {
- if (entry.key === "" || declaredKeys.has(entry.key)) continue;
- const sensitive = sensitiveKey(entry.key);
- detectedFields.push({
- key: entry.key,
- label: humanizeKey(entry.key),
- description: `Setting discovered in ${declaredSchema.label}.`,
- documentation: declaredSchema.documentation ?? `Configuration file ${declaredSchema.path}`,
- type: sensitive ? "secret" : inferredType(entry.value),
- sensitive,
- restartRequired: true
- });
- }
- if (detectedFields.length === 0) return declaredSchema;
- const detectedSection = {
- id: "detected-settings",
- label: "Detected settings",
- description: "Settings discovered from the loaded configuration file.",
- fields: detectedFields
- };
- return {
- ...declaredSchema,
- sections: [...declaredSchema.sections, detectedSection]
- };
-};
-var valuesEqual = (left, right) => Object.is(left, right);
-var displayValue = (field, value) => {
- if ((field.sensitive || field.type === "secret") && value !== void 0 && value !== "") {
- return MASKED_SECRET;
- }
- return value === null || value === void 0 ? "" : String(value);
-};
-var deepFreezeSnapshot = (snapshot) => {
- for (const field of Object.values(snapshot.fields)) {
- for (const fieldIssue of field.issues) Object.freeze(fieldIssue);
- Object.freeze(field.issues);
- Object.freeze(field);
- }
- for (const change of snapshot.changes) Object.freeze(change);
- for (const editorIssue of snapshot.issues) Object.freeze(editorIssue);
- Object.freeze(snapshot.fields);
- Object.freeze(snapshot.changes);
- Object.freeze(snapshot.issues);
- return Object.freeze(snapshot);
-};
-var ConfigEditorStore = class _ConfigEditorStore {
- declaredSchema;
- schemaValue;
- adapter;
- originalDocument;
- workingDocument;
- fields;
- constructor(schema, document, adapter) {
- this.declaredSchema = schema;
- this.adapter = adapter;
- this.schemaValue = schemaWithDetectedFields(schema, adapter.entries(document));
- this.originalDocument = document;
- this.workingDocument = document;
- this.fields = this.readFields(document, document);
- }
- get schema() {
- return this.schemaValue;
- }
- static fromLoadedFile(schema, document) {
- if (schema.format !== document.format) {
- throw new TypeError(
- `Schema format "${schema.format}" does not match document format "${document.format}".`
- );
- }
- const adapter = createAdapterRegistry().get(schema.format);
- if (!adapter) throw new TypeError(`No adapter registered for "${schema.format}".`);
- return new _ConfigEditorStore(schema, document, adapter);
- }
- readValue(document, field) {
- const raw = this.adapter.get(document, field.key);
- if (raw === void 0) return { value: void 0, issues: [] };
- try {
- const value = coerceFieldValue(field, raw);
- return { value, issues: validateField2(field, value) };
- } catch (error) {
- return {
- value: void 0,
- issues: [
- {
- code: "type",
- message: error instanceof Error ? error.message : "Invalid value.",
- severity: "error",
- fieldKey: field.key,
- filePath: this.schema.path
- }
- ]
- };
- }
- }
- readFields(original, working) {
- return new Map(
- allFields(this.schema).map((field) => {
- const originalResult = this.readValue(original, field);
- const workingResult = this.readValue(working, field);
- return [
- field.key,
- {
- schema: field,
- original: originalResult.value,
- current: workingResult.value,
- displayValue: displayValue(field, workingResult.value),
- issues: workingResult.issues
- }
- ];
- })
- );
- }
- setDisplayValue(key, input) {
- const state = this.fields.get(key);
- if (!state) throw new TypeError(`Unknown configuration field "${key}".`);
- const sensitive = state.schema.sensitive || state.schema.type === "secret";
- if (sensitive && input === MASKED_SECRET) {
- state.displayValue = MASKED_SECRET;
- state.issues = [];
- return;
- }
- state.displayValue = input;
- let value;
- try {
- value = coerceFieldValue(state.schema, input);
- } catch (error) {
- state.issues = [
- {
- code: "type",
- message: error instanceof Error ? error.message : "Invalid value.",
- severity: "error",
- fieldKey: key,
- filePath: this.schema.path
- }
- ];
- return;
- }
- const issues = validateField2(state.schema, value).map((fieldIssue) => ({
- ...fieldIssue,
- filePath: this.schema.path
- }));
- state.issues = issues;
- if (issues.length > 0) return;
- this.workingDocument = this.adapter.set(this.workingDocument, key, value);
- state.current = value;
- if (sensitive) state.displayValue = MASKED_SECRET;
- }
- serializeSelectedFile() {
- return this.adapter.serialize(this.workingDocument);
- }
- serializeForDisplay() {
- let displayDocument = this.workingDocument;
- for (const field of allFields(this.schema)) {
- if (!(field.sensitive || field.type === "secret")) continue;
- const values = this.adapter.getAll(displayDocument, field.key);
- if (values.length === 0) continue;
- displayDocument = this.adapter.setAll(
- displayDocument,
- field.key,
- values.map(() => MASKED_SECRET)
- );
- }
- return this.adapter.serialize(displayDocument);
- }
- validateSerializedSource(source) {
- const document = this.adapter.parse(source);
- const issues = this.adapter.validate(document, this.schema);
- for (const field of allFields(this.schema)) {
- issues.push(...this.readValue(document, field).issues);
- }
- return issues;
- }
- loadedSource() {
- return this.adapter.serialize(this.originalDocument);
- }
- replaceWorkingSource(source) {
- let document = this.adapter.parse(source);
- for (const field of allFields(this.schema)) {
- if (!(field.sensitive || field.type === "secret")) continue;
- const displayedValues = this.adapter.getAll(document, field.key);
- if (!displayedValues.includes(MASKED_SECRET)) continue;
- const currentSecrets = this.adapter.getAllRaw(this.workingDocument, field.key);
- if (displayedValues.length !== currentSecrets.length) {
- throw new Error(
- `Secret occurrences for "${field.key}" cannot be added or removed in raw mode.`
- );
- }
- document = this.adapter.setAllRaw(
- document,
- field.key,
- displayedValues.map(
- (value, index) => value === MASKED_SECRET ? currentSecrets[index] : this.adapter.getAllRaw(document, field.key)[index]
- )
- );
- }
- this.workingDocument = document;
- this.schemaValue = schemaWithDetectedFields(
- this.declaredSchema,
- this.adapter.entries(document)
- );
- this.fields = this.readFields(this.originalDocument, this.workingDocument);
- }
- acceptSavedSource(source) {
- const document = this.adapter.parse(source);
- this.originalDocument = document;
- this.workingDocument = document;
- this.schemaValue = schemaWithDetectedFields(
- this.declaredSchema,
- this.adapter.entries(document)
- );
- this.fields = this.readFields(document, document);
- }
- snapshot() {
- const fields = {};
- const changes = [];
- const issues = [...this.workingDocument.issues];
- const documentDirty = this.adapter.serialize(this.workingDocument) !== this.adapter.serialize(this.originalDocument);
- for (const [key, state] of this.fields) {
- const sensitive = Boolean(state.schema.sensitive || state.schema.type === "secret");
- const dirty = !valuesEqual(state.current, state.original);
- const fieldIssues = state.issues.map((fieldIssue) => ({ ...fieldIssue }));
- const fieldSnapshot = {
- key,
- label: state.schema.label,
- type: state.schema.type,
- displayValue: state.displayValue,
- sensitive,
- dirty,
- issues: fieldIssues,
- ...!sensitive && state.current !== void 0 ? { value: state.current } : {}
- };
- fields[key] = fieldSnapshot;
- issues.push(...fieldIssues);
- if (dirty) {
- changes.push({
- key,
- label: state.schema.label,
- before: sensitive ? null : state.original,
- after: sensitive ? null : state.current,
- sensitive,
- restartRequired: Boolean(state.schema.restartRequired)
- });
- }
- }
- const unstructuredChanges = documentDirty && changes.length === 0;
- return deepFreezeSnapshot({
- filePath: this.schema.path,
- fields,
- changes,
- issues,
- dirty: documentDirty,
- unstructuredChanges,
- restartRequired: unstructuredChanges || changes.some((change) => change.restartRequired)
- });
- }
-};
-
-// ../../packages/config-editor/src/fingerprint.ts
-var SHA256_CONSTANTS = [
- 1116352408,
- 1899447441,
- 3049323471,
- 3921009573,
- 961987163,
- 1508970993,
- 2453635748,
- 2870763221,
- 3624381080,
- 310598401,
- 607225278,
- 1426881987,
- 1925078388,
- 2162078206,
- 2614888103,
- 3248222580,
- 3835390401,
- 4022224774,
- 264347078,
- 604807628,
- 770255983,
- 1249150122,
- 1555081692,
- 1996064986,
- 2554220882,
- 2821834349,
- 2952996808,
- 3210313671,
- 3336571891,
- 3584528711,
- 113926993,
- 338241895,
- 666307205,
- 773529912,
- 1294757372,
- 1396182291,
- 1695183700,
- 1986661051,
- 2177026350,
- 2456956037,
- 2730485921,
- 2820302411,
- 3259730800,
- 3345764771,
- 3516065817,
- 3600352804,
- 4094571909,
- 275423344,
- 430227734,
- 506948616,
- 659060556,
- 883997877,
- 958139571,
- 1322822218,
- 1537002063,
- 1747873779,
- 1955562222,
- 2024104815,
- 2227730452,
- 2361852424,
- 2428436474,
- 2756734187,
- 3204031479,
- 3329325298
-];
-var rotateRight = (value, bits) => value >>> bits | value << 32 - bits;
-var utf8Bytes = (source) => {
- const bytes = [];
- for (const character of source) {
- const point = character.codePointAt(0);
- if (point <= 127) bytes.push(point);
- else if (point <= 2047) {
- bytes.push(192 | point >>> 6, 128 | point & 63);
- } else if (point <= 65535) {
- bytes.push(
- 224 | point >>> 12,
- 128 | point >>> 6 & 63,
- 128 | point & 63
- );
- } else {
- bytes.push(
- 240 | point >>> 18,
- 128 | point >>> 12 & 63,
- 128 | point >>> 6 & 63,
- 128 | point & 63
- );
- }
- }
- return bytes;
-};
-var sha256 = (source) => {
- const bytes = utf8Bytes(source);
- const bitLength = bytes.length * 8;
- bytes.push(128);
- while (bytes.length % 64 !== 56) bytes.push(0);
- const high = Math.floor(bitLength / 4294967296);
- const low = bitLength >>> 0;
- for (let shift = 24; shift >= 0; shift -= 8) bytes.push(high >>> shift & 255);
- for (let shift = 24; shift >= 0; shift -= 8) bytes.push(low >>> shift & 255);
- const hash = [
- 1779033703,
- 3144134277,
- 1013904242,
- 2773480762,
- 1359893119,
- 2600822924,
- 528734635,
- 1541459225
- ];
- const words = new Array(64).fill(0);
- for (let offset = 0; offset < bytes.length; offset += 64) {
- for (let index = 0; index < 16; index += 1) {
- const cursor = offset + index * 4;
- words[index] = (bytes[cursor] << 24 | bytes[cursor + 1] << 16 | bytes[cursor + 2] << 8 | bytes[cursor + 3]) >>> 0;
- }
- for (let index = 16; index < 64; index += 1) {
- const previous15 = words[index - 15];
- const previous2 = words[index - 2];
- const sigma0 = rotateRight(previous15, 7) ^ rotateRight(previous15, 18) ^ previous15 >>> 3;
- const sigma1 = rotateRight(previous2, 17) ^ rotateRight(previous2, 19) ^ previous2 >>> 10;
- words[index] = words[index - 16] + sigma0 + words[index - 7] + sigma1 >>> 0;
- }
- let [a, b, c, d, e, f, g, h] = hash;
- for (let index = 0; index < 64; index += 1) {
- const sum1 = rotateRight(e, 6) ^ rotateRight(e, 11) ^ rotateRight(e, 25);
- const choose = e & f ^ ~e & g;
- const temporary1 = h + sum1 + choose + SHA256_CONSTANTS[index] + words[index] >>> 0;
- const sum0 = rotateRight(a, 2) ^ rotateRight(a, 13) ^ rotateRight(a, 22);
- const majority = a & b ^ a & c ^ b & c;
- const temporary2 = sum0 + majority >>> 0;
- h = g;
- g = f;
- f = e;
- e = d + temporary1 >>> 0;
- d = c;
- c = b;
- b = a;
- a = temporary1 + temporary2 >>> 0;
- }
- hash[0] = hash[0] + a >>> 0;
- hash[1] = hash[1] + b >>> 0;
- hash[2] = hash[2] + c >>> 0;
- hash[3] = hash[3] + d >>> 0;
- hash[4] = hash[4] + e >>> 0;
- hash[5] = hash[5] + f >>> 0;
- hash[6] = hash[6] + g >>> 0;
- hash[7] = hash[7] + h >>> 0;
- }
- return hash.map((word) => word.toString(16).padStart(8, "0")).join("");
-};
-var fingerprint = async (source) => sha256(source);
-
-// ../../packages/config-editor/src/gateway.ts
-var MISSING_FILE_FINGERPRINT = "missing";
-var isMissingFileError = (error) => {
- const message = error instanceof Error ? error.message : String(error);
- return /(?:\b404\b|not[ -]?found|missing)/i.test(message);
-};
-var withMissingFileFallback = (gateway2, suffixes = [".scroll_template", ".default"]) => {
- const resolvedPaths = /* @__PURE__ */ new Map();
- const fallbackSuffixes = typeof suffixes === "string" ? [suffixes] : suffixes;
- return {
- async load(path) {
- try {
- const content = await gateway2.load(path);
- resolvedPaths.set(path, path);
- return content;
- } catch (error) {
- if (!isMissingFileError(error)) throw error;
- const resolved = resolvedPaths.get(path);
- if (resolved && resolved !== path) {
- try {
- return await gateway2.load(resolved);
- } catch (fallbackError) {
- if (!isMissingFileError(fallbackError)) throw fallbackError;
- }
- }
- for (const suffix of fallbackSuffixes) {
- const fallback = `${path}${suffix}`;
- if (fallback === resolved) continue;
- try {
- const content = await gateway2.load(fallback);
- resolvedPaths.set(path, fallback);
- return content;
- } catch (fallbackError) {
- if (!isMissingFileError(fallbackError)) throw fallbackError;
- }
- }
- throw new Error(`Missing configuration file: ${path}`);
- }
- },
- async save(path, content, expectedFingerprint) {
- const resolved = resolvedPaths.get(path) ?? path;
- if (resolved !== path) {
- const result = await gateway2.save(
- path,
- content,
- MISSING_FILE_FINGERPRINT
- );
- if (result.status === "saved") resolvedPaths.set(path, path);
- return result;
- }
- return await gateway2.save(resolved, content, expectedFingerprint);
- }
- };
-};
-var schemaForPath = (manifest, path) => {
- const schema = path ? manifest.files.find((candidate) => candidate.path === path) : manifest.files[0];
- if (!schema) throw new TypeError(path ? `Manifest has no file "${path}".` : "Manifest has no files.");
- return schema;
-};
-var loadEditor = async (gateway2, manifest, path) => {
- const schema = schemaForPath(manifest, path);
- const adapter = createAdapterRegistry().get(schema.format);
- if (!adapter) throw new TypeError(`No adapter registered for "${schema.format}".`);
- const source = await gateway2.load(schema.path);
- return ConfigEditorStore.fromLoadedFile(schema, adapter.parse(source));
-};
-var saveSelectedFile = async (store, gateway2) => {
- if (store.snapshot().issues.some((issue2) => issue2.severity === "error")) {
- throw new Error("Configuration has validation errors and cannot be saved.");
- }
- const content = store.serializeSelectedFile();
- const serializedIssues = store.validateSerializedSource(content);
- if (serializedIssues.some((issue2) => issue2.severity === "error")) {
- throw new Error("Serialized configuration failed validation.");
- }
- const expectedFingerprint = await fingerprint(store.loadedSource());
- const result = await gateway2.save(store.schema.path, content, expectedFingerprint);
- if (result.status === "conflict") return result;
- const contentFingerprint = await fingerprint(content);
- if (result.fingerprint !== contentFingerprint) {
- throw new Error("Save verification failed: gateway fingerprint does not match content.");
- }
- const remote = await gateway2.load(store.schema.path);
- const remoteFingerprint = await fingerprint(remote);
- if (remoteFingerprint !== result.fingerprint || remote !== content) {
- throw new Error("Save verification failed: reloaded content differs from the saved content.");
- }
- store.acceptSavedSource(remote);
- return result;
-};
-
-// ../../packages/component/dist/index.js
-import { d as dfunc } from "druid:ui/ui";
-import { log, rerender, setHook } from "druid:ui/ui";
-import { Event } from "druid:ui/utils";
-import { log as log2, rerender as rerender2 } from "druid:ui/ui";
-var createAsyncBridge = (onSettled = () => void 0, trace = () => void 0) => {
- const pending = /* @__PURE__ */ new Map();
- const early = /* @__PURE__ */ new Map();
- const settle = (operation, result) => {
- if (result.tag === "ok") operation.resolve(result.val);
- else operation.reject(new Error(String(result.val)));
- onSettled();
- };
- const complete = (id, result) => {
- trace(`Async callback received for id: ${id} with result: ${result.tag}`);
- const operation = pending.get(id);
- if (!operation) {
- early.set(id, result);
- return;
- }
- pending.delete(id);
- settle(operation, result);
- };
- const wrap = (fn) => (...args) => new Promise((resolve, reject) => {
- const id = fn(...args);
- const operation = {
- resolve: (value) => resolve(value),
- reject
- };
- const earlyResult = early.get(id);
- if (earlyResult) {
- early.delete(id);
- settle(operation, earlyResult);
- return;
- }
- pending.set(id, operation);
- });
- return { complete, wrap };
-};
-var lowerPropertyValue = (value) => value === void 0 || value === null ? void 0 : String(value);
-var callbackMap = {};
-function emit(nodeid, event, e) {
- log(`Emit called for nodeid: ${nodeid}, event: ${event}`);
- const callbacks = callbackMap[nodeid];
- const result = callbacks?.[event]?.(e);
- if (result instanceof Promise) {
- result.then(() => rerender());
- }
-}
-var registerHooks = (id, fnresult) => {
- switch (true) {
- case !!fnresult.init:
- setHook(id, "init");
- callbackMap[id] = {
- ...callbackMap[id],
- init: fnresult.init
- };
- break;
- }
-};
-var createDFunc = (dfunc2) => {
- return (tag, props, ...children) => {
- children = children.flat();
- if (typeof tag !== "string") {
- if (typeof tag === "function") {
- const fnresult = tag(props);
- if (fnresult?.view) {
- const id3 = fnresult.view(props);
- registerHooks(id3, fnresult);
- return id3;
- } else {
- return tag(props);
- }
- }
- const id2 = tag.view(props);
- registerHooks(id2, tag);
- return id2;
- }
- const ps = { prop: [], on: [] };
- const cbObj = {};
- if (props) {
- for (const [key, value] of Object.entries(props)) {
- if (value instanceof Function) {
- const eventKey = key.startsWith("on") ? key.slice(2).toLowerCase() : key;
- cbObj[eventKey] = value;
- ps.on.push(eventKey);
- } else {
- const loweredValue = lowerPropertyValue(value);
- if (loweredValue === void 0) continue;
- if (typeof value === "boolean") {
- ps.prop.push({ key, value: loweredValue });
- continue;
- }
- ps.prop.push({ key, value: loweredValue });
- }
- }
- }
- const id = dfunc2(
- tag,
- ps,
- children.filter((c) => typeof c !== "boolean").map((c) => String(c))
- );
- callbackMap[id] = {
- ...callbackMap[id],
- ...cbObj
- };
- return id;
- };
-};
-var asyncBridge = createAsyncBridge(
- () => rerender(),
- (message) => log(message)
-);
-var asyncCallback = asyncBridge.complete;
-var rawAsyncToPromise = asyncBridge.wrap;
-var createComponent = (j) => ({
- init: (ctx) => j(ctx),
- emit,
- asyncComplete: asyncCallback
-});
-var d2 = createDFunc(dfunc);
-
-// ../../packages/config-editor/src/copy.ts
-var copy = {
- appTitle: "Server configuration",
- loading: "Loading configuration\u2026",
- formTab: "Form",
- rawTab: "Raw",
- filesHeading: "Configuration files",
- inspectorHeading: "Changes & validation",
- noChanges: "No pending changes",
- rawChanged: "Raw configuration changed",
- unknownKeys: "Unknown keys: Preserved",
- restartRequired: "Restart required",
- stopBeforeSave: "Stop the server before saving configuration files that it may rewrite.",
- noRestartRequired: "No restart required",
- save: "Save changes",
- saving: "Saving\u2026",
- saved: "Saved and verified",
- conflict: "Remote file changed. Reload before saving.",
- rawLabel: "Raw configuration",
- validationHeading: "Validation",
- valid: "Configuration is valid",
- secretChanged: "Secret changed",
- fileFormat: "Format"
-};
-
-// ../../packages/config-editor/src/styles.ts
-var EDITOR_STYLES = `
-:host {
- display: block;
- width: 100%;
- --druid-bg: #07100d;
- --druid-panel: #101b17;
- --druid-panel-raised: #15231d;
- --druid-border: #294137;
- --druid-border-strong: #3f6655;
- --druid-text: #f4f7f5;
- --druid-muted: #9eb0a8;
- --druid-accent: #a8ef9c;
- --druid-accent-ink: #10210f;
- --druid-warning: #f0c96b;
- --druid-error: #ff8b7f;
- color: var(--druid-text);
- font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
- color-scheme: dark;
-}
-* { box-sizing: border-box; }
-button, input, select, textarea { font: inherit; }
-button, input, select, textarea, a { outline: none; }
-button:focus-visible, input:focus-visible, select:focus-visible, textarea:focus-visible, a:focus-visible {
- box-shadow: 0 0 0 3px rgba(168, 239, 156, .28);
- border-color: var(--druid-accent);
-}
-.config-editor { min-height: 640px; background: radial-gradient(circle at 75% 0%, #173326 0, transparent 32%), var(--druid-bg); color: var(--druid-text); }
-.editor-header { min-height: 76px; display: flex; align-items: center; justify-content: space-between; gap: 20px; padding: 16px 22px; border-bottom: 1px solid var(--druid-border); }
-.eyebrow { color: var(--druid-accent); font-size: 11px; font-weight: 800; letter-spacing: .14em; text-transform: uppercase; }
-.editor-title { margin: 3px 0 0; font-size: clamp(20px, 2.4vw, 30px); line-height: 1.1; }
-.server-version { color: var(--druid-muted); font-size: 13px; }
-.editor-grid { display: grid; grid-template-columns: minmax(210px, 250px) minmax(420px, 1fr) minmax(260px, 320px); min-height: 564px; }
-.file-rail, .inspector { background: rgba(11, 21, 17, .8); }
-.file-rail { padding: 18px 14px; border-right: 1px solid var(--druid-border); }
-.rail-heading, .inspector h2 { margin: 0 0 12px; font-size: 12px; color: var(--druid-muted); letter-spacing: .08em; text-transform: uppercase; }
-.file-list { display: grid; gap: 8px; }
-.file-button { width: 100%; min-height: 52px; display: grid; gap: 3px; padding: 10px 12px; text-align: left; border: 1px solid transparent; border-radius: 10px; background: transparent; color: var(--druid-text); cursor: pointer; }
-.file-button:hover { background: var(--druid-panel); }
-.file-button[aria-current="true"] { border-color: var(--druid-border-strong); background: var(--druid-panel-raised); }
-.file-path { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 700; }
-.file-format { color: var(--druid-muted); font-size: 12px; text-transform: uppercase; }
-.editor-main { min-width: 0; padding: 18px 22px 92px; }
-.tabs { display: inline-flex; gap: 4px; padding: 4px; margin-bottom: 18px; border: 1px solid var(--druid-border); border-radius: 10px; background: #0b1511; }
-.tab { min-height: 40px; min-width: 92px; border: 0; border-radius: 7px; background: transparent; color: var(--druid-muted); cursor: pointer; }
-.tab[aria-selected="true"] { background: var(--druid-panel-raised); color: var(--druid-text); }
-.form-editor { display: grid; gap: 18px; }
-.field-section { min-width: 0; margin: 0; padding: 18px; border: 1px solid var(--druid-border); border-radius: 14px; background: rgba(16, 27, 23, .84); }
-.field-section legend { padding: 0 8px; color: var(--druid-accent); font-weight: 800; }
-.section-description { margin: 0 0 16px; color: var(--druid-muted); font-size: 13px; }
-.field-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; }
-.field-control { min-width: 0; display: grid; align-content: start; gap: 7px; }
-.field-control label { font-size: 13px; font-weight: 750; }
-.field-description { min-height: 34px; margin: 0; color: var(--druid-muted); font-size: 12px; line-height: 1.45; }
-.field-input { width: 100%; min-height: 44px; padding: 10px 12px; border: 1px solid var(--druid-border); border-radius: 9px; background: #09130f; color: var(--druid-text); }
-.boolean-control { min-height: 44px; display: flex; align-items: center; gap: 10px; padding: 8px 11px; border: 1px solid var(--druid-border); border-radius: 9px; background: #09130f; }
-.boolean-control input { width: 20px; height: 20px; accent-color: var(--druid-accent); }
-.field-meta { display: flex; flex-wrap: wrap; gap: 8px; color: var(--druid-muted); font-size: 11px; }
-.restart-chip { color: var(--druid-warning); }
-.field-error { margin: 0; color: var(--druid-error); font-size: 12px; }
-.raw-editor { display: grid; gap: 10px; }
-.raw-textarea { width: 100%; min-height: 440px; resize: vertical; padding: 16px; border: 1px solid var(--druid-border); border-radius: 12px; background: #050b08; color: #dce8e1; font: 13px/1.55 "Cascadia Code", "SFMono-Regular", Consolas, monospace; tab-size: 2; }
-.inspector { display: flex; flex-direction: column; gap: 18px; padding: 18px; border-left: 1px solid var(--druid-border); }
-.status-card { padding: 14px; border: 1px solid var(--druid-border); border-radius: 12px; background: var(--druid-panel); }
-.status-card p { margin: 4px 0; color: var(--druid-muted); font-size: 13px; }
-.change-list, .issue-list { display: grid; gap: 8px; margin: 0; padding: 0; list-style: none; }
-.change-item { padding: 10px; border: 1px solid var(--druid-border); border-radius: 9px; background: #0b1511; font-size: 13px; }
-.change-value { display: block; margin-top: 4px; color: var(--druid-muted); word-break: break-word; }
-.issue-item { color: var(--druid-error); font-size: 12px; }
-.action-bar { position: sticky; bottom: 0; display: flex; align-items: center; justify-content: space-between; gap: 16px; margin: auto 0 0; padding-top: 14px; background: linear-gradient(transparent, rgba(11, 21, 17, .98) 32%); }
-.save-button { min-height: 46px; padding: 0 18px; border: 0; border-radius: 9px; background: var(--druid-accent); color: var(--druid-accent-ink); font-weight: 850; cursor: pointer; }
-.save-button:disabled { opacity: .45; cursor: not-allowed; }
-.action-status { color: var(--druid-muted); font-size: 12px; }
-.error-shell { margin: 20px; padding: 18px; border: 1px solid var(--druid-error); border-radius: 12px; background: #28120f; color: #ffd8d2; }
-@media (max-width: 900px) {
- .editor-grid { grid-template-columns: 190px minmax(0, 1fr); }
- .inspector { grid-column: 1 / -1; border-left: 0; border-top: 1px solid var(--druid-border); }
-}
-@media (max-width: 560px) {
- .config-editor { min-height: 100%; }
- .editor-header { align-items: flex-start; padding: 14px 16px; }
- .editor-grid { display: block; }
- .file-rail { padding: 12px 16px; border-right: 0; border-bottom: 1px solid var(--druid-border); overflow-x: auto; }
- .file-list { display: flex; width: max-content; }
- .file-button { width: 190px; }
- .editor-main { padding: 16px 16px 28px; }
- .tabs { display: flex; }
- .tab { flex: 1; }
- .field-section { padding: 14px; }
- .field-grid { grid-template-columns: 1fr; }
- .raw-textarea { min-height: 360px; }
- .inspector { padding: 16px; }
- .action-bar { position: static; }
- .save-button { width: 100%; }
-}
-@media (prefers-reduced-motion: reduce) {
- *, *::before, *::after { scroll-behavior: auto !important; transition: none !important; animation: none !important; }
-}
-`;
-
-// ../../packages/component/dist/jsx-runtime.js
-function jsx(type, props) {
- const { children, ...rest } = props || {};
- if (children !== void 0) {
- return d2(type, rest, children);
- }
- return d2(type, rest);
-}
-var jsxs = jsx;
-var Fragment = Symbol.for("react.fragment");
-
-// ../../packages/config-editor/src/components/ActionBar.tsx
-var ActionBar = ({ dirty, invalid, saving, status, onSave }) => /* @__PURE__ */ jsxs("div", { class: "action-bar", children: [
- /* @__PURE__ */ jsx("span", { class: "action-status", "aria-live": "polite", children: status }),
- /* @__PURE__ */ jsx(
- "button",
- {
- type: "button",
- class: "save-button",
- disabled: !dirty || invalid || saving,
- onClick: () => onSave(),
- children: saving ? copy.saving : copy.save
- }
- )
-] });
-
-// ../../packages/config-editor/src/components/FileRail.tsx
-var FileRail = ({ manifest, selectedPath, onSelect }) => /* @__PURE__ */ jsxs("nav", { class: "file-rail", "aria-label": copy.filesHeading, children: [
- /* @__PURE__ */ jsx("h2", { class: "rail-heading", children: copy.filesHeading }),
- /* @__PURE__ */ jsx("div", { class: "file-list", children: manifest.files.map((file) => /* @__PURE__ */ jsxs(
- "button",
- {
- type: "button",
- class: "file-button",
- "aria-current": file.path === selectedPath ? "true" : "false",
- onClick: () => onSelect(file.path),
- children: [
- /* @__PURE__ */ jsx("span", { class: "file-path", children: file.label }),
- /* @__PURE__ */ jsx("span", { class: "file-format", children: file.format })
- ]
- }
- )) })
-] });
-
-// ../../packages/config-editor/src/components/FieldControl.tsx
-var FieldControl = ({ field, state, onChange }) => {
- const inputId = `config-field-${field.key.replace(/[^a-zA-Z0-9_-]/g, "-")}`;
- const descriptionId = `${inputId}-description`;
- const input = field.type === "enum" ? /* @__PURE__ */ jsx(
- "select",
- {
- id: inputId,
- class: "field-input",
- "aria-label": field.label,
- "aria-describedby": descriptionId,
- "aria-invalid": state.issues.length > 0 ? "true" : "false",
- value: state.displayValue,
- onChange: (event) => onChange(field.key, event.value()),
- children: (field.values ?? []).map((value) => /* @__PURE__ */ jsx("option", { value, selected: state.displayValue === value, children: value }))
- }
- ) : field.type === "boolean" ? /* @__PURE__ */ jsxs("span", { class: "boolean-control", children: [
- /* @__PURE__ */ jsx(
- "input",
- {
- id: inputId,
- type: "checkbox",
- "aria-label": field.label,
- "aria-describedby": descriptionId,
- checked: state.displayValue === "true",
- onChange: (event) => onChange(field.key, event.checked() ? "true" : "false")
- }
- ),
- /* @__PURE__ */ jsx("span", { children: state.displayValue === "true" ? "Enabled" : "Disabled" })
- ] }) : /* @__PURE__ */ jsx(
- "input",
- {
- id: inputId,
- class: "field-input",
- "aria-label": field.label,
- "aria-describedby": descriptionId,
- type: field.type === "secret" || field.sensitive ? "password" : field.type === "integer" || field.type === "number" ? "number" : "text",
- step: field.type === "integer" ? "1" : field.type === "number" ? "any" : void 0,
- min: field.min,
- max: field.max,
- value: state.displayValue,
- "aria-invalid": state.issues.length > 0 ? "true" : "false",
- autocomplete: field.type === "secret" || field.sensitive ? "new-password" : "off",
- onInput: (event) => onChange(field.key, event.value())
- }
- );
- return /* @__PURE__ */ jsxs("div", { class: "field-control", children: [
- /* @__PURE__ */ jsx("label", { for: inputId, children: field.label }),
- /* @__PURE__ */ jsx("p", { id: descriptionId, class: "field-description", children: field.description }),
- input,
- /* @__PURE__ */ jsxs("div", { class: "field-meta", children: [
- field.restartRequired ? /* @__PURE__ */ jsx("span", { class: "restart-chip", children: copy.restartRequired }) : false,
- /* @__PURE__ */ jsx("span", { children: field.key })
- ] }),
- state.issues.map((issue2) => /* @__PURE__ */ jsx("p", { class: "field-error", role: "alert", children: issue2.message }))
- ] });
-};
-
-// ../../packages/config-editor/src/components/FormEditor.tsx
-var FormEditor = ({ schema, snapshot, onChange }) => /* @__PURE__ */ jsx("div", { class: "form-editor", children: schema.sections.map((section) => /* @__PURE__ */ jsxs("fieldset", { class: "field-section", children: [
- /* @__PURE__ */ jsx("legend", { children: section.label }),
- section.description ? /* @__PURE__ */ jsx("p", { class: "section-description", children: section.description }) : false,
- /* @__PURE__ */ jsx("div", { class: "field-grid", children: section.fields.map((field) => {
- const state = snapshot.fields[field.key];
- return state ? /* @__PURE__ */ jsx(FieldControl, { field, state, onChange }) : false;
- }) })
-] })) });
-
-// ../../packages/config-editor/src/components/Inspector.tsx
-var printable = (value) => value === void 0 ? "Not set" : value === null ? "Protected" : String(value);
-var Inspector = ({ snapshot }) => /* @__PURE__ */ jsxs("aside", { class: "inspector", "aria-label": copy.inspectorHeading, children: [
- /* @__PURE__ */ jsxs("section", { children: [
- /* @__PURE__ */ jsx("h2", { children: copy.inspectorHeading }),
- snapshot.unstructuredChanges ? /* @__PURE__ */ jsx("div", { class: "status-card", children: /* @__PURE__ */ jsx("p", { children: copy.rawChanged }) }) : snapshot.changes.length === 0 ? /* @__PURE__ */ jsx("div", { class: "status-card", children: /* @__PURE__ */ jsx("p", { children: copy.noChanges }) }) : /* @__PURE__ */ jsx("ul", { class: "change-list", children: snapshot.changes.map((change) => /* @__PURE__ */ jsxs("li", { class: "change-item", children: [
- /* @__PURE__ */ jsx("strong", { children: change.label }),
- /* @__PURE__ */ jsx("span", { class: "change-value", children: change.sensitive ? copy.secretChanged : `${printable(change.before)} \u2192 ${printable(change.after)}` })
- ] })) })
- ] }),
- /* @__PURE__ */ jsxs("section", { class: "status-card", children: [
- /* @__PURE__ */ jsx("h2", { children: copy.validationHeading }),
- snapshot.issues.length === 0 ? /* @__PURE__ */ jsx("p", { children: copy.valid }) : /* @__PURE__ */ jsx("ul", { class: "issue-list", "aria-live": "polite", children: snapshot.issues.map((issue2) => /* @__PURE__ */ jsx("li", { class: "issue-item", children: issue2.message })) }),
- /* @__PURE__ */ jsx("p", { children: copy.unknownKeys }),
- /* @__PURE__ */ jsx("p", { children: snapshot.restartRequired ? copy.restartRequired : copy.noRestartRequired }),
- snapshot.restartRequired ? /* @__PURE__ */ jsx("p", { children: copy.stopBeforeSave }) : false
- ] })
-] });
-
-// ../../packages/config-editor/src/components/RawEditor.tsx
-var RawEditor = ({ source, onChange }) => /* @__PURE__ */ jsxs("div", { class: "raw-editor", children: [
- /* @__PURE__ */ jsx("label", { for: "config-raw-source", children: copy.rawLabel }),
- /* @__PURE__ */ jsx(
- "textarea",
- {
- id: "config-raw-source",
- class: "raw-textarea",
- "aria-label": copy.rawLabel,
- spellcheck: "false",
- value: source,
- onInput: (event) => onChange(event.value())
- }
- )
-] });
-
-// ../../packages/config-editor/src/components/EditorApp.tsx
-var EditorApp = ({
- manifest,
- store,
- snapshot,
- mode,
- saving,
- status,
- onMode,
- onSelect,
- onField,
- onRaw,
- onSave
-}) => /* @__PURE__ */ jsxs("div", { class: "config-editor", children: [
- /* @__PURE__ */ jsx("style", { children: EDITOR_STYLES }),
- /* @__PURE__ */ jsxs("header", { class: "editor-header", children: [
- /* @__PURE__ */ jsxs("div", { children: [
- /* @__PURE__ */ jsx("div", { class: "eyebrow", children: "Druid Admin UI" }),
- /* @__PURE__ */ jsx("h1", { class: "editor-title", children: manifest.server.displayName })
- ] }),
- /* @__PURE__ */ jsx("div", { class: "server-version", children: manifest.server.appVersion ? `Version ${manifest.server.appVersion}` : copy.appTitle })
- ] }),
- /* @__PURE__ */ jsxs("div", { class: "editor-grid", children: [
- /* @__PURE__ */ jsx(
- FileRail,
- {
- manifest,
- selectedPath: store.schema.path,
- onSelect
- }
- ),
- /* @__PURE__ */ jsxs("main", { class: "editor-main", children: [
- /* @__PURE__ */ jsxs("div", { class: "tabs", role: "tablist", "aria-label": "Editor mode", children: [
- /* @__PURE__ */ jsx(
- "button",
- {
- type: "button",
- class: "tab",
- role: "tab",
- disabled: store.schema.sections.length === 0,
- "aria-disabled": store.schema.sections.length === 0 ? "true" : "false",
- "aria-selected": mode === "form" ? "true" : "false",
- onClick: () => onMode("form"),
- children: copy.formTab
- }
- ),
- /* @__PURE__ */ jsx(
- "button",
- {
- type: "button",
- class: "tab",
- role: "tab",
- "aria-selected": mode === "raw" ? "true" : "false",
- onClick: () => onMode("raw"),
- children: copy.rawTab
- }
- )
- ] }),
- mode === "form" ? /* @__PURE__ */ jsx(FormEditor, { schema: store.schema, snapshot, onChange: onField }) : /* @__PURE__ */ jsx(RawEditor, { source: store.serializeForDisplay(), onChange: onRaw }),
- /* @__PURE__ */ jsx(
- ActionBar,
- {
- dirty: snapshot.dirty,
- invalid: snapshot.issues.some((issue2) => issue2.severity === "error"),
- saving,
- status,
- onSave
- }
- )
- ] }),
- /* @__PURE__ */ jsx(Inspector, { snapshot })
- ] })
-] });
-var createConfigEditorComponent = ({
- manifestPath,
- gateway: gateway2
-}) => {
- let manifest;
- const stores = /* @__PURE__ */ new Map();
- let selectedPath = "";
- let mode = "form";
- let loading = false;
- let saving = false;
- let status = "";
- let errorMessage = "";
- const selectFile = async (path) => {
- if (!manifest) return;
- const previousPath = selectedPath;
- status = copy.loading;
- rerender2();
- try {
- let store = stores.get(path);
- if (!store) {
- store = await loadEditor(gateway2, manifest, path);
- stores.set(path, store);
- }
- selectedPath = path;
- mode = store.schema.sections.length === 0 ? "raw" : "form";
- status = "";
- } catch (error) {
- selectedPath = previousPath;
- status = error instanceof Error ? error.message : String(error);
- if (!stores.has(previousPath)) throw error;
- } finally {
- rerender2();
- }
- };
- const initialise = async () => {
- loading = true;
- try {
- manifest = validateManifest(JSON.parse(await gateway2.load(manifestPath)));
- selectedPath = manifest.files[0].path;
- await selectFile(selectedPath);
- } catch (error) {
- errorMessage = error instanceof Error ? error.message : String(error);
- } finally {
- loading = false;
- rerender2();
- }
- };
- const save = async () => {
- const store = stores.get(selectedPath);
- if (!store) return;
- saving = true;
- status = copy.saving;
- try {
- const result = await saveSelectedFile(store, gateway2);
- status = result.status === "saved" ? copy.saved : copy.conflict;
- } catch (error) {
- status = error instanceof Error ? error.message : String(error);
- } finally {
- saving = false;
- rerender2();
- }
- };
- return createComponent(() => {
- if (!loading && !manifest && !errorMessage) void initialise();
- if (errorMessage) {
- return /* @__PURE__ */ jsxs("div", { class: "config-editor", children: [
- /* @__PURE__ */ jsx("style", { children: EDITOR_STYLES }),
- /* @__PURE__ */ jsx("div", { class: "error-shell", role: "alert", children: errorMessage })
- ] });
- }
- const store = stores.get(selectedPath);
- if (!manifest || !store) {
- return /* @__PURE__ */ jsxs("div", { class: "config-editor", children: [
- /* @__PURE__ */ jsx("style", { children: EDITOR_STYLES }),
- /* @__PURE__ */ jsx("div", { class: "status-card", "aria-live": "polite", children: copy.loading })
- ] });
- }
- const snapshot = store.snapshot();
- return /* @__PURE__ */ jsx(
- EditorApp,
- {
- manifest,
- store,
- snapshot,
- mode,
- saving,
- status,
- onMode: (nextMode) => {
- mode = nextMode;
- },
- onSelect: selectFile,
- onField: (key, value) => {
- store.setDisplayValue(key, value);
- status = "";
- },
- onRaw: (source) => {
- store.replaceWorkingSource(source);
- status = "";
- },
- onSave: save
- }
- );
- });
-};
-
-// ../../packages/plattform/dist/index.js
-import {
- request as requestRaw,
- loadFileFromDeployment as loadFileFromDeploymentRaw,
- saveFileToDeployment as saveFileToDeploymentRaw,
- saveFileToDeploymentIfMatch as saveFileToDeploymentIfMatchRaw
-} from "druid:ui/plattform";
-var request = rawAsyncToPromise(requestRaw);
-var loadFileFromDeployment = rawAsyncToPromise(
- loadFileFromDeploymentRaw
-);
-var saveFileToDeployment = rawAsyncToPromise(saveFileToDeploymentRaw);
-var saveFileToDeploymentIfMatch = rawAsyncToPromise(
- saveFileToDeploymentIfMatchRaw
-);
-
-// src/app.tsx
-var gateway = withMissingFileFallback({
- async load(path) {
- return await loadFileFromDeployment(path);
- },
- async save(path, content, expectedFingerprint) {
- return JSON.parse(
- await saveFileToDeploymentIfMatch(path, content, expectedFingerprint)
- );
- }
-});
-var component = createConfigEditorComponent({
- manifestPath: "private/config-editor.manifest.json",
- gateway
-});
-export {
- component
-};
diff --git a/examples/config-editor-plattform/public/wit/druid-component.wit b/examples/config-editor-plattform/public/wit/druid-component.wit
deleted file mode 100644
index b3a7ba8..0000000
--- a/examples/config-editor-plattform/public/wit/druid-component.wit
+++ /dev/null
@@ -1,16 +0,0 @@
-package druid:ui;
-
-
-interface component {
- use utils.{event, context};
-
- init: func(ctx: context) -> string;
- emit: func(nodeid: string, event: string, e: event);
- async-complete: func(id: string, value: result);
-}
-
-
-world druid-ui {
- import ui;
- export component;
-}
diff --git a/examples/config-editor-plattform/public/wit/druid-plattform.wit b/examples/config-editor-plattform/public/wit/druid-plattform.wit
deleted file mode 100644
index 2ebe3e1..0000000
--- a/examples/config-editor-plattform/public/wit/druid-plattform.wit
+++ /dev/null
@@ -1,14 +0,0 @@
-package druid:ui;
-
-interface plattform {
- request: func(url: string, method: string, body: string, header: tuple) -> string;
- load-file-from-deployment: func(path: string) -> string;
- save-file-to-deployment: func(path: string, content: string) -> string;
- save-file-to-deployment-if-match: func(path: string, content: string, expected-fingerprint: string) -> string;
-}
-
-world druid-plattform {
- import ui;
- import plattform;
- export component;
-}
diff --git a/examples/config-editor-plattform/public/wit/druid-ui.wit b/examples/config-editor-plattform/public/wit/druid-ui.wit
deleted file mode 100644
index 4e4b4f5..0000000
--- a/examples/config-editor-plattform/public/wit/druid-ui.wit
+++ /dev/null
@@ -1,35 +0,0 @@
-package druid:ui;
-
-
-interface ui {
-
- record prop { key: string, value: string, }
-
- record props {
- prop: list,
- on: list,
- }
-
- type async-id = string;
-
- type children = option>;
-
- d: func(element: string, props: props, children: children) -> string;
- log: func(msg: string);
- set-hook: func(id: async-id, hook: string);
- rerender: func();
-}
-
-interface utils {
-
- resource event {
- constructor(value: string, checked: bool);
- prevent-default: func();
- stop-propagation: func();
- value: func() -> string;
- checked: func() -> bool;
- }
-
- type context = list>;
-
-}
diff --git a/examples/config-editor-plattform/src/app.tsx b/examples/config-editor-plattform/src/app.tsx
deleted file mode 100644
index 2debba7..0000000
--- a/examples/config-editor-plattform/src/app.tsx
+++ /dev/null
@@ -1,27 +0,0 @@
-import {
- createConfigEditorComponent,
- withMissingFileFallback,
- type FileGateway,
-} from "../../../packages/config-editor/src/index.js";
-// The example consumes the workspace source so package edits participate in raw
-// hot reload during local development. Released Scroll UIs consume the package.
-import {
- loadFileFromDeployment,
- saveFileToDeploymentIfMatch,
-} from "@druid-ui/plattform";
-
-const gateway = withMissingFileFallback({
- async load(path) {
- return await loadFileFromDeployment(path);
- },
- async save(path, content, expectedFingerprint) {
- return JSON.parse(
- await saveFileToDeploymentIfMatch(path, content, expectedFingerprint),
- );
- },
-} satisfies FileGateway);
-
-export const component = createConfigEditorComponent({
- manifestPath: "private/config-editor.manifest.json",
- gateway,
-});
diff --git a/examples/config-editor-plattform/src/main.ts b/examples/config-editor-plattform/src/main.ts
deleted file mode 100644
index 05859ee..0000000
--- a/examples/config-editor-plattform/src/main.ts
+++ /dev/null
@@ -1,85 +0,0 @@
-import { DruidUI, PromiseToResult } from "@druid-ui/host";
-import { ViteHMR } from "@druid-ui/vite/client";
-import manifest from "../fixtures/config-editor.manifest.json";
-import serverProperties from "../fixtures/server.properties?raw";
-
-const query = new URLSearchParams(window.location.search);
-const fixtureManifest = structuredClone(manifest);
-if (query.get("rawOnly") === "1") fixtureManifest.files[0]!.sections = [];
-const serverPropertiesPath =
- query.get("templateOnly") === "1"
- ? "server.properties.scroll_template"
- : "server.properties";
-
-const files = new Map([
- ["private/config-editor.manifest.json", JSON.stringify(fixtureManifest)],
- [serverPropertiesPath, serverProperties],
-]);
-
-const druidUiElement = new DruidUI();
-druidUiElement.extensionObject = {
- "druid:ui/plattform": {
- request: PromiseToResult(async () => ""),
- loadFileFromDeployment: PromiseToResult(async (path: string) => {
- const content = files.get(path);
- if (content === undefined) throw new Error(`Missing fixture file: ${path}`);
- return content;
- }),
- saveFileToDeployment: PromiseToResult(async (path: string, content: string) => {
- files.set(path, content);
- return "saved";
- }),
- saveFileToDeploymentIfMatch: PromiseToResult(
- async (path: string, content: string, expectedFingerprint: string) => {
- const remote = files.get(path);
- if (remote === undefined && expectedFingerprint === "missing") {
- files.set(path, content);
- const savedDigest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(content));
- const fingerprint = Array.from(new Uint8Array(savedDigest), (byte) =>
- byte.toString(16).padStart(2, "0")
- ).join("");
- return JSON.stringify({ status: "saved", fingerprint });
- }
- if (remote === undefined) throw new Error(`Missing fixture file: ${path}`);
- const bytes = new TextEncoder().encode(remote);
- const digest = await crypto.subtle.digest("SHA-256", bytes);
- const remoteFingerprint = Array.from(new Uint8Array(digest), (byte) =>
- byte.toString(16).padStart(2, "0")
- ).join("");
- if (remoteFingerprint !== expectedFingerprint) {
- return JSON.stringify({ status: "conflict", remote, fingerprint: remoteFingerprint });
- }
- files.set(path, content);
- const savedDigest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(content));
- const fingerprint = Array.from(new Uint8Array(savedDigest), (byte) =>
- byte.toString(16).padStart(2, "0")
- ).join("");
- return JSON.stringify({ status: "saved", fingerprint });
- },
- ),
- },
-};
-
-const mode = query.get("mode");
-const sandbox = mode === "wasm";
-druidUiElement.setAttribute(
- "entrypoint",
- sandbox ? "/app.wasm" : "/app.bundled-raw.js",
-);
-if (!sandbox) druidUiElement.setAttribute("no-sandbox", "true");
-druidUiElement.setAttribute("profile", "false");
-
-document.getElementById("app")?.appendChild(druidUiElement);
-ViteHMR(druidUiElement);
-
-Object.assign(window, {
- __druidConfigEditorFixture: {
- mode: sandbox ? "wasm" : "raw",
- read(path: string) {
- return files.get(path);
- },
- replace(path: string, content: string) {
- files.set(path, content);
- },
- },
-});
diff --git a/examples/config-editor-plattform/tsconfig.json b/examples/config-editor-plattform/tsconfig.json
deleted file mode 100644
index 19ee7df..0000000
--- a/examples/config-editor-plattform/tsconfig.json
+++ /dev/null
@@ -1,17 +0,0 @@
-{
- "compilerOptions": {
- "target": "ESNext",
- "module": "ESNext",
- "moduleResolution": "bundler",
- "strict": true,
- "esModuleInterop": true,
- "skipLibCheck": true,
- "forceConsistentCasingInFileNames": true,
- "jsx": "react-jsx",
- "jsxImportSource": "@druid-ui/component",
- "types": ["@druid-ui/component/jsx", "vite/client"],
- "outDir": "./dist"
- },
- "include": ["src/**/*", "public/**/*"],
- "exclude": ["node_modules", "dist"]
-}
diff --git a/examples/config-editor-plattform/vite.config.ts b/examples/config-editor-plattform/vite.config.ts
deleted file mode 100644
index 91d8660..0000000
--- a/examples/config-editor-plattform/vite.config.ts
+++ /dev/null
@@ -1,24 +0,0 @@
-import wasm from "vite-plugin-wasm";
-import { defineConfig } from "vite";
-import { ViteHMRPlugin } from "@druid-ui/vite";
-
-export default defineConfig({
- plugins: [
- wasm(),
- ViteHMRPlugin("src/app.tsx", "raw", undefined, [
- "../../packages/config-editor/src/**/*.{ts,tsx}",
- ]),
- ],
- build: { target: "esnext" },
- optimizeDeps: {
- exclude: [
- "@bytecodealliance/jco",
- "@bytecodealliance/componentize-js",
- "@druid-ui/host",
- ],
- },
- server: {
- fs: { allow: ["../.."] },
- watch: { usePolling: true, interval: 150 },
- },
-});
diff --git a/examples/config-editor-plattform/wit/druid-plattform.wit b/examples/config-editor-plattform/wit/druid-plattform.wit
deleted file mode 100644
index 2ebe3e1..0000000
--- a/examples/config-editor-plattform/wit/druid-plattform.wit
+++ /dev/null
@@ -1,14 +0,0 @@
-package druid:ui;
-
-interface plattform {
- request: func(url: string, method: string, body: string, header: tuple) -> string;
- load-file-from-deployment: func(path: string) -> string;
- save-file-to-deployment: func(path: string, content: string) -> string;
- save-file-to-deployment-if-match: func(path: string, content: string, expected-fingerprint: string) -> string;
-}
-
-world druid-plattform {
- import ui;
- import plattform;
- export component;
-}
diff --git a/package-lock.json b/package-lock.json
index 19d91fa..85c20a8 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -23,22 +23,6 @@
"typescript": "~5.8.3"
}
},
- "examples/config-editor-plattform": {
- "version": "0.0.0",
- "dependencies": {
- "@druid-ui/component": "*",
- "@druid-ui/config-editor": "*",
- "@druid-ui/host": "*",
- "@druid-ui/plattform": "*",
- "@druid-ui/vite": "*"
- },
- "devDependencies": {
- "@druid-ui/build": "*",
- "typescript": "~5.8.3",
- "vite": "^7.1.7",
- "vite-plugin-wasm": "^3.5.0"
- }
- },
"examples/druid-plattform": {
"version": "0.0.0",
"devDependencies": {
@@ -1218,10 +1202,6 @@
"resolved": "packages/component",
"link": true
},
- "node_modules/@druid-ui/config-editor": {
- "resolved": "packages/config-editor",
- "link": true
- },
"node_modules/@druid-ui/host": {
"resolved": "packages/host",
"link": true
@@ -4899,10 +4879,6 @@
"node": ">=20"
}
},
- "node_modules/config-editor-plattform": {
- "resolved": "examples/config-editor-plattform",
- "link": true
- },
"node_modules/consola": {
"version": "3.4.2",
"resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz",
@@ -7731,6 +7707,7 @@
},
"devDependencies": {
"@types/node": "^22.0.0",
+ "chokidar": "^4.0.3",
"typescript": "~5.8.3"
}
},
@@ -8704,13 +8681,6 @@
"@esbuild/win32-x64": "0.25.12"
}
},
- "packages/config-editor": {
- "name": "@druid-ui/config-editor",
- "version": "2.0.0",
- "dependencies": {
- "@druid-ui/component": "*"
- }
- },
"packages/create-druid-ui": {
"version": "2.0.0",
"dependencies": {
@@ -10250,6 +10220,36 @@
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true,
"license": "MIT"
+ },
+ "node_modules/chokidar": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
+ "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "readdirp": "^4.0.1"
+ },
+ "engines": {
+ "node": ">= 14.16.0"
+ },
+ "funding": {
+ "url": "https://paulmillr.com/funding/"
+ }
+ },
+ "node_modules/readdirp": {
+ "version": "4.1.2",
+ "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
+ "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 14.18.0"
+ },
+ "funding": {
+ "type": "individual",
+ "url": "https://paulmillr.com/funding/"
+ }
}
}
}
diff --git a/packages/build/package.json b/packages/build/package.json
index 6f7a12c..0e269eb 100644
--- a/packages/build/package.json
+++ b/packages/build/package.json
@@ -39,6 +39,7 @@
},
"devDependencies": {
"@types/node": "^22.0.0",
+ "chokidar": "^4.0.3",
"typescript": "~5.8.3"
}
}
diff --git a/packages/build/src/cmd/index.ts b/packages/build/src/cmd/index.ts
index d38ca86..fd42740 100755
--- a/packages/build/src/cmd/index.ts
+++ b/packages/build/src/cmd/index.ts
@@ -1,7 +1,7 @@
#!/usr/bin/env node
import { buildWasm, buildRaw } from "../";
-import { watch } from "node:fs";
+import { watch } from "chokidar";
import { resolve } from "node:path";
console.log("Druid UI Build Tool");
@@ -67,22 +67,28 @@ if (watchMode) {
console.log("Watching for changes...");
const srcDir = resolve(entryFile, "..");
let building = false;
+ let buildQueued = false;
- watch(srcDir, { recursive: true }, async (_eventType, filename) => {
- if (
- filename &&
- (filename.endsWith(".tsx") ||
- filename.endsWith(".ts") ||
- filename.endsWith(".jsx") ||
- filename.endsWith(".js"))
- ) {
- if (building) return;
+ const rebuild = async () => {
+ if (building) {
+ buildQueued = true;
+ return;
+ }
+ do {
+ buildQueued = false;
building = true;
- console.log(`\nFile changed: ${filename}`);
await doBuild();
building = false;
- }
- });
+ } while (buildQueued);
+ };
+
+ watch(srcDir, { ignoreInitial: true, usePolling: true, interval: 250 })
+ .on("error", (error) => console.error("Watch failed:", error))
+ .on("all", (_event, path) => {
+ if (!/\.[jt]sx?$/.test(path)) return;
+ console.log(`\nFile changed: ${path}`);
+ void rebuild();
+ });
// Keep the process running
await new Promise(() => {});
diff --git a/packages/config-editor/package.json b/packages/config-editor/package.json
deleted file mode 100644
index c26142a..0000000
--- a/packages/config-editor/package.json
+++ /dev/null
@@ -1,31 +0,0 @@
-{
- "name": "@druid-ui/config-editor",
- "version": "2.0.0",
- "repository": {
- "type": "git",
- "url": "git+https://github.com/highcard-dev/druid-ui.git"
- },
- "type": "module",
- "main": "./dist/index.js",
- "types": "./dist/types/index.d.ts",
- "exports": {
- ".": {
- "types": "./dist/types/index.d.ts",
- "import": "./dist/index.js",
- "default": "./dist/index.js"
- }
- },
- "files": [
- "dist",
- "src"
- ],
- "scripts": {
- "build": "npm run build:lib && npm run build:types",
- "build:lib": "esbuild src/index.ts --bundle --outfile=dist/index.js --format=esm --platform=neutral --external:@druid-ui/component --external:druid:ui/*",
- "build:types": "tsc --emitDeclarationOnly --outDir dist/types",
- "test": "vitest run src"
- },
- "dependencies": {
- "@druid-ui/component": "*"
- }
-}
diff --git a/packages/config-editor/src/adapters/ini.test.ts b/packages/config-editor/src/adapters/ini.test.ts
deleted file mode 100644
index 102af2b..0000000
--- a/packages/config-editor/src/adapters/ini.test.ts
+++ /dev/null
@@ -1,151 +0,0 @@
-import { describe, expect, it } from "vitest";
-import { iniAdapter, keyValueAdapter, unrealIniAdapter } from "./ini.js";
-
-describe("iniAdapter", () => {
- it("changes only a value in the selected section", () => {
- const source =
- "; generated\r\n[Server]\r\nName = Old ; keep this\r\nPlayers=20\r\n\r\n[Other]\r\nName=Untouched\r\n";
- const document = iniAdapter.parse(source);
- expect(iniAdapter.get(document, "Server.Name")).toBe("Old");
- const changed = iniAdapter.set(document, "Server.Name", "Druid");
- expect(iniAdapter.serialize(changed)).toBe(
- source.replace("Name = Old", "Name = Druid"),
- );
- });
-
- it("uses the last duplicate key and keeps all other lines", () => {
- const source = "[Server]\nName=old\nName=new\n";
- const changed = iniAdapter.set(iniAdapter.parse(source), "Server.Name", "final");
- expect(iniAdapter.get(changed, "Server.Name")).toBe("final");
- expect(iniAdapter.serialize(changed)).toBe(
- "[Server]\nName=old\nName=final\n",
- );
- });
-
- it("preserves an untouched document byte-for-byte", () => {
- const source = "# comment\nroot=value\n[Section]\nempty=\n";
- expect(iniAdapter.serialize(iniAdapter.parse(source))).toBe(source);
- });
-});
-
-describe("unrealIniAdapter", () => {
- it("changes only an Unreal INI value in the selected section", () => {
- const source =
- "[/Script/ShooterGame.ShooterGameUserSettings]\r\nServerPassword=\r\nDifficultyOffset=0.2\r\n";
- const changed = unrealIniAdapter.set(
- unrealIniAdapter.parse(source),
- "/Script/ShooterGame.ShooterGameUserSettings.DifficultyOffset",
- 1,
- );
- expect(unrealIniAdapter.serialize(changed)).toBe(
- source.replace("0.2", "1"),
- );
- });
-
- it("retains Unreal array prefixes", () => {
- const source = "[Rules]\n+AllowedClasses=One\n+AllowedClasses=Two\n";
- const changed = unrealIniAdapter.set(
- unrealIniAdapter.parse(source),
- "Rules.+AllowedClasses",
- "Three",
- );
- expect(unrealIniAdapter.serialize(changed)).toBe(
- "[Rules]\n+AllowedClasses=One\n+AllowedClasses=Three\n",
- );
- });
-});
-
-describe("keyValueAdapter", () => {
- it("preserves comments and inline spacing while changing one value", () => {
- const source = "// server config\nhostname Druid\nmaxplayers = 20 // capacity\n";
- const document = keyValueAdapter.parse(source);
- expect(keyValueAdapter.get(document, "hostname")).toBe("Druid");
- expect(
- keyValueAdapter.serialize(keyValueAdapter.set(document, "maxplayers", 64)),
- ).toBe("// server config\nhostname Druid\nmaxplayers = 64 // capacity\n");
- });
-
- it("preserves shell-style quotes while editing their contents", () => {
- const source = 'servername="Palworld Server"\n';
- const document = keyValueAdapter.parse(source);
-
- expect(keyValueAdapter.get(document, "servername")).toBe("Palworld Server");
- expect(
- keyValueAdapter.serialize(
- keyValueAdapter.set(document, "servername", "Druid Server"),
- ),
- ).toBe('servername="Druid Server"\n');
- });
-
- it("escapes matching quote characters inside preserved double quotes", () => {
- const document = keyValueAdapter.parse('servername="Palworld Server"\n');
-
- const changed = keyValueAdapter.set(document, "servername", 'Druid "EU"');
- expect(keyValueAdapter.serialize(changed)).toBe('servername="Druid \\"EU\\""\n');
- expect(keyValueAdapter.get(changed, "servername")).toBe('Druid "EU"');
- });
-
- it("uses POSIX-safe quoting inside preserved shell single quotes", () => {
- const document = keyValueAdapter.parse("servername='Druid Server'\n");
- const changed = keyValueAdapter.set(document, "servername", "Druid's Server");
- expect(keyValueAdapter.serialize(changed)).toBe("servername='Druid'\\''s Server'\n");
- expect(keyValueAdapter.get(changed, "servername")).toBe("Druid's Server");
- });
-
- it("roundtrips POSIX apostrophes without consuming inline comments", () => {
- const document = keyValueAdapter.parse("servername='Old' # public name\n");
- const changed = keyValueAdapter.set(document, "servername", "Druid's");
- const serialized = keyValueAdapter.serialize(changed);
-
- expect(serialized).toBe("servername='Druid'\\''s' # public name\n");
- expect(keyValueAdapter.get(keyValueAdapter.parse(serialized), "servername")).toBe("Druid's");
- expect(
- keyValueAdapter.serialize(
- keyValueAdapter.set(keyValueAdapter.parse(serialized), "servername", "Druid's EU"),
- ),
- ).toBe("servername='Druid'\\''s EU' # public name\n");
- });
-
- it("does not treat comment markers inside quotes as inline comments", () => {
- const document = keyValueAdapter.parse('servername="Druid #1" # public name\n');
-
- expect(keyValueAdapter.get(document, "servername")).toBe("Druid #1");
- expect(
- keyValueAdapter.serialize(keyValueAdapter.set(document, "servername", "Druid #2")),
- ).toBe('servername="Druid #2" # public name\n');
- });
-
- it("preserves DayZ quotes and statement terminators", () => {
- const document = keyValueAdapter.parse('hostname = "Druid Server";\nmaxPlayers = 60;\n');
-
- expect(keyValueAdapter.get(document, "hostname")).toBe("Druid Server");
- expect(keyValueAdapter.get(document, "maxPlayers")).toBe("60");
- expect(
- keyValueAdapter.serialize(keyValueAdapter.set(document, "hostname", "Druid EU")),
- ).toBe('hostname = "Druid EU";\nmaxPlayers = 60;\n');
- });
-});
-
-describe("unrealIniAdapter OptionSettings", () => {
- it("discovers and edits Palworld tuple options independently", () => {
- const source =
- '[/Script/Pal.PalGameWorldSettings]\nOptionSettings=(Difficulty=None,ServerName="Palworld, EU",bIsPvP=false,DayTimeSpeedRate=1.000000)\n';
- const document = unrealIniAdapter.parse(source);
-
- expect(unrealIniAdapter.entries(document)).toEqual([
- { key: "/Script/Pal.PalGameWorldSettings.OptionSettings.Difficulty", value: "None" },
- { key: "/Script/Pal.PalGameWorldSettings.OptionSettings.ServerName", value: "Palworld, EU" },
- { key: "/Script/Pal.PalGameWorldSettings.OptionSettings.bIsPvP", value: "false" },
- { key: "/Script/Pal.PalGameWorldSettings.OptionSettings.DayTimeSpeedRate", value: "1.000000" },
- ]);
-
- const updated = unrealIniAdapter.set(
- document,
- "/Script/Pal.PalGameWorldSettings.OptionSettings.ServerName",
- "Druid, EU",
- );
- expect(unrealIniAdapter.serialize(updated)).toBe(
- source.replace('ServerName="Palworld, EU"', 'ServerName="Druid, EU"'),
- );
- });
-});
diff --git a/packages/config-editor/src/adapters/ini.ts b/packages/config-editor/src/adapters/ini.ts
deleted file mode 100644
index f91ca3e..0000000
--- a/packages/config-editor/src/adapters/ini.ts
+++ /dev/null
@@ -1,378 +0,0 @@
-import type { ConfigFormat, ConfigValue, FileSchema, ValidationIssue } from "../model.js";
-import { hasFinalNewline, preferredLineEnding, splitSourceLines } from "./line-document.js";
-import type {
- ConfigAdapter,
- LineNode,
- ParsedDocument,
- SourceSpan,
-} from "./types.js";
-
-interface IniEntry {
- key: string;
- value: string;
- valueSpan: SourceSpan;
- line: number;
- quote?: '"' | "'";
- container?: boolean;
-}
-
-interface IniSection {
- name: string;
- line: number;
-}
-
-export interface IniDocument extends ParsedDocument {
- format: "ini" | "unreal-ini" | "key-value";
- entries: readonly IniEntry[];
- sections: readonly IniSection[];
- lineEnding: "\n" | "\r\n";
- finalNewline: boolean;
-}
-
-const inlineCommentAt = (value: string): number => {
- let quote: '"' | "'" | undefined;
- for (let index = 0; index < value.length; index += 1) {
- const character = value[index]!;
- if (quote) {
- if (quote === "'" && value.startsWith("'\\''", index)) {
- index += 3;
- continue;
- }
- if (character === quote && value[index - 1] !== "\\") quote = undefined;
- continue;
- }
- if (character === '"' || character === "'") {
- quote = character;
- continue;
- }
- const precededBySpace = index > 0 && /\s/.test(value[index - 1]!);
- if (!precededBySpace) continue;
- if (character === ";" || character === "#") return index;
- if (character === "/" && value[index + 1] === "/") return index;
- }
- return value.length;
-};
-
-const decodeQuotedValue = (value: string, quote?: '"' | "'"): string => {
- if (quote === '"') return value.replace(/\\(["\\])/g, "$1");
- if (quote === "'") return value.replace(/'\\''/g, "'");
- return value;
-};
-
-const parseEntryLine = (
- raw: string,
- start: number,
- section: string | undefined,
- line: number,
- format: IniDocument["format"],
-): IniEntry | undefined => {
- let keyStart = 0;
- while (keyStart < raw.length && /[ \t]/.test(raw[keyStart]!)) keyStart += 1;
- if (keyStart === raw.length) return undefined;
-
- let keyEnd = keyStart;
- while (
- keyEnd < raw.length &&
- raw[keyEnd] !== "=" &&
- raw[keyEnd] !== ":" &&
- !/[ \t]/.test(raw[keyEnd]!)
- ) {
- keyEnd += 1;
- }
- if (keyEnd === keyStart) return undefined;
-
- let valueStart = keyEnd;
- while (valueStart < raw.length && /[ \t]/.test(raw[valueStart]!)) valueStart += 1;
- if (raw[valueStart] === "=" || raw[valueStart] === ":") valueStart += 1;
- while (valueStart < raw.length && /[ \t]/.test(raw[valueStart]!)) valueStart += 1;
-
- const comment = inlineCommentAt(raw.slice(valueStart));
- let valueEnd = valueStart + comment;
- while (valueEnd > valueStart && /[ \t]/.test(raw[valueEnd - 1]!)) valueEnd -= 1;
- if (format === "key-value" && raw[valueEnd - 1] === ";") {
- valueEnd -= 1;
- while (valueEnd > valueStart && /[ \t]/.test(raw[valueEnd - 1]!)) valueEnd -= 1;
- }
- const quote = raw[valueStart];
- let valueQuote: '"' | "'" | undefined;
- if (
- (quote === '"' || quote === "'") &&
- valueEnd > valueStart &&
- raw[valueEnd - 1] === quote
- ) {
- valueQuote = quote;
- valueStart += 1;
- valueEnd -= 1;
- }
- const localKey = raw.slice(keyStart, keyEnd).trim();
- return {
- key: section ? `${section}.${localKey}` : localKey,
- value: decodeQuotedValue(raw.slice(valueStart, valueEnd), valueQuote),
- valueSpan: { start: start + valueStart, end: start + valueEnd },
- line,
- ...(valueQuote ? { quote: valueQuote } : {}),
- };
-};
-
-const tupleEntries = (entry: IniEntry): IniEntry[] => {
- if (!entry.value.startsWith("(") || !entry.value.endsWith(")")) return [];
- const inner = entry.value.slice(1, -1);
- const segments: Array<{ start: number; end: number }> = [];
- let segmentStart = 0;
- let depth = 0;
- let quote: '"' | "'" | undefined;
- for (let index = 0; index <= inner.length; index += 1) {
- const character = inner[index];
- if (quote) {
- if (character === quote && inner[index - 1] !== "\\") quote = undefined;
- continue;
- }
- if (character === '"' || character === "'") {
- quote = character;
- continue;
- }
- if (character === "(" || character === "[" || character === "{") depth += 1;
- else if (character === ")" || character === "]" || character === "}") depth -= 1;
- if ((character === "," && depth === 0) || index === inner.length) {
- segments.push({ start: segmentStart, end: index });
- segmentStart = index + 1;
- }
- }
-
- const result: IniEntry[] = [];
- for (const segment of segments) {
- const raw = inner.slice(segment.start, segment.end);
- let equals = -1;
- depth = 0;
- quote = undefined;
- for (let index = 0; index < raw.length; index += 1) {
- const character = raw[index]!;
- if (quote) {
- if (character === quote && raw[index - 1] !== "\\") quote = undefined;
- continue;
- }
- if (character === '"' || character === "'") quote = character;
- else if (character === "(" || character === "[" || character === "{") depth += 1;
- else if (character === ")" || character === "]" || character === "}") depth -= 1;
- else if (character === "=" && depth === 0) {
- equals = index;
- break;
- }
- }
- if (equals < 1) continue;
- const key = raw.slice(0, equals).trim();
- let localStart = equals + 1;
- while (localStart < raw.length && /\s/.test(raw[localStart]!)) localStart += 1;
- let localEnd = raw.length;
- while (localEnd > localStart && /\s/.test(raw[localEnd - 1]!)) localEnd -= 1;
- const valueQuote = raw[localStart];
- let preservedQuote: '"' | "'" | undefined;
- if (
- (valueQuote === '"' || valueQuote === "'") &&
- localEnd > localStart &&
- raw[localEnd - 1] === valueQuote
- ) {
- preservedQuote = valueQuote;
- localStart += 1;
- localEnd -= 1;
- }
- const absoluteStart = entry.valueSpan.start + 1 + segment.start + localStart;
- result.push({
- key: `${entry.key}.${key}`,
- value: decodeQuotedValue(raw.slice(localStart, localEnd), preservedQuote),
- valueSpan: {
- start: absoluteStart,
- end: absoluteStart + localEnd - localStart,
- },
- line: entry.line,
- ...(preservedQuote ? { quote: preservedQuote } : {}),
- });
- }
- return result;
-};
-
-const parseIni = (
- source: string,
- format: IniDocument["format"],
-): IniDocument => {
- const lines = splitSourceLines(source);
- const entries: IniEntry[] = [];
- const sections: IniSection[] = [];
- const nodes: LineNode[] = [];
- let section: string | undefined;
-
- for (let index = 0; index < lines.length; index += 1) {
- const line = lines[index]!;
- const trimmed = line.raw.trimStart();
- if (trimmed === "") {
- nodes.push({ ...line, kind: "blank" });
- continue;
- }
- if (
- trimmed.startsWith(";") ||
- trimmed.startsWith("#") ||
- trimmed.startsWith("//")
- ) {
- nodes.push({ ...line, kind: "comment" });
- continue;
- }
-
- const sectionMatch = format === "key-value" ? null : trimmed.match(/^\[([^\]]+)](?:\s*(?:[;#].*)?)$/);
- if (sectionMatch) {
- section = sectionMatch[1]!.trim();
- sections.push({ name: section, line: index });
- nodes.push({ ...line, kind: "section" });
- continue;
- }
-
- const entry = parseEntryLine(line.raw, line.start, section, index, format);
- if (entry) {
- entries.push(entry);
- if (format === "unreal-ini") {
- const children = tupleEntries(entry);
- if (children.length > 0) {
- entry.container = true;
- entries.push(...children);
- }
- }
- nodes.push({
- ...line,
- kind: "entry",
- key: entry.key,
- value: entry.value,
- valueSpan: entry.valueSpan,
- });
- } else nodes.push({ ...line, kind: "invalid" });
- }
-
- return {
- format,
- source,
- nodes,
- entries,
- sections,
- issues: [],
- lineEnding: preferredLineEnding(lines),
- finalNewline: hasFinalNewline(source),
- };
-};
-
-const effectiveEntry = (document: IniDocument, key: string): IniEntry | undefined => {
- for (let index = document.entries.length - 1; index >= 0; index -= 1) {
- const entry = document.entries[index]!;
- if (entry.key === key) return entry;
- }
- return undefined;
-};
-
-const formatValue = (value: ConfigValue, quote?: '"' | "'"): string => {
- let formatted = (value === null ? "" : String(value)).replace(
- /[\r\n]/g,
- (character) => character === "\r" ? "\\r" : "\\n",
- );
- if (quote === '"') formatted = formatted.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
- else if (quote === "'") formatted = formatted.replace(/'/g, "'\\''");
- return formatted;
-};
-
-const appendIniEntry = (
- document: IniDocument,
- key: string,
- value: ConfigValue,
-): string => {
- const split = document.format === "key-value" ? -1 : key.lastIndexOf(".");
- const section = split < 0 ? undefined : key.slice(0, split);
- const localKey = split < 0 ? key : key.slice(split + 1);
- const assignment = `${localKey}=${formatValue(value)}`;
- const prefix = document.source === "" || document.finalNewline ? "" : document.lineEnding;
- const suffix = document.finalNewline ? document.lineEnding : "";
-
- if (!section || document.sections.some((candidate) => candidate.name === section)) {
- return `${document.source}${prefix}${assignment}${suffix}`;
- }
- const sectionHeader = `[${section}]${document.lineEnding}`;
- return `${document.source}${prefix}${sectionHeader}${assignment}${suffix}`;
-};
-
-const createIniAdapter = (
- format: IniDocument["format"],
-): ConfigAdapter => ({
- parse(source) {
- return parseIni(source, format);
- },
- entries(document) {
- const entries = new Map();
- for (const entry of document.entries) {
- if (!entry.container) entries.set(entry.key, entry.value);
- }
- return [...entries].map(([key, value]) => ({ key, value }));
- },
- get(document, key) {
- return effectiveEntry(document, key)?.value;
- },
- getAll(document, key) {
- return document.entries
- .filter((entry) => !entry.container && entry.key === key)
- .map((entry) => entry.value);
- },
- getAllRaw(document, key) {
- return document.entries
- .filter((entry) => !entry.container && entry.key === key)
- .map((entry) => document.source.slice(entry.valueSpan.start, entry.valueSpan.end));
- },
- set(document, key, value) {
- const entry = effectiveEntry(document, key);
- if (!entry) return parseIni(appendIniEntry(document, key, value), format);
- return parseIni(
- document.source.slice(0, entry.valueSpan.start) +
- formatValue(value, entry.quote) +
- document.source.slice(entry.valueSpan.end),
- format,
- );
- },
- setAll(document, key, values) {
- const entries = document.entries.filter(
- (entry) => !entry.container && entry.key === key,
- );
- if (entries.length !== values.length) {
- throw new Error(`Expected ${entries.length} values for setting "${key}".`);
- }
- let source = document.source;
- for (let index = entries.length - 1; index >= 0; index -= 1) {
- const entry = entries[index]!;
- source =
- source.slice(0, entry.valueSpan.start) +
- formatValue(values[index]!, entry.quote) +
- source.slice(entry.valueSpan.end);
- }
- return parseIni(source, format);
- },
- setAllRaw(document, key, values) {
- const entries = document.entries.filter(
- (entry) => !entry.container && entry.key === key,
- );
- if (entries.length !== values.length) {
- throw new Error(`Expected ${entries.length} raw values for setting "${key}".`);
- }
- let source = document.source;
- for (let index = entries.length - 1; index >= 0; index -= 1) {
- const entry = entries[index]!;
- source = source.slice(0, entry.valueSpan.start) + values[index]! + source.slice(entry.valueSpan.end);
- }
- return parseIni(source, format);
- },
- validate(document, schema: FileSchema): ValidationIssue[] {
- void schema;
- return [...document.issues];
- },
- serialize(document) {
- return document.source;
- },
-});
-
-export const iniAdapter = createIniAdapter("ini");
-export const unrealIniAdapter = createIniAdapter("unreal-ini");
-export const keyValueAdapter = createIniAdapter("key-value");
-
-export const isIniFormat = (format: ConfigFormat): boolean =>
- format === "ini" || format === "unreal-ini" || format === "key-value";
diff --git a/packages/config-editor/src/adapters/java-properties.test.ts b/packages/config-editor/src/adapters/java-properties.test.ts
deleted file mode 100644
index 757c3a6..0000000
--- a/packages/config-editor/src/adapters/java-properties.test.ts
+++ /dev/null
@@ -1,103 +0,0 @@
-import { describe, expect, it } from "vitest";
-import { javaPropertiesAdapter } from "./java-properties.js";
-
-const source =
- "#Minecraft server properties\r\nmax-players = 20\r\nunknown.future:key\\ value\r\n\r\n";
-
-describe("javaPropertiesAdapter", () => {
- it("serializes an untouched properties file byte-for-byte", () => {
- const document = javaPropertiesAdapter.parse(source);
- expect(javaPropertiesAdapter.serialize(document)).toBe(source);
- });
-
- it("changes only the selected value token", () => {
- const document = javaPropertiesAdapter.parse(source);
- const changed = javaPropertiesAdapter.set(document, "max-players", 50);
- expect(javaPropertiesAdapter.serialize(changed)).toBe(
- "#Minecraft server properties\r\nmax-players = 50\r\nunknown.future:key\\ value\r\n\r\n",
- );
- });
-
- it("uses the last duplicate key as the effective value and retains every line", () => {
- const duplicateSource = "motd=old\nmotd=new\n";
- const document = javaPropertiesAdapter.parse(duplicateSource);
- expect(javaPropertiesAdapter.get(document, "motd")).toBe("new");
- expect(
- javaPropertiesAdapter.serialize(
- javaPropertiesAdapter.set(document, "motd", "final"),
- ),
- ).toBe("motd=old\nmotd=final\n");
- });
-
- it.each([
- ["equals", "name=value\n", "name", "value"],
- ["colon", "name:value\n", "name", "value"],
- ["whitespace", "name value\n", "name", "value"],
- ["empty", "name=\n", "name", ""],
- ["escaped separator", "part\\=name=value\n", "part=name", "value"],
- ["escaped space", "server\\ name=Druid\n", "server name", "Druid"],
- ["unicode", "motd=Grüße 世界\n", "motd", "Grüße 世界"],
- ["unicode escape", "motd=Hello\\u0020World\n", "motd", "Hello World"],
- ])("reads %s syntax", (_name, input, key, expected) => {
- expect(javaPropertiesAdapter.get(javaPropertiesAdapter.parse(input), key)).toBe(
- expected,
- );
- });
-
- it.each([
- "key=value",
- "key=value\n",
- "key=value\r\n",
- "# comment\n! another\n\nkey=value\n",
- "continued=first\\\n second\n",
- ])("preserves untouched source %j", (input) => {
- expect(
- javaPropertiesAdapter.serialize(javaPropertiesAdapter.parse(input)),
- ).toBe(input);
- });
-
- it("decodes a continued logical value without changing its source", () => {
- const input = "motd=Hello \\\r\n Druid\\ World\r\n";
- const document = javaPropertiesAdapter.parse(input);
- expect(javaPropertiesAdapter.get(document, "motd")).toBe(
- "Hello Druid World",
- );
- expect(javaPropertiesAdapter.serialize(document)).toBe(input);
- });
-
- it("appends a missing key using the existing line ending and final-newline policy", () => {
- expect(
- javaPropertiesAdapter.serialize(
- javaPropertiesAdapter.set(
- javaPropertiesAdapter.parse("motd=Druid\r\n"),
- "max-players",
- 20,
- ),
- ),
- ).toBe("motd=Druid\r\nmax-players=20\r\n");
-
- expect(
- javaPropertiesAdapter.serialize(
- javaPropertiesAdapter.set(
- javaPropertiesAdapter.parse("motd=Druid"),
- "max-players",
- 20,
- ),
- ),
- ).toBe("motd=Druid\nmax-players=20");
- });
-
- it("escapes a newly appended key and string value safely", () => {
- const changed = javaPropertiesAdapter.set(
- javaPropertiesAdapter.parse(""),
- "server name",
- "Druid\\Realm\nLine",
- );
- expect(javaPropertiesAdapter.serialize(changed)).toBe(
- "server\\ name=Druid\\\\Realm\\nLine",
- );
- expect(javaPropertiesAdapter.get(changed, "server name")).toBe(
- "Druid\\Realm\nLine",
- );
- });
-});
diff --git a/packages/config-editor/src/adapters/java-properties.ts b/packages/config-editor/src/adapters/java-properties.ts
deleted file mode 100644
index ad4db67..0000000
--- a/packages/config-editor/src/adapters/java-properties.ts
+++ /dev/null
@@ -1,311 +0,0 @@
-import type { ConfigValue, FileSchema, ValidationIssue } from "../model.js";
-import { hasFinalNewline, preferredLineEnding, splitSourceLines } from "./line-document.js";
-import type {
- ConfigAdapter,
- LineNode,
- ParsedDocument,
- SourceLine,
- SourceSpan,
-} from "./types.js";
-
-interface JavaPropertiesEntry {
- key: string;
- value: string;
- valueSpan: SourceSpan;
- firstLine: number;
- lastLine: number;
-}
-
-export interface JavaPropertiesDocument extends ParsedDocument {
- format: "java-properties";
- entries: readonly JavaPropertiesEntry[];
- lineEnding: "\n" | "\r\n";
- finalNewline: boolean;
-}
-
-interface LogicalLine {
- text: string;
- offsets: number[];
- end: number;
- firstLine: number;
- lastLine: number;
-}
-
-const trailingBackslashes = (value: string): number => {
- let count = 0;
- for (let index = value.length - 1; index >= 0 && value[index] === "\\"; index -= 1) {
- count += 1;
- }
- return count;
-};
-
-const logicalLineAt = (
- lines: readonly SourceLine[],
- firstLine: number,
-): LogicalLine => {
- let text = "";
- const offsets: number[] = [];
- let lineIndex = firstLine;
- let continued = false;
-
- for (; lineIndex < lines.length; lineIndex += 1) {
- const line = lines[lineIndex]!;
- const trim = continued ? line.raw.match(/^[ \t\f]*/)?.[0].length ?? 0 : 0;
- const content = line.raw.slice(trim);
- const continues = trailingBackslashes(content) % 2 === 1;
- const logicalContent = continues ? content.slice(0, -1) : content;
-
- for (let index = 0; index < logicalContent.length; index += 1) {
- text += logicalContent[index]!;
- offsets.push(line.start + trim + index);
- }
-
- continued = continues;
- if (!continues) break;
- }
-
- const lastLine = Math.min(lineIndex, lines.length - 1);
- return {
- text,
- offsets,
- end: lines[lastLine]?.end ?? 0,
- firstLine,
- lastLine,
- };
-};
-
-const isEscaped = (value: string, index: number): boolean => {
- let slashes = 0;
- for (let cursor = index - 1; cursor >= 0 && value[cursor] === "\\"; cursor -= 1) {
- slashes += 1;
- }
- return slashes % 2 === 1;
-};
-
-const decodeEscapes = (value: string): string => {
- let decoded = "";
- for (let index = 0; index < value.length; index += 1) {
- const character = value[index]!;
- if (character !== "\\" || index === value.length - 1) {
- decoded += character;
- continue;
- }
-
- const escaped = value[(index += 1)]!;
- if (escaped === "t") decoded += "\t";
- else if (escaped === "n") decoded += "\n";
- else if (escaped === "r") decoded += "\r";
- else if (escaped === "f") decoded += "\f";
- else if (escaped === "u") {
- while (value[index + 1] === "u") index += 1;
- const hexadecimal = value.slice(index + 1, index + 5);
- if (/^[\da-f]{4}$/i.test(hexadecimal)) {
- decoded += String.fromCharCode(Number.parseInt(hexadecimal, 16));
- index += 4;
- } else {
- decoded += "u";
- }
- } else decoded += escaped;
- }
- return decoded;
-};
-
-const parseEntry = (logical: LogicalLine): JavaPropertiesEntry | undefined => {
- const { text } = logical;
- let start = 0;
- while (start < text.length && /[ \t\f]/.test(text[start]!)) start += 1;
- if (start === text.length || text[start] === "#" || text[start] === "!") {
- return undefined;
- }
-
- let keyEnd = start;
- while (keyEnd < text.length) {
- const character = text[keyEnd]!;
- if (!isEscaped(text, keyEnd) && (character === "=" || character === ":" || /[ \t\f]/.test(character))) {
- break;
- }
- keyEnd += 1;
- }
-
- let valueStart = keyEnd;
- while (valueStart < text.length && /[ \t\f]/.test(text[valueStart]!)) {
- valueStart += 1;
- }
- if (valueStart < text.length && (text[valueStart] === "=" || text[valueStart] === ":")) {
- valueStart += 1;
- }
- while (valueStart < text.length && /[ \t\f]/.test(text[valueStart]!)) {
- valueStart += 1;
- }
-
- return {
- key: decodeEscapes(text.slice(start, keyEnd)),
- value: decodeEscapes(text.slice(valueStart)),
- valueSpan: {
- start: logical.offsets[valueStart] ?? logical.end,
- end: logical.end,
- },
- firstLine: logical.firstLine,
- lastLine: logical.lastLine,
- };
-};
-
-const classifyNodes = (
- lines: readonly SourceLine[],
- entries: readonly JavaPropertiesEntry[],
-): LineNode[] => {
- const firstLines = new Map(entries.map((entry) => [entry.firstLine, entry]));
- const continuationLines = new Set();
- for (const entry of entries) {
- for (let index = entry.firstLine + 1; index <= entry.lastLine; index += 1) {
- continuationLines.add(index);
- }
- }
-
- return lines.map((line, index) => {
- const entry = firstLines.get(index);
- if (entry) {
- return {
- ...line,
- kind: "entry",
- key: entry.key,
- value: entry.value,
- valueSpan: entry.valueSpan,
- };
- }
- if (continuationLines.has(index)) return { ...line, kind: "continuation" };
- const trimmed = line.raw.trimStart();
- if (trimmed === "") return { ...line, kind: "blank" };
- if (trimmed.startsWith("#") || trimmed.startsWith("!")) {
- return { ...line, kind: "comment" };
- }
- return { ...line, kind: "invalid" };
- });
-};
-
-const parseJavaProperties = (source: string): JavaPropertiesDocument => {
- const lines = splitSourceLines(source);
- const entries: JavaPropertiesEntry[] = [];
-
- for (let lineIndex = 0; lineIndex < lines.length; lineIndex += 1) {
- const logical = logicalLineAt(lines, lineIndex);
- const entry = parseEntry(logical);
- if (entry) entries.push(entry);
- lineIndex = logical.lastLine;
- }
-
- return {
- format: "java-properties",
- source,
- nodes: classifyNodes(lines, entries),
- entries,
- issues: [],
- lineEnding: preferredLineEnding(lines),
- finalNewline: hasFinalNewline(source),
- };
-};
-
-const escapeKey = (value: string): string =>
- value.replace(/[\\ \t\f=:#!]/g, (character) => {
- if (character === "\t") return "\\t";
- if (character === "\f") return "\\f";
- return `\\${character}`;
- });
-
-const escapeValue = (value: ConfigValue): string => {
- const text = value === null ? "" : String(value);
- return text.replace(/[\\\t\n\r\f]/g, (character) => {
- if (character === "\\") return "\\\\";
- if (character === "\t") return "\\t";
- if (character === "\n") return "\\n";
- if (character === "\r") return "\\r";
- return "\\f";
- }).replace(/^ +/, (spaces) => "\\ ".repeat(spaces.length));
-};
-
-const effectiveEntry = (
- document: JavaPropertiesDocument,
- key: string,
-): JavaPropertiesEntry | undefined => {
- for (let index = document.entries.length - 1; index >= 0; index -= 1) {
- const entry = document.entries[index]!;
- if (entry.key === key) return entry;
- }
- return undefined;
-};
-
-const appendEntry = (
- document: JavaPropertiesDocument,
- key: string,
- value: ConfigValue,
-): string => {
- const entry = `${escapeKey(key)}=${escapeValue(value)}`;
- if (document.source === "") return entry;
- if (document.finalNewline) return `${document.source}${entry}${document.lineEnding}`;
- return `${document.source}${document.lineEnding}${entry}`;
-};
-
-export const javaPropertiesAdapter: ConfigAdapter = {
- parse: parseJavaProperties,
- entries(document) {
- const entries = new Map();
- for (const entry of document.entries) entries.set(entry.key, entry.value);
- return [...entries].map(([key, value]) => ({ key, value }));
- },
- get(document, key) {
- return effectiveEntry(document, key)?.value;
- },
- getAll(document, key) {
- return document.entries
- .filter((entry) => entry.key === key)
- .map((entry) => entry.value);
- },
- getAllRaw(document, key) {
- return document.entries
- .filter((entry) => entry.key === key)
- .map((entry) => document.source.slice(entry.valueSpan.start, entry.valueSpan.end));
- },
- set(document, key, value) {
- const entry = effectiveEntry(document, key);
- if (!entry) return parseJavaProperties(appendEntry(document, key, value));
- const source =
- document.source.slice(0, entry.valueSpan.start) +
- escapeValue(value) +
- document.source.slice(entry.valueSpan.end);
- return parseJavaProperties(source);
- },
- setAll(document, key, values) {
- const entries = document.entries.filter((entry) => entry.key === key);
- if (entries.length !== values.length) {
- throw new Error(`Expected ${entries.length} values for Java property "${key}".`);
- }
- let source = document.source;
- for (let index = entries.length - 1; index >= 0; index -= 1) {
- const entry = entries[index]!;
- source =
- source.slice(0, entry.valueSpan.start) +
- escapeValue(values[index]!) +
- source.slice(entry.valueSpan.end);
- }
- return parseJavaProperties(source);
- },
- setAllRaw(document, key, values) {
- const entries = document.entries.filter((entry) => entry.key === key);
- if (entries.length !== values.length) {
- throw new Error(`Expected ${entries.length} raw values for Java property "${key}".`);
- }
- let source = document.source;
- for (let index = entries.length - 1; index >= 0; index -= 1) {
- const entry = entries[index]!;
- source = source.slice(0, entry.valueSpan.start) + values[index]! + source.slice(entry.valueSpan.end);
- }
- return parseJavaProperties(source);
- },
- validate(document, schema: FileSchema): ValidationIssue[] {
- void schema;
- return [...document.issues];
- },
- serialize(document) {
- return document.source;
- },
-};
diff --git a/packages/config-editor/src/adapters/json.test.ts b/packages/config-editor/src/adapters/json.test.ts
deleted file mode 100644
index c7dd34e..0000000
--- a/packages/config-editor/src/adapters/json.test.ts
+++ /dev/null
@@ -1,76 +0,0 @@
-import { describe, expect, it } from "vitest";
-import { jsonAdapter } from "./json.js";
-
-describe("jsonAdapter", () => {
- it("updates a JSON scalar without reformatting siblings", () => {
- const source =
- '{\n "server": { "name": "Old", "players": 20 },\n "future": true\n}\n';
- const changed = jsonAdapter.set(
- jsonAdapter.parse(source),
- "/server/players",
- 50,
- );
- expect(jsonAdapter.serialize(changed)).toBe(
- '{\n "server": { "name": "Old", "players": 50 },\n "future": true\n}\n',
- );
- expect(jsonAdapter.get(changed, "/server/name")).toBe("Old");
- });
-
- it("supports escaped JSON pointer segments and array indexes", () => {
- const document = jsonAdapter.parse(
- '{"a/b":{"~key":[false,null,"value"]}}',
- );
- expect(jsonAdapter.get(document, "/a~1b/~0key/0")).toBe(false);
- expect(jsonAdapter.get(document, "/a~1b/~0key/1")).toBe(null);
- expect(jsonAdapter.get(document, "/a~1b/~0key/2")).toBe("value");
- });
-
- it("reports invalid JSON with a line and column", () => {
- const document = jsonAdapter.parse('{\n "broken":,\n}\n');
- expect(document.issues).toEqual([
- expect.objectContaining({
- code: "invalid-json",
- severity: "error",
- line: 2,
- column: 12,
- }),
- ]);
- });
-
- it("rejects writes to objects and properties whose parent is missing", () => {
- const document = jsonAdapter.parse('{"server":{"players":20}}');
- expect(() => jsonAdapter.set(document, "/server", "invalid")).toThrow(
- /scalar/,
- );
- expect(() => jsonAdapter.set(document, "/missing/nested", 1)).toThrow(/pointer/);
- });
-
- it("adds missing scalar properties without reformatting existing JSON", () => {
- const source = '{\n "server": {\n "name": "Druid"\n }\n}\n';
- const changed = jsonAdapter.set(jsonAdapter.parse(source), "/server/port", 5520);
-
- expect(jsonAdapter.serialize(changed)).toBe(
- '{\n "server": {\n "name": "Druid",\n "port": 5520\n }\n}\n',
- );
- expect(jsonAdapter.get(changed, "/server/port")).toBe(5520);
- });
-
- it("preserves CRLF line endings while adding a missing property", () => {
- const source = "{\r\n \"server\": {\r\n \"name\": \"Druid\"\r\n }\r\n}\r\n";
- const changed = jsonAdapter.set(jsonAdapter.parse(source), "/server/port", 5520);
-
- expect(jsonAdapter.serialize(changed)).toBe(
- "{\r\n \"server\": {\r\n \"name\": \"Druid\",\r\n \"port\": 5520\r\n }\r\n}\r\n",
- );
- });
-
- it("keeps unsafe JSON integer identifiers exact and editable as strings", () => {
- const source = '{"steamId":765611980123456789}\n';
- const document = jsonAdapter.parse(source);
- expect(jsonAdapter.get(document, "/steamId")).toBe("765611980123456789");
- expect(jsonAdapter.serialize(document)).toBe(source);
-
- const changed = jsonAdapter.set(document, "/steamId", "765611980123456780");
- expect(jsonAdapter.serialize(changed)).toBe('{"steamId":765611980123456780}\n');
- });
-});
diff --git a/packages/config-editor/src/adapters/json.ts b/packages/config-editor/src/adapters/json.ts
deleted file mode 100644
index db4f5ca..0000000
--- a/packages/config-editor/src/adapters/json.ts
+++ /dev/null
@@ -1,331 +0,0 @@
-import type { ConfigValue, FileSchema, ValidationIssue } from "../model.js";
-import { splitSourceLines } from "./line-document.js";
-import type { ConfigAdapter, LineNode, ParsedDocument, SourceSpan } from "./types.js";
-
-interface JsonScalar {
- value: ConfigValue;
- span: SourceSpan;
- unsafeInteger?: boolean;
-}
-
-interface JsonContainer {
- kind: "object" | "array";
- open: number;
- close: number;
-}
-
-export interface JsonDocument extends ParsedDocument {
- format: "json";
- scalars: ReadonlyMap;
- scalarOccurrences: ReadonlyMap;
- containers: ReadonlyMap;
-}
-
-class JsonParseError extends Error {
- readonly position: number;
-
- constructor(position: number, message: string) {
- super(message);
- this.position = position;
- }
-}
-
-const pointerSegment = (value: string): string =>
- value.replace(/~/g, "~0").replace(/\//g, "~1");
-
-const locationAt = (source: string, position: number): { line: number; column: number } => {
- const before = source.slice(0, position);
- const lines = before.split("\n");
- return { line: lines.length, column: lines.at(-1)!.length + 1 };
-};
-
-const jsonNodes = (source: string): LineNode[] =>
- splitSourceLines(source).map((line) => ({
- ...line,
- kind: line.raw.trim() === "" ? "blank" : "invalid",
- }));
-
-const parseJson = (source: string): JsonDocument => {
- const scalars = new Map();
- const scalarOccurrences = new Map();
- const containers = new Map();
- let cursor = 0;
- const addScalar = (pointer: string, scalar: JsonScalar): void => {
- scalars.set(pointer, scalar);
- const occurrences = scalarOccurrences.get(pointer) ?? [];
- occurrences.push(scalar);
- scalarOccurrences.set(pointer, occurrences);
- };
-
- const fail = (message: string): never => {
- throw new JsonParseError(cursor, message);
- };
- const skipWhitespace = (): void => {
- while (cursor < source.length && /\s/.test(source[cursor]!)) cursor += 1;
- };
- const consume = (character: string): void => {
- if (source[cursor] !== character) fail(`Expected "${character}".`);
- cursor += 1;
- };
- const parseString = (): { value: string; span: SourceSpan } => {
- const start = cursor;
- consume('"');
- while (cursor < source.length) {
- const character = source[cursor]!;
- if (character === '"') {
- cursor += 1;
- const token = source.slice(start, cursor);
- try {
- return { value: JSON.parse(token) as string, span: { start, end: cursor } };
- } catch {
- fail("Invalid JSON string.");
- }
- }
- if (character === "\\") {
- cursor += 2;
- continue;
- }
- if (character.charCodeAt(0) < 0x20) fail("Unescaped control character.");
- cursor += 1;
- }
- return fail("Unterminated JSON string.");
- };
- const parseValue = (pointer: string): void => {
- skipWhitespace();
- const start = cursor;
- const character = source[cursor];
- if (character === '"') {
- const string = parseString();
- addScalar(pointer, { value: string.value, span: string.span });
- return;
- }
- if (character === "{") {
- const open = cursor;
- cursor += 1;
- skipWhitespace();
- if (source[cursor] === "}") {
- containers.set(pointer, { kind: "object", open, close: cursor });
- cursor += 1;
- return;
- }
- while (cursor < source.length) {
- skipWhitespace();
- if (source[cursor] !== '"') fail("Expected an object key.");
- const key = parseString().value;
- skipWhitespace();
- consume(":");
- parseValue(`${pointer}/${pointerSegment(key)}`);
- skipWhitespace();
- if (source[cursor] === "}") {
- containers.set(pointer, { kind: "object", open, close: cursor });
- cursor += 1;
- return;
- }
- consume(",");
- }
- fail("Unterminated JSON object.");
- }
- if (character === "[") {
- const open = cursor;
- cursor += 1;
- skipWhitespace();
- if (source[cursor] === "]") {
- containers.set(pointer, { kind: "array", open, close: cursor });
- cursor += 1;
- return;
- }
- let index = 0;
- while (cursor < source.length) {
- parseValue(`${pointer}/${index}`);
- index += 1;
- skipWhitespace();
- if (source[cursor] === "]") {
- containers.set(pointer, { kind: "array", open, close: cursor });
- cursor += 1;
- return;
- }
- consume(",");
- }
- fail("Unterminated JSON array.");
- }
-
- const remainder = source.slice(cursor);
- const literal = remainder.match(/^(?:true|false|null)(?![\w])/);
- if (literal) {
- cursor += literal[0].length;
- addScalar(pointer, {
- value: JSON.parse(literal[0]) as ConfigValue,
- span: { start, end: cursor },
- });
- return;
- }
- const number = remainder.match(/^-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?/);
- if (number) {
- cursor += number[0].length;
- const unsafeInteger = /^-?\d+$/.test(number[0]) &&
- !Number.isSafeInteger(Number(number[0]));
- addScalar(pointer, {
- value: unsafeInteger ? number[0] : Number(number[0]),
- span: { start, end: cursor },
- ...(unsafeInteger ? { unsafeInteger: true } : {}),
- });
- return;
- }
- fail("Expected a JSON scalar, object, or array.");
- };
-
- try {
- parseValue("");
- skipWhitespace();
- if (cursor !== source.length) fail("Unexpected content after the JSON value.");
- return {
- format: "json",
- source,
- nodes: jsonNodes(source),
- issues: [],
- scalars,
- scalarOccurrences,
- containers,
- };
- } catch (error) {
- if (!(error instanceof JsonParseError)) throw error;
- const location = locationAt(source, error.position);
- return {
- format: "json",
- source,
- nodes: jsonNodes(source),
- issues: [
- {
- code: "invalid-json",
- message: error.message,
- severity: "error",
- ...location,
- },
- ],
- scalars: new Map(),
- scalarOccurrences: new Map(),
- containers: new Map(),
- };
- }
-};
-
-const decodePointerSegment = (value: string): string =>
- value.replace(/~1/g, "/").replace(/~0/g, "~");
-
-const insertJsonProperty = (
- document: JsonDocument,
- pointer: string,
- value: ConfigValue,
-): JsonDocument => {
- const separator = pointer.lastIndexOf("/");
- if (separator < 0) throw new Error(`JSON pointer "${pointer}" does not exist.`);
- const parentPointer = pointer.slice(0, separator);
- const property = decodePointerSegment(pointer.slice(separator + 1));
- const parent = document.containers.get(parentPointer);
- if (!parent || parent.kind !== "object") {
- throw new Error(`JSON pointer "${pointer}" does not exist.`);
- }
-
- const content = document.source.slice(parent.open + 1, parent.close);
- const contentEnd = parent.close - (content.match(/\s*$/)?.[0].length ?? 0);
- const closingLine = document.source.slice(0, parent.close).split("\n").at(-1) ?? "";
- const closingIndent = closingLine.match(/^\s*/)?.[0] ?? "";
- const serialized = `${JSON.stringify(property)}: ${JSON.stringify(value)}`;
- const newline = document.source.includes("\r\n") ? "\r\n" : "\n";
- let insertion: string;
- if (content.trim() === "") {
- insertion = `${newline}${closingIndent} ${serialized}${newline}${closingIndent}`;
- return parseJson(
- document.source.slice(0, parent.open + 1) +
- insertion +
- document.source.slice(parent.close),
- );
- }
-
- const multiline = content.includes("\n");
- if (!multiline) insertion = `, ${serialized}`;
- else {
- const existingLine = document.source.slice(0, contentEnd).split("\n").at(-1) ?? "";
- const childIndent = existingLine.match(/^\s*/)?.[0] ?? `${closingIndent} `;
- insertion = `,${newline}${childIndent}${serialized}`;
- }
- return parseJson(
- document.source.slice(0, contentEnd) +
- insertion +
- document.source.slice(contentEnd),
- );
-};
-
-export const jsonAdapter: ConfigAdapter = {
- parse: parseJson,
- entries(document) {
- return [...document.scalars].map(([key, scalar]) => ({
- key,
- value: scalar.value,
- }));
- },
- get(document, key) {
- return document.scalars.get(key)?.value;
- },
- getAll(document, key) {
- return (document.scalarOccurrences.get(key) ?? []).map((scalar) => scalar.value);
- },
- getAllRaw(document, key) {
- return (document.scalarOccurrences.get(key) ?? []).map((scalar) =>
- document.source.slice(scalar.span.start, scalar.span.end)
- );
- },
- set(document, key, value) {
- if (document.issues.length > 0) throw new Error("Invalid JSON cannot be edited in form mode.");
- const scalar = document.scalars.get(key);
- if (!scalar) {
- if (document.containers.has(key)) throw new Error(`JSON pointer "${key}" is not a scalar.`);
- return insertJsonProperty(document, key, value);
- }
- const serialized = scalar.unsafeInteger && typeof value === "string" && /^-?\d+$/.test(value)
- ? value
- : JSON.stringify(value);
- return parseJson(
- document.source.slice(0, scalar.span.start) +
- serialized +
- document.source.slice(scalar.span.end),
- );
- },
- setAll(document, key, values) {
- const occurrences = document.scalarOccurrences.get(key) ?? [];
- const expected = occurrences.length;
- if (values.length !== expected) {
- throw new Error(`Expected ${expected} values for JSON pointer "${key}".`);
- }
- let source = document.source;
- for (let index = occurrences.length - 1; index >= 0; index -= 1) {
- const scalar = occurrences[index]!;
- const value = values[index]!;
- const serialized = scalar.unsafeInteger && typeof value === "string" && /^-?\d+$/.test(value)
- ? value
- : JSON.stringify(value);
- source = source.slice(0, scalar.span.start) + serialized + source.slice(scalar.span.end);
- }
- return parseJson(source);
- },
- setAllRaw(document, key, values) {
- const occurrences = document.scalarOccurrences.get(key) ?? [];
- const expected = occurrences.length;
- if (values.length !== expected) {
- throw new Error(`Expected ${expected} raw values for JSON pointer "${key}".`);
- }
- let source = document.source;
- for (let index = occurrences.length - 1; index >= 0; index -= 1) {
- const scalar = occurrences[index]!;
- source = source.slice(0, scalar.span.start) + values[index]! + source.slice(scalar.span.end);
- }
- return parseJson(source);
- },
- validate(document, schema: FileSchema): ValidationIssue[] {
- void schema;
- return [...document.issues];
- },
- serialize(document) {
- return document.source;
- },
-};
diff --git a/packages/config-editor/src/adapters/line-document.ts b/packages/config-editor/src/adapters/line-document.ts
deleted file mode 100644
index d688d7c..0000000
--- a/packages/config-editor/src/adapters/line-document.ts
+++ /dev/null
@@ -1,33 +0,0 @@
-import type { SourceLine } from "./types.js";
-
-export const splitSourceLines = (source: string): SourceLine[] => {
- const lines: SourceLine[] = [];
- let start = 0;
-
- while (start < source.length) {
- const newline = source.indexOf("\n", start);
- if (newline === -1) {
- lines.push({ raw: source.slice(start), ending: "", start, end: source.length });
- break;
- }
-
- const hasCarriageReturn = newline > start && source[newline - 1] === "\r";
- const rawEnd = hasCarriageReturn ? newline - 1 : newline;
- lines.push({
- raw: source.slice(start, rawEnd),
- ending: hasCarriageReturn ? "\r\n" : "\n",
- start,
- end: rawEnd,
- });
- start = newline + 1;
- }
-
- return lines;
-};
-
-export const preferredLineEnding = (
- lines: readonly SourceLine[],
-): "\n" | "\r\n" =>
- lines.find((line) => line.ending !== "")?.ending || "\n";
-
-export const hasFinalNewline = (source: string): boolean => source.endsWith("\n");
diff --git a/packages/config-editor/src/adapters/raw.test.ts b/packages/config-editor/src/adapters/raw.test.ts
deleted file mode 100644
index 2578d82..0000000
--- a/packages/config-editor/src/adapters/raw.test.ts
+++ /dev/null
@@ -1,14 +0,0 @@
-import { describe, expect, it } from "vitest";
-import { rawAdapter } from "./raw.js";
-
-describe("rawAdapter", () => {
- it("round-trips arbitrary content byte-for-byte", () => {
- const source = "arbitrary\r\n\0content\n";
- expect(rawAdapter.serialize(rawAdapter.parse(source))).toBe(source);
- });
-
- it("blocks typed writes through the raw fallback", () => {
- const document = rawAdapter.parse("arbitrary content\n");
- expect(() => rawAdapter.set(document, "key", "value")).toThrow(/Raw mode/);
- });
-});
diff --git a/packages/config-editor/src/adapters/raw.ts b/packages/config-editor/src/adapters/raw.ts
deleted file mode 100644
index ba5824b..0000000
--- a/packages/config-editor/src/adapters/raw.ts
+++ /dev/null
@@ -1,49 +0,0 @@
-import type { ConfigValue, FileSchema, ValidationIssue } from "../model.js";
-import { splitSourceLines } from "./line-document.js";
-import type { ConfigAdapter, LineNode, ParsedDocument } from "./types.js";
-
-export interface RawDocument extends ParsedDocument {
- format: "raw";
-}
-
-const parseRaw = (source: string): RawDocument => {
- const nodes: LineNode[] = splitSourceLines(source).map((line) => ({
- ...line,
- kind: line.raw.trim() === "" ? "blank" : "invalid",
- }));
- return { format: "raw", source, nodes, issues: [] };
-};
-
-export const rawAdapter: ConfigAdapter = {
- parse: parseRaw,
- entries() {
- return [];
- },
- get() {
- return undefined;
- },
- getAll() {
- return [];
- },
- getAllRaw() {
- return [];
- },
- set(_document, _key, _value: ConfigValue) {
- throw new Error("Raw mode does not support typed field writes.");
- },
- setAll(document, _key, values) {
- if (values.length > 0) throw new Error("Raw mode does not support typed field writes.");
- return document;
- },
- setAllRaw(document, _key, values) {
- if (values.length > 0) throw new Error("Raw mode does not support typed field writes.");
- return document;
- },
- validate(document, schema: FileSchema): ValidationIssue[] {
- void schema;
- return [...document.issues];
- },
- serialize(document) {
- return document.source;
- },
-};
diff --git a/packages/config-editor/src/adapters/registry.test.ts b/packages/config-editor/src/adapters/registry.test.ts
deleted file mode 100644
index 4718175..0000000
--- a/packages/config-editor/src/adapters/registry.test.ts
+++ /dev/null
@@ -1,17 +0,0 @@
-import { describe, expect, it } from "vitest";
-import { createAdapterRegistry } from "./registry.js";
-
-describe("createAdapterRegistry", () => {
- it("provides an adapter for every supported manifest format", () => {
- const registry = createAdapterRegistry();
- expect([...registry.keys()]).toEqual([
- "java-properties",
- "ini",
- "unreal-ini",
- "key-value",
- "json",
- "xml-properties",
- "raw",
- ]);
- });
-});
diff --git a/packages/config-editor/src/adapters/registry.ts b/packages/config-editor/src/adapters/registry.ts
deleted file mode 100644
index 6a798e7..0000000
--- a/packages/config-editor/src/adapters/registry.ts
+++ /dev/null
@@ -1,20 +0,0 @@
-import type { ConfigFormat } from "../model.js";
-import { iniAdapter, keyValueAdapter, unrealIniAdapter } from "./ini.js";
-import { javaPropertiesAdapter } from "./java-properties.js";
-import { jsonAdapter } from "./json.js";
-import { rawAdapter } from "./raw.js";
-import { xmlPropertiesAdapter } from "./xml-properties.js";
-import type { ConfigAdapter } from "./types.js";
-
-export type AdapterRegistry = ReadonlyMap;
-
-export const createAdapterRegistry = (): AdapterRegistry =>
- new Map([
- ["java-properties", javaPropertiesAdapter],
- ["ini", iniAdapter],
- ["unreal-ini", unrealIniAdapter],
- ["key-value", keyValueAdapter],
- ["json", jsonAdapter],
- ["xml-properties", xmlPropertiesAdapter],
- ["raw", rawAdapter],
- ]);
diff --git a/packages/config-editor/src/adapters/types.ts b/packages/config-editor/src/adapters/types.ts
deleted file mode 100644
index 5801552..0000000
--- a/packages/config-editor/src/adapters/types.ts
+++ /dev/null
@@ -1,60 +0,0 @@
-import type {
- ConfigFormat,
- ConfigValue,
- FileSchema,
- ValidationIssue,
-} from "../model.js";
-
-export interface SourceSpan {
- start: number;
- end: number;
-}
-
-export interface SourceLine {
- raw: string;
- ending: "\n" | "\r\n" | "";
- start: number;
- end: number;
-}
-
-export interface LineNode extends SourceLine {
- kind:
- | "blank"
- | "comment"
- | "section"
- | "entry"
- | "continuation"
- | "invalid";
- key?: string;
- value?: string;
- valueSpan?: SourceSpan;
-}
-
-export interface ParsedDocument {
- format: ConfigFormat;
- source: string;
- nodes: readonly LineNode[];
- issues: readonly ValidationIssue[];
-}
-
-export interface ConfigEntry {
- key: string;
- value: ConfigValue;
-}
-
-export interface ConfigAdapter {
- parse(source: string): TDocument;
- entries(document: TDocument): readonly ConfigEntry[];
- get(document: TDocument, key: string): ConfigValue | undefined;
- getAll(document: TDocument, key: string): readonly ConfigValue[];
- getAllRaw(document: TDocument, key: string): readonly string[];
- set(document: TDocument, key: string, value: ConfigValue): TDocument;
- setAll(
- document: TDocument,
- key: string,
- values: readonly ConfigValue[],
- ): TDocument;
- setAllRaw(document: TDocument, key: string, values: readonly string[]): TDocument;
- validate(document: TDocument, schema: FileSchema): ValidationIssue[];
- serialize(document: TDocument): string;
-}
diff --git a/packages/config-editor/src/adapters/xml-properties.test.ts b/packages/config-editor/src/adapters/xml-properties.test.ts
deleted file mode 100644
index 823e603..0000000
--- a/packages/config-editor/src/adapters/xml-properties.test.ts
+++ /dev/null
@@ -1,37 +0,0 @@
-import { describe, expect, it } from "vitest";
-import { xmlPropertiesAdapter } from "./xml-properties.js";
-
-describe("xmlPropertiesAdapter", () => {
- it("discovers 7 Days to Die property elements", () => {
- const document = xmlPropertiesAdapter.parse(
- '\n \n \n\n',
- );
-
- expect(xmlPropertiesAdapter.entries(document)).toEqual([
- { key: "ServerName", value: "Druid" },
- { key: "MaxPlayerCount", value: "8" },
- ]);
- });
-
- it("changes only the selected XML attribute value", () => {
- const source = '\n';
- const document = xmlPropertiesAdapter.parse(source);
- const updated = xmlPropertiesAdapter.set(document, "ServerName", 'Druid "EU" & Friends');
-
- expect(xmlPropertiesAdapter.get(document, "ServerName")).toBe("Druid & Friends");
- expect(xmlPropertiesAdapter.serialize(updated)).toBe(
- '\n',
- );
- });
-
- it("uses the last duplicate as the effective setting", () => {
- const document = xmlPropertiesAdapter.parse(
- '\n\n',
- );
-
- expect(xmlPropertiesAdapter.get(document, "ServerName")).toBe("new");
- expect(xmlPropertiesAdapter.entries(document)).toEqual([
- { key: "ServerName", value: "new" },
- ]);
- });
-});
diff --git a/packages/config-editor/src/adapters/xml-properties.ts b/packages/config-editor/src/adapters/xml-properties.ts
deleted file mode 100644
index eba49db..0000000
--- a/packages/config-editor/src/adapters/xml-properties.ts
+++ /dev/null
@@ -1,152 +0,0 @@
-import type { ConfigValue, FileSchema, ValidationIssue } from "../model.js";
-import { splitSourceLines } from "./line-document.js";
-import type { ConfigAdapter, LineNode, ParsedDocument, SourceSpan } from "./types.js";
-
-interface XmlProperty {
- key: string;
- value: string;
- valueSpan: SourceSpan;
- quote: '"' | "'";
-}
-
-export interface XmlPropertiesDocument extends ParsedDocument {
- format: "xml-properties";
- properties: readonly XmlProperty[];
-}
-
-const decodeXml = (value: string): string =>
- value.replace(/&(?:#x[\da-f]+|#\d+|amp|quot|apos|lt|gt);/gi, (entity) => {
- const token = entity.slice(1, -1);
- if (token.toLowerCase().startsWith("#x")) {
- return String.fromCodePoint(Number.parseInt(token.slice(2), 16));
- }
- if (token.startsWith("#")) {
- return String.fromCodePoint(Number.parseInt(token.slice(1), 10));
- }
- return { amp: "&", quot: '"', apos: "'", lt: "<", gt: ">" }[
- token.toLowerCase()
- ]!;
- });
-
-const encodeXml = (value: ConfigValue, quote: '"' | "'"): string => {
- let encoded = (value === null ? "" : String(value))
- .replace(/&/g, "&")
- .replace(//g, ">");
- encoded = quote === '"'
- ? encoded.replace(/"/g, """)
- : encoded.replace(/'/g, "'");
- return encoded;
-};
-
-const parseXmlProperties = (source: string): XmlPropertiesDocument => {
- const properties: XmlProperty[] = [];
- const propertyPattern = /]*>/gi;
- for (const match of source.matchAll(propertyPattern)) {
- const tag = match[0];
- const tagStart = match.index;
- const attributes = new Map();
- const attributePattern = /\b(name|value)\s*=\s*(["'])(.*?)\2/gi;
- for (const attribute of tag.matchAll(attributePattern)) {
- const quote = attribute[2] as '"' | "'";
- const rawValue = attribute[3]!;
- const rawStart = tagStart + attribute.index + attribute[0].indexOf(quote) + 1;
- attributes.set(attribute[1]!.toLowerCase(), {
- value: decodeXml(rawValue),
- span: { start: rawStart, end: rawStart + rawValue.length },
- quote,
- });
- }
- const name = attributes.get("name");
- const value = attributes.get("value");
- if (name && value) {
- properties.push({
- key: name.value,
- value: value.value,
- valueSpan: value.span,
- quote: value.quote,
- });
- }
- }
- const nodes: LineNode[] = splitSourceLines(source).map((line) => ({
- ...line,
- kind: line.raw.trim() === "" ? "blank" : "invalid",
- }));
- return { format: "xml-properties", source, nodes, issues: [], properties };
-};
-
-const effectiveProperty = (
- document: XmlPropertiesDocument,
- key: string,
-): XmlProperty | undefined => {
- for (let index = document.properties.length - 1; index >= 0; index -= 1) {
- const property = document.properties[index]!;
- if (property.key === key) return property;
- }
- return undefined;
-};
-
-export const xmlPropertiesAdapter: ConfigAdapter = {
- parse: parseXmlProperties,
- entries(document) {
- const entries = new Map();
- for (const property of document.properties) entries.set(property.key, property.value);
- return [...entries].map(([key, value]) => ({ key, value }));
- },
- get(document, key) {
- return effectiveProperty(document, key)?.value;
- },
- getAll(document, key) {
- return document.properties
- .filter((property) => property.key === key)
- .map((property) => property.value);
- },
- getAllRaw(document, key) {
- return document.properties
- .filter((property) => property.key === key)
- .map((property) => document.source.slice(property.valueSpan.start, property.valueSpan.end));
- },
- set(document, key, value) {
- const property = effectiveProperty(document, key);
- if (!property) throw new Error(`XML property "${key}" does not exist.`);
- return parseXmlProperties(
- document.source.slice(0, property.valueSpan.start) +
- encodeXml(value, property.quote) +
- document.source.slice(property.valueSpan.end),
- );
- },
- setAll(document, key, values) {
- const properties = document.properties.filter((property) => property.key === key);
- if (properties.length !== values.length) {
- throw new Error(`Expected ${properties.length} values for XML property "${key}".`);
- }
- let source = document.source;
- for (let index = properties.length - 1; index >= 0; index -= 1) {
- const property = properties[index]!;
- source =
- source.slice(0, property.valueSpan.start) +
- encodeXml(values[index]!, property.quote) +
- source.slice(property.valueSpan.end);
- }
- return parseXmlProperties(source);
- },
- setAllRaw(document, key, values) {
- const properties = document.properties.filter((property) => property.key === key);
- if (properties.length !== values.length) {
- throw new Error(`Expected ${properties.length} raw values for XML property "${key}".`);
- }
- let source = document.source;
- for (let index = properties.length - 1; index >= 0; index -= 1) {
- const property = properties[index]!;
- source = source.slice(0, property.valueSpan.start) + values[index]! + source.slice(property.valueSpan.end);
- }
- return parseXmlProperties(source);
- },
- validate(document, schema: FileSchema): ValidationIssue[] {
- void schema;
- return [...document.issues];
- },
- serialize(document) {
- return document.source;
- },
-};
diff --git a/packages/config-editor/src/components/ActionBar.tsx b/packages/config-editor/src/components/ActionBar.tsx
deleted file mode 100644
index efce411..0000000
--- a/packages/config-editor/src/components/ActionBar.tsx
+++ /dev/null
@@ -1,23 +0,0 @@
-import { copy } from "../copy.js";
-
-export interface ActionBarProps {
- dirty: boolean;
- invalid: boolean;
- saving: boolean;
- status: string;
- onSave(): Promise | void;
-}
-
-export const ActionBar = ({ dirty, invalid, saving, status, onSave }: ActionBarProps) => (
-
- {status}
-
-
-);
diff --git a/packages/config-editor/src/components/EditorApp.tsx b/packages/config-editor/src/components/EditorApp.tsx
deleted file mode 100644
index 126e5ac..0000000
--- a/packages/config-editor/src/components/EditorApp.tsx
+++ /dev/null
@@ -1,227 +0,0 @@
-import { createComponent, rerender } from "@druid-ui/component";
-import type { Context, Event } from "@druid-ui/component";
-import { copy } from "../copy.js";
-import type { FileGateway } from "../gateway.js";
-import { loadEditor, saveSelectedFile } from "../gateway.js";
-import { validateManifest } from "../manifest.js";
-import type { ConfigEditorManifest } from "../model.js";
-import type { ConfigEditorStore, EditorSnapshot } from "../store.js";
-import { EDITOR_STYLES } from "../styles.js";
-import { ActionBar } from "./ActionBar.js";
-import { FileRail } from "./FileRail.js";
-import { FormEditor } from "./FormEditor.js";
-import { Inspector } from "./Inspector.js";
-import { RawEditor } from "./RawEditor.js";
-
-export type EditorMode = "form" | "raw";
-
-export interface EditorAppProps {
- manifest: ConfigEditorManifest;
- store: ConfigEditorStore;
- snapshot: EditorSnapshot;
- mode: EditorMode;
- saving: boolean;
- status: string;
- onMode(mode: EditorMode): void;
- onSelect(path: string): Promise | void;
- onField(key: string, value: string): void;
- onRaw(source: string): void;
- onSave(): Promise | void;
-}
-
-export const EditorApp = ({
- manifest,
- store,
- snapshot,
- mode,
- saving,
- status,
- onMode,
- onSelect,
- onField,
- onRaw,
- onSave,
-}: EditorAppProps) => (
-
-
-
-
-
-
-
-
-
-
- {mode === "form" ? (
-
- ) : (
-
- )}
- issue.severity === "error")}
- saving={saving}
- status={status}
- onSave={onSave}
- />
-
-
-
-
-);
-
-export interface ConfigEditorComponentOptions {
- manifestPath: string;
- gateway: FileGateway;
-}
-
-export interface ConfigEditorComponent {
- init(context: Context): unknown;
- emit(nodeId: string, event: string, value: Event): void;
- asyncComplete(
- id: string,
- result: { tag: "ok" | "err"; val: unknown },
- ): void;
-}
-
-export const createConfigEditorComponent = ({
- manifestPath,
- gateway,
-}: ConfigEditorComponentOptions): ConfigEditorComponent => {
- let manifest: ConfigEditorManifest | undefined;
- const stores = new Map();
- let selectedPath = "";
- let mode: EditorMode = "form";
- let loading = false;
- let saving = false;
- let status = "";
- let errorMessage = "";
-
- const selectFile = async (path: string): Promise => {
- if (!manifest) return;
- const previousPath = selectedPath;
- status = copy.loading;
- rerender();
- try {
- let store = stores.get(path);
- if (!store) {
- store = await loadEditor(gateway, manifest, path);
- stores.set(path, store);
- }
- selectedPath = path;
- mode = store.schema.sections.length === 0 ? "raw" : "form";
- status = "";
- } catch (error) {
- selectedPath = previousPath;
- status = error instanceof Error ? error.message : String(error);
- if (!stores.has(previousPath)) throw error;
- } finally {
- rerender();
- }
- };
-
- const initialise = async (): Promise => {
- loading = true;
- try {
- manifest = validateManifest(JSON.parse(await gateway.load(manifestPath)));
- selectedPath = manifest.files[0]!.path;
- await selectFile(selectedPath);
- } catch (error) {
- errorMessage = error instanceof Error ? error.message : String(error);
- } finally {
- loading = false;
- rerender();
- }
- };
-
- const save = async (): Promise => {
- const store = stores.get(selectedPath);
- if (!store) return;
- saving = true;
- status = copy.saving;
- try {
- const result = await saveSelectedFile(store, gateway);
- status = result.status === "saved" ? copy.saved : copy.conflict;
- } catch (error) {
- status = error instanceof Error ? error.message : String(error);
- } finally {
- saving = false;
- rerender();
- }
- };
-
- return createComponent(() => {
- if (!loading && !manifest && !errorMessage) void initialise();
- if (errorMessage) {
- return (
-
- );
- }
- const store = stores.get(selectedPath);
- if (!manifest || !store) {
- return (
-
- );
- }
- const snapshot = store.snapshot();
- return (
- {
- mode = nextMode;
- }}
- onSelect={selectFile}
- onField={(key, value) => {
- store.setDisplayValue(key, value);
- status = "";
- }}
- onRaw={(source) => {
- store.replaceWorkingSource(source);
- status = "";
- }}
- onSave={save}
- />
- );
- });
-};
diff --git a/packages/config-editor/src/components/FieldControl.tsx b/packages/config-editor/src/components/FieldControl.tsx
deleted file mode 100644
index 627ccbe..0000000
--- a/packages/config-editor/src/components/FieldControl.tsx
+++ /dev/null
@@ -1,74 +0,0 @@
-import type { Event } from "@druid-ui/component";
-import type { FieldSchema } from "../model.js";
-import type { FieldSnapshot } from "../store.js";
-import { copy } from "../copy.js";
-
-export interface FieldControlProps {
- field: FieldSchema;
- state: FieldSnapshot;
- onChange(key: string, value: string): void;
-}
-
-export const FieldControl = ({ field, state, onChange }: FieldControlProps) => {
- const inputId = `config-field-${field.key.replace(/[^a-zA-Z0-9_-]/g, "-")}`;
- const descriptionId = `${inputId}-description`;
- const input = field.type === "enum" ? (
-
- ) : field.type === "boolean" ? (
-
-
- onChange(field.key, event.checked() ? "true" : "false")
- }
- />
- {state.displayValue === "true" ? "Enabled" : "Disabled"}
-
- ) : (
- 0 ? "true" : "false"}
- autocomplete={field.type === "secret" || field.sensitive ? "new-password" : "off"}
- onInput={(event: Event) => onChange(field.key, event.value())}
- />
- );
-
- return (
-
-
-
{field.description}
- {input}
-
- {field.restartRequired ? {copy.restartRequired} : false}
- {field.key}
-
- {state.issues.map((issue) => (
-
{issue.message}
- ))}
-
- );
-};
diff --git a/packages/config-editor/src/components/FileRail.tsx b/packages/config-editor/src/components/FileRail.tsx
deleted file mode 100644
index fa85828..0000000
--- a/packages/config-editor/src/components/FileRail.tsx
+++ /dev/null
@@ -1,27 +0,0 @@
-import type { ConfigEditorManifest } from "../model.js";
-import { copy } from "../copy.js";
-
-export interface FileRailProps {
- manifest: ConfigEditorManifest;
- selectedPath: string;
- onSelect(path: string): Promise | void;
-}
-
-export const FileRail = ({ manifest, selectedPath, onSelect }: FileRailProps) => (
-
-);
diff --git a/packages/config-editor/src/components/FormEditor.tsx b/packages/config-editor/src/components/FormEditor.tsx
deleted file mode 100644
index eda4153..0000000
--- a/packages/config-editor/src/components/FormEditor.tsx
+++ /dev/null
@@ -1,28 +0,0 @@
-import type { FileSchema } from "../model.js";
-import type { EditorSnapshot } from "../store.js";
-import { FieldControl } from "./FieldControl.js";
-
-export interface FormEditorProps {
- schema: FileSchema;
- snapshot: EditorSnapshot;
- onChange(key: string, value: string): void;
-}
-
-export const FormEditor = ({ schema, snapshot, onChange }: FormEditorProps) => (
-
-);
diff --git a/packages/config-editor/src/components/Inspector.tsx b/packages/config-editor/src/components/Inspector.tsx
deleted file mode 100644
index 93fb3ed..0000000
--- a/packages/config-editor/src/components/Inspector.tsx
+++ /dev/null
@@ -1,46 +0,0 @@
-import { copy } from "../copy.js";
-import type { EditorSnapshot } from "../store.js";
-
-export interface InspectorProps {
- snapshot: EditorSnapshot;
-}
-
-const printable = (value: unknown): string =>
- value === undefined ? "Not set" : value === null ? "Protected" : String(value);
-
-export const Inspector = ({ snapshot }: InspectorProps) => (
-
-);
diff --git a/packages/config-editor/src/components/RawEditor.tsx b/packages/config-editor/src/components/RawEditor.tsx
deleted file mode 100644
index 17f9d73..0000000
--- a/packages/config-editor/src/components/RawEditor.tsx
+++ /dev/null
@@ -1,21 +0,0 @@
-import type { Event } from "@druid-ui/component";
-import { copy } from "../copy.js";
-
-export interface RawEditorProps {
- source: string;
- onChange(source: string): void;
-}
-
-export const RawEditor = ({ source, onChange }: RawEditorProps) => (
-
-
-
-);
diff --git a/packages/config-editor/src/copy.ts b/packages/config-editor/src/copy.ts
deleted file mode 100644
index f4ebbc1..0000000
--- a/packages/config-editor/src/copy.ts
+++ /dev/null
@@ -1,23 +0,0 @@
-export const copy = {
- appTitle: "Server configuration",
- loading: "Loading configuration…",
- formTab: "Form",
- rawTab: "Raw",
- filesHeading: "Configuration files",
- inspectorHeading: "Changes & validation",
- noChanges: "No pending changes",
- rawChanged: "Raw configuration changed",
- unknownKeys: "Unknown keys: Preserved",
- restartRequired: "Restart required",
- stopBeforeSave: "Stop the server before saving configuration files that it may rewrite.",
- noRestartRequired: "No restart required",
- save: "Save changes",
- saving: "Saving…",
- saved: "Saved and verified",
- conflict: "Remote file changed. Reload before saving.",
- rawLabel: "Raw configuration",
- validationHeading: "Validation",
- valid: "Configuration is valid",
- secretChanged: "Secret changed",
- fileFormat: "Format",
-} as const;
diff --git a/packages/config-editor/src/fingerprint.test.ts b/packages/config-editor/src/fingerprint.test.ts
deleted file mode 100644
index ddc7045..0000000
--- a/packages/config-editor/src/fingerprint.test.ts
+++ /dev/null
@@ -1,16 +0,0 @@
-import { describe, expect, it } from "vitest";
-import { fingerprint } from "./fingerprint.js";
-
-describe("fingerprint", () => {
- it("returns the standard SHA-256 digest without platform APIs", async () => {
- await expect(fingerprint("abc")).resolves.toBe(
- "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad",
- );
- });
-
- it("fingerprints exact UTF-8 source bytes including line endings", async () => {
- await expect(fingerprint("Grüße\n")).resolves.not.toBe(
- await fingerprint("Grüße\r\n"),
- );
- });
-});
diff --git a/packages/config-editor/src/fingerprint.ts b/packages/config-editor/src/fingerprint.ts
deleted file mode 100644
index f44671a..0000000
--- a/packages/config-editor/src/fingerprint.ts
+++ /dev/null
@@ -1,123 +0,0 @@
-const SHA256_CONSTANTS = [
- 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5,
- 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5,
- 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3,
- 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174,
- 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc,
- 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,
- 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7,
- 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967,
- 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13,
- 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85,
- 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3,
- 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
- 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5,
- 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
- 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208,
- 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2,
-] as const;
-
-const rotateRight = (value: number, bits: number): number =>
- (value >>> bits) | (value << (32 - bits));
-
-const utf8Bytes = (source: string): number[] => {
- const bytes: number[] = [];
- for (const character of source) {
- const point = character.codePointAt(0)!;
- if (point <= 0x7f) bytes.push(point);
- else if (point <= 0x7ff) {
- bytes.push(0xc0 | (point >>> 6), 0x80 | (point & 0x3f));
- } else if (point <= 0xffff) {
- bytes.push(
- 0xe0 | (point >>> 12),
- 0x80 | ((point >>> 6) & 0x3f),
- 0x80 | (point & 0x3f),
- );
- } else {
- bytes.push(
- 0xf0 | (point >>> 18),
- 0x80 | ((point >>> 12) & 0x3f),
- 0x80 | ((point >>> 6) & 0x3f),
- 0x80 | (point & 0x3f),
- );
- }
- }
- return bytes;
-};
-
-const sha256 = (source: string): string => {
- const bytes = utf8Bytes(source);
- const bitLength = bytes.length * 8;
- bytes.push(0x80);
- while (bytes.length % 64 !== 56) bytes.push(0);
- const high = Math.floor(bitLength / 0x1_0000_0000);
- const low = bitLength >>> 0;
- for (let shift = 24; shift >= 0; shift -= 8) bytes.push((high >>> shift) & 0xff);
- for (let shift = 24; shift >= 0; shift -= 8) bytes.push((low >>> shift) & 0xff);
-
- const hash = [
- 0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a,
- 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19,
- ];
- const words = new Array(64).fill(0);
-
- for (let offset = 0; offset < bytes.length; offset += 64) {
- for (let index = 0; index < 16; index += 1) {
- const cursor = offset + index * 4;
- words[index] = (
- (bytes[cursor]! << 24) |
- (bytes[cursor + 1]! << 16) |
- (bytes[cursor + 2]! << 8) |
- bytes[cursor + 3]!
- ) >>> 0;
- }
- for (let index = 16; index < 64; index += 1) {
- const previous15 = words[index - 15]!;
- const previous2 = words[index - 2]!;
- const sigma0 =
- rotateRight(previous15, 7) ^
- rotateRight(previous15, 18) ^
- (previous15 >>> 3);
- const sigma1 =
- rotateRight(previous2, 17) ^
- rotateRight(previous2, 19) ^
- (previous2 >>> 10);
- words[index] = (
- words[index - 16]! + sigma0 + words[index - 7]! + sigma1
- ) >>> 0;
- }
-
- let [a, b, c, d, e, f, g, h] = hash as [
- number, number, number, number, number, number, number, number,
- ];
- for (let index = 0; index < 64; index += 1) {
- const sum1 = rotateRight(e, 6) ^ rotateRight(e, 11) ^ rotateRight(e, 25);
- const choose = (e & f) ^ (~e & g);
- const temporary1 =
- (h + sum1 + choose + SHA256_CONSTANTS[index]! + words[index]!) >>> 0;
- const sum0 = rotateRight(a, 2) ^ rotateRight(a, 13) ^ rotateRight(a, 22);
- const majority = (a & b) ^ (a & c) ^ (b & c);
- const temporary2 = (sum0 + majority) >>> 0;
- h = g;
- g = f;
- f = e;
- e = (d + temporary1) >>> 0;
- d = c;
- c = b;
- b = a;
- a = (temporary1 + temporary2) >>> 0;
- }
- hash[0] = (hash[0]! + a) >>> 0;
- hash[1] = (hash[1]! + b) >>> 0;
- hash[2] = (hash[2]! + c) >>> 0;
- hash[3] = (hash[3]! + d) >>> 0;
- hash[4] = (hash[4]! + e) >>> 0;
- hash[5] = (hash[5]! + f) >>> 0;
- hash[6] = (hash[6]! + g) >>> 0;
- hash[7] = (hash[7]! + h) >>> 0;
- }
-
- return hash.map((word) => word.toString(16).padStart(8, "0")).join("");
-};
-
-export const fingerprint = async (source: string): Promise => sha256(source);
diff --git a/packages/config-editor/src/gateway.test.ts b/packages/config-editor/src/gateway.test.ts
deleted file mode 100644
index e6a54b8..0000000
--- a/packages/config-editor/src/gateway.test.ts
+++ /dev/null
@@ -1,205 +0,0 @@
-import { describe, expect, it } from "vitest";
-import { fingerprint } from "./fingerprint.js";
-import {
- loadEditor,
- saveSelectedFile,
- withMissingFileFallback,
- MISSING_FILE_FINGERPRINT,
- type FileGateway,
- type SaveResult,
-} from "./gateway.js";
-import { validateManifest } from "./manifest.js";
-import { validManifest } from "./test-fixtures.js";
-
-const manifestFor = (path: string) => validateManifest(validManifest(path));
-
-const memoryGateway = (initial: Record) => {
- const files = new Map(Object.entries(initial));
- const gateway = {
- async load(path: string) {
- const value = files.get(path);
- if (value === undefined) throw new Error(`Missing fixture file: ${path}`);
- return value;
- },
- async save(
- path: string,
- content: string,
- expectedFingerprint: string,
- ): Promise {
- const remote = files.get(path);
- if (remote === undefined && expectedFingerprint === MISSING_FILE_FINGERPRINT) {
- files.set(path, content);
- return { status: "saved", fingerprint: await fingerprint(content) };
- }
- if (remote === undefined) throw new Error(`Missing fixture file: ${path}`);
- const remoteFingerprint = await fingerprint(remote);
- if (remoteFingerprint !== expectedFingerprint) {
- return { status: "conflict", remote, fingerprint: remoteFingerprint };
- }
- files.set(path, content);
- return { status: "saved", fingerprint: await fingerprint(content) };
- },
- replace(path: string, content: string) {
- files.set(path, content);
- },
- current(path: string) {
- return files.get(path);
- },
- } satisfies FileGateway & {
- replace(path: string, content: string): void;
- current(path: string): string | undefined;
- };
- return gateway;
-};
-
-describe("saveSelectedFile", () => {
- it("blocks overwrite when the remote fingerprint changed", async () => {
- const gateway = memoryGateway({ "server.properties": "max-players=20\n" });
- const store = await loadEditor(gateway, manifestFor("server.properties"));
- store.setDisplayValue("max-players", "50");
- gateway.replace("server.properties", "max-players=30\n");
-
- await expect(saveSelectedFile(store, gateway)).resolves.toEqual(
- expect.objectContaining({
- status: "conflict",
- remote: "max-players=30\n",
- }),
- );
- expect(gateway.current("server.properties")).toBe("max-players=30\n");
- expect(store.snapshot().dirty).toBe(true);
- });
-
- it("reparses and verifies content after a successful save", async () => {
- const gateway = memoryGateway({ "server.properties": "max-players=20\n" });
- const store = await loadEditor(gateway, manifestFor("server.properties"));
- store.setDisplayValue("max-players", "50");
- await expect(saveSelectedFile(store, gateway)).resolves.toEqual(
- expect.objectContaining({ status: "saved" }),
- );
- expect(gateway.current("server.properties")).toBe("max-players=50\n");
- expect(store.snapshot().dirty).toBe(false);
- });
-
- it("refuses to save while the visible form has validation errors", async () => {
- const gateway = memoryGateway({ "server.properties": "max-players=20\n" });
- const store = await loadEditor(gateway, manifestFor("server.properties"));
- store.setDisplayValue("max-players", "0");
- await expect(saveSelectedFile(store, gateway)).rejects.toThrow(
- /validation errors/,
- );
- expect(gateway.current("server.properties")).toBe("max-players=20\n");
- });
-
- it("detects a gateway that acknowledges a save but returns different content", async () => {
- const gateway = memoryGateway({ "server.properties": "max-players=20\n" });
- const brokenGateway: FileGateway = {
- load: gateway.load.bind(gateway),
- async save(path, _content, _expectedFingerprint) {
- gateway.replace(path, "max-players=999\n");
- return { status: "saved", fingerprint: await fingerprint("max-players=50\n") };
- },
- };
- const store = await loadEditor(brokenGateway, manifestFor("server.properties"));
- store.setDisplayValue("max-players", "50");
- await expect(saveSelectedFile(store, brokenGateway)).rejects.toThrow(
- /verification failed/,
- );
- });
-});
-
-describe("withMissingFileFallback", () => {
- it("edits a packaged default before the active configuration exists", async () => {
- const memory = memoryGateway({
- "data/server.properties.default": "max-players=20\n",
- });
- const gateway = withMissingFileFallback(memory);
- const store = await loadEditor(gateway, manifestFor("data/server.properties"));
-
- store.setDisplayValue("max-players", "42");
- await expect(saveSelectedFile(store, gateway)).resolves.toEqual(
- expect.objectContaining({ status: "saved" }),
- );
-
- expect(memory.current("data/server.properties")).toBe("max-players=42\n");
- expect(memory.current("data/server.properties.default")).toBe("max-players=20\n");
- });
-
- it("edits a scroll template before the active configuration exists", async () => {
- const memory = memoryGateway({
- "data/server.properties.scroll_template": "max-players=20\n",
- });
- const gateway = withMissingFileFallback(memory);
- const store = await loadEditor(gateway, manifestFor("data/server.properties"));
-
- store.setDisplayValue("max-players", "42");
- await expect(saveSelectedFile(store, gateway)).resolves.toEqual(
- expect.objectContaining({ status: "saved" }),
- );
-
- expect(memory.current("data/server.properties")).toBe("max-players=42\n");
- expect(memory.current("data/server.properties.scroll_template")).toBe("max-players=20\n");
- });
-
- it("prefers an active configuration over its template", async () => {
- const memory = memoryGateway({
- "data/server.properties": "max-players=30\n",
- "data/server.properties.scroll_template": "max-players=20\n",
- });
- const store = await loadEditor(
- withMissingFileFallback(memory),
- manifestFor("data/server.properties"),
- );
-
- expect(store.snapshot().fields["max-players"]?.displayValue).toBe("30");
- });
-
- it("switches from a fallback to an active file when the runtime creates it", async () => {
- const memory = memoryGateway({
- "data/server.properties.default": "max-players=20\n",
- });
- const gateway = withMissingFileFallback(memory);
-
- await expect(gateway.load("data/server.properties")).resolves.toBe("max-players=20\n");
- memory.replace("data/server.properties", "max-players=30\n");
- await expect(gateway.load("data/server.properties")).resolves.toBe("max-players=30\n");
- });
-
- it("fails closed when an active file appears before a fallback save", async () => {
- const memory = memoryGateway({
- "data/server.properties.default": "max-players=20\n",
- });
- const gateway = withMissingFileFallback(memory);
- const store = await loadEditor(gateway, manifestFor("data/server.properties"));
- store.setDisplayValue("max-players", "42");
- memory.replace("data/server.properties", "max-players=30\n");
-
- await expect(saveSelectedFile(store, gateway)).resolves.toEqual(
- expect.objectContaining({ status: "conflict", remote: "max-players=30\n" }),
- );
- expect(memory.current("data/server.properties")).toBe("max-players=30\n");
- expect(memory.current("data/server.properties.default")).toBe("max-players=20\n");
- });
-
- it("does not hide non-missing-file load failures", async () => {
- const gateway: FileGateway = {
- async load() {
- throw new Error("permission denied");
- },
- async save() {
- throw new Error("unexpected save");
- },
- };
-
- await expect(
- withMissingFileFallback(gateway).load("data/server.properties"),
- ).rejects.toThrow("permission denied");
- });
-
- it("reports the declared path after every fallback is missing", async () => {
- const gateway = withMissingFileFallback(memoryGateway({}));
-
- await expect(gateway.load("data/generated.cfg")).rejects.toThrow(
- "Missing configuration file: data/generated.cfg",
- );
- });
-});
diff --git a/packages/config-editor/src/gateway.ts b/packages/config-editor/src/gateway.ts
deleted file mode 100644
index 7e86454..0000000
--- a/packages/config-editor/src/gateway.ts
+++ /dev/null
@@ -1,132 +0,0 @@
-import { createAdapterRegistry } from "./adapters/registry.js";
-import { fingerprint } from "./fingerprint.js";
-import type { ConfigEditorManifest, FileSchema } from "./model.js";
-import { ConfigEditorStore } from "./store.js";
-
-export type SaveResult =
- | { status: "saved"; fingerprint: string }
- | { status: "conflict"; remote: string; fingerprint: string };
-
-export interface FileGateway {
- load(path: string): Promise;
- save(
- path: string,
- content: string,
- expectedFingerprint: string,
- ): Promise;
-}
-
-export const MISSING_FILE_FINGERPRINT = "missing";
-
-const isMissingFileError = (error: unknown): boolean => {
- const message = error instanceof Error ? error.message : String(error);
- return /(?:\b404\b|not[ -]?found|missing)/i.test(message);
-};
-
-export const withMissingFileFallback = (
- gateway: FileGateway,
- suffixes: string | readonly string[] = [".scroll_template", ".default"],
-): FileGateway => {
- const resolvedPaths = new Map();
- const fallbackSuffixes = typeof suffixes === "string" ? [suffixes] : suffixes;
-
- return {
- async load(path) {
- try {
- const content = await gateway.load(path);
- resolvedPaths.set(path, path);
- return content;
- } catch (error) {
- if (!isMissingFileError(error)) throw error;
- const resolved = resolvedPaths.get(path);
- if (resolved && resolved !== path) {
- try {
- return await gateway.load(resolved);
- } catch (fallbackError) {
- if (!isMissingFileError(fallbackError)) throw fallbackError;
- }
- }
- for (const suffix of fallbackSuffixes) {
- const fallback = `${path}${suffix}`;
- if (fallback === resolved) continue;
- try {
- const content = await gateway.load(fallback);
- resolvedPaths.set(path, fallback);
- return content;
- } catch (fallbackError) {
- if (!isMissingFileError(fallbackError)) throw fallbackError;
- }
- }
- throw new Error(`Missing configuration file: ${path}`);
- }
- },
- async save(path, content, expectedFingerprint) {
- const resolved = resolvedPaths.get(path) ?? path;
- if (resolved !== path) {
- const result = await gateway.save(
- path,
- content,
- MISSING_FILE_FINGERPRINT,
- );
- if (result.status === "saved") resolvedPaths.set(path, path);
- return result;
- }
- return await gateway.save(resolved, content, expectedFingerprint);
- },
- };
-};
-
-const schemaForPath = (
- manifest: ConfigEditorManifest,
- path: string | undefined,
-): FileSchema => {
- const schema = path
- ? manifest.files.find((candidate) => candidate.path === path)
- : manifest.files[0];
- if (!schema) throw new TypeError(path ? `Manifest has no file "${path}".` : "Manifest has no files.");
- return schema;
-};
-
-export const loadEditor = async (
- gateway: FileGateway,
- manifest: ConfigEditorManifest,
- path?: string,
-): Promise => {
- const schema = schemaForPath(manifest, path);
- const adapter = createAdapterRegistry().get(schema.format);
- if (!adapter) throw new TypeError(`No adapter registered for "${schema.format}".`);
- const source = await gateway.load(schema.path);
- return ConfigEditorStore.fromLoadedFile(schema, adapter.parse(source));
-};
-
-export const saveSelectedFile = async (
- store: ConfigEditorStore,
- gateway: FileGateway,
-): Promise => {
- if (store.snapshot().issues.some((issue) => issue.severity === "error")) {
- throw new Error("Configuration has validation errors and cannot be saved.");
- }
-
- const content = store.serializeSelectedFile();
- const serializedIssues = store.validateSerializedSource(content);
- if (serializedIssues.some((issue) => issue.severity === "error")) {
- throw new Error("Serialized configuration failed validation.");
- }
-
- const expectedFingerprint = await fingerprint(store.loadedSource());
- const result = await gateway.save(store.schema.path, content, expectedFingerprint);
- if (result.status === "conflict") return result;
-
- const contentFingerprint = await fingerprint(content);
- if (result.fingerprint !== contentFingerprint) {
- throw new Error("Save verification failed: gateway fingerprint does not match content.");
- }
- const remote = await gateway.load(store.schema.path);
- const remoteFingerprint = await fingerprint(remote);
- if (remoteFingerprint !== result.fingerprint || remote !== content) {
- throw new Error("Save verification failed: reloaded content differs from the saved content.");
- }
-
- store.acceptSavedSource(remote);
- return result;
-};
diff --git a/packages/config-editor/src/index.ts b/packages/config-editor/src/index.ts
deleted file mode 100644
index bf9d2f6..0000000
--- a/packages/config-editor/src/index.ts
+++ /dev/null
@@ -1,60 +0,0 @@
-export { fieldsForVersion, validateManifest } from "./manifest.js";
-export { javaPropertiesAdapter } from "./adapters/java-properties.js";
-export { iniAdapter, keyValueAdapter, unrealIniAdapter } from "./adapters/ini.js";
-export { jsonAdapter } from "./adapters/json.js";
-export { rawAdapter } from "./adapters/raw.js";
-export { xmlPropertiesAdapter } from "./adapters/xml-properties.js";
-export { createAdapterRegistry } from "./adapters/registry.js";
-export { coerceFieldValue, validateField } from "./validation.js";
-export { ConfigEditorStore, MASKED_SECRET } from "./store.js";
-export { fingerprint } from "./fingerprint.js";
-export {
- loadEditor,
- saveSelectedFile,
- withMissingFileFallback,
-} from "./gateway.js";
-export { createConfigEditorComponent, EditorApp } from "./components/EditorApp.js";
-export { FileRail } from "./components/FileRail.js";
-export { FieldControl } from "./components/FieldControl.js";
-export { FormEditor } from "./components/FormEditor.js";
-export { RawEditor } from "./components/RawEditor.js";
-export { Inspector } from "./components/Inspector.js";
-export { ActionBar } from "./components/ActionBar.js";
-export { EDITOR_STYLES } from "./styles.js";
-export { copy } from "./copy.js";
-export type {
- ConfigEditorManifest,
- ConfigFormat,
- ConfigValue,
- FieldSchema,
- FieldType,
- FileSchema,
- SectionSchema,
- ServerSchema,
- ValidationIssue,
-} from "./model.js";
-export type {
- ConfigAdapter,
- LineNode,
- ParsedDocument,
- SourceLine,
- SourceSpan,
-} from "./adapters/types.js";
-export type { JavaPropertiesDocument } from "./adapters/java-properties.js";
-export type { IniDocument } from "./adapters/ini.js";
-export type { JsonDocument } from "./adapters/json.js";
-export type { RawDocument } from "./adapters/raw.js";
-export type { XmlPropertiesDocument } from "./adapters/xml-properties.js";
-export type { AdapterRegistry } from "./adapters/registry.js";
-export type {
- ChangeRecord,
- EditorSnapshot,
- FieldSnapshot,
-} from "./store.js";
-export type { FileGateway, SaveResult } from "./gateway.js";
-export type {
- ConfigEditorComponentOptions,
- ConfigEditorComponent,
- EditorAppProps,
- EditorMode,
-} from "./components/EditorApp.js";
diff --git a/packages/config-editor/src/manifest.test.ts b/packages/config-editor/src/manifest.test.ts
deleted file mode 100644
index 94d6a0f..0000000
--- a/packages/config-editor/src/manifest.test.ts
+++ /dev/null
@@ -1,104 +0,0 @@
-import { describe, expect, it } from "vitest";
-import { fieldsForVersion, validateManifest } from "./manifest.js";
-import {
- duplicateFieldManifest,
- duplicateManifest,
- validManifest,
- versionedFile,
-} from "./test-fixtures.js";
-
-describe("validateManifest", () => {
- it("accepts a versioned manifest and preserves stable field keys", () => {
- const result = validateManifest({
- version: 1,
- server: {
- family: "minecraft",
- displayName: "Minecraft Server",
- appVersion: "1.21.7",
- },
- files: [
- {
- path: "server.properties",
- format: "java-properties",
- label: "server.properties",
- sections: [
- {
- id: "players",
- label: "Players",
- fields: [
- {
- key: "max-players",
- label: "Max players",
- type: "integer",
- min: 1,
- max: 1000,
- description: "Maximum player count.",
- documentation: "https://minecraft.wiki/w/Server.properties",
- restartRequired: true,
- },
- ],
- },
- ],
- },
- ],
- });
-
- expect(result.files[0]!.sections[0]!.fields[0]!.key).toBe("max-players");
- });
-
- it.each([
- "../secret",
- "/absolute",
- "C:/server.properties",
- "https://host/file",
- "folder\\file",
- ])("rejects unsafe file path %s", (path) => {
- expect(() => validateManifest(validManifest(path))).toThrow(
- /safe relative path/,
- );
- });
-
- it("rejects duplicate file paths", () => {
- expect(() => validateManifest(duplicateManifest())).toThrow(/duplicate/i);
- });
-
- it("rejects duplicate field keys within a file", () => {
- expect(() => validateManifest(duplicateFieldManifest())).toThrow(
- /duplicate/i,
- );
- });
-
- it("accepts a file with no typed fields for a lossless raw-only editor", () => {
- const manifest = validManifest();
- manifest.files[0]!.sections = [];
-
- const result = validateManifest(manifest);
-
- expect(result.files[0]!.sections).toEqual([]);
- });
-
- it("rejects inconsistent enum and numeric constraints", () => {
- const manifest = validManifest();
- const field = manifest.files[0]!.sections[0]!.fields[0]!;
- field.type = "enum";
- delete field.min;
- delete field.max;
- expect(() => validateManifest(manifest)).toThrow(/enum values/);
-
- const ranged = validManifest();
- ranged.files[0]!.sections[0]!.fields[0]!.min = 10;
- ranged.files[0]!.sections[0]!.fields[0]!.max = 1;
- expect(() => validateManifest(ranged)).toThrow(/minimum/i);
- });
-});
-
-describe("fieldsForVersion", () => {
- it("includes fields only inside their inclusive version range", () => {
- expect(
- fieldsForVersion(versionedFile(), "1.20.4").map((field) => field.key),
- ).toEqual(["always", "legacy"]);
- expect(
- fieldsForVersion(versionedFile(), "1.21.1").map((field) => field.key),
- ).toEqual(["always", "modern"]);
- });
-});
diff --git a/packages/config-editor/src/manifest.ts b/packages/config-editor/src/manifest.ts
deleted file mode 100644
index 4fd56a9..0000000
--- a/packages/config-editor/src/manifest.ts
+++ /dev/null
@@ -1,312 +0,0 @@
-import type {
- ConfigEditorManifest,
- ConfigFormat,
- ConfigValue,
- FieldSchema,
- FieldType,
- FileSchema,
- SectionSchema,
- ServerSchema,
-} from "./model.js";
-
-const CONFIG_FORMATS = new Set([
- "java-properties",
- "ini",
- "unreal-ini",
- "key-value",
- "json",
- "xml-properties",
- "raw",
-]);
-
-const FIELD_TYPES = new Set([
- "string",
- "integer",
- "number",
- "boolean",
- "enum",
- "secret",
-]);
-
-const DOTTED_VERSION = /^\d+(?:\.\d+)*$/;
-const URL_SCHEME = /^[a-z][a-z\d+.-]*:/i;
-const WINDOWS_DRIVE = /^[a-z]:\//i;
-
-const fail = (message: string): never => {
- throw new TypeError(`Invalid config editor manifest: ${message}`);
-};
-
-const objectValue = (value: unknown, name: string): Record => {
- if (typeof value !== "object" || value === null || Array.isArray(value)) {
- return fail(`${name} must be an object.`);
- }
- return value as Record;
-};
-
-const stringValue = (value: unknown, name: string): string => {
- if (typeof value !== "string" || value.trim().length === 0) {
- return fail(`${name} must be a non-empty string.`);
- }
- return value;
-};
-
-const optionalString = (value: unknown, name: string): string | undefined => {
- if (value === undefined) return undefined;
- return stringValue(value, name);
-};
-
-const optionalBoolean = (
- value: unknown,
- name: string,
-): boolean | undefined => {
- if (value === undefined) return undefined;
- if (typeof value !== "boolean") return fail(`${name} must be a boolean.`);
- return value;
-};
-
-const optionalNumber = (value: unknown, name: string): number | undefined => {
- if (value === undefined) return undefined;
- if (typeof value !== "number" || !Number.isFinite(value)) {
- return fail(`${name} must be a finite number.`);
- }
- return value;
-};
-
-const arrayValue = (value: unknown, name: string): unknown[] => {
- if (!Array.isArray(value)) return fail(`${name} must be an array.`);
- return value;
-};
-
-const assertVersion = (value: string, name: string): void => {
- if (!DOTTED_VERSION.test(value)) {
- fail(`${name} must be a dotted numeric version.`);
- }
-};
-
-const assertSafePath = (path: string): void => {
- const segments = path.split("/");
- const unsafe =
- path.startsWith("/") ||
- path.includes("\\") ||
- path.includes("\0") ||
- URL_SCHEME.test(path) ||
- WINDOWS_DRIVE.test(path) ||
- segments.some((segment) => segment === "" || segment === "." || segment === "..");
-
- if (unsafe) fail(`file path "${path}" must be a safe relative path.`);
-};
-
-const validateServer = (value: unknown): ServerSchema => {
- const server = objectValue(value, "server");
- return {
- family: stringValue(server["family"], "server.family"),
- displayName: stringValue(server["displayName"], "server.displayName"),
- ...(server["appVersion"] === undefined
- ? {}
- : { appVersion: stringValue(server["appVersion"], "server.appVersion") }),
- };
-};
-
-const validateField = (value: unknown, location: string): FieldSchema => {
- const field = objectValue(value, location);
- const type = stringValue(field["type"], `${location}.type`);
- if (!FIELD_TYPES.has(type as FieldType)) {
- fail(`${location}.type "${type}" is unsupported.`);
- }
-
- const min = optionalNumber(field["min"], `${location}.min`);
- const max = optionalNumber(field["max"], `${location}.max`);
- if (min !== undefined && max !== undefined && min > max) {
- fail(`${location} minimum cannot exceed its maximum.`);
- }
- if ((min !== undefined || max !== undefined) && type !== "integer" && type !== "number") {
- fail(`${location} numeric ranges require an integer or number field.`);
- }
-
- let values: string[] | undefined;
- if (field["values"] !== undefined) {
- values = arrayValue(field["values"], `${location}.values`).map((entry, index) =>
- stringValue(entry, `${location}.values[${index}]`),
- );
- if (new Set(values).size !== values.length) {
- fail(`${location}.values contains duplicate enum values.`);
- }
- }
- if (type === "enum" && (!values || values.length === 0)) {
- fail(`${location} enum values must contain at least one value.`);
- }
- if (type !== "enum" && values !== undefined) {
- fail(`${location}.values is only valid for enum fields.`);
- }
-
- const pattern = optionalString(field["pattern"], `${location}.pattern`);
- if (pattern !== undefined) {
- try {
- new RegExp(pattern);
- } catch {
- fail(`${location}.pattern must be a valid regular expression.`);
- }
- }
-
- const since = optionalString(field["since"], `${location}.since`);
- const until = optionalString(field["until"], `${location}.until`);
- if (since !== undefined) assertVersion(since, `${location}.since`);
- if (until !== undefined) assertVersion(until, `${location}.until`);
- if (since !== undefined && until !== undefined && compareVersions(since, until) > 0) {
- fail(`${location}.since cannot be later than .until.`);
- }
-
- const rawDefaultValue = field["defaultValue"];
- if (
- rawDefaultValue !== undefined &&
- rawDefaultValue !== null &&
- typeof rawDefaultValue !== "string" &&
- typeof rawDefaultValue !== "number" &&
- typeof rawDefaultValue !== "boolean"
- ) {
- fail(`${location}.defaultValue must be a scalar value.`);
- }
-
- const result: FieldSchema = {
- key: stringValue(field["key"], `${location}.key`),
- label: stringValue(field["label"], `${location}.label`),
- description: stringValue(field["description"], `${location}.description`),
- documentation: stringValue(
- field["documentation"],
- `${location}.documentation`,
- ),
- type: type as FieldType,
- };
- if (values !== undefined) result.values = values;
- if (min !== undefined) result.min = min;
- if (max !== undefined) result.max = max;
- if (pattern !== undefined) result.pattern = pattern;
- if (rawDefaultValue !== undefined) {
- result.defaultValue = rawDefaultValue as ConfigValue;
- }
- if (field["sensitive"] !== undefined) {
- result.sensitive = optionalBoolean(field["sensitive"], `${location}.sensitive`)!;
- }
- if (field["restartRequired"] !== undefined) {
- result.restartRequired = optionalBoolean(
- field["restartRequired"],
- `${location}.restartRequired`,
- )!;
- }
- if (since !== undefined) result.since = since;
- if (until !== undefined) result.until = until;
- return result;
-};
-
-const validateSection = (value: unknown, location: string): SectionSchema => {
- const section = objectValue(value, location);
- const fields = arrayValue(section["fields"], `${location}.fields`).map((field, index) =>
- validateField(field, `${location}.fields[${index}]`),
- );
- if (fields.length === 0) fail(`${location}.fields must not be empty.`);
-
- const result: SectionSchema = {
- id: stringValue(section["id"], `${location}.id`),
- label: stringValue(section["label"], `${location}.label`),
- fields,
- };
- if (section["description"] !== undefined) {
- result.description = stringValue(
- section["description"],
- `${location}.description`,
- );
- }
- return result;
-};
-
-const validateFile = (value: unknown, location: string): FileSchema => {
- const file = objectValue(value, location);
- const path = stringValue(file["path"], `${location}.path`);
- assertSafePath(path);
- const format = stringValue(file["format"], `${location}.format`);
- if (!CONFIG_FORMATS.has(format as ConfigFormat)) {
- fail(`${location}.format "${format}" is unsupported.`);
- }
-
- const sections = arrayValue(file["sections"], `${location}.sections`).map(
- (section, index) => validateSection(section, `${location}.sections[${index}]`),
- );
-
- const sectionIds = new Set();
- const fieldKeys = new Set();
- for (const section of sections) {
- if (sectionIds.has(section.id)) {
- fail(`${location} contains duplicate section id "${section.id}".`);
- }
- sectionIds.add(section.id);
- for (const field of section.fields) {
- if (fieldKeys.has(field.key)) {
- fail(`${location} contains duplicate field key "${field.key}".`);
- }
- fieldKeys.add(field.key);
- }
- }
-
- const result: FileSchema = {
- path,
- format: format as ConfigFormat,
- label: stringValue(file["label"], `${location}.label`),
- sections,
- };
- if (file["description"] !== undefined) {
- result.description = stringValue(
- file["description"],
- `${location}.description`,
- );
- }
- if (file["documentation"] !== undefined) {
- result.documentation = stringValue(
- file["documentation"],
- `${location}.documentation`,
- );
- }
- return result;
-};
-
-export const validateManifest = (value: unknown): ConfigEditorManifest => {
- const manifest = objectValue(value, "manifest");
- if (manifest["version"] !== 1) fail("version must be 1.");
-
- const files = arrayValue(manifest["files"], "files").map((file, index) =>
- validateFile(file, `files[${index}]`),
- );
- if (files.length === 0) fail("files must not be empty.");
-
- const paths = new Set();
- for (const file of files) {
- if (paths.has(file.path)) fail(`files contains duplicate path "${file.path}".`);
- paths.add(file.path);
- }
-
- return { version: 1, server: validateServer(manifest["server"]), files };
-};
-
-const compareVersions = (left: string, right: string): number => {
- const leftParts = left.split(".").map(Number);
- const rightParts = right.split(".").map(Number);
- const length = Math.max(leftParts.length, rightParts.length);
- for (let index = 0; index < length; index += 1) {
- const difference = (leftParts[index] ?? 0) - (rightParts[index] ?? 0);
- if (difference !== 0) return difference < 0 ? -1 : 1;
- }
- return 0;
-};
-
-export const fieldsForVersion = (
- file: FileSchema,
- version: string,
-): FieldSchema[] => {
- assertVersion(version, "version");
- return file.sections.flatMap((section) =>
- section.fields.filter(
- (field) =>
- (field.since === undefined || compareVersions(version, field.since) >= 0) &&
- (field.until === undefined || compareVersions(version, field.until) <= 0),
- ),
- );
-};
diff --git a/packages/config-editor/src/model.ts b/packages/config-editor/src/model.ts
deleted file mode 100644
index 450e185..0000000
--- a/packages/config-editor/src/model.ts
+++ /dev/null
@@ -1,73 +0,0 @@
-export type FieldType =
- | "string"
- | "integer"
- | "number"
- | "boolean"
- | "enum"
- | "secret";
-
-export type ConfigFormat =
- | "java-properties"
- | "ini"
- | "unreal-ini"
- | "key-value"
- | "json"
- | "xml-properties"
- | "raw";
-
-export type ConfigValue = string | number | boolean | null;
-
-export interface FieldSchema {
- key: string;
- label: string;
- description: string;
- documentation: string;
- type: FieldType;
- values?: string[];
- min?: number;
- max?: number;
- pattern?: string;
- defaultValue?: ConfigValue;
- sensitive?: boolean;
- restartRequired?: boolean;
- since?: string;
- until?: string;
-}
-
-export interface SectionSchema {
- id: string;
- label: string;
- description?: string;
- fields: FieldSchema[];
-}
-
-export interface FileSchema {
- path: string;
- format: ConfigFormat;
- label: string;
- description?: string;
- documentation?: string;
- sections: SectionSchema[];
-}
-
-export interface ServerSchema {
- family: string;
- displayName: string;
- appVersion?: string;
-}
-
-export interface ConfigEditorManifest {
- version: 1;
- server: ServerSchema;
- files: FileSchema[];
-}
-
-export interface ValidationIssue {
- code: string;
- message: string;
- severity: "error" | "warning";
- filePath?: string;
- fieldKey?: string;
- line?: number;
- column?: number;
-}
diff --git a/packages/config-editor/src/store.test.ts b/packages/config-editor/src/store.test.ts
deleted file mode 100644
index 15ee25a..0000000
--- a/packages/config-editor/src/store.test.ts
+++ /dev/null
@@ -1,338 +0,0 @@
-import { describe, expect, it } from "vitest";
-import { javaPropertiesAdapter } from "./adapters/java-properties.js";
-import { keyValueAdapter } from "./adapters/ini.js";
-import { rawAdapter } from "./adapters/raw.js";
-import { jsonAdapter } from "./adapters/json.js";
-import type { FieldSchema, FileSchema } from "./model.js";
-import { ConfigEditorStore, MASKED_SECRET } from "./store.js";
-
-const baseField = (overrides: Partial = {}): FieldSchema => ({
- key: "max-players",
- label: "Max players",
- description: "Maximum player count.",
- documentation: "https://minecraft.wiki/w/Server.properties",
- type: "integer",
- min: 1,
- max: 1000,
- ...overrides,
-});
-
-const fileSchema = (fields: FieldSchema[]): FileSchema => ({
- path: "server.properties",
- format: "java-properties",
- label: "server.properties",
- sections: [{ id: "general", label: "General", fields }],
-});
-
-const loadedStore = (source: string, schema: FileSchema): ConfigEditorStore =>
- ConfigEditorStore.fromLoadedFile(schema, javaPropertiesAdapter.parse(source));
-
-describe("ConfigEditorStore", () => {
- it("does not serialize an unchanged masked secret", () => {
- const store = loadedStore(
- "rcon.password=actual-secret\n",
- fileSchema([
- baseField({
- key: "rcon.password",
- label: "RCON password",
- type: "secret",
- min: undefined,
- max: undefined,
- sensitive: true,
- }),
- ]),
- );
- expect(store.snapshot().fields["rcon.password"]!.displayValue).toBe(
- MASKED_SECRET,
- );
- store.setDisplayValue("rcon.password", MASKED_SECRET);
- expect(store.serializeSelectedFile()).toBe(
- "rcon.password=actual-secret\n",
- );
- });
-
- it("tracks changes and restart impact by stable key", () => {
- const store = loadedStore(
- "view-distance=10\n",
- fileSchema([
- baseField({
- key: "view-distance",
- label: "View distance",
- min: 1,
- max: 32,
- restartRequired: true,
- }),
- ]),
- );
- store.setDisplayValue("view-distance", "14");
- expect(store.snapshot().changes).toEqual([
- expect.objectContaining({
- key: "view-distance",
- before: 10,
- after: 14,
- restartRequired: true,
- }),
- ]);
- expect(store.snapshot().restartRequired).toBe(true);
- expect(store.serializeSelectedFile()).toBe("view-distance=14\n");
- });
-
- it("keeps invalid display input without corrupting serialized content", () => {
- const store = loadedStore("max-players=20\n", fileSchema([baseField()]));
- store.setDisplayValue("max-players", "0");
- const snapshot = store.snapshot();
- expect(snapshot.fields["max-players"]!.displayValue).toBe("0");
- expect(snapshot.fields["max-players"]!.issues).toEqual([
- expect.objectContaining({ code: "range" }),
- ]);
- expect(snapshot.dirty).toBe(false);
- expect(store.serializeSelectedFile()).toBe("max-players=20\n");
- });
-
- it("redacts secret values from change summaries and immutable snapshots", () => {
- const store = loadedStore(
- "password=old-secret\n",
- fileSchema([
- baseField({
- key: "password",
- type: "secret",
- min: undefined,
- max: undefined,
- sensitive: true,
- }),
- ]),
- );
- store.setDisplayValue("password", "new-secret");
- const snapshot = store.snapshot();
- expect(JSON.stringify(snapshot)).not.toContain("old-secret");
- expect(JSON.stringify(snapshot)).not.toContain("new-secret");
- expect(snapshot.changes[0]).toEqual(
- expect.objectContaining({ before: null, after: null, sensitive: true }),
- );
- expect(Object.isFrozen(snapshot)).toBe(true);
- expect(Object.isFrozen(snapshot.fields)).toBe(true);
- });
-
- it("redacts secrets in raw display while preserving them through raw edits", () => {
- const store = loadedStore(
- "motd=hello\nrcon.password=actual-secret\nfuture=preserved\n",
- fileSchema([
- baseField({ key: "motd", type: "string", min: undefined, max: undefined }),
- baseField({
- key: "rcon.password",
- type: "secret",
- min: undefined,
- max: undefined,
- sensitive: true,
- }),
- ]),
- );
-
- const display = store.serializeForDisplay();
- expect(display).toContain(`rcon.password=${MASKED_SECRET}`);
- expect(display).not.toContain("actual-secret");
-
- store.replaceWorkingSource(display.replace("motd=hello", "motd=updated"));
-
- expect(store.serializeSelectedFile()).toContain("motd=updated");
- expect(store.serializeSelectedFile()).toContain("rcon.password=actual-secret");
- expect(store.serializeSelectedFile()).toContain("future=preserved");
- });
-
- it("redacts and restores every duplicate secret occurrence", () => {
- const store = loadedStore(
- "password=first-secret\npassword=second-secret\nmotd=hello\n",
- fileSchema([]),
- );
-
- const display = store.serializeForDisplay();
- expect(display).not.toContain("first-secret");
- expect(display).not.toContain("second-secret");
- expect(display.match(new RegExp(MASKED_SECRET, "g"))).toHaveLength(2);
-
- store.replaceWorkingSource(display.replace("motd=hello", "motd=updated"));
- expect(store.serializeSelectedFile()).toBe(
- "password=first-secret\npassword=second-secret\nmotd=updated\n",
- );
- });
-
- it("restores untouched secret source tokens byte-for-byte after raw edits", () => {
- const schema: FileSchema = {
- ...fileSchema([]),
- format: "key-value",
- };
- const store = ConfigEditorStore.fromLoadedFile(
- schema,
- keyValueAdapter.parse('password="a\\"b"\nmotd=hello\n'),
- );
-
- const display = store.serializeForDisplay();
- store.replaceWorkingSource(display.replace("motd=hello", "motd=updated"));
- expect(store.serializeSelectedFile()).toBe('password="a\\"b"\nmotd=updated\n');
- });
-
- it("redacts every duplicate secret in JSON raw mode", () => {
- const schema: FileSchema = {
- path: "config.json",
- format: "json",
- label: "config.json",
- sections: [],
- };
- const store = ConfigEditorStore.fromLoadedFile(
- schema,
- jsonAdapter.parse('{"password":"first-secret","password":"second-secret","name":"Druid"}\n'),
- );
- const display = store.serializeForDisplay();
- expect(display).not.toContain("first-secret");
- expect(display).not.toContain("second-secret");
- store.replaceWorkingSource(display.replace('"Druid"', '"Druid EU"'));
- expect(store.serializeSelectedFile()).toBe(
- '{"password":"first-secret","password":"second-secret","name":"Druid EU"}\n',
- );
- });
-
- it("marks raw-only document edits dirty so they can be saved", () => {
- const schema: FileSchema = {
- path: "serverconfig.xml",
- format: "raw",
- label: "serverconfig.xml",
- sections: [],
- };
- const store = ConfigEditorStore.fromLoadedFile(
- schema,
- rawAdapter.parse("\n"),
- );
-
- store.replaceWorkingSource("\n");
-
- expect(store.snapshot().dirty).toBe(true);
- expect(store.snapshot().unstructuredChanges).toBe(true);
- expect(store.snapshot().restartRequired).toBe(true);
- });
-
- it("marks unknown-key raw edits dirty in a typed document", () => {
- const store = loadedStore(
- "motd=hello\nfuture-setting=before\n",
- fileSchema([
- baseField({ key: "motd", type: "string", min: undefined, max: undefined }),
- ]),
- );
-
- store.replaceWorkingSource("motd=hello\nfuture-setting=after\n");
-
- expect(store.snapshot().dirty).toBe(true);
- });
-
- it("discovers loaded unknown keys as typed form fields", () => {
- const store = loadedStore(
- "motd=hello\nenable-feature=true\nworker-count=4\nratio=1.5\n",
- fileSchema([
- baseField({ key: "motd", type: "string", min: undefined, max: undefined }),
- ]),
- );
-
- const detected = store.schema.sections.find(
- (section) => section.id === "detected-settings",
- );
-
- expect(detected?.fields.map(({ key, type }) => ({ key, type }))).toEqual([
- { key: "enable-feature", type: "boolean" },
- { key: "worker-count", type: "string" },
- { key: "ratio", type: "string" },
- ]);
- });
-
- it("keeps undeclared numeric-looking strings lossless", () => {
- const store = loadedStore(
- "seed=000012340000123400001234\nsteamid=76561198012345678\n",
- fileSchema([]),
- );
-
- expect(store.snapshot().fields.seed).toEqual(
- expect.objectContaining({ type: "string", value: "000012340000123400001234" }),
- );
- expect(store.snapshot().fields.steamid).toEqual(
- expect.objectContaining({ type: "string", value: "76561198012345678" }),
- );
- });
-
- it("discovers keys added in raw mode without duplicating declared fields", () => {
- const store = loadedStore(
- "motd=hello\n",
- fileSchema([
- baseField({ key: "motd", type: "string", min: undefined, max: undefined }),
- ]),
- );
-
- store.replaceWorkingSource("motd=updated\nnew-option=enabled\n");
-
- expect(
- store.schema.sections.flatMap((section) => section.fields.map((field) => field.key)),
- ).toEqual(["motd", "new-option"]);
- });
-
- it("masks automatically discovered secret fields", () => {
- const store = loadedStore(
- "rcon.password=actual-secret\n",
- fileSchema([]),
- );
-
- const secret = store.snapshot().fields["rcon.password"];
-
- expect(secret).toEqual(
- expect.objectContaining({
- type: "secret",
- sensitive: true,
- displayValue: MASKED_SECRET,
- }),
- );
- expect(JSON.stringify(secret)).not.toContain("actual-secret");
- expect(store.serializeForDisplay()).not.toContain("actual-secret");
- });
-
- it("detects common game-server credentials without masking unrelated keys", () => {
- const store = loadedStore(
- "steampass=steam-secret\ngslt=token-value\nserverPassword=join-secret\npass=short-secret\ncompass=north\n",
- fileSchema([]),
- );
-
- for (const key of ["steampass", "gslt", "serverPassword", "pass"]) {
- expect(store.snapshot().fields[key]).toEqual(
- expect.objectContaining({ type: "secret", sensitive: true }),
- );
- }
- expect(store.snapshot().fields.compass).toEqual(
- expect.objectContaining({ type: "string", sensitive: false, value: "north" }),
- );
- const display = store.serializeForDisplay();
- expect(display).not.toContain("steam-secret");
- expect(display).not.toContain("token-value");
- expect(display).not.toContain("join-secret");
- expect(display).not.toContain("short-secret");
- expect(display).toContain("compass=north");
- });
-
- it("can add a declared JSON scalar that is absent from the loaded file", () => {
- const schema: FileSchema = {
- path: "config.json",
- format: "json",
- label: "config.json",
- sections: [{
- id: "server",
- label: "Server",
- fields: [baseField({
- key: "/port",
- label: "Port",
- type: "integer",
- min: 1,
- max: 65535,
- })],
- }],
- };
- const store = ConfigEditorStore.fromLoadedFile(schema, jsonAdapter.parse("{}\n"));
-
- store.setDisplayValue("/port", "5520");
-
- expect(store.serializeSelectedFile()).toBe('{\n "port": 5520\n}\n');
- });
-});
diff --git a/packages/config-editor/src/store.ts b/packages/config-editor/src/store.ts
deleted file mode 100644
index c30a421..0000000
--- a/packages/config-editor/src/store.ts
+++ /dev/null
@@ -1,399 +0,0 @@
-import { createAdapterRegistry } from "./adapters/registry.js";
-import type { ConfigAdapter, ParsedDocument } from "./adapters/types.js";
-import type {
- ConfigValue,
- FieldSchema,
- FileSchema,
- FieldType,
- SectionSchema,
- ValidationIssue,
-} from "./model.js";
-import { coerceFieldValue, validateField } from "./validation.js";
-
-export const MASKED_SECRET = "••••••••";
-
-interface InternalFieldState {
- schema: FieldSchema;
- original: ConfigValue | undefined;
- current: ConfigValue | undefined;
- displayValue: string;
- issues: ValidationIssue[];
-}
-
-export interface FieldSnapshot {
- readonly key: string;
- readonly label: string;
- readonly type: FieldType;
- readonly displayValue: string;
- readonly sensitive: boolean;
- readonly dirty: boolean;
- readonly issues: readonly ValidationIssue[];
- readonly value?: ConfigValue;
-}
-
-export interface ChangeRecord {
- readonly key: string;
- readonly label: string;
- readonly before: ConfigValue | null | undefined;
- readonly after: ConfigValue | null | undefined;
- readonly sensitive: boolean;
- readonly restartRequired: boolean;
-}
-
-export interface EditorSnapshot {
- readonly filePath: string;
- readonly fields: Readonly>;
- readonly changes: readonly ChangeRecord[];
- readonly issues: readonly ValidationIssue[];
- readonly dirty: boolean;
- readonly unstructuredChanges: boolean;
- readonly restartRequired: boolean;
-}
-
-const allFields = (schema: FileSchema): FieldSchema[] =>
- schema.sections.flatMap((section) => section.fields);
-
-const sensitiveKey = (key: string): boolean => {
- const normalized = key.replace(/([a-z\d])([A-Z])/g, "$1.$2").toLowerCase();
- const segments = normalized.split(/[^a-z\d]+/).filter(Boolean);
- const sensitiveSegments = new Set([
- "password",
- "passwd",
- "pass",
- "secret",
- "token",
- "apikey",
- "privatekey",
- "gslt",
- "steampass",
- ]);
- return segments.some((segment) => sensitiveSegments.has(segment)) ||
- /(?:password|passwd|steampass|api[-_.]?key|private[-_.]?key|gslt)/i.test(key);
-};
-
-const inferredType = (value: ConfigValue): FieldType => {
- if (typeof value === "boolean") return "boolean";
- if (typeof value === "number") return Number.isInteger(value) ? "integer" : "number";
- if (typeof value !== "string") return "string";
- if (/^(?:true|false)$/i.test(value)) return "boolean";
- return "string";
-};
-
-const humanizeKey = (key: string): string => {
- const leaf = key.split(/[./]/).at(-1) ?? key;
- const label = leaf.replace(/[-_]+/g, " ").replace(/([a-z\d])([A-Z])/g, "$1 $2");
- return label.charAt(0).toUpperCase() + label.slice(1);
-};
-
-const schemaWithDetectedFields = (
- declaredSchema: FileSchema,
- entries: readonly { key: string; value: ConfigValue }[],
-): FileSchema => {
- const declaredKeys = new Set(allFields(declaredSchema).map((field) => field.key));
- const detectedFields: FieldSchema[] = [];
- for (const entry of entries) {
- if (entry.key === "" || declaredKeys.has(entry.key)) continue;
- const sensitive = sensitiveKey(entry.key);
- detectedFields.push({
- key: entry.key,
- label: humanizeKey(entry.key),
- description: `Setting discovered in ${declaredSchema.label}.`,
- documentation:
- declaredSchema.documentation ??
- `Configuration file ${declaredSchema.path}`,
- type: sensitive ? "secret" : inferredType(entry.value),
- sensitive,
- restartRequired: true,
- });
- }
- if (detectedFields.length === 0) return declaredSchema;
- const detectedSection: SectionSchema = {
- id: "detected-settings",
- label: "Detected settings",
- description: "Settings discovered from the loaded configuration file.",
- fields: detectedFields,
- };
- return {
- ...declaredSchema,
- sections: [...declaredSchema.sections, detectedSection],
- };
-};
-
-const valuesEqual = (
- left: ConfigValue | undefined,
- right: ConfigValue | undefined,
-): boolean => Object.is(left, right);
-
-const displayValue = (
- field: FieldSchema,
- value: ConfigValue | undefined,
-): string => {
- if ((field.sensitive || field.type === "secret") && value !== undefined && value !== "") {
- return MASKED_SECRET;
- }
- return value === null || value === undefined ? "" : String(value);
-};
-
-const deepFreezeSnapshot = (snapshot: EditorSnapshot): EditorSnapshot => {
- for (const field of Object.values(snapshot.fields)) {
- for (const fieldIssue of field.issues) Object.freeze(fieldIssue);
- Object.freeze(field.issues);
- Object.freeze(field);
- }
- for (const change of snapshot.changes) Object.freeze(change);
- for (const editorIssue of snapshot.issues) Object.freeze(editorIssue);
- Object.freeze(snapshot.fields);
- Object.freeze(snapshot.changes);
- Object.freeze(snapshot.issues);
- return Object.freeze(snapshot);
-};
-
-export class ConfigEditorStore {
- private readonly declaredSchema: FileSchema;
- private schemaValue: FileSchema;
- private readonly adapter: ConfigAdapter;
- private originalDocument: ParsedDocument;
- private workingDocument: ParsedDocument;
- private fields: Map;
-
- private constructor(
- schema: FileSchema,
- document: ParsedDocument,
- adapter: ConfigAdapter,
- ) {
- this.declaredSchema = schema;
- this.adapter = adapter;
- this.schemaValue = schemaWithDetectedFields(schema, adapter.entries(document));
- this.originalDocument = document;
- this.workingDocument = document;
- this.fields = this.readFields(document, document);
- }
-
- get schema(): FileSchema {
- return this.schemaValue;
- }
-
- static fromLoadedFile(
- schema: FileSchema,
- document: ParsedDocument,
- ): ConfigEditorStore {
- if (schema.format !== document.format) {
- throw new TypeError(
- `Schema format "${schema.format}" does not match document format "${document.format}".`,
- );
- }
- const adapter = createAdapterRegistry().get(schema.format);
- if (!adapter) throw new TypeError(`No adapter registered for "${schema.format}".`);
- return new ConfigEditorStore(schema, document, adapter);
- }
-
- private readValue(
- document: ParsedDocument,
- field: FieldSchema,
- ): { value: ConfigValue | undefined; issues: ValidationIssue[] } {
- const raw = this.adapter.get(document, field.key);
- if (raw === undefined) return { value: undefined, issues: [] };
- try {
- const value = coerceFieldValue(field, raw);
- return { value, issues: validateField(field, value) };
- } catch (error) {
- return {
- value: undefined,
- issues: [
- {
- code: "type",
- message: error instanceof Error ? error.message : "Invalid value.",
- severity: "error",
- fieldKey: field.key,
- filePath: this.schema.path,
- },
- ],
- };
- }
- }
-
- private readFields(
- original: ParsedDocument,
- working: ParsedDocument,
- ): Map {
- return new Map(
- allFields(this.schema).map((field) => {
- const originalResult = this.readValue(original, field);
- const workingResult = this.readValue(working, field);
- return [
- field.key,
- {
- schema: field,
- original: originalResult.value,
- current: workingResult.value,
- displayValue: displayValue(field, workingResult.value),
- issues: workingResult.issues,
- },
- ];
- }),
- );
- }
-
- setDisplayValue(key: string, input: string): void {
- const state = this.fields.get(key);
- if (!state) throw new TypeError(`Unknown configuration field "${key}".`);
- const sensitive = state.schema.sensitive || state.schema.type === "secret";
- if (sensitive && input === MASKED_SECRET) {
- state.displayValue = MASKED_SECRET;
- state.issues = [];
- return;
- }
-
- state.displayValue = input;
- let value: ConfigValue;
- try {
- value = coerceFieldValue(state.schema, input);
- } catch (error) {
- state.issues = [
- {
- code: "type",
- message: error instanceof Error ? error.message : "Invalid value.",
- severity: "error",
- fieldKey: key,
- filePath: this.schema.path,
- },
- ];
- return;
- }
-
- const issues = validateField(state.schema, value).map((fieldIssue) => ({
- ...fieldIssue,
- filePath: this.schema.path,
- }));
- state.issues = issues;
- if (issues.length > 0) return;
- this.workingDocument = this.adapter.set(this.workingDocument, key, value);
- state.current = value;
- if (sensitive) state.displayValue = MASKED_SECRET;
- }
-
- serializeSelectedFile(): string {
- return this.adapter.serialize(this.workingDocument);
- }
-
- serializeForDisplay(): string {
- let displayDocument = this.workingDocument;
- for (const field of allFields(this.schema)) {
- if (!(field.sensitive || field.type === "secret")) continue;
- const values = this.adapter.getAll(displayDocument, field.key);
- if (values.length === 0) continue;
- displayDocument = this.adapter.setAll(
- displayDocument,
- field.key,
- values.map(() => MASKED_SECRET),
- );
- }
- return this.adapter.serialize(displayDocument);
- }
-
- validateSerializedSource(source: string): ValidationIssue[] {
- const document = this.adapter.parse(source);
- const issues = this.adapter.validate(document, this.schema);
- for (const field of allFields(this.schema)) {
- issues.push(...this.readValue(document, field).issues);
- }
- return issues;
- }
-
- loadedSource(): string {
- return this.adapter.serialize(this.originalDocument);
- }
-
- replaceWorkingSource(source: string): void {
- let document = this.adapter.parse(source);
- for (const field of allFields(this.schema)) {
- if (!(field.sensitive || field.type === "secret")) continue;
- const displayedValues = this.adapter.getAll(document, field.key);
- if (!displayedValues.includes(MASKED_SECRET)) continue;
- const currentSecrets = this.adapter.getAllRaw(this.workingDocument, field.key);
- if (displayedValues.length !== currentSecrets.length) {
- throw new Error(
- `Secret occurrences for "${field.key}" cannot be added or removed in raw mode.`,
- );
- }
- document = this.adapter.setAllRaw(
- document,
- field.key,
- displayedValues.map((value, index) =>
- value === MASKED_SECRET
- ? currentSecrets[index]!
- : this.adapter.getAllRaw(document, field.key)[index]!,
- ),
- );
- }
- this.workingDocument = document;
- this.schemaValue = schemaWithDetectedFields(
- this.declaredSchema,
- this.adapter.entries(document),
- );
- this.fields = this.readFields(this.originalDocument, this.workingDocument);
- }
-
- acceptSavedSource(source: string): void {
- const document = this.adapter.parse(source);
- this.originalDocument = document;
- this.workingDocument = document;
- this.schemaValue = schemaWithDetectedFields(
- this.declaredSchema,
- this.adapter.entries(document),
- );
- this.fields = this.readFields(document, document);
- }
-
- snapshot(): EditorSnapshot {
- const fields: Record = {};
- const changes: ChangeRecord[] = [];
- const issues: ValidationIssue[] = [...this.workingDocument.issues];
- const documentDirty =
- this.adapter.serialize(this.workingDocument) !==
- this.adapter.serialize(this.originalDocument);
-
- for (const [key, state] of this.fields) {
- const sensitive = Boolean(state.schema.sensitive || state.schema.type === "secret");
- const dirty = !valuesEqual(state.current, state.original);
- const fieldIssues = state.issues.map((fieldIssue) => ({ ...fieldIssue }));
- const fieldSnapshot: FieldSnapshot = {
- key,
- label: state.schema.label,
- type: state.schema.type,
- displayValue: state.displayValue,
- sensitive,
- dirty,
- issues: fieldIssues,
- ...(!sensitive && state.current !== undefined
- ? { value: state.current }
- : {}),
- };
- fields[key] = fieldSnapshot;
- issues.push(...fieldIssues);
-
- if (dirty) {
- changes.push({
- key,
- label: state.schema.label,
- before: sensitive ? null : state.original,
- after: sensitive ? null : state.current,
- sensitive,
- restartRequired: Boolean(state.schema.restartRequired),
- });
- }
- }
-
- const unstructuredChanges = documentDirty && changes.length === 0;
- return deepFreezeSnapshot({
- filePath: this.schema.path,
- fields,
- changes,
- issues,
- dirty: documentDirty,
- unstructuredChanges,
- restartRequired:
- unstructuredChanges || changes.some((change) => change.restartRequired),
- });
- }
-}
diff --git a/packages/config-editor/src/styles.ts b/packages/config-editor/src/styles.ts
deleted file mode 100644
index 653dc0d..0000000
--- a/packages/config-editor/src/styles.ts
+++ /dev/null
@@ -1,98 +0,0 @@
-export const EDITOR_STYLES = `
-:host {
- display: block;
- width: 100%;
- --druid-bg: #07100d;
- --druid-panel: #101b17;
- --druid-panel-raised: #15231d;
- --druid-border: #294137;
- --druid-border-strong: #3f6655;
- --druid-text: #f4f7f5;
- --druid-muted: #9eb0a8;
- --druid-accent: #a8ef9c;
- --druid-accent-ink: #10210f;
- --druid-warning: #f0c96b;
- --druid-error: #ff8b7f;
- color: var(--druid-text);
- font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
- color-scheme: dark;
-}
-* { box-sizing: border-box; }
-button, input, select, textarea { font: inherit; }
-button, input, select, textarea, a { outline: none; }
-button:focus-visible, input:focus-visible, select:focus-visible, textarea:focus-visible, a:focus-visible {
- box-shadow: 0 0 0 3px rgba(168, 239, 156, .28);
- border-color: var(--druid-accent);
-}
-.config-editor { min-height: 640px; background: radial-gradient(circle at 75% 0%, #173326 0, transparent 32%), var(--druid-bg); color: var(--druid-text); }
-.editor-header { min-height: 76px; display: flex; align-items: center; justify-content: space-between; gap: 20px; padding: 16px 22px; border-bottom: 1px solid var(--druid-border); }
-.eyebrow { color: var(--druid-accent); font-size: 11px; font-weight: 800; letter-spacing: .14em; text-transform: uppercase; }
-.editor-title { margin: 3px 0 0; font-size: clamp(20px, 2.4vw, 30px); line-height: 1.1; }
-.server-version { color: var(--druid-muted); font-size: 13px; }
-.editor-grid { display: grid; grid-template-columns: minmax(210px, 250px) minmax(420px, 1fr) minmax(260px, 320px); min-height: 564px; }
-.file-rail, .inspector { background: rgba(11, 21, 17, .8); }
-.file-rail { padding: 18px 14px; border-right: 1px solid var(--druid-border); }
-.rail-heading, .inspector h2 { margin: 0 0 12px; font-size: 12px; color: var(--druid-muted); letter-spacing: .08em; text-transform: uppercase; }
-.file-list { display: grid; gap: 8px; }
-.file-button { width: 100%; min-height: 52px; display: grid; gap: 3px; padding: 10px 12px; text-align: left; border: 1px solid transparent; border-radius: 10px; background: transparent; color: var(--druid-text); cursor: pointer; }
-.file-button:hover { background: var(--druid-panel); }
-.file-button[aria-current="true"] { border-color: var(--druid-border-strong); background: var(--druid-panel-raised); }
-.file-path { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 700; }
-.file-format { color: var(--druid-muted); font-size: 12px; text-transform: uppercase; }
-.editor-main { min-width: 0; padding: 18px 22px 92px; }
-.tabs { display: inline-flex; gap: 4px; padding: 4px; margin-bottom: 18px; border: 1px solid var(--druid-border); border-radius: 10px; background: #0b1511; }
-.tab { min-height: 40px; min-width: 92px; border: 0; border-radius: 7px; background: transparent; color: var(--druid-muted); cursor: pointer; }
-.tab[aria-selected="true"] { background: var(--druid-panel-raised); color: var(--druid-text); }
-.form-editor { display: grid; gap: 18px; }
-.field-section { min-width: 0; margin: 0; padding: 18px; border: 1px solid var(--druid-border); border-radius: 14px; background: rgba(16, 27, 23, .84); }
-.field-section legend { padding: 0 8px; color: var(--druid-accent); font-weight: 800; }
-.section-description { margin: 0 0 16px; color: var(--druid-muted); font-size: 13px; }
-.field-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; }
-.field-control { min-width: 0; display: grid; align-content: start; gap: 7px; }
-.field-control label { font-size: 13px; font-weight: 750; }
-.field-description { min-height: 34px; margin: 0; color: var(--druid-muted); font-size: 12px; line-height: 1.45; }
-.field-input { width: 100%; min-height: 44px; padding: 10px 12px; border: 1px solid var(--druid-border); border-radius: 9px; background: #09130f; color: var(--druid-text); }
-.boolean-control { min-height: 44px; display: flex; align-items: center; gap: 10px; padding: 8px 11px; border: 1px solid var(--druid-border); border-radius: 9px; background: #09130f; }
-.boolean-control input { width: 20px; height: 20px; accent-color: var(--druid-accent); }
-.field-meta { display: flex; flex-wrap: wrap; gap: 8px; color: var(--druid-muted); font-size: 11px; }
-.restart-chip { color: var(--druid-warning); }
-.field-error { margin: 0; color: var(--druid-error); font-size: 12px; }
-.raw-editor { display: grid; gap: 10px; }
-.raw-textarea { width: 100%; min-height: 440px; resize: vertical; padding: 16px; border: 1px solid var(--druid-border); border-radius: 12px; background: #050b08; color: #dce8e1; font: 13px/1.55 "Cascadia Code", "SFMono-Regular", Consolas, monospace; tab-size: 2; }
-.inspector { display: flex; flex-direction: column; gap: 18px; padding: 18px; border-left: 1px solid var(--druid-border); }
-.status-card { padding: 14px; border: 1px solid var(--druid-border); border-radius: 12px; background: var(--druid-panel); }
-.status-card p { margin: 4px 0; color: var(--druid-muted); font-size: 13px; }
-.change-list, .issue-list { display: grid; gap: 8px; margin: 0; padding: 0; list-style: none; }
-.change-item { padding: 10px; border: 1px solid var(--druid-border); border-radius: 9px; background: #0b1511; font-size: 13px; }
-.change-value { display: block; margin-top: 4px; color: var(--druid-muted); word-break: break-word; }
-.issue-item { color: var(--druid-error); font-size: 12px; }
-.action-bar { position: sticky; bottom: 0; display: flex; align-items: center; justify-content: space-between; gap: 16px; margin: auto 0 0; padding-top: 14px; background: linear-gradient(transparent, rgba(11, 21, 17, .98) 32%); }
-.save-button { min-height: 46px; padding: 0 18px; border: 0; border-radius: 9px; background: var(--druid-accent); color: var(--druid-accent-ink); font-weight: 850; cursor: pointer; }
-.save-button:disabled { opacity: .45; cursor: not-allowed; }
-.action-status { color: var(--druid-muted); font-size: 12px; }
-.error-shell { margin: 20px; padding: 18px; border: 1px solid var(--druid-error); border-radius: 12px; background: #28120f; color: #ffd8d2; }
-@media (max-width: 900px) {
- .editor-grid { grid-template-columns: 190px minmax(0, 1fr); }
- .inspector { grid-column: 1 / -1; border-left: 0; border-top: 1px solid var(--druid-border); }
-}
-@media (max-width: 560px) {
- .config-editor { min-height: 100%; }
- .editor-header { align-items: flex-start; padding: 14px 16px; }
- .editor-grid { display: block; }
- .file-rail { padding: 12px 16px; border-right: 0; border-bottom: 1px solid var(--druid-border); overflow-x: auto; }
- .file-list { display: flex; width: max-content; }
- .file-button { width: 190px; }
- .editor-main { padding: 16px 16px 28px; }
- .tabs { display: flex; }
- .tab { flex: 1; }
- .field-section { padding: 14px; }
- .field-grid { grid-template-columns: 1fr; }
- .raw-textarea { min-height: 360px; }
- .inspector { padding: 16px; }
- .action-bar { position: static; }
- .save-button { width: 100%; }
-}
-@media (prefers-reduced-motion: reduce) {
- *, *::before, *::after { scroll-behavior: auto !important; transition: none !important; animation: none !important; }
-}
-`;
diff --git a/packages/config-editor/src/test-fixtures.ts b/packages/config-editor/src/test-fixtures.ts
deleted file mode 100644
index 75e4c44..0000000
--- a/packages/config-editor/src/test-fixtures.ts
+++ /dev/null
@@ -1,76 +0,0 @@
-export const validManifest = (path = "server.properties") => ({
- version: 1,
- server: {
- family: "minecraft",
- displayName: "Minecraft Server",
- appVersion: "1.21.7",
- },
- files: [
- {
- path,
- format: "java-properties",
- label: path,
- sections: [
- {
- id: "players",
- label: "Players",
- fields: [
- {
- key: "max-players",
- label: "Max players",
- description: "Maximum player count.",
- documentation: "https://minecraft.wiki/w/Server.properties",
- type: "integer",
- min: 1,
- max: 1000,
- },
- ],
- },
- ],
- },
- ],
-});
-
-export const duplicateManifest = () => {
- const manifest = validManifest();
- return { ...manifest, files: [manifest.files[0], manifest.files[0]] };
-};
-
-export const duplicateFieldManifest = () => {
- const manifest = validManifest();
- const file = manifest.files[0]!;
- const field = file.sections[0]!.fields[0]!;
- return {
- ...manifest,
- files: [
- {
- ...file,
- sections: [
- {
- ...file.sections[0],
- fields: [field, { ...field, label: "Duplicate" }],
- },
- ],
- },
- ],
- };
-};
-
-export const versionedFile = () => {
- const file = validManifest().files[0]!;
- const field = file.sections[0]!.fields[0]!;
- return {
- ...file,
- sections: [
- {
- id: "versions",
- label: "Versions",
- fields: [
- { ...field, key: "always" },
- { ...field, key: "legacy", until: "1.20.4" },
- { ...field, key: "modern", since: "1.21.1" },
- ],
- },
- ],
- };
-};
diff --git a/packages/config-editor/src/validation.test.ts b/packages/config-editor/src/validation.test.ts
deleted file mode 100644
index 7a63e2d..0000000
--- a/packages/config-editor/src/validation.test.ts
+++ /dev/null
@@ -1,64 +0,0 @@
-import { describe, expect, it } from "vitest";
-import type { FieldSchema } from "./model.js";
-import { coerceFieldValue, validateField } from "./validation.js";
-
-const field = (overrides: Partial = {}): FieldSchema => ({
- key: "max-players",
- label: "Max players",
- description: "Maximum player count.",
- documentation: "https://minecraft.wiki/w/Server.properties",
- type: "integer",
- min: 1,
- max: 1000,
- ...overrides,
-});
-
-describe("coerceFieldValue", () => {
- it("strictly coerces supported scalar inputs", () => {
- expect(coerceFieldValue(field(), "20")).toBe(20);
- expect(coerceFieldValue(field({ type: "number" }), "1.25")).toBe(1.25);
- expect(coerceFieldValue(field({ type: "boolean", min: undefined, max: undefined }), "true")).toBe(true);
- expect(coerceFieldValue(field({ type: "boolean", min: undefined, max: undefined }), "false")).toBe(false);
- expect(coerceFieldValue(field({ type: "string", min: undefined, max: undefined }), "")).toBe("");
- });
-
- it.each(["TRUE", "yes", "1", ""])("rejects non-canonical boolean %j", (input) => {
- expect(() =>
- coerceFieldValue(
- field({ type: "boolean", min: undefined, max: undefined }),
- input,
- ),
- ).toThrow(/true or false/);
- });
-
- it.each(["1.5", "NaN", "Infinity", "", " "])(
- "rejects invalid integer %j",
- (input) => expect(() => coerceFieldValue(field(), input)).toThrow(/integer/),
- );
-});
-
-describe("validateField", () => {
- it("rejects an integer outside its inclusive range", () => {
- expect(validateField(field(), 0)).toEqual([
- expect.objectContaining({
- code: "range",
- message: "Value must be between 1 and 1000 (inclusive).",
- }),
- ]);
- });
-
- it("enforces enum membership and a full-string pattern", () => {
- expect(
- validateField(
- field({ type: "enum", values: ["peaceful", "hard"], min: undefined, max: undefined }),
- "unknown",
- ),
- ).toEqual([expect.objectContaining({ code: "enum" })]);
- expect(
- validateField(
- field({ type: "string", pattern: "[a-z]+", min: undefined, max: undefined }),
- "abc123",
- ),
- ).toEqual([expect.objectContaining({ code: "pattern" })]);
- });
-});
diff --git a/packages/config-editor/src/validation.ts b/packages/config-editor/src/validation.ts
deleted file mode 100644
index 809b640..0000000
--- a/packages/config-editor/src/validation.ts
+++ /dev/null
@@ -1,111 +0,0 @@
-import type { ConfigValue, FieldSchema, ValidationIssue } from "./model.js";
-
-const issue = (
- field: FieldSchema,
- code: string,
- message: string,
-): ValidationIssue => ({
- code,
- message,
- severity: "error",
- fieldKey: field.key,
-});
-
-const scalarText = (input: unknown): string => {
- if (input === null) return "";
- if (typeof input === "string") return input;
- if (typeof input === "number" || typeof input === "boolean") return String(input);
- throw new TypeError("Value must be a scalar.");
-};
-
-export const coerceFieldValue = (
- field: FieldSchema,
- input: unknown,
-): ConfigValue => {
- if (field.type === "string" || field.type === "secret" || field.type === "enum") {
- return scalarText(input);
- }
- if (field.type === "boolean") {
- if (input === true || input === false) return input;
- if (input === "true") return true;
- if (input === "false") return false;
- throw new TypeError("Value must be true or false.");
- }
-
- if (typeof input === "number") {
- if (!Number.isFinite(input)) throw new TypeError("Value must be a finite number.");
- if (field.type === "integer" && !Number.isSafeInteger(input)) {
- throw new TypeError("Value must be an integer.");
- }
- return input;
- }
-
- const text = scalarText(input).trim();
- if (field.type === "integer") {
- if (!/^[+-]?\d+$/.test(text)) throw new TypeError("Value must be an integer.");
- const value = Number(text);
- if (!Number.isSafeInteger(value)) throw new TypeError("Value must be an integer.");
- return value;
- }
- if (!/^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?$/.test(text)) {
- throw new TypeError("Value must be a finite number.");
- }
- const value = Number(text);
- if (!Number.isFinite(value)) throw new TypeError("Value must be a finite number.");
- return value;
-};
-
-export const validateField = (
- field: FieldSchema,
- value: ConfigValue,
-): ValidationIssue[] => {
- const issues: ValidationIssue[] = [];
-
- if (field.type === "integer" && (typeof value !== "number" || !Number.isSafeInteger(value))) {
- issues.push(issue(field, "type", "Value must be an integer."));
- return issues;
- }
- if (field.type === "number" && (typeof value !== "number" || !Number.isFinite(value))) {
- issues.push(issue(field, "type", "Value must be a finite number."));
- return issues;
- }
- if (field.type === "boolean" && typeof value !== "boolean") {
- issues.push(issue(field, "type", "Value must be true or false."));
- return issues;
- }
- if (
- (field.type === "string" || field.type === "secret" || field.type === "enum") &&
- typeof value !== "string"
- ) {
- issues.push(issue(field, "type", "Value must be text."));
- return issues;
- }
-
- if (typeof value === "number") {
- const below = field.min !== undefined && value < field.min;
- const above = field.max !== undefined && value > field.max;
- if (below || above) {
- let message: string;
- if (field.min !== undefined && field.max !== undefined) {
- message = `Value must be between ${field.min} and ${field.max} (inclusive).`;
- } else if (field.min !== undefined) message = `Value must be at least ${field.min}.`;
- else message = `Value must be at most ${field.max}.`;
- issues.push(issue(field, "range", message));
- }
- }
-
- if (field.type === "enum" && typeof value === "string" && !field.values?.includes(value)) {
- issues.push(
- issue(field, "enum", `Value must be one of: ${(field.values ?? []).join(", ")}.`),
- );
- }
-
- if (field.pattern !== undefined && typeof value === "string") {
- const pattern = new RegExp(`^(?:${field.pattern})$`);
- if (!pattern.test(value)) {
- issues.push(issue(field, "pattern", "Value does not match the required format."));
- }
- }
-
- return issues;
-};
diff --git a/packages/config-editor/tsconfig.json b/packages/config-editor/tsconfig.json
deleted file mode 100644
index 1b9359e..0000000
--- a/packages/config-editor/tsconfig.json
+++ /dev/null
@@ -1,12 +0,0 @@
-{
- "extends": "../../tsconfig.base.json",
- "compilerOptions": {
- "rootDir": "src",
- "outDir": "dist/types",
- "jsx": "react-jsx",
- "jsxImportSource": "@druid-ui/component",
- "types": ["@druid-ui/component/jsx"]
- },
- "include": ["src"],
- "exclude": ["src/**/*.test.ts", "src/test-fixtures.ts"]
-}
diff --git a/packages/plattform/src/index.ts b/packages/plattform/src/index.ts
index 7ef9ffb..24ef8dc 100644
--- a/packages/plattform/src/index.ts
+++ b/packages/plattform/src/index.ts
@@ -4,7 +4,6 @@ import {
request as requestRaw,
loadFileFromDeployment as loadFileFromDeploymentRaw,
saveFileToDeployment as saveFileToDeploymentRaw,
- saveFileToDeploymentIfMatch as saveFileToDeploymentIfMatchRaw,
} from "druid:ui/plattform";
// it is very imporant that this is external, as druid-ui/component contains
// functions that must be important and cannot just be copied in again
@@ -18,7 +17,3 @@ export const loadFileFromDeployment = rawAsyncToPromise(
);
export const saveFileToDeployment = rawAsyncToPromise(saveFileToDeploymentRaw);
-
-export const saveFileToDeploymentIfMatch = rawAsyncToPromise(
- saveFileToDeploymentIfMatchRaw,
-);
diff --git a/packages/plattform/wit/druid-plattform.wit b/packages/plattform/wit/druid-plattform.wit
index 2ebe3e1..dfb1225 100644
--- a/packages/plattform/wit/druid-plattform.wit
+++ b/packages/plattform/wit/druid-plattform.wit
@@ -4,7 +4,6 @@ interface plattform {
request: func(url: string, method: string, body: string, header: tuple) -> string;
load-file-from-deployment: func(path: string) -> string;
save-file-to-deployment: func(path: string, content: string) -> string;
- save-file-to-deployment-if-match: func(path: string, content: string, expected-fingerprint: string) -> string;
}
world druid-plattform {
diff --git a/packages/vite/src/plugin.test.ts b/packages/vite/src/plugin.test.ts
index 3fe97c6..d72fd57 100644
--- a/packages/vite/src/plugin.test.ts
+++ b/packages/vite/src/plugin.test.ts
@@ -5,7 +5,7 @@ const mocks = vi.hoisted(() => ({
buildRaw: vi.fn(async () => undefined),
glob: vi.fn(async () => [
"src/app.tsx",
- "../../packages/config-editor/src/copy.ts",
+ "../../packages/component/src/utils.ts",
]),
}));
@@ -24,7 +24,7 @@ describe("ViteHMRPlugin", () => {
it("registers component and dependency files with the dev-server watcher", async () => {
const plugin = ViteHMRPlugin("src/app.tsx", "raw", undefined, [
- "../../packages/config-editor/src/**/*.{ts,tsx}",
+ "../../packages/component/src/**/*.{ts,tsx}",
]) as {
configureServer(server: unknown): Promise;
};
@@ -37,11 +37,11 @@ describe("ViteHMRPlugin", () => {
expect(mocks.glob).toHaveBeenCalledWith([
"src/app.tsx",
- "../../packages/config-editor/src/**/*.{ts,tsx}",
+ "../../packages/component/src/**/*.{ts,tsx}",
]);
expect(add).toHaveBeenCalledWith([
"src/app.tsx",
- "../../packages/config-editor/src/copy.ts",
+ "../../packages/component/src/utils.ts",
]);
expect(mocks.buildRaw).toHaveBeenCalledWith("src/app.tsx", "public");
});
diff --git a/scripts/copy-plattform-wit.sh b/scripts/copy-plattform-wit.sh
index e578439..657b667 100755
--- a/scripts/copy-plattform-wit.sh
+++ b/scripts/copy-plattform-wit.sh
@@ -35,8 +35,7 @@ fi
for destination in \
"$ROOT/examples/druid-plattform/wit/druid-plattform.wit" \
"$ROOT/examples/druid-plattform-no-sandbox/wit/druid-plattform.wit" \
- "$ROOT/examples/starter-component-plattform/wit/druid-plattform.wit" \
- "$ROOT/examples/config-editor-plattform/wit/druid-plattform.wit"
+ "$ROOT/examples/starter-component-plattform/wit/druid-plattform.wit"
do
copy_wit "$DEFAULT_SOURCE" "$destination"
done