Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 46 additions & 8 deletions client/src/components/search.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,47 @@ type SearchResult = {

const API_BASE = "";

function findSelectedResult(results: SearchResult[], selectedIndex: number) {
if (selectedIndex < 0) return undefined;
let currentIndex = 0;
for (const result of results) {
if (currentIndex === selectedIndex) {
return result;
}
currentIndex += 1;
}
return undefined;
}

function splitByQuery(text: string, query: string) {
const normalizedQuery = query.trim().toLocaleLowerCase();
if (!normalizedQuery) {
return [{ text, matched: false }];
}

const segments: Array<{ text: string; matched: boolean }> = [];
const normalizedText = text.toLocaleLowerCase();
let cursor = 0;

while (cursor < text.length) {
const matchIndex = normalizedText.indexOf(normalizedQuery, cursor);
if (matchIndex === -1) {
segments.push({ text: text.slice(cursor), matched: false });
break;
}

if (matchIndex > cursor) {
segments.push({ text: text.slice(cursor, matchIndex), matched: false });
}

const matchEnd = matchIndex + normalizedQuery.length;
segments.push({ text: text.slice(matchIndex, matchEnd), matched: true });
cursor = matchEnd;
}

return segments.length > 0 ? segments : [{ text, matched: false }];
}

export function SearchOverlay() {
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
Expand Down Expand Up @@ -87,8 +128,7 @@ export function SearchOverlay() {
e.preventDefault();
setSelectedIndex((prev) => Math.max(prev - 1, 0));
} else if (e.key === "Enter") {
// eslint-disable-next-line security/detect-object-injection
const selected = results[selectedIndex];
const selected = findSelectedResult(results, selectedIndex);
if (selected) {
setOpen(false);
window.location.href = `/posts/${selected.slug}`;
Expand All @@ -99,16 +139,14 @@ export function SearchOverlay() {
// 高亮关键词
const highlightText = (text: string, q: string) => {
if (!q.trim()) return text;
// eslint-disable-next-line security/detect-non-literal-regexp
const regex = new RegExp(`(${q.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")})`, "gi");
const parts = text.split(regex);
const parts = splitByQuery(text, q);
return parts.map((part, i) =>
regex.test(part) ? (
part.matched ? (
<mark key={i} className="bg-amber-500/30 text-foreground rounded-sm px-[2px]">
{part}
{part.text}
</mark>
) : (
part
part.text
)
);
};
Expand Down
10 changes: 8 additions & 2 deletions client/src/lib/importers/frontmatter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,10 @@

while (i < lines.length) {
const line = lines[i];
if (typeof line !== "string") {
i++;
continue;
}
const keyMatch = line.match(/^(\w[\w-]*):\s*(.*)/);

if (!keyMatch) {
Expand All @@ -123,9 +127,11 @@
// 检测 block array
const arrayItems: string[] = [];
let j = i + 1;
while (j < lines.length && /^\s+-\s+/.test(lines[j])) {
arrayItems.push(lines[j].replace(/^\s+-\s+/, "").trim());
let nextLine = lines[j];
while (typeof nextLine === "string" && /^\s+-\s+/.test(nextLine)) {
arrayItems.push(nextLine.replace(/^\s+-\s+/, "").trim());
j++;
nextLine = lines[j];
}
if (arrayItems.length > 0) {
assignArrayField(result, key, arrayItems);
Expand Down
29 changes: 22 additions & 7 deletions client/src/lib/markdown.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,6 @@ renderer.code = ({ text, lang }: { text: string; lang?: string }) => {
const isHighlighted = highlightLines.has(lineNum);

// diff 高亮:检测原始文本行前缀
// eslint-disable-next-line security/detect-object-injection
const rawLine = rawLines[i] || "";
let diffClass = "";
if (isDiff) {
Expand Down Expand Up @@ -171,18 +170,34 @@ renderer.code = ({ text, lang }: { text: string; lang?: string }) => {
// 图片/视频:懒加载 + 圆角 + 视频解析
renderer.image = ({ href, title, text }: { href: string; title?: string | null; text: string }) => {
const titleAttr = title ? ` title="${escapeHtml(title)}"` : "";

let normalizedHref = href;
try {
normalizedHref = new URL(href, "https://monolith.local").toString();
} catch {
normalizedHref = href;
}

let mediaExtension = "";
try {
const videoUrl = new URL(normalizedHref, "https://monolith.local");
const lastSegment = videoUrl.pathname.split("/").filter(Boolean).pop() || "";
const dotIndex = lastSegment.lastIndexOf(".");
mediaExtension = dotIndex >= 0 ? lastSegment.slice(dotIndex + 1).toLowerCase() : "";
} catch {
mediaExtension = "";
}

// 1. 直链视频支持
// eslint-disable-next-line security/detect-unsafe-regex
if (href.match(/\.(mp4|webm|ogg|mov)(?:\?.*)?$/i)) {
if (["mp4", "webm", "ogg", "mov"].includes(mediaExtension)) {
return `<figure class="md-figure md-video">
<video src="${href}" controls playsinline preload="metadata" class="w-full rounded-lg border border-border/20 shadow-lg bg-black/5"></video>
<video src="${normalizedHref}" controls playsinline preload="metadata" class="w-full rounded-lg border border-border/20 shadow-lg bg-black/5"></video>
${text ? `<figcaption>${escapeHtml(text)}</figcaption>` : ""}
</figure>`;
}

// 2. 哔哩哔哩 (Bilibili) 视频支持解析
const bpxMatch = href.match(/bilibili\.com\/video\/([a-zA-Z0-9]+)/i);
const bpxMatch = normalizedHref.match(/bilibili\.com\/video\/([a-zA-Z0-9]+)/i);
if (bpxMatch) {
const bvid = bpxMatch[1];
return `<figure class="md-figure md-video">
Expand All @@ -194,7 +209,7 @@ renderer.image = ({ href, title, text }: { href: string; title?: string | null;
}

// 3. YouTube 视频支持解析
const ytMatch = href.match(/(?:youtube\.com\/watch\?v=|youtu\.be\/)([a-zA-Z0-9_-]{11})/i);
const ytMatch = normalizedHref.match(/(?:youtube\.com\/watch\?v=|youtu\.be\/)([a-zA-Z0-9_-]{11})/i);
if (ytMatch) {
const ytid = ytMatch[1];
return `<figure class="md-figure md-video">
Expand All @@ -206,7 +221,7 @@ renderer.image = ({ href, title, text }: { href: string; title?: string | null;
}

// 默认图片渲染 — 懒加载 + 渐进淡入
return `<figure class="md-figure"><img src="${href}" alt="${escapeHtml(text)}" loading="lazy" decoding="async" data-lazy-img${titleAttr} class="lazy-img"/>${text ? `<figcaption>${escapeHtml(text)}</figcaption>` : ""}</figure>`;
return `<figure class="md-figure"><img src="${normalizedHref}" alt="${escapeHtml(text)}" loading="lazy" decoding="async" data-lazy-img${titleAttr} class="lazy-img"/>${text ? `<figcaption>${escapeHtml(text)}</figcaption>` : ""}</figure>`;
};

// 表格:响应式包裹(兼容 marked v15+ 的 token 结构)
Expand Down
11 changes: 5 additions & 6 deletions client/src/pages/admin/backup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -599,12 +599,11 @@ function SectionTitle({ icon: Icon, title }: { icon: React.ElementType; title: s
function ActionCard({ icon: Icon, color, label, desc, loading, onClick, disabled }: {
icon: React.ElementType; color: string; label: string; desc: string; loading: boolean; onClick: () => void; disabled: boolean;
}) {
const toneMap: Record<string, { bg: string; text: string }> = {
orange: { bg: "bg-orange-500/10", text: "text-orange-400" },
blue: { bg: "bg-blue-500/10", text: "text-blue-400" },
emerald: { bg: "bg-emerald-500/10", text: "text-emerald-400" },
};
const tone = toneMap[color] || toneMap.blue;
const tone = color === "orange"
? { bg: "bg-orange-500/10", text: "text-orange-400" }
: color === "emerald"
? { bg: "bg-emerald-500/10", text: "text-emerald-400" }
: { bg: "bg-blue-500/10", text: "text-blue-400" };

return (
<button onClick={onClick} disabled={disabled}
Expand Down
Loading