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