Home Manual Reference Source Test

src/lib/transform/XMLTransformer.js

import { EOL } from 'os';
import { parse, render, isElement, moveToTop, attributeValues } from 'modify-xml';
import { TransformDirection } from './Transformer';
import SplittingTransformer from './SplittingTransformer';

function walk(element, action, filter = isElement) {
  action(element);

  if (element.childNodes) {
    for (const child of element.childNodes.filter((n) => filter(n))) {
      walk(child, action);
    }
  }
}

/**
 * A transformer used to transform XML documents.
 */
export default class XMLTransformer extends SplittingTransformer {
  /**
   * Creates a new XMLTransformer based on some options.
   * @param {Object} [options] The options to use.
   */
  constructor({ sortXMLAttributes = false, removeBuilderRefs = false, ...options } = {}) {
    super(options);

    /** @protected */
    this.sortXMLAttributes = sortXMLAttributes;

    /** @protected */
    this.removeBuilderRefs = removeBuilderRefs;

    function build(object, buildOptions) {
      const root = object.childNodes.find((n) => isElement(n));

      if (root) {
        moveToTop(root, 'metadata');
        moveToTop(root, 'defs');
        moveToTop(root, 'desc');
        moveToTop(root, 'title');
      }

      if (sortXMLAttributes || removeBuilderRefs)
        walk(root, (e) => {
          /* eslint-disable no-param-reassign */
          if (removeBuilderRefs)
            e.attributes = e.attributes.filter((a) => !['atv:refpx', 'atv:refpy'].includes(a.name));

          if (sortXMLAttributes)
            e.attributes = e.attributes.sort((a, b) => (b.name > a.name ? -1 : 1));

          delete e.openTag;
          /* eslint-enable no-param-reassign */
        });

      return render(object, { indent: ' '.repeat(buildOptions.spaces) });
    }

    // eslint-disable-next-line jsdoc/require-param
    /**
     * The builder to use with direction {@link TransformDirection.FromDB}.
     * @type {function(object: Object): string}
     */
    this._fromDBBuilder = (object) => {
      const xml = build(object, { compact: false, spaces: 2 });
      return xml.replace(/\r?\n/g, EOL);
    };

    // eslint-disable-next-line jsdoc/require-param
    /**
     * The builder to use with direction {@link TransformDirection.FromFilesystem}.
     * @type {function(object: Object): string}
     */
    this._fromFilesystemBuilder = (object) => {
      const xml = build(object, { compact: false, spaces: 1 });
      return xml.replace(/\r?\n/g, '\n');
    };
  }

  /**
   * @protected
   * @param {import('modify-xml').Element} node The node to handle.
   */
  sortedAttributeValues(node) {
    if (!this.sortXMLAttributes) return attributeValues(node);

    return Object.fromEntries(
      Object.entries(attributeValues(node)).sort((a, b) => (b > a ? -1 : 1))
    );
  }

  /**
   * Returns the XML builder to use based on the current {@link Transformer#direction}.
   * @type {function(object: Object): string}
   */
  get builder() {
    return this.direction === TransformDirection.FromDB
      ? this._fromDBBuilder
      : this._fromFilesystemBuilder;
  }

  /**
   * Parses XML in a node's contents.
   * @param {Node} node The node to process.
   */
  decodeContents(node) {
    const rawLines =
      this.direction === TransformDirection.FromDB ? node.value.value.toString() : node.stringValue;

    try {
      return parse(rawLines);
    } catch (error) {
      if (error.line) {
        Object.assign(error, {
          rawLines,
          location: {
            start: {
              line: error.line + 1,
              column: error.column + 1,
            },
          },
        });
      }

      throw error;
    }
  }

  /**
   * Builds an XML string from an object.
   * @param {Object} object The object to encode.
   */
  encodeContents(object) {
    return this.builder(object);
  }
}