misskey/packages/frontend/lib/rollup-plugin-create-search...

254 lines
9.4 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { parse as vueSfcParse } from 'vue/compiler-sfc';
import type { Plugin } from 'rollup';
import fs from 'node:fs';
import { glob } from 'glob';
import JSON5 from 'json5';
import { randomUUID } from 'crypto';
import MagicString from 'magic-string';
import path from 'node:path'
export interface AnalysisResult {
filePath: string;
usage: ComponentUsageInfo[];
}
export interface ComponentUsageInfo {
staticProps: Record<string, string>;
bindProps: Record<string, string>;
componentName: string;
}
function outputAnalysisResultAsTS(outputPath: string, analysisResults: AnalysisResult[]): void {
// (outputAnalysisResultAsTS 関数の実装は前回と同様)
const varName = 'searchIndexes'; //  変数名
const jsonString = JSON5.stringify(analysisResults, { space: "\t", quote: "'" }); //  JSON.stringify で JSON 文字列を生成
//  bindProps の値を文字列置換で修正する関数
function modifyBindPropsInString(jsonString: string): string {
const modifiedString = jsonString.replace(
/bindProps:\s*\{([^}]*)\}/g, //  bindProps: { ... } にマッチ (g フラグで複数箇所を置換)
(match, bindPropsBlock) => {
//  bindPropsBlock ( { ... } 内) の各プロパティをさらに置換
const modifiedBlock = bindPropsBlock.replace(
/(.*):\s*\'(.*)\'/g, //  propName: 'propValue' にマッチ
(propMatch, propName, propValue) => {
return `${propName}: ${propValue}`; // propValue のクォートを除去
}
).replaceAll("\\'", "'");
return `bindProps: {${modifiedBlock}}`; //  置換後の block で bindProps: { ... } を再構成
}
);
return modifiedString;
}
const tsOutput = `
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
// This file was automatically generated by create-search-index.
// Do not edit this file.
import { i18n } from '@/i18n.js';
export const ${varName} = ${modifyBindPropsInString(jsonString)} as const;
export type AnalysisResults = typeof ${varName};
export type ComponentUsageInfo = AnalysisResults[number]['usage'][number];
`;
try {
fs.writeFileSync(outputPath, tsOutput, 'utf-8');
console.log(`[create-search-index]: output done. ${outputPath}`);
} catch (error) {
console.error('[create-search-index]: error: ', error);
}
}
function extractUsageInfoFromTemplateAst(
templateAst: any,
targetComponents: string[]
): ComponentUsageInfo[] {
const usageInfoList: ComponentUsageInfo[] = [];
if (!templateAst) {
return usageInfoList;
}
function traverse(node: any) {
if (node.type === 1 /* ELEMENT */ && node.tag && targetComponents.includes(node.tag)) {
const componentTag = node.tag;
const staticProps: Record<string, string> = {};
const bindProps: Record<string, string> = {}; // bindProps の型を string に戻す
if (node.props && Array.isArray(node.props)) {
node.props.forEach((prop: any) => {
if (prop.type === 6 /* ATTRIBUTE */) { // type 6 は StaticAttribute
staticProps[prop.name] = prop.value?.content || ''; //  属性値を文字列として取得
} else if (prop.type === 7 /* DIRECTIVE */ && prop.name === 'bind' && prop.arg?.content) { // type 7 は DirectiveNode, v-bind:propName の場合
if (prop.exp?.content && prop.arg.content !== 'class') {
bindProps[prop.arg.content] = prop.exp.content; // prop.exp.content (文字列) を格納
}
}
});
}
usageInfoList.push({
staticProps,
bindProps,
componentName: componentTag,
});
} else if (node.children && Array.isArray(node.children)) {
node.children.forEach(child => traverse(child));
}
}
traverse(templateAst);
return usageInfoList;
}
export async function analyzeVueProps(options: {
targetComponents: string[],
targetFilePaths: string[],
exportFilePath: string,
transformedCodeCache: Record<string, string>
}): Promise<void> {
const targetComponents = options.targetComponents || [];
const analysisResults: AnalysisResult[] = [];
//  対象ファイルパスを glob で展開
const filePaths = options.targetFilePaths.reduce<string[]>((acc, filePathPattern) => {
const matchedFiles = glob.sync(filePathPattern);
return [...acc, ...matchedFiles];
}, []);
for (const filePath of filePaths) {
// ★ キャッシュから変換済みコードを取得 (修正): キャッシュに存在しない場合はエラーにする (キャッシュ必須)
const code = options.transformedCodeCache[path.resolve(filePath)]; // キャッシュからコードを取得 (キャッシュミス時は undefined)
if (!code) { // キャッシュミスの場合
console.error(`[create-search-index] Error: No cached code found for: ${filePath}.`); // エラーログ
continue;
}
console.log(`[create-search-index] analyzeVueProps: Processing file: ${filePath}, using cached code: true`); // ★ ログ: キャッシュ使用
const { descriptor, errors } = vueSfcParse(code, {
filename: filePath,
});
if (errors.length) {
console.error(`[create-search-index] Compile Error: ${filePath}`, errors);
continue; // エラーが発生したファイルはスキップ
}
// テンプレートASTを走査してコンポーネント使用箇所とpropsの値を取得
const usageInfo = extractUsageInfoFromTemplateAst(descriptor.template?.ast, targetComponents);
if (!usageInfo) continue;
if (usageInfo.length > 0) {
analysisResults.push({
filePath: filePath,
usage: usageInfo,
});
}
}
outputAnalysisResultAsTS(options.exportFilePath, analysisResults); // outputAnalysisResultAsTS を呼び出す
}
// Rollup プラグインとして export
export default function pluginCreateSearchIndex(options: {
targetComponents: string[],
targetFilePaths: string[],
exportFilePath: string
}): Plugin {
const transformedCodeCache: Record<string, string> = {}; // キャッシュオブジェクトを定義
return {
name: 'createSearchIndex',
async transform(code, id) {
if (!id.endsWith('.vue')) {
return null;
}
// targetFilePaths にマッチするファイルのみ処理を行う
// glob パターンでマッチング
let fullFileName = '';
let isMatch = false; // isMatch の初期値を false に設定
for (const pattern of options.targetFilePaths) { // パターンごとにマッチング確認
const globbedFiles = glob.sync(pattern);
for (const globbedFile of globbedFiles) {
const normalizedGlobbedFile = path.resolve(globbedFile); // glob 結果を絶対パスに
const normalizedId = path.resolve(id); // id を絶対パスに
if (normalizedGlobbedFile === normalizedId) { // 絶対パス同士で比較
isMatch = true;
fullFileName = normalizedId;
break; // マッチしたらループを抜ける
}
}
if (isMatch) break; // いずれかのパターンでマッチしたら、outer loop も抜ける
}
if (!isMatch) {
return null;
}
console.log(`[create-search-index] Processing file: ${id}`); // ログ: マッチしたファイルを処理中
const s = new MagicString(code); // magic-string のインスタンスを作成
const ast = vueSfcParse(code, { filename: id }).descriptor.template?.ast; // テンプレート AST を取得
if (ast) {
function traverse(node: any) {
if (node.type === 1 /* ELEMENT */ && node.tag === 'MkSearchMarker') { // MkSearchMarker コンポーネントを検出
const markerId = randomUUID(); // UUID を生成
const props = node.props || [];
const hasMarkerIdProp = props.some((prop: any) => prop.type === 6 && prop.name === 'markerId'); // markerId 属性が既に存在するか確認
if (!hasMarkerIdProp) {
// magic-string を使って markerId 属性を <MkSearchMarker> に追加
const startTagEnd = code.indexOf('>', node.loc.start.offset); // 開始タグの閉じ > の位置を検索
if (startTagEnd !== -1) {
s.appendRight(startTagEnd, ` markerId="${markerId}"`); //  markerId 属性を追記
console.log(`[create-search-index] 付与 markerId="${markerId}" to MkSearchMarker in ${id}`); // 付与ログ
}
}
}
if (node.children && Array.isArray(node.children)) {
node.children.forEach(child => traverse(child)); // 子ノードを再帰的に traverse
}
}
traverse(ast); // AST を traverse
const transformedCode = s.toString(); // ★ 変換後のコードを取得
transformedCodeCache[id] = transformedCode; // ★ 変換後のコードをキャッシュに保存
return {
code: transformedCode, // 変更後のコードを返す
map: s.generateMap({ source: id, includeContent: true }), // ソースマップも生成 (sourceMap: true が必要)
};
}
return null; // テンプレート AST がない場合は null を返す
},
async writeBundle() {
await analyzeVueProps({ ...options, transformedCodeCache }); // writeBundle フックで analyzeVueProps 関数を呼び出す (変更なし)
},
};
}