React で展開された HTML 要素から vscode の生成元コードに飛ぶ 方法
自分が欲しかったから作ったシリーズ
説明しづらいので下記の動画を見たほうが速いです。
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 にプラグインを追加
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(),
],
});
デフォルトだと、 div
や main
のようなプリミティブな要素だけを書き換えるターゲットにしてます。
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 変換を行うことができます。
このライブラリの肝はこれだけです。
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 反映する、というのができる気がしており、それを検証してみます。
Discussion