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,