/* * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ /// import { parse as vueSfcParse } from 'vue/compiler-sfc'; import { createLogger, EnvironmentModuleGraph, type LogErrorOptions, type LogOptions, normalizePath, 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, type DirectiveNode, type ElementNode, ElementTypes, NodeTypes, type RootNode, type SimpleExpressionNode, type TemplateChildNode, } from '@vue/compiler-core'; export interface SearchIndexItem { id: string; parentId?: string; path?: string; label: string; keywords: 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 | unknown) => { }, }; 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); } } //region AST Utility type WalkVueNode = RootNode | TemplateChildNode | SimpleExpressionNode; /** * Walks the Vue AST. * @param nodes * @param context The context value passed to callback. you can update context for children by returning value in callback * @param callback Returns false if you don't want to walk inner tree */ function walkVueElements(nodes: WalkVueNode[], context: C, callback: (node: ElementNode, context: C) => C | undefined | void | false): void { for (const node of nodes) { let currentContext = context; if (node.type === NodeTypes.COMPOUND_EXPRESSION) throw new Error("Unexpected COMPOUND_EXPRESSION"); if (node.type === NodeTypes.ELEMENT) { const result = callback(node, context); if (result === false) return; if (result !== undefined) currentContext = result; } if ('children' in node) { walkVueElements(node.children, currentContext, callback); } } } function findAttribute(props: Array, name: string): AttributeNode | DirectiveNode | null { for (const prop of props) { switch (prop.type) { case NodeTypes.ATTRIBUTE: if (prop.name === name) { return prop; } break; case NodeTypes.DIRECTIVE: if (prop.name === 'bind' && prop.arg && 'content' in prop.arg && prop.arg.content === name) { return prop; } break; } } return null; } function findEndOfStartTagAttributes(node: ElementNode): number { if (node.children.length > 0) { // 子要素がある場合、最初の子要素の開始位置を基準にする const nodeStart = node.loc.start.offset; const firstChildStart = node.children[0].loc.start.offset; const endOfStartTag = node.loc.source.lastIndexOf('>', firstChildStart - nodeStart); if (endOfStartTag === -1) throw new Error("Bug: Failed to find end of start tag"); return nodeStart + endOfStartTag; } else { // 子要素がない場合、自身の終了位置から逆算 return node.isSelfClosing ? node.loc.end.offset - 1 : node.loc.end.offset; } } //endregion /** * 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; }); } // region extractElementText /** * 要素のノードの中身のテキストを抽出する */ function extractElementText(node: ElementNode, id: string): string | null { return extractElementTextChecked(node, node.tag, id); } function extractElementTextChecked(node: ElementNode, processingNodeName: string, id: string): string | null { const result: string[] = []; for (const child of node.children) { const text = extractElementText2Inner(child, processingNodeName, id); if (text == null) return null; result.push(text); } return result.join(''); } function extractElementText2Inner(node: TemplateChildNode, processingNodeName: string, id: string): string | null { if (node.type === NodeTypes.COMPOUND_EXPRESSION) throw new Error("Unexpected COMPOUND_EXPRESSION"); switch (node.type) { case NodeTypes.INTERPOLATION: { const expr = node.content; if (expr.type === NodeTypes.COMPOUND_EXPRESSION) throw new Error(`Unexpected COMPOUND_EXPRESSION`); const exprResult = evalExpression(expr.content); if (typeof exprResult !== 'string') { logger.error(`Result of interpolation node is not string at line ${id}:${node.loc.start.line}`); return null; } return exprResult; } case NodeTypes.ELEMENT: if (node.tagType === ElementTypes.ELEMENT) { return extractElementTextChecked(node, processingNodeName, id); } else { logger.error(`Unexpected ${node.tag} extracting text of ${processingNodeName} ${id}:${node.loc.start.line}`); return null; } case NodeTypes.TEXT: return node.content; case NodeTypes.COMMENT: // We skip comments return ''; case NodeTypes.IF: case NodeTypes.IF_BRANCH: case NodeTypes.FOR: case NodeTypes.TEXT_CALL: logger.error(`Unexpected controlflow element extracting text of ${processingNodeName} ${id}:${node.loc.start.line}`); return null; } } // endregion // region extractUsageInfoFromTemplateAst /** * SearchLabel/SearchKeyword/SearchIconを探して抽出する関数 */ function extractSugarTags(nodes: TemplateChildNode[], id: string): { label: string | null, keywords: string[], icon: string | null } { let label: string | null | undefined = undefined; let icon: string | null | undefined = undefined; const keywords: string[] = []; logger.info(`Extracting labels and keywords from ${nodes.length} nodes`); walkVueElements(nodes, null, (node) => { switch (node.tag) { case 'SearchMarker': return false; // SearchMarkerはスキップ case 'SearchLabel': if (label !== undefined) { logger.warn(`Duplicate SearchLabel found, ignoring the second one at ${id}:${node.loc.start.line}`); break; // 2つ目のSearchLabelは無視 } label = extractElementText(node, id); return; case 'SearchKeyword': const content = extractElementText(node, id); if (content) { keywords.push(content); } return; case 'SearchIcon': if (icon !== undefined) { logger.warn(`Duplicate SearchIcon found, ignoring the second one at ${id}:${node.loc.start.line}`); break; // 2つ目のSearchIconは無視 } if (node.children.length !== 1) { logger.error(`SearchIcon must have exactly one child at ${id}:${node.loc.start.line}`); return; } const iconNode = node.children[0]; if (iconNode.type !== NodeTypes.ELEMENT) { logger.error(`SearchIcon must have a child element at ${id}:${node.loc.start.line}`); return; } icon = getStringProp(findAttribute(iconNode.props, 'class'), id); return; } return; }); // デバッグ情報 logger.info(`Extraction completed: label=${label}, keywords=[${keywords.join(', ')}, icon=${icon}]`); return { label: label ?? null, keywords, icon: icon ?? null }; } function getStringProp(attr: AttributeNode | DirectiveNode | null, id: string): string | null { switch (attr?.type) { case null: case undefined: return null; case NodeTypes.ATTRIBUTE: return attr.value?.content ?? null; case NodeTypes.DIRECTIVE: if (attr.exp == null) return null; if (attr.exp.type === NodeTypes.COMPOUND_EXPRESSION) throw new Error('Unexpected COMPOUND_EXPRESSION'); const value = evalExpression(attr.exp.content ?? ''); if (typeof value !== 'string') { logger.error(`Expected string value, got ${typeof value} at ${id}:${attr.loc.start.line}`); return null; } return value; } } function getStringArrayProp(attr: AttributeNode | DirectiveNode | null, id: string): string[] | null { switch (attr?.type) { case null: case undefined: return null; case NodeTypes.ATTRIBUTE: logger.error(`Expected directive, got attribute at ${id}:${attr.loc.start.line}`); return null; case NodeTypes.DIRECTIVE: if (attr.exp == null) return null; if (attr.exp.type === NodeTypes.COMPOUND_EXPRESSION) throw new Error('Unexpected COMPOUND_EXPRESSION'); const value = evalExpression(attr.exp.content ?? ''); if (!Array.isArray(value) || !value.every(x => typeof x === 'string')) { logger.error(`Expected string array value, got ${typeof value} at ${id}:${attr.loc.start.line}`); return null; } return value; } } function extractUsageInfoFromTemplateAst( templateAst: RootNode | undefined, id: string, ): SearchIndexItem[] { const allMarkers: SearchIndexItem[] = []; const markerMap = new Map(); if (!templateAst) return allMarkers; walkVueElements([templateAst], null, (node, parentId) => { if (node.tag !== 'SearchMarker') { return; } // マーカー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: [], }; // バインドプロパティを取得 const path = getStringProp(findAttribute(node.props, 'path'), id) const icon = getStringProp(findAttribute(node.props, 'icon'), id) const label = getStringProp(findAttribute(node.props, 'label'), id) const inlining = getStringArrayProp(findAttribute(node.props, 'inlining'), id) const keywords = getStringArrayProp(findAttribute(node.props, 'keywords'), id) if (path) markerInfo.path = path; if (icon) markerInfo.icon = icon; if (label) markerInfo.label = label; if (inlining) markerInfo.inlining = inlining; if (keywords) markerInfo.keywords = keywords; //pathがない場合はファイルパスを設定 if (markerInfo.path == null && parentId == null) { markerInfo.path = id.match(/.*(\/(admin|settings)\/[^\/]+)\.vue$/)?.[1]; } // SearchLabelとSearchKeywordを抽出 (AST全体を探索) { const extracted = extractSugarTags(node.children, id); if (extracted.label && markerInfo.label) logger.warn(`Duplicate label found for ${markerId} at ${id}:${node.loc.start.line}`); if (extracted.icon && markerInfo.icon) logger.warn(`Duplicate icon found for ${markerId} at ${id}:${node.loc.start.line}`); markerInfo.label = extracted.label ?? markerInfo.label ?? ''; markerInfo.keywords = [...extracted.keywords, ...markerInfo.keywords]; markerInfo.icon = extracted.icon ?? markerInfo.icon ?? undefined; } if (!markerInfo.label) { logger.warn(`No label found for ${markerId} at ${id}:${node.loc.start.line}`); } // マーカーを登録 markerMap.set(markerId, markerInfo); allMarkers.push(markerInfo); return markerId; }); return allMarkers; } //endregion //region evalExpression /** * 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 []; // エラーが発生したファイルはスキップ } return extractUsageInfoFromTemplateAst(descriptor.template?.ast, id); } catch (error) { logger.error(`Error analyzing file ${id}:`, error); } return []; } // endregion 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 が必要) }; } walkVueElements([ast], null, (node, parentId) => { if (node.tag !== 'SearchMarker') return; const markerIdProp = findAttribute(node.props, 'markerId'); let nodeMarkerId: string; if (markerIdProp != null) { if (markerIdProp.type !== NodeTypes.ATTRIBUTE) return logger.error(`markerId must be a attribute at ${id}:${markerIdProp.loc.start.line}`); if (markerIdProp.value == null) return logger.error(`markerId must have a value at ${id}:${markerIdProp.loc.start.line}`); nodeMarkerId = markerIdProp.value.content; } else { // ファイルパスと行番号からハッシュ値を生成 // この際実行環境で差が出ないようにファイルパスを正規化 const idKey = id.replace(/\\/g, '/').split('packages/frontend/')[1] const generatedMarkerId = toBase62(hash(`${idKey}:${node.loc.start.line}`)); // markerId attribute を追加 const endOfStartTag = findEndOfStartTagAttributes(node); s.appendRight(endOfStartTag, ` markerId="${generatedMarkerId}" data-in-app-search-marker-id="${generatedMarkerId}"`); nodeMarkerId = generatedMarkerId; } markerRelations.push({ parentId: parentId ?? undefined, markerId: nodeMarkerId, node: node, }); return nodeMarkerId; }) // 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) continue; const parentNode = parentRelation.node; const childrenProp = findAttribute(parentNode.props, 'children'); if (childrenProp != null) { if (childrenProp.type !== NodeTypes.DIRECTIVE) { console.error(`children prop should be directive (:children) at ${id}:${childrenProp.loc.start.line}`); continue; } // AST で :children 属性が検出された場合、それを更新 const childrenValue = getStringArrayProp(childrenProp, id); if (childrenValue == null) continue; const newValue: string[] = [...childrenValue]; for (const childId of [...childIds]) { if (!newValue.includes(childId)) { newValue.push(childId); } } const expression = JSON.stringify(newValue).replaceAll(/"/g, "'"); s.overwrite(childrenProp.exp!.loc.start.offset, childrenProp.exp!.loc.end.offset, expression); logger.info(`Added ${childIds.length} child markerIds to existing :children in ${id}`); } else { // :children 属性がまだない場合、新規作成 const endOfParentStartTag = findEndOfStartTagAttributes(parentNode); 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(searchIndexFilePath, 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; } }; }