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