misskey/packages/frontend/lib/vite-plugin-create-search-i...

1216 lines
44 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. 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 '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';
export type AnalysisResult = {
filePath: string;
usage: SearchIndexItem[];
}
export type SearchIndexItem = {
id: string;
path?: string;
label: string;
keywords: string | string[];
icon?: string;
children?: (SearchIndexItem[] | string);
}
const logger = createLogger();
const loggerInfo = logger.info;
const loggerWarn = logger.warn;
const loggerError = logger.error;
logger.info = (msg, options) => {
msg = `[create-search-index] ${msg}`;
loggerInfo(msg, options);
}
logger.warn = (msg, options) => {
msg = `[create-search-index] ${msg}`;
loggerWarn(msg, options);
}
logger.error = (msg, options) => {
msg = `[create-search-index] ${msg}`;
loggerError(msg, options);
}
function outputAnalysisResultAsTS(outputPath: string, analysisResults: AnalysisResult[]): void {
logger.info(`Processing ${analysisResults.length} files for output`);
// 新しいツリー構造を構築
const allMarkers = new Map<string, SearchIndexItem>();
// 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) {
// キーワードの処理(文字列から配列へ変換)
let keywords = marker.keywords;
if (typeof keywords === 'string' && keywords.startsWith('[') && keywords.endsWith(']')) {
try {
// JSON5解析を試みるただしi18n参照などがある場合は例外発生
keywords = JSON5.parse(keywords.replace(/'/g, '"'));
} catch (e) {
// 解析に失敗した場合は文字列のままにする
logger.warn(`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) {
// 解析に失敗した場合は空配列に
logger.warn(`Could not parse children: ${children}, using empty array`);
children = [];
}
}
// 子マーカーの内部構造を適切に更新
if (Array.isArray(children) && children.length > 0) {
logger.info(`Marker ${marker.id} has ${children.length} children: ${JSON.stringify(children)}`);
}
allMarkers.set(marker.id, {
...marker,
keywords,
children: Array.isArray(children) ? children : []
});
}
}
}
logger.info(`Collected total ${allMarkers.size} unique markers`);
// 2. 子マーカーIDの収集
const childIds = new Set<string>();
allMarkers.forEach((marker, id) => {
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);
}
}
});
}
});
logger.info(`Found ${childIds.size} child markers`);
// 3. ルートマーカーの特定(他の誰かの子でないマーカー)
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}`);
}
});
logger.info(`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);
logger.info(`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);
});
// 特殊なプロパティ変換用の関数 - i18n参照処理を強化
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 (value.includes('i18n.ts.')) {
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);
}
// オブジェクトをカスタム形式に変換する関数 - 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;
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}}`;
}
// 配列式の文字列表現を修正 - i18n参照を適切に処理
function formatArrayForOutput(items: any[]): string {
return items.map(item => {
// i18n.ts. 参照の文字列はそのままJavaScript式として出力
if (typeof item === 'string' && item.includes('i18n.ts.')) {
logger.info(`Preserving i18n reference in array: ${item}`);
return item; // クォートなしでそのまま
}
// その他の値はJSON5形式で文字列化
return JSON5.stringify(item);
}).join(', ');
}
// 最終出力用のデバッグ情報を生成
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);
logger.info(`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.
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;
`;
try {
fs.writeFileSync(outputPath, tsOutput, 'utf-8');
logger.info(`Successfully wrote search index to ${outputPath} with ${resolvedRootMarkers.length} root entries and ${totalChildren} nested children`);
} catch (error) {
logger.error('[create-search-index]: error: ', error);
}
}
// 要素ノードからテキスト内容を抽出する関数 (Mustache構文のi18n参照にも対応)
function extractElementText(node: any): string | null {
if (!node) return null;
console.log(`Extracting text from node type=${node.type}, tag=${node.tag || 'unknown'}`);
// Mustache構文を検出するための正規表現パターン
const mustachePattern = /^\s*{{\s*(.*?)\s*}}\s*$/;
// childrenが配列でない場合、content直接チェック (インラインテキスト対応)
if (node.content) {
const content = node.content.trim();
console.log(`Direct node content found: ${content}`);
// Mustache構文のチェック
const mustacheMatch = content.match(mustachePattern);
if (mustacheMatch && mustacheMatch[1] && mustacheMatch[1].includes('i18n.ts.')) {
const extractedContent = mustacheMatch[1].trim();
console.log(`Extracted i18n reference from mustache: ${extractedContent}`);
return extractedContent;
}
// 直接i18n参照を含む場合
if (content.includes('i18n.ts.')) {
console.log(`Direct i18n reference found: ${content}`);
return content;
}
// その他のコンテンツ
if (content) {
return content;
}
}
// childrenがない場合は終了
if (!node.children || !Array.isArray(node.children)) {
return null;
}
// Mustacheテンプレート構文の特殊ード検出 (type=5はインターポレーション)
for (const child of node.children) {
if (child.type === 5) { // インターポレーションノード (Mustache表現)
console.log(`Found interpolation node (Mustache): `, child);
if (child.content && child.content.type === 4 && child.content.content) {
const content = child.content.content.trim();
console.log(`Interpolation content: ${content}`);
if (content.includes('i18n.ts.')) {
return content;
}
} else if (child.content && typeof child.content === 'object') {
// オブジェクト形式のcontentを再帰的に探索
console.log(`Complex interpolation node:`, JSON.stringify(child.content).substring(0, 100));
if (child.content.content) {
const content = child.content.content.trim();
if (content.includes('i18n.ts.')) {
console.log(`Found i18n reference in complex interpolation: ${content}`);
return content;
}
}
}
}
}
// 最初のパスで i18n.ts. 参照パターンを持つものを探す (最優先)
for (const child of node.children) {
if (child.type === 2 && child.content) { // 式ノード
const expr = child.content.trim();
if (expr.includes('i18n.ts.')) {
console.log(`Found i18n reference in expression node: ${expr}`);
return expr; // i18n参照を見つけたら即座に返す
}
}
}
// 2回目のパスで一般的な式を探す
for (const child of node.children) {
if (child.type === 2 && child.content) { // その他の式ノード
const expr = child.content.trim();
console.log(`Found expression: ${expr}`);
return expr;
}
}
// 3回目のパスでテキストードを探す
for (const child of node.children) {
if (child.type === 3 && child.content) { // テキストノード
const text = child.content.trim();
if (text) {
console.log(`Found text node: ${text}`);
// Mustache構文のチェック
const mustacheMatch = text.match(mustachePattern);
if (mustacheMatch && mustacheMatch[1] && mustacheMatch[1].includes('i18n.ts.')) {
console.log(`Extracted i18n ref from text mustache: ${mustacheMatch[1]}`);
return mustacheMatch[1].trim();
}
return text;
}
}
}
// 深さ優先で再帰的に探索 (子の子まで調べる)
for (const child of node.children) {
if (child.children && Array.isArray(child.children) && child.children.length > 0) {
const nestedContent = extractElementText(child);
if (nestedContent) {
console.log(`Found nested content: ${nestedContent}`);
return nestedContent;
}
} else if (child.type === 1) { // 子要素ノード - childrenがなくても内部を調査
const nestedContent = extractElementText(child);
if (nestedContent) {
console.log(`Found content in childless element: ${nestedContent}`);
return nestedContent;
}
}
}
return null;
}
// SearchLabelとSearchKeywordを探して抽出する関数 (スコープを適切に分離)
function extractLabelsAndKeywords(nodes: any[]): { label: string | null, keywords: any[] } {
let label: string | null = null;
const keywords: any[] = [];
console.log(`Extracting labels and keywords from ${nodes.length} nodes`);
// 再帰的にSearchLabelとSearchKeywordを探索ネストされたSearchMarkerは処理しない
function findComponents(nodes: any[]) {
for (const node of nodes) {
if (node.type === 1) { // Element node
console.log(`Checking element: ${node.tag}`);
// SearchMarkerの場合は、その子要素は別スコープなのでスキップ
if (node.tag === 'SearchMarker') {
console.log(`Found nested SearchMarker - skipping its content to maintain scope isolation`);
continue; // このSearchMarkerの中身は処理しない (スコープ分離)
}
// SearchLabelの処理
if (node.tag === 'SearchLabel') {
console.log(`Found SearchLabel node, structure:`, JSON.stringify(node).substring(0, 200) + '...');
// まず完全なノード内容の抽出を試みる
const content = extractElementText(node);
if (content) {
label = content;
console.log(`SearchLabel content extracted: ${content}`);
} else {
console.log(`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 === 5 && child.content) {
// content内の式を取り出す
const expression = child.content.content ||
(child.content.type === 4 ? child.content.content : null) ||
JSON.stringify(child.content);
console.log(`Interpolation expression: ${expression}`);
if (typeof expression === 'string' && expression.includes('i18n.ts.')) {
label = expression.trim();
console.log(`Found i18n in interpolation: ${label}`);
break;
}
}
// 式ノード
else if (child.type === 2 && child.content && child.content.includes('i18n.ts.')) {
label = child.content.trim();
console.log(`Found i18n in expression: ${label}`);
break;
}
// テキストードでもMustache構文を探す
else if (child.type === 3 && child.content) {
const mustacheMatch = child.content.trim().match(/^\s*{{\s*(.*?)\s*}}\s*$/);
if (mustacheMatch && mustacheMatch[1] && mustacheMatch[1].includes('i18n.ts.')) {
label = mustacheMatch[1].trim();
console.log(`Found i18n in text mustache: ${label}`);
break;
}
}
}
}
}
}
// SearchKeywordの処理
else if (node.tag === 'SearchKeyword') {
console.log(`Found SearchKeyword node`);
// まず完全なノード内容の抽出を試みる
const content = extractElementText(node);
if (content) {
keywords.push(content);
console.log(`SearchKeyword content extracted: ${content}`);
} else {
console.log(`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 === 5 && child.content) {
// content内の式を取り出す
const expression = child.content.content ||
(child.content.type === 4 ? child.content.content : null) ||
JSON.stringify(child.content);
console.log(`Keyword interpolation: ${expression}`);
if (typeof expression === 'string' && expression.includes('i18n.ts.')) {
const keyword = expression.trim();
keywords.push(keyword);
console.log(`Found i18n keyword in interpolation: ${keyword}`);
break;
}
}
// 式ノード
else if (child.type === 2 && child.content && child.content.includes('i18n.ts.')) {
const keyword = child.content.trim();
keywords.push(keyword);
console.log(`Found i18n keyword in expression: ${keyword}`);
break;
}
// テキストードでもMustache構文を探す
else if (child.type === 3 && child.content) {
const mustacheMatch = child.content.trim().match(/^\s*{{\s*(.*?)\s*}}\s*$/);
if (mustacheMatch && mustacheMatch[1] && mustacheMatch[1].includes('i18n.ts.')) {
const keyword = mustacheMatch[1].trim();
keywords.push(keyword);
console.log(`Found i18n keyword in text mustache: ${keyword}`);
break;
}
}
}
}
}
}
// 子要素を再帰的に調査ただしSearchMarkerは除外
if (node.children && Array.isArray(node.children)) {
findComponents(node.children);
}
}
}
}
findComponents(nodes);
// デバッグ情報
console.log(`Extraction completed: label=${label}, keywords=[${keywords.join(', ')}]`);
return { label, keywords };
}
function extractUsageInfoFromTemplateAst(
templateAst: any,
code: string,
): SearchIndexItem[] {
const allMarkers: SearchIndexItem[] = [];
const markerMap = new Map<string, SearchIndexItem>();
const childrenIds = new Set<string>();
if (!templateAst) return allMarkers;
// マーカーの基本情報を収集
function collectMarkers(node: any, 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 ||
`marker-${Math.random().toString(36).substring(2, 10)}`;
// マーカー基本情報
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.keywords) {
if (Array.isArray(bindings.keywords)) {
markerInfo.keywords = bindings.keywords;
} else {
markerInfo.keywords = bindings.keywords || [];
}
}
// SearchLabelとSearchKeywordを抽出 (AST全体を探索)
if (node.children && Array.isArray(node.children)) {
console.log(`Processing marker ${markerId} for labels and keywords`);
const extracted = extractLabelsAndKeywords(node.children);
// SearchLabelからのラベル取得は最優先で適用
if (extracted.label) {
markerInfo.label = extracted.label;
console.log(`Using extracted label for ${markerId}: ${extracted.label}`);
} else if (markerInfo.label) {
console.log(`Using existing label for ${markerId}: ${markerInfo.label}`);
} else {
markerInfo.label = 'Unnamed marker';
console.log(`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);
console.log(`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) {
if (!parent.children) parent.children = [];
if (Array.isArray(parent.children)) {
parent.children.push(markerId);
} else {
parent.children = [markerId];
}
childrenIds.add(markerId);
}
}
// 子ノードを処理
if (node.children && Array.isArray(node.children)) {
node.children.forEach((child: any) => {
collectMarkers(child, markerId);
});
}
return markerId;
}
// 子ノードを処理
if (node.children && Array.isArray(node.children)) {
node.children.forEach((child: any) => {
collectMarkers(child, parentId);
});
}
return null;
}
// AST解析開始
collectMarkers(templateAst);
return allMarkers;
}
// バインドプロパティの処理を修正する関数
function extractNodeBindings(node: any): Record<string, any> {
const bindings: Record<string, any> = {};
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}`);
// 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;
}
}
// 配列式の文字列表現を修正 - i18n参照を適切に処理
function formatArrayForOutput(items: any[]): string {
return items.map(item => {
// i18n.ts. 参照の文字列はそのままJavaScript式として出力
if (typeof item === 'string' && item.includes('i18n.ts.')) {
logger.info(`Preserving i18n reference in array: ${item}`);
return item; // クォートなしでそのまま
}
// その他の値はJSON5形式で文字列化
return JSON5.stringify(item);
}).join(', ');
}
export async function analyzeVueProps(options: {
targetFilePaths: string[],
exportFilePath: string,
transformedCodeCache: Record<string, string>
}): Promise<void> {
const allMarkers: SearchIndexItem[] = [];
// 対象ファイルパスを glob で展開
const filePaths = options.targetFilePaths.reduce<string[]>((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 id = path.resolve(filePath); // 絶対パスに変換
const code = options.transformedCodeCache[id]; // options 経由でキャッシュ参照
if (!code) { // キャッシュミスの場合
logger.error(`Error: No cached code found for: ${filePath}.`); // エラーログ
// ファイルを直接読み込む代替策を実行
try {
const directCode = fs.readFileSync(filePath, 'utf-8');
options.transformedCodeCache[id] = directCode;
logger.info(`Loaded file directly instead: ${filePath}`);
} catch (err) {
logger.error(`Failed to load file directly: ${filePath}`, err);
continue;
}
}
try {
const { descriptor, errors } = vueSfcParse(options.transformedCodeCache[id], {
filename: filePath,
});
if (errors.length) {
logger.error(`Compile Error: ${filePath}, ${errors}`);
continue; // エラーが発生したファイルはスキップ
}
const fileMarkers = extractUsageInfoFromTemplateAst(descriptor.template?.ast, options.transformedCodeCache[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: any;
}
async function processVueFile(
code: string,
id: string,
options: { targetFilePaths: string[], exportFilePath: string },
transformedCodeCache: Record<string, string>
) {
// すでにキャッシュに存在する場合は、そのまま返す
if (transformedCodeCache[id] && transformedCodeCache[id].includes('markerId=')) {
logger.info(`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}"`);
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<string, string[]>();
// 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[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<string, string> = {}; // キャッシュオブジェクトをプラグインスコープで定義
const isDevServer = process.env.NODE_ENV === 'development'; // 開発サーバーかどうか
return {
name: 'createSearchIndex',
enforce: 'pre',
async buildStart() {
if (!isDevServer) {
return;
}
const filePaths = options.targetFilePaths.reduce<string[]>((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 を実行
},
};
}