@vdaluz/astro-blog 0.9.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/LICENSE +21 -0
- package/README.md +236 -0
- package/package.json +59 -0
- package/src/components/BlogPostMeta.astro +23 -0
- package/src/components/HeroImageCredit.astro +33 -0
- package/src/components/Pagination.astro +155 -0
- package/src/components/PostCard.astro +72 -0
- package/src/components/RelatedPosts.astro +80 -0
- package/src/components/Subheading.astro +28 -0
- package/src/components/TableOfContents.astro +59 -0
- package/src/components/TagFilterNav.astro +38 -0
- package/src/index.ts +11 -0
- package/src/lib/filterPosts.ts +18 -0
- package/src/lib/i18n.ts +66 -0
- package/src/lib/relatedPosts.ts +55 -0
- package/src/lib/remark-reading-time.ts +49 -0
- package/src/lib/rss.ts +34 -0
- package/src/lib/schema.ts +83 -0
- package/src/lib/shiki.ts +18 -0
- package/src/lib/types.ts +35 -0
- package/src/styles/tokens.example.css +71 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Victor Da Luz
|
|
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,236 @@
|
|
|
1
|
+
# @vdaluz/astro-blog
|
|
2
|
+
|
|
3
|
+
[](https://github.com/vdaluz/astro-blog/actions/workflows/ci.yml)
|
|
4
|
+
|
|
5
|
+
A blog needs a listing page, pagination, related posts, tag filters, JSON-LD, RSS, and a Shiki-highlighted code theme - most of it undifferentiated work you rebuild every time you spin up an Astro site. `@vdaluz/astro-blog` packages that layer as token-driven components, so styling comes from your own CSS custom properties, not a hardcoded palette. Ships raw `.astro` and `.ts` - the consuming app's Astro/Vite compiles them (no prebuild step). Built for and proven in production across two sites, [vdaluz.com](https://vdaluz.com) and [imperfectsystems.com](https://imperfectsystems.com) - see [Consumers](#consumers).
|
|
6
|
+
|
|
7
|
+
> **Scope:** this is a component library, not a drop-in blog. Routes (`src/pages/blog/*`) and content (`src/content/blog/*.md`) stay in each app - see [Per-app glue](#per-app-glue).
|
|
8
|
+
|
|
9
|
+
## Install
|
|
10
|
+
|
|
11
|
+
Pinned https tarball from a tag (no registry needed):
|
|
12
|
+
|
|
13
|
+
```jsonc
|
|
14
|
+
// package.json
|
|
15
|
+
"dependencies": {
|
|
16
|
+
"@vdaluz/astro-blog": "https://github.com/vdaluz/astro-blog/archive/refs/tags/v0.7.0.tar.gz"
|
|
17
|
+
}
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
> **Why a tarball, not `github:vdaluz/astro-blog#v0.1.0`?** npm canonicalizes GitHub
|
|
21
|
+
> shorthand (and even an explicit `git+https://` URL) to `git+ssh://` in the lockfile.
|
|
22
|
+
> CI runners (e.g. Cloudflare Pages/Workers) have no SSH key, so `npm ci` would fail to
|
|
23
|
+
> clone it. The `/archive/refs/tags/<tag>.tar.gz` URL is anonymous https with an integrity
|
|
24
|
+
> hash in the lockfile - it just works in CI. Bump the tag in the URL to upgrade. This is the
|
|
25
|
+
> only supported install path; there's no npm registry package (tag-tarball works for anyone,
|
|
26
|
+
> no registry auth needed).
|
|
27
|
+
|
|
28
|
+
Peer dependency: `astro` >= 6. For post body styling you'll also want `@tailwindcss/typography` in the app.
|
|
29
|
+
|
|
30
|
+
## Four things every consumer MUST do
|
|
31
|
+
|
|
32
|
+
1. **Define the token CSS variables.** Components reference only these names:
|
|
33
|
+
`bg`, `surface`, `surface-muted`, `fg`, `muted`, `border`, `accent`, `accent-strong`, `accent-soft`, `on-accent`.
|
|
34
|
+
Copy `src/styles/tokens.example.css` into your app and set your palette.
|
|
35
|
+
|
|
36
|
+
2. **Alias the tokens in `tailwind.config.mjs` AND scan the package** (this glob is the #1 thing people forget - without it the package's utility classes are never generated):
|
|
37
|
+
|
|
38
|
+
```js
|
|
39
|
+
export default {
|
|
40
|
+
content: [
|
|
41
|
+
'./src/**/*.{astro,html,js,jsx,md,mdx,svelte,ts,tsx,vue}',
|
|
42
|
+
'./node_modules/@vdaluz/astro-blog/**/*.{astro,ts}', // <-- required
|
|
43
|
+
],
|
|
44
|
+
theme: {
|
|
45
|
+
extend: {
|
|
46
|
+
colors: {
|
|
47
|
+
bg: 'rgb(var(--bg) / <alpha-value>)',
|
|
48
|
+
surface: 'rgb(var(--surface) / <alpha-value>)',
|
|
49
|
+
'surface-muted': 'rgb(var(--surface-muted) / <alpha-value>)',
|
|
50
|
+
fg: 'rgb(var(--fg) / <alpha-value>)',
|
|
51
|
+
muted: 'rgb(var(--muted) / <alpha-value>)',
|
|
52
|
+
border: 'rgb(var(--border) / <alpha-value>)',
|
|
53
|
+
accent: 'rgb(var(--accent) / <alpha-value>)',
|
|
54
|
+
'accent-strong': 'rgb(var(--accent-strong) / <alpha-value>)',
|
|
55
|
+
'accent-soft': 'rgb(var(--accent-soft) / <alpha-value>)',
|
|
56
|
+
'on-accent': 'rgb(var(--on-accent) / <alpha-value>)',
|
|
57
|
+
},
|
|
58
|
+
},
|
|
59
|
+
},
|
|
60
|
+
plugins: [require('@tailwindcss/typography')],
|
|
61
|
+
};
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
3. **Wire Shiki** in `astro.config.mjs` and ship the matching CSS handoff (included in `tokens.example.css`):
|
|
65
|
+
|
|
66
|
+
```js
|
|
67
|
+
import { shikiConfig } from '@vdaluz/astro-blog';
|
|
68
|
+
export default defineConfig({ markdown: { shikiConfig } });
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
The `shikiConfig` uses `defaultColor: false`, so the CSS handoff is what actually colors code blocks. They must ship together. **Dark-only sites:** keep `shikiConfig` and force `<html class="dark">` so the dark vars always apply.
|
|
72
|
+
|
|
73
|
+
4. **Generate a matching `.webp` sibling for every `heroImage`.** `PostCard` and `RelatedPosts` derive the thumbnail `src` by swapping the `heroImage` extension (`.jpg`/`.jpeg`/`.png`/`.gif`) for `.webp` - they never render the raw file. If a post sets `heroImage: /assets/images/foo.jpeg`, `assets/images/foo.webp` must exist at that same path or the thumbnail 404s. Any image pipeline that outputs a same-basename `.webp` next to the original works (e.g. a Sharp-based build step); nothing in this package generates it for you.
|
|
74
|
+
|
|
75
|
+
## Example
|
|
76
|
+
|
|
77
|
+
`PostCard` rendering real posts on vdaluz.com's `/blog` listing:
|
|
78
|
+
|
|
79
|
+

|
|
80
|
+
|
|
81
|
+
## Exports
|
|
82
|
+
|
|
83
|
+
| Import | What |
|
|
84
|
+
| --- | --- |
|
|
85
|
+
| `@vdaluz/astro-blog` | `blogSchema`, `buildBlogPostingSchema`, `scoreRelated`, `normalizeTag`, `filterPostsByTag`, `shikiConfig`, `buildRssItems`, `t`, `formatDate`, types |
|
|
86
|
+
| `@vdaluz/astro-blog/PostCard.astro` | Post card for listings |
|
|
87
|
+
| `@vdaluz/astro-blog/RelatedPosts.astro` | Related-posts grid |
|
|
88
|
+
| `@vdaluz/astro-blog/Pagination.astro` | Paginated listing nav |
|
|
89
|
+
| `@vdaluz/astro-blog/Subheading.astro` | Small uppercase section label |
|
|
90
|
+
| `@vdaluz/astro-blog/BlogPostMeta.astro` | JSON-LD BlogPosting `<script>` |
|
|
91
|
+
| `@vdaluz/astro-blog/HeroImageCredit.astro` | Photographer/source/license attribution line for a post's `heroImageCredit` |
|
|
92
|
+
| `@vdaluz/astro-blog/TagFilterNav.astro` | Filter chip nav (e.g. by project or topic tag) |
|
|
93
|
+
| `@vdaluz/astro-blog/TableOfContents.astro` | "On this page" nav from a post's `headings` array (sticky sidebar on desktop, `<details>` on mobile) |
|
|
94
|
+
| `@vdaluz/astro-blog/remark` | `remarkReadingTime` - writes `minutesRead` to the page's frontmatter |
|
|
95
|
+
|
|
96
|
+
Components that build post URLs (`PostCard`, `RelatedPosts`, `Pagination`) accept an optional `base` prop (default `/blog`).
|
|
97
|
+
|
|
98
|
+
`PostCard`, `RelatedPosts`, `Pagination`, and `BlogPostMeta` accept an optional `locale` prop (`'en' | 'es'`, default `'en'`) that localizes their built-in UI strings (dates, "Read More", pagination labels) and `BlogPostMeta`'s JSON-LD `inLanguage` field. It does not affect the post URLs those components build - a locale-specific `base` still needs passing separately if the consuming app routes translated posts under a different prefix (e.g. `/es/blog`).
|
|
99
|
+
|
|
100
|
+
`PostCard` accepts an optional `categoryLabel` prop to override the category badge text (default `post.data.category`). `RelatedPosts` accepts the same override as a `(post) => string` function, since it renders a badge per post. Use these when `category` is a canonical/English taxonomy value that the consuming app translates for display - the package has no built-in category translation since the taxonomy itself is app-defined.
|
|
101
|
+
|
|
102
|
+
### Hero image attribution
|
|
103
|
+
|
|
104
|
+
`blogSchema()` validates an optional `heroImageCredit` field (`name`, `url`, `source: 'pexels' | 'unsplash' | 'openverse'`, optional `licenseName`/`licenseUrl`) for posts whose hero image needs attribution - required for CC/attribution-required sources like Openverse, not just polite. Render it with `HeroImageCredit`:
|
|
105
|
+
|
|
106
|
+
```astro
|
|
107
|
+
---
|
|
108
|
+
import HeroImageCredit from '@vdaluz/astro-blog/HeroImageCredit.astro';
|
|
109
|
+
---
|
|
110
|
+
|
|
111
|
+
{entry.data.heroImageCredit && (
|
|
112
|
+
<HeroImageCredit credit={entry.data.heroImageCredit} locale={locale} />
|
|
113
|
+
)}
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
### Updated dates
|
|
117
|
+
|
|
118
|
+
Set `updatedDate` in a post's frontmatter when you substantively edit it after publishing. `buildBlogPostingSchema` uses it for the JSON-LD `dateModified` field, falling back to `pubDate` when unset - so an edited post can signal freshness without every post needing the field.
|
|
119
|
+
|
|
120
|
+
### Table of contents + reading time
|
|
121
|
+
|
|
122
|
+
`TableOfContents` reads the `headings` array Astro's own `render()` already returns - no separate parsing step. It renders nothing if the post has fewer than `minHeadings` (default 3) h2/h3 headings.
|
|
123
|
+
|
|
124
|
+
```js
|
|
125
|
+
// astro.config.mjs
|
|
126
|
+
import { remarkReadingTime } from '@vdaluz/astro-blog/remark';
|
|
127
|
+
export default defineConfig({ markdown: { remarkPlugins: [remarkReadingTime] } });
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
```astro
|
|
131
|
+
---
|
|
132
|
+
import TableOfContents from '@vdaluz/astro-blog/TableOfContents.astro';
|
|
133
|
+
import { t } from '@vdaluz/astro-blog';
|
|
134
|
+
|
|
135
|
+
const { Content, headings, remarkPluginFrontmatter } = await render(entry);
|
|
136
|
+
const strings = t(locale);
|
|
137
|
+
---
|
|
138
|
+
|
|
139
|
+
<span>{strings.minRead(remarkPluginFrontmatter.minutesRead)}</span>
|
|
140
|
+
<TableOfContents headings={headings} locale={locale} />
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
`TableOfContents` anchors each entry to `#<slug>` - the consuming app's markdown-to-HTML pipeline must emit matching `id` attributes on the rendered `<h2>`/`<h3>` tags (Astro/rehype does this by default; verify if a custom rehype config strips heading ids).
|
|
144
|
+
|
|
145
|
+
## Per-app glue
|
|
146
|
+
|
|
147
|
+
Each site keeps these - they can't be packaged because they bind to the app's own collection and routes.
|
|
148
|
+
|
|
149
|
+
`src/content.config.ts`:
|
|
150
|
+
|
|
151
|
+
```ts
|
|
152
|
+
import { defineCollection } from 'astro:content';
|
|
153
|
+
import { glob } from 'astro/loaders';
|
|
154
|
+
import { blogSchema } from '@vdaluz/astro-blog';
|
|
155
|
+
|
|
156
|
+
const blog = defineCollection({
|
|
157
|
+
loader: glob({ pattern: '**/*.md', base: './src/content/blog' }),
|
|
158
|
+
schema: blogSchema({ defaultAuthor: 'Your Name' }),
|
|
159
|
+
});
|
|
160
|
+
export const collections = { blog };
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
`src/pages/blog/[...page].astro` and `[...slug].astro`: do `getCollection` / `getStaticPaths` / `render()` in-app (tied to your collection), then render the package components. Keep `export const prerender = true`. Site-specific headings and CTAs live here.
|
|
164
|
+
|
|
165
|
+
Related posts:
|
|
166
|
+
|
|
167
|
+
```ts
|
|
168
|
+
import { scoreRelated } from '@vdaluz/astro-blog';
|
|
169
|
+
const related = scoreRelated(entry, allPosts, { k: 3, aliases: { ha: 'home-assistant' } });
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
Tag/project filtering: the route (`src/pages/blog/tag/[tag].astro` or similar) is per-app since URL
|
|
173
|
+
shape and how you build the tag list are site-specific. `filterPostsByTag` + `Pagination` do the rest -
|
|
174
|
+
`Pagination`'s `base` prop already works with any route prefix, no changes needed for a filtered route:
|
|
175
|
+
|
|
176
|
+
```ts
|
|
177
|
+
import { filterPostsByTag } from '@vdaluz/astro-blog';
|
|
178
|
+
|
|
179
|
+
export const getStaticPaths: GetStaticPaths = async ({ paginate }) => {
|
|
180
|
+
const all = await getCollection('blog');
|
|
181
|
+
const filtered = filterPostsByTag(all, 'homelab').sort(
|
|
182
|
+
(a, b) => b.data.pubDate.getTime() - a.data.pubDate.getTime()
|
|
183
|
+
);
|
|
184
|
+
return paginate(filtered, { pageSize: 5 });
|
|
185
|
+
};
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
```astro
|
|
189
|
+
<Pagination page={page} base="/blog/tag/homelab" />
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
`TagFilterNav` renders the filter chips themselves - build the `options` array (label, href, whether
|
|
193
|
+
it's the active filter) from whatever tag list your app tracks:
|
|
194
|
+
|
|
195
|
+
```astro
|
|
196
|
+
<TagFilterNav
|
|
197
|
+
options={[
|
|
198
|
+
{ label: 'All', href: '/blog', active: !tag },
|
|
199
|
+
{ label: 'Homelab', href: '/blog/tag/homelab', active: tag === 'homelab' },
|
|
200
|
+
]}
|
|
201
|
+
/>
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
RSS feed (`src/pages/rss.xml.ts`, needs the app's own `@astrojs/rss` dependency - this package doesn't ship it):
|
|
205
|
+
|
|
206
|
+
```ts
|
|
207
|
+
import rss from '@astrojs/rss';
|
|
208
|
+
import type { APIRoute } from 'astro';
|
|
209
|
+
import { getCollection } from 'astro:content';
|
|
210
|
+
import { buildRssItems } from '@vdaluz/astro-blog';
|
|
211
|
+
|
|
212
|
+
export const prerender = true;
|
|
213
|
+
|
|
214
|
+
export const GET: APIRoute = async (context) => {
|
|
215
|
+
const now = new Date();
|
|
216
|
+
const posts = (await getCollection('blog'))
|
|
217
|
+
.filter((p) => p.data.pubDate <= now)
|
|
218
|
+
.sort((a, b) => b.data.pubDate.getTime() - a.data.pubDate.getTime());
|
|
219
|
+
|
|
220
|
+
return rss({
|
|
221
|
+
title: 'Your Site - Blog',
|
|
222
|
+
description: 'Your site description',
|
|
223
|
+
site: context.site!,
|
|
224
|
+
items: buildRssItems(posts),
|
|
225
|
+
});
|
|
226
|
+
};
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
## Contributing
|
|
230
|
+
|
|
231
|
+
Issues welcome. PRs by discussion - open an issue first for anything beyond a typo or docs fix.
|
|
232
|
+
|
|
233
|
+
## Consumers
|
|
234
|
+
|
|
235
|
+
- [vdaluz.com](https://vdaluz.com)
|
|
236
|
+
- [imperfectsystems.com](https://imperfectsystems.com)
|
package/package.json
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@vdaluz/astro-blog",
|
|
3
|
+
"version": "0.9.0",
|
|
4
|
+
"description": "Token-driven Astro blog components, related-posts scoring, a schema factory, and Shiki config - proven in production on vdaluz.com and imperfectsystems.com.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"astro",
|
|
7
|
+
"astro-component",
|
|
8
|
+
"blog",
|
|
9
|
+
"blog-components",
|
|
10
|
+
"related-posts",
|
|
11
|
+
"rss",
|
|
12
|
+
"shiki",
|
|
13
|
+
"json-ld"
|
|
14
|
+
],
|
|
15
|
+
"homepage": "https://github.com/vdaluz/astro-blog#readme",
|
|
16
|
+
"bugs": {
|
|
17
|
+
"url": "https://github.com/vdaluz/astro-blog/issues"
|
|
18
|
+
},
|
|
19
|
+
"repository": {
|
|
20
|
+
"type": "git",
|
|
21
|
+
"url": "git+https://github.com/vdaluz/astro-blog.git"
|
|
22
|
+
},
|
|
23
|
+
"license": "MIT",
|
|
24
|
+
"type": "module",
|
|
25
|
+
"sideEffects": false,
|
|
26
|
+
"publishConfig": {
|
|
27
|
+
"access": "public"
|
|
28
|
+
},
|
|
29
|
+
"scripts": {
|
|
30
|
+
"check": "astro check",
|
|
31
|
+
"test": "node --test"
|
|
32
|
+
},
|
|
33
|
+
"files": [
|
|
34
|
+
"src"
|
|
35
|
+
],
|
|
36
|
+
"exports": {
|
|
37
|
+
".": "./src/index.ts",
|
|
38
|
+
"./shiki": "./src/lib/shiki.ts",
|
|
39
|
+
"./PostCard.astro": "./src/components/PostCard.astro",
|
|
40
|
+
"./RelatedPosts.astro": "./src/components/RelatedPosts.astro",
|
|
41
|
+
"./Pagination.astro": "./src/components/Pagination.astro",
|
|
42
|
+
"./Subheading.astro": "./src/components/Subheading.astro",
|
|
43
|
+
"./BlogPostMeta.astro": "./src/components/BlogPostMeta.astro",
|
|
44
|
+
"./HeroImageCredit.astro": "./src/components/HeroImageCredit.astro",
|
|
45
|
+
"./TagFilterNav.astro": "./src/components/TagFilterNav.astro",
|
|
46
|
+
"./TableOfContents.astro": "./src/components/TableOfContents.astro",
|
|
47
|
+
"./remark": "./src/lib/remark-reading-time.ts",
|
|
48
|
+
"./styles/tokens.example.css": "./src/styles/tokens.example.css"
|
|
49
|
+
},
|
|
50
|
+
"peerDependencies": {
|
|
51
|
+
"astro": ">=6.0.0"
|
|
52
|
+
},
|
|
53
|
+
"devDependencies": {
|
|
54
|
+
"@astrojs/check": "^0.9.9",
|
|
55
|
+
"@types/node": "^26.1.1",
|
|
56
|
+
"astro": "^7.1.3",
|
|
57
|
+
"typescript": "^6.0.3"
|
|
58
|
+
}
|
|
59
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
---
|
|
2
|
+
import { buildBlogPostingSchema } from '../lib/schema';
|
|
3
|
+
import type { BlogPostLike } from '../lib/types';
|
|
4
|
+
import type { Locale } from '../lib/i18n';
|
|
5
|
+
|
|
6
|
+
interface Props {
|
|
7
|
+
post: BlogPostLike;
|
|
8
|
+
/** Origin only, e.g. "https://imperfectsystems.com". */
|
|
9
|
+
siteUrl: string;
|
|
10
|
+
/** Route prefix posts live under. Defaults to "/blog". */
|
|
11
|
+
basePath?: string;
|
|
12
|
+
/** Site/brand name for JSON-LD publisher. Falls back to the post author. */
|
|
13
|
+
publisherName?: string;
|
|
14
|
+
/** Locale for the JSON-LD `inLanguage` field. Omitted if unset. */
|
|
15
|
+
locale?: Locale;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const { post, siteUrl, basePath = '/blog', publisherName, locale } = Astro.props;
|
|
19
|
+
const schema = buildBlogPostingSchema({ post, siteUrl, basePath, publisherName, locale });
|
|
20
|
+
---
|
|
21
|
+
|
|
22
|
+
{/* JSON-LD is valid in <body>; render this anywhere inside the post page. */}
|
|
23
|
+
<script type="application/ld+json" set:html={JSON.stringify(schema)} />
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
---
|
|
2
|
+
import { t, type Locale } from '../lib/i18n';
|
|
3
|
+
import type { HeroImageCredit as HeroImageCreditData } from '../lib/types';
|
|
4
|
+
|
|
5
|
+
interface Props {
|
|
6
|
+
credit: HeroImageCreditData;
|
|
7
|
+
/** Locale for the credit label text. Defaults to "en". */
|
|
8
|
+
locale?: Locale;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const { credit, locale = 'en' } = Astro.props;
|
|
12
|
+
const strings = t(locale);
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
<p class="text-xs text-muted mt-2">
|
|
16
|
+
{strings.photoCredit}
|
|
17
|
+
{' '}
|
|
18
|
+
<a href={credit.url} class="underline hover:text-accent">{credit.name}</a>
|
|
19
|
+
{' '}
|
|
20
|
+
{strings.via}
|
|
21
|
+
{' '}
|
|
22
|
+
{credit.source}
|
|
23
|
+
{
|
|
24
|
+
credit.licenseUrl && credit.licenseName && (
|
|
25
|
+
<>
|
|
26
|
+
{', '}
|
|
27
|
+
<a href={credit.licenseUrl} class="underline hover:text-accent">
|
|
28
|
+
{credit.licenseName}
|
|
29
|
+
</a>
|
|
30
|
+
</>
|
|
31
|
+
)
|
|
32
|
+
}
|
|
33
|
+
</p>
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
---
|
|
2
|
+
import { t, type Locale } from '../lib/i18n';
|
|
3
|
+
|
|
4
|
+
interface Props {
|
|
5
|
+
page: {
|
|
6
|
+
currentPage: number;
|
|
7
|
+
lastPage: number;
|
|
8
|
+
url: {
|
|
9
|
+
prev?: string;
|
|
10
|
+
next?: string;
|
|
11
|
+
first?: string;
|
|
12
|
+
last?: string;
|
|
13
|
+
};
|
|
14
|
+
};
|
|
15
|
+
/** Route prefix posts live under. Defaults to "/blog". */
|
|
16
|
+
base?: string;
|
|
17
|
+
/** Locale for UI strings. Defaults to "en". */
|
|
18
|
+
locale?: Locale;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const { page, base = '/blog', locale = 'en' } = Astro.props;
|
|
22
|
+
const { currentPage, lastPage, url } = page;
|
|
23
|
+
const strings = t(locale);
|
|
24
|
+
|
|
25
|
+
const getPageNumbers = () => {
|
|
26
|
+
const pages = [];
|
|
27
|
+
const maxVisible = 5;
|
|
28
|
+
|
|
29
|
+
if (lastPage <= maxVisible) {
|
|
30
|
+
for (let i = 1; i <= lastPage; i++) {
|
|
31
|
+
pages.push(i);
|
|
32
|
+
}
|
|
33
|
+
} else {
|
|
34
|
+
let start = Math.max(1, currentPage - 2);
|
|
35
|
+
let end = Math.min(lastPage, start + maxVisible - 1);
|
|
36
|
+
|
|
37
|
+
if (end === lastPage) {
|
|
38
|
+
start = Math.max(1, end - maxVisible + 1);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
for (let i = start; i <= end; i++) {
|
|
42
|
+
pages.push(i);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return pages;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const pageNumbers = getPageNumbers();
|
|
50
|
+
const pageHref = (n: number) => (n === 1 ? base : `${base}/${n}`);
|
|
51
|
+
---
|
|
52
|
+
|
|
53
|
+
<nav class="flex items-center justify-center space-x-2 mt-12" aria-label={strings.blogPagination}>
|
|
54
|
+
{
|
|
55
|
+
url.first ? (
|
|
56
|
+
<a
|
|
57
|
+
href={url.first}
|
|
58
|
+
class="inline-flex items-center justify-center min-w-11 min-h-11 px-3 py-2 text-muted hover:text-accent transition-colors"
|
|
59
|
+
aria-label={strings.goToFirstPage}
|
|
60
|
+
>
|
|
61
|
+
«
|
|
62
|
+
</a>
|
|
63
|
+
) : (
|
|
64
|
+
<span
|
|
65
|
+
aria-hidden="true"
|
|
66
|
+
class="inline-flex items-center justify-center min-w-11 min-h-11 px-3 py-2 text-border cursor-not-allowed"
|
|
67
|
+
>
|
|
68
|
+
«
|
|
69
|
+
</span>
|
|
70
|
+
)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
{
|
|
74
|
+
url.prev ? (
|
|
75
|
+
<a
|
|
76
|
+
href={url.prev}
|
|
77
|
+
class="inline-flex items-center justify-center min-w-11 min-h-11 px-3 py-2 text-muted hover:text-accent transition-colors"
|
|
78
|
+
aria-label={strings.goToPreviousPage}
|
|
79
|
+
>
|
|
80
|
+
‹
|
|
81
|
+
</a>
|
|
82
|
+
) : (
|
|
83
|
+
<span
|
|
84
|
+
aria-hidden="true"
|
|
85
|
+
class="inline-flex items-center justify-center min-w-11 min-h-11 px-3 py-2 text-border cursor-not-allowed"
|
|
86
|
+
>
|
|
87
|
+
‹
|
|
88
|
+
</span>
|
|
89
|
+
)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
{
|
|
93
|
+
pageNumbers.map((pageNum) =>
|
|
94
|
+
pageNum === currentPage ? (
|
|
95
|
+
<span
|
|
96
|
+
class="hidden sm:inline-flex sm:items-center sm:justify-center sm:min-w-11 sm:min-h-11 px-3 py-2 bg-accent text-on-accent rounded-lg font-medium"
|
|
97
|
+
aria-current="page"
|
|
98
|
+
>
|
|
99
|
+
{pageNum}
|
|
100
|
+
</span>
|
|
101
|
+
) : (
|
|
102
|
+
<a
|
|
103
|
+
href={pageHref(pageNum)}
|
|
104
|
+
class="hidden sm:inline-flex sm:items-center sm:justify-center sm:min-w-11 sm:min-h-11 px-3 py-2 text-muted hover:text-accent transition-colors rounded-lg hover:bg-surface-muted"
|
|
105
|
+
>
|
|
106
|
+
{pageNum}
|
|
107
|
+
</a>
|
|
108
|
+
)
|
|
109
|
+
)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
{
|
|
113
|
+
url.next ? (
|
|
114
|
+
<a
|
|
115
|
+
href={url.next}
|
|
116
|
+
class="inline-flex items-center justify-center min-w-11 min-h-11 px-3 py-2 text-muted hover:text-accent transition-colors"
|
|
117
|
+
aria-label={strings.goToNextPage}
|
|
118
|
+
>
|
|
119
|
+
›
|
|
120
|
+
</a>
|
|
121
|
+
) : (
|
|
122
|
+
<span
|
|
123
|
+
aria-hidden="true"
|
|
124
|
+
class="inline-flex items-center justify-center min-w-11 min-h-11 px-3 py-2 text-border cursor-not-allowed"
|
|
125
|
+
>
|
|
126
|
+
›
|
|
127
|
+
</span>
|
|
128
|
+
)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
{
|
|
132
|
+
url.last ? (
|
|
133
|
+
<a
|
|
134
|
+
href={url.last}
|
|
135
|
+
class="inline-flex items-center justify-center min-w-11 min-h-11 px-3 py-2 text-muted hover:text-accent transition-colors"
|
|
136
|
+
aria-label={strings.goToLastPage}
|
|
137
|
+
>
|
|
138
|
+
»
|
|
139
|
+
</a>
|
|
140
|
+
) : (
|
|
141
|
+
<span
|
|
142
|
+
aria-hidden="true"
|
|
143
|
+
class="inline-flex items-center justify-center min-w-11 min-h-11 px-3 py-2 text-border cursor-not-allowed"
|
|
144
|
+
>
|
|
145
|
+
»
|
|
146
|
+
</span>
|
|
147
|
+
)
|
|
148
|
+
}
|
|
149
|
+
</nav>
|
|
150
|
+
|
|
151
|
+
<div class="text-center text-muted mt-4">
|
|
152
|
+
<p class="text-sm">
|
|
153
|
+
{strings.pageOf(currentPage, lastPage)}
|
|
154
|
+
</p>
|
|
155
|
+
</div>
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
---
|
|
2
|
+
import type { BlogPostLike } from '../lib/types';
|
|
3
|
+
import { t, formatDate, type Locale } from '../lib/i18n';
|
|
4
|
+
|
|
5
|
+
interface Props {
|
|
6
|
+
post: BlogPostLike;
|
|
7
|
+
/** Route prefix posts live under. Defaults to "/blog". */
|
|
8
|
+
base?: string;
|
|
9
|
+
/** Locale for date formatting and UI strings. Defaults to "en". */
|
|
10
|
+
locale?: Locale;
|
|
11
|
+
/** Override text for the category badge. Defaults to post.data.category. */
|
|
12
|
+
categoryLabel?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const { post, base = '/blog', locale = 'en', categoryLabel = post.data.category } = Astro.props;
|
|
16
|
+
const href = `${base}/${post.id}`;
|
|
17
|
+
const strings = t(locale);
|
|
18
|
+
// Consumers must generate a matching .webp for every heroImage (see README).
|
|
19
|
+
const heroImageWebp = post.data.heroImage?.replace(/\.(jpe?g|png|gif)$/i, '.webp');
|
|
20
|
+
---
|
|
21
|
+
|
|
22
|
+
<article
|
|
23
|
+
class="bg-surface rounded-xl shadow-lg overflow-hidden hover:shadow-xl transition-all duration-300"
|
|
24
|
+
>
|
|
25
|
+
{
|
|
26
|
+
heroImageWebp && (
|
|
27
|
+
<a href={href}>
|
|
28
|
+
<img
|
|
29
|
+
src={heroImageWebp}
|
|
30
|
+
alt={post.data.title}
|
|
31
|
+
width="800"
|
|
32
|
+
height="192"
|
|
33
|
+
loading="lazy"
|
|
34
|
+
decoding="async"
|
|
35
|
+
class="w-full h-48 object-cover"
|
|
36
|
+
/>
|
|
37
|
+
</a>
|
|
38
|
+
)
|
|
39
|
+
}
|
|
40
|
+
<div class="p-8">
|
|
41
|
+
<div
|
|
42
|
+
class="inline-block px-3 py-1 bg-accent-soft text-accent rounded-full text-sm font-medium mb-4"
|
|
43
|
+
>
|
|
44
|
+
{categoryLabel}
|
|
45
|
+
</div>
|
|
46
|
+
<h2 class="text-2xl md:text-3xl font-bold text-fg mb-4 hover:text-accent transition-colors">
|
|
47
|
+
<a href={href}>{post.data.title}</a>
|
|
48
|
+
</h2>
|
|
49
|
+
<p class="text-muted mb-6 leading-relaxed text-lg">{post.data.description}</p>
|
|
50
|
+
<div class="flex items-center justify-between">
|
|
51
|
+
<time datetime={post.data.pubDate.toISOString()} class="text-sm text-muted">
|
|
52
|
+
{formatDate(post.data.pubDate, locale)}
|
|
53
|
+
</time>
|
|
54
|
+
<a
|
|
55
|
+
href={href}
|
|
56
|
+
class="inline-flex items-center text-accent font-semibold hover:text-accent-strong transition-colors group"
|
|
57
|
+
>
|
|
58
|
+
{strings.readMore}
|
|
59
|
+
<svg
|
|
60
|
+
class="w-4 h-4 ml-1 transform group-hover:translate-x-1 transition-transform duration-300"
|
|
61
|
+
viewBox="0 0 20 20"
|
|
62
|
+
fill="currentColor"
|
|
63
|
+
>
|
|
64
|
+
<path
|
|
65
|
+
fill-rule="evenodd"
|
|
66
|
+
d="M10.293 3.293a1 1 0 011.414 0l6 6a1 1 0 010 1.414l-6 6a1 1 0 01-1.414-1.414L14.586 11H3a1 1 0 110-2h11.586l-4.293-4.293a1 1 0 010-1.414z"
|
|
67
|
+
clip-rule="evenodd"></path>
|
|
68
|
+
</svg>
|
|
69
|
+
</a>
|
|
70
|
+
</div>
|
|
71
|
+
</div>
|
|
72
|
+
</article>
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
---
|
|
2
|
+
import type { BlogPostLike } from '../lib/types';
|
|
3
|
+
import { t, formatDate, type Locale } from '../lib/i18n';
|
|
4
|
+
|
|
5
|
+
interface Props {
|
|
6
|
+
posts: BlogPostLike[];
|
|
7
|
+
/** Route prefix posts live under. Defaults to "/blog". */
|
|
8
|
+
base?: string;
|
|
9
|
+
/** Locale for date formatting and UI strings. Defaults to "en". */
|
|
10
|
+
locale?: Locale;
|
|
11
|
+
/** Override text for each post's category badge. Defaults to post.data.category. */
|
|
12
|
+
categoryLabel?: (post: BlogPostLike) => string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const { posts, base = '/blog', locale = 'en', categoryLabel = (post) => post.data.category } =
|
|
16
|
+
Astro.props;
|
|
17
|
+
const strings = t(locale);
|
|
18
|
+
// Consumers must generate a matching .webp for every heroImage (see README).
|
|
19
|
+
const heroImageWebp = (heroImage?: string) => heroImage?.replace(/\.(jpe?g|png|gif)$/i, '.webp');
|
|
20
|
+
---
|
|
21
|
+
|
|
22
|
+
<section aria-label={strings.relatedReading} class="mt-16 pt-8 border-t border-border">
|
|
23
|
+
<h2 class="text-2xl font-bold text-fg mb-8">{strings.relatedReading}</h2>
|
|
24
|
+
<div class="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
|
25
|
+
{
|
|
26
|
+
posts.map((post) => (
|
|
27
|
+
<article class="bg-surface rounded-xl shadow-[0_1px_3px_0_rgb(0_0_0_/_0.1),0_1px_2px_-1px_rgb(0_0_0_/_0.1)] overflow-hidden hover:shadow-lg transition-all duration-300 flex flex-col">
|
|
28
|
+
{heroImageWebp(post.data.heroImage) && (
|
|
29
|
+
<a href={`${base}/${post.id}`}>
|
|
30
|
+
<img
|
|
31
|
+
src={heroImageWebp(post.data.heroImage)}
|
|
32
|
+
alt={post.data.title}
|
|
33
|
+
width="640"
|
|
34
|
+
height="160"
|
|
35
|
+
loading="lazy"
|
|
36
|
+
decoding="async"
|
|
37
|
+
class="w-full h-40 object-cover"
|
|
38
|
+
/>
|
|
39
|
+
</a>
|
|
40
|
+
)}
|
|
41
|
+
<div class="p-6 flex flex-col grow">
|
|
42
|
+
<div class="inline-block self-start px-3 py-1 bg-accent-soft text-accent rounded-full text-xs font-medium mb-3">
|
|
43
|
+
{categoryLabel(post)}
|
|
44
|
+
</div>
|
|
45
|
+
<h3 class="text-lg font-bold text-fg mb-3 hover:text-accent transition-colors leading-snug">
|
|
46
|
+
<a href={`${base}/${post.id}`}>{post.data.title}</a>
|
|
47
|
+
</h3>
|
|
48
|
+
<p class="text-muted text-sm leading-relaxed mb-4 grow line-clamp-3">
|
|
49
|
+
{post.data.description}
|
|
50
|
+
</p>
|
|
51
|
+
<div class="flex items-center justify-between mt-auto">
|
|
52
|
+
<time datetime={post.data.pubDate.toISOString()} class="text-xs text-muted">
|
|
53
|
+
{formatDate(post.data.pubDate, locale, { year: 'numeric', month: 'short', day: 'numeric' })}
|
|
54
|
+
</time>
|
|
55
|
+
<a
|
|
56
|
+
href={`${base}/${post.id}`}
|
|
57
|
+
class="inline-flex items-center text-accent text-sm font-semibold hover:text-accent-strong transition-colors group"
|
|
58
|
+
aria-label={`${strings.read} ${post.data.title}`}
|
|
59
|
+
>
|
|
60
|
+
{strings.read}
|
|
61
|
+
<svg
|
|
62
|
+
class="w-3 h-3 ml-1 transform group-hover:translate-x-1 transition-transform duration-300"
|
|
63
|
+
viewBox="0 0 20 20"
|
|
64
|
+
fill="currentColor"
|
|
65
|
+
aria-hidden="true"
|
|
66
|
+
>
|
|
67
|
+
<path
|
|
68
|
+
fill-rule="evenodd"
|
|
69
|
+
d="M10.293 3.293a1 1 0 011.414 0l6 6a1 1 0 010 1.414l-6 6a1 1 0 01-1.414-1.414L14.586 11H3a1 1 0 110-2h11.586l-4.293-4.293a1 1 0 010-1.414z"
|
|
70
|
+
clip-rule="evenodd"
|
|
71
|
+
/>
|
|
72
|
+
</svg>
|
|
73
|
+
</a>
|
|
74
|
+
</div>
|
|
75
|
+
</div>
|
|
76
|
+
</article>
|
|
77
|
+
))
|
|
78
|
+
}
|
|
79
|
+
</div>
|
|
80
|
+
</section>
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
---
|
|
2
|
+
interface Props {
|
|
3
|
+
text: string;
|
|
4
|
+
align?: 'left' | 'center' | 'right';
|
|
5
|
+
color?: 'blue' | 'gray';
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
const { text, align = 'left', color = 'blue' } = Astro.props;
|
|
9
|
+
|
|
10
|
+
const alignClasses = {
|
|
11
|
+
left: 'text-left',
|
|
12
|
+
center: 'text-center',
|
|
13
|
+
right: 'text-right',
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
const colorClasses = {
|
|
17
|
+
blue: 'text-accent',
|
|
18
|
+
gray: 'text-muted',
|
|
19
|
+
};
|
|
20
|
+
---
|
|
21
|
+
|
|
22
|
+
<div class={`mb-4 ${alignClasses[align]}`}>
|
|
23
|
+
<span
|
|
24
|
+
class={`inline-block text-sm uppercase tracking-wider font-semibold ${colorClasses[color]}`}
|
|
25
|
+
>
|
|
26
|
+
{text}
|
|
27
|
+
</span>
|
|
28
|
+
</div>
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
---
|
|
2
|
+
import { t } from '../lib/i18n';
|
|
3
|
+
import type { Locale } from '../lib/i18n';
|
|
4
|
+
|
|
5
|
+
export interface TocHeading {
|
|
6
|
+
depth: number;
|
|
7
|
+
slug: string;
|
|
8
|
+
text: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
interface Props {
|
|
12
|
+
headings: TocHeading[];
|
|
13
|
+
/** Locale for the "On this page" label. Defaults to "en". */
|
|
14
|
+
locale?: Locale;
|
|
15
|
+
/** Minimum eligible (h2/h3) heading count before rendering anything. Defaults to 3. */
|
|
16
|
+
minHeadings?: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const { headings, locale, minHeadings = 3 } = Astro.props;
|
|
20
|
+
const strings = t(locale);
|
|
21
|
+
const items = headings.filter((h) => h.depth === 2 || h.depth === 3);
|
|
22
|
+
const show = items.length >= minHeadings;
|
|
23
|
+
---
|
|
24
|
+
|
|
25
|
+
{
|
|
26
|
+
show && (
|
|
27
|
+
<>
|
|
28
|
+
<nav aria-label={strings.onThisPage} class="hidden lg:block">
|
|
29
|
+
<p class="text-xs font-semibold uppercase tracking-wide text-muted mb-3">
|
|
30
|
+
{strings.onThisPage}
|
|
31
|
+
</p>
|
|
32
|
+
<ul class="flex flex-col gap-2 text-sm">
|
|
33
|
+
{items.map((h) => (
|
|
34
|
+
<li class:list={[h.depth === 3 && 'pl-4']}>
|
|
35
|
+
<a href={`#${h.slug}`} class="text-muted hover:text-accent transition-colors">
|
|
36
|
+
{h.text}
|
|
37
|
+
</a>
|
|
38
|
+
</li>
|
|
39
|
+
))}
|
|
40
|
+
</ul>
|
|
41
|
+
</nav>
|
|
42
|
+
|
|
43
|
+
<details class="lg:hidden border border-border rounded-lg p-4">
|
|
44
|
+
<summary class="text-xs font-semibold uppercase tracking-wide text-muted cursor-pointer">
|
|
45
|
+
{strings.onThisPage}
|
|
46
|
+
</summary>
|
|
47
|
+
<ul class="flex flex-col gap-2 text-sm mt-4">
|
|
48
|
+
{items.map((h) => (
|
|
49
|
+
<li class:list={[h.depth === 3 && 'pl-4']}>
|
|
50
|
+
<a href={`#${h.slug}`} class="text-muted hover:text-accent transition-colors">
|
|
51
|
+
{h.text}
|
|
52
|
+
</a>
|
|
53
|
+
</li>
|
|
54
|
+
))}
|
|
55
|
+
</ul>
|
|
56
|
+
</details>
|
|
57
|
+
</>
|
|
58
|
+
)
|
|
59
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
---
|
|
2
|
+
import { t, type Locale } from '../lib/i18n';
|
|
3
|
+
|
|
4
|
+
export interface FilterOption {
|
|
5
|
+
label: string;
|
|
6
|
+
href: string;
|
|
7
|
+
active?: boolean;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
interface Props {
|
|
11
|
+
options: FilterOption[];
|
|
12
|
+
ariaLabel?: string;
|
|
13
|
+
/** Locale for the default aria-label. Defaults to "en". Ignored if ariaLabel is set. */
|
|
14
|
+
locale?: Locale;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const { options, ariaLabel, locale = 'en' } = Astro.props;
|
|
18
|
+
const strings = t(locale);
|
|
19
|
+
const resolvedAriaLabel = ariaLabel ?? strings.filterPosts;
|
|
20
|
+
---
|
|
21
|
+
|
|
22
|
+
<nav aria-label={resolvedAriaLabel} class="flex flex-wrap gap-2 mb-8">
|
|
23
|
+
{
|
|
24
|
+
options.map((opt) => (
|
|
25
|
+
<a
|
|
26
|
+
href={opt.href}
|
|
27
|
+
aria-current={opt.active ? 'page' : undefined}
|
|
28
|
+
class={`px-3 py-1.5 rounded-full text-sm font-medium border transition-colors ${
|
|
29
|
+
opt.active
|
|
30
|
+
? 'bg-accent text-on-accent border-accent'
|
|
31
|
+
: 'border-accent/30 text-muted hover:text-accent hover:border-accent'
|
|
32
|
+
}`}
|
|
33
|
+
>
|
|
34
|
+
{opt.label}
|
|
35
|
+
</a>
|
|
36
|
+
))
|
|
37
|
+
}
|
|
38
|
+
</nav>
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export { blogSchema, buildBlogPostingSchema } from './lib/schema';
|
|
2
|
+
export type { BlogPostingSchemaOptions } from './lib/schema';
|
|
3
|
+
export { scoreRelated, normalizeTag } from './lib/relatedPosts';
|
|
4
|
+
export type { ScoreRelatedOptions } from './lib/relatedPosts';
|
|
5
|
+
export { filterPostsByTag } from './lib/filterPosts';
|
|
6
|
+
export { shikiConfig } from './lib/shiki';
|
|
7
|
+
export { buildRssItems } from './lib/rss';
|
|
8
|
+
export type { RssItem } from './lib/rss';
|
|
9
|
+
export type { BlogPostData, BlogPostLike, HeroImageCredit } from './lib/types';
|
|
10
|
+
export { t, formatDate } from './lib/i18n';
|
|
11
|
+
export type { Locale } from './lib/i18n';
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { BlogPostLike } from './types';
|
|
2
|
+
import { normalizeTag } from './relatedPosts.ts';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Returns posts from `posts` carrying `tag` (case-insensitive, alias-aware via the
|
|
6
|
+
* same `aliases` map `scoreRelated`/`normalizeTag` use). Does not sort - callers
|
|
7
|
+
* already sort before paginating (see per-app glue in the README).
|
|
8
|
+
*/
|
|
9
|
+
export function filterPostsByTag<T extends BlogPostLike>(
|
|
10
|
+
posts: T[],
|
|
11
|
+
tag: string,
|
|
12
|
+
aliases: Record<string, string> = {}
|
|
13
|
+
): T[] {
|
|
14
|
+
const target = normalizeTag(tag, aliases);
|
|
15
|
+
return posts.filter((post) =>
|
|
16
|
+
(post.data.tags ?? []).some((t) => normalizeTag(t, aliases) === target)
|
|
17
|
+
);
|
|
18
|
+
}
|
package/src/lib/i18n.ts
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
export type Locale = 'en' | 'es';
|
|
2
|
+
|
|
3
|
+
interface Strings {
|
|
4
|
+
readMore: string;
|
|
5
|
+
read: string;
|
|
6
|
+
relatedReading: string;
|
|
7
|
+
goToFirstPage: string;
|
|
8
|
+
goToPreviousPage: string;
|
|
9
|
+
goToNextPage: string;
|
|
10
|
+
goToLastPage: string;
|
|
11
|
+
pageOf: (current: number, last: number) => string;
|
|
12
|
+
onThisPage: string;
|
|
13
|
+
minRead: (minutes: number) => string;
|
|
14
|
+
blogPagination: string;
|
|
15
|
+
filterPosts: string;
|
|
16
|
+
photoCredit: string;
|
|
17
|
+
via: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const STRINGS: Record<Locale, Strings> = {
|
|
21
|
+
en: {
|
|
22
|
+
readMore: 'Read More',
|
|
23
|
+
read: 'Read',
|
|
24
|
+
relatedReading: 'Related reading',
|
|
25
|
+
goToFirstPage: 'Go to first page',
|
|
26
|
+
goToPreviousPage: 'Go to previous page',
|
|
27
|
+
goToNextPage: 'Go to next page',
|
|
28
|
+
goToLastPage: 'Go to last page',
|
|
29
|
+
pageOf: (current, last) => `Page ${current} of ${last}`,
|
|
30
|
+
onThisPage: 'On this page',
|
|
31
|
+
minRead: (minutes) => `${minutes} min read`,
|
|
32
|
+
blogPagination: 'Blog pagination',
|
|
33
|
+
filterPosts: 'Filter posts',
|
|
34
|
+
photoCredit: 'Photo:',
|
|
35
|
+
via: 'via',
|
|
36
|
+
},
|
|
37
|
+
es: {
|
|
38
|
+
readMore: 'Leer más',
|
|
39
|
+
read: 'Leer',
|
|
40
|
+
relatedReading: 'Lecturas relacionadas',
|
|
41
|
+
goToFirstPage: 'Ir a la primera página',
|
|
42
|
+
goToPreviousPage: 'Ir a la página anterior',
|
|
43
|
+
goToNextPage: 'Ir a la página siguiente',
|
|
44
|
+
goToLastPage: 'Ir a la última página',
|
|
45
|
+
pageOf: (current, last) => `Página ${current} de ${last}`,
|
|
46
|
+
onThisPage: 'En esta página',
|
|
47
|
+
minRead: (minutes) => `${minutes} min de lectura`,
|
|
48
|
+
blogPagination: 'Paginación del blog',
|
|
49
|
+
filterPosts: 'Filtrar publicaciones',
|
|
50
|
+
photoCredit: 'Foto:',
|
|
51
|
+
via: 'vía',
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
export function t(locale: Locale = 'en'): Strings {
|
|
56
|
+
return STRINGS[locale];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const DATE_LOCALE: Record<Locale, string> = {
|
|
60
|
+
en: 'en-US',
|
|
61
|
+
es: 'es',
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
export function formatDate(date: Date, locale: Locale = 'en', options?: Intl.DateTimeFormatOptions): string {
|
|
65
|
+
return date.toLocaleDateString(DATE_LOCALE[locale], options ?? { year: 'numeric', month: 'long', day: 'numeric' });
|
|
66
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import type { BlogPostLike } from './types';
|
|
2
|
+
|
|
3
|
+
export interface ScoreRelatedOptions {
|
|
4
|
+
/** Max number of related posts to return. Defaults to 3. */
|
|
5
|
+
k?: number;
|
|
6
|
+
/**
|
|
7
|
+
* Collapse known tag spelling variants to a canonical form (after lowercasing).
|
|
8
|
+
* Content-specific, so it's passed in rather than baked in. Example:
|
|
9
|
+
* { pihole: 'pi-hole', homeassistant: 'home-assistant', ha: 'home-assistant' }
|
|
10
|
+
*/
|
|
11
|
+
aliases?: Record<string, string>;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function normalizeTag(tag: string, aliases: Record<string, string> = {}): string {
|
|
15
|
+
const lower = tag.toLowerCase().trim();
|
|
16
|
+
return aliases[lower] ?? lower;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function jaccard(a: string[], b: string[], aliases: Record<string, string>): number {
|
|
20
|
+
if (a.length === 0 || b.length === 0) return 0;
|
|
21
|
+
const sa = new Set(a.map((t) => normalizeTag(t, aliases)));
|
|
22
|
+
const sb = new Set(b.map((t) => normalizeTag(t, aliases)));
|
|
23
|
+
let overlap = 0;
|
|
24
|
+
for (const t of sa) if (sb.has(t)) overlap++;
|
|
25
|
+
return overlap / (sa.size + sb.size - overlap);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Returns up to `k` posts from `candidates` most related to `target`, ordered by
|
|
30
|
+
* tag Jaccard similarity (+0.1 same-category bonus), then recency. Only posts with
|
|
31
|
+
* score > 0 are returned.
|
|
32
|
+
*/
|
|
33
|
+
export function scoreRelated(
|
|
34
|
+
target: BlogPostLike,
|
|
35
|
+
candidates: BlogPostLike[],
|
|
36
|
+
options: ScoreRelatedOptions = {}
|
|
37
|
+
): BlogPostLike[] {
|
|
38
|
+
const { k = 3, aliases = {} } = options;
|
|
39
|
+
const tTags = target.data.tags ?? [];
|
|
40
|
+
|
|
41
|
+
return candidates
|
|
42
|
+
.filter((c) => c.id !== target.id)
|
|
43
|
+
.map((c) => {
|
|
44
|
+
let score = jaccard(tTags, c.data.tags ?? [], aliases);
|
|
45
|
+
if (score > 0 && c.data.category === target.data.category) score += 0.1;
|
|
46
|
+
return { entry: c, score };
|
|
47
|
+
})
|
|
48
|
+
.filter((s) => s.score > 0)
|
|
49
|
+
.sort((a, b) => {
|
|
50
|
+
if (b.score !== a.score) return b.score - a.score;
|
|
51
|
+
return b.entry.data.pubDate.getTime() - a.entry.data.pubDate.getTime();
|
|
52
|
+
})
|
|
53
|
+
.slice(0, k)
|
|
54
|
+
.map((s) => s.entry);
|
|
55
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
const WORDS_PER_MINUTE = 200;
|
|
2
|
+
|
|
3
|
+
/** Minimal shape this plugin cares about - avoids a mdast-util-* type dependency. */
|
|
4
|
+
interface MdastNode {
|
|
5
|
+
type: string;
|
|
6
|
+
value?: string;
|
|
7
|
+
children?: MdastNode[];
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
interface VFileWithAstroFrontmatter {
|
|
11
|
+
data?: {
|
|
12
|
+
astro?: {
|
|
13
|
+
frontmatter?: {
|
|
14
|
+
minutesRead?: number;
|
|
15
|
+
};
|
|
16
|
+
};
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function collectText(node: MdastNode, out: string[]) {
|
|
21
|
+
if (typeof node.value === 'string') out.push(node.value);
|
|
22
|
+
if (node.children) {
|
|
23
|
+
for (const child of node.children) collectText(child, out);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Remark plugin: counts words in the post body and writes the rounded-up
|
|
29
|
+
* reading time (at 200 wpm) to `minutesRead` in the page's frontmatter.
|
|
30
|
+
*
|
|
31
|
+
* import { remarkReadingTime } from '@vdaluz/astro-blog/remark';
|
|
32
|
+
*
|
|
33
|
+
* export default defineConfig({
|
|
34
|
+
* markdown: { remarkPlugins: [remarkReadingTime] },
|
|
35
|
+
* });
|
|
36
|
+
*/
|
|
37
|
+
export function remarkReadingTime() {
|
|
38
|
+
return (tree: MdastNode, file: VFileWithAstroFrontmatter) => {
|
|
39
|
+
const textParts: string[] = [];
|
|
40
|
+
collectText(tree, textParts);
|
|
41
|
+
const wordCount = textParts.join(' ').split(/\s+/).filter(Boolean).length;
|
|
42
|
+
const minutesRead = Math.max(1, Math.ceil(wordCount / WORDS_PER_MINUTE));
|
|
43
|
+
|
|
44
|
+
file.data ??= {};
|
|
45
|
+
file.data.astro ??= {};
|
|
46
|
+
file.data.astro.frontmatter ??= {};
|
|
47
|
+
file.data.astro.frontmatter.minutesRead = minutesRead;
|
|
48
|
+
};
|
|
49
|
+
}
|
package/src/lib/rss.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { BlogPostLike } from './types';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Structurally assignable to @astrojs/rss's `RSSFeedItem` — this package doesn't
|
|
5
|
+
* depend on @astrojs/rss itself, so consumers pass `buildRssItems(posts)` straight
|
|
6
|
+
* into `rss({ items })` from their own app-local route.
|
|
7
|
+
*/
|
|
8
|
+
export interface RssItem {
|
|
9
|
+
title: string;
|
|
10
|
+
description: string;
|
|
11
|
+
pubDate: Date;
|
|
12
|
+
link: string;
|
|
13
|
+
categories?: string[];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Maps posts to RSS feed items. `link` is root-relative (`${basePath}/${post.id}`);
|
|
18
|
+
* @astrojs/rss resolves relative links against `site` itself.
|
|
19
|
+
*
|
|
20
|
+
* `author` is intentionally not mapped: the RSS spec's `author` field expects an
|
|
21
|
+
* email address, but `BlogPostData.author` is a display name.
|
|
22
|
+
*/
|
|
23
|
+
export function buildRssItems(posts: BlogPostLike[], opts?: { basePath?: string }): RssItem[] {
|
|
24
|
+
const prefix = (opts?.basePath ?? '/blog').replace(/\/$/, '');
|
|
25
|
+
return posts.map((post) => ({
|
|
26
|
+
title: post.data.title,
|
|
27
|
+
description: post.data.description,
|
|
28
|
+
pubDate: post.data.pubDate,
|
|
29
|
+
link: `${prefix}/${post.id}`,
|
|
30
|
+
categories: [post.data.category, ...(post.data.tags ?? [])].filter(
|
|
31
|
+
(c, i, a) => a.indexOf(c) === i,
|
|
32
|
+
),
|
|
33
|
+
}));
|
|
34
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { z } from 'astro/zod';
|
|
2
|
+
import type { BlogPostLike } from './types';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Zod schema for a blog collection. Pass `defaultAuthor` to set the per-site
|
|
6
|
+
* fallback author. Use in the consuming app's content config:
|
|
7
|
+
*
|
|
8
|
+
* import { defineCollection } from 'astro:content';
|
|
9
|
+
* import { glob } from 'astro/loaders';
|
|
10
|
+
* import { blogSchema } from '@vdaluz/astro-blog';
|
|
11
|
+
*
|
|
12
|
+
* const blog = defineCollection({
|
|
13
|
+
* loader: glob({ pattern: '**\/*.md', base: './src/content/blog' }),
|
|
14
|
+
* schema: blogSchema({ defaultAuthor: 'Imperfect Systems' }),
|
|
15
|
+
* });
|
|
16
|
+
* export const collections = { blog };
|
|
17
|
+
*/
|
|
18
|
+
export function blogSchema(opts: { defaultAuthor?: string } = {}) {
|
|
19
|
+
return z.object({
|
|
20
|
+
title: z.string(),
|
|
21
|
+
description: z.string(),
|
|
22
|
+
pubDate: z.coerce.date(),
|
|
23
|
+
updatedDate: z.coerce.date().optional(),
|
|
24
|
+
category: z.string(),
|
|
25
|
+
author: z.string().default(opts.defaultAuthor ?? ''),
|
|
26
|
+
tags: z.array(z.string()).optional(),
|
|
27
|
+
heroImage: z.string().optional(),
|
|
28
|
+
heroImageCredit: z
|
|
29
|
+
.object({
|
|
30
|
+
name: z.string(),
|
|
31
|
+
url: z.string().url(),
|
|
32
|
+
source: z.enum(['pexels', 'unsplash', 'openverse']),
|
|
33
|
+
licenseName: z.string().optional(),
|
|
34
|
+
licenseUrl: z.string().url().optional(),
|
|
35
|
+
})
|
|
36
|
+
.optional(),
|
|
37
|
+
/** Affiliate program keys used by this post, e.g. ["amazon"]. See @vdaluz/astro-affiliate. */
|
|
38
|
+
affiliates: z.array(z.string()).optional(),
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface BlogPostingSchemaOptions {
|
|
43
|
+
post: BlogPostLike;
|
|
44
|
+
/** Origin only, e.g. "https://imperfectsystems.com" (trailing slash tolerated). */
|
|
45
|
+
siteUrl: string;
|
|
46
|
+
/** Route prefix posts live under. Defaults to "/blog". */
|
|
47
|
+
basePath?: string;
|
|
48
|
+
/** Site/brand name for the JSON-LD publisher. Falls back to the post author. */
|
|
49
|
+
publisherName?: string;
|
|
50
|
+
/** BCP 47 language tag for the `inLanguage` field, e.g. "en" or "es". Omitted if unset. */
|
|
51
|
+
locale?: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Builds a schema.org BlogPosting object for a post. Render it as JSON-LD via the
|
|
56
|
+
* `BlogPostMeta.astro` component, or pass it to a Layout that injects `schema`.
|
|
57
|
+
*/
|
|
58
|
+
export function buildBlogPostingSchema({
|
|
59
|
+
post,
|
|
60
|
+
siteUrl,
|
|
61
|
+
basePath = '/blog',
|
|
62
|
+
publisherName,
|
|
63
|
+
locale,
|
|
64
|
+
}: BlogPostingSchemaOptions) {
|
|
65
|
+
const origin = siteUrl.replace(/\/$/, '');
|
|
66
|
+
const prefix = basePath.replace(/\/$/, '');
|
|
67
|
+
const postUrl = `${origin}${prefix}/${post.id}`;
|
|
68
|
+
const authorName = post.data.author || publisherName || '';
|
|
69
|
+
return {
|
|
70
|
+
'@context': 'https://schema.org',
|
|
71
|
+
'@type': 'BlogPosting',
|
|
72
|
+
headline: post.data.title,
|
|
73
|
+
description: post.data.description,
|
|
74
|
+
datePublished: post.data.pubDate.toISOString(),
|
|
75
|
+
dateModified: (post.data.updatedDate ?? post.data.pubDate).toISOString(),
|
|
76
|
+
author: { '@type': 'Person', name: authorName, url: origin },
|
|
77
|
+
publisher: { '@type': 'Person', name: publisherName || authorName, url: origin },
|
|
78
|
+
url: postUrl,
|
|
79
|
+
mainEntityOfPage: { '@type': 'WebPage', '@id': postUrl },
|
|
80
|
+
...(post.data.heroImage ? { image: `${origin}${post.data.heroImage}` } : {}),
|
|
81
|
+
...(locale ? { inLanguage: locale } : {}),
|
|
82
|
+
};
|
|
83
|
+
}
|
package/src/lib/shiki.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared Shiki config for `markdown.shikiConfig` in astro.config.mjs.
|
|
3
|
+
*
|
|
4
|
+
* `defaultColor: false` makes Shiki emit `--shiki-light` / `--shiki-dark` CSS vars
|
|
5
|
+
* instead of baked-in colors. The matching CSS handoff (see styles/tokens.example.css)
|
|
6
|
+
* picks the right one based on the `.dark` class on <html>. The two MUST ship together
|
|
7
|
+
* or code blocks render with no color.
|
|
8
|
+
*
|
|
9
|
+
* Dark-only sites: keep this config and force `<html class="dark">` so the dark vars
|
|
10
|
+
* always apply (no toggle needed).
|
|
11
|
+
*/
|
|
12
|
+
export const shikiConfig = {
|
|
13
|
+
themes: {
|
|
14
|
+
light: 'github-light-high-contrast',
|
|
15
|
+
dark: 'github-dark-high-contrast',
|
|
16
|
+
},
|
|
17
|
+
defaultColor: false as const,
|
|
18
|
+
};
|
package/src/lib/types.ts
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Structural shape the blog components and helpers operate on.
|
|
3
|
+
*
|
|
4
|
+
* Deliberately NOT `CollectionEntry<'blog'>` from `astro:content`: that ties the
|
|
5
|
+
* package to a collection literally named "blog" in the consuming app. A site's
|
|
6
|
+
* `getCollection(...)` result is structurally assignable to `BlogPostLike` as long
|
|
7
|
+
* as its frontmatter matches `blogSchema()`, so consumers pass their entries directly.
|
|
8
|
+
*/
|
|
9
|
+
export interface HeroImageCredit {
|
|
10
|
+
name: string;
|
|
11
|
+
url: string;
|
|
12
|
+
source: 'pexels' | 'unsplash' | 'openverse';
|
|
13
|
+
licenseName?: string;
|
|
14
|
+
licenseUrl?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface BlogPostData {
|
|
18
|
+
title: string;
|
|
19
|
+
description: string;
|
|
20
|
+
pubDate: Date;
|
|
21
|
+
updatedDate?: Date;
|
|
22
|
+
category: string;
|
|
23
|
+
author?: string;
|
|
24
|
+
tags?: string[];
|
|
25
|
+
heroImage?: string;
|
|
26
|
+
heroImageCredit?: HeroImageCredit;
|
|
27
|
+
/** Affiliate program keys used by this post, e.g. ["amazon"]. See @vdaluz/astro-affiliate. */
|
|
28
|
+
affiliates?: string[];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface BlogPostLike {
|
|
32
|
+
/** Astro content-collection entry id, used to build the post URL. */
|
|
33
|
+
id: string;
|
|
34
|
+
data: BlogPostData;
|
|
35
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/* ============================================================
|
|
2
|
+
@vdaluz/astro-blog — token contract
|
|
3
|
+
------------------------------------------------------------
|
|
4
|
+
The package components reference ONLY these token names (never
|
|
5
|
+
hard-coded colors). Each consuming site must define them. Values
|
|
6
|
+
are R G B channel triplets so Tailwind's
|
|
7
|
+
`rgb(var(--name) / <alpha-value>)` pipeline works.
|
|
8
|
+
|
|
9
|
+
Copy this file into your app (e.g. src/styles/theme.css), keep the
|
|
10
|
+
variable NAMES, and replace the VALUES with your palette.
|
|
11
|
+
|
|
12
|
+
Your tailwind.config.mjs must alias these names — see README.
|
|
13
|
+
============================================================ */
|
|
14
|
+
|
|
15
|
+
:root {
|
|
16
|
+
color-scheme: light;
|
|
17
|
+
|
|
18
|
+
--bg: 250 250 250; /* page background */
|
|
19
|
+
--surface: 255 255 255; /* card / panel surface (should read as elevated vs --bg) */
|
|
20
|
+
--surface-muted: 218 219 225; /* tag pill, dimmed card, hover */
|
|
21
|
+
|
|
22
|
+
--fg: 52 59 89; /* primary text */
|
|
23
|
+
--muted: 92 94 110; /* secondary text, dates (keep >= 4.5:1 on --bg) */
|
|
24
|
+
|
|
25
|
+
--border: 220 222 230; /* dividers, input borders */
|
|
26
|
+
|
|
27
|
+
--accent: 41 89 170; /* links, category labels */
|
|
28
|
+
--accent-strong: 27 70 145; /* link hover / active */
|
|
29
|
+
--accent-soft: 230 234 245; /* tinted bg behind accents (blockquote, pill) */
|
|
30
|
+
--on-accent: 255 255 255; /* text on a solid --accent background */
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/* Dark theme. Sites with a light/dark toggle override under `.dark`.
|
|
34
|
+
Dark-only sites: force `<html class="dark">` and only this block applies. */
|
|
35
|
+
.dark {
|
|
36
|
+
color-scheme: dark;
|
|
37
|
+
|
|
38
|
+
--bg: 26 27 38;
|
|
39
|
+
--surface: 36 37 52;
|
|
40
|
+
--surface-muted: 50 54 79;
|
|
41
|
+
|
|
42
|
+
--fg: 169 177 214;
|
|
43
|
+
--muted: 154 160 189;
|
|
44
|
+
|
|
45
|
+
--border: 54 59 84;
|
|
46
|
+
|
|
47
|
+
--accent: 122 162 247;
|
|
48
|
+
--accent-strong: 125 207 255;
|
|
49
|
+
--accent-soft: 41 47 73;
|
|
50
|
+
--on-accent: 26 27 38;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/* ============================================================
|
|
54
|
+
Shiki dual-theme handoff (REQUIRED for code blocks)
|
|
55
|
+
Pairs with `shikiConfig` (defaultColor: false) from the package.
|
|
56
|
+
Shiki sets --shiki-light/--shiki-dark inline on <pre> and token
|
|
57
|
+
<span>s; these rules pick one based on the `.dark` class on <html>.
|
|
58
|
+
Ship this together with the config or code blocks lose color.
|
|
59
|
+
============================================================ */
|
|
60
|
+
html pre {
|
|
61
|
+
background-color: var(--shiki-light-bg);
|
|
62
|
+
}
|
|
63
|
+
html pre code span {
|
|
64
|
+
color: var(--shiki-light);
|
|
65
|
+
}
|
|
66
|
+
html.dark pre {
|
|
67
|
+
background-color: var(--shiki-dark-bg);
|
|
68
|
+
}
|
|
69
|
+
html.dark pre code span {
|
|
70
|
+
color: var(--shiki-dark);
|
|
71
|
+
}
|