😽

React で展開された HTML 要素から vscode の生成元コードに飛ぶ 方法

2021/12/27に公開

自分が欲しかったから作ったシリーズ

https://github.com/mizchi/tsx-source-jump

説明しづらいので下記の動画を見たほうが速いです。

Shift を押している間だけオーバレイが有効になり、要素名をクリックすると vscode の該当行に飛びます。

今のところ vite + react のみの対応ですが、仕組み上、あらゆる UI フレームワークに適応可能です。

何が起きているか

  • TypeScript transformer の仕組みで *.tsx の jsx 要素に data-sj-path="vscode://file/..." を付与する
    • TypeScript AST は sourcemap 用の情報を持っている
    • Node の parent を探索し、直近の関数コンポーネント名を探す
  • Shift を押している間、 マウスでホバーされた要素が data-sj-path を持っているならオーバレイを表示
  • オーバレイ中の要素名をクリックしたら <a href="vscode://file/..."> 要素を生成して el.click() で vscode に飛ばす

vscode の uri scheme についてはこちら

PoC 的な実装なので、まだ vite + react のみの対応ですが、仕組み上はどんなフレームワークでも同じ仕様、同じ UI で対応可能なはずです。気が向いたら作ります。

使い方

インストール

yarn add tsx-source-jump -D

vite にプラグインを追加

vite.config.ts
import react from "@vitejs/plugin-react";
import { defineConfig } from "vite";
import { tsxSourceJump } from "tsx-source-jump/vite";

export default defineConfig({
  plugins: [
    // DO NOT INCLUDE IN PRODUCTION
    ...(process.env.NODE_ENV !== "production"
      ? [
          tsxSourceJump({
            // your projectRoot
            projectRoot: __dirname + "/",
            // rewriting element target
            target: [/^[a-z]+$/],
          }),
        ]
      : []),
    react(),
  ],
});

デフォルトだと、 divmain のようなプリミティブな要素だけを書き換えるターゲットにしてます。

chakra-ui の Box のような要素をターゲットにしたい場合、 target を追加してください。

tsxSourceJump({
  projectRoot: __dirname + "/",
  target: [
    /^[a-z]+$/,
    /^(Box|Flex|Center|Container|Grid|SimpleGrid|Stack|Wrap|Button|Link|Icon|Image)$/,
  ],
});

オーバーレイ用の UI を追加

これだけだと <div data-sj-path="vscode://...">...</div> のように data-sj-path が埋まってるだけです。これを有効化する要素をマウントします。

// entrypoint
import React from "react";
import ReactDOM from "react-dom";
import { App } from "./App";
import { SourceJumpOverlayPortal } from "tsx-source-jump/runtime";

ReactDOM.render(
  <React.StrictMode>
    <SourceJumpOverlayPortal />
    <App />
  </React.StrictMode>,
  document.getElementById("root")
);

ブラウザ上で開く

yarn dev などで http://localhost:3000 を開きます。

Shift を押しながら要素をホバーし、要素名をクリックすると飛びます。

(初回はブラウザ側から警告が出ます)

内部実装の話

自分で実装したい人向けの話

TypeScript には transformer という仕組みがあり、ビルド時に任意の AST 変換を行うことができます。

madou/typescript-transformer-handbook: 📘 A comprehensive handbook on how to create transformers for TypeScript with code examples

このライブラリの肝はこれだけです。

import ts, { factory } from "typescript";
import type { SourceLinkerOptions } from "./types.js";

export function jsxTransformerFactory(opts: SourceLinkerOptions) {
  return (context: ts.TransformationContext) => {
    const __visitNode = (node: ts.Node): ts.Node => {
      const newNode = ts.visitEachChild(node, __visitNode, context);
      if (
        ts.isJsxOpeningElement(newNode) ||
        ts.isJsxSelfClosingElement(newNode)
      ) {
        return appendSourceMapAttribute(opts, newNode);
      }
      return newNode;
    };
    return (source: ts.SourceFile) => {
      return ts.factory.updateSourceFile(
        source,
        ts.visitNodes(source.statements, __visitNode)
      );
    };
  };
}

const defaultTarget = [/^[a-z]+$/];

function appendSourceMapAttribute(
  opts: SourceLinkerOptions,
  node: ts.JsxOpeningElement | ts.JsxSelfClosingElement
) {
  if (ts.isIdentifier(node.tagName)) {
    const tagName = node.tagName.getText();

    const target = opts.target ?? defaultTarget;
    if (!target.some((t) => t.test(tagName))) return node;

    const source = node.getSourceFile();
    const fileName = source.fileName;
    const position = ts.getLineAndCharacterOfPosition(
      source,
      node.getStart(source)
    );
    const factoryMethod =
      node.kind === ts.SyntaxKind.JsxOpeningElement
        ? factory.createJsxOpeningElement
        : factory.createJsxSelfClosingElement;
    const owner = findOwnerComponent(node);
    const displayText = `${fileName.replace(opts.projectRoot, "")}:${
      position.line + 1
    }:${position.character + 1}${owner ? ` | [${owner.name?.getText()}]` : ""}`;
    return factoryMethod(
      node.tagName,
      node.typeArguments,
      factory.updateJsxAttributes(node.attributes, [
        ...node.attributes.properties,
        factory.createJsxAttribute(
          factory.createIdentifier("data-sj-path"),
          factory.createStringLiteral(
            `vscode://file${fileName}:${position.line + 1}:${
              position.character + 1
            }`
          )
        ),
        factory.createJsxAttribute(
          factory.createIdentifier("data-sj-display-name"),
          factory.createStringLiteral(displayText)
        ),
      ])
    );
  }
  return node;
}

function findOwnerComponent(node: ts.Node): ts.FunctionDeclaration | null {
  let cur = node;
  while (cur.parent) {
    if (ts.isFunctionDeclaration(cur)) return cur;
    if (ts.isSourceFile(cur)) return null;
    cur = cur.parent;
  }
  return null;
}

次にやりたいこと

  • webpack plugin
  • swc plugin 版
  • babel plugin 版

そもそも最初にやるとしてたんですが、 vscode に飛ばさずに、 コードを埋め込んでブラウザ内にエディタを開き、そこでコードを直接書き換えたものを native-files 反映する、というのができる気がしており、それを検証してみます。

Native File System API でテキストエディタを作る - Qiita

Discussion