Refactor anchor link handling and enhance scrolling functionality. Update markdown processing to support GitHub-style anchor links and improve user experience with smooth scrolling to headings. Add debugging utilities for anchor links in development.

This commit is contained in:
2025-06-21 22:12:12 +02:00
parent 1cc864e4f0
commit 69e6336d5c
5 changed files with 556 additions and 148 deletions

101
posts/anchor-test.md Normal file
View File

@@ -0,0 +1,101 @@
---
title: Anchor Link Test
date: '2025-01-20'
tags:
- test
- anchors
summary: Testing anchor link functionality
author: Test Author
---
# Anchor Link Test
This is a test page to verify that anchor links work correctly.
## Table of Contents
- [Overview](#overview)
- [Basic Headings](#basic-headings)
- [Special Characters](#special-characters)
- [Numbers and Symbols](#numbers-and-symbols)
- [Long Headings](#long-headings)
- [Nested Sections](#nested-sections)
## Overview
This section tests basic anchor linking functionality.
## Basic Headings
### Simple Heading
This is a simple heading with basic text.
### Another Heading
This is another heading to test multiple anchors.
## Special Characters
### Heading with Special Chars: @#$%^&*()
This heading contains special characters that should be properly slugified.
### Heading with Spaces and Dashes
This heading has spaces and should be converted to dashes.
### Heading_with_Underscores
This heading uses underscores instead of spaces.
## Numbers and Symbols
### Heading with Numbers 123
This heading includes numbers.
### Heading with Symbols !@#$%^&*()
This heading has various symbols.
## Long Headings
### This is a Very Long Heading That Should Still Work Properly Even When It Contains Many Words and Characters
This heading is intentionally long to test slugification with extended text.
## Nested Sections
### Level 3 Heading
This is a level 3 heading.
#### Level 4 Heading
This is a level 4 heading.
##### Level 5 Heading
This is a level 5 heading.
###### Level 6 Heading
This is a level 6 heading.
### Another Level 3
This is another level 3 heading.
## Test Links
You can test the anchor links by clicking on these:
- [Go to Overview](#overview)
- [Go to Basic Headings](#basic-headings)
- [Go to Special Characters](#special-characters)
- [Go to Numbers and Symbols](#numbers-and-symbols)
- [Go to Long Headings](#long-headings)
- [Go to Nested Sections](#nested-sections)
- [Go to Level 3 Heading](#level-3-heading)
- [Go to Level 4 Heading](#level-4-heading)
- [Go to Level 5 Heading](#level-5-heading)
- [Go to Level 6 Heading](#level-6-heading)
## Simple Test
### Test Heading
This is a simple test heading to verify anchor linking works.
- [Go to Test Heading](#test-heading)
## Conclusion
If all the links above work correctly, the anchor linking system is functioning properly!

View File

@@ -11,23 +11,16 @@ author: Rattatwinko's
* [Overview](#overview)
* [Philosophy](#philosophy)
* [Inline HTML](#html)
* [Automatic Escaping for Special Characters](#autoescape)
* [Block Elements](#block)
* [Paragraphs and Line Breaks](#p)
* [Headers](#header)
* [Blockquotes](#blockquote)
* [Lists](#list)
* [Code Blocks](#precode)
* [Horizontal Rules](#hr)
* [Span Elements](#span)
* [Links](#link)
* [Emphasis](#em)
* [Block Elements](#block-elements)
* [Paragraphs and Line Breaks](#paragraphs-and-line-breaks)
* [Headers](#headers)
* [Blockquotes](#blockquotes)
* [Lists](#lists)
* [Code Blocks](#code-blocks)
* [Span Elements](#span-elements)
* [Links](#links)
* [Emphasis](#emphasis)
* [Code](#code)
* [Images](#img)
* [Miscellaneous](#misc)
* [Backslash Escapes](#backslash)
* [Automatic Links](#autolink)
**Note:** This document is itself written using Markdown; you

View File

@@ -57,6 +57,47 @@ html {
text-decoration-thickness: 2px;
}
/* Enhanced anchor link styles */
.prose a[href^="#"] {
color: #059669; /* Green color for anchor links */
font-weight: 500;
transition: all 0.2s ease;
}
.prose a[href^="#"]:hover {
color: #047857;
text-decoration-thickness: 2px;
background-color: #f0fdf4;
padding: 2px 4px;
border-radius: 4px;
}
/* Add subtle visual indicator for headings that have anchor links */
.prose h1, .prose h2, .prose h3, .prose h4, .prose h5, .prose h6 {
position: relative;
}
.prose h1:hover::before,
.prose h2:hover::before,
.prose h3:hover::before,
.prose h4:hover::before,
.prose h5:hover::before,
.prose h6:hover::before {
content: "🔗";
position: absolute;
left: -1.5rem;
opacity: 0.6;
font-size: 0.8em;
cursor: pointer;
}
/* Ensure proper spacing for anchor link indicators */
@media (min-width: 641px) {
.prose h1, .prose h2, .prose h3, .prose h4, .prose h5, .prose h6 {
padding-left: 0.5rem;
}
}
.prose h1, .prose h2, .prose h3, .prose h4, .prose h5, .prose h6 {
color: #111827;
font-weight: 700;

View File

@@ -35,124 +35,151 @@ export default function PostPage({ params }: { params: { slug: string[] } }) {
useEffect(() => {
if (!post) return;
// Function to generate ID from text (matches markdown parser behavior)
const generateId = (text: string): string => {
return text
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
};
// Function to scroll to element
// Function to scroll to element using scrollIntoView
const scrollToElement = (element: HTMLElement) => {
console.log('Attempting to scroll to element:', element.textContent);
// Get comprehensive element information
const documentHeight = document.documentElement.scrollHeight;
const windowHeight = window.innerHeight;
let attempts = 0;
const maxAttempts = 20; // 1 second max wait time
// Get the absolute position of the element by temporarily scrolling to top
const currentScrollY = window.scrollY;
let elementTop = 0;
// Wait for the element to be properly positioned in the DOM
const waitForElementPosition = () => {
attempts++;
// If we're not at the top, scroll to top temporarily to get accurate positions
if (currentScrollY > 0) {
// Temporarily scroll to top to get accurate element positions
window.scrollTo(0, 0);
// Wait a moment for the scroll to complete, then measure
setTimeout(() => {
const rect = element.getBoundingClientRect();
console.log('Element rect (attempt', attempts, '):', rect);
elementTop = rect.top;
// If the element has no dimensions, wait a bit more
if ((rect.height === 0 && rect.width === 0) && attempts < maxAttempts) {
console.log('Element not positioned yet, waiting... (attempt', attempts, ')');
setTimeout(waitForElementPosition, 50);
// Restore original scroll position
window.scrollTo(0, currentScrollY);
// Now perform the actual scroll to the target
performActualScroll(elementTop);
}, 50);
return;
} else {
// We're already at the top, get position directly
const rect = element.getBoundingClientRect();
elementTop = rect.top;
performActualScroll(elementTop);
}
function performActualScroll(elementTop: number) {
console.log('Element details:', {
elementText: element.textContent?.substring(0, 50),
elementId: element.id,
elementTop,
currentScrollY: window.scrollY,
documentHeight,
windowHeight,
canScroll: documentHeight > windowHeight
});
// Check if page is scrollable
if (documentHeight <= windowHeight) {
console.warn('Page is not tall enough to scroll');
return;
}
// If we've tried too many times, use fallback method
if (attempts >= maxAttempts) {
console.log('Max attempts reached, using fallback scroll method');
element.scrollIntoView({
behavior: 'smooth',
block: 'start'
// Calculate the target scroll position
const offset = 100; // Account for sticky header
const targetScrollY = Math.max(0, elementTop - offset);
console.log('Scroll calculation:', {
elementTop,
targetScrollY,
offset,
currentScrollY: window.scrollY,
scrollDifference: targetScrollY - window.scrollY
});
// Apply offset after scroll
// Check if we need to scroll at all
if (Math.abs(window.scrollY - targetScrollY) < 10) {
console.log('Element already at target position, no scroll needed');
return;
}
// Use a simple, reliable scroll method
console.log(`Scrolling from ${window.scrollY} to ${targetScrollY}`);
// Use requestAnimationFrame for smooth scrolling
const startScrollY = window.scrollY;
const scrollDistance = targetScrollY - startScrollY;
const duration = 500; // 500ms
const startTime = performance.now();
const animateScroll = (currentTime: number) => {
const elapsed = currentTime - startTime;
const progress = Math.min(elapsed / duration, 1);
// Easing function (ease-out)
const easeOut = 1 - Math.pow(1 - progress, 3);
const currentScrollY = startScrollY + (scrollDistance * easeOut);
window.scrollTo(0, currentScrollY);
if (progress < 1) {
requestAnimationFrame(animateScroll);
} else {
console.log('Scroll animation completed');
}
};
requestAnimationFrame(animateScroll);
// Log the scroll after a delay to verify it worked
setTimeout(() => {
const isDesktop = window.innerWidth >= 640;
const scrollOffset = isDesktop ? 120 : 100;
window.scrollBy({
top: -scrollOffset,
behavior: 'smooth'
});
console.log('Scroll verification - new scrollY:', window.scrollY);
console.log('Scroll difference:', Math.abs(window.scrollY - targetScrollY));
}, 1000);
}
};
// Function to find and scroll to element with retry
const findAndScrollToElement = (id: string, retryCount: number = 0) => {
// First check if the content is rendered
const proseContent = document.querySelector('.prose');
if (!proseContent || !proseContent.innerHTML.trim()) {
if (retryCount < 10) {
console.log(`Content not yet rendered, retrying... (${retryCount + 1}/10)`);
setTimeout(() => {
findAndScrollToElement(id, retryCount + 1);
}, 100);
return;
}
console.log('Element offsetTop:', element.offsetTop);
console.log('Current scroll position:', window.scrollY);
const isDesktop = window.innerWidth >= 640;
const scrollOffset = isDesktop ? 120 : 100;
// Use offsetTop which is more reliable for positioned elements
const elementTop = element.offsetTop - scrollOffset;
console.log('Target scroll position:', elementTop);
console.log('Scroll offset used:', scrollOffset);
// Perform the scroll
window.scrollTo({
top: elementTop,
behavior: 'smooth'
});
console.log('Scroll command executed');
};
// Start the positioning check
waitForElementPosition();
};
// Function to find element by ID or generated ID
const findElement = (id: string): HTMLElement | null => {
console.log('Looking for element with ID:', id);
// Try direct ID match first
let element = document.getElementById(id);
if (element) {
console.log('Found element by direct ID:', element.textContent);
return element;
}
// Try to find by generated ID from all headings
const headings = document.querySelectorAll('h1, h2, h3, h4, h5, h6');
console.log('Found', headings.length, 'headings on page');
const found = Array.from(headings).find(heading => {
const headingId = generateId(heading.textContent || '');
console.log('Checking heading:', heading.textContent, '-> ID:', headingId, 'vs target:', id);
return headingId === id;
});
if (found) {
console.log('Found element by generated ID:', found.textContent);
return found as HTMLElement;
}
console.log('Element not found for ID:', id);
return null;
};
// Function to handle hash-based scrolling
const handleHashScroll = () => {
if (!window.location.hash) return;
const id = window.location.hash.substring(1);
console.log('Handling hash scroll for:', id);
const element = findElement(id);
if (element) {
console.log('Found element for hash scroll:', element.textContent);
setTimeout(() => scrollToElement(element), 100);
} else {
console.log('Element not found for hash:', id);
console.warn('Content not rendered after retries');
return;
}
}
const element = document.getElementById(id);
if (element) {
console.log('Found target element:', element.textContent?.substring(0, 50));
scrollToElement(element);
} else if (retryCount < 5) {
console.log(`Element not found for anchor: ${id}, retrying... (${retryCount + 1}/5)`);
// Retry after a short delay
setTimeout(() => {
findAndScrollToElement(id, retryCount + 1);
}, 100);
} else {
console.warn('Target element not found for anchor after retries:', id);
// Log all available IDs for debugging
const allIds = Array.from(document.querySelectorAll('[id]')).map(el => el.id);
console.log('Available IDs on page:', allIds);
// Show a user-friendly error message
const linkElement = document.querySelector(`a[href="#${id}"]`) as HTMLElement;
if (linkElement) {
linkElement.setAttribute('title', `Heading "${id}" not found`);
linkElement.style.color = '#ef4444'; // Red color for broken links
linkElement.style.textDecoration = 'line-through';
}
}
};
@@ -163,48 +190,211 @@ export default function PostPage({ params }: { params: { slug: string[] } }) {
if (!link || !link.getAttribute('href')?.startsWith('#')) return;
const href = link.getAttribute('href');
const id = href?.substring(1);
if (!id) return;
console.log('Anchor click detected:', href);
// Prevent default behavior first
event.preventDefault();
const href = link.getAttribute('href')!;
const id = href.substring(1);
console.log('Anchor click detected:', id);
// Find the target element and scroll to it
findAndScrollToElement(id);
};
const element = findElement(id);
// Function to handle hash-based scrolling on page load
const handleHashScroll = () => {
if (!window.location.hash) return;
const id = window.location.hash.substring(1);
console.log('Handling hash scroll for:', id);
// Use a longer delay to ensure DOM is fully rendered
setTimeout(() => {
findAndScrollToElement(id);
}, 300);
};
// Handle initial hash scroll
handleHashScroll();
// Add event listener for anchor clicks
document.addEventListener('click', handleAnchorClick);
// Add a test function to the window object for debugging
(window as any).testScroll = (id: string) => {
console.log('Testing scroll to:', id);
findAndScrollToElement(id);
};
// Add a function to test basic scrolling
(window as any).testBasicScroll = () => {
console.log('Testing basic scroll functionality');
const currentScrollY = window.scrollY;
const testScrollY = currentScrollY + 500;
console.log('Current scrollY:', currentScrollY);
console.log('Target scrollY:', testScrollY);
window.scrollTo({
top: testScrollY,
behavior: 'smooth'
});
setTimeout(() => {
console.log('Basic scroll test completed, new scrollY:', window.scrollY);
}, 1000);
};
// Add a function to test scrolling to a specific element
(window as any).testElementScroll = (id: string) => {
console.log('Testing scroll to element:', id);
const element = document.getElementById(id);
if (element) {
console.log('Found element for anchor click:', element.textContent);
console.log('Element found, testing scroll...');
scrollToElement(element);
// Update URL without reload
history.replaceState(null, '', href);
} else {
console.log('Element not found for anchor:', id);
console.log('Element not found:', id);
(window as any).listIds();
}
};
// Add IDs to headings that don't have them
const headings = document.querySelectorAll('h1, h2, h3, h4, h5, h6');
console.log('Processing', headings.length, 'headings for ID assignment');
headings.forEach(heading => {
if (!heading.id) {
const id = generateId(heading.textContent || '');
heading.id = id;
console.log('Added ID to heading:', heading.textContent, '->', id);
// Add a simple test function
(window as any).runAnchorTest = () => {
console.log('Running anchor link test...');
// Test 1: Check if we can find the "overview" heading
const overviewElement = document.getElementById('overview');
if (overviewElement) {
console.log('✅ Found overview element, testing scroll...');
scrollToElement(overviewElement);
} else {
console.log('Heading already has ID:', heading.textContent, '->', heading.id);
console.log('❌ Overview element not found');
}
// Test 2: Check if we can find the "test-heading" element
setTimeout(() => {
const testHeadingElement = document.getElementById('test-heading');
if (testHeadingElement) {
console.log('✅ Found test-heading element, testing scroll...');
scrollToElement(testHeadingElement);
} else {
console.log('❌ Test-heading element not found');
}
}, 2000);
};
// Add a function to list all available IDs
(window as any).listIds = () => {
const allIds = Array.from(document.querySelectorAll('[id]')).map(el => ({
id: el.id,
text: el.textContent?.substring(0, 50),
tag: el.tagName
}));
console.log('Available IDs on page:', allIds);
return allIds;
};
// Add a function to debug anchor links
(window as any).debugAnchors = () => {
console.log('=== Anchor Link Debug ===');
// Get all anchor links
const anchorLinks = Array.from(document.querySelectorAll('a[href^="#"]')).map(el => ({
href: el.getAttribute('href'),
text: el.textContent,
targetId: el.getAttribute('href')?.substring(1)
}));
console.log('Anchor links found:', anchorLinks);
// Get all headings with IDs
const headings = Array.from(document.querySelectorAll('h1[id], h2[id], h3[id], h4[id], h5[id], h6[id]')).map(el => ({
id: el.id,
text: el.textContent?.substring(0, 50),
tag: el.tagName,
offsetTop: (el as HTMLElement).offsetTop,
getBoundingClientRect: (el as HTMLElement).getBoundingClientRect()
}));
console.log('Headings with IDs:', headings);
// Check which anchor links have matching headings
anchorLinks.forEach(link => {
const hasMatch = headings.some(h => h.id === link.targetId);
const status = hasMatch ? '✅' : '❌';
console.log(`${status} [${link.text}](#${link.targetId}) -> ${hasMatch ? 'FOUND' : 'NOT FOUND'}`);
});
// Handle initial hash scroll
setTimeout(handleHashScroll, 100);
console.log('=== End Debug ===');
};
// Add event listeners
document.addEventListener('click', handleAnchorClick);
window.addEventListener('hashchange', handleHashScroll);
// Add a function to show element positions
(window as any).showPositions = () => {
console.log('=== Element Positions ===');
const headings = Array.from(document.querySelectorAll('h1[id], h2[id], h3[id], h4[id], h5[id], h6[id]'));
headings.forEach((el, index) => {
const element = el as HTMLElement;
// Calculate absolute position
let elementTop = 0;
let currentElement = element;
while (currentElement && currentElement !== document.body) {
elementTop += currentElement.offsetTop;
currentElement = currentElement.offsetParent as HTMLElement;
}
console.log(`${index + 1}. ${element.textContent?.substring(0, 30)}:`);
console.log(` ID: ${element.id}`);
console.log(` Calculated elementTop: ${elementTop}`);
console.log(` element.offsetTop: ${element.offsetTop}`);
console.log(` Current scrollY: ${window.scrollY}`);
console.log(` Would scroll to: ${Math.max(0, elementTop - 100)}`);
console.log('---');
});
console.log('=== End Positions ===');
};
// Add a simple test function
(window as any).testScrollToElement = (id: string) => {
const element = document.getElementById(id);
if (element) {
console.log(`Testing scroll to ${id}...`);
// Calculate position the same way as scrollToElement
let elementTop = 0;
let currentElement = element;
while (currentElement && currentElement !== document.body) {
elementTop += currentElement.offsetTop;
currentElement = currentElement.offsetParent as HTMLElement;
}
const targetScrollY = Math.max(0, elementTop - 100);
console.log(`Element ${id} is at position ${elementTop}, would scroll to ${targetScrollY}`);
console.log(`Current scroll position: ${window.scrollY}`);
// Perform the scroll
scrollToElement(element);
} else {
console.log(`Element with id "${id}" not found`);
(window as any).listIds();
}
};
return () => {
document.removeEventListener('click', handleAnchorClick);
window.removeEventListener('hashchange', handleHashScroll);
delete (window as any).testScroll;
delete (window as any).testBasicScroll;
delete (window as any).testElementScroll;
delete (window as any).listIds;
delete (window as any).debugAnchors;
delete (window as any).showPositions;
delete (window as any).testScrollToElement;
};
}, [post]);

View File

@@ -36,11 +36,89 @@ function generateId(text: string): string {
.replace(/^-+|-+$/g, '');
}
// Enhanced slugification function that matches GitHub-style anchor links
function slugify(text: string): string {
return text
.toLowerCase()
.trim()
.replace(/[^\w\s-]/g, '') // Remove special characters except spaces and hyphens
.replace(/[\s_-]+/g, '-') // Replace spaces, underscores, and multiple hyphens with single hyphen
.replace(/^-+|-+$/g, ''); // Remove leading/trailing hyphens
}
// Function to process anchor links in markdown content
function processAnchorLinks(content: string): string {
// Find all markdown links that point to anchors (e.g., [text](#anchor))
return content.replace(/\[([^\]]+)\]\(#([^)]+)\)/g, (match, linkText, anchor) => {
// Only slugify if the anchor doesn't already look like a slug
// This prevents double-processing of already-correct anchor links
const isAlreadySlugified = /^[a-z0-9-]+$/.test(anchor);
const slugifiedAnchor = isAlreadySlugified ? anchor : slugify(anchor);
return `[${linkText}](#${slugifiedAnchor})`;
});
}
// Utility function to debug anchor links (for development)
export function debugAnchorLinks(content: string): void {
if (process.env.NODE_ENV !== 'development') return;
console.log('=== Anchor Link Debug Info ===');
// Extract all headings and their IDs
const headingRegex = /^(#{1,6})\s+(.+)$/gm;
const headings: Array<{ level: number; text: string; id: string }> = [];
let match;
while ((match = headingRegex.exec(content)) !== null) {
const level = match[1].length;
const text = match[2].trim();
const id = slugify(text);
headings.push({ level, text, id });
}
console.log('Generated heading IDs:');
headings.forEach(({ level, text, id }) => {
console.log(` H${level}: "${text}" -> id="${id}"`);
});
// Extract all anchor links
const anchorLinkRegex = /\[([^\]]+)\]\(#([^)]+)\)/g;
const anchorLinks: Array<{ linkText: string; originalAnchor: string; slugifiedAnchor: string }> = [];
while ((match = anchorLinkRegex.exec(content)) !== null) {
const linkText = match[1];
const originalAnchor = match[2];
const slugifiedAnchor = slugify(originalAnchor);
anchorLinks.push({ linkText, originalAnchor, slugifiedAnchor });
}
console.log('Anchor links found:');
anchorLinks.forEach(({ linkText, originalAnchor, slugifiedAnchor }) => {
const headingExists = headings.some(h => h.id === slugifiedAnchor);
const status = headingExists ? '✅' : '❌';
console.log(` ${status} [${linkText}](#${originalAnchor}) -> [${linkText}](#${slugifiedAnchor})`);
});
// Show missing headings
const missingAnchors = anchorLinks.filter(({ slugifiedAnchor }) =>
!headings.some(h => h.id === slugifiedAnchor)
);
if (missingAnchors.length > 0) {
console.warn('Missing headings for these anchor links:');
missingAnchors.forEach(({ linkText, originalAnchor, slugifiedAnchor }) => {
console.warn(` - [${linkText}](#${originalAnchor}) -> id="${slugifiedAnchor}"`);
});
}
console.log('=== End Debug Info ===');
}
const renderer = new marked.Renderer();
// Custom heading renderer to add IDs
renderer.heading = (text, level) => {
const id = generateId(text);
const id = slugify(text);
return `<h${level} id="${id}">${text}</h${level}>`;
};
@@ -68,7 +146,12 @@ export async function getPostBySlug(slug: string): Promise<Post> {
let processedContent = '';
try {
const rawHtml = marked.parse(content);
// Debug anchor links in development
debugAnchorLinks(content);
// Process anchor links before parsing markdown
const processedMarkdown = processAnchorLinks(content);
const rawHtml = marked.parse(processedMarkdown);
const window = new JSDOM('').window;
const purify = DOMPurify(window);
processedContent = purify.sanitize(rawHtml as string, {