Skip to content
New issue

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

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We鈥檒l occasionally send you account related emails.

Already on GitHub? Sign in 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

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

No branches or pull requests

2 participants