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
2 changes: 2 additions & 0 deletions astro.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -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 }],
[
Expand Down
Binary file added public/images/default-avatar.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 1 addition & 1 deletion src/content/wiki/macos-intel-install.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ authors:

1. 下载 ZIP 压缩包。
2. 下载完成后,在 Finder 中双击压缩包进行解压。
<img src="/images/macos-intel.png" width="20%" align="right" />
<img src="/images/macos-intel.png" width="332" height="440" style="width:20%;" align="right" />
3. 打开新的 Finder 窗口,进入 `/Applications/` 目录:
- 可在侧边栏直接选择(中文系统中显示为「应用程序」);
- 或使用快捷键 <kbd>⌘</kbd> <kbd>Shift</kbd> <kbd>G</kbd>,输入 `/Applications/` 后回车。
Expand Down
4 changes: 4 additions & 0 deletions src/layouts/Doc.astro
Original file line number Diff line number Diff line change
Expand Up @@ -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';"
/>
</a>
))}
Expand Down Expand Up @@ -224,6 +225,9 @@ const leftNavItems: NavItem[] = wiki
</style>

<script>
import { enhanceMarkdownImages } from "@/scripts/enhanceMarkdownImages";
enhanceMarkdownImages();

function scrollToAnchor(
target: HTMLElement,
options?: {
Expand Down
1 change: 1 addition & 0 deletions src/layouts/Layout.astro
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ const { title } = Astro.props;
content="Animeko, 弹幕, 追番, 全自动BT, 数据源聚合, 离线缓存, Bangumi, 弹幕云过滤, 开源, 动漫, 收藏同步">
<link rel="sitemap" href="/sitemap-index.xml"/>
<link rel="icon" type="image/svg+xml" href="/logo.png"/>
<link rel="preload" href="/images/default-avatar.jpg" as="image" type="image/jpeg" />
<title>{title}</title>
</head>
<body>
Expand Down
5 changes: 4 additions & 1 deletion src/layouts/Post.astro
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ const pageTitle = `Animeko - ${title}`;
</div>

<script>
import { enhanceMarkdownImages } from "@/scripts/enhanceMarkdownImages";
enhanceMarkdownImages();

// Copy Button Logic
const codeBlocks = document.querySelectorAll('pre');
codeBlocks.forEach(block => {
Expand Down Expand Up @@ -42,4 +45,4 @@ const pageTitle = `Animeko - ${title}`;
});
});
</script>
</Layout>
</Layout>
216 changes: 216 additions & 0 deletions src/plugins/rehypeMarkdownImages.ts
Original file line number Diff line number Diff line change
@@ -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<string, { width: number; height: number }> = {
"/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(/^<img\s+([^>]*?)\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 `<img>` 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 `<img width="200">` 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"];
}
});
};
}
30 changes: 30 additions & 0 deletions src/scripts/enhanceMarkdownImages.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
const enhanced = new WeakSet<HTMLImageElement>();

/**
* 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<HTMLImageElement>(".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 });
});
}
43 changes: 43 additions & 0 deletions src/styles/markdown.css
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading