Compare commits

...

3 Commits

Author SHA1 Message Date
237e5d403b Update content submodule with fixed internal links
Fixed all internal post links from /posts/ to /blog/ to match the actual URL structure. The contentlayer config generates URLs as /blog/{slug} for posts, not /posts/{slug}.

Fixed links in:
- content/pages/關於作者.md (7 links)
- content/posts/OPNsense 在 Proxmox VE 內安裝筆記.md (1 link)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-20 20:51:02 +08:00
e05295e003 Fix GitHub-style callout rendering
The callout plugin wasn't working because:
1. Contentlayer cache was preventing the plugin from running
2. The plugin wasn't handling blockquotes with whitespace text nodes
3. The plugin needed to skip whitespace-only children to find actual content

Updated the rehype plugin to:
- Skip whitespace-only text nodes when looking for [!TYPE] markers
- Handle both direct text children and text within paragraphs
- Properly extract the callout type from regex match
- Clean up empty text nodes after removing markers

Now callouts render correctly with proper structure:
- Header with icon and title
- Content wrapper with styled box
- All 5 callout types supported (NOTE, TIP, IMPORTANT, WARNING, CAUTION)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-20 20:39:16 +08:00
45cfc6acc4 Fix TOC showing wrong headings across navigation
The TOC component was only extracting headings once at mount, causing it to show stale headings when navigating between posts via client-side routing. Now it re-extracts headings whenever the pathname changes.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-20 20:29:30 +08:00
4 changed files with 51 additions and 19 deletions

View File

@@ -1,6 +1,7 @@
'use client'; 'use client';
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { usePathname } from 'next/navigation';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faListUl } from '@fortawesome/free-solid-svg-icons'; import { faListUl } from '@fortawesome/free-solid-svg-icons';
@@ -16,6 +17,7 @@ export function PostToc({ onLinkClick }: { onLinkClick?: () => void }) {
const listRef = useRef<HTMLDivElement | null>(null); const listRef = useRef<HTMLDivElement | null>(null);
const itemRefs = useRef<Record<string, HTMLDivElement | null>>({}); const itemRefs = useRef<Record<string, HTMLDivElement | null>>({});
const [indicator, setIndicator] = useState({ top: 0, opacity: 0 }); const [indicator, setIndicator] = useState({ top: 0, opacity: 0 });
const pathname = usePathname();
useEffect(() => { useEffect(() => {
const headings = Array.from( const headings = Array.from(
@@ -51,7 +53,7 @@ export function PostToc({ onLinkClick }: { onLinkClick?: () => void }) {
headings.forEach((el) => observer.observe(el)); headings.forEach((el) => observer.observe(el));
return () => observer.disconnect(); return () => observer.disconnect();
}, []); }, [pathname]);
useEffect(() => { useEffect(() => {
if (!activeId || !listRef.current) { if (!activeId || !listRef.current) {

Submodule content updated: a859f93327...d976bb08e2

View File

@@ -88,6 +88,7 @@ export default makeSource({
markdown: { markdown: {
remarkPlugins: [remarkGfm], remarkPlugins: [remarkGfm],
rehypePlugins: [ rehypePlugins: [
rehypeCallouts,
[ [
rehypePrettyCode, rehypePrettyCode,
{ {
@@ -98,7 +99,6 @@ export default makeSource({
keepBackground: false, keepBackground: false,
}, },
], ],
rehypeCallouts,
rehypeSlug, rehypeSlug,
[rehypeAutolinkHeadings, { behavior: 'wrap' }], [rehypeAutolinkHeadings, { behavior: 'wrap' }],
/** /**

View File

@@ -6,31 +6,61 @@ import { visit } from 'unist-util-visit';
*/ */
export function rehypeCallouts() { export function rehypeCallouts() {
return (tree: any) => { return (tree: any) => {
visit(tree, 'element', (node, index, parent) => { visit(tree, 'element', (node) => {
// Only process blockquotes // Only process blockquotes
if (node.tagName !== 'blockquote') return; if (node.tagName !== 'blockquote') return;
// Check if first child is a paragraph
if (!node.children || node.children.length === 0) return; if (!node.children || node.children.length === 0) return;
const firstChild = node.children[0];
if (firstChild.tagName !== 'p') return;
// Check if paragraph starts with [!TYPE] // Find the first non-whitespace child
if (!firstChild.children || firstChild.children.length === 0) return; let contentChild: any = null;
const firstText = firstChild.children[0]; for (const child of node.children) {
if (firstText.type !== 'text') return; if (child.type === 'text' && child.value.trim()) {
contentChild = child;
break;
} else if (child.tagName === 'p') {
contentChild = child;
break;
}
}
const match = firstText.value.match(/^\[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)\]\s*/i); if (!contentChild) return;
// Find the first text node
let textNode: any = null;
let textParent: any = null;
if (contentChild.type === 'text') {
// Direct text child
textNode = contentChild;
textParent = node;
} else if (contentChild.tagName === 'p' && contentChild.children) {
// Text inside paragraph - find first non-whitespace text
for (const child of contentChild.children) {
if (child.type === 'text' && child.value.trim()) {
textNode = child;
textParent = contentChild;
break;
}
}
}
if (!textNode || textNode.type !== 'text') return;
// Check if text starts with [!TYPE]
const match = textNode.value.match(/^\[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)\]\s*/i);
if (!match) return; if (!match) return;
const type = match[0].replace(/^\[!|\]\s*/g, '').toLowerCase(); const type = match[1].toLowerCase();
// Remove the [!TYPE] marker from the text // Remove the [!TYPE] marker from the text
firstText.value = firstText.value.replace(match[0], ''); textNode.value = textNode.value.replace(match[0], '').trim();
// If the text node is now empty, remove it // If the text node is now empty, remove it
if (!firstText.value.trim()) { if (!textNode.value) {
firstChild.children.shift(); const index = textParent.children.indexOf(textNode);
if (index > -1) {
textParent.children.splice(index, 1);
}
} }
// Add callout data attributes and classes // Add callout data attributes and classes
@@ -38,7 +68,7 @@ export function rehypeCallouts() {
node.properties.className = ['callout', `callout-${type}`]; node.properties.className = ['callout', `callout-${type}`];
node.properties['data-callout'] = type; node.properties['data-callout'] = type;
// Add icon element at the beginning // Add icon and title elements
const iconMap: Record<string, string> = { const iconMap: Record<string, string> = {
note: '📝', note: '📝',
tip: '💡', tip: '💡',
@@ -72,7 +102,7 @@ export function rehypeCallouts() {
type: 'element', type: 'element',
tagName: 'div', tagName: 'div',
properties: { className: ['callout-content'] }, properties: { className: ['callout-content'] },
children: node.children, children: [...node.children],
}; };
node.children = [header, content]; node.children = [header, content];