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 return _processAndroidXML(manifest); 34} 35 36export function _processAndroidXML(manifest: any): XMLObject { 37 // For strings.xml 38 if (Array.isArray(manifest?.resources?.string)) { 39 for (const string of manifest?.resources?.string) { 40 if (string.$.translatable === 'false' || string.$.translatable === false) { 41 continue; 42 } 43 string._ = unescapeAndroidString(string._); 44 } 45 } 46 47 return manifest; 48} 49 50export async function parseXMLAsync(contents: string): Promise<XMLObject> { 51 const xml = await new Parser().parseStringPromise(contents); 52 return xml; 53} 54 55const stringTimesN = (n: number, char: string) => Array(n + 1).join(char); 56 57export function format(manifest: any, { indentLevel = 2, newline = EOL } = {}): string { 58 let xmlInput: string; 59 if (typeof manifest === 'string') { 60 xmlInput = manifest; 61 } else if (manifest.toString) { 62 const builder = new Builder({ 63 headless: true, 64 }); 65 66 // For strings.xml 67 if (Array.isArray(manifest?.resources?.string)) { 68 for (const string of manifest?.resources?.string) { 69 if (string.$.translatable === 'false' || string.$.translatable === false) { 70 continue; 71 } 72 string._ = escapeAndroidString(string._); 73 } 74 } 75 76 xmlInput = builder.buildObject(manifest); 77 78 return xmlInput; 79 } else { 80 throw new Error(`Invalid XML value passed in: ${manifest}`); 81 } 82 const indentString = stringTimesN(indentLevel, ' '); 83 84 let formatted = ''; 85 const regex = /(>)(<)(\/*)/g; 86 const xml = xmlInput.replace(regex, `$1${newline}$2$3`); 87 let pad = 0; 88 xml 89 .split(/\r?\n/) 90 .map((line: string) => line.trim()) 91 .forEach((line: string) => { 92 let indent = 0; 93 if (line.match(/.+<\/\w[^>]*>$/)) { 94 indent = 0; 95 } else if (line.match(/^<\/\w/)) { 96 if (pad !== 0) { 97 pad -= 1; 98 } 99 } else if (line.match(/^<\w([^>]*[^/])?>.*$/)) { 100 indent = 1; 101 } else { 102 indent = 0; 103 } 104 105 const padding = stringTimesN(pad, indentString); 106 formatted += padding + line + newline; 107 pad += indent; 108 }); 109 110 return formatted.trim(); 111} 112 113/** 114 * Escapes Android string literals, specifically characters `"`, `'`, `\`, `\n`, `\r`, `\t` 115 * 116 * @param value unescaped Android XML string literal. 117 */ 118export function escapeAndroidString(value: string): string { 119 value = value.replace(/[\n\r\t'"@]/g, (m) => { 120 switch (m) { 121 case '"': 122 case "'": 123 case '@': 124 return '\\' + m; 125 case '\n': 126 return '\\n'; 127 case '\r': 128 return '\\r'; 129 case '\t': 130 return '\\t'; 131 default: 132 throw new Error(`Cannot escape unhandled XML character: ${m}`); 133 } 134 }); 135 if (value.match(/(^\s|\s$)/)) { 136 value = '"' + value + '"'; 137 } 138 return value; 139} 140 141export function unescapeAndroidString(value: string): string { 142 return value.replace(/\\(.)/g, '$1'); 143} 144