storebound/cli/lib/scan/config-edit.ts

71 lines
3.6 KiB
TypeScript

// Writes one top-level block (dataSafety, appPrivacy) into storebound.config.ts / .js / .json text: replaces the
// existing `key: { ... }` property, or inserts it after the `app: { ... }` block. Pure text in, text out; the rest of
// the file (comments, order, formatting) stays as it is. Used by `storebound scan --write` only.
/** Index of the brace that closes the one at `open`, skipping strings, template literals and comments. -1 if none. */
export function matchBrace(text: string, open: number): number {
const pairs: Record<string, string> = { '{': '}', '[': ']', '(': ')' };
const stack: string[] = [];
for (let i = open; i < text.length; i++) {
const ch = text[i]!;
if (ch === '/' && text[i + 1] === '/') { i = text.indexOf('\n', i); if (i < 0) return -1; continue; }
if (ch === '/' && text[i + 1] === '*') { i = text.indexOf('*/', i + 2); if (i < 0) return -1; i++; continue; }
if (ch === '"' || ch === '\'' || ch === '`') {
for (i++; i < text.length && text[i] !== ch; i++) if (text[i] === '\\') i++;
continue;
}
if (pairs[ch]) stack.push(pairs[ch]!);
else if (ch === '}' || ch === ']' || ch === ')') {
if (stack.pop() !== ch) return -1;
if (!stack.length) return i;
}
}
return -1;
}
const ID = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
/** A plain value as TypeScript source (single quotes, unquoted keys, trailing commas), indented by `ind`. */
export function toTs(v: unknown, ind = ''): string {
const next = ind + ' ';
if (v === null || v === undefined) return 'undefined';
if (typeof v === 'string') return `'${v.replace(/\\/g, '\\\\').replace(/'/g, '\\\'')}'`;
if (typeof v === 'number' || typeof v === 'boolean') return String(v);
if (Array.isArray(v)) {
const parts = v.map(x => toTs(x, next));
const one = `[${parts.join(', ')}]`;
return one.length <= 100 && !one.includes('\n') ? one : `[\n${parts.map(p => `${next}${p},`).join('\n')}\n${ind}]`;
}
const entries = Object.entries(v as Record<string, unknown>).filter(([, x]) => x !== undefined);
if (!entries.length) return '{}';
const parts = entries.map(([k, x]) => `${ID.test(k) ? k : `'${k}'`}: ${toTs(x, next)}`);
const one = `{ ${parts.join(', ')} }`;
return one.length <= 110 && !one.includes('\n') && entries.every(([, x]) => typeof x !== 'object' || Array.isArray(x)) ? one : `{\n${parts.map(p => `${next}${p},`).join('\n')}\n${ind}}`;
}
/** The config text with `key` set to `value` (plus an optional comment line above it). */
export function upsertBlock(text: string, file: string, key: string, value: unknown, comment?: string): string {
if (file.endsWith('.json')) {
const j = JSON.parse(text) as Record<string, unknown>;
j[key] = value;
return JSON.stringify(j, null, 2) + '\n';
}
const re = new RegExp(`^([ \\t]*)${key}\\s*:\\s*\\{`, 'm');
const m = re.exec(text);
if (m) {
const ind = m[1]!;
const open = m.index + m[0].length - 1;
const close = matchBrace(text, open);
if (close < 0) throw new Error(`${file}: could not find the end of the ${key} block`);
return `${text.slice(0, m.index)}${ind}${key}: ${toTs(value, ind)}${text.slice(close + 1)}`;
}
const app = /^([ \t]*)app\s*:\s*\{/m.exec(text);
if (!app) throw new Error(`${file}: no app block found to insert ${key} after; add it to the config file directly`);
const ind = app[1]!;
const close = matchBrace(text, app.index + app[0].length - 1);
if (close < 0) throw new Error(`${file}: could not find the end of the app block`);
let at = close + 1;
if (text[at] === ',') at++;
const block = `\n${comment ? `${ind}// ${comment}\n` : ''}${ind}${key}: ${toTs(value, ind)},`;
return text.slice(0, at) + block + text.slice(at);
}