1import fs from 'fs'; 2import { EOL } from 'os'; 3import path from 'path'; 4import { Builder, Parser } from 'xml2js'; 5 6export type XMLValue = boolean | number | string | null | XMLArray | XMLObject; 7 8export interface XMLArray extends Array<XMLValue> {} 9 10export interface XMLObject { 11 [key: string]: XMLValue | undefined; 12} 13 14export async function writeXMLAsync(options: { path: string; xml: any }): Promise<void> { 15 const xml = format(options.xml); 16 await fs.promises.mkdir(path.dirname(options.path), { recursive: true }); 17 await fs.promises.writeFile(options.path, xml); 18} 19 20export async function readXMLAsync(options: { 21 path: string; 22 fallback?: string | null; 23}): Promise<XMLObject> { 24 let contents: string = ''; 25 try { 26 contents = await fs.promises.readFile(options.path, { encoding: 'utf8', flag: 'r' }); 27 } catch { 28 // catch and use fallback 29 } 30 const parser = new Parser(); 31 const manifest = await parser.parseStringPromise(contents || options.fallback || ''); 32 33 // For strings.xml 34 if (Array.isArray(manifest?.resources?.string)) { 35 for (const string of manifest?.resources?.string) { 36 if (string.$.translatable === 'false' || string.$.translatable === false) { 37 continue; 38 } 39 string._ = unescapeAndroidString(string._); 40 } 41 } 42 43 return manifest; 44} 45 46export async function parseXMLAsync(contents: string): Promise<XMLObject> { 47 const xml = await new Parser().parseStringPromise(contents); 48 return xml; 49} 50 51const stringTimesN = (n: number, char: string) => Array(n + 1).join(char); 52 53export function format(manifest: any, { indentLevel = 2, newline = EOL } = {}): string { 54 let xmlInput: string; 55 if (typeof manifest === 'string') { 56 xmlInput = manifest; 57 } else if (manifest.toString) { 58 const builder = new Builder({ 59 headless: true, 60 }); 61 62 // For strings.xml 63 if (Array.isArray(manifest?.resources?.string)) { 64 for (const string of manifest?.resources?.string) { 65 if (string.$.translatable === 'false' || string.$.translatable === false) { 66 continue; 67 } 68 string._ = escapeAndroidString(string._); 69 } 70 } 71 72 xmlInput = builder.buildObject(manifest); 73 74 return xmlInput; 75 } else { 76 throw new Error(`Invalid XML value passed in: ${manifest}`); 77 } 78 const indentString = stringTimesN(indentLevel, ' '); 79 80 let formatted = ''; 81 const regex = /(>)(<)(\/*)/g; 82 const xml = xmlInput.replace(regex, `$1${newline}$2$3`); 83 let pad = 0; 84 xml 85 .split(/\r?\n/) 86 .map((line: string) => line.trim()) 87 .forEach((line: string) => { 88 let indent = 0; 89 if (line.match(/.+<\/\w[^>]*>$/)) { 90 indent = 0; 91 } else if (line.match(/^<\/\w/)) { 92 if (pad !== 0) { 93 pad -= 1; 94 } 95 } else if (line.match(/^<\w([^>]*[^/])?>.*$/)) { 96 indent = 1; 97 } else { 98 indent = 0; 99 } 100 101 const padding = stringTimesN(pad, indentString); 102 formatted += padding + line + newline; 103 pad += indent; 104 }); 105 106 return formatted.trim(); 107} 108 109/** 110 * Escapes Android string literals, specifically characters `"`, `'`, `\`, `\n`, `\r`, `\t` 111 * 112 * @param value unescaped Android XML string literal. 113 */ 114export function escapeAndroidString(value: string): string { 115 value = value.replace(/[\n\r\t'"@]/g, (m) => { 116 switch (m) { 117 case '"': 118 case "'": 119 case '@': 120 return '\\' + m; 121 case '\n': 122 return '\\n'; 123 case '\r': 124 return '\\r'; 125 case '\t': 126 return '\\t'; 127 default: 128 throw new Error(`Cannot escape unhandled XML character: ${m}`); 129 } 130 }); 131 if (value.match(/(^\s|\s$)/)) { 132 value = '"' + value + '"'; 133 } 134 return value; 135} 136 137export function unescapeAndroidString(value: string): string { 138 return value.replace(/\\(.)/g, '$1'); 139} 140