1082815dcSEvan Baconimport fs from 'fs'; 2082815dcSEvan Baconimport { EOL } from 'os'; 3082815dcSEvan Baconimport path from 'path'; 4082815dcSEvan Baconimport { Builder, Parser } from 'xml2js'; 5082815dcSEvan Bacon 6082815dcSEvan Baconexport type XMLValue = boolean | number | string | null | XMLArray | XMLObject; 7082815dcSEvan Bacon 8082815dcSEvan Baconexport interface XMLArray extends Array<XMLValue> {} 9082815dcSEvan Bacon 10082815dcSEvan Baconexport interface XMLObject { 11082815dcSEvan Bacon [key: string]: XMLValue | undefined; 12082815dcSEvan Bacon} 13082815dcSEvan Bacon 14082815dcSEvan Baconexport async function writeXMLAsync(options: { path: string; xml: any }): Promise<void> { 15082815dcSEvan Bacon const xml = format(options.xml); 16082815dcSEvan Bacon await fs.promises.mkdir(path.dirname(options.path), { recursive: true }); 17082815dcSEvan Bacon await fs.promises.writeFile(options.path, xml); 18082815dcSEvan Bacon} 19082815dcSEvan Bacon 20082815dcSEvan Baconexport async function readXMLAsync(options: { 21082815dcSEvan Bacon path: string; 22082815dcSEvan Bacon fallback?: string | null; 23082815dcSEvan Bacon}): Promise<XMLObject> { 24082815dcSEvan Bacon let contents: string = ''; 25082815dcSEvan Bacon try { 26082815dcSEvan Bacon contents = await fs.promises.readFile(options.path, { encoding: 'utf8', flag: 'r' }); 27082815dcSEvan Bacon } catch { 28082815dcSEvan Bacon // catch and use fallback 29082815dcSEvan Bacon } 30082815dcSEvan Bacon const parser = new Parser(); 31082815dcSEvan Bacon const manifest = await parser.parseStringPromise(contents || options.fallback || ''); 32082815dcSEvan Bacon 33*ed3bd27bSEvan Bacon return _processAndroidXML(manifest); 34*ed3bd27bSEvan Bacon} 35*ed3bd27bSEvan Bacon 36*ed3bd27bSEvan Baconexport function _processAndroidXML(manifest: any): XMLObject { 37082815dcSEvan Bacon // For strings.xml 38082815dcSEvan Bacon if (Array.isArray(manifest?.resources?.string)) { 39082815dcSEvan Bacon for (const string of manifest?.resources?.string) { 40082815dcSEvan Bacon if (string.$.translatable === 'false' || string.$.translatable === false) { 41082815dcSEvan Bacon continue; 42082815dcSEvan Bacon } 43082815dcSEvan Bacon string._ = unescapeAndroidString(string._); 44082815dcSEvan Bacon } 45082815dcSEvan Bacon } 46082815dcSEvan Bacon 47082815dcSEvan Bacon return manifest; 48082815dcSEvan Bacon} 49082815dcSEvan Bacon 50082815dcSEvan Baconexport async function parseXMLAsync(contents: string): Promise<XMLObject> { 51082815dcSEvan Bacon const xml = await new Parser().parseStringPromise(contents); 52082815dcSEvan Bacon return xml; 53082815dcSEvan Bacon} 54082815dcSEvan Bacon 55082815dcSEvan Baconconst stringTimesN = (n: number, char: string) => Array(n + 1).join(char); 56082815dcSEvan Bacon 57082815dcSEvan Baconexport function format(manifest: any, { indentLevel = 2, newline = EOL } = {}): string { 58082815dcSEvan Bacon let xmlInput: string; 59082815dcSEvan Bacon if (typeof manifest === 'string') { 60082815dcSEvan Bacon xmlInput = manifest; 61082815dcSEvan Bacon } else if (manifest.toString) { 62082815dcSEvan Bacon const builder = new Builder({ 63082815dcSEvan Bacon headless: true, 64082815dcSEvan Bacon }); 65082815dcSEvan Bacon 66082815dcSEvan Bacon // For strings.xml 67082815dcSEvan Bacon if (Array.isArray(manifest?.resources?.string)) { 68082815dcSEvan Bacon for (const string of manifest?.resources?.string) { 69082815dcSEvan Bacon if (string.$.translatable === 'false' || string.$.translatable === false) { 70082815dcSEvan Bacon continue; 71082815dcSEvan Bacon } 72082815dcSEvan Bacon string._ = escapeAndroidString(string._); 73082815dcSEvan Bacon } 74082815dcSEvan Bacon } 75082815dcSEvan Bacon 76082815dcSEvan Bacon xmlInput = builder.buildObject(manifest); 77082815dcSEvan Bacon 78082815dcSEvan Bacon return xmlInput; 79082815dcSEvan Bacon } else { 80082815dcSEvan Bacon throw new Error(`Invalid XML value passed in: ${manifest}`); 81082815dcSEvan Bacon } 82082815dcSEvan Bacon const indentString = stringTimesN(indentLevel, ' '); 83082815dcSEvan Bacon 84082815dcSEvan Bacon let formatted = ''; 85082815dcSEvan Bacon const regex = /(>)(<)(\/*)/g; 86082815dcSEvan Bacon const xml = xmlInput.replace(regex, `$1${newline}$2$3`); 87082815dcSEvan Bacon let pad = 0; 88082815dcSEvan Bacon xml 89082815dcSEvan Bacon .split(/\r?\n/) 90082815dcSEvan Bacon .map((line: string) => line.trim()) 91082815dcSEvan Bacon .forEach((line: string) => { 92082815dcSEvan Bacon let indent = 0; 93082815dcSEvan Bacon if (line.match(/.+<\/\w[^>]*>$/)) { 94082815dcSEvan Bacon indent = 0; 95082815dcSEvan Bacon } else if (line.match(/^<\/\w/)) { 96082815dcSEvan Bacon if (pad !== 0) { 97082815dcSEvan Bacon pad -= 1; 98082815dcSEvan Bacon } 99082815dcSEvan Bacon } else if (line.match(/^<\w([^>]*[^/])?>.*$/)) { 100082815dcSEvan Bacon indent = 1; 101082815dcSEvan Bacon } else { 102082815dcSEvan Bacon indent = 0; 103082815dcSEvan Bacon } 104082815dcSEvan Bacon 105082815dcSEvan Bacon const padding = stringTimesN(pad, indentString); 106082815dcSEvan Bacon formatted += padding + line + newline; 107082815dcSEvan Bacon pad += indent; 108082815dcSEvan Bacon }); 109082815dcSEvan Bacon 110082815dcSEvan Bacon return formatted.trim(); 111082815dcSEvan Bacon} 112082815dcSEvan Bacon 113082815dcSEvan Bacon/** 114082815dcSEvan Bacon * Escapes Android string literals, specifically characters `"`, `'`, `\`, `\n`, `\r`, `\t` 115082815dcSEvan Bacon * 116082815dcSEvan Bacon * @param value unescaped Android XML string literal. 117082815dcSEvan Bacon */ 118082815dcSEvan Baconexport function escapeAndroidString(value: string): string { 119082815dcSEvan Bacon value = value.replace(/[\n\r\t'"@]/g, (m) => { 120082815dcSEvan Bacon switch (m) { 121082815dcSEvan Bacon case '"': 122082815dcSEvan Bacon case "'": 123082815dcSEvan Bacon case '@': 124082815dcSEvan Bacon return '\\' + m; 125082815dcSEvan Bacon case '\n': 126082815dcSEvan Bacon return '\\n'; 127082815dcSEvan Bacon case '\r': 128082815dcSEvan Bacon return '\\r'; 129082815dcSEvan Bacon case '\t': 130082815dcSEvan Bacon return '\\t'; 131082815dcSEvan Bacon default: 132082815dcSEvan Bacon throw new Error(`Cannot escape unhandled XML character: ${m}`); 133082815dcSEvan Bacon } 134082815dcSEvan Bacon }); 135082815dcSEvan Bacon if (value.match(/(^\s|\s$)/)) { 136082815dcSEvan Bacon value = '"' + value + '"'; 137082815dcSEvan Bacon } 138082815dcSEvan Bacon return value; 139082815dcSEvan Bacon} 140082815dcSEvan Bacon 141082815dcSEvan Baconexport function unescapeAndroidString(value: string): string { 142082815dcSEvan Bacon return value.replace(/\\(.)/g, '$1'); 143082815dcSEvan Bacon} 144