/* * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ import { parse as vueSfcParse } from 'vue/compiler-sfc'; import type { LogOptions, 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'; import { createLogger } from 'vite'; interface VueAstNode { type: number; tag?: string; loc?: { start: { offset: number, line: number, column: number }, end: { offset: number, line: number, column: number }, source?: string }; props?: Array<{ name: string; type: number; value?: { content?: string }; arg?: { content?: string }; exp?: { content?: string; loc?: any }; }>; children?: VueAstNode[]; content?: any; __markerId?: string; __children?: string[]; } export type AnalysisResult = { filePath: string; usage: SearchIndexItem[]; } export type SearchIndexItem = { id: string; path?: string; label: string; keywords: string | string[]; icon?: string; inlining?: string[]; children?: SearchIndexItem[]; }; export type Options = { targetFilePaths: string[], exportFilePath: string, verbose?: boolean, }; // 関連するノードタイプの定数化 const NODE_TYPES = { ELEMENT: 1, EXPRESSION: 2, TEXT: 3, INTERPOLATION: 5, // Mustache }; // マーカー関係を表す型 interface MarkerRelation { parentId?: string; markerId: string; node: VueAstNode; } // ロガー let logger = { info: (msg: string, options?: LogOptions) => { }, warn: (msg: string, options?: LogOptions) => { }, error: (msg: string, options?: LogOptions) => { }, }; let loggerInitialized = false; function initLogger(options: Options) { if (loggerInitialized) return; loggerInitialized = true; const viteLogger = createLogger(options.verbose ? 'info' : 'warn'); logger.info = (msg, options) => { msg = `[create-search-index] ${msg}`; viteLogger.info(msg, options); } logger.warn = (msg, options) => { msg = `[create-search-index] ${msg}`; viteLogger.warn(msg, options); } logger.error = (msg, options) => { msg = `[create-search-index] ${msg}`; viteLogger.error(msg, options); } } /** * 解析結果をTypeScriptファイルとして出力する */ function outputAnalysisResultAsTS(outputPath: string, analysisResults: AnalysisResult[]): void { logger.info(`Processing ${analysisResults.length} files for output`); // 新しいツリー構造を構築 const allMarkers = new Map(); // 1. すべてのマーカーを一旦フラットに収集 for (const file of analysisResults) { logger.info(`Processing file: ${file.filePath} with ${file.usage.length} markers`); for (const marker of file.usage) { if (marker.id) { // キーワードとchildren処理を共通化 const processedMarker = { ...marker, keywords: processMarkerProperty(marker.keywords, 'keywords'), children: processMarkerProperty(marker.children || [], 'children') }; allMarkers.set(marker.id, processedMarker); } } } logger.info(`Collected total ${allMarkers.size} unique markers`); // 2. 子マーカーIDの収集 const childIds = collectChildIds(allMarkers); logger.info(`Found ${childIds.size} child markers`); // 3. ルートマーカーの特定(他の誰かの子でないマーカー) const rootMarkers = identifyRootMarkers(allMarkers, childIds); logger.info(`Found ${rootMarkers.length} root markers`); // 4. 子マーカーの参照を解決 const resolvedRootMarkers = resolveChildReferences(rootMarkers, allMarkers); // 5. デバッグ情報を生成 const { totalMarkers, totalChildren } = countMarkers(resolvedRootMarkers); logger.info(`Total markers in tree: ${totalMarkers} (${resolvedRootMarkers.length} roots + ${totalChildren} nested children)`); // 6. 結果をTS形式で出力 writeOutputFile(outputPath, resolvedRootMarkers); } /** * マーカーのプロパティ(keywordsやchildren)を処理する */ function processMarkerProperty(propValue: any, propType: 'keywords' | 'children'): any { // 文字列の配列表現を解析 if (typeof propValue === 'string' && propValue.startsWith('[') && propValue.endsWith(']')) { try { // JSON5解析を試みる return JSON5.parse(propValue.replace(/'/g, '"')); } catch (e) { // 解析に失敗した場合 logger.warn(`Could not parse ${propType}: ${propValue}, using ${propType === 'children' ? 'empty array' : 'as is'}`); return propType === 'children' ? [] : propValue; } } return propValue; } /** * 全マーカーから子IDを収集する */ function collectChildIds(allMarkers: Map): Set { const childIds = new Set(); allMarkers.forEach((marker, id) => { // 通常のchildren処理 const children = marker.children; if (Array.isArray(children)) { children.forEach(childId => { if (typeof childId === 'string') { if (!allMarkers.has(childId)) { logger.warn(`Warning: Child marker ID ${childId} referenced but not found`); } else { childIds.add(childId); } } }); } // inlining処理を追加 if (marker.inlining) { let inliningIds: string[] = []; // 文字列の場合は配列に変換 if (typeof marker.inlining === 'string') { try { const inliningStr = (marker.inlining as string).trim(); if (inliningStr.startsWith('[') && inliningStr.endsWith(']')) { inliningIds = JSON5.parse(inliningStr.replace(/'/g, '"')); logger.info(`Parsed inlining string to array: ${inliningStr} -> ${JSON.stringify(inliningIds)}`); } else { inliningIds = [inliningStr]; } } catch (e) { logger.error(`Failed to parse inlining string: ${marker.inlining}`, e); } } // 既に配列の場合 else if (Array.isArray(marker.inlining)) { inliningIds = marker.inlining; } // inliningで指定されたIDを子セットに追加 for (const inlineId of inliningIds) { if (typeof inlineId === 'string') { if (!allMarkers.has(inlineId)) { logger.warn(`Warning: Inlining marker ID ${inlineId} referenced but not found`); } else { // inliningで参照されているマーカーも子として扱う childIds.add(inlineId); logger.info(`Added inlined marker ${inlineId} as child in collectChildIds`); } } } } }); return childIds; } /** * ルートマーカー(他の子でないマーカー)を特定する */ function identifyRootMarkers( allMarkers: Map, childIds: Set ): SearchIndexItem[] { const rootMarkers: SearchIndexItem[] = []; allMarkers.forEach((marker, id) => { if (!childIds.has(id)) { rootMarkers.push(marker); logger.info(`Added root marker to output: ${id} with label ${marker.label}`); } }); return rootMarkers; } /** * 子マーカーの参照をIDから実際のオブジェクトに解決する */ function resolveChildReferences( rootMarkers: SearchIndexItem[], allMarkers: Map ): SearchIndexItem[] { function resolveChildrenForMarker(marker: SearchIndexItem): SearchIndexItem { // マーカーのディープコピーを作成 const resolvedMarker = { ...marker }; // 明示的に子マーカー配列を作成 const resolvedChildren: SearchIndexItem[] = []; // 通常のchildren処理 if (Array.isArray(marker.children)) { for (const childId of marker.children) { if (typeof childId === 'string') { const childMarker = allMarkers.get(childId); if (childMarker) { // 子マーカーの子も再帰的に解決 const resolvedChild = resolveChildrenForMarker(childMarker); resolvedChildren.push(resolvedChild); logger.info(`Resolved regular child ${childId} for parent ${marker.id}`); } } } } // inlining属性の処理 let inliningIds: string[] = []; // 文字列の場合は配列に変換。例: "['2fa']" -> ['2fa'] if (typeof marker.inlining === 'string') { try { // 文字列形式の配列を実際の配列に変換 const inliningStr = (marker.inlining as string).trim(); if (inliningStr.startsWith('[') && inliningStr.endsWith(']')) { inliningIds = JSON5.parse(inliningStr.replace(/'/g, '"')); logger.info(`Converted string inlining to array: ${inliningStr} -> ${JSON.stringify(inliningIds)}`); } else { // 単一値の場合は配列に inliningIds = [inliningStr]; logger.info(`Converted single string inlining to array: ${inliningStr}`); } } catch (e) { logger.error(`Failed to parse inlining string: ${marker.inlining}`, e); } } // 既に配列の場合はそのまま使用 else if (Array.isArray(marker.inlining)) { inliningIds = marker.inlining; } // インライン指定されたマーカーを子として追加 for (const inlineId of inliningIds) { if (typeof inlineId === 'string') { const inlineMarker = allMarkers.get(inlineId); if (inlineMarker) { // インライン指定されたマーカーを再帰的に解決 const resolvedInline = resolveChildrenForMarker(inlineMarker); delete resolvedInline.path resolvedChildren.push(resolvedInline); logger.info(`Added inlined marker ${inlineId} as child to ${marker.id}`); } else { logger.warn(`Inlining target not found: ${inlineId} referenced by ${marker.id}`); } } } // 解決した子が存在する場合のみchildrenプロパティを設定 if (resolvedChildren.length > 0) { resolvedMarker.children = resolvedChildren; } else { delete resolvedMarker.children; } return resolvedMarker; } // すべてのルートマーカーの子を解決 return rootMarkers.map(marker => resolveChildrenForMarker(marker)); } /** * マーカー数を数える(デバッグ用) */ function countMarkers(markers: SearchIndexItem[]): { totalMarkers: number, totalChildren: number } { let totalMarkers = markers.length; let totalChildren = 0; function countNested(items: SearchIndexItem[]): void { for (const marker of items) { if (marker.children && Array.isArray(marker.children)) { totalChildren += marker.children.length; totalMarkers += marker.children.length; countNested(marker.children as SearchIndexItem[]); } } } countNested(markers); return { totalMarkers, totalChildren }; } /** * 最終的なTypeScriptファイルを出力 */ function writeOutputFile(outputPath: string, resolvedRootMarkers: SearchIndexItem[]): void { try { const tsOutput = generateTypeScriptCode(resolvedRootMarkers); fs.writeFileSync(outputPath, tsOutput, 'utf-8'); // 強制的に出力させるためにViteロガーを使わない console.log(`Successfully wrote search index to ${outputPath} with ${resolvedRootMarkers.length} root entries`); } catch (error) { logger.error('[create-search-index]: error writing output: ', error); } } /** * TypeScriptコード生成 */ function generateTypeScriptCode(resolvedRootMarkers: SearchIndexItem[]): string { return ` /* * 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 type SearchIndexItem = { id: string; path?: string; label: string; keywords: string[]; icon?: string; children?: SearchIndexItem[]; }; export const searchIndexes: SearchIndexItem[] = ${customStringify(resolvedRootMarkers)} as const; export type SearchIndex = typeof searchIndexes; `; } /** * オブジェクトを特殊な形式の文字列に変換する * i18n参照を保持しつつ適切な形式に変換 */ 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 => { // オブジェクト要素 if (typeof item === 'object' && item !== null) { return `${childIndent}${customStringify(item, depth + 1)}`; } // i18n参照を含む文字列要素 if (typeof item === 'string' && item.includes('i18n.ts.')) { return `${childIndent}${item}`; // クォートなしでそのまま出力 } // その他の要素 return `${childIndent}${JSON5.stringify(item)}`; }).join(',\n'); return `[\n${items},\n${indent}]`; } // null または非オブジェクト 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]) => { if (value === undefined) return false; if (key === 'children' && Array.isArray(value) && value.length === 0) return false; if (key === 'inlining') return false; return true; }) // 各プロパティを変換 .map(([key, value]) => { // 子要素配列の特殊処理 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}}`; } /** * 特殊プロパティの書式設定 */ function formatSpecialProperty(key: string, value: any): string { // 値がundefinedの場合は空文字列を返す if (value === undefined) { return '""'; } // childrenが配列の場合は特別に処理 if (key === 'children' && Array.isArray(value)) { return customStringify(value); } // keywordsが配列の場合、特別に処理 if (key === 'keywords' && Array.isArray(value)) { return `[${formatArrayForOutput(value)}]`; } // 文字列値の場合の特別処理 if (typeof value === 'string') { // i18n.ts 参照を含む場合 - クォートなしでそのまま出力 if (isI18nReference(value)) { logger.info(`Preserving i18n reference in output: ${value}`); return value; } // keywords が配列リテラルの形式の場合 if (key === 'keywords' && value.startsWith('[') && value.endsWith(']')) { return value; } } // 上記以外は通常の JSON5 文字列として返す return JSON5.stringify(value); } /** * 配列式の文字列表現を生成 */ function formatArrayForOutput(items: any[]): string { return items.map(item => { // i18n.ts. 参照の文字列はそのままJavaScript式として出力 if (typeof item === 'string' && isI18nReference(item)) { logger.info(`Preserving i18n reference in array: ${item}`); return item; // クォートなしでそのまま } // その他の値はJSON5形式で文字列化 return JSON5.stringify(item); }).join(', '); } /** * 要素ノードからテキスト内容を抽出する * 各抽出方法を分離して可読性を向上 */ function extractElementText(node: VueAstNode): string | null { if (!node) return null; logger.info(`Extracting text from node type=${node.type}, tag=${node.tag || 'unknown'}`); // 1. 直接コンテンツの抽出を試行 const directContent = extractDirectContent(node); if (directContent) return directContent; // 子要素がない場合は終了 if (!node.children || !Array.isArray(node.children)) { return null; } // 2. インターポレーションノードを検索 const interpolationContent = extractInterpolationContent(node.children); if (interpolationContent) return interpolationContent; // 3. 式ノードを検索 const expressionContent = extractExpressionContent(node.children); if (expressionContent) return expressionContent; // 4. テキストノードを検索 const textContent = extractTextContent(node.children); if (textContent) return textContent; // 5. 再帰的に子ノードを探索 return extractNestedContent(node.children); } /** * ノードから直接コンテンツを抽出 */ function extractDirectContent(node: VueAstNode): string | null { if (!node.content) return null; const content = typeof node.content === 'string' ? node.content.trim() : (node.content.content ? node.content.content.trim() : null); if (!content) return null; logger.info(`Direct node content found: ${content}`); // Mustache構文のチェック const mustachePattern = /^\s*{{\s*(.*?)\s*}}\s*$/; const mustacheMatch = content.match(mustachePattern); if (mustacheMatch && mustacheMatch[1] && isI18nReference(mustacheMatch[1])) { const extractedContent = mustacheMatch[1].trim(); logger.info(`Extracted i18n reference from mustache: ${extractedContent}`); return extractedContent; } // 直接i18n参照を含む場合 if (isI18nReference(content)) { logger.info(`Direct i18n reference found: ${content}`); return content; } // その他のコンテンツ return content; } /** * インターポレーションノード(Mustache)からコンテンツを抽出 */ function extractInterpolationContent(children: VueAstNode[]): string | null { for (const child of children) { if (child.type === NODE_TYPES.INTERPOLATION) { logger.info(`Found interpolation node (Mustache): ${JSON.stringify(child.content).substring(0, 100)}...`); if (child.content && child.content.type === 4 && child.content.content) { const content = child.content.content.trim(); logger.info(`Interpolation content: ${content}`); if (isI18nReference(content)) { return content; } } else if (child.content && typeof child.content === 'object') { // オブジェクト形式のcontentを探索 logger.info(`Complex interpolation node: ${JSON.stringify(child.content).substring(0, 100)}...`); if (child.content.content) { const content = child.content.content.trim(); if (isI18nReference(content)) { logger.info(`Found i18n reference in complex interpolation: ${content}`); return content; } } } } } return null; } /** * 式ノードからコンテンツを抽出 */ function extractExpressionContent(children: VueAstNode[]): string | null { // i18n.ts. 参照パターンを持つものを優先 for (const child of children) { if (child.type === NODE_TYPES.EXPRESSION && child.content) { const expr = child.content.trim(); if (isI18nReference(expr)) { logger.info(`Found i18n reference in expression node: ${expr}`); return expr; } } } // その他の式 for (const child of children) { if (child.type === NODE_TYPES.EXPRESSION && child.content) { const expr = child.content.trim(); logger.info(`Found expression: ${expr}`); return expr; } } return null; } /** * テキストノードからコンテンツを抽出 */ function extractTextContent(children: VueAstNode[]): string | null { for (const child of children) { if (child.type === NODE_TYPES.TEXT && child.content) { const text = child.content.trim(); if (text) { logger.info(`Found text node: ${text}`); // Mustache構文のチェック const mustachePattern = /^\s*{{\s*(.*?)\s*}}\s*$/; const mustacheMatch = text.match(mustachePattern); if (mustacheMatch && mustacheMatch[1] && isI18nReference(mustacheMatch[1])) { logger.info(`Extracted i18n ref from text mustache: ${mustacheMatch[1]}`); return mustacheMatch[1].trim(); } return text; } } } return null; } /** * 子ノードを再帰的に探索してコンテンツを抽出 */ function extractNestedContent(children: VueAstNode[]): string | null { for (const child of children) { if (child.children && Array.isArray(child.children) && child.children.length > 0) { const nestedContent = extractElementText(child); if (nestedContent) { logger.info(`Found nested content: ${nestedContent}`); return nestedContent; } } else if (child.type === NODE_TYPES.ELEMENT) { // childrenがなくても内部を調査 const nestedContent = extractElementText(child); if (nestedContent) { logger.info(`Found content in childless element: ${nestedContent}`); return nestedContent; } } } return null; } /** * SearchLabelとSearchKeywordを探して抽出する関数 */ function extractLabelsAndKeywords(nodes: VueAstNode[]): { label: string | null, keywords: any[] } { let label: string | null = null; const keywords: any[] = []; logger.info(`Extracting labels and keywords from ${nodes.length} nodes`); // 再帰的にSearchLabelとSearchKeywordを探索(ネストされたSearchMarkerは処理しない) function findComponents(nodes: VueAstNode[]) { for (const node of nodes) { if (node.type === NODE_TYPES.ELEMENT) { logger.info(`Checking element: ${node.tag}`); // SearchMarkerの場合は、その子要素は別スコープなのでスキップ if (node.tag === 'SearchMarker') { logger.info(`Found nested SearchMarker - skipping its content to maintain scope isolation`); continue; // このSearchMarkerの中身は処理しない (スコープ分離) } // SearchLabelの処理 if (node.tag === 'SearchLabel') { logger.info(`Found SearchLabel node, structure: ${JSON.stringify(node).substring(0, 200)}...`); // まず完全なノード内容の抽出を試みる const content = extractElementText(node); if (content) { label = content; logger.info(`SearchLabel content extracted: ${content}`); } else { logger.info(`SearchLabel found but extraction failed, trying direct children inspection`); // バックアップ: 子直接確認 - type=5のMustacheインターポレーションを重点的に確認 if (node.children && Array.isArray(node.children)) { for (const child of node.children) { // Mustacheインターポレーション if (child.type === NODE_TYPES.INTERPOLATION && child.content) { // content内の式を取り出す const expression = child.content.content || (child.content.type === 4 ? child.content.content : null) || JSON.stringify(child.content); logger.info(`Interpolation expression: ${expression}`); if (typeof expression === 'string' && isI18nReference(expression)) { label = expression.trim(); logger.info(`Found i18n in interpolation: ${label}`); break; } } // 式ノード else if (child.type === NODE_TYPES.EXPRESSION && child.content && isI18nReference(child.content)) { label = child.content.trim(); logger.info(`Found i18n in expression: ${label}`); break; } // テキストノードでもMustache構文を探す else if (child.type === NODE_TYPES.TEXT && child.content) { const mustacheMatch = child.content.trim().match(/^\s*{{\s*(.*?)\s*}}\s*$/); if (mustacheMatch && mustacheMatch[1] && isI18nReference(mustacheMatch[1])) { label = mustacheMatch[1].trim(); logger.info(`Found i18n in text mustache: ${label}`); break; } } } } } } // SearchKeywordの処理 else if (node.tag === 'SearchKeyword') { logger.info(`Found SearchKeyword node`); // まず完全なノード内容の抽出を試みる const content = extractElementText(node); if (content) { keywords.push(content); logger.info(`SearchKeyword content extracted: ${content}`); } else { logger.info(`SearchKeyword found but extraction failed, trying direct children inspection`); // バックアップ: 子直接確認 - type=5のMustacheインターポレーションを重点的に確認 if (node.children && Array.isArray(node.children)) { for (const child of node.children) { // Mustacheインターポレーション if (child.type === NODE_TYPES.INTERPOLATION && child.content) { // content内の式を取り出す const expression = child.content.content || (child.content.type === 4 ? child.content.content : null) || JSON.stringify(child.content); logger.info(`Keyword interpolation: ${expression}`); if (typeof expression === 'string' && isI18nReference(expression)) { const keyword = expression.trim(); keywords.push(keyword); logger.info(`Found i18n keyword in interpolation: ${keyword}`); break; } } // 式ノード else if (child.type === NODE_TYPES.EXPRESSION && child.content && isI18nReference(child.content)) { const keyword = child.content.trim(); keywords.push(keyword); logger.info(`Found i18n keyword in expression: ${keyword}`); break; } // テキストノードでもMustache構文を探す else if (child.type === NODE_TYPES.TEXT && child.content) { const mustacheMatch = child.content.trim().match(/^\s*{{\s*(.*?)\s*}}\s*$/); if (mustacheMatch && mustacheMatch[1] && isI18nReference(mustacheMatch[1])) { const keyword = mustacheMatch[1].trim(); keywords.push(keyword); logger.info(`Found i18n keyword in text mustache: ${keyword}`); break; } } } } } } // 子要素を再帰的に調査(ただしSearchMarkerは除外) if (node.children && Array.isArray(node.children)) { findComponents(node.children); } } } } findComponents(nodes); // デバッグ情報 logger.info(`Extraction completed: label=${label}, keywords=[${keywords.join(', ')}]`); return { label, keywords }; } function extractUsageInfoFromTemplateAst( templateAst: any, id: string, ): SearchIndexItem[] { const allMarkers: SearchIndexItem[] = []; const markerMap = new Map(); const childrenIds = new Set(); const normalizedId = id.replace(/\\/g, '/'); if (!templateAst) return allMarkers; // マーカーの基本情報を収集 function collectMarkers(node: VueAstNode, parentId: string | null = null) { if (node.type === 1 && node.tag === 'SearchMarker') { // マーカーID取得 const markerIdProp = node.props?.find((p: any) => p.name === 'markerId'); const markerId = markerIdProp?.value?.content || node.__markerId; // SearchMarkerにマーカーIDがない場合はエラー if (markerId == null) { logger.error(`Marker ID not found for node: ${JSON.stringify(node)}`); throw new Error(`Marker ID not found in file ${id}`); } // マーカー基本情報 const markerInfo: SearchIndexItem = { id: markerId, children: [], label: '', // デフォルト値 keywords: [], }; // 静的プロパティを取得 if (node.props && Array.isArray(node.props)) { for (const prop of node.props) { 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 || ''; } } } // バインドプロパティを取得 const bindings = extractNodeBindings(node); if (bindings.path) markerInfo.path = bindings.path; if (bindings.icon) markerInfo.icon = bindings.icon; if (bindings.label) markerInfo.label = bindings.label; if (bindings.children) markerInfo.children = bindings.children; if (bindings.inlining) { markerInfo.inlining = bindings.inlining; logger.info(`Added inlining ${JSON.stringify(bindings.inlining)} to marker ${markerId}`); } if (bindings.keywords) { if (Array.isArray(bindings.keywords)) { markerInfo.keywords = bindings.keywords; } else { markerInfo.keywords = bindings.keywords || []; } } //pathがない場合はファイルパスを設定 if (markerInfo.path == null && parentId == null) { markerInfo.path = normalizedId.match(/.*(\/(admin|settings)\/[^\/]+)\.vue$/)?.[1]; } // SearchLabelとSearchKeywordを抽出 (AST全体を探索) if (node.children && Array.isArray(node.children)) { logger.info(`Processing marker ${markerId} for labels and keywords`); const extracted = extractLabelsAndKeywords(node.children); // SearchLabelからのラベル取得は最優先で適用 if (extracted.label) { markerInfo.label = extracted.label; logger.info(`Using extracted label for ${markerId}: ${extracted.label}`); } else if (markerInfo.label) { logger.info(`Using existing label for ${markerId}: ${markerInfo.label}`); } else { markerInfo.label = 'Unnamed marker'; logger.info(`No label found for ${markerId}, using default`); } // SearchKeywordからのキーワード取得を追加 if (extracted.keywords.length > 0) { const existingKeywords = Array.isArray(markerInfo.keywords) ? [...markerInfo.keywords] : (markerInfo.keywords ? [markerInfo.keywords] : []); // i18n参照のキーワードは最優先で追加 const combinedKeywords = [...existingKeywords]; for (const kw of extracted.keywords) { combinedKeywords.push(kw); logger.info(`Added extracted keyword to ${markerId}: ${kw}`); } markerInfo.keywords = combinedKeywords; } } // マーカーを登録 markerMap.set(markerId, markerInfo); allMarkers.push(markerInfo); // 親子関係を記録 if (parentId) { const parent = markerMap.get(parentId); if (parent) { childrenIds.add(markerId); } } // 子ノードを処理 if (node.children && Array.isArray(node.children)) { node.children.forEach((child: VueAstNode) => { collectMarkers(child, markerId); }); } return markerId; } // SearchMarkerでない場合は再帰的に子ノードを処理 else if (node.children && Array.isArray(node.children)) { node.children.forEach((child: VueAstNode) => { collectMarkers(child, parentId); }); } return null; } // AST解析開始 collectMarkers(templateAst); return allMarkers; } // バインドプロパティの処理を修正する関数 function extractNodeBindings(node: VueAstNode): Record { const bindings: Record = {}; if (!node.props || !Array.isArray(node.props)) return bindings; // バインド式を収集 for (const prop of node.props) { if (prop.type === 7 && prop.name === 'bind' && prop.arg?.content) { const propName = prop.arg.content; const propContent = prop.exp?.content || ''; logger.info(`Processing bind prop ${propName}: ${propContent}`); // inliningプロパティの処理を追加 if (propName === 'inlining') { try { const content = propContent.trim(); // 配列式の場合 if (content.startsWith('[') && content.endsWith(']')) { // 配列要素を解析 const elements = parseArrayExpression(content); if (elements.length > 0) { bindings.inlining = elements; logger.info(`Parsed inlining array: ${JSON5.stringify(elements)}`); } else { bindings.inlining = []; } } // 文字列の場合は配列に変換 else if (content) { bindings.inlining = [content]; // 単一の値を配列に logger.info(`Converting inlining to array: [${content}]`); } } catch (e) { logger.error(`Failed to parse inlining binding: ${propContent}`, e); } } // keywordsの特殊処理 if (propName === 'keywords') { try { const content = propContent.trim(); // 配列式の場合 if (content.startsWith('[') && content.endsWith(']')) { // i18n参照や特殊な式を保持するため、各要素を個別に解析 const elements = parseArrayExpression(content); if (elements.length > 0) { bindings.keywords = elements; logger.info(`Parsed keywords array: ${JSON5.stringify(elements)}`); } else { bindings.keywords = []; logger.info('Empty keywords array'); } } // その他の式(非配列) else if (content) { bindings.keywords = content; // 式をそのまま保持 logger.info(`Keeping keywords as expression: ${content}`); } else { bindings.keywords = []; logger.info('No keywords provided'); } } catch (e) { logger.error(`Failed to parse keywords binding: ${propContent}`, e); // エラーが起きても何らかの値を設定 bindings.keywords = propContent || []; } } // その他のプロパティ else if (propName === 'label') { // ラベルの場合も式として保持 bindings[propName] = propContent; logger.info(`Set label from bind expression: ${propContent}`); } else { bindings[propName] = propContent; } } } return bindings; } // 配列式をパースする補助関数(文字列リテラル処理を改善) function parseArrayExpression(expr: string): any[] { try { // 単純なケースはJSON5でパースを試みる return JSON5.parse(expr.replace(/'/g, '"')); } catch (e) { // 複雑なケース(i18n.ts.xxx などの式を含む場合)は手動パース logger.info(`Complex array expression, trying manual parsing: ${expr}`); // "["と"]"を取り除く const content = expr.substring(1, expr.length - 1).trim(); if (!content) return []; const result: any[] = []; let currentItem = ''; let depth = 0; let inString = false; let stringChar = ''; // カンマで区切る(ただし文字列内や入れ子の配列内のカンマは無視) for (let i = 0; i < content.length; i++) { const char = content[i]; if (inString) { if (char === stringChar && content[i - 1] !== '\\') { inString = false; } currentItem += char; } else if (char === '"' || char === "'") { inString = true; stringChar = char; currentItem += char; } else if (char === '[') { depth++; currentItem += char; } else if (char === ']') { depth--; currentItem += char; } else if (char === ',' && depth === 0) { // 項目の区切りを検出 const trimmed = currentItem.trim(); // 純粋な文字列リテラルの場合、実際の値に変換 if ((trimmed.startsWith("'") && trimmed.endsWith("'")) || (trimmed.startsWith('"') && trimmed.endsWith('"'))) { try { result.push(JSON5.parse(trimmed)); } catch (err) { result.push(trimmed); } } else { // それ以外の式はそのまま(i18n.ts.xxx など) result.push(trimmed); } currentItem = ''; } else { currentItem += char; } } // 最後の項目を処理 if (currentItem.trim()) { const trimmed = currentItem.trim(); // 純粋な文字列リテラルの場合、実際の値に変換 if ((trimmed.startsWith("'") && trimmed.endsWith("'")) || (trimmed.startsWith('"') && trimmed.endsWith('"'))) { try { result.push(JSON5.parse(trimmed)); } catch (err) { result.push(trimmed); } } else { // それ以外の式はそのまま(i18n.ts.xxx など) result.push(trimmed); } } logger.info(`Parsed complex array expression: ${expr} -> ${JSON.stringify(result)}`); return result; } } export async function analyzeVueProps(options: Options & { transformedCodeCache: Record, }): Promise { initLogger(options); const allMarkers: SearchIndexItem[] = []; // 対象ファイルパスを glob で展開 const filePaths = options.targetFilePaths.reduce((acc, filePathPattern) => { const matchedFiles = glob.sync(filePathPattern); return [...acc, ...matchedFiles]; }, []); logger.info(`Found ${filePaths.length} matching files to analyze`); for (const filePath of filePaths) { const absolutePath = path.join(process.cwd(), filePath); const id = absolutePath.replace(/\\/g, '/'); // 絶対パスに変換 const code = options.transformedCodeCache[id]; // options 経由でキャッシュ参照 if (!code) { // キャッシュミスの場合 logger.error(`Error: No cached code found for: ${id}.`); // エラーログ throw new Error(`No cached code found for: ${id}.`); // エラーを投げる } try { const { descriptor, errors } = vueSfcParse(options.transformedCodeCache[id], { filename: filePath, }); if (errors.length > 0) { logger.error(`Compile Error: ${filePath}, ${errors}`); continue; // エラーが発生したファイルはスキップ } const fileMarkers = extractUsageInfoFromTemplateAst(descriptor.template?.ast, id); if (fileMarkers && fileMarkers.length > 0) { allMarkers.push(...fileMarkers); // すべてのマーカーを収集 logger.info(`Successfully extracted ${fileMarkers.length} markers from ${filePath}`); } else { logger.info(`No markers found in ${filePath}`); } } catch (error) { logger.error(`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: VueAstNode; } async function processVueFile( code: string, id: string, options: Options, transformedCodeCache: Record ): Promise<{ code: string, map: any, transformedCodeCache: Record }> { const normalizedId = id.replace(/\\/g, '/'); // ファイルパスを正規化 // すでにキャッシュに存在する場合は、そのまま返す if (transformedCodeCache[normalizedId] && transformedCodeCache[normalizedId].includes('markerId=')) { logger.info(`Using cached version for ${id}`); return { code: transformedCodeCache[normalizedId], map: null, transformedCodeCache }; } const s = new MagicString(code); // magic-string のインスタンスを作成 const parsed = vueSfcParse(code, { filename: id }); if (!parsed.descriptor.template) { return { code, map: null, transformedCodeCache }; } 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 idKey = id.replace(/\\/g, '/').split('packages/frontend/')[1] const generatedMarkerId = toBase62(hash(`${idKey}:${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}" data-in-app-search-marker-id="${generatedMarkerId}"`); logger.info(`Adding markerId="${generatedMarkerId}" to ${id}:${lineNumber}`); } else { logger.info(`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); logger.info(`Added ${newIds.length} child markerIds to existing :children in ${id}`); } } } catch (e) { logger.error('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); logger.info(`Updated existing :children in tag text for ${id}`); } } } } catch (e) { logger.error('Error updating :children in tag text:', e); } } else { // :children 属性がまだない場合、新規作成 s.appendRight(endOfParentStartTag, ` :children="${JSON5.stringify(childIds).replace(/"/g, "'")}"`); logger.info(`Created new :children attribute with ${childIds.length} markerIds in ${id}`); } } } } const transformedCode = s.toString(); // 変換後のコードを取得 transformedCodeCache[normalizedId] = transformedCode; // 変換後のコードをキャッシュに保存 return { code: transformedCode, // 変更後のコードを返す map: s.generateMap({ source: id, includeContent: true }), // ソースマップも生成 (sourceMap: true が必要) transformedCodeCache // キャッシュも返す }; } // Rollup プラグインとして export export default function pluginCreateSearchIndex(options: Options): Plugin { let transformedCodeCache: Record = {}; // キャッシュオブジェクトをプラグインスコープで定義 const isDevServer = process.env.NODE_ENV === 'development'; // 開発サーバーかどうか initLogger(options); // ロガーを初期化 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'); // ファイル内容を読み込む const { transformedCodeCache: newCache } = await processVueFile(code, id, options, transformedCodeCache); // processVueFile 関数を呼び出す transformedCodeCache = newCache; // キャッシュを更新 } 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); transformedCodeCache = transformed.transformedCodeCache; // キャッシュを更新 if (isDevServer) { await analyzeVueProps({ ...options, transformedCodeCache }); // analyzeVueProps を呼び出す } return transformed; }, async writeBundle() { await analyzeVueProps({ ...options, transformedCodeCache }); // ビルド時にも analyzeVueProps を実行 }, }; } // i18n参照を検出するためのヘルパー関数を追加 function isI18nReference(text: string | null | undefined): boolean { if (!text) return false; // ドット記法(i18n.ts.something) const dotPattern = /i18n\.ts\.\w+/; // ブラケット記法(i18n.ts['something']) const bracketPattern = /i18n\.ts\[['"][^'"]+['"]\]/; return dotPattern.test(text) || bracketPattern.test(text); }