diff --git a/astro.config.mjs b/astro.config.mjs
index 1ff5762..ac365dd 100644
--- a/astro.config.mjs
+++ b/astro.config.mjs
@@ -5,6 +5,7 @@ import tailwindcss from "@tailwindcss/vite";
import { defineConfig } from "astro/config";
import rehypeAutolinkHeadings from "rehype-autolink-headings";
import { remarkAlert } from "remark-github-blockquote-alert";
+import rehypeMarkdownImages from "./src/plugins/rehypeMarkdownImages.ts";
// https://astro.build/config
export default defineConfig({
@@ -27,6 +28,7 @@ export default defineConfig({
},
remarkPlugins: [remarkAlert],
rehypePlugins: [
+ rehypeMarkdownImages,
// https://docs.astro.build/en/guides/markdown-content/#heading-ids-and-plugins
[rehypeHeadingIds, { headingIdCompat: true }],
[
diff --git a/public/images/default-avatar.jpg b/public/images/default-avatar.jpg
new file mode 100644
index 0000000..1d904a3
Binary files /dev/null and b/public/images/default-avatar.jpg differ
diff --git a/src/content/wiki/macos-intel-install.md b/src/content/wiki/macos-intel-install.md
index 23b4a98..5d161af 100644
--- a/src/content/wiki/macos-intel-install.md
+++ b/src/content/wiki/macos-intel-install.md
@@ -7,7 +7,7 @@ authors:
1. 下载 ZIP 压缩包。
2. 下载完成后,在 Finder 中双击压缩包进行解压。
-
+
3. 打开新的 Finder 窗口,进入 `/Applications/` 目录:
- 可在侧边栏直接选择(中文系统中显示为「应用程序」);
- 或使用快捷键 ⌘ Shift G,输入 `/Applications/` 后回车。
diff --git a/src/layouts/Doc.astro b/src/layouts/Doc.astro
index 9ad8978..d7e62e0 100644
--- a/src/layouts/Doc.astro
+++ b/src/layouts/Doc.astro
@@ -118,6 +118,7 @@ const leftNavItems: NavItem[] = wiki
alt={author}
class="w-8 h-8 rounded-full bg-gray-600"
loading="lazy"
+ onerror="this.onerror=null;this.src='/images/default-avatar.jpg';"
/>
))}
@@ -224,6 +225,9 @@ const leftNavItems: NavItem[] = wiki
-
\ No newline at end of file
+
diff --git a/src/plugins/rehypeMarkdownImages.ts b/src/plugins/rehypeMarkdownImages.ts
new file mode 100644
index 0000000..71a3edd
--- /dev/null
+++ b/src/plugins/rehypeMarkdownImages.ts
@@ -0,0 +1,216 @@
+/** Minimal inline types for hast nodes (avoids external type dependency). */
+interface HastProperties {
+ [key: string]: unknown;
+ src?: string;
+ loading?: string;
+ decoding?: string;
+ width?: number | string;
+ height?: number | string;
+ className?: string | string[];
+}
+
+interface HastElement {
+ type: "element";
+ tagName: string;
+ properties: HastProperties;
+ children: HastNode[];
+}
+
+interface HastParent {
+ type: string;
+ children: HastNode[];
+}
+
+interface HastRaw {
+ type: "raw";
+ value: string;
+}
+
+type HastNode = HastElement | HastParent | HastRaw;
+
+function isRawNode(node: HastNode): node is HastRaw {
+ return node.type === "raw" && "value" in node;
+}
+
+const imageDimensions: Record = {
+ "/images/animeko.svg": { width: 1280, height: 640 },
+ "/features/subject-details.png": { width: 575, height: 1280 },
+ "/features/subject-rating.png": { width: 575, height: 1280 },
+ "/features/anime-schedule.png": { width: 575, height: 1280 },
+ "/features/search-by-tag.png": { width: 575, height: 1280 },
+ "/features/subject-collection.png": { width: 575, height: 1280 },
+ "/features/home.png": { width: 575, height: 1280 },
+ "/features/mediaselector-simple.png": { width: 1080, height: 2400 },
+ "/features/mediaselector-detailed.png": { width: 1080, height: 2400 },
+ "/features/episode.png": { width: 575, height: 1280 },
+ "/features/episode-scrolled.png": { width: 575, height: 1280 },
+ "/features/cache-episode.png": { width: 575, height: 1280 },
+ "/features/cache-list.png": { width: 575, height: 1280 },
+ "/features/player-fullscreen.png": { width: 2400, height: 1080 },
+ "/features/pc-home.png": { width: 2966, height: 1576 },
+ "/features/pc-search.png": { width: 2722, height: 1742 },
+ "/features/pc-search-detail.png": { width: 2528, height: 1742 },
+ "/features/danmaku-settings.png": { width: 2400, height: 1080 },
+ "/features/theme-settings.png": { width: 1080, height: 2400 },
+ "/features/media-preferences.png": { width: 575, height: 1280 },
+ "/images/iOS-sideloadly.png": { width: 1053, height: 374 },
+ "/images/iOS-ready.png": { width: 1053, height: 374 },
+ "/images/iOS-done.png": { width: 1053, height: 374 },
+ "/images/macos-intel.png": { width: 332, height: 440 },
+ "/images/macos-security.png": { width: 504, height: 418 },
+ "/images/macos-xattr.png": { width: 1400, height: 1326 },
+ "/images/macos-open.png": { width: 500, height: 582 },
+ "/images/win-font-1.png": { width: 1689, height: 951 },
+ "/images/win-font-2.png": { width: 956, height: 571 },
+ "/images/win-font-3.png": { width: 942, height: 557 },
+};
+
+function titleCase(value: string): string {
+ return value.replace(/\b\w/g, (char) => char.toUpperCase());
+}
+
+function inferAltFromSrc(src: string): string | null {
+ const cleaned = src.split(/[?#]/)[0];
+ const filename = cleaned.split("/").pop();
+ if (!filename) return null;
+
+ const base = filename.replace(/\.[a-z0-9]+$/i, "");
+ const spaced = base.replace(/[-_]+/g, " ").replace(/\s+/g, " ").trim();
+ if (!spaced) return null;
+
+ return titleCase(spaced);
+}
+
+function parseImgFromRaw(value: string): HastElement | null {
+ const trimmed = value.trim();
+ const match = trimmed.match(/^
]*?)\s*\/?>$/i);
+ if (!match) return null;
+
+ const props: HastProperties = {};
+ const attrs = match[1];
+ const attrRegex = /([^\s=]+)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'>]+))/g;
+
+ for (let attrMatch = attrRegex.exec(attrs); attrMatch; attrMatch = attrRegex.exec(attrs)) {
+ const key = attrMatch[1];
+ const rawValue = attrMatch[2] ?? attrMatch[3] ?? attrMatch[4] ?? "";
+
+ if (key === "class") {
+ props.className = rawValue.split(/\s+/).filter(Boolean);
+ continue;
+ }
+
+ if (key === "width" || key === "height") {
+ const numeric = Number(rawValue);
+ props[key] = Number.isNaN(numeric) ? rawValue : numeric;
+ continue;
+ }
+
+ props[key] = rawValue;
+ }
+
+ return {
+ type: "element",
+ tagName: "img",
+ properties: props,
+ children: [],
+ };
+}
+
+function visitElements(
+ node: HastNode,
+ visitor: (element: HastElement) => void,
+ parent?: HastParent,
+ index?: number
+): void {
+ if (isRawNode(node)) {
+ const parsed = parseImgFromRaw(node.value);
+ if (parsed && parent && typeof index === "number") {
+ parent.children[index] = parsed;
+ visitor(parsed);
+ return;
+ }
+ }
+
+ if (node.type === "element") {
+ visitor(node as HastElement);
+ }
+
+ if ("children" in node && Array.isArray(node.children)) {
+ for (let i = 0; i < node.children.length; i += 1) {
+ visitElements(node.children[i], visitor, node, i);
+ }
+ }
+}
+
+/**
+ * Rehype plugin that enhances markdown `
` elements at build time:
+ * - Adds `loading="lazy"` and `decoding="async"`
+ * - Injects missing `width`/`height` for known images (including SVGs).
+ * When only one dimension is already present the other is inferred from the
+ * known aspect ratio — this covers the inline `
` elements
+ * in about.md where the browser needs both values to reserve space (CLS).
+ * - Adds `lazy-image is-loading` CSS classes so the skeleton is server-rendered
+ *
+ * SVG images are skipped only for the skeleton class treatment; they still
+ * receive `loading`, `decoding`, and dimension attributes.
+ */
+export default function rehypeMarkdownImages() {
+ return (tree: HastNode) => {
+ visitElements(tree, (node) => {
+ if (node.tagName !== "img") return;
+
+ const props = node.properties ?? {};
+ node.properties = props;
+ const src = typeof props.src === "string" ? props.src : undefined;
+
+ // Add loading="lazy" and decoding="async" to all images
+ if (!props.loading) {
+ props.loading = "lazy";
+ }
+ props.decoding = "async";
+
+ if (!props.alt && src) {
+ const inferredAlt = inferAltFromSrc(src);
+ if (inferredAlt) {
+ props.alt = inferredAlt;
+ }
+ }
+
+ // Inject width/height for ALL known images (including SVGs).
+ // When only one dimension is supplied (e.g. width="200" in about.md inline HTML)
+ // the missing value is inferred from the known aspect ratio so the browser can
+ // reserve the correct amount of space before the image loads (prevents CLS).
+ const dims = src ? imageDimensions[src] : undefined;
+ if (dims) {
+ const hasWidth = props.width != null && props.width !== "";
+ const hasHeight = props.height != null && props.height !== "";
+
+ if (!hasWidth && !hasHeight) {
+ props.width = dims.width;
+ props.height = dims.height;
+ } else if (hasWidth && !hasHeight) {
+ const w = Number(props.width);
+ if (!Number.isNaN(w) && w > 0) {
+ props.height = Math.round((w * dims.height) / dims.width);
+ }
+ } else if (!hasWidth && hasHeight) {
+ const h = Number(props.height);
+ if (!Number.isNaN(h) && h > 0) {
+ props.width = Math.round((h * dims.width) / dims.height);
+ }
+ }
+ }
+
+ // Add lazy-image and is-loading classes for skeleton animation
+ const existing = Array.isArray(props.className)
+ ? (props.className as string[]).filter((c) => typeof c === "string")
+ : props.className
+ ? [String(props.className)]
+ : [];
+
+ if (!existing.includes("lazy-image")) {
+ props.className = [...existing, "lazy-image", "is-loading"];
+ }
+ });
+ };
+}
diff --git a/src/scripts/enhanceMarkdownImages.ts b/src/scripts/enhanceMarkdownImages.ts
new file mode 100644
index 0000000..744c3c9
--- /dev/null
+++ b/src/scripts/enhanceMarkdownImages.ts
@@ -0,0 +1,30 @@
+const enhanced = new WeakSet();
+
+/**
+ * Attaches load/error listeners to markdown images that were given the
+ * `lazy-image is-loading` classes by the server-side rehype plugin, so the
+ * skeleton animation is dismissed once each image settles.
+ */
+export function enhanceMarkdownImages() {
+ const images = document.querySelectorAll(".markdown-body img.lazy-image");
+
+ images.forEach((img) => {
+ if (enhanced.has(img)) {
+ return;
+ }
+ enhanced.add(img);
+
+ const markLoaded = (hasError = false) => {
+ img.classList.remove("is-loading");
+ img.classList.add(hasError ? "is-error" : "is-loaded");
+ };
+
+ if (img.complete) {
+ markLoaded(img.naturalWidth === 0);
+ return;
+ }
+
+ img.addEventListener("load", () => markLoaded(false), { once: true });
+ img.addEventListener("error", () => markLoaded(true), { once: true });
+ });
+}
diff --git a/src/styles/markdown.css b/src/styles/markdown.css
index 7cf0b07..865d416 100644
--- a/src/styles/markdown.css
+++ b/src/styles/markdown.css
@@ -128,6 +128,49 @@ details .anchor {
margin: 8px 0;
}
+.markdown-body img.lazy-image {
+ display: block;
+ background: #1f2937;
+}
+
+.markdown-body img.lazy-image.is-loading {
+ animation: markdown-image-pulse 1.4s ease-in-out infinite;
+ /* Ensure a visible skeleton area even when intrinsic dimensions are unknown. */
+ min-height: 120px;
+}
+
+.markdown-body img.lazy-image.is-loaded {
+ background: transparent;
+ animation: markdown-image-fade-in 0.35s ease forwards;
+}
+
+.markdown-body img.lazy-image.is-error {
+ animation: none;
+ opacity: 0.6;
+ background: #111827;
+}
+
+@keyframes markdown-image-pulse {
+ 0%,
+ 100% {
+ opacity: 0.75;
+ }
+
+ 50% {
+ opacity: 1;
+ }
+}
+
+@keyframes markdown-image-fade-in {
+ from {
+ opacity: 0;
+ }
+
+ to {
+ opacity: 1;
+ }
+}
+
.markdown-body code,
.markdown-body kbd,
.markdown-body pre,