Skip to content
New issue

Have a question about this project? # for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “#”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? # to your account

[Code Share] support shouldUnescape prop of <Trans /> component from react-i18next #678

Closed
Cielquan opened this issue Nov 8, 2022 · 1 comment

Comments

@Cielquan
Copy link
Contributor

Cielquan commented Nov 8, 2022

🚀 Feature Proposal

Currently

<Trans shouldUnescape>I&apros;m Cielquan</Trans>

would be extracted as I&apros;m Cielquan and not I'm Cielquan, because i18next-parser currently does not respect the shouldUnescape prop of the <Trans /> component from react-i18next. I made a custom lexer which does.

Motivation

Because I currently do not have the time to make a PR I wanted to at least throw the code out into the wild for others to use or someone else to make a PR.

Example

I added the this.unescapeFn class property and the unescapeChars function. I also updated the jsxExtractor function for the part where <Trans /> components are handled.
This adds react-i18next as a dependency.

class JsxLexer extends JavascriptLexer {
  constructor(options = {}) {
    super(options);

    this.transSupportBasicHtmlNodes = options.transSupportBasicHtmlNodes || false;
    this.transKeepBasicHtmlNodesFor = options.transKeepBasicHtmlNodesFor || [
      "br",
      "strong",
      "i",
      "p",
    ];
    this.omitAttributes = [this.attr, "ns", "defaults"];
    this.unescapeFn = undefined;
  }

  extract(content, filename = "__default.jsx") {
    const keys = [];

    const parseCommentNode = this.createCommentNodeParser();

    const parseTree = (node) => {
      let entry;

      parseCommentNode(keys, node, content);

      switch (node.kind) {
        case ts.SyntaxKind.CallExpression:
          entry = this.expressionExtractor.call(this, node);
          break;
        case ts.SyntaxKind.TaggedTemplateExpression:
          entry = this.taggedTemplateExpressionExtractor.call(this, node);
          break;
        case ts.SyntaxKind.JsxElement:
          entry = this.jsxExtractor.call(this, node, content);
          break;
        case ts.SyntaxKind.JsxSelfClosingElement:
          entry = this.jsxExtractor.call(this, node, content);
          break;
      }

      if (entry) {
        keys.push(entry);
      }

      node.forEachChild(parseTree);
    };

    const sourceFile = ts.createSourceFile(filename, content, ts.ScriptTarget.Latest);
    parseTree(sourceFile);

    const keysWithNamespace = this.setNamespaces(keys);
    const keysWithPrefixes = this.setKeyPrefixes(keysWithNamespace);

    return keysWithPrefixes;
  }

  unescapeChars(escapedString) {
    if (this.unescapeFn === undefined)
      this.unescapeFn = require("react-i18next").getDefaults().unescape;
    return this.unescapeFn(escapedString);
  }

  jsxExtractor(node, sourceText) {
    const tagNode = node.openingElement || node;

    const getPropValue = (node, attributeName) => {
      const attribute = node.attributes.properties.find(
        (attr) => attr.name !== undefined && attr.name.text === attributeName,
      );
      if (!attribute) {
        return undefined;
      }

      if (attribute.initializer.expression?.kind === ts.SyntaxKind.Identifier) {
        this.emit(
          "warning",
          `Namespace is not a string literal: ${attribute.initializer.expression.text}`,
        );
        return undefined;
      }

      return attribute.initializer.expression
        ? attribute.initializer.expression.text
        : attribute.initializer.text;
    };

    const getKey = (node) => getPropValue(node, this.attr);

    if (tagNode.tagName.text === "Trans") {
      const entry = {};
      entry.key = getKey(tagNode);

      const namespace = getPropValue(tagNode, "ns");
      if (namespace) {
        entry.namespace = namespace;
      }

      tagNode.attributes.properties.forEach((property) => {
        if (property.kind === ts.SyntaxKind.JsxSpreadAttribute) {
          this.emit(
            "warning",
            `Component attribute is a JSX spread attribute : ${property.expression.text}`,
          );
          return;
        }

        if (this.omitAttributes.includes(property.name.text)) {
          return;
        }

        if (property.initializer) {
          if (property.initializer.expression) {
            if (property.initializer.expression.kind === ts.SyntaxKind.TrueKeyword) {
              entry[property.name.text] = true;
            } else if (property.initializer.expression.kind === ts.SyntaxKind.FalseKeyword) {
              entry[property.name.text] = false;
            } else {
              entry[property.name.text] = `{${property.initializer.expression.text}}`;
            }
          } else {
            entry[property.name.text] = property.initializer.text;
          }
        } else entry[property.name.text] = true;
      });

      const defaultsProp = getPropValue(tagNode, "defaults");
      let defaultValue = defaultsProp || this.nodeToString.call(this, node, sourceText);

      if (entry.shouldUnescape === true) {
        defaultValue = this.unescapeChars(defaultValue);
      }

      if (defaultValue !== "") {
        entry.defaultValue = defaultValue;

        if (!entry.key) {
          entry.key = entry.defaultValue;
        }
      }

      return entry.key ? entry : null;
    } else if (tagNode.tagName.text === "Interpolate") {
      const entry = {};
      entry.key = getKey(tagNode);
      return entry.key ? entry : null;
    }
  }

  nodeToString(node, sourceText) {
    const children = this.parseChildren.call(this, node.children, sourceText);

    const elemsToString = (children) =>
      children
        .map((child, index) => {
          switch (child.type) {
            case "js":
            case "text":
              return child.content;
            case "tag":
              const useTagName =
                child.isBasic &&
                this.transSupportBasicHtmlNodes &&
                this.transKeepBasicHtmlNodesFor.includes(child.name);
              const elementName = useTagName ? child.name : index;
              const childrenString = elemsToString(child.children);
              return childrenString || !(useTagName && child.selfClosing)
                ? `<${elementName}>${childrenString}</${elementName}>`
                : `<${elementName} />`;
            default:
              throw new Error("Unknown parsed content: " + child.type);
          }
        })
        .join("");

    return elemsToString(children);
  }

  parseChildren(children = [], sourceText) {
    return children
      .map((child) => {
        if (child.kind === ts.SyntaxKind.JsxText) {
          return {
            type: "text",
            content: child.text
              .replace(/(^(\n|\r)\s*)|((\n|\r)\s*$)/g, "")
              .replace(/(\n|\r)\s*/g, " "),
          };
        } else if (
          child.kind === ts.SyntaxKind.JsxElement ||
          child.kind === ts.SyntaxKind.JsxSelfClosingElement
        ) {
          const element = child.openingElement || child;
          const name = element.tagName.escapedText;
          const isBasic = !element.attributes.properties.length;
          return {
            type: "tag",
            children: this.parseChildren(child.children, sourceText),
            name,
            isBasic,
            selfClosing: child.kind === ts.SyntaxKind.JsxSelfClosingElement,
          };
        } else if (child.kind === ts.SyntaxKind.JsxExpression) {
          // strip empty expressions
          if (!child.expression) {
            return {
              type: "text",
              content: "",
            };
          }

          // simplify trivial expressions, like TypeScript typecasts
          if (child.expression.kind === ts.SyntaxKind.AsExpression) {
            child = child.expression;
          }

          if (child.expression.kind === ts.SyntaxKind.StringLiteral) {
            return {
              type: "text",
              content: child.expression.text,
            };
          }

          // strip properties from ObjectExpressions
          // annoying (and who knows how many other exceptions we'll need to write) but necessary
          else if (child.expression.kind === ts.SyntaxKind.ObjectLiteralExpression) {
            // i18next-react only accepts two props, any random single prop, and a format prop
            // for our purposes, format prop is always ignored

            let nonFormatProperties = child.expression.properties.filter(
              (prop) => prop.name.text !== "format",
            );

            // more than one property throw a warning in i18next-react, but still works as a key
            if (nonFormatProperties.length > 1) {
              this.emit(
                "warning",
                `The passed in object contained more than one variable - the object should look like {{ value, format }} where format is optional.`,
              );

              return {
                type: "text",
                content: "",
              };
            }

            return {
              type: "js",
              content: `{{${nonFormatProperties[0].name.text}}}`,
            };
          }

          // slice on the expression so that we ignore comments around it
          return {
            type: "js",
            content: `{${sourceText.slice(child.expression.pos, child.expression.end)}}`,
          };
        } else {
          throw new Error("Unknown ast element when parsing jsx: " + child.kind);
        }
      })
      .filter((child) => child.type !== "text" || child.content);
  }
}
@karellm
Copy link
Member

karellm commented Nov 11, 2022

Published as 7.0.0

# for free to join this conversation on GitHub. Already have an account? # to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

2 participants