astro-awesomeness 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.
@@ -0,0 +1,50 @@
1
+ import { t as __exportAll } from "./chunk-pbuEa-1d.js";
2
+ import { t as cn } from "./cn-B3UhF_Ec.js";
3
+ //#region src/lib/format-date.ts
4
+ const defaultOptions = {
5
+ day: "numeric",
6
+ month: "long",
7
+ timeZone: "UTC",
8
+ year: "numeric"
9
+ };
10
+ const formatDate = (date, locale = "en-US", options = defaultOptions) => new Intl.DateTimeFormat(locale, {
11
+ ...defaultOptions,
12
+ ...options
13
+ }).format(date);
14
+ //#endregion
15
+ //#region src/lib/get-related-posts.ts
16
+ const countShared = (a, b) => {
17
+ const setB = new Set(b);
18
+ return a.filter((tag) => setB.has(tag)).length;
19
+ };
20
+ const getRelatedPosts = (current, all, n) => all.filter((post) => post.slug !== current.slug).map((post) => ({
21
+ post,
22
+ score: countShared(current.data.tags, post.data.tags)
23
+ })).toSorted((x, y) => y.score - x.score).slice(0, n).map(({ post }) => post);
24
+ //#endregion
25
+ //#region src/lib/reading-time.ts
26
+ const WORDS_PER_MINUTE = 200;
27
+ const readingTime = (input) => {
28
+ const stripped = input.replaceAll(/* @__PURE__ */ new RegExp("<[^>]*>", "gv"), "").replaceAll(/* @__PURE__ */ new RegExp("[#*_`>\\-]", "gv"), " ").replaceAll(/* @__PURE__ */ new RegExp("\\s+", "gv"), " ").trim();
29
+ const words = stripped.length === 0 ? 0 : stripped.split(" ").length;
30
+ return {
31
+ minutes: Math.max(1, Math.ceil(words / WORDS_PER_MINUTE)),
32
+ words
33
+ };
34
+ };
35
+ //#endregion
36
+ //#region src/lib/slugify.ts
37
+ const slugify = (input) => input.normalize("NFD").replaceAll(/* @__PURE__ */ new RegExp("[̀-ͯ]", "gv"), "").toLowerCase().replaceAll(/* @__PURE__ */ new RegExp("[^a-z0-9\\s\\-]", "gv"), "").trim().replaceAll(/* @__PURE__ */ new RegExp("[\\s\\-]+", "gv"), "-").replaceAll(/* @__PURE__ */ new RegExp("^-+|-+$", "gv"), "");
38
+ //#endregion
39
+ //#region src/lib/index.ts
40
+ var lib_exports = /* @__PURE__ */ __exportAll({
41
+ cn: () => cn,
42
+ formatDate: () => formatDate,
43
+ getRelatedPosts: () => getRelatedPosts,
44
+ readingTime: () => readingTime,
45
+ slugify: () => slugify
46
+ });
47
+ //#endregion
48
+ export { formatDate as a, getRelatedPosts as i, slugify as n, readingTime as r, lib_exports as t };
49
+
50
+ //# sourceMappingURL=lib-DRaCmJ7r.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"lib-DRaCmJ7r.js","names":[],"sources":["../src/lib/format-date.ts","../src/lib/get-related-posts.ts","../src/lib/reading-time.ts","../src/lib/slugify.ts","../src/lib/index.ts"],"sourcesContent":["const defaultOptions: Intl.DateTimeFormatOptions = {\n day: \"numeric\",\n month: \"long\",\n timeZone: \"UTC\",\n year: \"numeric\",\n};\n\nconst formatDate = (\n date: Date,\n locale: string = \"en-US\",\n options: Intl.DateTimeFormatOptions = defaultOptions,\n) => new Intl.DateTimeFormat(locale, { ...defaultOptions, ...options }).format(date);\n\nexport { formatDate };\n","type HasTags = { data: { tags: Array<string> }; slug: string };\n\nconst countShared = (a: Array<string>, b: Array<string>) => {\n const setB = new Set(b);\n return a.filter((tag) => setB.has(tag)).length;\n};\n\nconst getRelatedPosts = <T extends HasTags>(current: T, all: Array<T>, n: number) =>\n all\n .filter((post) => post.slug !== current.slug)\n .map((post) => ({\n post,\n score: countShared(current.data.tags, post.data.tags),\n }))\n .toSorted((x, y) => y.score - x.score)\n .slice(0, n)\n .map(({ post }) => post);\n\nexport { getRelatedPosts };\n","const WORDS_PER_MINUTE = 200;\n\nconst readingTime = (input: string) => {\n const stripped = input\n .replaceAll(/<[^>]*>/gv, \"\")\n .replaceAll(/[#*_`>\\-]/gv, \" \")\n .replaceAll(/\\s+/gv, \" \")\n .trim();\n const words = stripped.length === 0 ? 0 : stripped.split(\" \").length;\n const minutes = Math.max(1, Math.ceil(words / WORDS_PER_MINUTE));\n return { minutes, words };\n};\n\nexport { readingTime };\n","const slugify = (input: string) =>\n input\n .normalize(\"NFD\")\n .replaceAll(/[̀-ͯ]/gv, \"\")\n .toLowerCase()\n .replaceAll(/[^a-z0-9\\s\\-]/gv, \"\")\n .trim()\n .replaceAll(/[\\s\\-]+/gv, \"-\")\n .replaceAll(/^-+|-+$/gv, \"\");\n\nexport { slugify };\n","export { cn } from \"./cn\";\nexport { formatDate } from \"./format-date\";\nexport { getRelatedPosts } from \"./get-related-posts\";\nexport { readingTime } from \"./reading-time\";\nexport { slugify } from \"./slugify\";\n"],"mappings":";;;AAAA,MAAM,iBAA6C;CACjD,KAAK;CACL,OAAO;CACP,UAAU;CACV,MAAM;AACR;AAEA,MAAM,cACJ,MACA,SAAiB,SACjB,UAAsC,mBACnC,IAAI,KAAK,eAAe,QAAQ;CAAE,GAAG;CAAgB,GAAG;AAAQ,CAAC,EAAE,OAAO,IAAI;;;ACTnF,MAAM,eAAe,GAAkB,MAAqB;CAC1D,MAAM,OAAO,IAAI,IAAI,CAAC;CACtB,OAAO,EAAE,QAAQ,QAAQ,KAAK,IAAI,GAAG,CAAC,EAAE;AAC1C;AAEA,MAAM,mBAAsC,SAAY,KAAe,MACrE,IACG,QAAQ,SAAS,KAAK,SAAS,QAAQ,IAAI,EAC3C,KAAK,UAAU;CACd;CACA,OAAO,YAAY,QAAQ,KAAK,MAAM,KAAK,KAAK,IAAI;AACtD,EAAE,EACD,UAAU,GAAG,MAAM,EAAE,QAAQ,EAAE,KAAK,EACpC,MAAM,GAAG,CAAC,EACV,KAAK,EAAE,WAAW,IAAI;;;AChB3B,MAAM,mBAAmB;AAEzB,MAAM,eAAe,UAAkB;CACrC,MAAM,WAAW,MACd,2BAAW,IAAA,OAAA,WAAA,IAAU,GAAG,EAAE,EAC1B,2BAAW,IAAA,OAAA,cAAA,IAAY,GAAG,GAAG,EAC7B,2BAAW,IAAA,OAAA,QAAA,IAAM,GAAG,GAAG,EACvB,KAAK;CACR,MAAM,QAAQ,SAAS,WAAW,IAAI,IAAI,SAAS,MAAM,GAAG,EAAE;CAE9D,OAAO;EAAE,SADO,KAAK,IAAI,GAAG,KAAK,KAAK,QAAQ,gBAAgB,CAC/C;EAAG;CAAM;AAC1B;;;ACXA,MAAM,WAAW,UACf,MACG,UAAU,KAAK,EACf,2BAAW,IAAA,OAAA,SAAA,IAAQ,GAAG,EAAE,EACxB,YAAY,EACZ,2BAAW,IAAA,OAAA,mBAAA,IAAgB,GAAG,EAAE,EAChC,KAAK,EACL,2BAAW,IAAA,OAAA,aAAA,IAAU,GAAG,GAAG,EAC3B,2BAAW,IAAA,OAAA,WAAA,IAAU,GAAG,EAAE"}
@@ -0,0 +1,209 @@
1
+ /* This stylesheet is consumed AFTER `@import "tailwindcss"` in the host app.
2
+ It contributes tokens, base styles, prose, and the dark variant. */
3
+
4
+ @import "tw-animate-css";
5
+
6
+ @custom-variant dark (&:is(.dark *));
7
+
8
+ @theme {
9
+ --color-background: oklch(1 0 0);
10
+ --color-foreground: oklch(0.145 0 0);
11
+ --color-card: oklch(1 0 0);
12
+ --color-card-foreground: oklch(0.145 0 0);
13
+ --color-popover: oklch(1 0 0);
14
+ --color-popover-foreground: oklch(0.145 0 0);
15
+ --color-primary: oklch(0.205 0 0);
16
+ --color-primary-foreground: oklch(0.985 0 0);
17
+ --color-secondary: oklch(0.97 0 0);
18
+ --color-secondary-foreground: oklch(0.205 0 0);
19
+ --color-muted: oklch(0.97 0 0);
20
+ --color-muted-foreground: oklch(0.556 0 0);
21
+ --color-accent: oklch(0.97 0 0);
22
+ --color-accent-foreground: oklch(0.205 0 0);
23
+ --color-destructive: oklch(0.577 0.245 27.325);
24
+ --color-destructive-foreground: oklch(0.985 0 0);
25
+ --color-border: oklch(0.922 0 0);
26
+ --color-input: oklch(0.922 0 0);
27
+ --color-ring: oklch(0.708 0 0);
28
+
29
+ --radius-sm: 0.25rem;
30
+ --radius-md: 0.5rem;
31
+ --radius-lg: 0.75rem;
32
+ --radius-xl: 1rem;
33
+
34
+ --font-sans:
35
+ ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
36
+ --font-serif: ui-serif, Georgia, Cambria, "Times New Roman", Times, serif;
37
+ --font-mono:
38
+ ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace;
39
+ }
40
+
41
+ .dark {
42
+ --color-background: oklch(0.145 0 0);
43
+ --color-foreground: oklch(0.985 0 0);
44
+ --color-card: oklch(0.145 0 0);
45
+ --color-card-foreground: oklch(0.985 0 0);
46
+ --color-popover: oklch(0.145 0 0);
47
+ --color-popover-foreground: oklch(0.985 0 0);
48
+ --color-primary: oklch(0.985 0 0);
49
+ --color-primary-foreground: oklch(0.205 0 0);
50
+ --color-secondary: oklch(0.269 0 0);
51
+ --color-secondary-foreground: oklch(0.985 0 0);
52
+ --color-muted: oklch(0.269 0 0);
53
+ --color-muted-foreground: oklch(0.708 0 0);
54
+ --color-accent: oklch(0.269 0 0);
55
+ --color-accent-foreground: oklch(0.985 0 0);
56
+ --color-destructive: oklch(0.704 0.191 22.216);
57
+ --color-destructive-foreground: oklch(0.985 0 0);
58
+ --color-border: oklch(0.269 0 0);
59
+ --color-input: oklch(0.269 0 0);
60
+ --color-ring: oklch(0.556 0 0);
61
+ }
62
+
63
+ @layer base {
64
+ *,
65
+ *::before,
66
+ *::after {
67
+ box-sizing: border-box;
68
+ }
69
+ html {
70
+ -webkit-text-size-adjust: 100%;
71
+ }
72
+ body {
73
+ background-color: var(--color-background);
74
+ color: var(--color-foreground);
75
+ font-family: var(--font-sans);
76
+ font-feature-settings:
77
+ "rlig" 1,
78
+ "calt" 1;
79
+ line-height: 1.6;
80
+ }
81
+
82
+ :focus-visible {
83
+ outline: 2px solid var(--color-ring);
84
+ outline-offset: 2px;
85
+ }
86
+
87
+ button:not(:disabled),
88
+ [role="button"]:not(:disabled) {
89
+ cursor: pointer;
90
+ }
91
+
92
+ ::-webkit-scrollbar {
93
+ width: 6px;
94
+ height: 6px;
95
+ }
96
+ ::-webkit-scrollbar-track {
97
+ background: transparent;
98
+ }
99
+ ::-webkit-scrollbar-thumb {
100
+ background: var(--color-border);
101
+ border-radius: 6px;
102
+ }
103
+ * {
104
+ scrollbar-width: thin;
105
+ scrollbar-color: var(--color-border) transparent;
106
+ }
107
+ }
108
+
109
+ @layer components {
110
+ .prose {
111
+ max-width: 65ch;
112
+ color: var(--color-foreground);
113
+ font-size: 1rem;
114
+ line-height: 1.75;
115
+ }
116
+ .prose :where(h1, h2, h3, h4, h5, h6) {
117
+ color: var(--color-foreground);
118
+ font-weight: 700;
119
+ line-height: 1.25;
120
+ margin-top: 2em;
121
+ margin-bottom: 0.75em;
122
+ scroll-margin-top: 4rem;
123
+ }
124
+ .prose h1 {
125
+ font-size: 2.25rem;
126
+ }
127
+ .prose h2 {
128
+ font-size: 1.75rem;
129
+ }
130
+ .prose h3 {
131
+ font-size: 1.375rem;
132
+ }
133
+ .prose p {
134
+ margin-top: 1em;
135
+ margin-bottom: 1em;
136
+ }
137
+ .prose a {
138
+ color: var(--color-accent-foreground);
139
+ text-decoration: underline;
140
+ text-underline-offset: 0.2em;
141
+ text-decoration-color: var(--color-border);
142
+ transition: text-decoration-color 0.15s;
143
+ }
144
+ .prose a:hover {
145
+ text-decoration-color: var(--color-accent-foreground);
146
+ }
147
+ .prose blockquote {
148
+ margin: 1.5em 0;
149
+ padding-left: 1em;
150
+ border-left: 3px solid var(--color-border);
151
+ color: var(--color-muted-foreground);
152
+ font-style: italic;
153
+ }
154
+ .prose code {
155
+ font-family: var(--font-mono);
156
+ font-size: 0.875em;
157
+ background-color: var(--color-muted);
158
+ padding: 0.125em 0.375em;
159
+ border-radius: var(--radius-sm);
160
+ }
161
+ .prose pre {
162
+ margin: 1.5em 0;
163
+ padding: 1em 1.25em;
164
+ background-color: var(--color-muted);
165
+ border-radius: var(--radius-md);
166
+ overflow-x: auto;
167
+ font-size: 0.875rem;
168
+ line-height: 1.6;
169
+ }
170
+ .prose pre code {
171
+ background: none;
172
+ padding: 0;
173
+ font-size: inherit;
174
+ }
175
+ .prose ul,
176
+ .prose ol {
177
+ margin: 1em 0;
178
+ padding-left: 1.5em;
179
+ }
180
+ .prose li {
181
+ margin: 0.25em 0;
182
+ }
183
+ .prose ul {
184
+ list-style-type: disc;
185
+ }
186
+ .prose ol {
187
+ list-style-type: decimal;
188
+ }
189
+ .prose img {
190
+ border-radius: var(--radius-md);
191
+ margin: 1.5em 0;
192
+ }
193
+ .prose hr {
194
+ margin: 2em 0;
195
+ border: 0;
196
+ border-top: 1px solid var(--color-border);
197
+ }
198
+ .prose table {
199
+ width: 100%;
200
+ border-collapse: collapse;
201
+ margin: 1.5em 0;
202
+ }
203
+ .prose th,
204
+ .prose td {
205
+ padding: 0.5em 0.75em;
206
+ border-bottom: 1px solid var(--color-border);
207
+ text-align: left;
208
+ }
209
+ }
@@ -0,0 +1,5 @@
1
+ //#region src/tailwind-preset.d.ts
2
+ declare const awesomenessPreset: import("tailwindcss/plugin").PluginWithConfig;
3
+ //#endregion
4
+ export { awesomenessPreset as default };
5
+ //# sourceMappingURL=tailwind-preset.d.ts.map
@@ -0,0 +1,13 @@
1
+ import plugin from "tailwindcss/plugin";
2
+ //#region src/tailwind-preset.ts
3
+ const awesomenessPreset = plugin(({ addUtilities, addVariant }) => {
4
+ addVariant("dark", "&:is(.dark *)");
5
+ addUtilities({
6
+ ".icon-inline-end": { marginInlineStart: "0.375rem" },
7
+ ".icon-inline-start": { marginInlineEnd: "0.375rem" }
8
+ });
9
+ });
10
+ //#endregion
11
+ export { awesomenessPreset as default };
12
+
13
+ //# sourceMappingURL=tailwind-preset.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"tailwind-preset.js","names":[],"sources":["../src/tailwind-preset.ts"],"sourcesContent":["import plugin from \"tailwindcss/plugin\";\n\nconst awesomenessPreset = plugin(({ addUtilities, addVariant }) => {\n addVariant(\"dark\", \"&:is(.dark *)\");\n\n addUtilities({\n \".icon-inline-end\": { marginInlineStart: \"0.375rem\" },\n \".icon-inline-start\": { marginInlineEnd: \"0.375rem\" },\n });\n});\n\nexport default awesomenessPreset;\n"],"mappings":";;AAEA,MAAM,oBAAoB,QAAQ,EAAE,cAAc,iBAAiB;CACjE,WAAW,QAAQ,eAAe;CAElC,aAAa;EACX,oBAAoB,EAAE,mBAAmB,WAAW;EACpD,sBAAsB,EAAE,iBAAiB,WAAW;CACtD,CAAC;AACH,CAAC"}
package/package.json ADDED
@@ -0,0 +1,81 @@
1
+ {
2
+ "name": "astro-awesomeness",
3
+ "version": "0.1.0",
4
+ "description": "Astro 5 blog theme with shadcn primitives and a Tailwind v4 preset.",
5
+ "keywords": [
6
+ "astro",
7
+ "astro-theme",
8
+ "blog",
9
+ "shadcn",
10
+ "tailwindcss"
11
+ ],
12
+ "license": "MIT",
13
+ "files": [
14
+ "dist",
15
+ "src/astro",
16
+ "src/layouts"
17
+ ],
18
+ "type": "module",
19
+ "exports": {
20
+ ".": {
21
+ "types": "./dist/index.d.ts",
22
+ "import": "./dist/index.js"
23
+ },
24
+ "./components": {
25
+ "types": "./dist/components/index.d.ts",
26
+ "import": "./dist/components/index.js"
27
+ },
28
+ "./astro/*": "./src/astro/*.astro",
29
+ "./layouts/*": "./src/layouts/*.astro",
30
+ "./content": {
31
+ "types": "./dist/content/index.d.ts",
32
+ "import": "./dist/content/index.js"
33
+ },
34
+ "./lib": {
35
+ "types": "./dist/lib/index.d.ts",
36
+ "import": "./dist/lib/index.js"
37
+ },
38
+ "./tailwind": {
39
+ "types": "./dist/tailwind-preset.d.ts",
40
+ "import": "./dist/tailwind-preset.js"
41
+ },
42
+ "./styles.css": "./dist/styles/globals.css"
43
+ },
44
+ "dependencies": {
45
+ "class-variance-authority": "^0.7.1",
46
+ "clsx": "^2.1.1",
47
+ "cmdk": "^1.1.1",
48
+ "lucide-react": "^1.14.0",
49
+ "radix-ui": "^1.4.3",
50
+ "tailwind-merge": "^3.6.0",
51
+ "tw-animate-css": "^1.4.0",
52
+ "zod": "^3.24.0"
53
+ },
54
+ "devDependencies": {
55
+ "@tailwindcss/postcss": "^4.3.0",
56
+ "@types/node": "^24.0.0",
57
+ "@types/react": "^19.2.14",
58
+ "@types/react-dom": "^19.2.0",
59
+ "@vitest/coverage-v8": "^4.1.5",
60
+ "happy-dom": "^15.11.0",
61
+ "tailwindcss": "^4.3.0",
62
+ "typescript": "^6.0.3",
63
+ "vitest": "^4.1.5",
64
+ "@repo/typescript-config": "0.0.0"
65
+ },
66
+ "peerDependencies": {
67
+ "astro": "^5.0.0",
68
+ "react": "^19.0.0",
69
+ "react-dom": "^19.0.0",
70
+ "tailwindcss": "^4.0.0"
71
+ },
72
+ "scripts": {
73
+ "dev": "tsdown --watch",
74
+ "build": "tsdown",
75
+ "typecheck": "tsc --noEmit",
76
+ "lint": "oxlint src",
77
+ "test": "vitest run",
78
+ "test:coverage": "vitest run --coverage",
79
+ "clean": "rm -rf .turbo dist node_modules"
80
+ }
81
+ }
@@ -0,0 +1,14 @@
1
+ ---
2
+ import { Code } from "astro:components";
3
+
4
+ type Props = {
5
+ code: string;
6
+ lang?: string;
7
+ class?: string;
8
+ };
9
+
10
+ const { code, lang = "ts", class: className = "" } = Astro.props;
11
+ ---
12
+ <div class:list={["bg-muted border-border overflow-hidden rounded-md border", className]}>
13
+ <Code code={code} lang={lang} theme="github-dark" />
14
+ </div>
@@ -0,0 +1,15 @@
1
+ ---
2
+ type Props = {
3
+ siteTitle: string;
4
+ class?: string;
5
+ };
6
+
7
+ const { siteTitle, class: className = "" } = Astro.props;
8
+ const year = new Date().getFullYear();
9
+ ---
10
+ <footer class:list={["border-border text-muted-foreground mt-16 border-t py-6 text-sm", className]}>
11
+ <div class="mx-auto flex max-w-3xl flex-col items-center justify-between gap-2 px-4 sm:flex-row">
12
+ <p>© {year} {siteTitle}</p>
13
+ <slot name="links" />
14
+ </div>
15
+ </footer>
@@ -0,0 +1,14 @@
1
+ ---
2
+ import { formatDate } from "../lib/format-date";
3
+
4
+ type Props = {
5
+ date: Date;
6
+ locale?: string;
7
+ class?: string;
8
+ };
9
+
10
+ const { date, locale = "en-US", class: className = "" } = Astro.props;
11
+ const iso = date.toISOString();
12
+ const formatted = formatDate(date, locale);
13
+ ---
14
+ <time datetime={iso} class={className}>{formatted}</time>
@@ -0,0 +1,22 @@
1
+ ---
2
+ import { ThemeToggle } from "../components/theme-toggle";
3
+
4
+ type Props = {
5
+ siteTitle: string;
6
+ homeHref?: string;
7
+ class?: string;
8
+ };
9
+
10
+ const { siteTitle, homeHref = "/", class: className = "" } = Astro.props;
11
+ ---
12
+ <header class:list={["border-border bg-background/80 sticky top-0 z-40 border-b backdrop-blur", className]}>
13
+ <div class="mx-auto flex max-w-3xl items-center justify-between px-4 py-3">
14
+ <a href={homeHref} class="text-foreground text-base font-semibold tracking-tight">
15
+ {siteTitle}
16
+ </a>
17
+ <nav class="flex items-center gap-2">
18
+ <slot name="nav" />
19
+ <ThemeToggle client:load />
20
+ </nav>
21
+ </div>
22
+ </header>
@@ -0,0 +1,35 @@
1
+ ---
2
+ type Props = {
3
+ currentPage: number;
4
+ lastPage: number;
5
+ urlForPage: (page: number) => string;
6
+ class?: string;
7
+ };
8
+
9
+ const { currentPage, lastPage, urlForPage, class: className = "" } = Astro.props;
10
+ const hasPrev = currentPage > 1;
11
+ const hasNext = currentPage < lastPage;
12
+ ---
13
+ <nav class:list={["mt-8 flex items-center justify-between", className]} aria-label="Pagination">
14
+ {hasPrev ? (
15
+ <a
16
+ href={urlForPage(currentPage - 1)}
17
+ class="border-border hover:bg-muted inline-flex items-center rounded-md border px-3 py-1.5 text-sm transition-colors"
18
+ rel="prev"
19
+ >
20
+ ← Previous
21
+ </a>
22
+ ) : <span aria-hidden="true" />}
23
+ <span class="text-muted-foreground text-sm">
24
+ Page {currentPage} of {lastPage}
25
+ </span>
26
+ {hasNext ? (
27
+ <a
28
+ href={urlForPage(currentPage + 1)}
29
+ class="border-border hover:bg-muted inline-flex items-center rounded-md border px-3 py-1.5 text-sm transition-colors"
30
+ rel="next"
31
+ >
32
+ Next →
33
+ </a>
34
+ ) : <span aria-hidden="true" />}
35
+ </nav>
@@ -0,0 +1,28 @@
1
+ ---
2
+ import type { Post } from "../content/index";
3
+
4
+ import FormattedDate from "./formatted-date.astro";
5
+ import TagList from "./tag-list.astro";
6
+
7
+ type PostEntry = { id: string; data: Post };
8
+ type Props = {
9
+ post: PostEntry;
10
+ locale?: string;
11
+ };
12
+
13
+ const { post, locale } = Astro.props;
14
+ const { title, description, pubDate, tags } = post.data;
15
+ const href = `/blog/${post.id}`;
16
+ ---
17
+ <article class="border-border group border-b py-6 last:border-0">
18
+ <a href={href} class="block">
19
+ <h2 class="text-foreground group-hover:text-accent-foreground text-xl font-semibold tracking-tight transition-colors">
20
+ {title}
21
+ </h2>
22
+ <div class="text-muted-foreground mt-1 flex items-center gap-2 text-sm">
23
+ <FormattedDate date={pubDate} locale={locale} />
24
+ </div>
25
+ <p class="text-muted-foreground mt-2 line-clamp-2 text-sm">{description}</p>
26
+ </a>
27
+ {tags.length > 0 && <TagList tags={tags} class="mt-3" />}
28
+ </article>
@@ -0,0 +1,10 @@
1
+ ---
2
+ type Props = {
3
+ class?: string;
4
+ };
5
+
6
+ const { class: className = "" } = Astro.props;
7
+ ---
8
+ <div class:list={["prose", className]}>
9
+ <slot />
10
+ </div>
@@ -0,0 +1,12 @@
1
+ ---
2
+ import { readingTime } from "../lib/reading-time";
3
+
4
+ type Props = {
5
+ text: string;
6
+ class?: string;
7
+ };
8
+
9
+ const { text, class: className = "" } = Astro.props;
10
+ const { minutes } = readingTime(text);
11
+ ---
12
+ <span class={className}>{minutes} min read</span>
@@ -0,0 +1,26 @@
1
+ ---
2
+ type Props = {
3
+ title: string;
4
+ description: string;
5
+ canonical?: string;
6
+ image?: string;
7
+ type?: "website" | "article";
8
+ };
9
+
10
+ const { title, description, canonical, image, type = "website" } = Astro.props;
11
+ const url = canonical ?? new URL(Astro.url.pathname, Astro.site ?? Astro.url.origin).toString();
12
+ ---
13
+ <title>{title}</title>
14
+ <meta name="description" content={description} />
15
+ <link rel="canonical" href={url} />
16
+
17
+ <meta property="og:type" content={type} />
18
+ <meta property="og:title" content={title} />
19
+ <meta property="og:description" content={description} />
20
+ <meta property="og:url" content={url} />
21
+ {image && <meta property="og:image" content={image} />}
22
+
23
+ <meta name="twitter:card" content={image ? "summary_large_image" : "summary"} />
24
+ <meta name="twitter:title" content={title} />
25
+ <meta name="twitter:description" content={description} />
26
+ {image && <meta name="twitter:image" content={image} />}
@@ -0,0 +1,35 @@
1
+ ---
2
+ import { slugify } from "../lib/slugify";
3
+
4
+ type Heading = { depth: number; text: string; slug: string };
5
+
6
+ type Props = {
7
+ headings: Array<{ depth: number; text: string; slug?: string }>;
8
+ minDepth?: number;
9
+ maxDepth?: number;
10
+ class?: string;
11
+ };
12
+
13
+ const { headings, minDepth = 2, maxDepth = 3, class: className = "" } = Astro.props;
14
+
15
+ const visible: Array<Heading> = headings
16
+ .filter((h) => h.depth >= minDepth && h.depth <= maxDepth)
17
+ .map((h) => ({ depth: h.depth, text: h.text, slug: h.slug ?? slugify(h.text) }));
18
+ ---
19
+ {visible.length > 0 && (
20
+ <nav class:list={["text-sm", className]} aria-label="Table of contents">
21
+ <p class="text-muted-foreground mb-2 font-medium tracking-wide uppercase">On this page</p>
22
+ <ul class="space-y-1">
23
+ {visible.map((heading) => (
24
+ <li class:list={["leading-snug", heading.depth === 3 && "pl-4"]}>
25
+ <a
26
+ href={`#${heading.slug}`}
27
+ class="text-muted-foreground hover:text-foreground block transition-colors"
28
+ >
29
+ {heading.text}
30
+ </a>
31
+ </li>
32
+ ))}
33
+ </ul>
34
+ </nav>
35
+ )}
@@ -0,0 +1,23 @@
1
+ ---
2
+ import { slugify } from "../lib/slugify";
3
+
4
+ type Props = {
5
+ tags: Array<string>;
6
+ base?: string;
7
+ class?: string;
8
+ };
9
+
10
+ const { tags, base = "/tags", class: className = "" } = Astro.props;
11
+ ---
12
+ <ul class:list={["flex flex-wrap gap-2", className]}>
13
+ {tags.map((tag) => (
14
+ <li>
15
+ <a
16
+ href={`${base}/${slugify(tag)}`}
17
+ class="border-border bg-muted text-muted-foreground hover:bg-accent hover:text-accent-foreground inline-flex items-center rounded-md border px-2 py-0.5 text-xs transition-colors"
18
+ >
19
+ {tag}
20
+ </a>
21
+ </li>
22
+ ))}
23
+ </ul>
@@ -0,0 +1,50 @@
1
+ ---
2
+ import Footer from "../astro/footer.astro";
3
+ import Header from "../astro/header.astro";
4
+ import Seo from "../astro/seo.astro";
5
+
6
+ type Props = {
7
+ title: string;
8
+ description: string;
9
+ canonical?: string;
10
+ image?: string;
11
+ type?: "website" | "article";
12
+ lang?: string;
13
+ siteTitle?: string;
14
+ };
15
+
16
+ const {
17
+ title,
18
+ description,
19
+ canonical,
20
+ image,
21
+ type = "website",
22
+ lang = "en",
23
+ siteTitle,
24
+ } = Astro.props;
25
+ ---
26
+ <!doctype html>
27
+ <html lang={lang}>
28
+ <head>
29
+ <meta charset="utf-8" />
30
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
31
+ <meta name="generator" content={Astro.generator} />
32
+ <Seo title={title} description={description} canonical={canonical} image={image} type={type} />
33
+ <script is:inline>
34
+ (() => {
35
+ const stored = localStorage.getItem("theme");
36
+ const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
37
+ const theme = stored === "dark" || stored === "light" ? stored : prefersDark ? "dark" : "light";
38
+ document.documentElement.classList.toggle("dark", theme === "dark");
39
+ })();
40
+ </script>
41
+ <slot name="head" />
42
+ </head>
43
+ <body class="bg-background text-foreground min-h-dvh flex flex-col">
44
+ {siteTitle && <Header siteTitle={siteTitle} />}
45
+ <div class="flex-1">
46
+ <slot />
47
+ </div>
48
+ {siteTitle && <Footer siteTitle={siteTitle} />}
49
+ </body>
50
+ </html>