refactor: flatten search index

This commit is contained in:
anatawa12 2025-04-06 16:22:43 +09:00
parent f9a3db2ac3
commit eede579e1b
No known key found for this signature in database
GPG Key ID: 9CA909848B8E4EA6
4 changed files with 66 additions and 322 deletions

View File

@ -28,21 +28,14 @@ import type {
} from '@vue/compiler-core'; } from '@vue/compiler-core';
import { NodeTypes } from '@vue/compiler-core'; import { NodeTypes } from '@vue/compiler-core';
export type AnalysisResult<T = SearchIndexItem> = { export interface SearchIndexItem {
filePath: string;
usage: T[];
}
export type SearchIndexItem = SearchIndexItemLink<SearchIndexItem>;
export type SearchIndexStringItem = SearchIndexItemLink<string>;
export interface SearchIndexItemLink<T> {
id: string; id: string;
parentId?: string;
path?: string; path?: string;
label: string; label: string;
keywords: string | string[]; keywords: string | string[];
icon?: string; icon?: string;
inlining?: string[]; inlining?: string[];
children?: T[];
} }
export type Options = { export type Options = {
@ -90,253 +83,6 @@ function initLogger(options: Options) {
} }
} }
function collectSearchItemIndexes(analysisResults: AnalysisResult<SearchIndexStringItem>[]): SearchIndexItem[] {
logger.info(`Processing ${analysisResults.length} files for output`);
// 新しいツリー構造を構築
const allMarkers = new Map<string, SearchIndexStringItem>();
// 1. すべてのマーカーを一旦フラットに収集
for (const file of analysisResults) {
logger.info(`Processing file: ${file.filePath} with ${file.usage.length} markers`);
for (const marker of file.usage) {
if (marker.id) {
// キーワードとchildren処理を共通化
const processedMarker: SearchIndexStringItem = {
...marker,
keywords: processMarkerProperty(marker.keywords, 'keywords'),
};
allMarkers.set(marker.id, processedMarker);
}
}
}
logger.info(`Collected total ${allMarkers.size} unique markers`);
// 2. 子マーカーIDの収集
const childIds = collectChildIds(allMarkers);
logger.info(`Found ${childIds.size} child markers`);
// 3. ルートマーカーの特定(他の誰かの子でないマーカー)
const rootMarkers = identifyRootMarkers(allMarkers, childIds);
logger.info(`Found ${rootMarkers.length} root markers`);
// 4. 子マーカーの参照を解決
const resolvedRootMarkers = resolveChildReferences(rootMarkers, allMarkers);
// 5. デバッグ情報を生成
const { totalMarkers, totalChildren } = countMarkers(resolvedRootMarkers);
logger.info(`Total markers in tree: ${totalMarkers} (${resolvedRootMarkers.length} roots + ${totalChildren} nested children)`);
return resolvedRootMarkers;
}
/**
* keywordsやchildren
*/
function processMarkerProperty(propValue: string | string[], propType: 'keywords' | 'children'): string | string[] {
// 文字列の配列表現を解析
if (typeof propValue === 'string' && propValue.startsWith('[') && propValue.endsWith(']')) {
try {
// JSON5解析を試みる
return JSON5.parse(propValue.replace(/'/g, '"'));
} catch (e) {
// 解析に失敗した場合
logger.warn(`Could not parse ${propType}: ${propValue}, using ${propType === 'children' ? 'empty array' : 'as is'}`);
return propType === 'children' ? [] : propValue;
}
}
return propValue;
}
/**
* IDを収集する
*/
function collectChildIds(allMarkers: Map<string, SearchIndexStringItem>): Set<string> {
const childIds = new Set<string>();
allMarkers.forEach((marker, id) => {
// 通常のchildren処理
const children = marker.children;
if (Array.isArray(children)) {
children.forEach(childId => {
if (typeof childId === 'string') {
if (!allMarkers.has(childId)) {
logger.warn(`Warning: Child marker ID ${childId} referenced but not found`);
} else {
childIds.add(childId);
}
}
});
}
// inlining処理を追加
if (marker.inlining) {
let inliningIds: string[] = [];
// 文字列の場合は配列に変換
if (typeof marker.inlining === 'string') {
try {
const inliningStr = (marker.inlining as string).trim();
if (inliningStr.startsWith('[') && inliningStr.endsWith(']')) {
inliningIds = JSON5.parse(inliningStr.replace(/'/g, '"'));
logger.info(`Parsed inlining string to array: ${inliningStr} -> ${JSON.stringify(inliningIds)}`);
} else {
inliningIds = [inliningStr];
}
} catch (e) {
logger.error(`Failed to parse inlining string: ${marker.inlining}`, e);
}
}
// 既に配列の場合
else if (Array.isArray(marker.inlining)) {
inliningIds = marker.inlining;
}
// inliningで指定されたIDを子セットに追加
for (const inlineId of inliningIds) {
if (typeof inlineId === 'string') {
if (!allMarkers.has(inlineId)) {
logger.warn(`Warning: Inlining marker ID ${inlineId} referenced but not found`);
} else {
// inliningで参照されているマーカーも子として扱う
childIds.add(inlineId);
logger.info(`Added inlined marker ${inlineId} as child in collectChildIds`);
}
}
}
}
});
return childIds;
}
/**
*
*/
function identifyRootMarkers(
allMarkers: Map<string, SearchIndexStringItem>,
childIds: Set<string>
): SearchIndexStringItem[] {
const rootMarkers: SearchIndexStringItem[] = [];
allMarkers.forEach((marker, id) => {
if (!childIds.has(id)) {
rootMarkers.push(marker);
logger.info(`Added root marker to output: ${id} with label ${marker.label}`);
}
});
return rootMarkers;
}
/**
* IDから実際のオブジェクトに解決する
*/
function resolveChildReferences(
rootMarkers: SearchIndexStringItem[],
allMarkers: Map<string, SearchIndexStringItem>
): SearchIndexItem[] {
function resolveChildrenForMarker(marker: SearchIndexStringItem): SearchIndexItem {
// マーカーのディープコピーを作成
const resolvedMarker: SearchIndexItem = { ...marker, children: [] };
// 明示的に子マーカー配列を作成
const resolvedChildren: SearchIndexItem[] = [];
// 通常のchildren処理
if (Array.isArray(marker.children)) {
for (const childId of marker.children) {
if (typeof childId === 'string') {
const childMarker = allMarkers.get(childId);
if (childMarker) {
// 子マーカーの子も再帰的に解決
const resolvedChild = resolveChildrenForMarker(childMarker);
resolvedChildren.push(resolvedChild);
logger.info(`Resolved regular child ${childId} for parent ${marker.id}`);
}
}
}
}
// inlining属性の処理
let inliningIds: string[] = [];
// 文字列の場合は配列に変換。例: "['2fa']" -> ['2fa']
if (typeof marker.inlining === 'string') {
try {
// 文字列形式の配列を実際の配列に変換
const inliningStr = (marker.inlining as string).trim();
if (inliningStr.startsWith('[') && inliningStr.endsWith(']')) {
inliningIds = JSON5.parse(inliningStr.replace(/'/g, '"'));
logger.info(`Converted string inlining to array: ${inliningStr} -> ${JSON.stringify(inliningIds)}`);
} else {
// 単一値の場合は配列に
inliningIds = [inliningStr];
logger.info(`Converted single string inlining to array: ${inliningStr}`);
}
} catch (e) {
logger.error(`Failed to parse inlining string: ${marker.inlining}`, e);
}
}
// 既に配列の場合はそのまま使用
else if (Array.isArray(marker.inlining)) {
inliningIds = marker.inlining;
}
// インライン指定されたマーカーを子として追加
for (const inlineId of inliningIds) {
if (typeof inlineId === 'string') {
const inlineMarker = allMarkers.get(inlineId);
if (inlineMarker) {
// インライン指定されたマーカーを再帰的に解決
const resolvedInline = resolveChildrenForMarker(inlineMarker);
delete resolvedInline.path
resolvedChildren.push(resolvedInline);
logger.info(`Added inlined marker ${inlineId} as child to ${marker.id}`);
} else {
logger.warn(`Inlining target not found: ${inlineId} referenced by ${marker.id}`);
}
}
}
// 解決した子が存在する場合のみchildrenプロパティを設定
if (resolvedChildren.length > 0) {
resolvedMarker.children = resolvedChildren;
} else {
delete resolvedMarker.children;
}
return resolvedMarker;
}
// すべてのルートマーカーの子を解決
return rootMarkers.map(marker => resolveChildrenForMarker(marker));
}
/**
*
*/
function countMarkers(markers: SearchIndexItem[]): { totalMarkers: number, totalChildren: number } {
let totalMarkers = markers.length;
let totalChildren = 0;
function countNested(items: SearchIndexItem[]): void {
for (const marker of items) {
if (marker.children && Array.isArray(marker.children)) {
totalChildren += marker.children.length;
totalMarkers += marker.children.length;
countNested(marker.children as SearchIndexItem[]);
}
}
}
countNested(markers);
return { totalMarkers, totalChildren };
}
/** /**
* TypeScriptコード生成 * TypeScriptコード生成
*/ */
@ -790,9 +536,9 @@ function extractLabelsAndKeywords(nodes: TemplateChildNode[]): { label: string |
function extractUsageInfoFromTemplateAst( function extractUsageInfoFromTemplateAst(
templateAst: RootNode | undefined, templateAst: RootNode | undefined,
id: string, id: string,
): SearchIndexStringItem[] { ): SearchIndexItem[] {
const allMarkers: SearchIndexStringItem[] = []; const allMarkers: SearchIndexItem[] = [];
const markerMap = new Map<string, SearchIndexItemLink<string>>(); const markerMap = new Map<string, SearchIndexItem>();
const childrenIds = new Set<string>(); const childrenIds = new Set<string>();
const normalizedId = id.replace(/\\/g, '/'); const normalizedId = id.replace(/\\/g, '/');
@ -812,9 +558,9 @@ function extractUsageInfoFromTemplateAst(
} }
// マーカー基本情報 // マーカー基本情報
const markerInfo: SearchIndexStringItem = { const markerInfo: SearchIndexItem = {
id: markerId, id: markerId,
children: [], parentId: parentId ?? undefined,
label: '', // デフォルト値 label: '', // デフォルト値
keywords: [], keywords: [],
}; };
@ -835,7 +581,6 @@ function extractUsageInfoFromTemplateAst(
if (bindings.path) markerInfo.path = bindings.path; if (bindings.path) markerInfo.path = bindings.path;
if (bindings.icon) markerInfo.icon = bindings.icon; if (bindings.icon) markerInfo.icon = bindings.icon;
if (bindings.label) markerInfo.label = bindings.label; if (bindings.label) markerInfo.label = bindings.label;
if (bindings.children) markerInfo.children = processMarkerProperty(bindings.children, 'children') as string[];
if (bindings.inlining) { if (bindings.inlining) {
markerInfo.inlining = bindings.inlining; markerInfo.inlining = bindings.inlining;
logger.info(`Added inlining ${JSON.stringify(bindings.inlining)} to marker ${markerId}`); logger.info(`Added inlining ${JSON.stringify(bindings.inlining)} to marker ${markerId}`);
@ -968,7 +713,7 @@ function extractNodeBindings(node: TemplateChildNode | RootNode): Bindings {
} }
} }
// keywordsの特殊処理 // keywordsの特殊処理
if (propName === 'keywords') { else if (propName === 'keywords') {
try { try {
const content = propContent.trim(); const content = propContent.trim();
@ -1097,9 +842,7 @@ function parseArrayExpression(expr: string): string[] {
} }
} }
export function collectFileMarkers(files: [id: string, code: string][]): AnalysisResult<SearchIndexStringItem> { export function collectFileMarkers(id: string, code: string): SearchIndexItem[] {
const allMarkers: SearchIndexStringItem[] = [];
for (const [id, code] of files) {
try { try {
const { descriptor, errors } = vueSfcParse(code, { const { descriptor, errors } = vueSfcParse(code, {
filename: id, filename: id,
@ -1107,27 +850,22 @@ export function collectFileMarkers(files: [id: string, code: string][]): Analysi
if (errors.length > 0) { if (errors.length > 0) {
logger.error(`Compile Error: ${id}, ${errors}`); logger.error(`Compile Error: ${id}, ${errors}`);
continue; // エラーが発生したファイルはスキップ return []; // エラーが発生したファイルはスキップ
} }
const fileMarkers = extractUsageInfoFromTemplateAst(descriptor.template?.ast, id); const fileMarkers = extractUsageInfoFromTemplateAst(descriptor.template?.ast, id);
if (fileMarkers && fileMarkers.length > 0) { if (fileMarkers && fileMarkers.length > 0) {
allMarkers.push(...fileMarkers); // すべてのマーカーを収集
logger.info(`Successfully extracted ${fileMarkers.length} markers from ${id}`); logger.info(`Successfully extracted ${fileMarkers.length} markers from ${id}`);
} else { } else {
logger.info(`No markers found in ${id}`); logger.info(`No markers found in ${id}`);
} }
return fileMarkers;
} catch (error) { } catch (error) {
logger.error(`Error analyzing file ${id}:`, error); logger.error(`Error analyzing file ${id}:`, error);
} }
}
// 収集したすべてのマーカー情報を使用 return [];
return {
filePath: "combined-markers", // すべてのファイルのマーカーを1つのエントリとして扱う
usage: allMarkers,
};
} }
type TransformedCode = { type TransformedCode = {
@ -1490,7 +1228,7 @@ export function pluginCreateSearchIndexVirtualModule(options: Options, asigner:
this.addWatchFile(searchIndexFilePath); this.addWatchFile(searchIndexFilePath);
const code = await asigner.getOrLoad(searchIndexFilePath); const code = await asigner.getOrLoad(searchIndexFilePath);
return generateJavaScriptCode(collectSearchItemIndexes([collectFileMarkers([[id, code]])])); return generateJavaScriptCode(collectFileMarkers(id, code));
} }
return null; return null;
}, },

View File

@ -93,11 +93,11 @@ export type SuperMenuDef = {
</script> </script>
<script lang="ts" setup> <script lang="ts" setup>
import { useTemplateRef, ref, watch, nextTick } from 'vue'; import { useTemplateRef, ref, watch, nextTick, computed } from 'vue';
import { getScrollContainer } from '@@/js/scroll.js';
import type { SearchIndexItem } from '@/utility/settings-search-index.js'; import type { SearchIndexItem } from '@/utility/settings-search-index.js';
import MkInput from '@/components/MkInput.vue'; import MkInput from '@/components/MkInput.vue';
import { i18n } from '@/i18n.js'; import { i18n } from '@/i18n.js';
import { getScrollContainer } from '@@/js/scroll.js';
import { useRouter } from '@/router.js'; import { useRouter } from '@/router.js';
import { initIntlString, compareStringIncludes } from '@/utility/intl-string.js'; import { initIntlString, compareStringIncludes } from '@/utility/intl-string.js';
@ -124,6 +124,7 @@ const searchResult = ref<{
isRoot: boolean; isRoot: boolean;
parentLabels: string[]; parentLabels: string[];
}[]>([]); }[]>([]);
const searchIndexItemByIdComputed = computed(() => props.searchIndex && new Map<string, SearchIndexItem>(props.searchIndex.map(i => [i.id, i])));
watch(searchQuery, (value) => { watch(searchQuery, (value) => {
rawSearchQuery.value = value; rawSearchQuery.value = value;
@ -137,32 +138,41 @@ watch(rawSearchQuery, (value) => {
return; return;
} }
const dive = (items: SearchIndexItem[], parents: SearchIndexItem[] = []) => { const searchIndexItemById = searchIndexItemByIdComputed.value;
for (const item of items) { if (searchIndexItemById != null) {
const matched = ( const addSearchResult = (item: SearchIndexItem) => {
compareStringIncludes(item.label, value) || let path: string | undefined = item.path;
item.keywords.some((x) => compareStringIncludes(x, value)) let icon: string | undefined = item.icon;
); const parentLabels: string[] = [];
for (let current = searchIndexItemById.get(item.parentId ?? '');
current != null;
current = searchIndexItemById.get(current.parentId ?? '')) {
path ??= current.path;
icon ??= current.icon;
parentLabels.push(current.label);
}
if (_DEV_ && path == null) throw new Error('path is null for ' + item.id);
if (matched) {
searchResult.value.push({ searchResult.value.push({
id: item.id, id: item.id,
path: item.path ?? parents.find((x) => x.path != null)?.path ?? '/', // never gets `/` path: path ?? '/', // never gets `/`
label: item.label, label: item.label,
parentLabels: parents.map((x) => x.label).toReversed(), parentLabels: parentLabels.toReversed(),
icon: item.icon ?? parents.find((x) => x.icon != null)?.icon, icon,
isRoot: parents.length === 0, isRoot: item.parentId == null,
}); });
}
if (item.children) {
dive(item.children, [item, ...parents]);
}
}
}; };
if (props.searchIndex) { for (const item of searchIndexItemById.values()) {
dive(props.searchIndex); if (
compareStringIncludes(item.label, value) ||
item.keywords.some((x) => compareStringIncludes(x, value))
) {
addSearchResult(item);
}
}
} }
}); });

View File

@ -8,11 +8,11 @@ import type { GeneratedSearchIndexItem } from 'search-index:settings';
export type SearchIndexItem = { export type SearchIndexItem = {
id: string; id: string;
parentId?: string;
path?: string; path?: string;
label: string; label: string;
keywords: string[]; keywords: string[];
icon?: string; icon?: string;
children?: SearchIndexItem[];
}; };
const rootMods = new Map(generated.map(item => [item.id, item])); const rootMods = new Map(generated.map(item => [item.id, item]));
@ -22,17 +22,13 @@ function walk(item: GeneratedSearchIndexItem) {
for (const id of item.inlining) { for (const id of item.inlining) {
const inline = rootMods.get(id); const inline = rootMods.get(id);
if (inline) { if (inline) {
(item.children ??= []).push(inline); inline.parentId = item.id;
rootMods.delete(id); rootMods.delete(id);
} else { } else {
console.log('[Settings Search Index] Failed to inline', id); console.log('[Settings Search Index] Failed to inline', id);
} }
} }
} }
for (const child of item.children ?? []) {
walk(child);
}
} }
for (const item of generated) { for (const item of generated) {

View File

@ -6,12 +6,12 @@
declare module 'search-index:settings' { declare module 'search-index:settings' {
export type GeneratedSearchIndexItem = { export type GeneratedSearchIndexItem = {
id: string; id: string;
parentId?: string;
path?: string; path?: string;
label: string; label: string;
keywords: string[]; keywords: string[];
icon?: string; icon?: string;
inlining?: string[]; inlining?: string[];
children?: GeneratedSearchIndexItem[];
}; };
export const searchIndexes: GeneratedSearchIndexItem[]; export const searchIndexes: GeneratedSearchIndexItem[];