diff --git a/.gitignore b/.gitignore index ac7502f384..eec33ea593 100644 --- a/.gitignore +++ b/.gitignore @@ -64,6 +64,7 @@ temp /packages/frontend/src/**/*.stories.ts tsdoc-metadata.json misskey-assets +/packages/frontend/src/scripts/autogen/search-index.ts # Vite temporary files vite.config.js.timestamp-* diff --git a/packages/frontend/lib/rollup-plugin-create-search-index.ts b/packages/frontend/lib/rollup-plugin-create-search-index.ts deleted file mode 100644 index cb2a3cd751..0000000000 --- a/packages/frontend/lib/rollup-plugin-create-search-index.ts +++ /dev/null @@ -1,253 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and misskey-project - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { parse as vueSfcParse } from 'vue/compiler-sfc'; -import type { Plugin } from 'rollup'; -import fs from 'node:fs'; -import { glob } from 'glob'; -import JSON5 from 'json5'; -import { randomUUID } from 'crypto'; -import MagicString from 'magic-string'; -import path from 'node:path' - -export interface AnalysisResult { - filePath: string; - usage: ComponentUsageInfo[]; -} - -export interface ComponentUsageInfo { - staticProps: Record; - bindProps: Record; - componentName: string; -} - -function outputAnalysisResultAsTS(outputPath: string, analysisResults: AnalysisResult[]): void { - // (outputAnalysisResultAsTS 関数の実装は前回と同様) - const varName = 'searchIndexes'; //  変数名 - - const jsonString = JSON5.stringify(analysisResults, { space: "\t", quote: "'" }); //  JSON.stringify で JSON 文字列を生成 - - //  bindProps の値を文字列置換で修正する関数 - function modifyBindPropsInString(jsonString: string): string { - const modifiedString = jsonString.replace( - /bindProps:\s*\{([^}]*)\}/g, //  bindProps: { ... } にマッチ (g フラグで複数箇所を置換) - (match, bindPropsBlock) => { - //  bindPropsBlock ( { ... } 内) の各プロパティをさらに置換 - const modifiedBlock = bindPropsBlock.replace( - /(.*):\s*\'(.*)\'/g, //  propName: 'propValue' にマッチ - (propMatch, propName, propValue) => { - return `${propName}: ${propValue}`; // propValue のクォートを除去 - } - ).replaceAll("\\'", "'"); - return `bindProps: {${modifiedBlock}}`; //  置換後の block で bindProps: { ... } を再構成 - } - ); - return modifiedString; - } - - - const tsOutput = ` -/* - * 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 const ${varName} = ${modifyBindPropsInString(jsonString)} as const; - -export type AnalysisResults = typeof ${varName}; -export type ComponentUsageInfo = AnalysisResults[number]['usage'][number]; -`; - - try { - fs.writeFileSync(outputPath, tsOutput, 'utf-8'); - console.log(`[create-search-index]: output done. ${outputPath}`); - } catch (error) { - console.error('[create-search-index]: error: ', error); - } -} - -function extractUsageInfoFromTemplateAst( - templateAst: any, - targetComponents: string[] -): ComponentUsageInfo[] { - const usageInfoList: ComponentUsageInfo[] = []; - - if (!templateAst) { - return usageInfoList; - } - - function traverse(node: any) { - if (node.type === 1 /* ELEMENT */ && node.tag && targetComponents.includes(node.tag)) { - const componentTag = node.tag; - - const staticProps: Record = {}; - const bindProps: Record = {}; // bindProps の型を string に戻す - - if (node.props && Array.isArray(node.props)) { - node.props.forEach((prop: any) => { - if (prop.type === 6 /* ATTRIBUTE */) { // type 6 は StaticAttribute - staticProps[prop.name] = prop.value?.content || ''; //  属性値を文字列として取得 - } else if (prop.type === 7 /* DIRECTIVE */ && prop.name === 'bind' && prop.arg?.content) { // type 7 は DirectiveNode, v-bind:propName の場合 - if (prop.exp?.content && prop.arg.content !== 'class') { - bindProps[prop.arg.content] = prop.exp.content; // prop.exp.content (文字列) を格納 - } - } - }); - } - - usageInfoList.push({ - staticProps, - bindProps, - componentName: componentTag, - }); - - } else if (node.children && Array.isArray(node.children)) { - node.children.forEach(child => traverse(child)); - } - } - - traverse(templateAst); - return usageInfoList; -} - -export async function analyzeVueProps(options: { - targetComponents: string[], - targetFilePaths: string[], - exportFilePath: string, - transformedCodeCache: Record -}): Promise { - - const targetComponents = options.targetComponents || []; - const analysisResults: AnalysisResult[] = []; - - //  対象ファイルパスを glob で展開 - const filePaths = options.targetFilePaths.reduce((acc, filePathPattern) => { - const matchedFiles = glob.sync(filePathPattern); - return [...acc, ...matchedFiles]; - }, []); - - - for (const filePath of filePaths) { - // ★ キャッシュから変換済みコードを取得 (修正): キャッシュに存在しない場合はエラーにする (キャッシュ必須) - const code = options.transformedCodeCache[path.resolve(filePath)]; // キャッシュからコードを取得 (キャッシュミス時は undefined) - if (!code) { // キャッシュミスの場合 - console.error(`[create-search-index] Error: No cached code found for: ${filePath}.`); // エラーログ - continue; - } - console.log(`[create-search-index] analyzeVueProps: Processing file: ${filePath}, using cached code: true`); // ★ ログ: キャッシュ使用 - const { descriptor, errors } = vueSfcParse(code, { - filename: filePath, - }); - - if (errors.length) { - console.error(`[create-search-index] Compile Error: ${filePath}`, errors); - continue; // エラーが発生したファイルはスキップ - } - - // テンプレートASTを走査してコンポーネント使用箇所とpropsの値を取得 - const usageInfo = extractUsageInfoFromTemplateAst(descriptor.template?.ast, targetComponents); - if (!usageInfo) continue; - - if (usageInfo.length > 0) { - analysisResults.push({ - filePath: filePath, - usage: usageInfo, - }); - } - } - - outputAnalysisResultAsTS(options.exportFilePath, analysisResults); // outputAnalysisResultAsTS を呼び出す -} - -// Rollup プラグインとして export -export default function pluginCreateSearchIndex(options: { - targetComponents: string[], - targetFilePaths: string[], - exportFilePath: string -}): Plugin { - const transformedCodeCache: Record = {}; // キャッシュオブジェクトを定義 - - return { - name: 'createSearchIndex', - - async transform(code, id) { - if (!id.endsWith('.vue')) { - return null; - } - - // targetFilePaths にマッチするファイルのみ処理を行う - // glob パターンでマッチング - let fullFileName = ''; - - 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; - fullFileName = normalizedId; - break; // マッチしたらループを抜ける - } - } - if (isMatch) break; // いずれかのパターンでマッチしたら、outer loop も抜ける - } - - - if (!isMatch) { - return null; - } - console.log(`[create-search-index] Processing file: ${id}`); // ログ: マッチしたファイルを処理中 - - const s = new MagicString(code); // magic-string のインスタンスを作成 - const ast = vueSfcParse(code, { filename: id }).descriptor.template?.ast; // テンプレート AST を取得 - - if (ast) { - function traverse(node: any) { - if (node.type === 1 /* ELEMENT */ && node.tag === 'MkSearchMarker') { // MkSearchMarker コンポーネントを検出 - const markerId = randomUUID(); // UUID を生成 - const props = node.props || []; - const hasMarkerIdProp = props.some((prop: any) => prop.type === 6 && prop.name === 'markerId'); // markerId 属性が既に存在するか確認 - - if (!hasMarkerIdProp) { - // magic-string を使って markerId 属性を に追加 - const startTagEnd = code.indexOf('>', node.loc.start.offset); // 開始タグの閉じ > の位置を検索 - if (startTagEnd !== -1) { - s.appendRight(startTagEnd, ` markerId="${markerId}"`); //  markerId 属性を追記 - console.log(`[create-search-index] 付与 markerId="${markerId}" to MkSearchMarker in ${id}`); // 付与ログ - } - } - } - - if (node.children && Array.isArray(node.children)) { - node.children.forEach(child => traverse(child)); // 子ノードを再帰的に traverse - } - } - traverse(ast); // AST を traverse - - const transformedCode = s.toString(); // ★ 変換後のコードを取得 - transformedCodeCache[id] = transformedCode; // ★ 変換後のコードをキャッシュに保存 - - return { - code: transformedCode, // 変更後のコードを返す - map: s.generateMap({ source: id, includeContent: true }), // ソースマップも生成 (sourceMap: true が必要) - }; - } - - return null; // テンプレート AST がない場合は null を返す - }, - - - async writeBundle() { - await analyzeVueProps({ ...options, transformedCodeCache }); // writeBundle フックで analyzeVueProps 関数を呼び出す (変更なし) - }, - }; -} diff --git a/packages/frontend/lib/vite-plugin-create-search-index.ts b/packages/frontend/lib/vite-plugin-create-search-index.ts new file mode 100644 index 0000000000..33f34e2818 --- /dev/null +++ b/packages/frontend/lib/vite-plugin-create-search-index.ts @@ -0,0 +1,276 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { parse as vueSfcParse } from 'vue/compiler-sfc'; +import type { Plugin } from 'vite'; +import fs from 'node:fs'; +import { glob } from 'glob'; +import JSON5 from 'json5'; +import { randomUUID } from 'crypto'; +import MagicString from 'magic-string'; +import path from 'node:path' + +export interface AnalysisResult { + filePath: string; + usage: ComponentUsageInfo[]; +} + +export interface ComponentUsageInfo { + staticProps: Record; + bindProps: Record; + componentName: string; +} + +function outputAnalysisResultAsTS(outputPath: string, analysisResults: AnalysisResult[]): void { + // (outputAnalysisResultAsTS 関数の実装は前回と同様) + const varName = 'searchIndexes'; //  変数名 + + const jsonString = JSON5.stringify(analysisResults, { space: "\t", quote: "'" }); //  JSON.stringify で JSON 文字列を生成 + + //  bindProps の値を文字列置換で修正する関数 + function modifyBindPropsInString(jsonString: string): string { + const modifiedString = jsonString.replace( + /bindProps:\s*\{([^}]*)\}/g, //  bindProps: { ... } にマッチ (g フラグで複数箇所を置換) + (match, bindPropsBlock) => { + //  bindPropsBlock ( { ... } 内) の各プロパティをさらに置換 + const modifiedBlock = bindPropsBlock.replace( + /(.*):\s*\'(.*)\'/g, //  propName: 'propValue' にマッチ + (propMatch, propName, propValue) => { + return `${propName}: ${propValue}`; // propValue のクォートを除去 + } + ).replaceAll("\\'", "'"); + return `bindProps: {${modifiedBlock}}`; //  置換後の block で bindProps: { ... } を再構成 + } + ); + return modifiedString; + } + + + const tsOutput = ` +/* + * 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 const ${varName} = ${modifyBindPropsInString(jsonString)} as const; + +export type AnalysisResults = typeof ${varName}; +export type ComponentUsageInfo = AnalysisResults[number]['usage'][number]; +`; + + try { + fs.writeFileSync(outputPath, tsOutput, 'utf-8'); + console.log(`[create-search-index]: output done. ${outputPath}`); + } catch (error) { + console.error('[create-search-index]: error: ', error); + } +} + +function extractUsageInfoFromTemplateAst( + templateAst: any, + targetComponents: string[] +): ComponentUsageInfo[] { + const usageInfoList: ComponentUsageInfo[] = []; + + if (!templateAst) { + return usageInfoList; + } + + function traverse(node: any) { + if (node.type === 1 /* ELEMENT */ && node.tag && targetComponents.includes(node.tag)) { + const componentTag = node.tag; + + const staticProps: Record = {}; + const bindProps: Record = {}; // bindProps の型を string に戻す + + if (node.props && Array.isArray(node.props)) { + node.props.forEach((prop: any) => { + if (prop.type === 6 /* ATTRIBUTE */) { // type 6 は StaticAttribute + staticProps[prop.name] = prop.value?.content || ''; //  属性値を文字列として取得 + } else if (prop.type === 7 /* DIRECTIVE */ && prop.name === 'bind' && prop.arg?.content) { // type 7 は DirectiveNode, v-bind:propName の場合 + if (prop.exp?.content && prop.arg.content !== 'class') { + bindProps[prop.arg.content] = prop.exp.content; // prop.exp.content (文字列) を格納 + } + } + }); + } + + usageInfoList.push({ + staticProps, + bindProps, + componentName: componentTag, + }); + + } else if (node.children && Array.isArray(node.children)) { + node.children.forEach(child => traverse(child)); + } + } + + traverse(templateAst); + return usageInfoList; +} + +export async function analyzeVueProps(options: { + targetComponents: string[], + targetFilePaths: string[], + exportFilePath: string, + transformedCodeCache: Record // ★ transformedCodeCache を options から受け取る +}): Promise { + + const targetComponents = options.targetComponents || []; + const analysisResults: AnalysisResult[] = []; + + //  対象ファイルパスを glob で展開 + const filePaths = options.targetFilePaths.reduce((acc, filePathPattern) => { + const matchedFiles = glob.sync(filePathPattern); + return [...acc, ...matchedFiles]; + }, []); + + + for (const filePath of filePaths) { + // ★ キャッシュから変換済みコードを取得 (修正): キャッシュに存在しない場合はエラーにする (キャッシュ必須) + const code = options.transformedCodeCache[path.resolve(filePath)]; // options 経由でキャッシュ参照 + if (!code) { // キャッシュミスの場合 + console.error(`[create-search-index] Error: No cached code found for: ${filePath}.`); // エラーログ + continue; + } + const { descriptor, errors } = vueSfcParse(code, { + filename: filePath, + }); + + if (errors.length) { + console.error(`[create-search-index] Compile Error: ${filePath}`, errors); + continue; // エラーが発生したファイルはスキップ + } + + // テンプレートASTを走査してコンポーネント使用箇所とpropsの値を取得 + const usageInfo = extractUsageInfoFromTemplateAst(descriptor.template?.ast, targetComponents); + if (!usageInfo) continue; + + if (usageInfo.length > 0) { + analysisResults.push({ + filePath: filePath, + usage: usageInfo, + }); + } + } + + outputAnalysisResultAsTS(options.exportFilePath, analysisResults); // outputAnalysisResultAsTS を呼び出す +} + +async function processVueFile( + code: string, + id: string, + options: { targetComponents: string[], targetFilePaths: string[], exportFilePath: string }, + transformedCodeCache: Record +) { + const s = new MagicString(code); // magic-string のインスタンスを作成 + const ast = vueSfcParse(code, { filename: id }).descriptor.template?.ast; // テンプレート AST を取得 + + if (ast) { + function traverse(node: any) { + if (node.type === 1 /* ELEMENT */ && node.tag === 'MkSearchMarker') { // MkSearchMarker コンポーネントを検出 + const markerId = randomUUID(); // UUID を生成 + const props = node.props || []; + const hasMarkerIdProp = props.some((prop: any) => prop.type === 6 && prop.name === 'markerId'); // markerId 属性が既に存在するか確認 + + if (!hasMarkerIdProp) { + // magic-string を使って markerId 属性を に追加 + const startTagEnd = code.indexOf('>', node.loc.start.offset); // 開始タグの閉じ > の位置を検索 + if (startTagEnd !== -1) { + s.appendRight(startTagEnd, ` markerId="${markerId}"`); // markerId 属性を追記 + } + } + } + + if (node.children && Array.isArray(node.children)) { + node.children.forEach(child => traverse(child)); // 子ノードを再帰的に traverse + } + } + traverse(ast); // AST を traverse + } + + const transformedCode = s.toString(); // ★ 変換後のコードを取得 + transformedCodeCache[id] = transformedCode; // ★ 変換後のコードをキャッシュに保存 + + return { + code: transformedCode, // 変更後のコードを返す + map: s.generateMap({ source: id, includeContent: true }), // ソースマップも生成 (sourceMap: true が必要) + }; +} + + +// Rollup プラグインとして export +export default function pluginCreateSearchIndex(options: { + targetComponents: string[], + targetFilePaths: string[], + exportFilePath: string +}): Plugin { + let transformedCodeCache: Record = {}; // ★ キャッシュオブジェクトをプラグインスコープで定義 + + return { + name: 'createSearchIndex', + enforce: 'pre', + + async buildStart() { + transformedCodeCache = {}; + + const filePaths = options.targetFilePaths.reduce((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'); // ファイル内容を読み込む + await processVueFile(code, id, options, transformedCodeCache); // processVueFile 関数を呼び出す + } + + + await analyzeVueProps({ ...options, transformedCodeCache }); // 開発サーバー起動時にも analyzeVueProps を実行 + }, + + async transform(code, id) { + if (!id.endsWith('.vue')) { + return null; + } + + // 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 null; + } + console.log(`[create-search-index] transform: Processing file: ${id}`); // ログ: transform で処理中のファイル + + const transformed = await processVueFile(code, id, options, transformedCodeCache); + await analyzeVueProps({ ...options, transformedCodeCache }); // analyzeVueProps を呼び出す + return transformed; + }, + + async writeBundle() { + await analyzeVueProps({ ...options, transformedCodeCache }); // ビルド時にも analyzeVueProps を実行 + }, + }; +} diff --git a/packages/frontend/src/scripts/autogen/.gitkeep b/packages/frontend/src/scripts/autogen/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/frontend/src/scripts/autogen/search-index.ts b/packages/frontend/src/scripts/autogen/search-index.ts deleted file mode 100644 index 4207aae35b..0000000000 --- a/packages/frontend/src/scripts/autogen/search-index.ts +++ /dev/null @@ -1,68 +0,0 @@ - -/* - * 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 const searchIndexes = [ - { - filePath: 'src/pages/settings/profile.vue', - usage: [ - { - staticProps: { - markerId: '727cc9e8-ad67-474a-9241-b5a9a6475e47', - }, - bindProps: {}, - componentName: 'MkSearchMarker', - }, - { - staticProps: { - markerId: '1a06c7f9-e85e-46cb-bf5f-b3efa8e71b93', - }, - bindProps: {}, - componentName: 'MkSearchMarker', - }, - ], - }, - { - filePath: 'src/pages/settings/privacy.vue', - usage: [ - { - staticProps: { - icon: 'ti ti-lock-open', - markerId: 'db7de893-e299-40af-a515-8954da435f4b', - }, - bindProps: { - locationLabel: [i18n.ts.privacy, i18n.ts.makeFollowManuallyApprove], - keywords: ['follow', 'lock', i18n.ts.lockedAccountInfo], - }, - componentName: 'MkSearchMarker', - }, - ], - }, - { - filePath: 'src/pages/settings/mute-block.vue', - usage: [ - { - staticProps: { - markerId: 'test', - icon: 'ti ti-ban', - }, - bindProps: { - locationLabel: [i18n.ts.muteAndBlock], - keywords: ['mute', i18n.ts.wordMute], - children: ['test2'], - }, - componentName: 'MkSearchMarker', - }, - ], - }, -] as const; - -export type AnalysisResults = typeof searchIndexes; -export type ComponentUsageInfo = AnalysisResults[number]['usage'][number]; diff --git a/packages/frontend/vite.config.ts b/packages/frontend/vite.config.ts index 771530092e..0323a1ced6 100644 --- a/packages/frontend/vite.config.ts +++ b/packages/frontend/vite.config.ts @@ -10,7 +10,7 @@ import meta from '../../package.json'; import packageInfo from './package.json' with { type: 'json' }; import pluginUnwindCssModuleClassName from './lib/rollup-plugin-unwind-css-module-class-name.js'; import pluginJson5 from './vite.json5.js'; -import pluginCreateSearchIndex from './lib/rollup-plugin-create-search-index.js'; +import pluginCreateSearchIndex from './lib/vite-plugin-create-search-index.js'; const url = process.env.NODE_ENV === 'development' ? yaml.load(await fsp.readFile('../../.config/default.yml', 'utf-8')).url : null; const host = url ? (new URL(url)).hostname : undefined;