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