mirror of
				https://codeberg.org/yeentown/barkey.git
				synced 2025-10-30 21:14:12 +00:00 
			
		
		
		
	
		
			
				
	
	
		
			756 lines
		
	
	
	
		
			24 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
			
		
		
	
	
			756 lines
		
	
	
	
		
			24 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
| /*
 | |
|  * SPDX-FileCopyrightText: syuilo and misskey-project
 | |
|  * SPDX-License-Identifier: AGPL-3.0-only
 | |
|  */
 | |
| 
 | |
| /// <reference lib="esnext" />
 | |
| 
 | |
| 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<C extends {} | null>(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<AttributeNode | DirectiveNode>, 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<string, SearchIndexItem>();
 | |
| 
 | |
| 	if (!templateAst) return allMarkers;
 | |
| 
 | |
| 	walkVueElements<string | null>([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<AccessProxy> = {
 | |
| 	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<string, TransformedCode>;
 | |
| 
 | |
| 	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<string | null>([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<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) 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;
 | |
| 		}
 | |
| 	};
 | |
| }
 |