vending-mocha 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/npm-publish.yml +45 -0
- package/.github/workflows/regenerate-dist.yml +38 -0
- package/LICENSE +201 -0
- package/README.md +85 -0
- package/SKILL.md +82 -0
- package/bin/cli.js +441 -0
- package/eslint.config.js +23 -0
- package/index.html +16 -0
- package/package.json +57 -0
- package/posts/customization-guide.md +45 -0
- package/posts/deploy-to-github-pages.md +109 -0
- package/posts/hello-world.md +20 -0
- package/posts/markdown-features.md +57 -0
- package/prerender.js +221 -0
- package/projects/legacy-api.md +7 -0
- package/projects/task-master.md +7 -0
- package/projects/vending-mocha.md +7 -0
- package/scripts/generate-posts-data.js +41 -0
- package/scripts/generate-projects-data.js +40 -0
- package/scripts/generate-rss.js +75 -0
- package/src/App.css +566 -0
- package/src/App.tsx +33 -0
- package/src/components/Footer.tsx +11 -0
- package/src/components/MarkdownImage.tsx +40 -0
- package/src/components/Profile.tsx +45 -0
- package/src/components/SiteHeader.tsx +44 -0
- package/src/context/ThemeContext.tsx +75 -0
- package/src/entry-client.tsx +32 -0
- package/src/entry-server.tsx +26 -0
- package/src/pages/BlogPost.tsx +93 -0
- package/src/pages/HomePage.tsx +85 -0
- package/src/pages/Projects.tsx +50 -0
- package/src/site.config.ts +38 -0
- package/src/utils/basePath.ts +21 -0
- package/src/utils/date.ts +17 -0
- package/src/utils/frontmatter.ts +32 -0
- package/static/favicon.ico +0 -0
- package/static/images/profile.png +0 -0
- package/tsconfig.app.json +36 -0
- package/tsconfig.json +7 -0
- package/tsconfig.node.json +26 -0
- package/vite.config.ts +61 -0
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { useState } from 'react';
|
|
2
|
+
import { Link } from 'react-router-dom';
|
|
3
|
+
import { Home, FolderGit2, Sun, Moon, Menu, X } from 'lucide-react';
|
|
4
|
+
import { useTheme } from '../context/ThemeContext';
|
|
5
|
+
import { siteConfig } from '../site.config';
|
|
6
|
+
import projects from '../projects.json';
|
|
7
|
+
|
|
8
|
+
export default function SiteHeader({ showTitle = true }: { showTitle?: boolean }) {
|
|
9
|
+
const { theme, toggleTheme } = useTheme();
|
|
10
|
+
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
|
11
|
+
|
|
12
|
+
const toggleMenu = () => setIsMenuOpen(!isMenuOpen);
|
|
13
|
+
const closeMenu = () => setIsMenuOpen(false);
|
|
14
|
+
|
|
15
|
+
return (
|
|
16
|
+
<header className={showTitle ? 'main-header with-bottom-border' : 'main-header'}>
|
|
17
|
+
<div className="header-title">{showTitle && <h1 className="site-title"><Link to="/">{siteConfig.title}</Link></h1>}</div>
|
|
18
|
+
<div className="nav-container">
|
|
19
|
+
<nav className={`top-nav ${isMenuOpen ? 'active' : ''}`}>
|
|
20
|
+
<Link to="/" onClick={closeMenu}>
|
|
21
|
+
<Home size={18} />
|
|
22
|
+
<span>Home</span>
|
|
23
|
+
</Link>
|
|
24
|
+
{projects.length > 0 && (
|
|
25
|
+
<Link to="/projects/" onClick={closeMenu}>
|
|
26
|
+
<FolderGit2 size={18} />
|
|
27
|
+
<span>Projects</span>
|
|
28
|
+
</Link>
|
|
29
|
+
)}
|
|
30
|
+
</nav>
|
|
31
|
+
|
|
32
|
+
<div className="header-actions">
|
|
33
|
+
<button onClick={toggleTheme} className="theme-toggle" aria-label="Toggle theme">
|
|
34
|
+
{theme === 'light' ? <Moon size={20} /> : <Sun size={20} />}
|
|
35
|
+
</button>
|
|
36
|
+
|
|
37
|
+
<button className="hamburger-menu" onClick={toggleMenu} aria-label="Toggle menu">
|
|
38
|
+
{isMenuOpen ? <X size={24} /> : <Menu size={24} />}
|
|
39
|
+
</button>
|
|
40
|
+
</div>
|
|
41
|
+
</div>
|
|
42
|
+
</header>
|
|
43
|
+
);
|
|
44
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { createContext, useContext, useEffect, useState } from 'react';
|
|
2
|
+
import { siteConfig } from '../site.config';
|
|
3
|
+
|
|
4
|
+
type Theme = 'light' | 'dark';
|
|
5
|
+
|
|
6
|
+
interface ThemeContextType {
|
|
7
|
+
theme: Theme;
|
|
8
|
+
toggleTheme: () => void;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
|
|
12
|
+
|
|
13
|
+
export function ThemeProvider({ children }: { children: React.ReactNode }) {
|
|
14
|
+
const [theme, setTheme] = useState<Theme>(() => {
|
|
15
|
+
if (typeof window !== 'undefined') {
|
|
16
|
+
const savedTheme = localStorage.getItem('theme') as Theme;
|
|
17
|
+
if (savedTheme) return savedTheme;
|
|
18
|
+
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
|
19
|
+
}
|
|
20
|
+
return 'light';
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
useEffect(() => {
|
|
24
|
+
const root = document.documentElement;
|
|
25
|
+
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
26
|
+
// @ts-ignore
|
|
27
|
+
const themeConfig = siteConfig.theme[theme];
|
|
28
|
+
|
|
29
|
+
root.style.setProperty('--color-primary', themeConfig.primary);
|
|
30
|
+
root.style.setProperty('--color-secondary', themeConfig.secondary);
|
|
31
|
+
root.style.setProperty('--color-background', themeConfig.background);
|
|
32
|
+
root.style.setProperty('--color-text', themeConfig.text);
|
|
33
|
+
root.style.setProperty('--color-border', themeConfig.border);
|
|
34
|
+
root.style.setProperty('--color-accent', themeConfig.accent);
|
|
35
|
+
root.style.setProperty('--color-card-bg', themeConfig.cardBackground);
|
|
36
|
+
root.style.setProperty('--color-link-hover', themeConfig.linkHover);
|
|
37
|
+
root.style.setProperty('--color-link', themeConfig.linkColor);
|
|
38
|
+
root.style.setProperty('--font-family', siteConfig.theme.fontFamily);
|
|
39
|
+
}, [theme]);
|
|
40
|
+
|
|
41
|
+
useEffect(() => {
|
|
42
|
+
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
|
43
|
+
const handleChange = (e: MediaQueryListEvent) => {
|
|
44
|
+
if (!localStorage.getItem('theme')) {
|
|
45
|
+
setTheme(e.matches ? 'dark' : 'light');
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
mediaQuery.addEventListener('change', handleChange);
|
|
50
|
+
return () => mediaQuery.removeEventListener('change', handleChange);
|
|
51
|
+
}, []);
|
|
52
|
+
|
|
53
|
+
const toggleTheme = () => {
|
|
54
|
+
setTheme(prevTheme => {
|
|
55
|
+
const newTheme = prevTheme === 'light' ? 'dark' : 'light';
|
|
56
|
+
localStorage.setItem('theme', newTheme);
|
|
57
|
+
return newTheme;
|
|
58
|
+
});
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
return (
|
|
62
|
+
<ThemeContext.Provider value={{ theme, toggleTheme }}>
|
|
63
|
+
{children}
|
|
64
|
+
</ThemeContext.Provider>
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// eslint-disable-next-line react-refresh/only-export-components
|
|
69
|
+
export const useTheme = () => {
|
|
70
|
+
const context = useContext(ThemeContext);
|
|
71
|
+
if (!context) {
|
|
72
|
+
throw new Error('useTheme must be used within a ThemeProvider');
|
|
73
|
+
}
|
|
74
|
+
return context;
|
|
75
|
+
};
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { StrictMode } from 'react'
|
|
2
|
+
import { createRoot, hydrateRoot } from 'react-dom/client'
|
|
3
|
+
import { BrowserRouter } from 'react-router-dom'
|
|
4
|
+
import * as ReactHelmetAsync from 'react-helmet-async'
|
|
5
|
+
import App from './App'
|
|
6
|
+
|
|
7
|
+
const { HelmetProvider } = (ReactHelmetAsync as any).default || ReactHelmetAsync;
|
|
8
|
+
|
|
9
|
+
const container = document.getElementById('root')!
|
|
10
|
+
|
|
11
|
+
if (container.children.length > 0) {
|
|
12
|
+
hydrateRoot(
|
|
13
|
+
container,
|
|
14
|
+
<StrictMode>
|
|
15
|
+
<HelmetProvider>
|
|
16
|
+
<BrowserRouter basename={import.meta.env.VITE_BASE_PATH || '/'}>
|
|
17
|
+
<App />
|
|
18
|
+
</BrowserRouter>
|
|
19
|
+
</HelmetProvider>
|
|
20
|
+
</StrictMode>,
|
|
21
|
+
)
|
|
22
|
+
} else {
|
|
23
|
+
createRoot(container).render(
|
|
24
|
+
<StrictMode>
|
|
25
|
+
<HelmetProvider>
|
|
26
|
+
<BrowserRouter basename={import.meta.env.VITE_BASE_PATH || '/'}>
|
|
27
|
+
<App />
|
|
28
|
+
</BrowserRouter>
|
|
29
|
+
</HelmetProvider>
|
|
30
|
+
</StrictMode>,
|
|
31
|
+
)
|
|
32
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { StrictMode } from 'react'
|
|
2
|
+
import { renderToString } from 'react-dom/server'
|
|
3
|
+
import { StaticRouter } from 'react-router-dom'
|
|
4
|
+
import * as ReactHelmetAsync from 'react-helmet-async'
|
|
5
|
+
import App from './App'
|
|
6
|
+
import { siteConfig } from './site.config'
|
|
7
|
+
export { siteConfig }
|
|
8
|
+
|
|
9
|
+
const { HelmetProvider } = (ReactHelmetAsync as any).default || ReactHelmetAsync;
|
|
10
|
+
|
|
11
|
+
import { getBasePath } from './utils/basePath';
|
|
12
|
+
|
|
13
|
+
export function render(url: string) {
|
|
14
|
+
const helmetContext: any = {};
|
|
15
|
+
const html = renderToString(
|
|
16
|
+
<StrictMode>
|
|
17
|
+
<HelmetProvider context={helmetContext}>
|
|
18
|
+
<StaticRouter location={url} basename={getBasePath()}>
|
|
19
|
+
<App />
|
|
20
|
+
</StaticRouter>
|
|
21
|
+
</HelmetProvider>
|
|
22
|
+
</StrictMode>
|
|
23
|
+
);
|
|
24
|
+
|
|
25
|
+
return { html, helmet: helmetContext.helmet };
|
|
26
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { useParams } from 'react-router-dom';
|
|
2
|
+
import { siteConfig } from '../site.config';
|
|
3
|
+
import SiteHeader from '../components/SiteHeader';
|
|
4
|
+
import Markdown from 'react-markdown';
|
|
5
|
+
import { parseFrontmatter } from '../utils/frontmatter';
|
|
6
|
+
import postsData from '../posts.json';
|
|
7
|
+
import { formatDate } from '../utils/date';
|
|
8
|
+
import remarkGfm from 'remark-gfm';
|
|
9
|
+
import { MarkdownImage } from '../components/MarkdownImage';
|
|
10
|
+
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
|
|
11
|
+
import { dracula } from 'react-syntax-highlighter/dist/esm/styles/prism';
|
|
12
|
+
|
|
13
|
+
// Load all posts synchronously
|
|
14
|
+
const postFiles = import.meta.glob('../../posts/*.md', {
|
|
15
|
+
eager: true,
|
|
16
|
+
query: '?raw',
|
|
17
|
+
import: 'default'
|
|
18
|
+
}) as Record<string, string>;
|
|
19
|
+
|
|
20
|
+
export default function BlogPost() {
|
|
21
|
+
const { slug } = useParams();
|
|
22
|
+
const postMeta = (postsData as any[]).find(p => p.slug === slug);
|
|
23
|
+
|
|
24
|
+
if (!postMeta) {
|
|
25
|
+
return <div>Post Not Found</div>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const filePath = `../../posts/${slug}.md`;
|
|
29
|
+
const rawContent = postFiles[filePath];
|
|
30
|
+
|
|
31
|
+
if (!rawContent) {
|
|
32
|
+
return <div>Post Content Not Found</div>;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const { content } = parseFrontmatter(rawContent);
|
|
36
|
+
|
|
37
|
+
const jsonLd = {
|
|
38
|
+
"@context": "https://schema.org",
|
|
39
|
+
"@type": "BlogPosting",
|
|
40
|
+
"headline": postMeta.title,
|
|
41
|
+
"datePublished": postMeta.date,
|
|
42
|
+
"description": postMeta.summary || siteConfig.description,
|
|
43
|
+
"author": {
|
|
44
|
+
"@type": "Person",
|
|
45
|
+
"name": siteConfig.title,
|
|
46
|
+
"url": siteConfig.url
|
|
47
|
+
},
|
|
48
|
+
"mainEntityOfPage": {
|
|
49
|
+
"@type": "WebPage",
|
|
50
|
+
"@id": `${siteConfig.url}/post/${slug}`
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
return (
|
|
55
|
+
<div className="blog-container">
|
|
56
|
+
<SiteHeader />
|
|
57
|
+
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} />
|
|
58
|
+
<header>
|
|
59
|
+
<h1>{postMeta.title}</h1>
|
|
60
|
+
<div className="meta">
|
|
61
|
+
<span>{formatDate(postMeta.date)}</span>
|
|
62
|
+
</div>
|
|
63
|
+
</header>
|
|
64
|
+
<div className="markdown-content">
|
|
65
|
+
<Markdown
|
|
66
|
+
remarkPlugins={[remarkGfm]}
|
|
67
|
+
components={{
|
|
68
|
+
img: MarkdownImage,
|
|
69
|
+
code(props) {
|
|
70
|
+
const { children, className, node, ref, ...rest } = props
|
|
71
|
+
const match = /language-(\w+)/.exec(className || '')
|
|
72
|
+
return match ? (
|
|
73
|
+
<SyntaxHighlighter
|
|
74
|
+
{...rest}
|
|
75
|
+
PreTag="div"
|
|
76
|
+
children={String(children).replace(/\n$/, '')}
|
|
77
|
+
language={match[1]}
|
|
78
|
+
style={dracula}
|
|
79
|
+
/>
|
|
80
|
+
) : (
|
|
81
|
+
<code {...rest} className={className}>
|
|
82
|
+
{children}
|
|
83
|
+
</code>
|
|
84
|
+
)
|
|
85
|
+
}
|
|
86
|
+
}}
|
|
87
|
+
>
|
|
88
|
+
{content}
|
|
89
|
+
</Markdown>
|
|
90
|
+
</div>
|
|
91
|
+
</div>
|
|
92
|
+
);
|
|
93
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { Link, useParams } from 'react-router-dom';
|
|
2
|
+
import { getBasePath } from '../utils/basePath';
|
|
3
|
+
import SiteHeader from '../components/SiteHeader';
|
|
4
|
+
import * as ReactHelmetAsync from 'react-helmet-async';
|
|
5
|
+
import { siteConfig } from '../site.config';
|
|
6
|
+
import { formatDate } from '../utils/date';
|
|
7
|
+
|
|
8
|
+
const { Helmet } = (ReactHelmetAsync as any).default || ReactHelmetAsync;
|
|
9
|
+
|
|
10
|
+
const POSTS_PER_PAGE = 50;
|
|
11
|
+
|
|
12
|
+
import Profile from '../components/Profile';
|
|
13
|
+
|
|
14
|
+
// Import generated posts data
|
|
15
|
+
import postsData from '../posts.json';
|
|
16
|
+
|
|
17
|
+
interface BlogPost {
|
|
18
|
+
slug: string;
|
|
19
|
+
title: string;
|
|
20
|
+
date: string;
|
|
21
|
+
summary: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export default function HomePage() {
|
|
25
|
+
const { pageNumber } = useParams();
|
|
26
|
+
const currentPage = parseInt(pageNumber || '1', 10);
|
|
27
|
+
const blogPosts: BlogPost[] = postsData as BlogPost[];
|
|
28
|
+
|
|
29
|
+
const totalPages = Math.ceil(blogPosts.length / POSTS_PER_PAGE);
|
|
30
|
+
const startIndex = (currentPage - 1) * POSTS_PER_PAGE;
|
|
31
|
+
const paginatedPosts = blogPosts.slice(startIndex, startIndex + POSTS_PER_PAGE);
|
|
32
|
+
|
|
33
|
+
const hasNextPage = currentPage < totalPages;
|
|
34
|
+
const hasPrevPage = currentPage > 1;
|
|
35
|
+
|
|
36
|
+
const pageTitle = currentPage > 1 ? `${siteConfig.title} - Page ${currentPage}` : siteConfig.title;
|
|
37
|
+
|
|
38
|
+
return (
|
|
39
|
+
<div className="blog-container">
|
|
40
|
+
<Helmet>
|
|
41
|
+
<title>{pageTitle}</title>
|
|
42
|
+
<meta name="description" content={siteConfig.description} />
|
|
43
|
+
<meta property="og:title" content={pageTitle} />
|
|
44
|
+
<meta property="og:description" content={siteConfig.description} />
|
|
45
|
+
<meta property="og:url" content={siteConfig.url} />
|
|
46
|
+
<meta property="og:type" content="website" />
|
|
47
|
+
{siteConfig.image && <meta property="og:image" content={siteConfig.image.startsWith('http') ? siteConfig.image : `${getBasePath()}${siteConfig.image.startsWith('/') ? siteConfig.image.slice(1) : siteConfig.image}`} />}
|
|
48
|
+
</Helmet>
|
|
49
|
+
<SiteHeader showTitle={false} />
|
|
50
|
+
<Profile />
|
|
51
|
+
|
|
52
|
+
<section className="posts-section">
|
|
53
|
+
<h2>
|
|
54
|
+
{currentPage > 1 ? `Posts - Page ${currentPage}` : 'Recent Posts'}
|
|
55
|
+
</h2>
|
|
56
|
+
<div className="posts-list">
|
|
57
|
+
{paginatedPosts.map((post) => (
|
|
58
|
+
<article key={post.slug} className="post-item">
|
|
59
|
+
<div className="post-header">
|
|
60
|
+
<h3><Link to={`/post/${post.slug}/`}>{post.title}</Link></h3>
|
|
61
|
+
<span className="post-date">{formatDate(post.date)}</span>
|
|
62
|
+
</div>
|
|
63
|
+
<p className="post-summary">{post.summary}</p>
|
|
64
|
+
</article>
|
|
65
|
+
))}
|
|
66
|
+
</div>
|
|
67
|
+
|
|
68
|
+
{totalPages > 1 && (
|
|
69
|
+
<nav className="pagination">
|
|
70
|
+
{hasPrevPage && (
|
|
71
|
+
<Link to={currentPage === 2 ? '/' : `/page/${currentPage - 1}/`} className="prev-link">
|
|
72
|
+
← Previous
|
|
73
|
+
</Link>
|
|
74
|
+
)}
|
|
75
|
+
{hasNextPage && (
|
|
76
|
+
<Link to={`/page/${currentPage + 1}/`} className="next-link">
|
|
77
|
+
Next →
|
|
78
|
+
</Link>
|
|
79
|
+
)}
|
|
80
|
+
</nav>
|
|
81
|
+
)}
|
|
82
|
+
</section>
|
|
83
|
+
</div>
|
|
84
|
+
);
|
|
85
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import SiteHeader from '../components/SiteHeader';
|
|
2
|
+
|
|
3
|
+
import projectsData from '../projects.json';
|
|
4
|
+
|
|
5
|
+
type ProjectStatus = 'active' | 'dead' | 'inactive';
|
|
6
|
+
|
|
7
|
+
interface Project {
|
|
8
|
+
name: string;
|
|
9
|
+
description: string;
|
|
10
|
+
link: string;
|
|
11
|
+
status: ProjectStatus;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const projects = projectsData as Project[];
|
|
15
|
+
|
|
16
|
+
export default function Projects() {
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
const getStatusDisplay = (status: ProjectStatus) => {
|
|
20
|
+
switch (status) {
|
|
21
|
+
case 'active':
|
|
22
|
+
return '🚀 Active';
|
|
23
|
+
case 'dead':
|
|
24
|
+
return '💀 Dead';
|
|
25
|
+
case 'inactive':
|
|
26
|
+
return '🦥 Inactive';
|
|
27
|
+
default:
|
|
28
|
+
return status;
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
return (
|
|
33
|
+
<div className="blog-container">
|
|
34
|
+
<SiteHeader />
|
|
35
|
+
<section className="projects-section">
|
|
36
|
+
<h2>Projects</h2>
|
|
37
|
+
<div className="project-grid">
|
|
38
|
+
{projects.map((project, index) => (
|
|
39
|
+
<div key={index} className="project-card">
|
|
40
|
+
<h3><a href={project.link} target="_blank">{project.name}</a></h3>
|
|
41
|
+
<p>{project.description}</p>
|
|
42
|
+
<p className="project-status">{getStatusDisplay(project.status)}</p>
|
|
43
|
+
<a className="btn" href={project.link} target="_blank">View Project</a>
|
|
44
|
+
</div>
|
|
45
|
+
))}
|
|
46
|
+
</div>
|
|
47
|
+
</section>
|
|
48
|
+
</div>
|
|
49
|
+
);
|
|
50
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
|
|
2
|
+
|
|
3
|
+
export const siteConfig = {
|
|
4
|
+
title: "Vending Mocha",
|
|
5
|
+
url: "https://vendingmocha.com",
|
|
6
|
+
description: '**Vending Mocha** is a lightweight, strictly typed, and blazing fast personal blogging framework built with React, TypeScript, and Vite.\n\nIt is designed to be minimal, easy to customize, and deployed within minutes. Create a site, update the config, and start writing!',
|
|
7
|
+
image: '/images/profile.png',
|
|
8
|
+
contact: {
|
|
9
|
+
github: "https://github.com/kcthota/vending-mocha",
|
|
10
|
+
email: undefined
|
|
11
|
+
},
|
|
12
|
+
footerText: "My opinions are my own.",
|
|
13
|
+
theme: {
|
|
14
|
+
light: {
|
|
15
|
+
primary: "#222",
|
|
16
|
+
secondary: "#666",
|
|
17
|
+
background: "#ffffff",
|
|
18
|
+
text: "#213547",
|
|
19
|
+
border: "#eee",
|
|
20
|
+
accent: "#213547",
|
|
21
|
+
cardBackground: "#eee",
|
|
22
|
+
linkHover: "#000",
|
|
23
|
+
linkColor: "#222",
|
|
24
|
+
},
|
|
25
|
+
dark: {
|
|
26
|
+
primary: "#fff",
|
|
27
|
+
secondary: "#aaa",
|
|
28
|
+
background: "#121212",
|
|
29
|
+
text: "#e0e0e0",
|
|
30
|
+
border: "#333",
|
|
31
|
+
accent: "#e0e0e0",
|
|
32
|
+
cardBackground: "#373636ff",
|
|
33
|
+
linkHover: "#fff",
|
|
34
|
+
linkColor: "#ccc",
|
|
35
|
+
},
|
|
36
|
+
fontFamily: "Avenir, Open Sans, sans-serif",
|
|
37
|
+
},
|
|
38
|
+
};
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
|
|
2
|
+
import { siteConfig } from '../site.config';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Derives the base path from siteConfig.url.
|
|
6
|
+
* This is useful for SSG and ensure consistency.
|
|
7
|
+
*
|
|
8
|
+
* If siteConfig.url is "https://user.github.io/repo/", basePath is "/repo/".
|
|
9
|
+
* If siteConfig.url is "https://custom.com/", basePath is "/".
|
|
10
|
+
*/
|
|
11
|
+
export function getBasePath(): string {
|
|
12
|
+
try {
|
|
13
|
+
if (!siteConfig.url) return '/';
|
|
14
|
+
const url = new URL(siteConfig.url);
|
|
15
|
+
const path = url.pathname;
|
|
16
|
+
return path.endsWith('/') ? path : `${path}/`;
|
|
17
|
+
} catch (e) {
|
|
18
|
+
console.warn('Invalid siteConfig.url, defaulting to /');
|
|
19
|
+
return '/';
|
|
20
|
+
}
|
|
21
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { format, parse } from 'date-fns';
|
|
2
|
+
|
|
3
|
+
export const formatDate = (dateString: string): string => {
|
|
4
|
+
if (!dateString) return '';
|
|
5
|
+
|
|
6
|
+
// Try parsing as standard date first (handles ISO strings with time)
|
|
7
|
+
const date = new Date(dateString);
|
|
8
|
+
if (!isNaN(date.getTime())) {
|
|
9
|
+
return format(date, 'MMMM dd, yyyy');
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
try {
|
|
13
|
+
return format(parse(dateString, 'yyyy-MM-dd', new Date()), 'MMMM dd, yyyy');
|
|
14
|
+
} catch (e) {
|
|
15
|
+
return dateString;
|
|
16
|
+
}
|
|
17
|
+
};
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
export interface Frontmatter {
|
|
2
|
+
[key: string]: string;
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
export function parseFrontmatter(content: string): { data: Frontmatter; content: string } {
|
|
6
|
+
const frontmatterRegex = /^---\s*[\r\n]+([\s\S]*?)[\r\n]+---\s*[\r\n]+([\s\S]*)$/;
|
|
7
|
+
const match = frontmatterRegex.exec(content);
|
|
8
|
+
|
|
9
|
+
// If no frontmatter is found, return empty data and the whole content
|
|
10
|
+
if (!match) {
|
|
11
|
+
return { data: {}, content };
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const frontmatterBlock = match[1];
|
|
15
|
+
const body = match[2];
|
|
16
|
+
|
|
17
|
+
const data: Frontmatter = {};
|
|
18
|
+
frontmatterBlock.split('\n').forEach((line) => {
|
|
19
|
+
const parts = line.split(':');
|
|
20
|
+
if (parts.length >= 2) {
|
|
21
|
+
const key = parts[0].trim();
|
|
22
|
+
// Join the rest back in case the value contained colons (e.g., date times)
|
|
23
|
+
// And remove surrounding quotes if present
|
|
24
|
+
const value = parts.slice(1).join(':').trim().replace(/^['"](.*)['"]$/, '$1');
|
|
25
|
+
if (key) {
|
|
26
|
+
data[key] = value;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
return { data, content: body };
|
|
32
|
+
}
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
|
4
|
+
"target": "ES2022",
|
|
5
|
+
"useDefineForClassFields": true,
|
|
6
|
+
"lib": [
|
|
7
|
+
"ES2022",
|
|
8
|
+
"DOM",
|
|
9
|
+
"DOM.Iterable"
|
|
10
|
+
],
|
|
11
|
+
"module": "ESNext",
|
|
12
|
+
"types": [
|
|
13
|
+
"vite/client"
|
|
14
|
+
],
|
|
15
|
+
"skipLibCheck": true,
|
|
16
|
+
/* Bundler mode */
|
|
17
|
+
"moduleResolution": "bundler",
|
|
18
|
+
"allowImportingTsExtensions": true,
|
|
19
|
+
"verbatimModuleSyntax": true,
|
|
20
|
+
"moduleDetection": "force",
|
|
21
|
+
"noEmit": true,
|
|
22
|
+
"jsx": "react-jsx",
|
|
23
|
+
/* Linting */
|
|
24
|
+
"strict": true,
|
|
25
|
+
"noUnusedLocals": true,
|
|
26
|
+
"noUnusedParameters": true,
|
|
27
|
+
"erasableSyntaxOnly": true,
|
|
28
|
+
"noFallthroughCasesInSwitch": true,
|
|
29
|
+
"noUncheckedSideEffectImports": true,
|
|
30
|
+
"esModuleInterop": true,
|
|
31
|
+
"allowSyntheticDefaultImports": true
|
|
32
|
+
},
|
|
33
|
+
"include": [
|
|
34
|
+
"src"
|
|
35
|
+
]
|
|
36
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
|
4
|
+
"target": "ES2023",
|
|
5
|
+
"lib": ["ES2023"],
|
|
6
|
+
"module": "ESNext",
|
|
7
|
+
"types": ["node"],
|
|
8
|
+
"skipLibCheck": true,
|
|
9
|
+
|
|
10
|
+
/* Bundler mode */
|
|
11
|
+
"moduleResolution": "bundler",
|
|
12
|
+
"allowImportingTsExtensions": true,
|
|
13
|
+
"verbatimModuleSyntax": true,
|
|
14
|
+
"moduleDetection": "force",
|
|
15
|
+
"noEmit": true,
|
|
16
|
+
|
|
17
|
+
/* Linting */
|
|
18
|
+
"strict": true,
|
|
19
|
+
"noUnusedLocals": true,
|
|
20
|
+
"noUnusedParameters": true,
|
|
21
|
+
"erasableSyntaxOnly": true,
|
|
22
|
+
"noFallthroughCasesInSwitch": true,
|
|
23
|
+
"noUncheckedSideEffectImports": true
|
|
24
|
+
},
|
|
25
|
+
"include": ["vite.config.ts"]
|
|
26
|
+
}
|
package/vite.config.ts
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { defineConfig, type ViteDevServer } from 'vite'
|
|
2
|
+
import react from '@vitejs/plugin-react'
|
|
3
|
+
import { exec } from 'child_process'
|
|
4
|
+
import fs from 'fs'
|
|
5
|
+
import path from 'path'
|
|
6
|
+
|
|
7
|
+
// Custom plugin to watch markdown files and regenerate data
|
|
8
|
+
const watchMarkdownPlugin = () => ({
|
|
9
|
+
name: 'watch-markdown',
|
|
10
|
+
configureServer(server: ViteDevServer) {
|
|
11
|
+
const handleFileChange = (file: string) => {
|
|
12
|
+
// Use absolute paths or relative checks to ensure we only trigger on relevant files
|
|
13
|
+
const normalizedFile = file.replace(/\\/g, '/')
|
|
14
|
+
if (normalizedFile.endsWith('.md')) {
|
|
15
|
+
if (normalizedFile.includes('/src/posts/')) {
|
|
16
|
+
console.log('Post changed, regenerating posts data...')
|
|
17
|
+
exec('node scripts/generate-posts-data.js', (err) => {
|
|
18
|
+
if (err) console.error('Error regenerating posts:', err)
|
|
19
|
+
})
|
|
20
|
+
} else if (normalizedFile.includes('/src/projects/')) {
|
|
21
|
+
console.log('Project changed, regenerating projects data...')
|
|
22
|
+
exec('node scripts/generate-projects-data.js', (err) => {
|
|
23
|
+
if (err) console.error('Error regenerating projects:', err)
|
|
24
|
+
})
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
server.watcher.on('add', handleFileChange)
|
|
30
|
+
server.watcher.on('change', handleFileChange)
|
|
31
|
+
server.watcher.on('unlink', handleFileChange)
|
|
32
|
+
}
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
const siteConfigPath = path.resolve(__dirname, 'src/site.config.ts');
|
|
36
|
+
const siteConfigContent = fs.readFileSync(siteConfigPath, 'utf-8');
|
|
37
|
+
|
|
38
|
+
// Derive base path from siteConfig.url
|
|
39
|
+
const urlMatch = siteConfigContent.match(/url:\s*["']([^"']+)["']/);
|
|
40
|
+
let basePath = '/';
|
|
41
|
+
if (urlMatch) {
|
|
42
|
+
try {
|
|
43
|
+
const url = new URL(urlMatch[1]);
|
|
44
|
+
basePath = url.pathname.endsWith('/') ? url.pathname : `${url.pathname}/`;
|
|
45
|
+
} catch (e) {
|
|
46
|
+
console.warn('Invalid URL in site.config.ts, defaulting to /');
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// https://vite.dev/config/
|
|
51
|
+
export default defineConfig({
|
|
52
|
+
base: basePath,
|
|
53
|
+
publicDir: 'static',
|
|
54
|
+
plugins: [react(), watchMarkdownPlugin()],
|
|
55
|
+
build: {
|
|
56
|
+
outDir: 'docs',
|
|
57
|
+
},
|
|
58
|
+
ssr: {
|
|
59
|
+
noExternal: ['react-syntax-highlighter']
|
|
60
|
+
}
|
|
61
|
+
})
|