diff --git a/packages/frontend/lib/vite-plugin-create-search-index.ts b/packages/frontend/lib/vite-plugin-create-search-index.ts
index dd846b6fec..2e67c0a05c 100644
--- a/packages/frontend/lib/vite-plugin-create-search-index.ts
+++ b/packages/frontend/lib/vite-plugin-create-search-index.ts
@@ -3,6 +3,8 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
+///
+
import { parse as vueSfcParse } from 'vue/compiler-sfc';
import {
createLogger,
@@ -95,116 +97,13 @@ function generateJavaScriptCode(resolvedRootMarkers: SearchIndexItem[]): string
* オブジェクトを特殊な形式の文字列に変換する
* i18n参照を保持しつつ適切な形式に変換
*/
-function customStringify(obj: unknown, 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;
- 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: unknown): 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: unknown[]): 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 customStringify(obj: unknown): string {
+ return JSON.stringify(obj).replaceAll(/"(.*?)"/g, (all, group) => {
+ // propertyAccessProxy が i18n 参照を "${i18n.xxx}"のような形に変換してるので、これをそのまま`${i18n.xxx}`
+ // のような形にすると、実行時にi18nのプロパティにアクセスするようになる。
+ // objectのkeyでは``が使えないので、${ が使われている場合にのみ``に置き換えるようにする
+ return group.includes('${') ? '`' + group + '`' : all;
+ });
}
/**
@@ -269,7 +168,7 @@ function extractDirectContent(node: TemplateChildNode): string | null {
// 直接i18n参照を含む場合
if (isI18nReference(content)) {
logger.info(`Direct i18n reference found: ${content}`);
- return content;
+ return '$\{' + content + '}';
}
// その他のコンテンツ
@@ -289,7 +188,7 @@ function extractInterpolationContent(children: TemplateChildNode[]): string | nu
logger.info(`Interpolation content: ${content}`);
if (isI18nReference(content)) {
- return content;
+ return '$\{' + content + '}';
}
} else if (child.content && typeof child.content === 'object') {
if (child.content.type == NodeTypes.COMPOUND_EXPRESSION) throw new Error("Unexpected COMPOUND_EXPRESSION");
@@ -301,7 +200,7 @@ function extractInterpolationContent(children: TemplateChildNode[]): string | nu
if (isI18nReference(content)) {
logger.info(`Found i18n reference in complex interpolation: ${content}`);
- return content;
+ return '$\{' + content + '}';
}
}
}
@@ -322,7 +221,7 @@ function extractExpressionContent(children: TemplateChildNode[]): string | null
if (isI18nReference(expr)) {
logger.info(`Found i18n reference in expression node: ${expr}`);
- return expr;
+ return '$\{' + expr + '}';
}
}
}
@@ -356,7 +255,7 @@ function extractTextContent(children: TemplateChildNode[]): string | null {
if (mustacheMatch && mustacheMatch[1] && isI18nReference(mustacheMatch[1])) {
logger.info(`Extracted i18n ref from text mustache: ${mustacheMatch[1]}`);
- return mustacheMatch[1].trim();
+ return '$\{' + mustacheMatch[1] + '}';
}
return text;
@@ -578,19 +477,26 @@ function extractUsageInfoFromTemplateAst(
// バインドプロパティを取得
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;
+
+ const assertString = (value: unknown, key: string): string => {
+ if (typeof value !== 'string') throw new Error(`Invalid type for ${key} in marker ${markerId}: expected string, got ${typeof value}`);
+ return value;
+ }
+
+ const assertStringArray = (value: unknown, key: string): string[] => {
+ if (!Array.isArray(value) || !value.every(x => typeof x === 'string')) throw new Error(`Invalid type for ${key} in marker ${markerId}: expected string array`);
+ return value;
+ }
+
+ if (bindings.path) markerInfo.path = assertString(bindings.path, 'path');
+ if (bindings.icon) markerInfo.icon = assertString(bindings.icon, 'icon');
+ if (bindings.label) markerInfo.label = assertString(bindings.label, 'label');
if (bindings.inlining) {
- markerInfo.inlining = bindings.inlining;
+ markerInfo.inlining = assertStringArray(bindings.inlining, '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 || [];
- }
+ markerInfo.keywords = assertStringArray(bindings.keywords, 'keywords');
}
//pathがない場合はファイルパスを設定
@@ -667,11 +573,7 @@ function extractUsageInfoFromTemplateAst(
return allMarkers;
}
-type SpecialBindings = {
- inlining: string[];
- keywords: string[] | string;
-};
-type Bindings = Partial, keyof SpecialBindings> & SpecialBindings>;
+type Bindings = Partial>;
// バインドプロパティの処理を修正する関数
function extractNodeBindings(node: TemplateChildNode | RootNode): Bindings {
const bindings: Bindings = {};
@@ -687,161 +589,72 @@ function extractNodeBindings(node: TemplateChildNode | RootNode): Bindings {
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の特殊処理
- else 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;
- }
+ bindings[propName] = evalExpression(propContent);
}
}
return bindings;
}
-// 配列式をパースする補助関数(文字列リテラル処理を改善)
-function parseArrayExpression(expr: string): string[] {
- try {
- // 単純なケースはJSON5でパースを試みる
- return JSON5.parse(expr.replace(/'/g, '"'));
- } catch (e) {
- // 複雑なケース(i18n.ts.xxx などの式を含む場合)は手動パース
- logger.info(`Complex array expression, trying manual parsing: ${expr}`);
+/**
+ * expr を実行します。
+ * i18n はそのアクセスを保持するために propertyAccessProxy を使用しています。
+ */
+function evalExpression(expr: string): unknown {
+ const rarResult = Function('i18n', `return ${expr}`)(i18nProxy);
+ // JSON.stringify を一回通すことで、 AccessProxy を文字列に変換する
+ // Walk してもいいんだけど横着してJSON.stringifyしてる。ビルド時にしか通らないのであんまりパフォーマンス気にする必要ないんで
+ return JSON.parse(JSON.stringify(rarResult));
+}
- // "["と"]"を取り除く
- const content = expr.substring(1, expr.length - 1).trim();
- if (!content) return [];
+const propertyAccessProxySymbol = Symbol('propertyAccessProxySymbol');
- const result: string[] = [];
- let currentItem = '';
- let depth = 0;
- let inString = false;
- let stringChar = '';
+type AccessProxy = {
+ [propertyAccessProxySymbol]: string[],
+ [k: string]: AccessProxy,
+}
- // カンマで区切る(ただし文字列内や入れ子の配列内のカンマは無視)
- 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;
- }
+const propertyAccessProxyHandler: ProxyHandler = {
+ get(target: AccessProxy, p: string | symbol): any {
+ if (p in target) {
+ return (target as any)[p];
}
-
- // 最後の項目を処理
- 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);
- }
+ if (p == "toJSON" || p == Symbol.toPrimitive) {
+ return propertyAccessProxyToJSON;
}
-
- logger.info(`Parsed complex array expression: ${expr} -> ${JSON.stringify(result)}`);
- return result;
+ if (typeof p == 'string') {
+ return target[p] = propertyAccessProxy([...target[propertyAccessProxySymbol], p]);
+ }
+ return undefined;
}
}
+function propertyAccessProxyToJSON(this: AccessProxy, hint: string) {
+ const expression = this[propertyAccessProxySymbol].reduce((prev, current) => {
+ if (current.match(/^[a-z][0-9a-z]*$/i)) {
+ return `${prev}.${current}`;
+ } else {
+ return `${prev}['${current}']`;
+ }
+ });
+ return '$\{' + expression + '}';
+}
+
+/**
+ * プロパティのアクセスを保持するための Proxy オブジェクトを作成します。
+ *
+ * この関数で生成した proxy は JSON でシリアライズするか、`${}`のように string にすると、 ${property.path} のような形になる。
+ * @param path
+ */
+function propertyAccessProxy(path: string[]): AccessProxy {
+ const target: AccessProxy = {
+ [propertyAccessProxySymbol]: path,
+ };
+ return new Proxy(target, propertyAccessProxyHandler);
+}
+
+const i18nProxy = propertyAccessProxy(['i18n']);
+
export function collectFileMarkers(id: string, code: string): SearchIndexItem[] {
try {
const { descriptor, errors } = vueSfcParse(code, {