mirror of
https://github.com/waynesutton/markdown-site.git
synced 2026-01-12 04:09:14 +00:00
Export as PDF, Core Web Vitals performance optimizations, Enhanced diff code block rendering and blog post example on codeblocks
This commit is contained in:
@@ -47,6 +47,7 @@ import { Copy, Check, X } from "lucide-react";
|
||||
import { useTheme } from "../context/ThemeContext";
|
||||
import NewsletterSignup from "./NewsletterSignup";
|
||||
import ContactForm from "./ContactForm";
|
||||
import DiffCodeBlock from "./DiffCodeBlock";
|
||||
import siteConfig from "../config/siteConfig";
|
||||
import { useSearchHighlighting } from "../hooks/useSearchHighlighting";
|
||||
|
||||
@@ -611,6 +612,17 @@ export default function BlogPost({
|
||||
|
||||
const codeString = String(children).replace(/\n$/, "");
|
||||
const language = match ? match[1] : "text";
|
||||
|
||||
// Route diff/patch to DiffCodeBlock for enhanced diff rendering
|
||||
if (language === "diff" || language === "patch") {
|
||||
return (
|
||||
<DiffCodeBlock
|
||||
code={codeString}
|
||||
language={language as "diff" | "patch"}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const isTextBlock = language === "text";
|
||||
|
||||
// Custom styles for text blocks to enable wrapping
|
||||
@@ -899,6 +911,17 @@ export default function BlogPost({
|
||||
|
||||
const codeString = String(children).replace(/\n$/, "");
|
||||
const language = match ? match[1] : "text";
|
||||
|
||||
// Route diff/patch to DiffCodeBlock for enhanced diff rendering
|
||||
if (language === "diff" || language === "patch") {
|
||||
return (
|
||||
<DiffCodeBlock
|
||||
code={codeString}
|
||||
language={language as "diff" | "patch"}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const isTextBlock = language === "text";
|
||||
|
||||
// Custom styles for text blocks to enable wrapping
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
Download,
|
||||
ExternalLink,
|
||||
} from "lucide-react";
|
||||
import { FilePdf } from "@phosphor-icons/react";
|
||||
|
||||
// Maximum URL length for query parameters (conservative limit)
|
||||
const MAX_URL_LENGTH = 6000;
|
||||
@@ -110,6 +111,38 @@ function formatAsSkill(props: CopyPageDropdownProps): string {
|
||||
return skill;
|
||||
}
|
||||
|
||||
// Format content for print/PDF export (clean, readable document)
|
||||
function formatForPrint(props: CopyPageDropdownProps): {
|
||||
title: string;
|
||||
metadata: string[];
|
||||
description: string;
|
||||
content: string;
|
||||
} {
|
||||
const { title, content, description, date, tags, readTime } = props;
|
||||
|
||||
const metadata: string[] = [];
|
||||
if (date) metadata.push(date);
|
||||
if (readTime) metadata.push(readTime);
|
||||
if (tags && tags.length > 0) metadata.push(tags.join(", "));
|
||||
|
||||
// Strip common markdown syntax for cleaner display
|
||||
const cleanContent = content
|
||||
.replace(/^#{1,6}\s+/gm, "") // Remove heading markers
|
||||
.replace(/\*\*([^*]+)\*\*/g, "$1") // Bold to plain
|
||||
.replace(/\*([^*]+)\*/g, "$1") // Italic to plain
|
||||
.replace(/`([^`]+)`/g, "$1") // Inline code to plain
|
||||
.replace(/^\s*[-*+]\s+/gm, "- ") // Normalize list markers
|
||||
.replace(/^\s*>\s+/gm, "") // Remove blockquote markers
|
||||
.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1"); // Links to text only
|
||||
|
||||
return {
|
||||
title,
|
||||
metadata,
|
||||
description: description || "",
|
||||
content: cleanContent,
|
||||
};
|
||||
}
|
||||
|
||||
// Check if URL length exceeds safe limits
|
||||
function isUrlTooLong(url: string): boolean {
|
||||
return url.length > MAX_URL_LENGTH;
|
||||
@@ -280,6 +313,67 @@ export default function CopyPageDropdown(props: CopyPageDropdownProps) {
|
||||
setTimeout(() => setIsOpen(false), 1500);
|
||||
};
|
||||
|
||||
// Handle export as PDF (browser print dialog)
|
||||
const handleExportPDF = () => {
|
||||
const printData = formatForPrint(props);
|
||||
const printWindow = window.open("", "_blank");
|
||||
if (printWindow) {
|
||||
const escapeHtml = (str: string) =>
|
||||
str.replace(/</g, "<").replace(/>/g, ">");
|
||||
|
||||
printWindow.document.write(`
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>${escapeHtml(printData.title)}</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
max-width: 700px;
|
||||
margin: 0 auto;
|
||||
padding: 40px 20px;
|
||||
line-height: 1.7;
|
||||
color: #1a1a1a;
|
||||
}
|
||||
h1 {
|
||||
font-size: 28px;
|
||||
margin-bottom: 8px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.metadata {
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.description {
|
||||
font-size: 18px;
|
||||
color: #444;
|
||||
margin-bottom: 24px;
|
||||
font-style: italic;
|
||||
}
|
||||
.content {
|
||||
white-space: pre-wrap;
|
||||
font-size: 16px;
|
||||
}
|
||||
@media print {
|
||||
body { padding: 20px; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>${escapeHtml(printData.title)}</h1>
|
||||
${printData.metadata.length > 0 ? `<div class="metadata">${printData.metadata.join(" | ")}</div>` : ""}
|
||||
${printData.description ? `<div class="description">${escapeHtml(printData.description)}</div>` : ""}
|
||||
<div class="content">${escapeHtml(printData.content)}</div>
|
||||
</body>
|
||||
</html>
|
||||
`);
|
||||
printWindow.document.close();
|
||||
printWindow.print();
|
||||
}
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
// Get feedback icon
|
||||
const getFeedbackIcon = () => {
|
||||
switch (feedback) {
|
||||
@@ -515,6 +609,22 @@ export default function CopyPageDropdown(props: CopyPageDropdownProps) {
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Export as PDF option */}
|
||||
<button
|
||||
className="copy-page-item"
|
||||
onClick={handleExportPDF}
|
||||
role="menuitem"
|
||||
tabIndex={0}
|
||||
>
|
||||
<FilePdf size={16} className="copy-page-icon" aria-hidden="true" />
|
||||
<div className="copy-page-item-content">
|
||||
<span className="copy-page-item-title">Export as PDF</span>
|
||||
<span className="copy-page-item-desc">
|
||||
Print or save as PDF
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
74
src/components/DiffCodeBlock.tsx
Normal file
74
src/components/DiffCodeBlock.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
import { useState } from "react";
|
||||
import { PatchDiff } from "@pierre/diffs/react";
|
||||
import { Copy, Check, Columns2, AlignJustify } from "lucide-react";
|
||||
import { useTheme } from "../context/ThemeContext";
|
||||
|
||||
// Map app themes to @pierre/diffs themeType
|
||||
const THEME_MAP: Record<string, "dark" | "light"> = {
|
||||
dark: "dark",
|
||||
light: "light",
|
||||
tan: "light",
|
||||
cloud: "light",
|
||||
};
|
||||
|
||||
interface DiffCodeBlockProps {
|
||||
code: string;
|
||||
language: "diff" | "patch";
|
||||
}
|
||||
|
||||
export default function DiffCodeBlock({ code, language }: DiffCodeBlockProps) {
|
||||
const { theme } = useTheme();
|
||||
const [viewMode, setViewMode] = useState<"split" | "unified">("unified");
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
// Get theme type for @pierre/diffs
|
||||
const themeType = THEME_MAP[theme] || "dark";
|
||||
|
||||
const handleCopy = async () => {
|
||||
await navigator.clipboard.writeText(code);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="diff-block-wrapper" data-theme-type={themeType}>
|
||||
<div className="diff-block-header">
|
||||
<span className="diff-language">{language}</span>
|
||||
<div className="diff-block-controls">
|
||||
<button
|
||||
className="diff-view-toggle"
|
||||
onClick={() =>
|
||||
setViewMode(viewMode === "split" ? "unified" : "split")
|
||||
}
|
||||
title={
|
||||
viewMode === "split"
|
||||
? "Switch to unified view"
|
||||
: "Switch to split view"
|
||||
}
|
||||
>
|
||||
{viewMode === "split" ? (
|
||||
<AlignJustify size={14} />
|
||||
) : (
|
||||
<Columns2 size={14} />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
className="diff-copy-button"
|
||||
onClick={handleCopy}
|
||||
aria-label={copied ? "Copied!" : "Copy code"}
|
||||
title={copied ? "Copied!" : "Copy code"}
|
||||
>
|
||||
{copied ? <Check size={14} /> : <Copy size={14} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<PatchDiff
|
||||
patch={code}
|
||||
options={{
|
||||
themeType,
|
||||
diffStyle: viewMode,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -116,21 +116,21 @@ export default function VisitorMap({ locations, title }: VisitorMapProps) {
|
||||
{/* Visitor location dots with pulse animation */}
|
||||
{visitorDots.map((dot, i) => (
|
||||
<g key={`visitor-${i}`}>
|
||||
{/* Outer pulse ring */}
|
||||
{/* Outer pulse ring - base r=5, scaled via CSS transform */}
|
||||
<circle
|
||||
cx={dot.x}
|
||||
cy={dot.y}
|
||||
r="12"
|
||||
r="5"
|
||||
fill="var(--visitor-map-dot)"
|
||||
opacity="0"
|
||||
className="visitor-pulse-ring"
|
||||
style={{ animationDelay: `${i * 0.2}s` }}
|
||||
/>
|
||||
{/* Middle pulse ring */}
|
||||
{/* Middle pulse ring - base r=5, scaled via CSS transform */}
|
||||
<circle
|
||||
cx={dot.x}
|
||||
cy={dot.y}
|
||||
r="8"
|
||||
r="5"
|
||||
fill="var(--visitor-map-dot)"
|
||||
opacity="0.2"
|
||||
className="visitor-pulse-ring-mid"
|
||||
|
||||
Reference in New Issue
Block a user