/* * 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 'vite'; import fs from 'node:fs'; import { glob } from 'glob'; import JSON5 from 'json5'; import MagicString from 'magic-string'; import path from 'node:path' import { hash, toBase62 } from '../vite.config'; export type AnalysisResult = { filePath: string; usage: SearchIndexItem[]; } export type SearchIndexItem = { id: string; path?: string; label: string; keywords: string | string[]; icon?: string; children?: (SearchIndexItem[] | string); } function outputAnalysisResultAsTS(outputPath: string, analysisResults: AnalysisResult[]): void { console.log(`[create-search-index] Processing ${analysisResults.length} files for output`); // 新しいツリー構造を構築 const allMarkers = new Map(); // 1. すべてのマーカーを一旦フラットに収集 for (const file of analysisResults) { console.log(`[create-search-index] Processing file: ${file.filePath} with ${file.usage.length} markers`); for (const marker of file.usage) { if (marker.id) { // キーワードの処理(文字列から配列へ変換) let keywords = marker.keywords; if (typeof keywords === 'string' && keywords.startsWith('[') && keywords.endsWith(']')) { try { // JSON5解析を試みる(ただしi18n参照などがある場合は例外発生) keywords = JSON5.parse(keywords.replace(/'/g, '"')); } catch (e) { // 解析に失敗した場合は文字列のままにする console.log(`[create-search-index] Keeping keywords as string expression: ${keywords}`); } } // 子要素の処理(文字列から配列へ変換) let children = marker.children || []; if (typeof children === 'string' && children.startsWith('[') && children.endsWith(']')) { try { // JSON5解析を試みる children = JSON5.parse(children.replace(/'/g, '"')); } catch (e) { // 解析に失敗した場合は空配列に console.log(`[create-search-index] Could not parse children: ${children}, using empty array`); children = []; } } // 子マーカーの内部構造を適切に更新 if (Array.isArray(children) && children.length > 0) { console.log(`[create-search-index] Marker ${marker.id} has ${children.length} children: ${JSON.stringify(children)}`); } allMarkers.set(marker.id, { ...marker, keywords, children: Array.isArray(children) ? children : [] }); } } } console.log(`[create-search-index] Collected total ${allMarkers.size} unique markers`); // 2. 子マーカーIDの収集 const childIds = new Set(); allMarkers.forEach((marker, id) => { const children = marker.children; if (Array.isArray(children)) { children.forEach(childId => { if (typeof childId === 'string') { if (!allMarkers.has(childId)) { console.warn(`[create-search-index] Warning: Child marker ID ${childId} referenced but not found`); } else { childIds.add(childId); } } }); } }); console.log(`[create-search-index] Found ${childIds.size} child markers`); // 3. ルートマーカーの特定(他の誰かの子でないマーカー) const rootMarkers: SearchIndexItem[] = []; allMarkers.forEach((marker, id) => { if (!childIds.has(id)) { // このマーカーはルート(他の誰の子でもない) rootMarkers.push(marker); console.log(`[create-search-index] Added root marker to output: ${id} with label ${marker.label}`); } }); console.log(`[create-search-index] Found ${rootMarkers.length} root markers`); // 4. 子マーカーの参照を解決(IDから実際のオブジェクトに) function resolveChildrenReferences(marker: SearchIndexItem): SearchIndexItem { // マーカーのディープコピーを作成 const resolvedMarker = { ...marker }; // 子リファレンスを解決 if (Array.isArray(marker.children)) { const children: SearchIndexItem[] = []; for (const childId of marker.children) { if (typeof childId === 'string') { const childMarker = allMarkers.get(childId); if (childMarker) { // 子マーカーの子も再帰的に解決 const resolvedChild = resolveChildrenReferences(childMarker); children.push(resolvedChild); console.log(`[create-search-index] Resolved child ${childId} for parent ${marker.id}`); } } } // 子が存在する場合のみchildrenプロパティを設定 if (children.length > 0) { resolvedMarker.children = children; } else { // 子がない場合はchildrenプロパティを削除 delete resolvedMarker.children; } } return resolvedMarker; } // すべてのルートマーカーに対して子の参照を解決 const resolvedRootMarkers = rootMarkers.map(marker => { return resolveChildrenReferences(marker); }); // 特殊なプロパティ変換用の関数 function formatSpecialProperty(key: string, value: any): string { // 値がundefinedの場合は空文字列を返す if (value === undefined) { return '""'; } // childrenが配列の場合は特別に処理 if (key === 'children' && Array.isArray(value)) { return customStringify(value); } // 文字列でない場合はJSON5で文字列化 if (typeof value !== 'string') { return JSON5.stringify(value); } // i18n.ts 参照を含む場合 if (value.includes('i18n.ts.')) { return value; // クォートなしで直接返す } // keywords が配列リテラルの形式の場合 if (key === 'keywords' && value.startsWith('[') && value.endsWith(']')) { return value; // クォートなしで直接返す } // 上記以外は通常の JSON5 文字列として返す return JSON5.stringify(value); } // オブジェクトをカスタム形式に変換する関数 function customStringify(obj: any, depth = 0): string { const INDENT_STR = '\t'; if (Array.isArray(obj)) { if (obj.length === 0) return '[]'; const indent = INDENT_STR.repeat(depth); const childIndent = INDENT_STR.repeat(depth + 1); const items = obj.map(item => `${childIndent}${customStringify(item, depth + 1)}`).join(',\n'); return `[\n${items},\n${indent}]`; } if (obj === null || typeof obj !== 'object') { return JSON5.stringify(obj); } const indent = INDENT_STR.repeat(depth); const childIndent = INDENT_STR.repeat(depth + 1); const entries = Object.entries(obj) .filter(([key, value]) => { // valueがundefinedの場合は出力しない if (value === undefined) return false; // childrenが空配列の場合は出力しない if (key === 'children' && Array.isArray(value) && value.length === 0) return false; return true; }) .map(([key, value]) => { // childrenが配列の場合で要素がある場合のみ特別処理 if (key === 'children' && Array.isArray(value) && value.length > 0) { return `${childIndent}${key}: ${customStringify(value, depth + 1)}`; } return `${childIndent}${key}: ${formatSpecialProperty(key, value)}`; }); if (entries.length === 0) return '{}'; return `{\n${entries.join(',\n')},\n${indent}}`; } // 最終出力用のデバッグ情報を生成 let totalMarkers = resolvedRootMarkers.length; let totalChildren = 0; function countNestedMarkers(markers: SearchIndexItem[]): void { for (const marker of markers) { if (marker.children && Array.isArray(marker.children)) { totalChildren += marker.children.length; totalMarkers += marker.children.length; countNestedMarkers(marker.children as SearchIndexItem[]); } } } countNestedMarkers(resolvedRootMarkers); console.log(`[create-search-index] Total markers in tree: ${totalMarkers} (${resolvedRootMarkers.length} roots + ${totalChildren} nested children)`); // 結果をTS形式で出力 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. /* eslint-disable @stylistic/comma-spacing */ import { i18n } from '@/i18n.js'; export type SearchIndexItem = { id: string; path?: string; label: string; keywords: string | string[]; icon?: string; children?: (SearchIndexItem[] | string); }; export const searchIndexes:SearchIndexItem[] = ${customStringify(resolvedRootMarkers)} as const; export type SearchIndex = typeof searchIndexes; /* eslint-enable @stylistic/comma-spacing */ `; try { fs.writeFileSync(outputPath, tsOutput, 'utf-8'); console.log(`[create-search-index] Successfully wrote search index to ${outputPath} with ${resolvedRootMarkers.length} root entries and ${totalChildren} nested children`); } catch (error) { console.error('[create-search-index]: error: ', error); } } function extractUsageInfoFromTemplateAst( templateAst: any, code: string, ): SearchIndexItem[] { // すべてのマーカー情報を保持するために、結果をトップレベルマーカー+すべての子マーカーを含む配列に変更 const allMarkers: SearchIndexItem[] = []; // マーカーIDからオブジェクトへのマップ const markerMap = new Map(); // 子マーカーIDを集約するためのセット const childrenIds = new Set(); if (!templateAst) { return allMarkers; } // デバッグ情報 console.log('[create-search-index] Started extracting markers from AST'); // マーカーの基本情報を収集 function collectMarkers(node: any, parentId: string | null = null) { // SearchMarkerコンポーネントの検出 if (node.type === 1 && node.tag === 'SearchMarker') { // マーカーID生成 (markerId属性またはDOM内に記録されているものを使用) const markerIdProp = node.props?.find((p: any) => p.name === 'markerId'); const markerId = markerIdProp?.value?.content || node.__markerId || `marker-${Math.random().toString(36).substring(2, 10)}`; console.log(`[create-search-index] Found SearchMarker with ID: ${markerId}`); // マーカー基本情報 const markerInfo: SearchIndexItem = { id: markerId, children: [], label: '', keywords: [], }; // 静的プロパティを抽出 if (node.props && Array.isArray(node.props)) { node.props.forEach((prop: any) => { if (prop.type === 6 && prop.name && prop.name !== 'markerId') { // 静的プロパティの抽出 if (prop.name === 'path') markerInfo.path = prop.value?.content || ''; else if (prop.name === 'icon') markerInfo.icon = prop.value?.content || ''; else if (prop.name === 'label') markerInfo.label = prop.value?.content || ''; console.log(`[create-search-index] Static prop ${prop.name}:`, prop.value?.content); } }); } // バインドプロパティを抽出 if (node.props && Array.isArray(node.props)) { node.props.forEach((prop: any) => { if (prop.type === 7 && prop.name === 'bind' && prop.arg?.content) { const propName = prop.arg.content; const propValue = prop.exp?.content || ''; console.log(`[create-search-index] Bind prop ${propName}:`, propValue); if (propName === 'label') { markerInfo.label = propValue; } else if (propName === 'path') { markerInfo.path = propValue; } else if (propName === 'icon') { markerInfo.icon = propValue; } else if (propName === 'keywords') { markerInfo.keywords = propValue || '[]'; } else if (propName === 'children') { markerInfo.children = propValue || '[]'; } } }); } // ラベルがない場合はデフォルト値を設定 if (!markerInfo.label) { markerInfo.label = 'Unnamed marker'; } // キーワードがない場合はデフォルト値を設定 if (!markerInfo.keywords || (Array.isArray(markerInfo.keywords) && markerInfo.keywords.length === 0)) { markerInfo.keywords = '[]'; } // マーカーをマップに保存し、すべてのマーカーリストにも追加 markerMap.set(markerId, markerInfo); allMarkers.push(markerInfo); // すべてのマーカーを保持 // 親子関係を記録 if (parentId) { const parent = markerMap.get(parentId); if (parent) { if (!parent.children) parent.children = []; if (Array.isArray(parent.children)) { parent.children.push(markerId); } else { parent.children = [markerId]; } childrenIds.add(markerId); console.log(`[create-search-index] Added ${markerId} as child of ${parentId}`); } } // 子ノードを処理(親は現在のノード) if (node.children && Array.isArray(node.children)) { node.children.forEach((child: any) => { collectMarkers(child, markerId); }); } return markerId; } // SearchMarkerでない場合は、子ノードを同じ親コンテキストで処理 if (node.children && Array.isArray(node.children)) { node.children.forEach((child: any) => { collectMarkers(child, parentId); }); } return null; } // AST解析を開始 collectMarkers(templateAst); // デバッグ情報 console.log(`[create-search-index] Found ${markerMap.size} markers, ${childrenIds.size} children`); // 重要: すべてのマーカー情報を返す return allMarkers; } export async function analyzeVueProps(options: { targetFilePaths: string[], exportFilePath: string, transformedCodeCache: Record }): Promise { const allMarkers: SearchIndexItem[] = []; // 対象ファイルパスを glob で展開 const filePaths = options.targetFilePaths.reduce((acc, filePathPattern) => { const matchedFiles = glob.sync(filePathPattern); return [...acc, ...matchedFiles]; }, []); console.log(`[create-search-index] Found ${filePaths.length} matching files to analyze`); for (const filePath of filePaths) { const id = path.resolve(filePath); // 絶対パスに変換 const code = options.transformedCodeCache[id]; // options 経由でキャッシュ参照 if (!code) { // キャッシュミスの場合 console.error(`[create-search-index] Error: No cached code found for: ${filePath}.`); // エラーログ // ファイルを直接読み込む代替策を実行 try { const directCode = fs.readFileSync(filePath, 'utf-8'); options.transformedCodeCache[id] = directCode; console.log(`[create-search-index] Loaded file directly instead: ${filePath}`); } catch (err) { console.error(`[create-search-index] Failed to load file directly: ${filePath}`, err); continue; } } try { const { descriptor, errors } = vueSfcParse(options.transformedCodeCache[id], { filename: filePath, }); if (errors.length) { console.error(`[create-search-index] Compile Error: ${filePath}`, errors); continue; // エラーが発生したファイルはスキップ } const fileMarkers = extractUsageInfoFromTemplateAst(descriptor.template?.ast, options.transformedCodeCache[id]); if (fileMarkers && fileMarkers.length > 0) { allMarkers.push(...fileMarkers); // すべてのマーカーを収集 console.log(`[create-search-index] Successfully extracted ${fileMarkers.length} markers from ${filePath}`); } else { console.log(`[create-search-index] No markers found in ${filePath}`); } } catch (error) { console.error(`[create-search-index] Error analyzing file ${filePath}:`, error); } } // 収集したすべてのマーカー情報を使用 const analysisResult: AnalysisResult[] = [ { filePath: "combined-markers", // すべてのファイルのマーカーを1つのエントリとして扱う usage: allMarkers, } ]; outputAnalysisResultAsTS(options.exportFilePath, analysisResult); // すべてのマーカー情報を渡す } interface MarkerRelation { parentId?: string; markerId: string; node: any; } async function processVueFile( code: string, id: string, options: { targetFilePaths: string[], exportFilePath: string }, transformedCodeCache: Record ) { // すでにキャッシュに存在する場合は、そのまま返す if (transformedCodeCache[id] && transformedCodeCache[id].includes('markerId=')) { console.log(`[create-search-index] Using cached version for ${id}`); return { code: transformedCodeCache[id], map: null }; } const s = new MagicString(code); // magic-string のインスタンスを作成 const parsed = vueSfcParse(code, { filename: id }); if (!parsed.descriptor.template) { return; } const ast = parsed.descriptor.template.ast; // テンプレート AST を取得 const markerRelations: MarkerRelation[] = []; // MarkerRelation 配列を初期化 if (ast) { function traverse(node: any, currentParent?: any) { if (node.type === 1 && node.tag === 'SearchMarker') { // 行番号はコード先頭からの改行数で取得 const lineNumber = code.slice(0, node.loc.start.offset).split('\n').length; // ファイルパスと行番号からハッシュ値を生成 const generatedMarkerId = toBase62(hash(`${id}:${lineNumber}`)); const props = node.props || []; const hasMarkerIdProp = props.some((prop: any) => prop.type === 6 && prop.name === 'markerId'); const nodeMarkerId = hasMarkerIdProp ? props.find((prop: any) => prop.type === 6 && prop.name === 'markerId')?.value?.content as string : generatedMarkerId; node.__markerId = nodeMarkerId; // 子マーカーの場合、親ノードに __children を設定しておく if (currentParent && currentParent.type === 1 && currentParent.tag === 'SearchMarker') { currentParent.__children = currentParent.__children || []; currentParent.__children.push(nodeMarkerId); } const parentMarkerId = currentParent && currentParent.__markerId; markerRelations.push({ parentId: parentMarkerId, markerId: nodeMarkerId, node: node, }); if (!hasMarkerIdProp) { const nodeStart = node.loc.start.offset; let endOfStartTag; if (node.children && node.children.length > 0) { // 子要素がある場合、最初の子要素の開始位置を基準にする endOfStartTag = code.lastIndexOf('>', node.children[0].loc.start.offset); } else if (node.loc.end.offset > nodeStart) { // 子要素がない場合、自身の終了位置から逆算 const nodeSource = code.substring(nodeStart, node.loc.end.offset); // 自己終了タグか通常の終了タグかを判断 if (nodeSource.includes('/>')) { endOfStartTag = code.indexOf('/>', nodeStart) - 1; } else { endOfStartTag = code.indexOf('>', nodeStart); } } if (endOfStartTag !== undefined && endOfStartTag !== -1) { // markerId が既に存在しないことを確認 const tagText = code.substring(nodeStart, endOfStartTag + 1); const markerIdRegex = /\s+markerId\s*=\s*["'][^"']*["']/; if (!markerIdRegex.test(tagText)) { s.appendRight(endOfStartTag, ` markerId="${generatedMarkerId}"`); console.log(`[create-search-index] Adding markerId="${generatedMarkerId}" to ${id}:${lineNumber}`); } else { console.log(`[create-search-index] markerId already exists in ${id}:${lineNumber}`); } } } } const newParent = node.type === 1 && node.tag === 'SearchMarker' ? node : currentParent; if (node.children && Array.isArray(node.children)) { node.children.forEach(child => traverse(child, newParent)); } } traverse(ast); // AST を traverse (1段階目: ID 生成と親子関係記録) // 2段階目: :children 属性の追加 // 最初に親マーカーごとに子マーカーIDを集約する処理を追加 const parentChildrenMap = new Map(); // 1. まず親ごとのすべての子マーカーIDを収集 markerRelations.forEach(relation => { if (relation.parentId) { if (!parentChildrenMap.has(relation.parentId)) { parentChildrenMap.set(relation.parentId, []); } parentChildrenMap.get(relation.parentId)?.push(relation.markerId); } }); // 2. 親ごとにまとめて :children 属性を処理 for (const [parentId, childIds] of parentChildrenMap.entries()) { const parentRelation = markerRelations.find(r => r.markerId === parentId); if (!parentRelation || !parentRelation.node) continue; const parentNode = parentRelation.node; const childrenProp = parentNode.props?.find((prop: any) => prop.type === 7 && prop.name === 'bind' && prop.arg?.content === 'children'); // 親ノードの開始位置を特定 const parentNodeStart = parentNode.loc.start.offset; const endOfParentStartTag = parentNode.children && parentNode.children.length > 0 ? code.lastIndexOf('>', parentNode.children[0].loc.start.offset) : code.indexOf('>', parentNodeStart); if (endOfParentStartTag === -1) continue; // 親タグのテキストを取得 const parentTagText = code.substring(parentNodeStart, endOfParentStartTag + 1); if (childrenProp) { // AST で :children 属性が検出された場合、それを更新 try { const childrenStart = code.indexOf('[', childrenProp.exp.loc.start.offset); const childrenEnd = code.indexOf(']', childrenProp.exp.loc.start.offset); if (childrenStart !== -1 && childrenEnd !== -1) { const childrenArrayStr = code.slice(childrenStart, childrenEnd + 1); let childrenArray = JSON5.parse(childrenArrayStr.replace(/'/g, '"')); // 新しいIDを追加(重複は除外) const newIds = childIds.filter(id => !childrenArray.includes(id)); if (newIds.length > 0) { childrenArray = [...childrenArray, ...newIds]; const updatedChildrenArrayStr = JSON5.stringify(childrenArray).replace(/"/g, "'"); s.overwrite(childrenStart, childrenEnd + 1, updatedChildrenArrayStr); console.log(`[create-search-index] Added ${newIds.length} child markerIds to existing :children in ${id}`); } } } catch (e) { console.error('[create-search-index] Error updating :children attribute:', e); } } else { // AST では検出されなかった場合、タグテキストを調べる const childrenRegex = /:children\s*=\s*["']\[(.*?)\]["']/; const childrenMatch = parentTagText.match(childrenRegex); if (childrenMatch) { // テキストから :children 属性値を解析して更新 try { const childrenContent = childrenMatch[1]; const childrenArrayStr = `[${childrenContent}]`; const childrenArray = JSON5.parse(childrenArrayStr.replace(/'/g, '"')); // 新しいIDを追加(重複は除外) const newIds = childIds.filter(id => !childrenArray.includes(id)); if (newIds.length > 0) { childrenArray.push(...newIds); // :children="[...]" の位置を特定して上書き const attrStart = parentTagText.indexOf(':children='); if (attrStart > -1) { const attrValueStart = parentTagText.indexOf('[', attrStart); const attrValueEnd = parentTagText.indexOf(']', attrValueStart) + 1; if (attrValueStart > -1 && attrValueEnd > -1) { const absoluteStart = parentNodeStart + attrValueStart; const absoluteEnd = parentNodeStart + attrValueEnd; const updatedArrayStr = JSON5.stringify(childrenArray).replace(/"/g, "'"); s.overwrite(absoluteStart, absoluteEnd, updatedArrayStr); console.log(`[create-search-index] Updated existing :children in tag text for ${id}`); } } } } catch (e) { console.error('[create-search-index] Error updating :children in tag text:', e); } } else { // :children 属性がまだない場合、新規作成 s.appendRight(endOfParentStartTag, ` :children="${JSON5.stringify(childIds).replace(/"/g, "'")}"`); console.log(`[create-search-index] Created new :children attribute with ${childIds.length} markerIds in ${id}`); } } } } const transformedCode = s.toString(); // 変換後のコードを取得 transformedCodeCache[id] = transformedCode; // 変換後のコードをキャッシュに保存 return { code: transformedCode, // 変更後のコードを返す map: s.generateMap({ source: id, includeContent: true }), // ソースマップも生成 (sourceMap: true が必要) }; } // Rollup プラグインとして export export default function pluginCreateSearchIndex(options: { targetFilePaths: string[], exportFilePath: string }): Plugin { let transformedCodeCache: Record = {}; // キャッシュオブジェクトをプラグインスコープで定義 const isDevServer = process.env.NODE_ENV === 'development'; // 開発サーバーかどうか return { name: 'createSearchIndex', enforce: 'pre', async buildStart() { if (!isDevServer) { return; } const filePaths = options.targetFilePaths.reduce((acc, filePathPattern) => { const matchedFiles = glob.sync(filePathPattern); return [...acc, ...matchedFiles]; }, []); for (const filePath of filePaths) { const id = path.resolve(filePath); // 絶対パスに変換 const code = fs.readFileSync(filePath, 'utf-8'); // ファイル内容を読み込む await processVueFile(code, id, options, transformedCodeCache); // processVueFile 関数を呼び出す } await analyzeVueProps({ ...options, transformedCodeCache }); // 開発サーバー起動時にも analyzeVueProps を実行 }, async transform(code, id) { if (!id.endsWith('.vue')) { return; } // targetFilePaths にマッチするファイルのみ処理を行う // glob パターンでマッチング 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; break; // マッチしたらループを抜ける } } if (isMatch) break; // いずれかのパターンでマッチしたら、outer loop も抜ける } if (!isMatch) { return; } const transformed = await processVueFile(code, id, options, transformedCodeCache); if (isDevServer) { await analyzeVueProps({ ...options, transformedCodeCache }); // analyzeVueProps を呼び出す } return transformed; }, async writeBundle() { await analyzeVueProps({ ...options, transformedCodeCache }); // ビルド時にも analyzeVueProps を実行 }, }; }