Add Obsidian Toolbox — 4 syntax generators under /tools

Inside-LMS toolbox for registered students: callout-CSS, YAML-frontmatter,
theme CSS-variables, Style Settings generators. Auth inherited from (student)
route group; no public surface, no email gate.

- Pure generators in src/lib/tools/* with Vitest (33 tests)
- Shared YAML-safe scalar quoter (_shared/yaml.ts) used by frontmatter +
  style-settings: unquoted user input was silently breaking YAML (tags '#x'
  -> null, color default '#7C3AED' -> null, 'a: b' -> parse error)
- hex validation in callout (no NaN), date input constrained, clipboard +
  analytics calls guarded
- Prisma ToolUsage model + migration; logToolUsage Server Action (auth-first,
  10-min dedup window per user/tool)
- Tool index /tools + ToolCard (explicit lucide map, no import *) + header link

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-23 15:45:52 +05:00
parent f8aa27533c
commit b6d555ab3b
30 changed files with 1713 additions and 46 deletions
+25
View File
@@ -0,0 +1,25 @@
"use server";
import { headers } from "next/headers";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { TOOL_IDS, type ToolId } from "@/lib/tools/_shared/types";
// Дедуп-окно: один (userId, tool) пишем раз в N минут — чистит аналитику и
// снимает storage-abuse (Server Action — публичный RPC, не покрыт Better Auth rateLimit).
const DEDUP_WINDOW_MS = 10 * 60 * 1000;
export async function logToolUsage({ tool }: { tool: ToolId }): Promise<{ ok: boolean }> {
// auth-first: сессия раньше валидации ввода
const session = await auth.api.getSession({ headers: await headers() });
if (!session) return { ok: false };
if (!TOOL_IDS.includes(tool)) return { ok: false };
const recent = await prisma.toolUsage.findFirst({
where: { userId: session.user.id, tool, createdAt: { gte: new Date(Date.now() - DEDUP_WINDOW_MS) } },
select: { id: true },
});
if (recent) return { ok: true };
await prisma.toolUsage.create({ data: { userId: session.user.id, tool } });
return { ok: true };
}
+35
View File
@@ -0,0 +1,35 @@
import { describe, it, expect } from "vitest";
import { buildCalloutCss, normalizeCalloutId } from "@/lib/tools/callout";
describe("normalizeCalloutId", () => {
it("слагифицирует пробелы и регистр", () => {
expect(normalizeCalloutId("My Note ")).toBe("my-note");
});
it("выкидывает недопустимые символы", () => {
expect(normalizeCalloutId("Идея!@# 2")).toBe("2");
});
});
describe("buildCalloutCss", () => {
it("генерирует css с data-callout и rgb", () => {
const r = buildCalloutCss({ id: "idea", title: "Идея", color: "#7C3AED", icon: "lightbulb" });
expect(r.css).toContain('[data-callout="idea"]');
expect(r.css).toContain("--callout-color: 124, 58, 237;");
expect(r.css).toContain("--callout-icon: lucide-lightbulb;");
});
it("поддерживает короткий hex", () => {
const r = buildCalloutCss({ id: "x", title: "X", color: "#fff", icon: "star" });
expect(r.css).toContain("--callout-color: 255, 255, 255;");
});
it("кидает на пустом id", () => {
expect(() => buildCalloutCss({ id: "!!!", title: "", color: "#000", icon: "x" })).toThrow();
});
it("markdown содержит [!id]", () => {
const r = buildCalloutCss({ id: "warn", title: "Внимание", color: "#f00", icon: "alert" });
expect(r.markdown).toContain("> [!warn] Внимание");
});
it("кидает на некорректном HEX (не отдаёт NaN)", () => {
expect(() => buildCalloutCss({ id: "x", title: "X", color: "#zz", icon: "star" })).toThrow();
expect(() => buildCalloutCss({ id: "x", title: "X", color: "#1234", icon: "star" })).toThrow();
});
});
@@ -0,0 +1,81 @@
import { describe, it, expect } from "vitest";
import { buildFrontmatter } from "@/lib/tools/frontmatter";
describe("buildFrontmatter", () => {
it("оборачивает в --- ---", () => {
const y = buildFrontmatter([{ key: "title", type: "text", value: "Заметка" }]);
expect(y.startsWith("---\n")).toBe(true);
expect(y.endsWith("\n---")).toBe(true);
expect(y).toContain("title: Заметка");
});
it("number без кавычек", () => {
expect(buildFrontmatter([{ key: "rating", type: "number", value: "5" }])).toContain("rating: 5");
});
it("checkbox в boolean", () => {
expect(buildFrontmatter([{ key: "done", type: "checkbox", value: "true" }])).toContain("done: true");
});
it("tags как yaml-список", () => {
const y = buildFrontmatter([{ key: "tags", type: "tags", value: "a, b" }]);
expect(y).toContain("tags: \n - a\n - b");
});
it("пропускает поля без key", () => {
expect(buildFrontmatter([{ key: " ", type: "text", value: "x" }])).toBe("---\n---");
});
it("простой текст остаётся без кавычек", () => {
expect(buildFrontmatter([{ key: "title", type: "text", value: "Заметка" }])).toContain("title: Заметка");
});
});
describe("buildFrontmatter — безопасный YAML-квотинг text", () => {
it("квотит двоеточие+пробел (иначе ScannerError)", () => {
expect(buildFrontmatter([{ key: "title", type: "text", value: "Урок 3: введение" }]))
.toContain('title: "Урок 3: введение"');
});
it("квотит ведущий @ (хэндлы)", () => {
expect(buildFrontmatter([{ key: "tg", type: "text", value: "@dmitriylaukhin" }]))
.toContain('tg: "@dmitriylaukhin"');
});
it("квотит ведущий # (иначе тихая потеря в null)", () => {
expect(buildFrontmatter([{ key: "note", type: "text", value: "#важное" }]))
.toContain('note: "#важное"');
});
it("квотит число-подобный текст (сохраняет ведущие нули)", () => {
expect(buildFrontmatter([{ key: "code", type: "text", value: "007" }]))
.toContain('code: "007"');
});
it("квотит время HH:MM (иначе YAML читает как base-60)", () => {
expect(buildFrontmatter([{ key: "at", type: "text", value: "10:30" }]))
.toContain('at: "10:30"');
});
it("квотит boolean-подобный текст", () => {
expect(buildFrontmatter([{ key: "ans", type: "text", value: "true" }]))
.toContain('ans: "true"');
});
it("внутренние кавычки в середине — валидный plain-scalar, без квотинга", () => {
expect(buildFrontmatter([{ key: "q", type: "text", value: 'фраза "в кавычках"' }]))
.toContain('q: фраза "в кавычках"');
});
it("экранирует кавычки, когда значение всё же требует квотинга", () => {
expect(buildFrontmatter([{ key: "q", type: "text", value: '"в кавычках"' }]))
.toContain('q: "\\"в кавычках\\""');
});
it("date с двоеточием квотится", () => {
expect(buildFrontmatter([{ key: "d", type: "date", value: "10:30" }]))
.toContain('d: "10:30"');
});
});
describe("buildFrontmatter — list/tags элементы", () => {
it("снимает ведущий # у тегов (иначе #tag → null)", () => {
const y = buildFrontmatter([{ key: "tags", type: "tags", value: "#важное, проект" }]);
expect(y).toContain(" - важное");
expect(y).toContain(" - проект");
expect(y).not.toContain("- #важное");
});
it("квотит элемент списка с двоеточием+пробелом", () => {
const y = buildFrontmatter([{ key: "steps", type: "list", value: "шаг: один, два" }]);
expect(y).toContain(' - "шаг: один"');
expect(y).toContain(" - два");
});
});
@@ -0,0 +1,35 @@
import { describe, it, expect } from "vitest";
import { buildStyleSettings } from "@/lib/tools/style-settings";
describe("buildStyleSettings", () => {
it("оборачивает в /* @settings ... */", () => {
const css = buildStyleSettings({ pluginName: "My Theme", settings: [] });
expect(css.startsWith("/* @settings")).toBe(true);
expect(css.trim().endsWith("*/")).toBe(true);
expect(css).toContain("name: My Theme");
expect(css).toContain("id: my-theme");
});
it("variable-color добавляет format", () => {
const css = buildStyleSettings({ pluginName: "T", settings: [{ id: "accent", title: "Accent", type: "variable-color", default: "#000", format: "hex" }] });
expect(css).toContain("type: variable-color");
expect(css).toContain("format: hex");
});
it("variable-select рендерит options списком", () => {
const css = buildStyleSettings({ pluginName: "T", settings: [{ id: "font", title: "Font", type: "variable-select", default: "a", options: "a, b" }] });
expect(css).toContain("options:");
expect(css).toContain("- a");
expect(css).toContain("- b");
});
it("heading не получает default", () => {
const css = buildStyleSettings({ pluginName: "T", settings: [{ id: "h", title: "H", type: "heading", default: "x" }] });
expect(css).not.toContain("default:");
});
it("квотит hex-дефолт цвета (иначе # = коммент YAML → null)", () => {
const css = buildStyleSettings({ pluginName: "T", settings: [{ id: "accent", title: "Accent", type: "variable-color", default: "#7C3AED", format: "hex" }] });
expect(css).toContain('default: "#7C3AED"');
});
it("квотит title с двоеточием+пробелом (иначе ломает весь блок)", () => {
const css = buildStyleSettings({ pluginName: "T", settings: [{ id: "s", title: "Шрифт: основной", type: "variable-text", default: "Inter" }] });
expect(css).toContain('title: "Шрифт: основной"');
});
});
+19
View File
@@ -0,0 +1,19 @@
import { describe, it, expect } from "vitest";
import { buildThemeCss } from "@/lib/tools/theme";
describe("buildThemeCss", () => {
const input = { background: "#0F0F0F", text: "#EAEAEA", accent: "#7C3AED", link: "#4EA1FF", border: "#333333" };
it("оборачивает в body { }", () => {
const css = buildThemeCss(input);
expect(css.startsWith("body {")).toBe(true);
expect(css.trim().endsWith("}")).toBe(true);
});
it("мапит accent в --interactive-accent", () => {
expect(buildThemeCss(input)).toContain("--interactive-accent: #7C3AED;");
});
it("содержит все 5 переменных", () => {
const css = buildThemeCss(input);
["--background-primary", "--text-normal", "--interactive-accent", "--link-color", "--background-modifier-border"]
.forEach((v) => expect(css).toContain(v));
});
});
@@ -0,0 +1,10 @@
{
"checkedAt": "20260623",
"callout": { "ref": "Obsidian callouts (core)", "version": "obsidian-1.x" },
"frontmatter": { "ref": "Obsidian Properties (core)", "version": "obsidian-1.x" },
"styleSettings": {
"ref": "obsidian-community/obsidian-style-settings",
"version": "1.x",
"types": ["heading", "class-toggle", "class-select", "variable-text", "variable-number", "variable-number-slider", "variable-select", "variable-color", "info-text"]
}
}
+23
View File
@@ -0,0 +1,23 @@
export type ToolId = "callout" | "frontmatter" | "theme" | "style-settings";
export const TOOL_IDS: ToolId[] = [
"callout",
"frontmatter",
"theme",
"style-settings",
];
export interface ToolMeta {
id: ToolId;
title: string;
description: string;
/** lucide icon name, рендерится на индексе */
icon: string;
}
export const TOOLS: ToolMeta[] = [
{ id: "callout", title: "Генератор callout-CSS", description: "Кастомные коллауты: тип, цвет, иконка, snippet для CSS.", icon: "MessageSquareQuote" },
{ id: "frontmatter", title: "Генератор YAML-frontmatter", description: "Шаблоны свойств заметок: типы, дефолты, теги.", icon: "FileCode2" },
{ id: "theme", title: "Генератор темы / CSS-переменных", description: "Палитра → набор CSS-переменных темы Obsidian.", icon: "Palette" },
{ id: "style-settings", title: "Генератор Style Settings", description: "Комментарии-настройки для плагина Style Settings.", icon: "SlidersHorizontal" },
];
+35
View File
@@ -0,0 +1,35 @@
// Безопасная сериализация YAML-скаляра. Общая для генераторов frontmatter и
// style-settings: пользовательский ввод не должен ломать/тихо терять данные в YAML.
// YAML plain-scalar indicator chars: значение, начинающееся с любого из них,
// нужно квотить, иначе парсер прочитает его как seq/map/anchor/tag/comment/число.
const LEADING_INDICATORS = new Set([
"-", "?", ":", ",", "[", "]", "{", "}", "#", "&", "*", "!", "|", ">", "'", '"', "%", "@", "`",
]);
/**
* True, когда значение сломает или коэрцитнёт тип, если вывести его как plain-scalar.
* Покрывает: пустое, окружающие пробелы, ведущие индикаторы, "двоеточие+пробел",
* inline-комментарий "#", и значения, выглядящие как число/булево/null/дата/время.
*/
export function yamlScalarNeedsQuotes(v: string): boolean {
if (v === "") return true;
if (v !== v.trim()) return true;
if (LEADING_INDICATORS.has(v[0])) return true;
if (v.includes(": ") || v.endsWith(":")) return true;
if (v.includes(" #")) return true;
if (/[\n\t]/.test(v)) return true;
if (/^(true|false|yes|no|on|off|null|~)$/i.test(v)) return true; // bool / null
if (/^[+-]?(\d+\.?\d*|\.\d+)([eE][+-]?\d+)?$/.test(v)) return true; // number
if (/^0x[0-9a-fA-F]+$/.test(v)) return true; // hex int
if (/^\d{1,2}:\d{2}(:\d{2})?$/.test(v)) return true; // time HH:MM(:SS) — YAML читает как base-60
if (/^\d{4}-\d{2}-\d{2}([Tt ].*)?$/.test(v)) return true; // date / datetime-looking
return false;
}
/** Вывести значение как безопасный YAML-скаляр — в кавычках только когда нужно. */
export function yamlScalar(v: string): string {
return yamlScalarNeedsQuotes(v)
? '"' + v.replace(/\\/g, "\\\\").replace(/"/g, '\\"') + '"'
: v;
}
+52
View File
@@ -0,0 +1,52 @@
export interface CalloutInput {
/** слаг типа: только [a-z0-9-] */
id: string;
/** человекочитаемый заголовок по умолчанию */
title: string;
/** HEX-цвет акцента, напр. #7C3AED */
color: string;
/** имя lucide-иконки (Obsidian мапит на свой набор по lucide-id) */
icon: string;
}
export interface CalloutResult {
css: string;
markdown: string;
}
function hexToRgb(hex: string): string {
const h = hex.replace("#", "").trim();
if (!/^([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.test(h)) throw new Error("invalid hex color");
const full = h.length === 3 ? h.split("").map((c) => c + c).join("") : h;
const r = parseInt(full.slice(0, 2), 16);
const g = parseInt(full.slice(2, 4), 16);
const b = parseInt(full.slice(4, 6), 16);
return `${r}, ${g}, ${b}`;
}
export function normalizeCalloutId(raw: string): string {
return raw
.toLowerCase()
.trim()
.replace(/[^a-z0-9-\s]/g, "")
.replace(/\s+/g, "-")
.replace(/-+/g, "-")
.replace(/^-|-$/g, "");
}
export function buildCalloutCss(input: CalloutInput): CalloutResult {
const id = normalizeCalloutId(input.id);
if (!id) throw new Error("callout id is empty after normalization");
const rgb = hexToRgb(input.color);
const css = [
`.callout[data-callout="${id}"] {`,
` --callout-color: ${rgb};`,
` --callout-icon: lucide-${input.icon};`,
`}`,
].join("\n");
const markdown = `> [!${id}] ${input.title}\n> Текст коллаута.`;
return { css, markdown };
}
+39
View File
@@ -0,0 +1,39 @@
import { yamlScalar } from "./_shared/yaml";
export type PropType = "text" | "number" | "checkbox" | "date" | "list" | "tags";
export interface PropField {
key: string;
type: PropType;
/** дефолтное значение в строковом виде из формы */
value?: string;
}
function yamlValue(field: PropField): string {
switch (field.type) {
case "number": return field.value ?? "0";
case "checkbox": return field.value === "true" ? "true" : "false";
case "date": return yamlScalar(field.value ?? "");
case "list":
case "tags": {
// Obsidian-теги в frontmatter пишутся БЕЗ ведущего "#"; иначе "#tag" в YAML = коммент → null.
const strip = field.type === "tags";
const items = (field.value ?? "")
.split(",")
.map((s) => s.trim())
.filter(Boolean)
.map((s) => (strip ? s.replace(/^#+/, "") : s))
.filter(Boolean);
if (items.length === 0) return "[]";
return "\n" + items.map((i) => ` - ${yamlScalar(i)}`).join("\n");
}
default: return yamlScalar(field.value ?? "");
}
}
export function buildFrontmatter(fields: PropField[]): string {
const lines = fields
.filter((f) => f.key.trim())
.map((f) => `${f.key.trim()}: ${yamlValue(f)}`);
return ["---", ...lines, "---"].join("\n");
}
+56
View File
@@ -0,0 +1,56 @@
import { yamlScalar } from "./_shared/yaml";
export type SettingType =
| "heading" | "class-toggle" | "class-select"
| "variable-text" | "variable-number" | "variable-number-slider"
| "variable-select" | "variable-color" | "info-text";
export interface SettingItem {
id: string;
title: string;
type: SettingType;
default?: string;
/** для variable-select / class-select: значения через запятую */
options?: string;
/** для variable-color: формат, напр. hex */
format?: string;
}
export interface StyleSettingsInput {
pluginName: string;
settings: SettingItem[];
}
function renderItem(item: SettingItem): string {
// id/title/default/options — пользовательский ввод → безопасный YAML-скаляр
// (иначе, напр., default "#7C3AED" YAML читает как комментарий → null).
const lines = [
` -`,
` id: ${yamlScalar(item.id)}`,
` title: ${yamlScalar(item.title)}`,
` type: ${item.type}`,
];
if (item.type !== "heading" && item.type !== "info-text" && item.default !== undefined && item.default !== "")
lines.push(` default: ${yamlScalar(item.default)}`);
if ((item.type === "variable-select" || item.type === "class-select") && item.options) {
const opts = item.options.split(",").map((o) => o.trim()).filter(Boolean);
lines.push(` options:`);
opts.forEach((o) => lines.push(` - ${yamlScalar(o)}`));
}
if (item.type === "variable-color")
lines.push(` format: ${item.format || "hex"}`);
return lines.join("\n");
}
export function buildStyleSettings(input: StyleSettingsInput): string {
const id = input.pluginName.toLowerCase().replace(/\s+/g, "-");
const body = [
`/* @settings`, ``,
`name: ${yamlScalar(input.pluginName)}`,
`id: ${yamlScalar(id)}`,
`settings:`,
...input.settings.map(renderItem),
``, `*/`,
].join("\n");
return body;
}
+22
View File
@@ -0,0 +1,22 @@
export interface ThemeInput {
background: string;
text: string;
accent: string;
link: string;
border: string;
}
const VAR_MAP: Record<keyof ThemeInput, string> = {
background: "--background-primary",
text: "--text-normal",
accent: "--interactive-accent",
link: "--link-color",
border: "--background-modifier-border",
};
export function buildThemeCss(input: ThemeInput): string {
const decls = (Object.keys(VAR_MAP) as (keyof ThemeInput)[])
.map((k) => ` ${VAR_MAP[k]}: ${input[k]};`)
.join("\n");
return `body {\n${decls}\n}`;
}