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

1517 lines
49 KiB
TypeScript
Raw Normal View History

feat(frontend): 設定の検索 (#15505) * wip * wip * wip * test * wip rollup pluginでsearchIndexの情報生成 * wip * SPDX * wip: markerIdを自動付与 * rollupでビルド時・devモード時に毎回uuidを生成するように * 開発サーバーでだけ必要な挙動は開発サーバーのみで * 条件が逆 * wip: childrenの生成 * update comment * update comment * rename auto generated file * hashをパスと行数から決定 * Update privacy.vue * Update privacy.vue * wip * Update general.vue * Update general.vue * wip * wip * Update SearchMarker.vue * wip * Update profile.vue * Update mute-block.vue * Update mute-block.vue * Update general.vue * Update general.vue * childrenがduplicate key errorを吐く問題をいったん解決 * マーカーの形を成形 * loggerを置きかえ * とりあえず省略記法に対応 * Refactor and Format codes * wip * Update settings-search-index.ts * wip * wip * とりあえず不確定要因の仮置きidを削除 * hashの生成を正規化(絶対パスになっていたのを緩和) * pathの入力を省略可能に * adminでもパス生成できるように * Update settings-search-index.ts * Update privacy.vue * wip * build searchIndex * wip * build * Update general.vue * build * Update sounds.vue * build * build * Update sounds.vue * 🎨 * 🎨 * Update privacy.vue * Update privacy.vue * Update security.vue * create-search-indexを多少改善 * build * Update 2fa.vue * wip * 必ずtransformCodeCacheを利用するように, キャッシュの明確な受け渡しを定義 * キャッシュはdevServerでなくても更新 * Revert "wip" This reverts commit 41bffd3a13f55618bf939dc1c9acb2a77ead4054. * inlining * wip * Update theme.vue * 🎨 * wip normalize * Update theme.vue * キャッシュのパス変換 * build * wip * wip * Update SearchMarker.vue * i18n.ts['key'] の形式が取り出せない問題のFix * build * 仮でpath入れ * 必ず絶対パスが使われるように * wip * 🎨 * storybookビルド時はcreateSearchIndexをしない * inliningの構造化 * format code * Update index.vue * wip * wip * 🎨 * wip * wip * wip * wip * wip * wip * wip * wip * clean up * Update navbar.vue * enhance: 検索で上下矢印を使用することで検索結果を移動できるように * refactor * fix(frontend): PageWindowでSearchMarkerが動作するように * enhance(frontend): SearchMarkerの点滅を一定時間で止める * lint fix * fix: 子要素監視が抜けていたのを修正 * アニメーションの回数はCSSで制御するように * refactor * enhance(frontend): 検索インデックス作成時のログを削減 * revert * fix * fix --------- Co-authored-by: tai-cha <dev@taichan.site> Co-authored-by: taichan <40626578+tai-cha@users.noreply.github.com> Co-authored-by: kakkokari-gtyih <67428053+kakkokari-gtyih@users.noreply.github.com>
2025-03-06 14:15:19 +00:00
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { parse as vueSfcParse } from 'vue/compiler-sfc';
import type { LogOptions, Plugin } from 'vite';
import fs from 'node:fs';
import { glob } from 'glob';
import JSON5 from 'json5';
import MagicString from 'magic-string';
import path from 'node:path'
import { hash, toBase62 } from '../vite.config';
import { createLogger } from 'vite';
interface VueAstNode {
type: number;
tag?: string;
loc?: {
start: { offset: number, line: number, column: number },
end: { offset: number, line: number, column: number },
source?: string
};
props?: Array<{
name: string;
type: number;
value?: { content?: string };
arg?: { content?: string };
exp?: { content?: string; loc?: any };
}>;
children?: VueAstNode[];
content?: any;
__markerId?: string;
__children?: string[];
}
export type AnalysisResult = {
filePath: string;
usage: SearchIndexItem[];
}
export type SearchIndexItem = {
id: string;
path?: string;
label: string;
keywords: string | string[];
icon?: string;
inlining?: string[];
children?: SearchIndexItem[];
};
export type Options = {
targetFilePaths: string[],
exportFilePath: string,
verbose?: boolean,
};
// 関連するノードタイプの定数化
const NODE_TYPES = {
ELEMENT: 1,
EXPRESSION: 2,
TEXT: 3,
INTERPOLATION: 5, // Mustache
};
// マーカー関係を表す型
interface MarkerRelation {
parentId?: string;
markerId: string;
node: VueAstNode;
}
// ロガー
let logger = {
info: (msg: string, options?: LogOptions) => { },
warn: (msg: string, options?: LogOptions) => { },
error: (msg: string, options?: LogOptions) => { },
};
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 outputAnalysisResultAsTS(outputPath: string, analysisResults: AnalysisResult[]): void {
logger.info(`Processing ${analysisResults.length} files for output`);
// 新しいツリー構造を構築
const allMarkers = new Map<string, SearchIndexItem>();
// 1. すべてのマーカーを一旦フラットに収集
for (const file of analysisResults) {
logger.info(`Processing file: ${file.filePath} with ${file.usage.length} markers`);
for (const marker of file.usage) {
if (marker.id) {
// キーワードとchildren処理を共通化
const processedMarker = {
...marker,
keywords: processMarkerProperty(marker.keywords, 'keywords'),
children: processMarkerProperty(marker.children || [], 'children')
};
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)`);
// 6. 結果をTS形式で出力
writeOutputFile(outputPath, resolvedRootMarkers);
}
/**
* keywordsやchildren
*/
function processMarkerProperty(propValue: any, propType: 'keywords' | 'children'): any {
// 文字列の配列表現を解析
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, SearchIndexItem>): 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, SearchIndexItem>,
childIds: Set<string>
): SearchIndexItem[] {
const rootMarkers: SearchIndexItem[] = [];
allMarkers.forEach((marker, id) => {
if (!childIds.has(id)) {
rootMarkers.push(marker);
logger.info(`Added root marker to output: ${id} with label ${marker.label}`);
}
});
return rootMarkers;
}
/**
* IDから実際のオブジェクトに解決する
*/
function resolveChildReferences(
rootMarkers: SearchIndexItem[],
allMarkers: Map<string, SearchIndexItem>
): SearchIndexItem[] {
function resolveChildrenForMarker(marker: SearchIndexItem): SearchIndexItem {
// マーカーのディープコピーを作成
const resolvedMarker = { ...marker };
// 明示的に子マーカー配列を作成
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ファイルを出力
*/
function writeOutputFile(outputPath: string, resolvedRootMarkers: SearchIndexItem[]): void {
try {
const tsOutput = generateTypeScriptCode(resolvedRootMarkers);
fs.writeFileSync(outputPath, tsOutput, 'utf-8');
// 強制的に出力させるためにViteロガーを使わない
console.log(`Successfully wrote search index to ${outputPath} with ${resolvedRootMarkers.length} root entries`);
} catch (error) {
logger.error('[create-search-index]: error writing output: ', error);
}
}
/**
* TypeScriptコード生成
*/
function generateTypeScriptCode(resolvedRootMarkers: SearchIndexItem[]): string {
return `
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
// This file was automatically generated by create-search-index.
// Do not edit this file.
import { i18n } from '@/i18n.js';
export type SearchIndexItem = {
id: string;
path?: string;
label: string;
keywords: string[];
icon?: string;
children?: SearchIndexItem[];
};
export const searchIndexes: SearchIndexItem[] = ${customStringify(resolvedRootMarkers)} as const;
export type SearchIndex = typeof searchIndexes;
`;
}
/**
*
* i18n参照を保持しつつ適切な形式に変換
*/
function customStringify(obj: any, depth = 0): string {
const INDENT_STR = '\t';
// 配列の処理
if (Array.isArray(obj)) {
if (obj.length === 0) return '[]';
const indent = INDENT_STR.repeat(depth);
const childIndent = INDENT_STR.repeat(depth + 1);
// 配列要素の処理
const items = obj.map(item => {
// オブジェクト要素
if (typeof item === 'object' && item !== null) {
return `${childIndent}${customStringify(item, depth + 1)}`;
}
// i18n参照を含む文字列要素
if (typeof item === 'string' && item.includes('i18n.ts.')) {
return `${childIndent}${item}`; // クォートなしでそのまま出力
}
// その他の要素
return `${childIndent}${JSON5.stringify(item)}`;
}).join(',\n');
return `[\n${items},\n${indent}]`;
}
// null または非オブジェクト
if (obj === null || typeof obj !== 'object') {
return JSON5.stringify(obj);
}
// オブジェクトの処理
const indent = INDENT_STR.repeat(depth);
const childIndent = INDENT_STR.repeat(depth + 1);
const entries = Object.entries(obj)
// 不要なプロパティを除去
.filter(([key, value]) => {
if (value === undefined) return false;
if (key === 'children' && Array.isArray(value) && value.length === 0) return false;
if (key === 'inlining') return false;
return true;
})
// 各プロパティを変換
.map(([key, value]) => {
// 子要素配列の特殊処理
if (key === 'children' && Array.isArray(value) && value.length > 0) {
return `${childIndent}${key}: ${customStringify(value, depth + 1)}`;
}
// ラベルやその他プロパティを処理
return `${childIndent}${key}: ${formatSpecialProperty(key, value)}`;
});
if (entries.length === 0) return '{}';
return `{\n${entries.join(',\n')},\n${indent}}`;
}
/**
*
*/
function formatSpecialProperty(key: string, value: any): string {
// 値がundefinedの場合は空文字列を返す
if (value === undefined) {
return '""';
}
// childrenが配列の場合は特別に処理
if (key === 'children' && Array.isArray(value)) {
return customStringify(value);
}
// keywordsが配列の場合、特別に処理
if (key === 'keywords' && Array.isArray(value)) {
return `[${formatArrayForOutput(value)}]`;
}
// 文字列値の場合の特別処理
if (typeof value === 'string') {
// i18n.ts 参照を含む場合 - クォートなしでそのまま出力
if (isI18nReference(value)) {
logger.info(`Preserving i18n reference in output: ${value}`);
return value;
}
// keywords が配列リテラルの形式の場合
if (key === 'keywords' && value.startsWith('[') && value.endsWith(']')) {
return value;
}
}
// 上記以外は通常の JSON5 文字列として返す
return JSON5.stringify(value);
}
/**
*
*/
function formatArrayForOutput(items: any[]): string {
return items.map(item => {
// i18n.ts. 参照の文字列はそのままJavaScript式として出力
if (typeof item === 'string' && isI18nReference(item)) {
logger.info(`Preserving i18n reference in array: ${item}`);
return item; // クォートなしでそのまま
}
// その他の値はJSON5形式で文字列化
return JSON5.stringify(item);
}).join(', ');
}
/**
*
*
*/
function extractElementText(node: VueAstNode): string | null {
if (!node) return null;
logger.info(`Extracting text from node type=${node.type}, tag=${node.tag || 'unknown'}`);
// 1. 直接コンテンツの抽出を試行
const directContent = extractDirectContent(node);
if (directContent) return directContent;
// 子要素がない場合は終了
if (!node.children || !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: VueAstNode): string | null {
if (!node.content) return null;
const content = typeof node.content === 'string'
? node.content.trim()
: (node.content.content ? 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: VueAstNode[]): string | null {
for (const child of children) {
if (child.type === NODE_TYPES.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') {
// オブジェクト形式の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: VueAstNode[]): string | null {
// i18n.ts. 参照パターンを持つものを優先
for (const child of children) {
if (child.type === NODE_TYPES.EXPRESSION && 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 === NODE_TYPES.EXPRESSION && child.content) {
const expr = child.content.trim();
logger.info(`Found expression: ${expr}`);
return expr;
}
}
return null;
}
/**
*
*/
function extractTextContent(children: VueAstNode[]): string | null {
for (const child of children) {
if (child.type === NODE_TYPES.TEXT && 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].trim();
}
return text;
}
}
}
return null;
}
/**
*
*/
function extractNestedContent(children: VueAstNode[]): string | null {
for (const child of children) {
if (child.children && 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 === NODE_TYPES.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: VueAstNode[]): { label: string | null, keywords: any[] } {
let label: string | null = null;
const keywords: any[] = [];
logger.info(`Extracting labels and keywords from ${nodes.length} nodes`);
// 再帰的にSearchLabelとSearchKeywordを探索ネストされたSearchMarkerは処理しない
function findComponents(nodes: VueAstNode[]) {
for (const node of nodes) {
if (node.type === NODE_TYPES.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インターポレーションを重点的に確認
if (node.children && Array.isArray(node.children)) {
for (const child of node.children) {
// Mustacheインターポレーション
if (child.type === NODE_TYPES.INTERPOLATION && child.content) {
// content内の式を取り出す
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 === NODE_TYPES.EXPRESSION && child.content && isI18nReference(child.content)) {
label = child.content.trim();
logger.info(`Found i18n in expression: ${label}`);
break;
}
// テキストードでもMustache構文を探す
else if (child.type === NODE_TYPES.TEXT && 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インターポレーションを重点的に確認
if (node.children && Array.isArray(node.children)) {
for (const child of node.children) {
// Mustacheインターポレーション
if (child.type === NODE_TYPES.INTERPOLATION && child.content) {
// content内の式を取り出す
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 === NODE_TYPES.EXPRESSION && 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 === NODE_TYPES.TEXT && 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: any,
id: string,
): SearchIndexItem[] {
const allMarkers: SearchIndexItem[] = [];
const markerMap = new Map<string, SearchIndexItem>();
const childrenIds = new Set<string>();
const normalizedId = id.replace(/\\/g, '/');
if (!templateAst) return allMarkers;
// マーカーの基本情報を収集
function collectMarkers(node: VueAstNode, parentId: string | null = null) {
if (node.type === 1 && node.tag === 'SearchMarker') {
// マーカーID取得
const markerIdProp = node.props?.find((p: any) => p.name === 'markerId');
const markerId = markerIdProp?.value?.content ||
node.__markerId;
// 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,
children: [],
label: '', // デフォルト値
keywords: [],
};
// 静的プロパティを取得
if (node.props && Array.isArray(node.props)) {
for (const prop of node.props) {
if (prop.type === 6 && prop.name && prop.name !== 'markerId') {
if (prop.name === 'path') markerInfo.path = prop.value?.content || '';
else if (prop.name === 'icon') markerInfo.icon = prop.value?.content || '';
else if (prop.name === 'label') markerInfo.label = prop.value?.content || '';
}
}
}
// バインドプロパティを取得
const bindings = extractNodeBindings(node);
if (bindings.path) markerInfo.path = bindings.path;
if (bindings.icon) markerInfo.icon = bindings.icon;
if (bindings.label) markerInfo.label = bindings.label;
if (bindings.children) markerInfo.children = bindings.children;
if (bindings.inlining) {
markerInfo.inlining = bindings.inlining;
logger.info(`Added inlining ${JSON.stringify(bindings.inlining)} to marker ${markerId}`);
}
if (bindings.keywords) {
if (Array.isArray(bindings.keywords)) {
markerInfo.keywords = bindings.keywords;
} else {
markerInfo.keywords = bindings.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);
}
}
// 子ノードを処理
if (node.children && Array.isArray(node.children)) {
node.children.forEach((child: VueAstNode) => {
collectMarkers(child, markerId);
});
}
return markerId;
}
// SearchMarkerでない場合は再帰的に子ードを処理
else if (node.children && Array.isArray(node.children)) {
node.children.forEach((child: VueAstNode) => {
collectMarkers(child, parentId);
});
}
return null;
}
// AST解析開始
collectMarkers(templateAst);
return allMarkers;
}
// バインドプロパティの処理を修正する関数
function extractNodeBindings(node: VueAstNode): Record<keyof SearchIndexItem, any> {
const bindings: Record<string, any> = {};
if (!node.props || !Array.isArray(node.props)) return bindings;
// バインド式を収集
for (const prop of node.props) {
if (prop.type === 7 && prop.name === 'bind' && prop.arg?.content) {
const propName = prop.arg.content;
const propContent = prop.exp?.content || '';
logger.info(`Processing bind prop ${propName}: ${propContent}`);
// inliningプロパティの処理を追加
if (propName === 'inlining') {
try {
const content = propContent.trim();
// 配列式の場合
if (content.startsWith('[') && content.endsWith(']')) {
// 配列要素を解析
const elements = parseArrayExpression(content);
if (elements.length > 0) {
bindings.inlining = elements;
logger.info(`Parsed inlining array: ${JSON5.stringify(elements)}`);
} else {
bindings.inlining = [];
}
}
// 文字列の場合は配列に変換
else if (content) {
bindings.inlining = [content]; // 単一の値を配列に
logger.info(`Converting inlining to array: [${content}]`);
}
} catch (e) {
logger.error(`Failed to parse inlining binding: ${propContent}`, e);
}
}
// keywordsの特殊処理
if (propName === 'keywords') {
try {
const content = propContent.trim();
// 配列式の場合
if (content.startsWith('[') && content.endsWith(']')) {
// i18n参照や特殊な式を保持するため、各要素を個別に解析
const elements = parseArrayExpression(content);
if (elements.length > 0) {
bindings.keywords = elements;
logger.info(`Parsed keywords array: ${JSON5.stringify(elements)}`);
} else {
bindings.keywords = [];
logger.info('Empty keywords array');
}
}
// その他の式(非配列)
else if (content) {
bindings.keywords = content; // 式をそのまま保持
logger.info(`Keeping keywords as expression: ${content}`);
} else {
bindings.keywords = [];
logger.info('No keywords provided');
}
} catch (e) {
logger.error(`Failed to parse keywords binding: ${propContent}`, e);
// エラーが起きても何らかの値を設定
bindings.keywords = propContent || [];
}
}
// その他のプロパティ
else if (propName === 'label') {
// ラベルの場合も式として保持
bindings[propName] = propContent;
logger.info(`Set label from bind expression: ${propContent}`);
}
else {
bindings[propName] = propContent;
}
}
}
return bindings;
}
// 配列式をパースする補助関数(文字列リテラル処理を改善)
function parseArrayExpression(expr: string): any[] {
try {
// 単純なケースはJSON5でパースを試みる
return JSON5.parse(expr.replace(/'/g, '"'));
} catch (e) {
// 複雑なケースi18n.ts.xxx などの式を含む場合)は手動パース
logger.info(`Complex array expression, trying manual parsing: ${expr}`);
// "["と"]"を取り除く
const content = expr.substring(1, expr.length - 1).trim();
if (!content) return [];
const result: any[] = [];
let currentItem = '';
let depth = 0;
let inString = false;
let stringChar = '';
// カンマで区切る(ただし文字列内や入れ子の配列内のカンマは無視)
for (let i = 0; i < content.length; i++) {
const char = content[i];
if (inString) {
if (char === stringChar && content[i - 1] !== '\\') {
inString = false;
}
currentItem += char;
} else if (char === '"' || char === "'") {
inString = true;
stringChar = char;
currentItem += char;
} else if (char === '[') {
depth++;
currentItem += char;
} else if (char === ']') {
depth--;
currentItem += char;
} else if (char === ',' && depth === 0) {
// 項目の区切りを検出
const trimmed = currentItem.trim();
// 純粋な文字列リテラルの場合、実際の値に変換
if ((trimmed.startsWith("'") && trimmed.endsWith("'")) ||
(trimmed.startsWith('"') && trimmed.endsWith('"'))) {
try {
result.push(JSON5.parse(trimmed));
} catch (err) {
result.push(trimmed);
}
} else {
// それ以外の式はそのままi18n.ts.xxx など)
result.push(trimmed);
}
currentItem = '';
} else {
currentItem += char;
}
}
// 最後の項目を処理
if (currentItem.trim()) {
const trimmed = currentItem.trim();
// 純粋な文字列リテラルの場合、実際の値に変換
if ((trimmed.startsWith("'") && trimmed.endsWith("'")) ||
(trimmed.startsWith('"') && trimmed.endsWith('"'))) {
try {
result.push(JSON5.parse(trimmed));
} catch (err) {
result.push(trimmed);
}
} else {
// それ以外の式はそのままi18n.ts.xxx など)
result.push(trimmed);
}
}
logger.info(`Parsed complex array expression: ${expr} -> ${JSON.stringify(result)}`);
return result;
}
}
export async function analyzeVueProps(options: Options & {
transformedCodeCache: Record<string, string>,
}): Promise<void> {
initLogger(options);
const allMarkers: SearchIndexItem[] = [];
// 対象ファイルパスを glob で展開
const filePaths = options.targetFilePaths.reduce<string[]>((acc, filePathPattern) => {
const matchedFiles = glob.sync(filePathPattern);
return [...acc, ...matchedFiles];
}, []);
logger.info(`Found ${filePaths.length} matching files to analyze`);
for (const filePath of filePaths) {
const absolutePath = path.join(process.cwd(), filePath);
const id = absolutePath.replace(/\\/g, '/'); // 絶対パスに変換
const code = options.transformedCodeCache[id]; // options 経由でキャッシュ参照
if (!code) { // キャッシュミスの場合
logger.error(`Error: No cached code found for: ${id}.`); // エラーログ
throw new Error(`No cached code found for: ${id}.`); // エラーを投げる
}
try {
const { descriptor, errors } = vueSfcParse(options.transformedCodeCache[id], {
filename: filePath,
});
if (errors.length > 0) {
logger.error(`Compile Error: ${filePath}, ${errors}`);
continue; // エラーが発生したファイルはスキップ
}
const fileMarkers = extractUsageInfoFromTemplateAst(descriptor.template?.ast, id);
if (fileMarkers && fileMarkers.length > 0) {
allMarkers.push(...fileMarkers); // すべてのマーカーを収集
logger.info(`Successfully extracted ${fileMarkers.length} markers from ${filePath}`);
} else {
logger.info(`No markers found in ${filePath}`);
}
} catch (error) {
logger.error(`Error analyzing file ${filePath}:`, error);
}
}
// 収集したすべてのマーカー情報を使用
const analysisResult: AnalysisResult[] = [
{
filePath: "combined-markers", // すべてのファイルのマーカーを1つのエントリとして扱う
usage: allMarkers,
}
];
outputAnalysisResultAsTS(options.exportFilePath, analysisResult); // すべてのマーカー情報を渡す
}
interface MarkerRelation {
parentId?: string;
markerId: string;
node: VueAstNode;
}
async function processVueFile(
code: string,
id: string,
options: Options,
transformedCodeCache: Record<string, string>
): Promise<{
code: string,
map: any,
transformedCodeCache: Record<string, string>
}> {
const normalizedId = id.replace(/\\/g, '/'); // ファイルパスを正規化
// 開発モード時はコード内容に変更があれば常に再処理する
// コード内容が同じ場合のみキャッシュを使用
const isDevMode = process.env.NODE_ENV === 'development';
const s = new MagicString(code); // magic-string のインスタンスを作成
if (!isDevMode && transformedCodeCache[normalizedId] && transformedCodeCache[normalizedId].includes('markerId=')) {
feat(frontend): 設定の検索 (#15505) * wip * wip * wip * test * wip rollup pluginでsearchIndexの情報生成 * wip * SPDX * wip: markerIdを自動付与 * rollupでビルド時・devモード時に毎回uuidを生成するように * 開発サーバーでだけ必要な挙動は開発サーバーのみで * 条件が逆 * wip: childrenの生成 * update comment * update comment * rename auto generated file * hashをパスと行数から決定 * Update privacy.vue * Update privacy.vue * wip * Update general.vue * Update general.vue * wip * wip * Update SearchMarker.vue * wip * Update profile.vue * Update mute-block.vue * Update mute-block.vue * Update general.vue * Update general.vue * childrenがduplicate key errorを吐く問題をいったん解決 * マーカーの形を成形 * loggerを置きかえ * とりあえず省略記法に対応 * Refactor and Format codes * wip * Update settings-search-index.ts * wip * wip * とりあえず不確定要因の仮置きidを削除 * hashの生成を正規化(絶対パスになっていたのを緩和) * pathの入力を省略可能に * adminでもパス生成できるように * Update settings-search-index.ts * Update privacy.vue * wip * build searchIndex * wip * build * Update general.vue * build * Update sounds.vue * build * build * Update sounds.vue * 🎨 * 🎨 * Update privacy.vue * Update privacy.vue * Update security.vue * create-search-indexを多少改善 * build * Update 2fa.vue * wip * 必ずtransformCodeCacheを利用するように, キャッシュの明確な受け渡しを定義 * キャッシュはdevServerでなくても更新 * Revert "wip" This reverts commit 41bffd3a13f55618bf939dc1c9acb2a77ead4054. * inlining * wip * Update theme.vue * 🎨 * wip normalize * Update theme.vue * キャッシュのパス変換 * build * wip * wip * Update SearchMarker.vue * i18n.ts['key'] の形式が取り出せない問題のFix * build * 仮でpath入れ * 必ず絶対パスが使われるように * wip * 🎨 * storybookビルド時はcreateSearchIndexをしない * inliningの構造化 * format code * Update index.vue * wip * wip * 🎨 * wip * wip * wip * wip * wip * wip * wip * wip * clean up * Update navbar.vue * enhance: 検索で上下矢印を使用することで検索結果を移動できるように * refactor * fix(frontend): PageWindowでSearchMarkerが動作するように * enhance(frontend): SearchMarkerの点滅を一定時間で止める * lint fix * fix: 子要素監視が抜けていたのを修正 * アニメーションの回数はCSSで制御するように * refactor * enhance(frontend): 検索インデックス作成時のログを削減 * revert * fix * fix --------- Co-authored-by: tai-cha <dev@taichan.site> Co-authored-by: taichan <40626578+tai-cha@users.noreply.github.com> Co-authored-by: kakkokari-gtyih <67428053+kakkokari-gtyih@users.noreply.github.com>
2025-03-06 14:15:19 +00:00
logger.info(`Using cached version for ${id}`);
return {
code: transformedCodeCache[normalizedId],
map: s.generateMap({ source: id, includeContent: true }),
transformedCodeCache
};
}
// すでに処理済みのファイルでコードに変更がない場合はキャッシュを返す
if (transformedCodeCache[normalizedId] === code) {
logger.info(`Code unchanged for ${id}, using cached version`);
return {
code: transformedCodeCache[normalizedId],
map: s.generateMap({ source: id, includeContent: true }),
feat(frontend): 設定の検索 (#15505) * wip * wip * wip * test * wip rollup pluginでsearchIndexの情報生成 * wip * SPDX * wip: markerIdを自動付与 * rollupでビルド時・devモード時に毎回uuidを生成するように * 開発サーバーでだけ必要な挙動は開発サーバーのみで * 条件が逆 * wip: childrenの生成 * update comment * update comment * rename auto generated file * hashをパスと行数から決定 * Update privacy.vue * Update privacy.vue * wip * Update general.vue * Update general.vue * wip * wip * Update SearchMarker.vue * wip * Update profile.vue * Update mute-block.vue * Update mute-block.vue * Update general.vue * Update general.vue * childrenがduplicate key errorを吐く問題をいったん解決 * マーカーの形を成形 * loggerを置きかえ * とりあえず省略記法に対応 * Refactor and Format codes * wip * Update settings-search-index.ts * wip * wip * とりあえず不確定要因の仮置きidを削除 * hashの生成を正規化(絶対パスになっていたのを緩和) * pathの入力を省略可能に * adminでもパス生成できるように * Update settings-search-index.ts * Update privacy.vue * wip * build searchIndex * wip * build * Update general.vue * build * Update sounds.vue * build * build * Update sounds.vue * 🎨 * 🎨 * Update privacy.vue * Update privacy.vue * Update security.vue * create-search-indexを多少改善 * build * Update 2fa.vue * wip * 必ずtransformCodeCacheを利用するように, キャッシュの明確な受け渡しを定義 * キャッシュはdevServerでなくても更新 * Revert "wip" This reverts commit 41bffd3a13f55618bf939dc1c9acb2a77ead4054. * inlining * wip * Update theme.vue * 🎨 * wip normalize * Update theme.vue * キャッシュのパス変換 * build * wip * wip * Update SearchMarker.vue * i18n.ts['key'] の形式が取り出せない問題のFix * build * 仮でpath入れ * 必ず絶対パスが使われるように * wip * 🎨 * storybookビルド時はcreateSearchIndexをしない * inliningの構造化 * format code * Update index.vue * wip * wip * 🎨 * wip * wip * wip * wip * wip * wip * wip * wip * clean up * Update navbar.vue * enhance: 検索で上下矢印を使用することで検索結果を移動できるように * refactor * fix(frontend): PageWindowでSearchMarkerが動作するように * enhance(frontend): SearchMarkerの点滅を一定時間で止める * lint fix * fix: 子要素監視が抜けていたのを修正 * アニメーションの回数はCSSで制御するように * refactor * enhance(frontend): 検索インデックス作成時のログを削減 * revert * fix * fix --------- Co-authored-by: tai-cha <dev@taichan.site> Co-authored-by: taichan <40626578+tai-cha@users.noreply.github.com> Co-authored-by: kakkokari-gtyih <67428053+kakkokari-gtyih@users.noreply.github.com>
2025-03-06 14:15:19 +00:00
transformedCodeCache
};
}
const parsed = vueSfcParse(code, { filename: id });
if (!parsed.descriptor.template) {
return {
code,
map: s.generateMap({ source: id, includeContent: true }),
feat(frontend): 設定の検索 (#15505) * wip * wip * wip * test * wip rollup pluginでsearchIndexの情報生成 * wip * SPDX * wip: markerIdを自動付与 * rollupでビルド時・devモード時に毎回uuidを生成するように * 開発サーバーでだけ必要な挙動は開発サーバーのみで * 条件が逆 * wip: childrenの生成 * update comment * update comment * rename auto generated file * hashをパスと行数から決定 * Update privacy.vue * Update privacy.vue * wip * Update general.vue * Update general.vue * wip * wip * Update SearchMarker.vue * wip * Update profile.vue * Update mute-block.vue * Update mute-block.vue * Update general.vue * Update general.vue * childrenがduplicate key errorを吐く問題をいったん解決 * マーカーの形を成形 * loggerを置きかえ * とりあえず省略記法に対応 * Refactor and Format codes * wip * Update settings-search-index.ts * wip * wip * とりあえず不確定要因の仮置きidを削除 * hashの生成を正規化(絶対パスになっていたのを緩和) * pathの入力を省略可能に * adminでもパス生成できるように * Update settings-search-index.ts * Update privacy.vue * wip * build searchIndex * wip * build * Update general.vue * build * Update sounds.vue * build * build * Update sounds.vue * 🎨 * 🎨 * Update privacy.vue * Update privacy.vue * Update security.vue * create-search-indexを多少改善 * build * Update 2fa.vue * wip * 必ずtransformCodeCacheを利用するように, キャッシュの明確な受け渡しを定義 * キャッシュはdevServerでなくても更新 * Revert "wip" This reverts commit 41bffd3a13f55618bf939dc1c9acb2a77ead4054. * inlining * wip * Update theme.vue * 🎨 * wip normalize * Update theme.vue * キャッシュのパス変換 * build * wip * wip * Update SearchMarker.vue * i18n.ts['key'] の形式が取り出せない問題のFix * build * 仮でpath入れ * 必ず絶対パスが使われるように * wip * 🎨 * storybookビルド時はcreateSearchIndexをしない * inliningの構造化 * format code * Update index.vue * wip * wip * 🎨 * wip * wip * wip * wip * wip * wip * wip * wip * clean up * Update navbar.vue * enhance: 検索で上下矢印を使用することで検索結果を移動できるように * refactor * fix(frontend): PageWindowでSearchMarkerが動作するように * enhance(frontend): SearchMarkerの点滅を一定時間で止める * lint fix * fix: 子要素監視が抜けていたのを修正 * アニメーションの回数はCSSで制御するように * refactor * enhance(frontend): 検索インデックス作成時のログを削減 * revert * fix * fix --------- Co-authored-by: tai-cha <dev@taichan.site> Co-authored-by: taichan <40626578+tai-cha@users.noreply.github.com> Co-authored-by: kakkokari-gtyih <67428053+kakkokari-gtyih@users.noreply.github.com>
2025-03-06 14:15:19 +00:00
transformedCodeCache
};
}
const ast = parsed.descriptor.template.ast; // テンプレート AST を取得
const markerRelations: MarkerRelation[] = []; // MarkerRelation 配列を初期化
if (ast) {
function traverse(node: any, currentParent?: any) {
if (node.type === 1 && node.tag === 'SearchMarker') {
// 行番号はコード先頭からの改行数で取得
const lineNumber = code.slice(0, node.loc.start.offset).split('\n').length;
// ファイルパスと行番号からハッシュ値を生成
// この際実行環境で差が出ないようにファイルパスを正規化
const idKey = id.replace(/\\/g, '/').split('packages/frontend/')[1]
const generatedMarkerId = toBase62(hash(`${idKey}:${lineNumber}`));
const props = node.props || [];
const hasMarkerIdProp = props.some((prop: any) => prop.type === 6 && prop.name === 'markerId');
const nodeMarkerId = hasMarkerIdProp
? props.find((prop: any) => prop.type === 6 && prop.name === 'markerId')?.value?.content as string
: generatedMarkerId;
node.__markerId = nodeMarkerId;
// 子マーカーの場合、親ノードに __children を設定しておく
if (currentParent && currentParent.type === 1 && currentParent.tag === 'SearchMarker') {
currentParent.__children = currentParent.__children || [];
currentParent.__children.push(nodeMarkerId);
}
const parentMarkerId = currentParent && currentParent.__markerId;
markerRelations.push({
parentId: parentMarkerId,
markerId: nodeMarkerId,
node: node,
});
if (!hasMarkerIdProp) {
const nodeStart = node.loc.start.offset;
let endOfStartTag;
if (node.children && node.children.length > 0) {
// 子要素がある場合、最初の子要素の開始位置を基準にする
endOfStartTag = code.lastIndexOf('>', node.children[0].loc.start.offset);
} else if (node.loc.end.offset > nodeStart) {
// 子要素がない場合、自身の終了位置から逆算
const nodeSource = code.substring(nodeStart, node.loc.end.offset);
// 自己終了タグか通常の終了タグかを判断
if (nodeSource.includes('/>')) {
endOfStartTag = code.indexOf('/>', nodeStart) - 1;
} else {
endOfStartTag = code.indexOf('>', nodeStart);
}
}
if (endOfStartTag !== undefined && endOfStartTag !== -1) {
// markerId が既に存在しないことを確認
const tagText = code.substring(nodeStart, endOfStartTag + 1);
const markerIdRegex = /\s+markerId\s*=\s*["'][^"']*["']/;
if (!markerIdRegex.test(tagText)) {
s.appendRight(endOfStartTag, ` markerId="${generatedMarkerId}" 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 = node.type === 1 && node.tag === 'SearchMarker' ? node : currentParent;
if (node.children && Array.isArray(node.children)) {
node.children.forEach(child => traverse(child, newParent));
}
}
traverse(ast); // AST を traverse (1段階目: ID 生成と親子関係記録)
// 2段階目: :children 属性の追加
// 最初に親マーカーごとに子マーカーIDを集約する処理を追加
const parentChildrenMap = new Map<string, string[]>();
// 1. まず親ごとのすべての子マーカーIDを収集
markerRelations.forEach(relation => {
if (relation.parentId) {
if (!parentChildrenMap.has(relation.parentId)) {
parentChildrenMap.set(relation.parentId, []);
}
parentChildrenMap.get(relation.parentId)?.push(relation.markerId);
}
});
// 2. 親ごとにまとめて :children 属性を処理
for (const [parentId, childIds] of parentChildrenMap.entries()) {
const parentRelation = markerRelations.find(r => r.markerId === parentId);
if (!parentRelation || !parentRelation.node) continue;
const parentNode = parentRelation.node;
const childrenProp = parentNode.props?.find((prop: any) => prop.type === 7 && prop.name === 'bind' && prop.arg?.content === 'children');
// 親ノードの開始位置を特定
const parentNodeStart = parentNode.loc!.start.offset;
const endOfParentStartTag = parentNode.children && parentNode.children.length > 0
? code.lastIndexOf('>', parentNode.children[0].loc!.start.offset)
: code.indexOf('>', parentNodeStart);
if (endOfParentStartTag === -1) continue;
// 親タグのテキストを取得
const parentTagText = code.substring(parentNodeStart, endOfParentStartTag + 1);
if (childrenProp) {
// AST で :children 属性が検出された場合、それを更新
try {
const childrenStart = code.indexOf('[', childrenProp.exp!.loc.start.offset);
const childrenEnd = code.indexOf(']', childrenProp.exp!.loc.start.offset);
if (childrenStart !== -1 && childrenEnd !== -1) {
const childrenArrayStr = code.slice(childrenStart, childrenEnd + 1);
let childrenArray = JSON5.parse(childrenArrayStr.replace(/'/g, '"'));
// 新しいIDを追加重複は除外
const newIds = childIds.filter(id => !childrenArray.includes(id));
if (newIds.length > 0) {
childrenArray = [...childrenArray, ...newIds];
const updatedChildrenArrayStr = JSON5.stringify(childrenArray).replace(/"/g, "'");
s.overwrite(childrenStart, childrenEnd + 1, updatedChildrenArrayStr);
logger.info(`Added ${newIds.length} child markerIds to existing :children in ${id}`);
}
}
} catch (e) {
logger.error('Error updating :children attribute:', e);
}
} else {
// AST では検出されなかった場合、タグテキストを調べる
const childrenRegex = /:children\s*=\s*["']\[(.*?)\]["']/;
const childrenMatch = parentTagText.match(childrenRegex);
if (childrenMatch) {
// テキストから :children 属性値を解析して更新
try {
const childrenContent = childrenMatch[1];
const childrenArrayStr = `[${childrenContent}]`;
const childrenArray = JSON5.parse(childrenArrayStr.replace(/'/g, '"'));
// 新しいIDを追加重複は除外
const newIds = childIds.filter(id => !childrenArray.includes(id));
if (newIds.length > 0) {
childrenArray.push(...newIds);
// :children="[...]" の位置を特定して上書き
const attrStart = parentTagText.indexOf(':children=');
if (attrStart > -1) {
const attrValueStart = parentTagText.indexOf('[', attrStart);
const attrValueEnd = parentTagText.indexOf(']', attrValueStart) + 1;
if (attrValueStart > -1 && attrValueEnd > -1) {
const absoluteStart = parentNodeStart + attrValueStart;
const absoluteEnd = parentNodeStart + attrValueEnd;
const updatedArrayStr = JSON5.stringify(childrenArray).replace(/"/g, "'");
s.overwrite(absoluteStart, absoluteEnd, updatedArrayStr);
logger.info(`Updated existing :children in tag text for ${id}`);
}
}
}
} catch (e) {
logger.error('Error updating :children in tag text:', e);
}
} else {
// :children 属性がまだない場合、新規作成
s.appendRight(endOfParentStartTag, ` :children="${JSON5.stringify(childIds).replace(/"/g, "'")}"`);
logger.info(`Created new :children attribute with ${childIds.length} markerIds in ${id}`);
}
}
}
}
const transformedCode = s.toString(); // 変換後のコードを取得
transformedCodeCache[normalizedId] = transformedCode; // 変換後のコードをキャッシュに保存
return {
code: transformedCode, // 変更後のコードを返す
map: s.generateMap({ source: id, includeContent: true }), // ソースマップも生成 (sourceMap: true が必要)
transformedCodeCache // キャッシュも返す
};
}
// Rollup プラグインとして export
export default function pluginCreateSearchIndex(options: Options): Plugin {
let transformedCodeCache: Record<string, string> = {}; // キャッシュオブジェクトをプラグインスコープで定義
const isDevServer = process.env.NODE_ENV === 'development'; // 開発サーバーかどうか
initLogger(options); // ロガーを初期化
return {
name: 'createSearchIndex',
enforce: 'pre',
async buildStart() {
if (!isDevServer) {
return;
}
const filePaths = options.targetFilePaths.reduce<string[]>((acc, filePathPattern) => {
const matchedFiles = glob.sync(filePathPattern);
return [...acc, ...matchedFiles];
}, []);
for (const filePath of filePaths) {
const id = path.resolve(filePath); // 絶対パスに変換
const code = fs.readFileSync(filePath, 'utf-8'); // ファイル内容を読み込む
const { transformedCodeCache: newCache } = await processVueFile(code, id, options, transformedCodeCache); // processVueFile 関数を呼び出す
transformedCodeCache = newCache; // キャッシュを更新
}
await analyzeVueProps({ ...options, transformedCodeCache }); // 開発サーバー起動時にも analyzeVueProps を実行
},
async transform(code, id) {
if (!id.endsWith('.vue')) {
return;
}
// targetFilePaths にマッチするファイルのみ処理を行う
// glob パターンでマッチング
let isMatch = false; // isMatch の初期値を false に設定
for (const pattern of options.targetFilePaths) { // パターンごとにマッチング確認
const globbedFiles = glob.sync(pattern);
for (const globbedFile of globbedFiles) {
const normalizedGlobbedFile = path.resolve(globbedFile); // glob 結果を絶対パスに
const normalizedId = path.resolve(id); // id を絶対パスに
if (normalizedGlobbedFile === normalizedId) { // 絶対パス同士で比較
isMatch = true;
break; // マッチしたらループを抜ける
}
}
if (isMatch) break; // いずれかのパターンでマッチしたら、outer loop も抜ける
}
if (!isMatch) {
return;
}
// ファイルの内容が変更された場合は再処理を行う
const normalizedId = id.replace(/\\/g, '/');
const hasContentChanged = !transformedCodeCache[normalizedId] || transformedCodeCache[normalizedId] !== code;
feat(frontend): 設定の検索 (#15505) * wip * wip * wip * test * wip rollup pluginでsearchIndexの情報生成 * wip * SPDX * wip: markerIdを自動付与 * rollupでビルド時・devモード時に毎回uuidを生成するように * 開発サーバーでだけ必要な挙動は開発サーバーのみで * 条件が逆 * wip: childrenの生成 * update comment * update comment * rename auto generated file * hashをパスと行数から決定 * Update privacy.vue * Update privacy.vue * wip * Update general.vue * Update general.vue * wip * wip * Update SearchMarker.vue * wip * Update profile.vue * Update mute-block.vue * Update mute-block.vue * Update general.vue * Update general.vue * childrenがduplicate key errorを吐く問題をいったん解決 * マーカーの形を成形 * loggerを置きかえ * とりあえず省略記法に対応 * Refactor and Format codes * wip * Update settings-search-index.ts * wip * wip * とりあえず不確定要因の仮置きidを削除 * hashの生成を正規化(絶対パスになっていたのを緩和) * pathの入力を省略可能に * adminでもパス生成できるように * Update settings-search-index.ts * Update privacy.vue * wip * build searchIndex * wip * build * Update general.vue * build * Update sounds.vue * build * build * Update sounds.vue * 🎨 * 🎨 * Update privacy.vue * Update privacy.vue * Update security.vue * create-search-indexを多少改善 * build * Update 2fa.vue * wip * 必ずtransformCodeCacheを利用するように, キャッシュの明確な受け渡しを定義 * キャッシュはdevServerでなくても更新 * Revert "wip" This reverts commit 41bffd3a13f55618bf939dc1c9acb2a77ead4054. * inlining * wip * Update theme.vue * 🎨 * wip normalize * Update theme.vue * キャッシュのパス変換 * build * wip * wip * Update SearchMarker.vue * i18n.ts['key'] の形式が取り出せない問題のFix * build * 仮でpath入れ * 必ず絶対パスが使われるように * wip * 🎨 * storybookビルド時はcreateSearchIndexをしない * inliningの構造化 * format code * Update index.vue * wip * wip * 🎨 * wip * wip * wip * wip * wip * wip * wip * wip * clean up * Update navbar.vue * enhance: 検索で上下矢印を使用することで検索結果を移動できるように * refactor * fix(frontend): PageWindowでSearchMarkerが動作するように * enhance(frontend): SearchMarkerの点滅を一定時間で止める * lint fix * fix: 子要素監視が抜けていたのを修正 * アニメーションの回数はCSSで制御するように * refactor * enhance(frontend): 検索インデックス作成時のログを削減 * revert * fix * fix --------- Co-authored-by: tai-cha <dev@taichan.site> Co-authored-by: taichan <40626578+tai-cha@users.noreply.github.com> Co-authored-by: kakkokari-gtyih <67428053+kakkokari-gtyih@users.noreply.github.com>
2025-03-06 14:15:19 +00:00
const transformed = await processVueFile(code, id, options, transformedCodeCache);
transformedCodeCache = transformed.transformedCodeCache; // キャッシュを更新
if (isDevServer && hasContentChanged) {
await analyzeVueProps({ ...options, transformedCodeCache }); // ファイルが変更されたときのみ分析を実行
feat(frontend): 設定の検索 (#15505) * wip * wip * wip * test * wip rollup pluginでsearchIndexの情報生成 * wip * SPDX * wip: markerIdを自動付与 * rollupでビルド時・devモード時に毎回uuidを生成するように * 開発サーバーでだけ必要な挙動は開発サーバーのみで * 条件が逆 * wip: childrenの生成 * update comment * update comment * rename auto generated file * hashをパスと行数から決定 * Update privacy.vue * Update privacy.vue * wip * Update general.vue * Update general.vue * wip * wip * Update SearchMarker.vue * wip * Update profile.vue * Update mute-block.vue * Update mute-block.vue * Update general.vue * Update general.vue * childrenがduplicate key errorを吐く問題をいったん解決 * マーカーの形を成形 * loggerを置きかえ * とりあえず省略記法に対応 * Refactor and Format codes * wip * Update settings-search-index.ts * wip * wip * とりあえず不確定要因の仮置きidを削除 * hashの生成を正規化(絶対パスになっていたのを緩和) * pathの入力を省略可能に * adminでもパス生成できるように * Update settings-search-index.ts * Update privacy.vue * wip * build searchIndex * wip * build * Update general.vue * build * Update sounds.vue * build * build * Update sounds.vue * 🎨 * 🎨 * Update privacy.vue * Update privacy.vue * Update security.vue * create-search-indexを多少改善 * build * Update 2fa.vue * wip * 必ずtransformCodeCacheを利用するように, キャッシュの明確な受け渡しを定義 * キャッシュはdevServerでなくても更新 * Revert "wip" This reverts commit 41bffd3a13f55618bf939dc1c9acb2a77ead4054. * inlining * wip * Update theme.vue * 🎨 * wip normalize * Update theme.vue * キャッシュのパス変換 * build * wip * wip * Update SearchMarker.vue * i18n.ts['key'] の形式が取り出せない問題のFix * build * 仮でpath入れ * 必ず絶対パスが使われるように * wip * 🎨 * storybookビルド時はcreateSearchIndexをしない * inliningの構造化 * format code * Update index.vue * wip * wip * 🎨 * wip * wip * wip * wip * wip * wip * wip * wip * clean up * Update navbar.vue * enhance: 検索で上下矢印を使用することで検索結果を移動できるように * refactor * fix(frontend): PageWindowでSearchMarkerが動作するように * enhance(frontend): SearchMarkerの点滅を一定時間で止める * lint fix * fix: 子要素監視が抜けていたのを修正 * アニメーションの回数はCSSで制御するように * refactor * enhance(frontend): 検索インデックス作成時のログを削減 * revert * fix * fix --------- Co-authored-by: tai-cha <dev@taichan.site> Co-authored-by: taichan <40626578+tai-cha@users.noreply.github.com> Co-authored-by: kakkokari-gtyih <67428053+kakkokari-gtyih@users.noreply.github.com>
2025-03-06 14:15:19 +00:00
}
feat(frontend): 設定の検索 (#15505) * wip * wip * wip * test * wip rollup pluginでsearchIndexの情報生成 * wip * SPDX * wip: markerIdを自動付与 * rollupでビルド時・devモード時に毎回uuidを生成するように * 開発サーバーでだけ必要な挙動は開発サーバーのみで * 条件が逆 * wip: childrenの生成 * update comment * update comment * rename auto generated file * hashをパスと行数から決定 * Update privacy.vue * Update privacy.vue * wip * Update general.vue * Update general.vue * wip * wip * Update SearchMarker.vue * wip * Update profile.vue * Update mute-block.vue * Update mute-block.vue * Update general.vue * Update general.vue * childrenがduplicate key errorを吐く問題をいったん解決 * マーカーの形を成形 * loggerを置きかえ * とりあえず省略記法に対応 * Refactor and Format codes * wip * Update settings-search-index.ts * wip * wip * とりあえず不確定要因の仮置きidを削除 * hashの生成を正規化(絶対パスになっていたのを緩和) * pathの入力を省略可能に * adminでもパス生成できるように * Update settings-search-index.ts * Update privacy.vue * wip * build searchIndex * wip * build * Update general.vue * build * Update sounds.vue * build * build * Update sounds.vue * 🎨 * 🎨 * Update privacy.vue * Update privacy.vue * Update security.vue * create-search-indexを多少改善 * build * Update 2fa.vue * wip * 必ずtransformCodeCacheを利用するように, キャッシュの明確な受け渡しを定義 * キャッシュはdevServerでなくても更新 * Revert "wip" This reverts commit 41bffd3a13f55618bf939dc1c9acb2a77ead4054. * inlining * wip * Update theme.vue * 🎨 * wip normalize * Update theme.vue * キャッシュのパス変換 * build * wip * wip * Update SearchMarker.vue * i18n.ts['key'] の形式が取り出せない問題のFix * build * 仮でpath入れ * 必ず絶対パスが使われるように * wip * 🎨 * storybookビルド時はcreateSearchIndexをしない * inliningの構造化 * format code * Update index.vue * wip * wip * 🎨 * wip * wip * wip * wip * wip * wip * wip * wip * clean up * Update navbar.vue * enhance: 検索で上下矢印を使用することで検索結果を移動できるように * refactor * fix(frontend): PageWindowでSearchMarkerが動作するように * enhance(frontend): SearchMarkerの点滅を一定時間で止める * lint fix * fix: 子要素監視が抜けていたのを修正 * アニメーションの回数はCSSで制御するように * refactor * enhance(frontend): 検索インデックス作成時のログを削減 * revert * fix * fix --------- Co-authored-by: tai-cha <dev@taichan.site> Co-authored-by: taichan <40626578+tai-cha@users.noreply.github.com> Co-authored-by: kakkokari-gtyih <67428053+kakkokari-gtyih@users.noreply.github.com>
2025-03-06 14:15:19 +00:00
return transformed;
},
async writeBundle() {
await analyzeVueProps({ ...options, transformedCodeCache }); // ビルド時にも analyzeVueProps を実行
},
};
}
// 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);
}