feat: separate slug pages from tools

This commit is contained in:
2026-09-27 16:10:28 +05:00
parent 931ee27b05
commit 7a3498c173
35 changed files with 1436 additions and 993 deletions
+18 -30
View File
@@ -1,10 +1,10 @@
import { afterEach, describe, expect, it } from "vitest";
import { TOOLS } from "../registry";
import { PAGES } from "../registry";
import { setLocale } from "./locale.svelte";
import {
searchTools,
toolDescription,
toolTitle,
pageDescription,
pageTitle,
searchPages,
verdictText,
} from "./schema-tool-strings";
@@ -12,53 +12,41 @@ afterEach(() => {
setLocale("ru");
});
const ids = (tools: { id: string }[]) => tools.map((t) => t.id);
const slugs = (pages: { slug: string }[]) => pages.map((page) => page.slug);
describe("searchTools (кросс-языковой поиск каталога)", () => {
describe("searchPages (кросс-языковой поиск каталога)", () => {
it("пустой запрос возвращает весь каталог", () => {
expect(searchTools(TOOLS, "")).toHaveLength(TOOLS.length);
expect(searchTools(TOOLS, " ")).toHaveLength(TOOLS.length);
expect(searchPages(PAGES, "")).toHaveLength(PAGES.length);
expect(searchPages(PAGES, " ")).toHaveLength(PAGES.length);
});
it("находит ru-название, даже когда локаль en", () => {
setLocale("en");
expect(ids(searchTools(TOOLS, "обрез"))).toContain("crop-png");
expect(ids(searchTools(TOOLS, "пустые поля"))).toContain(
expect(slugs(searchPages(PAGES, "обрез"))).toContain("crop-png");
expect(slugs(searchPages(PAGES, "пустые поля"))).toContain(
"trim-empty-space-png",
);
});
it("находит en-название, даже когда локаль ru", () => {
expect(ids(searchTools(TOOLS, "resize"))).toContain("resize-png");
expect(ids(searchTools(TOOLS, "round corners"))).toContain(
expect(slugs(searchPages(PAGES, "resize"))).toContain("resize-png");
expect(slugs(searchPages(PAGES, "round corners"))).toContain(
"round-corners-png",
);
});
it("находит по id", () => {
expect(ids(searchTools(TOOLS, "crop png"))).toContain("crop-png");
it("находит по slug (тексту адреса)", () => {
expect(slugs(searchPages(PAGES, "crop png"))).toContain("crop-png");
});
it("ё и е — один запрос", () => {
const byYo = searchTools(TOOLS, "чёрно");
const byYe = searchTools(TOOLS, "черно");
expect(ids(byYo)).toEqual(ids(byYe));
const byYo = searchPages(PAGES, "чёрно");
const byYe = searchPages(PAGES, "черно");
expect(slugs(byYo)).toEqual(slugs(byYe));
});
it("нет совпадения — пустой список", () => {
expect(searchTools(TOOLS, "квантовый тостер")).toEqual([]);
});
});
describe("metadata fallback", () => {
it("uses registry title and description in the base locale", () => {
setLocale("en");
for (const tool of TOOLS) {
expect(toolTitle(tool), `${tool.id}: title`).toBe(tool.title);
expect(toolDescription(tool), `${tool.id}: description`).toBe(
tool.description,
);
}
expect(searchPages(PAGES, "квантовый тостер")).toEqual([]);
});
});
+16 -19
View File
@@ -1,4 +1,4 @@
import type { ToolEntry } from "$lib/registry";
import type { Page } from "$lib/registry";
import type { Field } from "$lib/registry-schema";
import { getMergedDict } from "./locale.svelte";
import { normalizeForSearch, scoreDoc, type SearchDoc } from "./matching";
@@ -12,12 +12,12 @@ function labelOf(id: string): string {
.replace(/\b\w/g, (c) => c.toUpperCase());
}
export function toolTitle(tool: ToolEntry): string {
return getMergedDict().tools[tool.id]?.title ?? tool.title;
export function pageTitle(page: Page): string {
return getMergedDict().tools[page.slug]?.title ?? page.title;
}
export function toolDescription(tool: ToolEntry): string {
return getMergedDict().tools[tool.id]?.description ?? tool.description;
export function pageDescription(page: Page): string {
return getMergedDict().tools[page.slug]?.description ?? page.description;
}
export function fieldLabel(field: Field<unknown>, id: string): string {
@@ -64,33 +64,30 @@ function dedupe(values: string[]): string[] {
return [...new Set(values.filter((v) => v.length > 0))];
}
export function toolSearchDoc(tool: ToolEntry): SearchDoc {
const active = getMergedDict().tools[tool.id];
export function pageSearchDoc(page: Page): SearchDoc {
const active = getMergedDict().tools[page.slug];
return {
id: tool.id,
id: page.slug,
titles: dedupe([
active?.title ?? "",
ru.tools[tool.id]?.title ?? "",
tool.title,
ru.tools[page.slug]?.title ?? "",
page.title,
]),
descriptions: dedupe([
active?.description ?? "",
ru.tools[tool.id]?.description ?? "",
tool.description,
ru.tools[page.slug]?.description ?? "",
page.description,
]),
};
}
/**
* Кросс-языковой поиск по каталогу: скор через `scoreDoc` на
* `toolSearchDoc` (заголовки/описания всех локалей). Порядок каталога
* `pageSearchDoc` (заголовки/описания всех локалей). Порядок каталога
* сохраняется, элементы без совпадения отбрасываются.
*/
export function searchTools(
tools: readonly ToolEntry[],
query: string,
): ToolEntry[] {
export function searchPages(pages: readonly Page[], query: string): Page[] {
const q = normalizeForSearch(query.trim());
if (q.length === 0) return [...tools];
return tools.filter((tool) => scoreDoc(toolSearchDoc(tool), q) !== null);
if (q.length === 0) return [...pages];
return pages.filter((page) => scoreDoc(pageSearchDoc(page), q) !== null);
}
+5 -5
View File
@@ -3,8 +3,8 @@ import { getLocale, setLocale } from "./locale.svelte";
import { LOCALE_TAGS } from "./dict";
import { normalizeForSearch } from "./matching";
import { t } from "./t";
import { getTool, TOOLS } from "../registry";
import { toolTitle } from "./schema-tool-strings";
import { getPageBySlug, PAGES } from "../registry";
import { pageTitle } from "./schema-tool-strings";
afterEach(() => {
setLocale("ru");
@@ -52,11 +52,11 @@ describe("Смоук §6 i18n (новый UI)", () => {
});
it("4. Заголовок инструмента переключается локалями (schema-tool-strings)", () => {
const tool = getTool("crop-png") ?? TOOLS[0];
const page = getPageBySlug("crop-png") ?? PAGES[0];
setLocale("ru");
expect(toolTitle(tool)).toContain("Обрезать");
expect(pageTitle(page)).toContain("Обрезать");
setLocale("en");
expect(toolTitle(tool)).toBe(tool.title);
expect(pageTitle(page)).toBe(page.title);
});
it("5. Ошибки с vars локализуются на оба языка", () => {
+16 -13
View File
@@ -1,9 +1,9 @@
import { describe, expect, it } from "vitest";
import { TOOLS, type ToolEntry } from "../registry";
import { PAGES, TOOLS, type Tool } from "../registry";
import { en } from "./en";
import { ru } from "./ru";
function toolKeys(tool: ToolEntry): { fields: string[]; groups: string[] } {
function toolKeys(tool: Tool): { fields: string[]; groups: string[] } {
const fields: string[] = [];
for (const field of Object.values(tool.schema?.fields ?? {})) {
const label = (field as { spec?: { label?: string } }).spec?.label;
@@ -18,7 +18,7 @@ function toolKeys(tool: ToolEntry): { fields: string[]; groups: string[] } {
type SelectOption = { value: string; label: string };
function selectOptions(
tool: ToolEntry,
tool: Tool,
): { fieldId: string; option: SelectOption }[] {
const result: { fieldId: string; option: SelectOption }[] = [];
for (const [fieldId, field] of Object.entries(tool.schema?.fields ?? {})) {
@@ -32,12 +32,12 @@ function selectOptions(
}
describe("полнота словарей для нового registry", () => {
it("у каждого инструмента есть перевод ru с непустыми title/description", () => {
for (const tool of TOOLS) {
const strings = ru.tools[tool.id];
expect(strings, `нет перевода ru для ${tool.id}`).toBeDefined();
expect(strings?.title, `${tool.id}: title`).toBeTruthy();
expect(strings?.description, `${tool.id}: description`).toBeTruthy();
it("у каждой страницы есть перевод ru с непустыми title/description", () => {
for (const page of PAGES) {
const strings = ru.tools[page.slug];
expect(strings, `нет перевода ru для ${page.slug}`).toBeDefined();
expect(strings?.title, `${page.slug}: title`).toBeTruthy();
expect(strings?.description, `${page.slug}: description`).toBeTruthy();
}
});
@@ -75,11 +75,14 @@ describe("полнота словарей для нового registry", () => {
}
});
it("в словарях нет лишних инструментов", () => {
const ids = new Set(TOOLS.map((tool) => tool.id));
it("в словарях нет ключей без записи в реестре", () => {
const known = new Set([
...TOOLS.map((tool) => tool.id),
...PAGES.map((page) => page.slug),
]);
for (const dict of [ru.tools, en.tools]) {
for (const id of Object.keys(dict)) {
expect(ids.has(id), `лишний инструмент в словаре: ${id}`).toBe(true);
for (const key of Object.keys(dict)) {
expect(known.has(key), `лишний ключ в словаре: ${key}`).toBe(true);
}
}
});