/* * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ /// import { parse as vueSfcParse } from 'vue/compiler-sfc'; import { createLogger, EnvironmentModuleGraph, normalizePath, type LogErrorOptions, type LogOptions, type Plugin, type PluginOption } from 'vite'; import fs from 'node:fs'; import { glob } from 'glob'; import JSON5 from 'json5'; import MagicString, { SourceMap } from 'magic-string'; import path from 'node:path' import { hash, toBase62 } from '../vite.config'; import { minimatch } from 'minimatch'; import type { AttributeNode, CompoundExpressionNode, DirectiveNode, ElementNode, RootNode, SimpleExpressionNode, TemplateChildNode, } from '@vue/compiler-core'; import { NodeTypes } from '@vue/compiler-core'; export interface SearchIndexItem { id: string; parentId?: string; path?: string; label: string; keywords: string | string[]; icon?: string; inlining?: string[]; } export type Options = { targetFilePaths: string[], mainVirtualModule: string, modulesToHmrOnUpdate: string[], fileVirtualModulePrefix?: string, fileVirtualModuleSuffix?: string, verbose?: boolean, }; // マーカー関係を表す型 interface MarkerRelation { parentId?: string; markerId: string; node: ElementNode; } // ロガー let logger = { info: (msg: string, options?: LogOptions) => { }, warn: (msg: string, options?: LogOptions) => { }, error: (msg: string, options?: LogErrorOptions) => { }, }; 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 generateJavaScriptCode(resolvedRootMarkers: SearchIndexItem[]): string { return `import { i18n } from '@/i18n.js';\n` + `export const searchIndexes = ${customStringify(resolvedRootMarkers)};\n`; } /** * オブジェクトを特殊な形式の文字列に変換する * i18n参照を保持しつつ適切な形式に変換 */ function customStringify(obj: unknown): string { return JSON.stringify(obj).replaceAll(/"(.*?)"/g, (all, group) => { // propertyAccessProxy が i18n 参照を "${i18n.xxx}"のような形に変換してるので、これをそのまま`${i18n.xxx}` // のような形にすると、実行時にi18nのプロパティにアクセスするようになる。 // objectのkeyでは``が使えないので、${ が使われている場合にのみ``に置き換えるようにする return group.includes('${') ? '`' + group + '`' : all; }); } /** * 要素ノードからテキスト内容を抽出する * 各抽出方法を分離して可読性を向上 */ function extractElementText(node: TemplateChildNode): string | null { if (!node) return null; if (node.type === NodeTypes.COMPOUND_EXPRESSION) throw new Error("Unexpected COMPOUND_EXPRESSION"); logger.info(`Extracting text from node type=${node.type}, tag=${'tag' in node ? node.tag : 'unknown'}`); // 1. 直接コンテンツの抽出を試行 const directContent = extractDirectContent(node); if (directContent) return directContent; // 子要素がない場合は終了 if (!('children' in node) || !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: TemplateChildNode): string | null { if (!('content' in node)) return null; if (typeof node.content == 'object' && node.content.type === NodeTypes.COMPOUND_EXPRESSION) throw new Error("Unexpected COMPOUND_EXPRESSION"); const content = typeof node.content === 'string' ? node.content.trim() : node.content.type !== NodeTypes.INTERPOLATION ? 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: TemplateChildNode[]): string | null { for (const child of children) { if (child.type === NodeTypes.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') { if (child.content.type == NodeTypes.COMPOUND_EXPRESSION) throw new Error("Unexpected COMPOUND_EXPRESSION"); // オブジェクト形式の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: TemplateChildNode[]): string | null { // i18n.ts. 参照パターンを持つものを優先 for (const child of children) { if (child.type === NodeTypes.TEXT && 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 === NodeTypes.TEXT && child.content) { const expr = child.content.trim(); logger.info(`Found expression: ${expr}`); return expr; } } return null; } /** * テキストノードからコンテンツを抽出 */ function extractTextContent(children: TemplateChildNode[]): string | null { for (const child of children) { if (child.type === NodeTypes.COMMENT && 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] + '}'; } return text; } } } return null; } /** * 子ノードを再帰的に探索してコンテンツを抽出 */ function extractNestedContent(children: TemplateChildNode[]): string | null { for (const child of children) { if ('children' in child && 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 === NodeTypes.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: TemplateChildNode[]): { label: string | null, keywords: string[] } { let label: string | null = null; const keywords: string[] = []; logger.info(`Extracting labels and keywords from ${nodes.length} nodes`); // 再帰的にSearchLabelとSearchKeywordを探索(ネストされたSearchMarkerは処理しない) function findComponents(nodes: TemplateChildNode[]) { for (const node of nodes) { if (node.type === NodeTypes.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インターポレーションを重点的に確認 { for (const child of node.children) { // Mustacheインターポレーション if (child.type === NodeTypes.INTERPOLATION && child.content) { // content内の式を取り出す if (child.content.type == NodeTypes.COMPOUND_EXPRESSION) throw new Error("unexpected COMPOUND_EXPRESSION"); 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 === NodeTypes.TEXT && child.content && isI18nReference(child.content)) { label = child.content.trim(); logger.info(`Found i18n in expression: ${label}`); break; } // テキストノードでもMustache構文を探す else if (child.type === NodeTypes.COMMENT && 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インターポレーションを重点的に確認 { for (const child of node.children) { // Mustacheインターポレーション if (child.type === NodeTypes.INTERPOLATION && child.content) { // content内の式を取り出す if (child.content.type == NodeTypes.COMPOUND_EXPRESSION) throw new Error("unexpected COMPOUND_EXPRESSION"); 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 === NodeTypes.TEXT && 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 === NodeTypes.COMMENT && 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: RootNode | undefined, 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: TemplateChildNode | RootNode, parentId: string | null = null) { if (node.type === NodeTypes.ELEMENT && node.tag === 'SearchMarker') { // マーカーID取得 const markerIdProp = node.props?.find(p => p.name === 'markerId'); const markerId = markerIdProp?.type == NodeTypes.ATTRIBUTE ? markerIdProp.value?.content : null; // 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, parentId: parentId ?? undefined, 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); const assertString = (value: unknown, key: string): string => { if (typeof value !== 'string') throw new Error(`Invalid type for ${key} in marker ${markerId}: expected string, got ${typeof value}`); return value; } const assertStringArray = (value: unknown, key: string): string[] => { if (!Array.isArray(value) || !value.every(x => typeof x === 'string')) throw new Error(`Invalid type for ${key} in marker ${markerId}: expected string array`); return value; } if (bindings.path) markerInfo.path = assertString(bindings.path, 'path'); if (bindings.icon) markerInfo.icon = assertString(bindings.icon, 'icon'); if (bindings.label) markerInfo.label = assertString(bindings.label, 'label'); if (bindings.inlining) { markerInfo.inlining = assertStringArray(bindings.inlining, 'inlining'); logger.info(`Added inlining ${JSON.stringify(bindings.inlining)} to marker ${markerId}`); } if (bindings.keywords) { markerInfo.keywords = assertStringArray(bindings.keywords, '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); } } // 子ノードを処理 for (const child of node.children) { collectMarkers(child, markerId); } return markerId; } // SearchMarkerでない場合は再帰的に子ノードを処理 else if ('children' in node && Array.isArray(node.children)) { for (const child of node.children) { if (typeof child == 'object' && child.type !== NodeTypes.SIMPLE_EXPRESSION) { collectMarkers(child, parentId); } } } return null; } // AST解析開始 collectMarkers(templateAst); return allMarkers; } type Bindings = Partial>; // バインドプロパティの処理を修正する関数 function extractNodeBindings(node: TemplateChildNode | RootNode): Bindings { const bindings: Bindings = {}; if (node.type !== NodeTypes.ELEMENT) return bindings; // バインド式を収集 for (const prop of node.props) { if (prop.type === NodeTypes.DIRECTIVE && prop.name === 'bind' && prop.arg && 'content' in prop.arg) { const propName = prop.arg.content; if (prop.exp?.type === NodeTypes.COMPOUND_EXPRESSION) throw new Error('unexpected COMPOUND_EXPRESSION'); const propContent = prop.exp?.content || ''; logger.info(`Processing bind prop ${propName}: ${propContent}`); bindings[propName] = evalExpression(propContent); } } return bindings; } /** * expr を実行します。 * i18n はそのアクセスを保持するために propertyAccessProxy を使用しています。 */ function evalExpression(expr: string): unknown { const rarResult = Function('i18n', `return ${expr}`)(i18nProxy); // JSON.stringify を一回通すことで、 AccessProxy を文字列に変換する // Walk してもいいんだけど横着してJSON.stringifyしてる。ビルド時にしか通らないのであんまりパフォーマンス気にする必要ないんで return JSON.parse(JSON.stringify(rarResult)); } const propertyAccessProxySymbol = Symbol('propertyAccessProxySymbol'); type AccessProxy = { [propertyAccessProxySymbol]: string[], [k: string]: AccessProxy, } const propertyAccessProxyHandler: ProxyHandler = { get(target: AccessProxy, p: string | symbol): any { if (p in target) { return (target as any)[p]; } if (p == "toJSON" || p == Symbol.toPrimitive) { return propertyAccessProxyToJSON; } if (typeof p == 'string') { return target[p] = propertyAccessProxy([...target[propertyAccessProxySymbol], p]); } return undefined; } } function propertyAccessProxyToJSON(this: AccessProxy, hint: string) { const expression = this[propertyAccessProxySymbol].reduce((prev, current) => { if (current.match(/^[a-z][0-9a-z]*$/i)) { return `${prev}.${current}`; } else { return `${prev}['${current}']`; } }); return '$\{' + expression + '}'; } /** * プロパティのアクセスを保持するための Proxy オブジェクトを作成します。 * * この関数で生成した proxy は JSON でシリアライズするか、`${}`のように string にすると、 ${property.path} のような形になる。 * @param path */ function propertyAccessProxy(path: string[]): AccessProxy { const target: AccessProxy = { [propertyAccessProxySymbol]: path, }; return new Proxy(target, propertyAccessProxyHandler); } const i18nProxy = propertyAccessProxy(['i18n']); export function collectFileMarkers(id: string, code: string): SearchIndexItem[] { try { const { descriptor, errors } = vueSfcParse(code, { filename: id, }); if (errors.length > 0) { logger.error(`Compile Error: ${id}, ${errors}`); return []; // エラーが発生したファイルはスキップ } const fileMarkers = extractUsageInfoFromTemplateAst(descriptor.template?.ast, id); if (fileMarkers && fileMarkers.length > 0) { logger.info(`Successfully extracted ${fileMarkers.length} markers from ${id}`); } else { logger.info(`No markers found in ${id}`); } return fileMarkers; } catch (error) { logger.error(`Error analyzing file ${id}:`, error); } return []; } type TransformedCode = { code: string, map: SourceMap, }; export class MarkerIdAssigner { // key: file id private cache: Map; constructor() { this.cache = new Map(); } public onInvalidate(id: string) { this.cache.delete(id); } public processFile(id: string, code: string): TransformedCode { // try cache first if (this.cache.has(id)) { return this.cache.get(id)!; } const transformed = this.#processImpl(id, code); this.cache.set(id, transformed); return transformed; } #processImpl(id: string, code: string): TransformedCode { const s = new MagicString(code); // magic-string のインスタンスを作成 const parsed = vueSfcParse(code, { filename: id }); if (!parsed.descriptor.template) { return { code, map: s.generateMap({ source: id, includeContent: true }), }; } const ast = parsed.descriptor.template.ast; // テンプレート AST を取得 const markerRelations: MarkerRelation[] = []; // MarkerRelation 配列を初期化 if (!ast) { return { code: s.toString(), // 変更後のコードを返す map: s.generateMap({ source: id, includeContent: true }), // ソースマップも生成 (sourceMap: true が必要) }; } type SearchMarkerElementNode = ElementNode & { __markerId?: string, __children?: string[], }; function traverse(node: RootNode | TemplateChildNode | SimpleExpressionNode | CompoundExpressionNode, currentParent?: SearchMarkerElementNode) { if (node.type === NodeTypes.ELEMENT && 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) => prop.type === NodeTypes.ATTRIBUTE && prop.name === 'markerId'); const nodeMarkerId = hasMarkerIdProp ? props.find((prop): prop is AttributeNode => prop.type === NodeTypes.ATTRIBUTE && prop.name === 'markerId')?.value?.content as string : generatedMarkerId; (node as SearchMarkerElementNode).__markerId = nodeMarkerId; // 子マーカーの場合、親ノードに __children を設定しておく if (currentParent) { 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: SearchMarkerElementNode | undefined = node.type === NodeTypes.ELEMENT && node.tag === 'SearchMarker' ? node : currentParent; if ('children' in node) { for (const child of node.children) { if (typeof child == 'object') { 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): prop is DirectiveNode => prop.type === NodeTypes.DIRECTIVE && prop.name === 'bind' && prop.arg?.type === NodeTypes.SIMPLE_EXPRESSION && 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}`); } } } return { code: s.toString(), // 変更後のコードを返す map: s.generateMap({ source: id, includeContent: true }), // ソースマップも生成 (sourceMap: true が必要) }; } async getOrLoad(id: string) { // if there already exists a cache, return it // note cahce will be invalidated on file change so the cache must be up to date let code = this.getCached(id)?.code; if (code != null) { return code; } // if no cache found, read and parse the file const originalCode = await fs.promises.readFile(id, 'utf-8'); // Other code may already parsed the file while we were waiting for the file to be read so re-check the cache code = this.getCached(id)?.code; if (code != null) { return code; } // parse the file code = this.processFile(id, originalCode)?.code; return code; } getCached(id: string) { return this.cache.get(id); } } // Rollup プラグインとして export export default function pluginCreateSearchIndex(options: Options): PluginOption { const assigner = new MarkerIdAssigner(); return [ createSearchIndex(options, assigner), pluginCreateSearchIndexVirtualModule(options, assigner), ] } function createSearchIndex(options: Options, assigner: MarkerIdAssigner): Plugin { initLogger(options); // ロガーを初期化 const root = normalizePath(process.cwd()); function isTargetFile(id: string): boolean { const relativePath = path.posix.relative(root, id); return options.targetFilePaths.some(pat => minimatch(relativePath, pat)) } return { name: 'autoAssignMarkerId', enforce: 'pre', watchChange(id) { assigner.onInvalidate(id); }, async transform(code, id) { if (!id.endsWith('.vue')) { return; } if (!isTargetFile(id)) { return; } return assigner.processFile(id, code); }, }; } export function pluginCreateSearchIndexVirtualModule(options: Options, asigner: MarkerIdAssigner): Plugin { const searchIndexPrefix = options.fileVirtualModulePrefix ?? 'search-index-individual:'; const searchIndexSuffix = options.fileVirtualModuleSuffix ?? '.ts'; const allSearchIndexFile = options.mainVirtualModule; const root = normalizePath(process.cwd()); function isTargetFile(id: string): boolean { const relativePath = path.posix.relative(root, id); return options.targetFilePaths.some(pat => minimatch(relativePath, pat)) } function parseSearchIndexFileId(id: string): string | null { const noQuery = id.split('?')[0]; if (noQuery.startsWith(searchIndexPrefix) && noQuery.endsWith(searchIndexSuffix)) { const filePath = id.slice(searchIndexPrefix.length).slice(0, -searchIndexSuffix.length); if (isTargetFile(filePath)) { return filePath; } } return null; } return { name: 'generateSearchIndexVirtualModule', // hotUpdate hook を vite:vue よりもあとに実行したいため enforce: post enforce: 'post', async resolveId(id) { if (id == allSearchIndexFile) { return '\0' + allSearchIndexFile; } const searchIndexFilePath = parseSearchIndexFileId(id); if (searchIndexFilePath != null) { return id; } return undefined; }, async load(id) { if (id == '\0' + allSearchIndexFile) { const files = await Promise.all(options.targetFilePaths.map(async (filePathPattern) => await glob(filePathPattern))).then(paths => paths.flat()); let generatedFile = ''; let arrayElements = ''; for (let file of files) { const normalizedRelative = normalizePath(file); const absoluteId = normalizePath(path.join(process.cwd(), normalizedRelative)) + searchIndexSuffix; const variableName = normalizedRelative.replace(/[\/.-]/g, '_'); generatedFile += `import { searchIndexes as ${variableName} } from '${searchIndexPrefix}${absoluteId}';\n`; arrayElements += ` ...${variableName},\n`; } generatedFile += `export let searchIndexes = [\n${arrayElements}];\n`; return generatedFile; } const searchIndexFilePath = parseSearchIndexFileId(id); if (searchIndexFilePath != null) { // call load to update the index file when the file is changed this.addWatchFile(searchIndexFilePath); const code = await asigner.getOrLoad(searchIndexFilePath); return generateJavaScriptCode(collectFileMarkers(id, code)); } return null; }, hotUpdate(this: { environment: { moduleGraph: EnvironmentModuleGraph } }, { file, modules }) { if (isTargetFile(file)) { const updateMods = options.modulesToHmrOnUpdate.map(id => this.environment.moduleGraph.getModuleById(path.posix.join(root, id))).filter(x => x != null); return [...modules, ...updateMods]; } return modules; } }; } // 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); }