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:
Generated
+815
-44
File diff suppressed because it is too large
Load Diff
+4
-2
@@ -7,7 +7,8 @@
|
|||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "eslint",
|
"lint": "eslint",
|
||||||
"type-check": "tsc --noEmit"
|
"type-check": "tsc --noEmit",
|
||||||
|
"test": "vitest run"
|
||||||
},
|
},
|
||||||
"prisma": {
|
"prisma": {
|
||||||
"seed": "ts-node --project tsconfig.json prisma/seed.ts"
|
"seed": "ts-node --project tsconfig.json prisma/seed.ts"
|
||||||
@@ -63,6 +64,7 @@
|
|||||||
"prisma": "^7.6.0",
|
"prisma": "^7.6.0",
|
||||||
"tailwindcss": "^4",
|
"tailwindcss": "^4",
|
||||||
"ts-node": "^10.9.2",
|
"ts-node": "^10.9.2",
|
||||||
"typescript": "^5"
|
"typescript": "^5",
|
||||||
|
"vitest": "^4.1.9"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
-- Obsidian Toolbox — факт использования генератора участником (аналитика)
|
||||||
|
CREATE TABLE "ToolUsage" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"tool" TEXT NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT "ToolUsage_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX "ToolUsage_userId_idx" ON "ToolUsage"("userId");
|
||||||
|
CREATE INDEX "ToolUsage_tool_idx" ON "ToolUsage"("tool");
|
||||||
|
CREATE INDEX "ToolUsage_createdAt_idx" ON "ToolUsage"("createdAt");
|
||||||
|
|
||||||
|
ALTER TABLE "ToolUsage" ADD CONSTRAINT "ToolUsage_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
@@ -46,6 +46,7 @@ model User {
|
|||||||
closedQuestions StudentQuestion[] @relation("QuestionClosedBy")
|
closedQuestions StudentQuestion[] @relation("QuestionClosedBy")
|
||||||
questionMessages StudentQuestionMessage[]
|
questionMessages StudentQuestionMessage[]
|
||||||
wrappedRuns WrappedRun[]
|
wrappedRuns WrappedRun[]
|
||||||
|
toolUsages ToolUsage[]
|
||||||
}
|
}
|
||||||
|
|
||||||
// Obsidian Wrapped — агрегаты прогона (БЕЗ имён/текстов заметок; обработка волта 100% client-side)
|
// Obsidian Wrapped — агрегаты прогона (БЕЗ имён/текстов заметок; обработка волта 100% client-side)
|
||||||
@@ -68,6 +69,19 @@ model WrappedRun {
|
|||||||
@@index([userId])
|
@@index([userId])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Obsidian Toolbox — факт использования генератора участником (для аналитики)
|
||||||
|
model ToolUsage {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
userId String
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
tool String // callout | frontmatter | theme | style-settings
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
@@index([userId])
|
||||||
|
@@index([tool])
|
||||||
|
@@index([createdAt])
|
||||||
|
}
|
||||||
|
|
||||||
model Session {
|
model Session {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
userId String
|
userId String
|
||||||
|
|||||||
@@ -57,6 +57,9 @@ export default async function StudentLayout({ children }: { children: React.Reac
|
|||||||
<Link href="/wrapped" className="text-sm hover:underline" style={{ color: "var(--muted-foreground)" }}>
|
<Link href="/wrapped" className="text-sm hover:underline" style={{ color: "var(--muted-foreground)" }}>
|
||||||
Wrapped
|
Wrapped
|
||||||
</Link>
|
</Link>
|
||||||
|
<Link href="/tools" className="text-sm hover:underline" style={{ color: "var(--muted-foreground)" }}>
|
||||||
|
Инструменты
|
||||||
|
</Link>
|
||||||
<Link href="/questions" className="text-sm hover:underline" style={{ color: "var(--muted-foreground)" }}>
|
<Link href="/questions" className="text-sm hover:underline" style={{ color: "var(--muted-foreground)" }}>
|
||||||
Вопросы
|
Вопросы
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
"use client";
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { buildCalloutCss } from "@/lib/tools/callout";
|
||||||
|
import { CodeOutput } from "@/components/tools/CodeOutput";
|
||||||
|
|
||||||
|
export function CalloutForm() {
|
||||||
|
const [id, setId] = useState("idea");
|
||||||
|
const [title, setTitle] = useState("Идея");
|
||||||
|
const [color, setColor] = useState("#7C3AED");
|
||||||
|
const [icon, setIcon] = useState("lightbulb");
|
||||||
|
|
||||||
|
const result = useMemo(() => {
|
||||||
|
try { return buildCalloutCss({ id, title, color, icon }); }
|
||||||
|
catch { return null; }
|
||||||
|
}, [id, title, color, icon]);
|
||||||
|
|
||||||
|
const field = "w-full p-2 text-sm";
|
||||||
|
const fieldStyle = { border: "1px solid var(--border)", borderRadius: "2px", backgroundColor: "var(--background)", color: "var(--foreground)" } as const;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid gap-6 md:grid-cols-2">
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<label className="text-sm">Тип (id)
|
||||||
|
<input className={field} style={fieldStyle} value={id} onChange={(e) => setId(e.target.value)} />
|
||||||
|
</label>
|
||||||
|
<label className="text-sm">Заголовок
|
||||||
|
<input className={field} style={fieldStyle} value={title} onChange={(e) => setTitle(e.target.value)} />
|
||||||
|
</label>
|
||||||
|
<label className="text-sm">Цвет (HEX)
|
||||||
|
<input className={field} style={fieldStyle} value={color} onChange={(e) => setColor(e.target.value)} />
|
||||||
|
</label>
|
||||||
|
<label className="text-sm">Иконка (lucide-id)
|
||||||
|
<input className={field} style={fieldStyle} value={icon} onChange={(e) => setIcon(e.target.value)} />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
{result ? (
|
||||||
|
<>
|
||||||
|
<CodeOutput code={result.css} tool="callout" label="CSS-snippet" />
|
||||||
|
<CodeOutput code={result.markdown} tool="callout" label="Пример разметки" />
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm" style={{ color: "var(--muted-foreground)" }}>
|
||||||
|
Проверь поля: id — латиница и цифры (напр. <code>idea</code>), цвет — корректный HEX (напр. <code>#7C3AED</code>).
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { CalloutForm } from "./CalloutForm";
|
||||||
|
|
||||||
|
export const metadata = { title: "Генератор callout-CSS — Obsidian Toolbox" };
|
||||||
|
|
||||||
|
export default function CalloutToolPage() {
|
||||||
|
return (
|
||||||
|
<main className="mx-auto w-full max-w-5xl px-6 py-8">
|
||||||
|
<h1 className="text-2xl font-bold tracking-wide" style={{ color: "var(--foreground)" }}>Генератор callout-CSS</h1>
|
||||||
|
<p className="mt-1 text-sm" style={{ color: "var(--muted-foreground)" }}>Кастомные коллауты Obsidian: тип, заголовок, цвет, иконка.</p>
|
||||||
|
<div className="mt-6 card-aubade p-4">
|
||||||
|
<CalloutForm />
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
"use client";
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { Plus, Trash2 } from "lucide-react";
|
||||||
|
import { buildFrontmatter, type PropField, type PropType } from "@/lib/tools/frontmatter";
|
||||||
|
import { CodeOutput } from "@/components/tools/CodeOutput";
|
||||||
|
|
||||||
|
const TYPES: PropType[] = ["text", "number", "checkbox", "date", "list", "tags"];
|
||||||
|
const TYPE_RU: Record<PropType, string> = {
|
||||||
|
text: "текст", number: "число", checkbox: "флажок", date: "дата", list: "список", tags: "теги",
|
||||||
|
};
|
||||||
|
|
||||||
|
const field = "w-full p-2 text-sm";
|
||||||
|
const fieldStyle = { border: "1px solid var(--border)", borderRadius: "2px", backgroundColor: "var(--background)", color: "var(--foreground)" } as const;
|
||||||
|
|
||||||
|
export function FrontmatterForm() {
|
||||||
|
const [fields, setFields] = useState<PropField[]>([
|
||||||
|
{ key: "title", type: "text", value: "Моя заметка" },
|
||||||
|
{ key: "tags", type: "tags", value: "obsidian, pkm" },
|
||||||
|
{ key: "created", type: "date", value: "2026-06-23" },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const yaml = useMemo(() => buildFrontmatter(fields), [fields]);
|
||||||
|
|
||||||
|
function update(i: number, patch: Partial<PropField>) {
|
||||||
|
setFields((prev) => prev.map((f, idx) => (idx === i ? { ...f, ...patch } : f)));
|
||||||
|
}
|
||||||
|
function addRow() {
|
||||||
|
setFields((prev) => [...prev, { key: "", type: "text", value: "" }]);
|
||||||
|
}
|
||||||
|
function removeRow(i: number) {
|
||||||
|
setFields((prev) => prev.filter((_, idx) => idx !== i));
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid gap-6 md:grid-cols-2">
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
{fields.map((f, i) => (
|
||||||
|
<div key={i} className="flex flex-col gap-2 p-2" style={{ border: "1px solid var(--border)", borderRadius: "2px" }}>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
className={field} style={fieldStyle} placeholder="имя свойства"
|
||||||
|
value={f.key} onChange={(e) => update(i, { key: e.target.value })}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button" onClick={() => removeRow(i)} aria-label="Удалить свойство"
|
||||||
|
className="btn-aubade inline-flex items-center justify-center p-2"
|
||||||
|
>
|
||||||
|
<Trash2 size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<select
|
||||||
|
className={field} style={fieldStyle}
|
||||||
|
value={f.type} onChange={(e) => update(i, { type: e.target.value as PropType })}
|
||||||
|
>
|
||||||
|
{TYPES.map((t) => <option key={t} value={t}>{TYPE_RU[t]}</option>)}
|
||||||
|
</select>
|
||||||
|
{f.type === "checkbox" ? (
|
||||||
|
<select className={field} style={fieldStyle} value={f.value ?? "false"} onChange={(e) => update(i, { value: e.target.value })}>
|
||||||
|
<option value="true">true</option>
|
||||||
|
<option value="false">false</option>
|
||||||
|
</select>
|
||||||
|
) : f.type === "date" ? (
|
||||||
|
<input
|
||||||
|
type="date" className={field} style={fieldStyle}
|
||||||
|
value={f.value ?? ""} onChange={(e) => update(i, { value: e.target.value })}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<input
|
||||||
|
className={field} style={fieldStyle}
|
||||||
|
placeholder={f.type === "list" || f.type === "tags" ? "через запятую" : "значение"}
|
||||||
|
value={f.value ?? ""} onChange={(e) => update(i, { value: e.target.value })}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<button type="button" onClick={addRow} className="btn-aubade inline-flex items-center justify-center gap-2 text-sm">
|
||||||
|
<Plus size={16} /> Добавить свойство
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<CodeOutput code={yaml} tool="frontmatter" label="YAML-frontmatter" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { FrontmatterForm } from "./FrontmatterForm";
|
||||||
|
|
||||||
|
export const metadata = { title: "Генератор YAML-frontmatter — Obsidian Toolbox" };
|
||||||
|
|
||||||
|
export default function FrontmatterToolPage() {
|
||||||
|
return (
|
||||||
|
<main className="mx-auto w-full max-w-5xl px-6 py-8">
|
||||||
|
<h1 className="text-2xl font-bold tracking-wide" style={{ color: "var(--foreground)" }}>Генератор YAML-frontmatter</h1>
|
||||||
|
<p className="mt-1 text-sm" style={{ color: "var(--muted-foreground)" }}>Шаблон свойств заметки: имя, тип, значение по умолчанию.</p>
|
||||||
|
<div className="mt-6 card-aubade p-4">
|
||||||
|
<FrontmatterForm />
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { TOOLS } from "@/lib/tools/_shared/types";
|
||||||
|
import { ToolCard } from "@/components/tools/ToolCard";
|
||||||
|
|
||||||
|
export const metadata = { title: "Инструменты — Obsidian Toolbox" };
|
||||||
|
|
||||||
|
export default function ToolsIndexPage() {
|
||||||
|
return (
|
||||||
|
<main className="mx-auto w-full max-w-5xl px-6 py-8">
|
||||||
|
<h1 className="text-2xl font-bold tracking-wide" style={{ color: "var(--foreground)" }}>Инструменты</h1>
|
||||||
|
<p className="mt-1 text-sm" style={{ color: "var(--muted-foreground)" }}>Генераторы синтаксиса Obsidian для участников школы.</p>
|
||||||
|
<div className="mt-6 grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
|
{TOOLS.map((t) => <ToolCard key={t.id} tool={t} />)}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
"use client";
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { Plus, Trash2 } from "lucide-react";
|
||||||
|
import { buildStyleSettings, type SettingItem, type SettingType } from "@/lib/tools/style-settings";
|
||||||
|
import { CodeOutput } from "@/components/tools/CodeOutput";
|
||||||
|
|
||||||
|
const TYPES: SettingType[] = [
|
||||||
|
"heading", "class-toggle", "class-select",
|
||||||
|
"variable-text", "variable-number", "variable-number-slider",
|
||||||
|
"variable-select", "variable-color", "info-text",
|
||||||
|
];
|
||||||
|
|
||||||
|
const field = "w-full p-2 text-sm";
|
||||||
|
const fieldStyle = { border: "1px solid var(--border)", borderRadius: "2px", backgroundColor: "var(--background)", color: "var(--foreground)" } as const;
|
||||||
|
|
||||||
|
function hasDefault(t: SettingType) { return t !== "heading" && t !== "info-text"; }
|
||||||
|
function hasOptions(t: SettingType) { return t === "variable-select" || t === "class-select"; }
|
||||||
|
|
||||||
|
export function StyleSettingsForm() {
|
||||||
|
const [pluginName, setPluginName] = useState("Моя тема");
|
||||||
|
const [settings, setSettings] = useState<SettingItem[]>([
|
||||||
|
{ id: "section", title: "Цвета", type: "heading" },
|
||||||
|
{ id: "accent", title: "Акцент", type: "variable-color", default: "#7C3AED", format: "hex" },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const output = useMemo(() => buildStyleSettings({ pluginName, settings }), [pluginName, settings]);
|
||||||
|
|
||||||
|
function update(i: number, patch: Partial<SettingItem>) {
|
||||||
|
setSettings((prev) => prev.map((s, idx) => (idx === i ? { ...s, ...patch } : s)));
|
||||||
|
}
|
||||||
|
function addRow() {
|
||||||
|
setSettings((prev) => [...prev, { id: "", title: "", type: "variable-text", default: "" }]);
|
||||||
|
}
|
||||||
|
function removeRow(i: number) {
|
||||||
|
setSettings((prev) => prev.filter((_, idx) => idx !== i));
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid gap-6 md:grid-cols-2">
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<label className="text-sm">Имя темы / плагина
|
||||||
|
<input className={field} style={fieldStyle} value={pluginName} onChange={(e) => setPluginName(e.target.value)} />
|
||||||
|
</label>
|
||||||
|
{settings.map((s, i) => (
|
||||||
|
<div key={i} className="flex flex-col gap-2 p-2" style={{ border: "1px solid var(--border)", borderRadius: "2px" }}>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input className={field} style={fieldStyle} placeholder="id" value={s.id} onChange={(e) => update(i, { id: e.target.value })} />
|
||||||
|
<button type="button" onClick={() => removeRow(i)} aria-label="Удалить настройку" className="btn-aubade inline-flex items-center justify-center p-2">
|
||||||
|
<Trash2 size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<input className={field} style={fieldStyle} placeholder="заголовок" value={s.title} onChange={(e) => update(i, { title: e.target.value })} />
|
||||||
|
<select className={field} style={fieldStyle} value={s.type} onChange={(e) => update(i, { type: e.target.value as SettingType })}>
|
||||||
|
{TYPES.map((t) => <option key={t} value={t}>{t}</option>)}
|
||||||
|
</select>
|
||||||
|
{hasDefault(s.type) && (
|
||||||
|
<input className={field} style={fieldStyle} placeholder="значение по умолчанию" value={s.default ?? ""} onChange={(e) => update(i, { default: e.target.value })} />
|
||||||
|
)}
|
||||||
|
{hasOptions(s.type) && (
|
||||||
|
<input className={field} style={fieldStyle} placeholder="варианты через запятую" value={s.options ?? ""} onChange={(e) => update(i, { options: e.target.value })} />
|
||||||
|
)}
|
||||||
|
{s.type === "variable-color" && (
|
||||||
|
<select className={field} style={fieldStyle} value={s.format ?? "hex"} onChange={(e) => update(i, { format: e.target.value })}>
|
||||||
|
{["hex", "rgb", "hsl"].map((fmt) => <option key={fmt} value={fmt}>{fmt}</option>)}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<button type="button" onClick={addRow} className="btn-aubade inline-flex items-center justify-center gap-2 text-sm">
|
||||||
|
<Plus size={16} /> Добавить настройку
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<CodeOutput code={output} tool="style-settings" label="Блок Style Settings (в начало CSS-сниппета)" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { StyleSettingsForm } from "./StyleSettingsForm";
|
||||||
|
|
||||||
|
export const metadata = { title: "Генератор Style Settings — Obsidian Toolbox" };
|
||||||
|
|
||||||
|
export default function StyleSettingsToolPage() {
|
||||||
|
return (
|
||||||
|
<main className="mx-auto w-full max-w-5xl px-6 py-8">
|
||||||
|
<h1 className="text-2xl font-bold tracking-wide" style={{ color: "var(--foreground)" }}>Генератор Style Settings</h1>
|
||||||
|
<p className="mt-1 text-sm" style={{ color: "var(--muted-foreground)" }}>Комментарии-настройки для плагина Style Settings.</p>
|
||||||
|
<div className="mt-6 card-aubade p-4">
|
||||||
|
<StyleSettingsForm />
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
"use client";
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { buildThemeCss, type ThemeInput } from "@/lib/tools/theme";
|
||||||
|
import { CodeOutput } from "@/components/tools/CodeOutput";
|
||||||
|
|
||||||
|
const FIELDS: { key: keyof ThemeInput; label: string }[] = [
|
||||||
|
{ key: "background", label: "Фон" },
|
||||||
|
{ key: "text", label: "Текст" },
|
||||||
|
{ key: "accent", label: "Акцент" },
|
||||||
|
{ key: "link", label: "Ссылки" },
|
||||||
|
{ key: "border", label: "Рамка" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const fieldStyle = { border: "1px solid var(--border)", borderRadius: "2px", backgroundColor: "var(--background)", color: "var(--foreground)" } as const;
|
||||||
|
|
||||||
|
export function ThemeForm() {
|
||||||
|
const [theme, setTheme] = useState<ThemeInput>({
|
||||||
|
background: "#0F0F0F", text: "#EAEAEA", accent: "#7C3AED", link: "#4EA1FF", border: "#333333",
|
||||||
|
});
|
||||||
|
|
||||||
|
const css = useMemo(() => buildThemeCss(theme), [theme]);
|
||||||
|
|
||||||
|
function update(key: keyof ThemeInput, value: string) {
|
||||||
|
setTheme((prev) => ({ ...prev, [key]: value }));
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid gap-6 md:grid-cols-2">
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
{FIELDS.map((f) => (
|
||||||
|
<label key={f.key} className="flex items-center justify-between gap-3 text-sm">
|
||||||
|
<span>{f.label}</span>
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="color" value={theme[f.key]} onChange={(e) => update(f.key, e.target.value)}
|
||||||
|
aria-label={f.label} style={{ width: 36, height: 28, border: "1px solid var(--border)", borderRadius: 2, padding: 0, background: "none" }}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
className="w-28 p-2 text-sm" style={fieldStyle}
|
||||||
|
value={theme[f.key]} onChange={(e) => update(f.key, e.target.value)}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
<div
|
||||||
|
className="mt-2 p-4 text-sm"
|
||||||
|
style={{ backgroundColor: theme.background, color: theme.text, border: `2px solid ${theme.border}`, borderRadius: 2 }}
|
||||||
|
>
|
||||||
|
Превью темы. <a href="#" onClick={(e) => e.preventDefault()} style={{ color: theme.link }}>ссылка</a>,{" "}
|
||||||
|
<span style={{ color: theme.accent, fontWeight: 700 }}>акцент</span>.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<CodeOutput code={css} tool="theme" label="CSS-переменные темы" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { ThemeForm } from "./ThemeForm";
|
||||||
|
|
||||||
|
export const metadata = { title: "Генератор темы — Obsidian Toolbox" };
|
||||||
|
|
||||||
|
export default function ThemeToolPage() {
|
||||||
|
return (
|
||||||
|
<main className="mx-auto w-full max-w-5xl px-6 py-8">
|
||||||
|
<h1 className="text-2xl font-bold tracking-wide" style={{ color: "var(--foreground)" }}>Генератор темы / CSS-переменных</h1>
|
||||||
|
<p className="mt-1 text-sm" style={{ color: "var(--muted-foreground)" }}>Палитра → набор CSS-переменных темы Obsidian.</p>
|
||||||
|
<div className="mt-6 card-aubade p-4">
|
||||||
|
<ThemeForm />
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { CopyButton } from "./CopyButton";
|
||||||
|
import type { ToolId } from "@/lib/tools/_shared/types";
|
||||||
|
|
||||||
|
export function CodeOutput({ code, tool, label }: { code: string; tool: ToolId; label?: string }) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{label && <div className="text-xs font-bold tracking-wide" style={{ color: "var(--muted-foreground)" }}>{label}</div>}
|
||||||
|
<pre
|
||||||
|
className="overflow-auto p-3 text-sm"
|
||||||
|
style={{
|
||||||
|
fontFamily: "var(--font-mono, 'Fira Mono', monospace)",
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
borderRadius: "2px",
|
||||||
|
backgroundColor: "var(--background)",
|
||||||
|
color: "var(--foreground)",
|
||||||
|
}}
|
||||||
|
>{code}</pre>
|
||||||
|
<div><CopyButton text={code} tool={tool} /></div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
"use client";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Copy, Check } from "lucide-react";
|
||||||
|
import { logToolUsage } from "@/lib/actions/tool-usage";
|
||||||
|
import type { ToolId } from "@/lib/tools/_shared/types";
|
||||||
|
|
||||||
|
export function CopyButton({ text, tool }: { text: string; tool: ToolId }) {
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
async function onCopy() {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
} catch {
|
||||||
|
return; // буфер недоступен (нет разрешения / insecure context) — молча
|
||||||
|
}
|
||||||
|
setCopied(true);
|
||||||
|
logToolUsage({ tool }).catch(() => {}); // аналитика — best-effort, не роняем UI
|
||||||
|
setTimeout(() => setCopied(false), 1500);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<button type="button" onClick={onCopy} className="btn-aubade btn-aubade-accent inline-flex items-center gap-2 text-sm">
|
||||||
|
{copied ? <Check size={16} /> : <Copy size={16} />}
|
||||||
|
{copied ? "Скопировано" : "Скопировать"}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { MessageSquareQuote, FileCode2, Palette, SlidersHorizontal, Wrench, type LucideIcon } from "lucide-react";
|
||||||
|
import type { ToolMeta } from "@/lib/tools/_shared/types";
|
||||||
|
|
||||||
|
// Явная карта (не `import * as Icons`) — иначе весь набор lucide попадает в бандл.
|
||||||
|
const ICONS: Record<string, LucideIcon> = {
|
||||||
|
MessageSquareQuote,
|
||||||
|
FileCode2,
|
||||||
|
Palette,
|
||||||
|
SlidersHorizontal,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ToolCard({ tool }: { tool: ToolMeta }) {
|
||||||
|
const Icon = ICONS[tool.icon] ?? Wrench;
|
||||||
|
return (
|
||||||
|
<Link href={`/tools/${tool.id}`} className="card-aubade p-4 flex flex-col gap-2 no-underline">
|
||||||
|
<div className="flex items-center gap-2" style={{ color: "var(--foreground)" }}>
|
||||||
|
<Icon size={20} />
|
||||||
|
<span className="font-bold">{tool.title}</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm" style={{ color: "var(--muted-foreground)" }}>{tool.description}</p>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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: "Шрифт: основной"');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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" },
|
||||||
|
];
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { defineConfig } from "vitest/config";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
resolve: {
|
||||||
|
alias: { "@": path.resolve(__dirname, "src") },
|
||||||
|
},
|
||||||
|
test: {
|
||||||
|
environment: "node",
|
||||||
|
include: ["src/lib/tools/**/__tests__/**/*.test.ts"],
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user