Compare commits

...

3 Commits

Author SHA1 Message Date
gbanyan 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
gbanyan 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
gbanyan 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
+3 -1
View File
@@ -1,6 +1,7 @@
'use client';
import { useEffect, useRef, useState } from 'react';
import { usePathname } from 'next/navigation';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
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 itemRefs = useRef<Record<string, HTMLDivElement | null>>({});
const [indicator, setIndicator] = useState({ top: 0, opacity: 0 });
const pathname = usePathname();
useEffect(() => {
const headings = Array.from(
@@ -51,7 +53,7 @@ export function PostToc({ onLinkClick }: { onLinkClick?: () => void }) {
headings.forEach((el) => observer.observe(el));
return () => observer.disconnect();
}, []);
}, [pathname]);
useEffect(() => {
if (!activeId || !listRef.current) {
+1 -1
Submodule content updated: a859f93327...d976bb08e2
+1 -1
View File
@@ -88,6 +88,7 @@ export default makeSource({
markdown: {
remarkPlugins: [remarkGfm],
rehypePlugins: [
rehypeCallouts,
[
rehypePrettyCode,
{
@@ -98,7 +99,6 @@ export default makeSource({
keepBackground: false,
},
],
rehypeCallouts,
rehypeSlug,
[rehypeAutolinkHeadings, { behavior: 'wrap' }],
/**
+46 -16
View File
@@ -6,31 +6,61 @@ import { visit } from 'unist-util-visit';
*/
export function rehypeCallouts() {
return (tree: any) => {
visit(tree, 'element', (node, index, parent) => {
visit(tree, 'element', (node) => {
// Only process blockquotes
if (node.tagName !== 'blockquote') return;
// Check if first child is a paragraph
if (!node.children || node.children.length === 0) return;
const firstChild = node.children[0];
if (firstChild.tagName !== 'p') return;
// Check if paragraph starts with [!TYPE]
if (!firstChild.children || firstChild.children.length === 0) return;
const firstText = firstChild.children[0];
if (firstText.type !== 'text') return;
// Find the first non-whitespace child
let contentChild: any = null;
for (const child of node.children) {
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;
const type = match[0].replace(/^\[!|\]\s*/g, '').toLowerCase();
const type = match[1].toLowerCase();
// 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 (!firstText.value.trim()) {
firstChild.children.shift();
if (!textNode.value) {
const index = textParent.children.indexOf(textNode);
if (index > -1) {
textParent.children.splice(index, 1);
}
}
// Add callout data attributes and classes
@@ -38,7 +68,7 @@ export function rehypeCallouts() {
node.properties.className = ['callout', `callout-${type}`];
node.properties['data-callout'] = type;
// Add icon element at the beginning
// Add icon and title elements
const iconMap: Record<string, string> = {
note: '📝',
tip: '💡',
@@ -72,7 +102,7 @@ export function rehypeCallouts() {
type: 'element',
tagName: 'div',
properties: { className: ['callout-content'] },
children: node.children,
children: [...node.children],
};
node.children = [header, content];