@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
@@ -0,0 +1,183 @@
1
+ ---
2
+ ---
3
+
4
+ <dialog class="search-dialog" data-search-dialog aria-label="Search">
5
+ <section class="search-modal" data-search-root data-search-mode="modal" data-search-limit="8">
6
+ <div class="search-modal-header">
7
+ <p class="utility-note text-xs">full-text search</p>
8
+ <button class="search-close" type="button" data-search-close aria-label="Close search">Esc</button>
9
+ </div>
10
+ <label class="search-field">
11
+ <span class="sr-only">Search posts</span>
12
+ <input data-search-input type="search" placeholder="Search posts..." autocomplete="off" />
13
+ </label>
14
+ <p class="search-status" data-search-status>Type at least 2 characters to search.</p>
15
+ <ol class="search-results" data-search-results></ol>
16
+ </section>
17
+ </dialog>
18
+
19
+ <script>
20
+ const pagefindPath = "/pagefind/pagefind.js";
21
+ let pagefindModule;
22
+ let pagefindLoad;
23
+
24
+ function loadPagefind() {
25
+ pagefindLoad ??= import(/* @vite-ignore */ pagefindPath).then((module) => {
26
+ pagefindModule = module;
27
+ return module;
28
+ });
29
+ return pagefindLoad;
30
+ }
31
+
32
+ function escapeHtml(value) {
33
+ return String(value)
34
+ .replaceAll("&", "&amp;")
35
+ .replaceAll("<", "&lt;")
36
+ .replaceAll(">", "&gt;")
37
+ .replaceAll('"', "&quot;");
38
+ }
39
+
40
+ function escapeRegExp(value) {
41
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
42
+ }
43
+
44
+ function highlight(value, query) {
45
+ const safe = escapeHtml(value);
46
+ const terms = [...new Set(query.split(/\s+/).map((term) => term.trim()).filter(Boolean))];
47
+ if (terms.length === 0) return safe;
48
+
49
+ const pattern = new RegExp(`(${terms.map(escapeRegExp).join("|")})`, "gi");
50
+ return safe.replace(pattern, "<mark>$1</mark>");
51
+ }
52
+
53
+ function setFallback(root, visible) {
54
+ const fallback = root.querySelector("[data-search-fallback]");
55
+ if (fallback) fallback.hidden = !visible;
56
+ }
57
+
58
+ function renderResults(root, results, query) {
59
+ const list = root.querySelector("[data-search-results]");
60
+ const status = root.querySelector("[data-search-status]");
61
+ if (!list || !status) return;
62
+
63
+ if (results.length === 0) {
64
+ list.innerHTML = "";
65
+ status.textContent = "No matching posts found.";
66
+ setFallback(root, false);
67
+ return;
68
+ }
69
+
70
+ status.textContent = `${results.length} result${results.length === 1 ? "" : "s"}`;
71
+ list.innerHTML = results
72
+ .map((result) => {
73
+ const title = result.meta?.title || result.url;
74
+ const category = result.meta?.category;
75
+ const date = result.meta?.date;
76
+ const meta = [category, date].filter(Boolean).join(" / ") || result.url;
77
+ const excerpt = result.excerpt || result.sub_results?.[0]?.excerpt || "";
78
+
79
+ return `
80
+ <li>
81
+ <a class="search-result" href="${escapeHtml(result.url)}" data-search-result>
82
+ <span class="search-result-meta">${escapeHtml(meta)}</span>
83
+ <span class="search-result-title">${highlight(title, query)}</span>
84
+ ${excerpt ? `<span class="search-result-excerpt">${excerpt}</span>` : ""}
85
+ </a>
86
+ </li>
87
+ `;
88
+ })
89
+ .join("");
90
+ setFallback(root, false);
91
+ }
92
+
93
+ function initSearchRoot(root) {
94
+ const input = root.querySelector("[data-search-input]");
95
+ const status = root.querySelector("[data-search-status]");
96
+ const list = root.querySelector("[data-search-results]");
97
+ const limit = Number(root.dataset.searchLimit || 20);
98
+ let timer;
99
+ let token = 0;
100
+
101
+ if (!input || !status || !list || root.dataset.searchReady === "true") return;
102
+ root.dataset.searchReady = "true";
103
+
104
+ async function runSearch() {
105
+ const query = input.value.trim();
106
+ token += 1;
107
+ const current = token;
108
+
109
+ if (query.length < 2) {
110
+ list.innerHTML = "";
111
+ status.textContent = "Type at least 2 characters to search.";
112
+ setFallback(root, root.dataset.searchMode === "page");
113
+ return;
114
+ }
115
+
116
+ status.textContent = "Searching...";
117
+ setFallback(root, false);
118
+
119
+ try {
120
+ const pagefind = pagefindModule || (await loadPagefind());
121
+ const search = await pagefind.search(query);
122
+ const data = await Promise.all(search.results.slice(0, limit).map((result) => result.data()));
123
+ if (current !== token) return;
124
+ renderResults(root, data, query);
125
+ } catch (error) {
126
+ if (current !== token) return;
127
+ list.innerHTML = "";
128
+ status.textContent = "Search index is available after a production build.";
129
+ setFallback(root, root.dataset.searchMode === "page");
130
+ console.error(error);
131
+ }
132
+ }
133
+
134
+ input.addEventListener("input", () => {
135
+ window.clearTimeout(timer);
136
+ timer = window.setTimeout(runSearch, 150);
137
+ });
138
+ input.addEventListener("focus", () => {
139
+ loadPagefind().catch(() => undefined);
140
+ });
141
+
142
+ root.addEventListener("click", (event) => {
143
+ const result = event.target.closest("[data-search-result]");
144
+ if (result) document.querySelector("[data-search-dialog]")?.close();
145
+ });
146
+ }
147
+
148
+ function initSearch() {
149
+ const dialog = document.querySelector("[data-search-dialog]");
150
+ const modalRoot = dialog?.querySelector("[data-search-root]");
151
+ const modalInput = dialog?.querySelector("[data-search-input]");
152
+
153
+ document.querySelectorAll("[data-search-root]").forEach(initSearchRoot);
154
+
155
+ function openSearch() {
156
+ if (!dialog || !modalRoot || !modalInput) return;
157
+ initSearchRoot(modalRoot);
158
+ if (!dialog.open) dialog.showModal();
159
+ window.setTimeout(() => modalInput.focus(), 0);
160
+ }
161
+
162
+ document.querySelectorAll("[data-search-trigger]").forEach((trigger) => {
163
+ trigger.addEventListener("click", openSearch);
164
+ });
165
+
166
+ document.querySelectorAll("[data-search-close]").forEach((trigger) => {
167
+ trigger.addEventListener("click", () => dialog?.close());
168
+ });
169
+
170
+ dialog?.addEventListener("click", (event) => {
171
+ if (event.target === dialog) dialog.close();
172
+ });
173
+
174
+ document.addEventListener("keydown", (event) => {
175
+ const isSearchShortcut = (event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "k";
176
+ if (!isSearchShortcut) return;
177
+ event.preventDefault();
178
+ openSearch();
179
+ });
180
+ }
181
+
182
+ initSearch();
183
+ </script>
@@ -0,0 +1,35 @@
1
+ ---
2
+ import { renderEmojiShortcodes } from "../emoji";
3
+
4
+ interface Heading {
5
+ depth: number;
6
+ slug: string;
7
+ text: string;
8
+ }
9
+
10
+ interface Props {
11
+ headings: Heading[];
12
+ }
13
+
14
+ const headings = Astro.props.headings
15
+ .filter((heading) => heading.depth === 2 || heading.depth === 3)
16
+ .map((heading) => ({
17
+ ...heading,
18
+ text: renderEmojiShortcodes(heading.text),
19
+ }));
20
+ ---
21
+
22
+ {
23
+ headings.length > 0 && (
24
+ <aside class="sticky top-20 hidden max-h-[calc(100dvh-5rem)] overflow-auto px-6 py-10 xl:block">
25
+ <h2 class="font-mono text-xs font-medium uppercase tracking-widest text-[var(--text-muted)]">On this page</h2>
26
+ <nav class="mt-4 grid gap-2 text-sm text-[var(--text-muted)]" data-emoji-shortcodes>
27
+ {headings.map((heading) => (
28
+ <a class:list={["border-l border-[var(--border-soft)] hover:text-[var(--text-strong)]", heading.depth === 3 ? "pl-6" : "pl-3"]} href={`#${heading.slug}`}>
29
+ {heading.text}
30
+ </a>
31
+ ))}
32
+ </nav>
33
+ </aside>
34
+ )
35
+ }
@@ -0,0 +1,19 @@
1
+ ---
2
+ interface Props {
3
+ tags: string[];
4
+ }
5
+
6
+ const { tags } = Astro.props;
7
+ ---
8
+
9
+ {
10
+ tags.length > 0 && (
11
+ <div class="flex flex-wrap gap-2">
12
+ {tags.map((tag) => (
13
+ <a class="rounded-full border border-[var(--border-soft)] px-2.5 py-1 font-mono text-xs text-[var(--text-muted)] hover:text-[var(--accent)]" href={`/tags/${tag}`}>
14
+ {tag}
15
+ </a>
16
+ ))}
17
+ </div>
18
+ )
19
+ }
@@ -0,0 +1,25 @@
1
+ ---
2
+ ---
3
+
4
+ <button
5
+ type="button"
6
+ class="grid size-8 place-items-center rounded-full border border-[var(--border-soft)] text-sm text-[var(--text-muted)] hover:text-[var(--text-strong)]"
7
+ aria-label="Toggle color theme"
8
+ data-theme-toggle
9
+ >
10
+
11
+ </button>
12
+
13
+ <script>
14
+ const button = document.querySelector("[data-theme-toggle]");
15
+ const modes = ["system", "light", "dark"];
16
+
17
+ button?.addEventListener("click", () => {
18
+ const current = document.documentElement.dataset.theme || "system";
19
+ const next = modes[(modes.indexOf(current) + 1) % modes.length];
20
+ localStorage.setItem("theme", next);
21
+ document.documentElement.dataset.theme = next;
22
+ const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
23
+ document.documentElement.classList.toggle("dark", next === "dark" || (next === "system" && prefersDark));
24
+ });
25
+ </script>
package/src/config.ts ADDED
@@ -0,0 +1,62 @@
1
+ import type { BlogThemeOptions, NormalizedBlogThemeOptions } from "./types";
2
+
3
+ export function normalizeOptions(options: BlogThemeOptions): NormalizedBlogThemeOptions {
4
+ return {
5
+ site: {
6
+ name: options.site.name,
7
+ title: options.site.title ?? options.site.name,
8
+ description: options.site.description ?? "",
9
+ lang: options.site.lang ?? "en",
10
+ },
11
+ author: {
12
+ name: options.author.name,
13
+ headline: options.author.headline ?? "",
14
+ bio: options.author.bio ?? "",
15
+ avatar: options.author.avatar,
16
+ },
17
+ postsPerPage: options.postsPerPage ?? 8,
18
+ nav:
19
+ options.nav ?? [
20
+ { label: "Posts", href: "/posts" },
21
+ { label: "Tags", href: "/tags" },
22
+ { label: "Categories", href: "/categories" },
23
+ { label: "About", href: "/about" },
24
+ ],
25
+ socialLinks: (options.socialLinks ?? []).map((link) => ({
26
+ ...link,
27
+ icon: link.icon ?? "link",
28
+ })),
29
+ theme: {
30
+ defaultMode: options.theme?.defaultMode ?? "system",
31
+ accent: options.theme?.accent ?? "sky",
32
+ },
33
+ diagrams: {
34
+ mermaid: options.diagrams?.mermaid ?? false,
35
+ plantuml: {
36
+ enabled: Boolean(options.diagrams?.plantuml),
37
+ serverUrl:
38
+ typeof options.diagrams?.plantuml === "object"
39
+ ? (options.diagrams.plantuml.serverUrl ?? "https://www.plantuml.com/plantuml/svg")
40
+ : "https://www.plantuml.com/plantuml/svg",
41
+ },
42
+ },
43
+ routes:
44
+ options.routes === false
45
+ ? {
46
+ home: false,
47
+ posts: false,
48
+ tags: false,
49
+ categories: false,
50
+ about: false,
51
+ search: false,
52
+ }
53
+ : {
54
+ home: options.routes?.home ?? true,
55
+ posts: options.routes?.posts ?? true,
56
+ tags: options.routes?.tags ?? true,
57
+ categories: options.routes?.categories ?? true,
58
+ about: options.routes?.about ?? true,
59
+ search: options.routes?.search ?? true,
60
+ },
61
+ };
62
+ }
package/src/content.ts ADDED
@@ -0,0 +1,20 @@
1
+ import { defineCollection } from "astro:content";
2
+ import { glob } from "astro/loaders";
3
+ import { z } from "astro/zod";
4
+
5
+ const posts = defineCollection({
6
+ loader: glob({ base: "./src/content/posts", pattern: "**/*.{md,mdx}" }),
7
+ schema: z.object({
8
+ title: z.string(),
9
+ description: z.string().default(""),
10
+ publishDate: z.union([z.date(), z.coerce.date()]).optional(),
11
+ updatedDate: z.union([z.date(), z.coerce.date()]).optional(),
12
+ category: z.string().default("Uncategorized"),
13
+ tags: z.array(z.string()).default([]),
14
+ cover: z.string().optional(),
15
+ draft: z.boolean().default(false),
16
+ featured: z.boolean().default(false),
17
+ }),
18
+ });
19
+
20
+ export const collections = { posts };
package/src/emoji.ts ADDED
@@ -0,0 +1,9 @@
1
+ import { nameToEmoji } from "gemoji";
2
+
3
+ export const emojiShortcodes = nameToEmoji;
4
+
5
+ export function renderEmojiShortcodes(value: string) {
6
+ return value.replace(/:([+-]1|[a-z0-9_+-]+):/gi, (match, shortcode: string) => {
7
+ return emojiShortcodes[shortcode.toLowerCase()] ?? match;
8
+ });
9
+ }
package/src/env.d.ts ADDED
@@ -0,0 +1,32 @@
1
+ declare module "virtual:blog-theme/config" {
2
+ import type { NormalizedBlogThemeOptions } from "./types";
3
+
4
+ const config: NormalizedBlogThemeOptions;
5
+ export { config };
6
+ export default config;
7
+ }
8
+
9
+ declare module "virtual:blog-theme/about-content" {
10
+ import type { AstroComponentFactory } from "astro";
11
+ export const Content: AstroComponentFactory | null;
12
+ }
13
+
14
+ declare module "astro:content" {
15
+ export function getCollection(
16
+ collection: string,
17
+ filter?: (entry: any) => boolean
18
+ ): Promise<any[]>;
19
+
20
+ export function defineCollection(config: {
21
+ loader?: any;
22
+ schema?: any;
23
+ }): any;
24
+
25
+ export type CollectionEntry<T extends string> = {
26
+ id: string;
27
+ slug: string;
28
+ data: Record<string, any>;
29
+ body: string;
30
+ collection: T;
31
+ };
32
+ }
package/src/index.ts ADDED
@@ -0,0 +1,136 @@
1
+ import { existsSync } from "node:fs";
2
+ import { resolve } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import type { AstroIntegration } from "astro";
5
+ import { unified } from "@astrojs/markdown-remark";
6
+ import * as pagefind from "pagefind";
7
+ import remarkGemoji from "remark-gemoji";
8
+ import { normalizeOptions } from "./config";
9
+ import { transformContentShortcodes } from "./shortcodes";
10
+ import type { BlogThemeOptions } from "./types";
11
+
12
+ function virtualConfigPlugin(config: unknown) {
13
+ const moduleId = "virtual:blog-theme/config";
14
+ const resolvedModuleId = `\0${moduleId}`;
15
+
16
+ return {
17
+ name: "astro-blog-theme-config",
18
+ resolveId(id: string) {
19
+ if (id === moduleId) return resolvedModuleId;
20
+ },
21
+ load(id: string) {
22
+ if (id === resolvedModuleId) {
23
+ return `export const config = ${JSON.stringify(config)}; export default config;`;
24
+ }
25
+ },
26
+ };
27
+ }
28
+
29
+ function virtualAboutPlugin() {
30
+ const moduleId = "virtual:blog-theme/about-content";
31
+ const resolvedModuleId = `\0${moduleId}`;
32
+ let root = "";
33
+
34
+ return {
35
+ name: "astro-blog-theme-about",
36
+ configResolved(config: any) {
37
+ root = config.root;
38
+ },
39
+ resolveId(id: string) {
40
+ if (id === moduleId) return resolvedModuleId;
41
+ },
42
+ load(id: string) {
43
+ if (id !== resolvedModuleId) return;
44
+ const md = resolve(root, "src/content/about.md");
45
+ const mdx = resolve(root, "src/content/about.mdx");
46
+ const aboutPath = existsSync(md) ? md : existsSync(mdx) ? mdx : null;
47
+ if (aboutPath) {
48
+ return `export { Content } from ${JSON.stringify(aboutPath)}`;
49
+ }
50
+ return `export const Content = null`;
51
+ },
52
+ };
53
+ }
54
+
55
+ function contentShortcodePlugin() {
56
+ return {
57
+ name: "astro-blog-theme-content-shortcodes",
58
+ enforce: "pre" as const,
59
+ transform(code: string, id: string) {
60
+ if (!/\.(md|mdx)(?:[?#].*)?$/.test(id) || !code.includes("{{<")) return;
61
+ return {
62
+ code: transformContentShortcodes(code),
63
+ map: null,
64
+ };
65
+ },
66
+ };
67
+ }
68
+
69
+ function route(path: string) {
70
+ return fileURLToPath(new URL(path, import.meta.url));
71
+ }
72
+
73
+ export default function blogTheme(options: BlogThemeOptions): AstroIntegration {
74
+ const config = normalizeOptions(options);
75
+
76
+ return {
77
+ name: "@guzhongren/sha",
78
+ hooks: {
79
+ "astro:config:setup": ({ injectRoute, updateConfig }) => {
80
+ updateConfig({
81
+ markdown: {
82
+ processor: unified({ remarkPlugins: [remarkGemoji] }),
83
+ },
84
+ vite: {
85
+ plugins: [contentShortcodePlugin(), virtualConfigPlugin(config), virtualAboutPlugin()],
86
+ },
87
+ });
88
+
89
+ if (config.routes.home) injectRoute({ pattern: "/", entrypoint: route("./pages/index.astro") });
90
+ if (config.routes.posts) injectRoute({ pattern: "/posts", entrypoint: route("./pages/posts/index.astro") });
91
+ if (config.routes.posts) {
92
+ injectRoute({ pattern: "/posts/[...slug]", entrypoint: route("./pages/posts/[...slug].astro") });
93
+ injectRoute({ pattern: "/posts/page/[page]", entrypoint: route("./pages/posts/page/[page].astro") });
94
+ }
95
+ if (config.routes.tags) injectRoute({ pattern: "/tags", entrypoint: route("./pages/tags/index.astro") });
96
+ if (config.routes.tags) injectRoute({ pattern: "/tags/[tag]", entrypoint: route("./pages/tags/[tag].astro") });
97
+ if (config.routes.categories) {
98
+ injectRoute({ pattern: "/categories", entrypoint: route("./pages/categories/index.astro") });
99
+ injectRoute({ pattern: "/categories/[category]", entrypoint: route("./pages/categories/[category].astro") });
100
+ }
101
+ if (config.routes.about) injectRoute({ pattern: "/about", entrypoint: route("./pages/about.astro") });
102
+ if (config.routes.search) injectRoute({ pattern: "/search", entrypoint: route("./pages/search.astro") });
103
+ },
104
+ "astro:build:done": async ({ dir }) => {
105
+ if (!config.routes.search) return;
106
+
107
+ const sitePath = fileURLToPath(dir);
108
+ const outputPath = fileURLToPath(new URL("./pagefind", dir));
109
+ const { index, errors: createErrors } = await pagefind.createIndex({
110
+ rootSelector: "[data-pagefind-body]",
111
+ includeCharacters: "_-:/#.",
112
+ });
113
+
114
+ if (!index || createErrors.length > 0) {
115
+ throw new Error(`Pagefind index creation failed: ${createErrors.join("; ") || "unknown error"}`);
116
+ }
117
+
118
+ try {
119
+ const addResult = await index.addDirectory({ path: sitePath });
120
+ if (addResult.errors.length > 0) {
121
+ throw new Error(addResult.errors.join("; "));
122
+ }
123
+
124
+ const writeResult = await index.writeFiles({ outputPath });
125
+ if (writeResult.errors.length > 0) {
126
+ throw new Error(writeResult.errors.join("; "));
127
+ }
128
+ } finally {
129
+ await pagefind.close();
130
+ }
131
+ },
132
+ },
133
+ };
134
+ }
135
+
136
+ export type { BlogThemeOptions } from "./types";
@@ -0,0 +1,59 @@
1
+ ---
2
+ import "../styles/global.css";
3
+ import Header from "../components/Header.astro";
4
+ import Footer from "../components/Footer.astro";
5
+ import DiagramEnhancer from "../components/DiagramEnhancer.astro";
6
+ import CodeCopyEnhancer from "../components/CodeCopyEnhancer.astro";
7
+ import EmojiEnhancer from "../components/EmojiEnhancer.astro";
8
+ import EChartsEnhancer from "../components/EChartsEnhancer.astro";
9
+ import SearchEnhancer from "../components/SearchEnhancer.astro";
10
+ import config from "virtual:blog-theme/config";
11
+
12
+ interface Props {
13
+ title?: string;
14
+ description?: string;
15
+ }
16
+
17
+ const title = Astro.props.title ? `${Astro.props.title} - ${config.site.title}` : config.site.title;
18
+ const description = Astro.props.description ?? config.site.description;
19
+ const defaultMode = config.theme.defaultMode;
20
+ ---
21
+
22
+ <!doctype html>
23
+ <html lang={config.site.lang} data-default-theme={defaultMode}>
24
+ <head>
25
+ <meta charset="utf-8" />
26
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
27
+ <meta name="description" content={description} />
28
+ {config.author.avatar && (
29
+ <>
30
+ <link rel="icon" href={config.author.avatar} />
31
+ <link rel="apple-touch-icon" href={config.author.avatar} />
32
+ </>
33
+ )}
34
+ <title>{title}</title>
35
+ <script is:inline define:vars={{ defaultMode }}>
36
+ const stored = localStorage.getItem("theme");
37
+ const mode = stored || defaultMode;
38
+ const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
39
+ document.documentElement.classList.toggle("dark", mode === "dark" || (mode === "system" && prefersDark));
40
+ document.documentElement.dataset.theme = mode;
41
+ </script>
42
+ </head>
43
+ <body class="min-h-dvh bg-[var(--page-bg)] text-[var(--text-body)] antialiased">
44
+ <Header />
45
+ <div class="mx-auto grid min-h-dvh max-w-7xl grid-cols-1 pt-14 lg:grid-cols-[2rem_minmax(0,1fr)_2rem]">
46
+ <div class="gutter-stripes hidden border-x border-[var(--border-soft)] lg:block" aria-hidden="true"></div>
47
+ <main id="main-content">
48
+ <slot />
49
+ </main>
50
+ <div class="gutter-stripes hidden border-x border-[var(--border-soft)] lg:block" aria-hidden="true"></div>
51
+ </div>
52
+ <Footer />
53
+ <EmojiEnhancer />
54
+ <EChartsEnhancer />
55
+ <CodeCopyEnhancer />
56
+ {config.routes.search && <SearchEnhancer />}
57
+ {(config.diagrams.mermaid || config.diagrams.plantuml.enabled) && <DiagramEnhancer diagrams={config.diagrams} />}
58
+ </body>
59
+ </html>
@@ -0,0 +1,25 @@
1
+ ---
2
+ import BaseLayout from "../layouts/BaseLayout.astro";
3
+ import ProfileIntro from "../components/ProfileIntro.astro";
4
+ import config from "virtual:blog-theme/config";
5
+ import { Content } from "virtual:blog-theme/about-content";
6
+ ---
7
+
8
+ <BaseLayout title="About" description={config.author.bio || config.site.description}>
9
+ <ProfileIntro />
10
+ <section class="mx-auto max-w-3xl px-4 pb-20 sm:px-6">
11
+ {Content ? (
12
+ <div class="prose" data-emoji-shortcodes>
13
+ <Content />
14
+ </div>
15
+ ) : (
16
+ <div class="prose">
17
+ <h2>About this site</h2>
18
+ <p>
19
+ This blog is built for technical notes, engineering decisions, and long-lived references. It keeps the author
20
+ signal visible without pushing the writing below a marketing-style landing page.
21
+ </p>
22
+ </div>
23
+ )}
24
+ </section>
25
+ </BaseLayout>
@@ -0,0 +1,30 @@
1
+ ---
2
+ import { getCollection } from "astro:content";
3
+ import BaseLayout from "../../layouts/BaseLayout.astro";
4
+ import PostList from "../../components/PostList.astro";
5
+ import { isPublished, sortPosts, uniqueSorted } from "../../utils";
6
+
7
+ export async function getStaticPaths() {
8
+ const allPosts = (await getCollection("posts")).filter(isPublished);
9
+ const categories = uniqueSorted(allPosts.map((post) => post.data.category));
10
+ return categories.map((category) => ({
11
+ params: { category },
12
+ props: {
13
+ category,
14
+ posts: sortPosts(allPosts.filter((post) => post.data.category === category)),
15
+ },
16
+ }));
17
+ }
18
+
19
+ const { category, posts } = Astro.props;
20
+ ---
21
+
22
+ <BaseLayout title={`Category: ${category}`} description={`Posts in ${category}`}>
23
+ <section class="mx-auto max-w-4xl px-4 py-12 sm:px-6">
24
+ <p class="font-mono text-xs font-medium uppercase tracking-widest text-[var(--text-muted)]">Category</p>
25
+ <h1 class="mt-3 text-4xl font-semibold tracking-tight text-[var(--text-strong)]">{category}</h1>
26
+ <div class="mt-8">
27
+ <PostList posts={posts} />
28
+ </div>
29
+ </section>
30
+ </BaseLayout>
@@ -0,0 +1,18 @@
1
+ ---
2
+ import { getCollection } from "astro:content";
3
+ import BaseLayout from "../../layouts/BaseLayout.astro";
4
+ import { isPublished, uniqueSorted } from "../../utils";
5
+
6
+ const posts = (await getCollection("posts")).filter(isPublished);
7
+ const categories = uniqueSorted(posts.map((post) => post.data.category));
8
+ ---
9
+
10
+ <BaseLayout title="Categories" description="Browse posts by category">
11
+ <section class="mx-auto max-w-4xl px-4 py-12 sm:px-6">
12
+ <p class="font-mono text-xs font-medium uppercase tracking-widest text-[var(--text-muted)]">Index</p>
13
+ <h1 class="mt-3 text-4xl font-semibold tracking-tight text-[var(--text-strong)]">Categories</h1>
14
+ <div class="mt-8 grid gap-3 sm:grid-cols-2">
15
+ {categories.map((category) => <a class="rounded-xl border border-[var(--border-soft)] bg-[var(--panel-bg)] p-4 text-[var(--text-strong)] hover:border-[var(--accent)]" href={`/categories/${category}`}>{category}</a>)}
16
+ </div>
17
+ </section>
18
+ </BaseLayout>