@mxml3gend/gloss 0.1.2 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,12 +1,19 @@
1
1
  #!/usr/bin/env node
2
2
  import fs from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { createInterface } from "node:readline/promises";
3
5
  import { fileURLToPath } from "node:url";
4
6
  import open from "open";
7
+ import { resetIssueBaseline, updateIssueBaseline } from "./baseline.js";
8
+ import { clearGlossCaches, getCacheStatus } from "./cache.js";
5
9
  import { printGlossCheck, runGlossCheck } from "./check.js";
6
- import { GlossConfigError, loadGlossConfig } from "./config.js";
10
+ import { GlossConfigError, discoverLocaleDirectoryCandidates, loadGlossConfig, } from "./config.js";
11
+ import { installPreCommitHooks } from "./hooks.js";
7
12
  import { startServer } from "./server.js";
8
13
  import { generateKeyTypes } from "./typegen.js";
9
14
  const DEFAULT_PORT = 5179;
15
+ const GENERATED_CONFIG_FILE = "gloss.config.cjs";
16
+ const projectRoot = () => process.env.INIT_CWD || process.cwd();
10
17
  const getVersion = async () => {
11
18
  const packagePath = fileURLToPath(new URL("../package.json", import.meta.url));
12
19
  const raw = await fs.readFile(packagePath, "utf8");
@@ -20,6 +27,10 @@ Usage:
20
27
  gloss [options]
21
28
  gloss check [options]
22
29
  gloss gen-types [options]
30
+ gloss init-hooks [options]
31
+ gloss baseline reset
32
+ gloss cache status
33
+ gloss cache clear
23
34
  gloss open key <translation-key> [options]
24
35
 
25
36
  Options:
@@ -28,21 +39,85 @@ Options:
28
39
 
29
40
  Serve options:
30
41
  --no-open Do not open browser automatically
42
+ --no-cache Bypass scanner caches for API responses
31
43
  -p, --port Set server port (default: ${DEFAULT_PORT})
32
44
 
33
45
  Check options:
34
46
  --format <human|json|both> Output format (default: human)
35
47
  --json Shortcut for --format json
48
+ --no-cache Force full rescan without reading/writing scanner cache
36
49
 
37
50
  Type generation options:
38
51
  --out <path> Output file for generated key types (default: i18n-keys.d.ts)
39
52
 
53
+ Hook options:
54
+ gloss init-hooks Install pre-commit hooks for gloss check
55
+
56
+ Baseline options:
57
+ gloss baseline reset Remove the local issue baseline (.gloss/baseline.json)
58
+
59
+ Cache options:
60
+ gloss cache status Show scanner cache status
61
+ gloss cache clear Clear in-memory scanner cache and .gloss/cache-metrics.json
62
+
40
63
  Open key options:
41
64
  gloss open key <key> Open Gloss focused on a translation key
65
+ --no-cache Bypass scanner caches while serving
42
66
  `);
43
67
  };
44
68
  const parseArgs = (args) => {
45
69
  const firstArg = args[0];
70
+ if (firstArg === "baseline") {
71
+ const commandArgs = args.slice(1);
72
+ const options = {
73
+ command: "baseline-reset",
74
+ help: false,
75
+ version: false,
76
+ };
77
+ for (const arg of commandArgs) {
78
+ if (arg === "-h" || arg === "--help") {
79
+ options.help = true;
80
+ continue;
81
+ }
82
+ if (arg === "-v" || arg === "--version") {
83
+ options.version = true;
84
+ continue;
85
+ }
86
+ if (arg === "reset") {
87
+ continue;
88
+ }
89
+ throw new Error(`Unknown argument for gloss baseline: ${arg}`);
90
+ }
91
+ if (!options.help && commandArgs[0] !== "reset") {
92
+ throw new Error("Usage: gloss baseline reset");
93
+ }
94
+ return options;
95
+ }
96
+ if (firstArg === "cache") {
97
+ const commandArgs = args.slice(1);
98
+ const action = commandArgs[0];
99
+ const restArgs = commandArgs.slice(1);
100
+ if (action !== "status" && action !== "clear") {
101
+ throw new Error("Usage: gloss cache <status|clear>");
102
+ }
103
+ const options = {
104
+ command: action === "status" ? "cache-status" : "cache-clear",
105
+ help: false,
106
+ version: false,
107
+ };
108
+ for (const arg of restArgs) {
109
+ if (arg === "-h" || arg === "--help") {
110
+ options.help = true;
111
+ continue;
112
+ }
113
+ if (arg === "-v" || arg === "--version") {
114
+ options.version = true;
115
+ continue;
116
+ }
117
+ throw new Error(`Unknown argument for gloss cache: ${arg}`);
118
+ }
119
+ return options;
120
+ }
46
121
  if (firstArg === "open") {
47
122
  const commandArgs = args.slice(1);
48
123
  const base = { help: false, version: false };
@@ -50,6 +125,7 @@ const parseArgs = (args) => {
50
125
  command: "open-key",
51
126
  ...base,
52
127
  noOpen: false,
128
+ noCache: false,
53
129
  port: DEFAULT_PORT,
54
130
  key: "",
55
131
  };
@@ -88,6 +164,10 @@ const parseArgs = (args) => {
88
164
  options.noOpen = true;
89
165
  continue;
90
166
  }
167
+ if (arg === "--no-cache") {
168
+ options.noCache = true;
169
+ continue;
170
+ }
91
171
  if (arg === "-p" || arg === "--port") {
92
172
  const nextValue = commandArgs[index + 1];
93
173
  if (!nextValue) {
@@ -106,7 +186,9 @@ const parseArgs = (args) => {
106
186
  return options;
107
187
  }
108
188
  const isCommand = firstArg && !firstArg.startsWith("-");
109
- const command = firstArg === "check" || firstArg === "gen-types" ? firstArg : "serve";
189
+ const command = firstArg === "check" || firstArg === "gen-types" || firstArg === "init-hooks"
190
+ ? firstArg
191
+ : "serve";
110
192
  const commandArgs = command === "serve" ? args : args.slice(1);
111
193
  const base = {
112
194
  help: false,
@@ -117,6 +199,7 @@ const parseArgs = (args) => {
117
199
  command,
118
200
  ...base,
119
201
  format: "human",
202
+ noCache: false,
120
203
  };
121
204
  for (let index = 0; index < commandArgs.length; index += 1) {
122
205
  const arg = commandArgs[index];
@@ -132,6 +215,10 @@ const parseArgs = (args) => {
132
215
  options.format = "json";
133
216
  continue;
134
217
  }
218
+ if (arg === "--no-cache") {
219
+ options.noCache = true;
220
+ continue;
221
+ }
135
222
  if (arg === "--format") {
136
223
  const nextValue = commandArgs[index + 1];
137
224
  if (!nextValue) {
@@ -176,6 +263,24 @@ const parseArgs = (args) => {
176
263
  }
177
264
  return options;
178
265
  }
266
+ if (command === "init-hooks") {
267
+ const options = {
268
+ command,
269
+ ...base,
270
+ };
271
+ for (const arg of commandArgs) {
272
+ if (arg === "-h" || arg === "--help") {
273
+ options.help = true;
274
+ continue;
275
+ }
276
+ if (arg === "-v" || arg === "--version") {
277
+ options.version = true;
278
+ continue;
279
+ }
280
+ throw new Error(`Unknown argument for gloss init-hooks: ${arg}`);
281
+ }
282
+ return options;
283
+ }
179
284
  if (isCommand && command === "serve" && firstArg !== undefined) {
180
285
  throw new Error(`Unknown command: ${firstArg}`);
181
286
  }
@@ -183,6 +288,7 @@ const parseArgs = (args) => {
183
288
  command: "serve",
184
289
  ...base,
185
290
  noOpen: false,
291
+ noCache: false,
186
292
  port: DEFAULT_PORT,
187
293
  };
188
294
  for (let index = 0; index < commandArgs.length; index += 1) {
@@ -199,6 +305,10 @@ const parseArgs = (args) => {
199
305
  options.noOpen = true;
200
306
  continue;
201
307
  }
308
+ if (arg === "--no-cache") {
309
+ options.noCache = true;
310
+ continue;
311
+ }
202
312
  if (arg === "-p" || arg === "--port") {
203
313
  const nextValue = commandArgs[index + 1];
204
314
  if (!nextValue) {
@@ -231,6 +341,87 @@ const printConfigError = (error) => {
231
341
  console.error("Gloss could not start: invalid gloss.config.ts.");
232
342
  console.error(error.message);
233
343
  };
344
+ const renderGeneratedConfig = (candidatePath, locales) => {
345
+ const defaultLocale = locales.includes("en") ? "en" : locales[0] ?? "en";
346
+ const localeList = locales.map((locale) => JSON.stringify(locale)).join(", ");
347
+ const pathLiteral = JSON.stringify(candidatePath);
348
+ return `module.exports = {
349
+ locales: [${localeList}],
350
+ defaultLocale: ${JSON.stringify(defaultLocale)},
351
+ path: ${pathLiteral},
352
+ format: "json",
353
+ };
354
+ `;
355
+ };
356
+ const chooseCandidateInteractive = async (candidates) => {
357
+ if (candidates.length === 1) {
358
+ return candidates[0];
359
+ }
360
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
361
+ return candidates[0];
362
+ }
363
+ const rl = createInterface({
364
+ input: process.stdin,
365
+ output: process.stdout,
366
+ });
367
+ try {
368
+ console.log("Gloss setup: multiple locale directories were found.");
369
+ candidates.forEach((candidate, index) => {
370
+ const marker = index === 0 ? " (recommended)" : "";
371
+ console.log(` ${index + 1}. ${candidate.path} -> [${candidate.locales.join(", ")}]${marker}`);
372
+ });
373
+ while (true) {
374
+ const answer = (await rl.question(`Choose a locale directory [1-${candidates.length}] (default 1): `)).trim();
375
+ if (!answer) {
376
+ return candidates[0];
377
+ }
378
+ const selection = Number.parseInt(answer, 10);
379
+ if (Number.isFinite(selection) && selection >= 1 && selection <= candidates.length) {
380
+ return candidates[selection - 1];
381
+ }
382
+ console.log(`Please enter a number between 1 and ${candidates.length}.`);
383
+ }
384
+ }
385
+ finally {
386
+ rl.close();
387
+ }
388
+ };
389
+ const bootstrapConfigIfMissing = async () => {
390
+ const cwd = projectRoot();
391
+ const candidates = await discoverLocaleDirectoryCandidates(cwd);
392
+ if (candidates.length === 0) {
393
+ return false;
394
+ }
395
+ console.log("No Gloss config found. Starting first-run setup.");
396
+ const selected = await chooseCandidateInteractive(candidates);
397
+ const configFilePath = path.join(cwd, GENERATED_CONFIG_FILE);
398
+ const content = renderGeneratedConfig(selected.path, selected.locales);
399
+ await fs.writeFile(configFilePath, content, "utf8");
400
+ console.log(`Created ${GENERATED_CONFIG_FILE} using ${selected.path}.`);
401
+ console.log("Starting Gloss with the generated config.");
402
+ return true;
403
+ };
404
+ const formatBytes = (value) => {
405
+ if (value < 1024) {
406
+ return `${value} B`;
407
+ }
408
+ if (value < 1024 * 1024) {
409
+ return `${(value / 1024).toFixed(1)} KB`;
410
+ }
411
+ return `${(value / (1024 * 1024)).toFixed(1)} MB`;
412
+ };
413
+ const formatAge = (ageMs) => {
414
+ if (ageMs === null) {
415
+ return "n/a";
416
+ }
417
+ const totalSeconds = Math.max(0, Math.floor(ageMs / 1000));
418
+ const minutes = Math.floor(totalSeconds / 60);
419
+ const seconds = totalSeconds % 60;
420
+ if (minutes === 0) {
421
+ return `${seconds}s`;
422
+ }
423
+ return `${minutes}m ${seconds}s`;
424
+ };
234
425
  async function main() {
235
426
  const options = parseArgs(process.argv.slice(2));
236
427
  if (options.help) {
@@ -241,21 +432,86 @@ async function main() {
241
432
  console.log(await getVersion());
242
433
  return;
243
434
  }
244
- const cfg = await loadGlossConfig();
435
+ if (options.command === "init-hooks") {
436
+ const result = await installPreCommitHooks(projectRoot());
437
+ console.log("Gloss hook installation");
438
+ for (const message of result.messages) {
439
+ console.log(`- ${message}`);
440
+ }
441
+ return;
442
+ }
443
+ if (options.command === "baseline-reset") {
444
+ const result = await resetIssueBaseline(projectRoot());
445
+ if (result.existed) {
446
+ console.log(`Removed baseline at ${result.baselinePath}`);
447
+ }
448
+ else {
449
+ console.log(`No baseline found at ${result.baselinePath}`);
450
+ }
451
+ return;
452
+ }
453
+ if (options.command === "cache-clear") {
454
+ const result = await clearGlossCaches(projectRoot());
455
+ console.log("Gloss cache clear");
456
+ console.log(`- Usage scanner cache: ${result.usage.fileCount} files across ${result.usage.bucketCount} buckets removed`);
457
+ console.log(`- Key usage cache: ${result.keyUsage.fileCount} files across ${result.keyUsage.bucketCount} buckets removed`);
458
+ console.log(`- Cache metrics file: ${result.metrics.existed ? "removed" : "not found"} (${result.metrics.path})`);
459
+ return;
460
+ }
461
+ let cfg;
462
+ try {
463
+ cfg = await loadGlossConfig();
464
+ }
465
+ catch (error) {
466
+ if (error instanceof GlossConfigError && error.code === "MISSING_CONFIG") {
467
+ const generated = await bootstrapConfigIfMissing();
468
+ if (generated) {
469
+ cfg = await loadGlossConfig();
470
+ }
471
+ else {
472
+ throw error;
473
+ }
474
+ }
475
+ else {
476
+ throw error;
477
+ }
478
+ }
245
479
  if (options.command === "check") {
246
- const result = await runGlossCheck(cfg);
247
- printGlossCheck(result, options.format);
480
+ const result = await runGlossCheck(cfg, {
481
+ useCache: !options.noCache,
482
+ });
483
+ let baseline;
484
+ try {
485
+ baseline = await updateIssueBaseline(projectRoot(), result.summary);
486
+ }
487
+ catch (error) {
488
+ console.error(`Warning: failed to update issue baseline: ${error.message}`);
489
+ }
490
+ printGlossCheck(result, options.format, baseline);
248
491
  if (!result.ok) {
249
492
  process.exit(1);
250
493
  }
251
494
  return;
252
495
  }
496
+ if (options.command === "cache-status") {
497
+ const status = await getCacheStatus(projectRoot(), cfg);
498
+ console.log("Gloss cache status");
499
+ console.log(`- Metrics file: ${status.metricsFileFound ? "found" : "not found"}${status.metricsUpdatedAt ? ` (updated ${status.metricsUpdatedAt})` : ""}`);
500
+ console.log(`- Total cached files: ${status.totalCachedFiles} (${formatBytes(status.totalCachedSizeBytes)})`);
501
+ console.log(`- Oldest cache entry age: ${formatAge(status.oldestEntryAgeMs)}`);
502
+ console.log(`- Stale relative to config: ${status.staleRelativeToConfig ? "yes" : "no"}`);
503
+ console.log(`- Usage scanner: ${status.usageScanner.fileCount} files, ${formatBytes(status.usageScanner.totalSizeBytes)}, source=${status.usageScanner.source}`);
504
+ console.log(`- Key usage: ${status.keyUsage.fileCount} files, ${formatBytes(status.keyUsage.totalSizeBytes)}, source=${status.keyUsage.source}`);
505
+ return;
506
+ }
253
507
  if (options.command === "gen-types") {
254
508
  const result = await generateKeyTypes(cfg, { outFile: options.outFile });
255
509
  console.log(`Generated ${result.keyCount} keys in ${result.outFile}`);
256
510
  return;
257
511
  }
258
- const { port } = await startServer(cfg, options.port);
512
+ const { port } = await startServer(cfg, options.port, {
513
+ useCache: !options.noCache,
514
+ });
259
515
  const url = options.command === "open-key"
260
516
  ? `http://localhost:${port}/?key=${encodeURIComponent(options.key)}`
261
517
  : `http://localhost:${port}`;
package/dist/server.js CHANGED
@@ -3,11 +3,14 @@ import path from "node:path";
3
3
  import { fileURLToPath } from "node:url";
4
4
  import express from "express";
5
5
  import cors from "cors";
6
+ import { updateIssueBaseline } from "./baseline.js";
6
7
  import { runGlossCheck } from "./check.js";
7
- import { readAllTranslations, writeAllTranslations } from "./fs.js";
8
+ import { readAllTranslations, WriteLockError, writeAllTranslations } from "./fs.js";
8
9
  import { buildGitKeyDiff } from "./gitDiff.js";
10
+ import { flattenObject, unflattenObject } from "./translationTree.js";
9
11
  import { buildKeyUsageMap } from "./usage.js";
10
12
  import { inferUsageRoot, scanUsage } from "./usageScanner.js";
13
+ import { buildXliffDocument, parseXliffTargets } from "./xliff.js";
11
14
  import { renameKeyUsage } from "./renameKeyUsage.js";
12
15
  const resolveUiDistPath = () => {
13
16
  const runtimeDir = path.dirname(fileURLToPath(import.meta.url));
@@ -25,24 +28,57 @@ const resolveUiDistPath = () => {
25
28
  return null;
26
29
  };
27
30
  export function createServerApp(cfg) {
31
+ return createServerAppWithOptions(cfg);
32
+ }
33
+ const shouldBypassCache = (value) => {
34
+ if (typeof value !== "string") {
35
+ return false;
36
+ }
37
+ return value === "1" || value.toLowerCase() === "true";
38
+ };
39
+ export function createServerAppWithOptions(cfg, runtimeOptions = {}) {
28
40
  const app = express();
29
41
  app.use(cors());
30
42
  app.use(express.json({ limit: "5mb" }));
43
+ const defaultUseCache = runtimeOptions.useCache !== false;
44
+ const requestUseCache = (req) => {
45
+ if (!defaultUseCache) {
46
+ return false;
47
+ }
48
+ const noCacheValue = req.query.noCache;
49
+ if (Array.isArray(noCacheValue)) {
50
+ return !noCacheValue.some((entry) => shouldBypassCache(entry));
51
+ }
52
+ return !shouldBypassCache(noCacheValue);
53
+ };
31
54
  app.get("/api/config", (_req, res) => res.json(cfg));
32
55
  app.get("/api/translations", async (_req, res) => {
33
56
  const data = await readAllTranslations(cfg);
34
57
  res.json(data);
35
58
  });
36
59
  app.get("/api/usage", async (_req, res) => {
37
- const usage = await scanUsage(inferUsageRoot(cfg), cfg.scan);
60
+ const usage = await scanUsage(inferUsageRoot(cfg), cfg.scan, {
61
+ useCache: requestUseCache(_req),
62
+ });
38
63
  res.json(usage);
39
64
  });
40
- app.get("/api/key-usage", async (_req, res) => {
41
- const usage = await buildKeyUsageMap(cfg);
65
+ app.get("/api/key-usage", async (req, res) => {
66
+ const usage = await buildKeyUsageMap(cfg, {
67
+ useCache: requestUseCache(req),
68
+ });
42
69
  res.json(usage);
43
70
  });
44
71
  app.get("/api/check", async (req, res) => {
45
- const result = await runGlossCheck(cfg);
72
+ const result = await runGlossCheck(cfg, {
73
+ useCache: requestUseCache(req),
74
+ });
75
+ let baseline = null;
76
+ try {
77
+ baseline = await updateIssueBaseline(process.env.INIT_CWD || process.cwd(), result.summary);
78
+ }
79
+ catch {
80
+ baseline = null;
81
+ }
46
82
  const summaryValue = typeof req.query.summary === "string" ? req.query.summary : "";
47
83
  const summaryOnly = summaryValue === "1" || summaryValue === "true";
48
84
  if (summaryOnly) {
@@ -51,10 +87,11 @@ export function createServerApp(cfg) {
51
87
  generatedAt: result.generatedAt,
52
88
  summary: result.summary,
53
89
  hardcodedTexts: result.hardcodedTexts.slice(0, 20),
90
+ baseline,
54
91
  });
55
92
  return;
56
93
  }
57
- res.json(result);
94
+ res.json({ ...result, baseline });
58
95
  });
59
96
  app.get("/api/git-diff", async (req, res) => {
60
97
  const base = typeof req.query.base === "string" && req.query.base.trim()
@@ -63,10 +100,103 @@ export function createServerApp(cfg) {
63
100
  const diff = await buildGitKeyDiff(cfg, base);
64
101
  res.json(diff);
65
102
  });
103
+ app.get("/api/xliff/export", async (req, res) => {
104
+ const locale = typeof req.query.locale === "string" ? req.query.locale.trim() : "";
105
+ const sourceLocale = typeof req.query.sourceLocale === "string" && req.query.sourceLocale.trim()
106
+ ? req.query.sourceLocale.trim()
107
+ : cfg.defaultLocale;
108
+ if (!locale || !cfg.locales.includes(locale)) {
109
+ res.status(400).json({
110
+ ok: false,
111
+ error: "Query parameter `locale` is required and must be one of configured locales.",
112
+ });
113
+ return;
114
+ }
115
+ if (!cfg.locales.includes(sourceLocale)) {
116
+ res.status(400).json({
117
+ ok: false,
118
+ error: "Query parameter `sourceLocale` must be one of configured locales when provided.",
119
+ });
120
+ return;
121
+ }
122
+ const data = await readAllTranslations(cfg);
123
+ const xml = buildXliffDocument({
124
+ translations: data,
125
+ locales: cfg.locales,
126
+ sourceLocale,
127
+ targetLocale: locale,
128
+ });
129
+ res.setHeader("Content-Type", "application/xliff+xml; charset=utf-8");
130
+ res.setHeader("Content-Disposition", `attachment; filename="gloss-${locale}.xlf"`);
131
+ res.send(xml);
132
+ });
133
+ app.post("/api/xliff/import", async (req, res) => {
134
+ const locale = typeof req.query.locale === "string" ? req.query.locale.trim() : "";
135
+ if (!locale || !cfg.locales.includes(locale)) {
136
+ res.status(400).json({
137
+ ok: false,
138
+ error: "Query parameter `locale` is required and must be one of configured locales.",
139
+ });
140
+ return;
141
+ }
142
+ const body = req.body;
143
+ if (typeof body.content !== "string" || body.content.trim().length === 0) {
144
+ res.status(400).json({
145
+ ok: false,
146
+ error: "Request body must include non-empty `content` string.",
147
+ });
148
+ return;
149
+ }
150
+ try {
151
+ const parsedTargets = parseXliffTargets(body.content);
152
+ const updates = Object.entries(parsedTargets).filter(([key]) => key.trim().length > 0);
153
+ if (updates.length === 0) {
154
+ res.status(400).json({
155
+ ok: false,
156
+ error: "No translatable units found in XLIFF content.",
157
+ });
158
+ return;
159
+ }
160
+ const data = await readAllTranslations(cfg);
161
+ const localeFlat = flattenObject(data[locale] ?? {});
162
+ for (const [key, value] of updates) {
163
+ localeFlat[key] = value;
164
+ }
165
+ data[locale] = unflattenObject(localeFlat);
166
+ await writeAllTranslations(cfg, data);
167
+ res.json({
168
+ ok: true,
169
+ locale,
170
+ updated: updates.length,
171
+ });
172
+ }
173
+ catch (error) {
174
+ if (error instanceof WriteLockError) {
175
+ res.status(409).json({ ok: false, error: error.message });
176
+ return;
177
+ }
178
+ res.status(400).json({
179
+ ok: false,
180
+ error: error.message || "Failed to parse XLIFF content.",
181
+ });
182
+ }
183
+ });
66
184
  app.post("/api/translations", async (req, res) => {
67
185
  const data = req.body;
68
- await writeAllTranslations(cfg, data);
69
- res.json({ ok: true });
186
+ try {
187
+ await writeAllTranslations(cfg, data);
188
+ res.json({ ok: true });
189
+ }
190
+ catch (error) {
191
+ if (error instanceof WriteLockError) {
192
+ res.status(409).json({ ok: false, error: error.message });
193
+ return;
194
+ }
195
+ res.status(500).json({
196
+ ok: false,
197
+ error: "Failed to write translation files.",
198
+ });
199
+ }
70
200
  });
71
201
  app.post("/api/rename-key", async (req, res) => {
72
202
  const body = req.body;
@@ -105,8 +235,8 @@ export function createServerApp(cfg) {
105
235
  }
106
236
  return app;
107
237
  }
108
- export async function startServer(cfg, port = 5179) {
109
- const app = createServerApp(cfg);
238
+ export async function startServer(cfg, port = 5179, runtimeOptions = {}) {
239
+ const app = createServerAppWithOptions(cfg, runtimeOptions);
110
240
  const server = await new Promise((resolve) => {
111
241
  const nextServer = app.listen(port, () => resolve(nextServer));
112
242
  });
@@ -20,3 +20,23 @@ export const flattenObject = (obj) => {
20
20
  visit(obj, "");
21
21
  return result;
22
22
  };
23
+ export const unflattenObject = (flat) => {
24
+ const root = {};
25
+ for (const [key, value] of Object.entries(flat)) {
26
+ const parts = key.split(".").filter((part) => part.length > 0);
27
+ if (parts.length === 0) {
28
+ continue;
29
+ }
30
+ let cursor = root;
31
+ for (let index = 0; index < parts.length - 1; index += 1) {
32
+ const part = parts[index];
33
+ const existing = cursor[part];
34
+ if (!existing || typeof existing !== "object" || Array.isArray(existing)) {
35
+ cursor[part] = {};
36
+ }
37
+ cursor = cursor[part];
38
+ }
39
+ cursor[parts[parts.length - 1]] = value;
40
+ }
41
+ return root;
42
+ };