mdact 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/.gitkeep +0 -0
- package/README.md +47 -0
- package/content/building-the-parser.md +19 -0
- package/content/hello-world.md +21 -0
- package/content/mdact-docs.md +44 -0
- package/index.html +12 -0
- package/package.json +23 -0
- package/src/components/BlogLayout.tsx +58 -0
- package/src/components/BlogList.tsx +32 -0
- package/src/components/BlogPost.tsx +36 -0
- package/src/lib/content.ts +61 -0
- package/src/lib/mdParser.tsx +163 -0
- package/src/main.tsx +13 -0
- package/src/pages/App.tsx +17 -0
- package/src/styles/global.css +258 -0
- package/tsconfig.json +16 -0
- package/vite.config.ts +6 -0
package/.gitkeep
ADDED
|
File without changes
|
package/README.md
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# mdact
|
|
2
|
+
|
|
3
|
+
mdact is a markdown-to-React library plus a reference blog you can host on GitHub Pages or Vercel. Drop `.md` files into `content/` and the app renders them with the custom mdact parser.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install mdact
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Local development
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
npm install
|
|
15
|
+
npm run dev
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## Setup guide
|
|
19
|
+
|
|
20
|
+
1. Create new markdown files in `content/` with frontmatter.
|
|
21
|
+
2. Use `import.meta.glob` (see `src/lib/content.ts`) to load posts.
|
|
22
|
+
3. Render `post.body` with the mdact renderer.
|
|
23
|
+
|
|
24
|
+
```md
|
|
25
|
+
---
|
|
26
|
+
title: My New Post
|
|
27
|
+
summary: A short teaser.
|
|
28
|
+
date: 2024-05-25
|
|
29
|
+
---
|
|
30
|
+
|
|
31
|
+
# My New Post
|
|
32
|
+
|
|
33
|
+
Write markdown here.
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## Current features
|
|
37
|
+
|
|
38
|
+
- Headings (H1–H3)
|
|
39
|
+
- Paragraphs and blockquotes
|
|
40
|
+
- Lists
|
|
41
|
+
- Fenced code blocks with language labels
|
|
42
|
+
- Inline formatting for **bold**, *italic*, and `inline code`
|
|
43
|
+
|
|
44
|
+
## Deploying
|
|
45
|
+
|
|
46
|
+
- **GitHub Pages**: Build with `npm run build`, then deploy the `dist/` folder.
|
|
47
|
+
- **Vercel**: Import the repo, set the build command to `npm run build`, and output to `dist`.
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Building the mdact Parser
|
|
3
|
+
summary: Notes on the handcrafted renderer that powers mdact.
|
|
4
|
+
date: 2024-05-22
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# Building the mdact parser
|
|
8
|
+
|
|
9
|
+
This post explains the architecture of the markdown parser inside mdact.
|
|
10
|
+
|
|
11
|
+
## Inline formatting
|
|
12
|
+
|
|
13
|
+
You can write **bold**, *italic*, or `inline code` without relying on external libraries.
|
|
14
|
+
|
|
15
|
+
## Lists
|
|
16
|
+
|
|
17
|
+
- One idea
|
|
18
|
+
- Another idea
|
|
19
|
+
- A third idea
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Hello mdact
|
|
3
|
+
summary: A first entry showing the mdact markdown pipeline.
|
|
4
|
+
date: 2024-05-20
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# Hello mdact
|
|
8
|
+
|
|
9
|
+
Welcome to the mdact blog. This file lives in the `content/` folder and is parsed by the mdact markdown-to-React library.
|
|
10
|
+
|
|
11
|
+
## Why this approach
|
|
12
|
+
|
|
13
|
+
- Markdown stays in Git.
|
|
14
|
+
- The renderer is local and hackable.
|
|
15
|
+
- Vercel/GitHub Pages can host the static output.
|
|
16
|
+
|
|
17
|
+
```ts
|
|
18
|
+
export const greeting = "Hello from mdact";
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
> Keep writing. The parser will turn this into components.
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: mdact Docs — Install, Setup, Features
|
|
3
|
+
summary: Everything you need to ship a markdown-powered blog with mdact.
|
|
4
|
+
date: 2024-05-24
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# mdact docs
|
|
8
|
+
|
|
9
|
+
mdact is a lightweight markdown-to-React toolkit plus a reference blog you can host on GitHub Pages or Vercel.
|
|
10
|
+
|
|
11
|
+
## Install
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
npm install mdact
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Setup guide
|
|
18
|
+
|
|
19
|
+
1. Create a `content/` folder in your repo.
|
|
20
|
+
2. Add markdown files with frontmatter for title, summary, and date.
|
|
21
|
+
3. Load the posts with `import.meta.glob` (see `src/lib/content.ts`).
|
|
22
|
+
4. Render the body with the mdact renderer.
|
|
23
|
+
|
|
24
|
+
```ts
|
|
25
|
+
import { renderMarkdown } from "mdact";
|
|
26
|
+
|
|
27
|
+
export const PostBody = ({ markdown }: { markdown: string }) => {
|
|
28
|
+
return <article>{renderMarkdown(markdown)}</article>;
|
|
29
|
+
};
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## Current features
|
|
33
|
+
|
|
34
|
+
- Headings (H1–H3)
|
|
35
|
+
- Paragraphs and blockquotes
|
|
36
|
+
- Lists
|
|
37
|
+
- Fenced code blocks with language labels
|
|
38
|
+
- Inline formatting for **bold**, *italic*, and `inline code`
|
|
39
|
+
|
|
40
|
+
## What's next
|
|
41
|
+
|
|
42
|
+
- Custom component slots
|
|
43
|
+
- Tables and images
|
|
44
|
+
- Theme tokens for fast rebranding
|
package/index.html
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
|
+
<title>mdact</title>
|
|
7
|
+
</head>
|
|
8
|
+
<body>
|
|
9
|
+
<div id="root"></div>
|
|
10
|
+
<script type="module" src="/src/main.tsx"></script>
|
|
11
|
+
</body>
|
|
12
|
+
</html>
|
package/package.json
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "mdact",
|
|
3
|
+
"private": false,
|
|
4
|
+
"version": "0.1.0",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"dev": "vite",
|
|
8
|
+
"build": "vite build",
|
|
9
|
+
"preview": "vite preview"
|
|
10
|
+
},
|
|
11
|
+
"dependencies": {
|
|
12
|
+
"react": "^18.2.0",
|
|
13
|
+
"react-dom": "^18.2.0",
|
|
14
|
+
"react-router-dom": "^6.22.3"
|
|
15
|
+
},
|
|
16
|
+
"devDependencies": {
|
|
17
|
+
"@types/react": "^18.2.66",
|
|
18
|
+
"@types/react-dom": "^18.2.22",
|
|
19
|
+
"@vitejs/plugin-react": "^4.2.1",
|
|
20
|
+
"typescript": "^5.4.2",
|
|
21
|
+
"vite": "^5.2.0"
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { ReactNode, useEffect, useState } from "react";
|
|
2
|
+
import { Link } from "react-router-dom";
|
|
3
|
+
|
|
4
|
+
const BlogLayout = ({ children }: { children: ReactNode }) => {
|
|
5
|
+
const [isDark, setIsDark] = useState(false);
|
|
6
|
+
|
|
7
|
+
useEffect(() => {
|
|
8
|
+
document.body.classList.toggle("theme-dark", isDark);
|
|
9
|
+
}, [isDark]);
|
|
10
|
+
|
|
11
|
+
return (
|
|
12
|
+
<div className="app-shell">
|
|
13
|
+
<header className="site-header">
|
|
14
|
+
<div>
|
|
15
|
+
<Link to="/" className="logo">
|
|
16
|
+
mdact
|
|
17
|
+
</Link>
|
|
18
|
+
<p className="tagline">
|
|
19
|
+
A markdown-to-React toolkit and blog starter you can publish anywhere.
|
|
20
|
+
</p>
|
|
21
|
+
</div>
|
|
22
|
+
<div className="header-actions">
|
|
23
|
+
<button
|
|
24
|
+
type="button"
|
|
25
|
+
className="ghost-link"
|
|
26
|
+
onClick={() => setIsDark((prev) => !prev)}
|
|
27
|
+
>
|
|
28
|
+
{isDark ? "Light mode" : "Dark mode"}
|
|
29
|
+
</button>
|
|
30
|
+
<a
|
|
31
|
+
href="https://www.npmjs.com/package/mdact"
|
|
32
|
+
target="_blank"
|
|
33
|
+
rel="noreferrer"
|
|
34
|
+
className="header-link"
|
|
35
|
+
>
|
|
36
|
+
Install mdact
|
|
37
|
+
</a>
|
|
38
|
+
<a
|
|
39
|
+
href="https://github.com/"
|
|
40
|
+
target="_blank"
|
|
41
|
+
rel="noreferrer"
|
|
42
|
+
className="ghost-link secondary-link"
|
|
43
|
+
>
|
|
44
|
+
View repo
|
|
45
|
+
</a>
|
|
46
|
+
</div>
|
|
47
|
+
</header>
|
|
48
|
+
<main className="main-content">{children}</main>
|
|
49
|
+
<footer className="site-footer">
|
|
50
|
+
<p>
|
|
51
|
+
mdact turns markdown into React components so you can publish blogs that feel native.
|
|
52
|
+
</p>
|
|
53
|
+
</footer>
|
|
54
|
+
</div>
|
|
55
|
+
);
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
export default BlogLayout;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { Link } from "react-router-dom";
|
|
2
|
+
import { getPosts } from "../lib/content";
|
|
3
|
+
|
|
4
|
+
const BlogList = () => {
|
|
5
|
+
const posts = getPosts().sort((a, b) => b.date.localeCompare(a.date));
|
|
6
|
+
|
|
7
|
+
return (
|
|
8
|
+
<section className="blog-list">
|
|
9
|
+
<h1>mdact showcase</h1>
|
|
10
|
+
<p className="intro">
|
|
11
|
+
This site is a living demo of the <strong>mdact</strong> library: drop markdown files
|
|
12
|
+
into <code>content/</code> and they render as styled React pages.
|
|
13
|
+
</p>
|
|
14
|
+
<div className="post-grid">
|
|
15
|
+
{posts.map((post) => (
|
|
16
|
+
<article key={post.slug} className="post-card">
|
|
17
|
+
<div>
|
|
18
|
+
<p className="post-date">{post.date}</p>
|
|
19
|
+
<h2>{post.title}</h2>
|
|
20
|
+
<p>{post.summary}</p>
|
|
21
|
+
</div>
|
|
22
|
+
<Link to={`/post/${post.slug}`} className="read-link">
|
|
23
|
+
Read more →
|
|
24
|
+
</Link>
|
|
25
|
+
</article>
|
|
26
|
+
))}
|
|
27
|
+
</div>
|
|
28
|
+
</section>
|
|
29
|
+
);
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export default BlogList;
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { Link, useParams } from "react-router-dom";
|
|
2
|
+
import { getPostBySlug } from "../lib/content";
|
|
3
|
+
import { renderMarkdown } from "../lib/mdParser";
|
|
4
|
+
|
|
5
|
+
const BlogPost = () => {
|
|
6
|
+
const { slug } = useParams();
|
|
7
|
+
const post = slug ? getPostBySlug(slug) : undefined;
|
|
8
|
+
|
|
9
|
+
if (!post) {
|
|
10
|
+
return (
|
|
11
|
+
<section className="blog-post">
|
|
12
|
+
<h1>Post not found</h1>
|
|
13
|
+
<p>We couldn't locate that markdown file.</p>
|
|
14
|
+
<Link to="/" className="read-link">
|
|
15
|
+
← Back to posts
|
|
16
|
+
</Link>
|
|
17
|
+
</section>
|
|
18
|
+
);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
return (
|
|
22
|
+
<section className="blog-post">
|
|
23
|
+
<Link to="/" className="read-link">
|
|
24
|
+
← Back to posts
|
|
25
|
+
</Link>
|
|
26
|
+
<header className="post-header">
|
|
27
|
+
<p className="post-date">{post.date}</p>
|
|
28
|
+
<h1>{post.title}</h1>
|
|
29
|
+
<p className="post-summary">{post.summary}</p>
|
|
30
|
+
</header>
|
|
31
|
+
{renderMarkdown(post.body)}
|
|
32
|
+
</section>
|
|
33
|
+
);
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export default BlogPost;
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
export type BlogPost = {
|
|
2
|
+
slug: string;
|
|
3
|
+
title: string;
|
|
4
|
+
summary: string;
|
|
5
|
+
date: string;
|
|
6
|
+
body: string;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
type FrontMatter = {
|
|
10
|
+
title?: string;
|
|
11
|
+
summary?: string;
|
|
12
|
+
date?: string;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
const markdownFiles = import.meta.glob("../../content/*.md", {
|
|
16
|
+
as: "raw",
|
|
17
|
+
eager: true,
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
const parseFrontMatter = (raw: string): { frontMatter: FrontMatter; body: string } => {
|
|
21
|
+
if (!raw.startsWith("---")) {
|
|
22
|
+
return { frontMatter: {}, body: raw };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const end = raw.indexOf("---", 3);
|
|
26
|
+
if (end === -1) {
|
|
27
|
+
return { frontMatter: {}, body: raw };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const frontMatterRaw = raw.slice(3, end).trim();
|
|
31
|
+
const body = raw.slice(end + 3).trim();
|
|
32
|
+
const frontMatter = frontMatterRaw
|
|
33
|
+
.split("\n")
|
|
34
|
+
.map((line) => line.split(":"))
|
|
35
|
+
.reduce<FrontMatter>((acc, [key, ...rest]) => {
|
|
36
|
+
if (!key) return acc;
|
|
37
|
+
acc[key.trim() as keyof FrontMatter] = rest.join(":").trim();
|
|
38
|
+
return acc;
|
|
39
|
+
}, {});
|
|
40
|
+
|
|
41
|
+
return { frontMatter, body };
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export const getPosts = (): BlogPost[] => {
|
|
45
|
+
return Object.entries(markdownFiles).map(([path, raw]) => {
|
|
46
|
+
const slug = path.split("/").pop()?.replace(".md", "") ?? "";
|
|
47
|
+
const { frontMatter, body } = parseFrontMatter(raw as string);
|
|
48
|
+
|
|
49
|
+
return {
|
|
50
|
+
slug,
|
|
51
|
+
title: frontMatter.title ?? slug,
|
|
52
|
+
summary: frontMatter.summary ?? "",
|
|
53
|
+
date: frontMatter.date ?? "",
|
|
54
|
+
body,
|
|
55
|
+
};
|
|
56
|
+
});
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
export const getPostBySlug = (slug: string): BlogPost | undefined => {
|
|
60
|
+
return getPosts().find((post) => post.slug === slug);
|
|
61
|
+
};
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
|
|
3
|
+
type Block =
|
|
4
|
+
| { type: "heading"; level: number; text: string }
|
|
5
|
+
| { type: "paragraph"; text: string }
|
|
6
|
+
| { type: "code"; language: string; text: string }
|
|
7
|
+
| { type: "list"; items: string[] }
|
|
8
|
+
| { type: "blockquote"; text: string };
|
|
9
|
+
|
|
10
|
+
const inlinePattern = /(\*\*[^*]+\*\*|\*[^*]+\*|`[^`]+`)/g;
|
|
11
|
+
|
|
12
|
+
const renderInline = (text: string): React.ReactNode[] => {
|
|
13
|
+
const parts: React.ReactNode[] = [];
|
|
14
|
+
let lastIndex = 0;
|
|
15
|
+
|
|
16
|
+
text.replace(inlinePattern, (match, _group, offset) => {
|
|
17
|
+
if (offset > lastIndex) {
|
|
18
|
+
parts.push(text.slice(lastIndex, offset));
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
if (match.startsWith("**")) {
|
|
22
|
+
parts.push(<strong key={`${offset}-bold`}>{match.slice(2, -2)}</strong>);
|
|
23
|
+
} else if (match.startsWith("*")) {
|
|
24
|
+
parts.push(<em key={`${offset}-italic`}>{match.slice(1, -1)}</em>);
|
|
25
|
+
} else if (match.startsWith("`")) {
|
|
26
|
+
parts.push(<code key={`${offset}-code`} className="inline-code">{match.slice(1, -1)}</code>);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
lastIndex = offset + match.length;
|
|
30
|
+
return match;
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
if (lastIndex < text.length) {
|
|
34
|
+
parts.push(text.slice(lastIndex));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return parts;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export const parseMarkdown = (markdown: string): Block[] => {
|
|
41
|
+
const lines = markdown.split("\n");
|
|
42
|
+
const blocks: Block[] = [];
|
|
43
|
+
let currentParagraph: string[] = [];
|
|
44
|
+
let currentList: string[] = [];
|
|
45
|
+
let inCodeBlock = false;
|
|
46
|
+
let codeLanguage = "";
|
|
47
|
+
let codeLines: string[] = [];
|
|
48
|
+
|
|
49
|
+
const flushParagraph = () => {
|
|
50
|
+
if (currentParagraph.length > 0) {
|
|
51
|
+
blocks.push({ type: "paragraph", text: currentParagraph.join(" ") });
|
|
52
|
+
currentParagraph = [];
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const flushList = () => {
|
|
57
|
+
if (currentList.length > 0) {
|
|
58
|
+
blocks.push({ type: "list", items: currentList });
|
|
59
|
+
currentList = [];
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const flushCode = () => {
|
|
64
|
+
if (codeLines.length > 0) {
|
|
65
|
+
blocks.push({ type: "code", language: codeLanguage, text: codeLines.join("\n") });
|
|
66
|
+
codeLines = [];
|
|
67
|
+
codeLanguage = "";
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
lines.forEach((line) => {
|
|
72
|
+
if (line.startsWith("```")) {
|
|
73
|
+
if (!inCodeBlock) {
|
|
74
|
+
flushParagraph();
|
|
75
|
+
flushList();
|
|
76
|
+
inCodeBlock = true;
|
|
77
|
+
codeLanguage = line.replace("```", "").trim();
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
inCodeBlock = false;
|
|
82
|
+
flushCode();
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (inCodeBlock) {
|
|
87
|
+
codeLines.push(line);
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (line.trim() === "") {
|
|
92
|
+
flushParagraph();
|
|
93
|
+
flushList();
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (line.startsWith("#")) {
|
|
98
|
+
flushParagraph();
|
|
99
|
+
flushList();
|
|
100
|
+
const level = Math.min(line.match(/^#+/)?.[0].length ?? 1, 3);
|
|
101
|
+
blocks.push({ type: "heading", level, text: line.replace(/^#+\s*/, "") });
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (line.startsWith(">")) {
|
|
106
|
+
flushParagraph();
|
|
107
|
+
flushList();
|
|
108
|
+
blocks.push({ type: "blockquote", text: line.replace(/^>\s*/, "") });
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (line.match(/^[-*]\s+/)) {
|
|
113
|
+
flushParagraph();
|
|
114
|
+
currentList.push(line.replace(/^[-*]\s+/, ""));
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
currentParagraph.push(line.trim());
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
flushParagraph();
|
|
122
|
+
flushList();
|
|
123
|
+
flushCode();
|
|
124
|
+
|
|
125
|
+
return blocks;
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
export const renderMarkdown = (markdown: string): React.ReactElement => {
|
|
129
|
+
const blocks = parseMarkdown(markdown);
|
|
130
|
+
|
|
131
|
+
return (
|
|
132
|
+
<div className="markdown-content">
|
|
133
|
+
{blocks.map((block, index) => {
|
|
134
|
+
switch (block.type) {
|
|
135
|
+
case "heading":
|
|
136
|
+
if (block.level === 1) return <h1 key={index}>{renderInline(block.text)}</h1>;
|
|
137
|
+
if (block.level === 2) return <h2 key={index}>{renderInline(block.text)}</h2>;
|
|
138
|
+
return <h3 key={index}>{renderInline(block.text)}</h3>;
|
|
139
|
+
case "paragraph":
|
|
140
|
+
return <p key={index}>{renderInline(block.text)}</p>;
|
|
141
|
+
case "code":
|
|
142
|
+
return (
|
|
143
|
+
<pre key={index}>
|
|
144
|
+
<code data-language={block.language}>{block.text}</code>
|
|
145
|
+
</pre>
|
|
146
|
+
);
|
|
147
|
+
case "list":
|
|
148
|
+
return (
|
|
149
|
+
<ul key={index}>
|
|
150
|
+
{block.items.map((item, itemIndex) => (
|
|
151
|
+
<li key={`${index}-${itemIndex}`}>{renderInline(item)}</li>
|
|
152
|
+
))}
|
|
153
|
+
</ul>
|
|
154
|
+
);
|
|
155
|
+
case "blockquote":
|
|
156
|
+
return <blockquote key={index}>{renderInline(block.text)}</blockquote>;
|
|
157
|
+
default:
|
|
158
|
+
return null;
|
|
159
|
+
}
|
|
160
|
+
})}
|
|
161
|
+
</div>
|
|
162
|
+
);
|
|
163
|
+
};
|
package/src/main.tsx
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import ReactDOM from "react-dom/client";
|
|
3
|
+
import { BrowserRouter } from "react-router-dom";
|
|
4
|
+
import App from "./pages/App";
|
|
5
|
+
import "./styles/global.css";
|
|
6
|
+
|
|
7
|
+
ReactDOM.createRoot(document.getElementById("root")!).render(
|
|
8
|
+
<React.StrictMode>
|
|
9
|
+
<BrowserRouter>
|
|
10
|
+
<App />
|
|
11
|
+
</BrowserRouter>
|
|
12
|
+
</React.StrictMode>
|
|
13
|
+
);
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { Route, Routes } from "react-router-dom";
|
|
2
|
+
import BlogLayout from "../components/BlogLayout";
|
|
3
|
+
import BlogList from "../components/BlogList";
|
|
4
|
+
import BlogPost from "../components/BlogPost";
|
|
5
|
+
|
|
6
|
+
const App = () => {
|
|
7
|
+
return (
|
|
8
|
+
<BlogLayout>
|
|
9
|
+
<Routes>
|
|
10
|
+
<Route path="/" element={<BlogList />} />
|
|
11
|
+
<Route path="/post/:slug" element={<BlogPost />} />
|
|
12
|
+
</Routes>
|
|
13
|
+
</BlogLayout>
|
|
14
|
+
);
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export default App;
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
:root {
|
|
2
|
+
font-family: "Inter", "Segoe UI", system-ui, sans-serif;
|
|
3
|
+
color: #1f2937;
|
|
4
|
+
background: #f9fafb;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
* {
|
|
8
|
+
box-sizing: border-box;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
body {
|
|
12
|
+
margin: 0;
|
|
13
|
+
background: #f9fafb;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
body.theme-dark {
|
|
17
|
+
background: #0f172a;
|
|
18
|
+
color: #e2e8f0;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
a {
|
|
22
|
+
color: inherit;
|
|
23
|
+
text-decoration: none;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
code {
|
|
27
|
+
font-family: "JetBrains Mono", "SFMono-Regular", monospace;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
.app-shell {
|
|
31
|
+
min-height: 100vh;
|
|
32
|
+
display: flex;
|
|
33
|
+
flex-direction: column;
|
|
34
|
+
gap: 2rem;
|
|
35
|
+
padding: 2rem 6vw 3rem;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
.site-header {
|
|
39
|
+
display: flex;
|
|
40
|
+
justify-content: space-between;
|
|
41
|
+
align-items: center;
|
|
42
|
+
border-bottom: 1px solid #e5e7eb;
|
|
43
|
+
padding-bottom: 1.5rem;
|
|
44
|
+
gap: 1.5rem;
|
|
45
|
+
flex-wrap: wrap;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
body.theme-dark .site-header {
|
|
49
|
+
border-bottom-color: rgba(148, 163, 184, 0.25);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
.logo {
|
|
53
|
+
font-size: 1.5rem;
|
|
54
|
+
font-weight: 700;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
.tagline {
|
|
58
|
+
margin: 0.4rem 0 0;
|
|
59
|
+
color: #6b7280;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
body.theme-dark .tagline {
|
|
63
|
+
color: #94a3b8;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
.header-actions {
|
|
67
|
+
display: flex;
|
|
68
|
+
align-items: center;
|
|
69
|
+
gap: 0.75rem;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
.header-link {
|
|
73
|
+
padding: 0.6rem 1.1rem;
|
|
74
|
+
border-radius: 999px;
|
|
75
|
+
background: #111827;
|
|
76
|
+
color: #f9fafb;
|
|
77
|
+
font-weight: 600;
|
|
78
|
+
font-size: 0.9rem;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
.ghost-link {
|
|
82
|
+
padding: 0.6rem 1rem;
|
|
83
|
+
border-radius: 999px;
|
|
84
|
+
border: 1px solid #e5e7eb;
|
|
85
|
+
font-weight: 600;
|
|
86
|
+
font-size: 0.9rem;
|
|
87
|
+
background: transparent;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
.secondary-link {
|
|
91
|
+
color: #111827;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
body.theme-dark .ghost-link {
|
|
95
|
+
border-color: rgba(148, 163, 184, 0.4);
|
|
96
|
+
color: #e2e8f0;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
body.theme-dark .secondary-link {
|
|
100
|
+
color: #e2e8f0;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
.main-content {
|
|
104
|
+
flex: 1;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
.blog-list h1 {
|
|
108
|
+
font-size: 2.4rem;
|
|
109
|
+
margin-bottom: 0.4rem;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
.intro {
|
|
113
|
+
color: #6b7280;
|
|
114
|
+
max-width: 640px;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
body.theme-dark .intro {
|
|
118
|
+
color: #94a3b8;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
.post-grid {
|
|
122
|
+
display: grid;
|
|
123
|
+
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
|
124
|
+
gap: 1.5rem;
|
|
125
|
+
margin-top: 2rem;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
.post-card {
|
|
129
|
+
background: white;
|
|
130
|
+
border-radius: 1.2rem;
|
|
131
|
+
padding: 1.5rem;
|
|
132
|
+
box-shadow: 0 10px 30px rgba(15, 23, 42, 0.08);
|
|
133
|
+
display: flex;
|
|
134
|
+
flex-direction: column;
|
|
135
|
+
justify-content: space-between;
|
|
136
|
+
gap: 1.5rem;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
body.theme-dark .post-card {
|
|
140
|
+
background: #111827;
|
|
141
|
+
box-shadow: 0 12px 28px rgba(15, 23, 42, 0.45);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
.post-card h2 {
|
|
145
|
+
margin: 0.4rem 0 0.6rem;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
.post-date {
|
|
149
|
+
font-size: 0.85rem;
|
|
150
|
+
color: #9ca3af;
|
|
151
|
+
text-transform: uppercase;
|
|
152
|
+
letter-spacing: 0.08em;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
body.theme-dark .post-date {
|
|
156
|
+
color: #94a3b8;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
.read-link {
|
|
160
|
+
font-weight: 600;
|
|
161
|
+
color: #2563eb;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
body.theme-dark .read-link {
|
|
165
|
+
color: #93c5fd;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
.blog-post {
|
|
169
|
+
max-width: 760px;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
.post-header {
|
|
173
|
+
margin: 1.5rem 0 2rem;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
.post-summary {
|
|
177
|
+
color: #6b7280;
|
|
178
|
+
font-size: 1.05rem;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
body.theme-dark .post-summary {
|
|
182
|
+
color: #94a3b8;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
.markdown-content h1 {
|
|
186
|
+
font-size: 2.3rem;
|
|
187
|
+
margin-bottom: 1rem;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
.markdown-content h2 {
|
|
191
|
+
font-size: 1.6rem;
|
|
192
|
+
margin-top: 2rem;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
.markdown-content h3 {
|
|
196
|
+
font-size: 1.3rem;
|
|
197
|
+
margin-top: 1.5rem;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
.markdown-content p {
|
|
201
|
+
line-height: 1.7;
|
|
202
|
+
color: #374151;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
body.theme-dark .markdown-content p {
|
|
206
|
+
color: #cbd5f5;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
.markdown-content ul {
|
|
210
|
+
padding-left: 1.2rem;
|
|
211
|
+
line-height: 1.7;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
.markdown-content pre {
|
|
215
|
+
background: #111827;
|
|
216
|
+
color: #f9fafb;
|
|
217
|
+
padding: 1.2rem;
|
|
218
|
+
border-radius: 0.9rem;
|
|
219
|
+
overflow-x: auto;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
body.theme-dark .markdown-content pre {
|
|
223
|
+
background: #0b1120;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
.inline-code {
|
|
227
|
+
background: #e5e7eb;
|
|
228
|
+
padding: 0.1rem 0.3rem;
|
|
229
|
+
border-radius: 0.3rem;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
body.theme-dark .inline-code {
|
|
233
|
+
background: rgba(148, 163, 184, 0.2);
|
|
234
|
+
color: #e2e8f0;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
.markdown-content blockquote {
|
|
238
|
+
border-left: 4px solid #60a5fa;
|
|
239
|
+
padding-left: 1rem;
|
|
240
|
+
color: #4b5563;
|
|
241
|
+
margin: 1.5rem 0;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
body.theme-dark .markdown-content blockquote {
|
|
245
|
+
color: #cbd5f5;
|
|
246
|
+
border-left-color: #38bdf8;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
.site-footer {
|
|
250
|
+
border-top: 1px solid #e5e7eb;
|
|
251
|
+
padding-top: 1.5rem;
|
|
252
|
+
color: #6b7280;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
body.theme-dark .site-footer {
|
|
256
|
+
border-top-color: rgba(148, 163, 184, 0.25);
|
|
257
|
+
color: #94a3b8;
|
|
258
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2020",
|
|
4
|
+
"useDefineForClassFields": true,
|
|
5
|
+
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
|
6
|
+
"module": "ESNext",
|
|
7
|
+
"skipLibCheck": true,
|
|
8
|
+
"moduleResolution": "Bundler",
|
|
9
|
+
"resolveJsonModule": true,
|
|
10
|
+
"isolatedModules": true,
|
|
11
|
+
"noEmit": true,
|
|
12
|
+
"jsx": "react-jsx",
|
|
13
|
+
"strict": true
|
|
14
|
+
},
|
|
15
|
+
"include": ["src"]
|
|
16
|
+
}
|