@guzhongren/sha 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.
Files changed (39) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +154 -0
  3. package/package.json +41 -0
  4. package/src/avatar.ts +3 -0
  5. package/src/components/Avatar.astro +23 -0
  6. package/src/components/CodeCopyEnhancer.astro +31 -0
  7. package/src/components/DiagramEnhancer.astro +96 -0
  8. package/src/components/EChartsEnhancer.astro +31 -0
  9. package/src/components/EmojiEnhancer.astro +30 -0
  10. package/src/components/Footer.astro +12 -0
  11. package/src/components/Header.astro +30 -0
  12. package/src/components/Pagination.astro +29 -0
  13. package/src/components/PostCard.astro +44 -0
  14. package/src/components/PostList.astro +15 -0
  15. package/src/components/ProfileIntro.astro +17 -0
  16. package/src/components/SearchEnhancer.astro +183 -0
  17. package/src/components/TableOfContents.astro +35 -0
  18. package/src/components/TagList.astro +19 -0
  19. package/src/components/ThemeToggle.astro +25 -0
  20. package/src/config.ts +62 -0
  21. package/src/content.ts +20 -0
  22. package/src/emoji.ts +9 -0
  23. package/src/env.d.ts +32 -0
  24. package/src/index.ts +136 -0
  25. package/src/layouts/BaseLayout.astro +59 -0
  26. package/src/pages/about.astro +25 -0
  27. package/src/pages/categories/[category].astro +30 -0
  28. package/src/pages/categories/index.astro +18 -0
  29. package/src/pages/index.astro +28 -0
  30. package/src/pages/posts/[...slug].astro +49 -0
  31. package/src/pages/posts/index.astro +24 -0
  32. package/src/pages/posts/page/[page].astro +37 -0
  33. package/src/pages/search.astro +34 -0
  34. package/src/pages/tags/[tag].astro +30 -0
  35. package/src/pages/tags/index.astro +18 -0
  36. package/src/shortcodes.ts +8 -0
  37. package/src/styles/global.css +579 -0
  38. package/src/types.ts +94 -0
  39. package/src/utils.ts +35 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 guzhongren
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,154 @@
1
+ # Astro Blog Theme
2
+
3
+ Config-driven Astro blog theme for technical writing. The theme is consumed as an Astro integration and injects default pages, layouts, and styling.
4
+
5
+ ## Install
6
+
7
+ ```sh
8
+ pnpm add @guzhongren/sha astro @astrojs/mdx tailwindcss @tailwindcss/vite
9
+ ```
10
+
11
+ ## Configure
12
+
13
+ ```js
14
+ // astro.config.mjs
15
+ import { defineConfig } from "astro/config";
16
+ import mdx from "@astrojs/mdx";
17
+ import tailwindcss from "@tailwindcss/vite";
18
+ import blogTheme from "@guzhongren/sha";
19
+
20
+ export default defineConfig({
21
+ site: "https://example.com",
22
+ integrations: [
23
+ blogTheme({
24
+ site: {
25
+ name: "谷中的博客",
26
+ description: "全栈开发者的技术写作与知识沉淀",
27
+ lang: "zh-CN",
28
+ },
29
+ author: {
30
+ name: "谷中",
31
+ headline: "全栈开发者 / 技术顾问",
32
+ bio: "写工程实践、架构思考、开源与日常观察。",
33
+ avatar: "/avatar.svg",
34
+ },
35
+ diagrams: {
36
+ mermaid: true,
37
+ plantuml: {
38
+ serverUrl: "https://www.plantuml.com/plantuml/svg",
39
+ },
40
+ },
41
+ }),
42
+ mdx(),
43
+ ],
44
+ vite: {
45
+ plugins: [tailwindcss()],
46
+ },
47
+ });
48
+ ```
49
+
50
+ Register `blogTheme(...)` before `mdx()` when using theme shortcodes such as ECharts. The theme pre-processes content before MDX parses it.
51
+
52
+ ## Content collections
53
+
54
+ Astro requires the app to export content collections:
55
+
56
+ ```ts
57
+ // src/content.config.ts
58
+ export { collections } from "@guzhongren/sha/content";
59
+ ```
60
+
61
+ Posts live in `src/content/posts`.
62
+
63
+ ```md
64
+ ---
65
+ title: "用 Astro 构建技术博客主题"
66
+ description: "一次从内容模型到主题集成方式的工程化拆解。"
67
+ publishDate: 2026-07-31
68
+ category: "Engineering"
69
+ tags: ["Astro", "Tailwind", "Theme"]
70
+ cover: "/covers/astro-theme.svg"
71
+ featured: true
72
+ draft: false
73
+ ---
74
+ ```
75
+
76
+ ## Routes
77
+
78
+ The integration injects these routes by default:
79
+
80
+ - `/`
81
+ - `/posts`
82
+ - `/posts/[...slug]`
83
+
84
+ Post files can be placed directly under `src/content/posts` or nested by date, for example `src/content/posts/2024/05/12/my-post.mdx`. Nested posts are rendered at matching URLs such as `/posts/2024/05/12/my-post`.
85
+ - `/tags`
86
+ - `/tags/[tag]`
87
+ - `/categories`
88
+ - `/categories/[category]`
89
+ - `/about`
90
+ - `/search`
91
+
92
+ Set `routes: false` to disable injected pages and use the exported components manually.
93
+
94
+ ## Diagrams
95
+
96
+ Enable Mermaid and PlantUML from `astro.config.mjs`:
97
+
98
+ ```js
99
+ blogTheme({
100
+ diagrams: {
101
+ mermaid: true,
102
+ plantuml: {
103
+ serverUrl: "https://www.plantuml.com/plantuml/svg",
104
+ },
105
+ },
106
+ });
107
+ ```
108
+
109
+ Use normal fenced code blocks in MDX:
110
+
111
+ ````md
112
+ ```mermaid
113
+ flowchart TD
114
+ A --> B
115
+ ```
116
+
117
+ ```plantuml
118
+ @startuml
119
+ A -> B
120
+ @enduml
121
+ ```
122
+ ````
123
+
124
+ Mermaid is rendered client-side from the bundled `mermaid` package. PlantUML is rendered as an image using the configured PlantUML server.
125
+
126
+ ## ECharts
127
+
128
+ Use Hugo-style shortcode blocks for ECharts options:
129
+
130
+ ````md
131
+ {{< echarts >}}
132
+ {
133
+ "title": { "text": "折线统计图", "left": "center" },
134
+ "xAxis": { "type": "category", "data": ["周一", "周二"] },
135
+ "yAxis": { "type": "value" },
136
+ "series": [{ "type": "line", "data": [120, 200] }]
137
+ }
138
+ {{< /echarts >}}
139
+ ````
140
+
141
+ The theme converts the shortcode before MDX parsing and renders the chart client-side with the bundled `echarts` package.
142
+
143
+ ## Search
144
+
145
+ The `/search` route and header search button use Pagefind full-text search. The theme builds the Pagefind index automatically after `astro build` when the search route is enabled.
146
+
147
+ Search indexes only post detail pages marked by the theme, so drafts and listing pages are excluded from results. During `astro dev`, the Pagefind bundle may not exist yet; the search page keeps a static published-post fallback.
148
+
149
+ ## V1 limits
150
+
151
+ - Single author.
152
+ - No CMS.
153
+ - No React/Vue dependency.
154
+ - No component override framework.
package/package.json ADDED
@@ -0,0 +1,41 @@
1
+ {
2
+ "name": "@guzhongren/sha",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "description": "个人 Astro 博客主题 — 开箱即用的内容驱动博客",
6
+ "license": "MIT",
7
+ "author": "guzhongren",
8
+ "keywords": ["astro", "astro-component", "astro-integration", "withastro", "blog", "theme"],
9
+ "files": ["src/", "README.md", "LICENSE"],
10
+ "exports": {
11
+ ".": "./src/index.ts",
12
+ "./content": "./src/content.ts",
13
+ "./styles.css": "./src/styles/global.css"
14
+ },
15
+ "scripts": {
16
+ "dev": "cd example && astro dev",
17
+ "sync": "cd example && astro sync",
18
+ "check": "cd example && astro check",
19
+ "build": "cd example && astro build"
20
+ },
21
+ "peerDependencies": {
22
+ "astro": "^7.0.0",
23
+ "@astrojs/mdx": "^7.0.0",
24
+ "@astrojs/markdown-remark": "^7.0.0",
25
+ "@tailwindcss/vite": "^4.0.0",
26
+ "tailwindcss": "^4.0.0"
27
+ },
28
+ "dependencies": {
29
+ "echarts": "^6.1.0",
30
+ "gemoji": "^8.1.0",
31
+ "mermaid": "^11.16.0",
32
+ "pagefind": "^1.5.2",
33
+ "plantuml-encoder": "^1.4.0",
34
+ "remark-gemoji": "^8.0.0"
35
+ },
36
+ "devDependencies": {
37
+ "@astrojs/check": "^0.9.10",
38
+ "@types/node": "^26.1.2",
39
+ "typescript": "^7.0.2"
40
+ }
41
+ }
package/src/avatar.ts ADDED
@@ -0,0 +1,3 @@
1
+ export function avatarInitial(siteName: string, authorName: string) {
2
+ return [...(siteName.trim() || authorName.trim() || "B")][0]?.toUpperCase() ?? "B";
3
+ }
@@ -0,0 +1,23 @@
1
+ ---
2
+ import { avatarInitial } from "../avatar";
3
+ import config from "virtual:blog-theme/config";
4
+
5
+ interface Props {
6
+ size?: "sm" | "lg";
7
+ class?: string;
8
+ }
9
+
10
+ const { size = "sm", class: className } = Astro.props;
11
+ const sizeClass = size === "lg" ? "size-24 text-4xl" : "size-7 text-xs";
12
+ const initial = avatarInitial(config.site.name, config.author.name);
13
+ ---
14
+
15
+ {
16
+ config.author.avatar ? (
17
+ <img class:list={["avatar-mark shrink-0 rounded-full object-cover", sizeClass, className]} src={config.author.avatar} alt={config.author.name} />
18
+ ) : (
19
+ <span class:list={["avatar-mark grid shrink-0 place-items-center rounded-full font-mono font-semibold text-[var(--text-muted)]", sizeClass, className]} aria-label={config.author.name}>
20
+ {initial}
21
+ </span>
22
+ )
23
+ }
@@ -0,0 +1,31 @@
1
+ ---
2
+ ---
3
+
4
+ <script>
5
+ const blocks = [...document.querySelectorAll(".prose pre > code")];
6
+
7
+ blocks.forEach((code) => {
8
+ const pre = code.parentElement;
9
+ if (!pre || pre.parentElement?.classList.contains("code-copy-frame")) return;
10
+
11
+ const frame = document.createElement("div");
12
+ const button = document.createElement("button");
13
+
14
+ frame.className = "code-copy-frame";
15
+ button.type = "button";
16
+ button.className = "code-copy-button";
17
+ button.textContent = "Copy";
18
+ button.setAttribute("aria-label", "Copy code");
19
+
20
+ pre.replaceWith(frame);
21
+ frame.append(button, pre);
22
+
23
+ button.addEventListener("click", async () => {
24
+ await navigator.clipboard.writeText(code.textContent || "");
25
+ button.textContent = "Copied";
26
+ window.setTimeout(() => {
27
+ button.textContent = "Copy";
28
+ }, 1200);
29
+ });
30
+ });
31
+ </script>
@@ -0,0 +1,96 @@
1
+ ---
2
+ import type { NormalizedBlogThemeOptions } from "../types";
3
+
4
+ interface Props {
5
+ diagrams: NormalizedBlogThemeOptions["diagrams"];
6
+ }
7
+
8
+ const { diagrams } = Astro.props;
9
+ ---
10
+
11
+ <script id="blog-theme-diagrams" type="application/json" set:html={JSON.stringify(diagrams)}></script>
12
+
13
+ <script>
14
+ function readDiagramConfig() {
15
+ const element = document.getElementById("blog-theme-diagrams");
16
+ return element?.textContent ? JSON.parse(element.textContent) : null;
17
+ }
18
+
19
+ function diagramBlocks(language) {
20
+ return [
21
+ ...document.querySelectorAll(`pre[data-language="${language}"] > code`),
22
+ ...document.querySelectorAll(`pre > code.language-${language}`),
23
+ ];
24
+ }
25
+
26
+ function plantUmlBlocks() {
27
+ return [
28
+ ...diagramBlocks("plantuml"),
29
+ ...[...document.querySelectorAll('pre[data-language="plaintext"] > code')].filter((code) =>
30
+ (code.textContent || "").trimStart().startsWith("@startuml"),
31
+ ),
32
+ ];
33
+ }
34
+
35
+ async function renderPlantUml(diagrams) {
36
+ if (!diagrams.plantuml.enabled) return;
37
+
38
+ const blocks = plantUmlBlocks();
39
+ if (blocks.length === 0) return;
40
+
41
+ const { encode } = await import("plantuml-encoder");
42
+
43
+ blocks.forEach((code) => {
44
+ const pre = code.parentElement;
45
+ const source = code.textContent || "";
46
+ const figure = document.createElement("figure");
47
+ const img = document.createElement("img");
48
+ const serverUrl = diagrams.plantuml.serverUrl.replace(/\/$/, "");
49
+
50
+ figure.className = "diagram diagram-plantuml";
51
+ img.alt = "PlantUML diagram";
52
+ img.loading = "lazy";
53
+ img.src = `${serverUrl}/${encode(source)}`;
54
+ figure.append(img);
55
+ pre.replaceWith(figure);
56
+ });
57
+ }
58
+
59
+ async function renderMermaid(diagrams) {
60
+ if (!diagrams.mermaid) return;
61
+
62
+ const blocks = diagramBlocks("mermaid");
63
+ if (blocks.length === 0) return;
64
+
65
+ const { default: mermaid } = await import("mermaid/dist/mermaid.esm.min.mjs");
66
+ mermaid.initialize({
67
+ startOnLoad: false,
68
+ securityLevel: "strict",
69
+ theme: document.documentElement.classList.contains("dark") ? "dark" : "default",
70
+ });
71
+
72
+ await Promise.all(
73
+ blocks.map(async (code, index) => {
74
+ const pre = code.parentElement;
75
+ const source = code.textContent || "";
76
+ const figure = document.createElement("figure");
77
+ figure.className = "diagram diagram-mermaid";
78
+
79
+ try {
80
+ const { svg } = await mermaid.render(`mermaid-${index}`, source);
81
+ figure.innerHTML = svg;
82
+ pre.replaceWith(figure);
83
+ } catch (error) {
84
+ pre.dataset.diagramError = "mermaid";
85
+ console.error(error);
86
+ }
87
+ }),
88
+ );
89
+ }
90
+
91
+ const diagrams = readDiagramConfig();
92
+ if (diagrams) {
93
+ renderPlantUml(diagrams);
94
+ renderMermaid(diagrams);
95
+ }
96
+ </script>
@@ -0,0 +1,31 @@
1
+ <script>
2
+ const chartBlocks = [...document.querySelectorAll("[data-echarts-options]")];
3
+
4
+ if (chartBlocks.length > 0) {
5
+ const echarts = await import("echarts");
6
+
7
+ chartBlocks.forEach((chart, index) => {
8
+ const figure = chart.closest(".echarts-frame");
9
+ const caption = document.createElement("figcaption");
10
+
11
+ chart.setAttribute("role", "img");
12
+ chart.setAttribute("aria-label", `ECharts chart ${index + 1}`);
13
+ caption.className = "utility-note echarts-error";
14
+ caption.hidden = true;
15
+ figure?.append(caption);
16
+
17
+ try {
18
+ const options = JSON.parse(decodeURIComponent(chart.getAttribute("data-echarts-options") || "%7B%7D"));
19
+ const instance = echarts.init(chart, document.documentElement.classList.contains("dark") ? "dark" : undefined);
20
+ instance.setOption(options);
21
+
22
+ const resize = () => instance.resize();
23
+ window.addEventListener("resize", resize);
24
+ } catch (error) {
25
+ caption.hidden = false;
26
+ caption.textContent = "Invalid ECharts options.";
27
+ console.error(error);
28
+ }
29
+ });
30
+ }
31
+ </script>
@@ -0,0 +1,30 @@
1
+ ---
2
+ import { emojiShortcodes } from "../emoji";
3
+ ---
4
+
5
+ <script is:inline define:vars={{ emojiShortcodes }}>
6
+ const shortcodePattern = /:([+-]1|[a-z0-9_+-]+):/gi;
7
+ const skipSelector = "code, pre, kbd, samp, script, style, textarea";
8
+ const roots = document.querySelectorAll("[data-emoji-shortcodes]");
9
+
10
+ function renderShortcodes(value) {
11
+ return value.replace(shortcodePattern, (match, shortcode) => emojiShortcodes[shortcode.toLowerCase()] || match);
12
+ }
13
+
14
+ function enhance(root) {
15
+ const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, {
16
+ acceptNode(node) {
17
+ if (!node.nodeValue || !shortcodePattern.test(node.nodeValue)) return NodeFilter.FILTER_REJECT;
18
+ shortcodePattern.lastIndex = 0;
19
+ if (node.parentElement?.closest(skipSelector)) return NodeFilter.FILTER_REJECT;
20
+ return NodeFilter.FILTER_ACCEPT;
21
+ },
22
+ });
23
+
24
+ const nodes = [];
25
+ while (walker.nextNode()) nodes.push(walker.currentNode);
26
+ for (const node of nodes) node.nodeValue = renderShortcodes(node.nodeValue || "");
27
+ }
28
+
29
+ for (const root of roots) enhance(root);
30
+ </script>
@@ -0,0 +1,12 @@
1
+ ---
2
+ import config from "virtual:blog-theme/config";
3
+ ---
4
+
5
+ <footer class="border-t border-[var(--border-soft)] bg-[var(--page-bg)]">
6
+ <div class="mx-auto flex max-w-7xl flex-col gap-4 px-4 py-10 text-sm text-[var(--text-muted)] sm:flex-row sm:items-center sm:justify-between sm:px-6">
7
+ <p>&copy; {new Date().getFullYear()} {config.author.name}</p>
8
+ <div class="flex flex-wrap gap-4">
9
+ {config.nav.map((item) => <a class="hover:text-[var(--text-strong)]" href={item.href}>{item.label}</a>)}
10
+ </div>
11
+ </div>
12
+ </footer>
@@ -0,0 +1,30 @@
1
+ ---
2
+ import config from "virtual:blog-theme/config";
3
+ import Avatar from "./Avatar.astro";
4
+ import ThemeToggle from "./ThemeToggle.astro";
5
+ ---
6
+
7
+ <header class="fixed inset-x-0 top-0 z-50 border-b border-[var(--border-soft)] bg-[var(--page-bg)]/90 backdrop-blur">
8
+ <div class="mx-auto flex h-14 max-w-7xl items-center justify-between gap-4 px-4 sm:px-6">
9
+ <a href="/" class="flex min-w-0 items-center gap-2 font-semibold text-[var(--text-strong)]" aria-label={config.site.name}>
10
+ <Avatar size="sm" />
11
+ <span class="truncate">{config.site.name}</span>
12
+ </a>
13
+ <nav class="header-nav hidden items-center text-sm font-medium text-[var(--text-muted)] md:flex" aria-label="Primary">
14
+ {config.nav.map((item) => <a class="hover:text-[var(--text-strong)]" href={item.href}>{item.label}</a>)}
15
+ {
16
+ config.routes.search && (
17
+ <button class="search-trigger" type="button" data-search-trigger aria-label="Open search">
18
+ <span>Search</span>
19
+ <kbd>⌘K</kbd>
20
+ </button>
21
+ )
22
+ }
23
+ <ThemeToggle />
24
+ </nav>
25
+ <div class="flex items-center gap-3 md:hidden">
26
+ {config.routes.search && <button class="text-sm font-medium text-[var(--text-muted)] hover:text-[var(--text-strong)]" type="button" data-search-trigger>Search</button>}
27
+ <ThemeToggle />
28
+ </div>
29
+ </div>
30
+ </header>
@@ -0,0 +1,29 @@
1
+ ---
2
+ interface Props {
3
+ current: number;
4
+ total: number;
5
+ base: string;
6
+ }
7
+
8
+ const { current, total, base } = Astro.props;
9
+ ---
10
+
11
+ {
12
+ total > 1 && (
13
+ <nav class="mt-12 flex items-center justify-between border-t border-[var(--border-soft)] pt-8" aria-label="Pagination">
14
+ {current > 1 ? (
15
+ <a class="text-sm font-medium text-[var(--text-muted)] hover:text-[var(--accent)]" href={current === 2 ? base : `${base}page/${current - 1}/`}>
16
+ ← Newer posts
17
+ </a>
18
+ ) : <span />}
19
+ <span class="text-sm text-[var(--text-muted)]">
20
+ Page {current} of {total}
21
+ </span>
22
+ {current < total ? (
23
+ <a class="text-sm font-medium text-[var(--text-muted)] hover:text-[var(--accent)]" href={`${base}page/${current + 1}/`}>
24
+ Older posts →
25
+ </a>
26
+ ) : <span />}
27
+ </nav>
28
+ )
29
+ }
@@ -0,0 +1,44 @@
1
+ ---
2
+ import type { Post } from "../utils";
3
+ import { formatDate, postHref } from "../utils";
4
+ import { renderEmojiShortcodes } from "../emoji";
5
+ import TagList from "./TagList.astro";
6
+
7
+ interface Props {
8
+ post: Post;
9
+ featured?: boolean;
10
+ }
11
+
12
+ const { post, featured = false } = Astro.props;
13
+ const title = renderEmojiShortcodes(post.data.title);
14
+ const description = renderEmojiShortcodes(post.data.description);
15
+ ---
16
+
17
+ <article
18
+ class:list={[
19
+ "post-item group line-b px-3 py-8 sm:px-4",
20
+ post.data.cover && (featured ? "grid gap-6 md:grid-cols-[minmax(0,1.15fr)_minmax(18rem,0.85fr)] md:items-center" : "grid gap-5 sm:grid-cols-[minmax(0,1fr)_9rem] sm:items-start"),
21
+ ]}
22
+ >
23
+ {
24
+ post.data.cover && (
25
+ <a href={postHref(post)} class:list={["surface-block block overflow-hidden p-1", !featured && "sm:order-last"]}>
26
+ <img class:list={["w-full rounded-lg object-cover", featured ? "aspect-[16/8] md:aspect-[16/10]" : "aspect-[16/9] sm:aspect-square"]} src={post.data.cover} alt="" />
27
+ </a>
28
+ )
29
+ }
30
+ <div>
31
+ <p class="font-mono text-xs/6 font-medium uppercase tracking-widest text-[var(--text-muted)]">
32
+ <a class="hover:text-[var(--accent)]" href={`/categories/${post.data.category}`}>{post.data.category}</a>
33
+ <span class="px-1 text-[var(--border-soft)]" aria-hidden="true">/</span>
34
+ {post.data.publishDate && <time datetime={post.data.publishDate.toISOString()}>{formatDate(post.data.publishDate)}</time>}
35
+ </p>
36
+ <h2 class:list={["mt-2 font-semibold tracking-tight text-[var(--text-strong)] group-hover:text-[var(--accent)]", featured ? "text-3xl" : "text-xl"]}>
37
+ <a href={postHref(post)}>{title}</a>
38
+ </h2>
39
+ <p class="mt-3 text-sm/7 text-[var(--text-muted)]">{description}</p>
40
+ <div class="mt-5">
41
+ <TagList tags={post.data.tags} />
42
+ </div>
43
+ </div>
44
+ </article>
@@ -0,0 +1,15 @@
1
+ ---
2
+ import type { Post } from "../utils";
3
+ import PostCard from "./PostCard.astro";
4
+
5
+ interface Props {
6
+ posts: Post[];
7
+ featuredFirst?: boolean;
8
+ }
9
+
10
+ const { posts, featuredFirst = false } = Astro.props;
11
+ ---
12
+
13
+ <div>
14
+ {posts.map((post, index) => <PostCard post={post} featured={featuredFirst && index === 0} />)}
15
+ </div>
@@ -0,0 +1,17 @@
1
+ ---
2
+ import Avatar from "./Avatar.astro";
3
+ import config from "virtual:blog-theme/config";
4
+ ---
5
+
6
+ <section class="mx-auto max-w-4xl px-4 py-12 text-center sm:px-6 sm:py-16">
7
+ <div class="section-frame rounded-xl px-4 py-10 sm:px-8">
8
+ <div class="utility-note mb-5 text-xs">prose dark:system latest-posts</div>
9
+ <Avatar size="lg" class="mx-auto profile-mark" />
10
+ <h1 class="mt-5 text-2xl font-semibold tracking-tight text-[var(--text-strong)]">{config.site.name}</h1>
11
+ {config.author.headline && <p class="mt-3 text-sm text-[var(--text-body)]">{config.author.headline}</p>}
12
+ {config.author.bio && <p class="mx-auto mt-4 max-w-2xl text-sm/7 text-[var(--text-muted)]">{config.author.bio}</p>}
13
+ <div class="mt-5 flex justify-center gap-4 text-sm font-medium text-[var(--text-muted)]">
14
+ {config.socialLinks.map((link) => <a class="hover:text-[var(--accent)]" href={link.href}>{link.label}</a>)}
15
+ </div>
16
+ </div>
17
+ </section>