init(): Initialize project with basic configuration files

This commit is contained in:
Prad Nukala
2026-06-29 10:48:48 -04:00
parent 5c8d69c49f
commit 293810b235
48 changed files with 1916 additions and 0 deletions
+9
View File
@@ -0,0 +1,9 @@
root = true
[*]
end_of_line = lf
indent_style = space
indent_size = 2
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
+3
View File
@@ -0,0 +1,3 @@
WEBMENTION_API_KEY=
WEBMENTION_URL=
WEBMENTION_PINGBACK=#optional
+1
View File
@@ -0,0 +1 @@
22
+8
View File
@@ -0,0 +1,8 @@
*.min.js
node_modules
# cache-dirs
**/.cache
pnpm-lock.yaml
dist
+23
View File
@@ -0,0 +1,23 @@
/** @type {import("@types/prettier").Options} */
export default {
printWidth: 100,
semi: true,
singleQuote: false,
tabWidth: 2,
useTabs: true,
plugins: ["prettier-plugin-astro", "prettier-plugin-tailwindcss" /* Must come last */],
overrides: [
{
files: "**/*.astro",
options: {
parser: "astro",
},
},
{
files: ["*.mdx", "*.md"],
options: {
printWidth: 80,
},
},
],
};
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2022 Chris Williams
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 272 480"><path fill="#cdffb8" d="M181.334 93.333v-40L226.667 80v40zM136.001 53.333 90.667 26.667v426.666L136.001 480zM45.333 220 0 193.334v140L45.333 360z"/><path fill="#d482ab" d="M90.667 26.667 136.001 0l45.333 26.667-45.333 26.666zM181.334 53.33l45.333-26.72L272 53.33 226.667 80zM136 240l-45.333-26.67v53.34zM0 193.33l45.333-26.72 45.334 26.72L45.333 220zM181.334 93.277 226.667 120l-45.333 26.67z"/><path fill="#2abc89" d="m136 53.333 45.333-26.666v120L226.667 120V80L272 53.333V160l-90.667 53.333v240L136 480V306.667L45.334 360V220l45.333-26.667v73.334L136 240z"/></svg>

After

Width:  |  Height:  |  Size: 642 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 KiB

Binary file not shown.
Binary file not shown.
+59
View File
@@ -0,0 +1,59 @@
import { defineCollection } from "astro:content";
import { glob } from "astro/loaders";
import { z } from "astro/zod";
function removeDupsAndLowerCase(array: string[]) {
return [...new Set(array.map((str) => str.toLowerCase()))];
}
const titleSchema = z.string().max(60);
const baseSchema = z.object({
title: titleSchema,
});
const post = defineCollection({
loader: glob({ base: "./src/content/post", pattern: "**/*.{md,mdx}" }),
schema: ({ image }) =>
baseSchema.extend({
description: z.string(),
coverImage: z
.object({
alt: z.string(),
src: image(),
})
.optional(),
draft: z.boolean().default(false),
ogImage: z.string().optional(),
tags: z.array(z.string()).default([]).transform(removeDupsAndLowerCase),
publishDate: z
.string()
.or(z.date())
.transform((val) => new Date(val)),
updatedDate: z
.string()
.optional()
.transform((str) => (str ? new Date(str) : undefined)),
pinned: z.boolean().default(false),
}),
});
const note = defineCollection({
loader: glob({ base: "./src/content/note", pattern: "**/*.{md,mdx}" }),
schema: baseSchema.extend({
description: z.string().optional(),
publishDate: z.iso
.datetime({ offset: true }) // Ensures ISO 8601 format with offsets allowed (e.g. "2024-01-01T00:00:00Z" and "2024-01-01T00:00:00+02:00")
.transform((val) => new Date(val)),
}),
});
const tag = defineCollection({
loader: glob({ base: "./src/content/tag", pattern: "**/*.{md,mdx}" }),
schema: z.object({
title: titleSchema.optional(),
description: z.string().optional(),
}),
});
export const collections = { post, note, tag };
+9
View File
@@ -0,0 +1,9 @@
---
title: Hello, Welcome
description: An introduction to using the note feature in Astro Cactus
publishDate: "2024-10-14T11:23:00Z"
---
Hi, Hello. This is an example note feature included with Astro Cactus 🌵
They're for shorter, more concise posts that you'd like to share. They generally don't include headings, but hey, it's entirely up to you!
@@ -0,0 +1,135 @@
---
title: "Markdown Admonitions"
description: "This post showcases using the markdown admonition feature in Astro Cactus"
publishDate: "25 Aug 2024"
updatedDate: "4 July 2025"
tags: ["markdown", "admonitions"]
---
## What are admonitions
Admonitions (also known as “asides”) are useful for providing supportive and/or supplementary information related to your content.
## How to use them
To use admonitions in Astro Cactus, wrap your Markdown content in a pair of triple colons `:::`. The first pair should also include the type of admonition you want to use.
For example, with the following Markdown:
```md
:::note
Highlights information that users should take into account, even when skimming.
:::
```
Outputs:
:::note
Highlights information that users should take into account, even when skimming.
:::
## Admonition Types
The following admonitions are currently supported:
- `note`
- `tip`
- `important`
- `warning`
- `caution`
### Note
```md
:::note
Highlights information that users should take into account, even when skimming.
:::
```
:::note
Highlights information that users should take into account, even when skimming.
:::
### Tip
```md
:::tip
Optional information to help a user be more successful.
:::
```
:::tip
Optional information to help a user be more successful.
:::
### Important
```md
:::important
Crucial information necessary for users to succeed.
:::
```
:::important
Crucial information necessary for users to succeed.
:::
### Caution
```md
:::caution
Negative potential consequences of an action.
:::
```
:::caution
Negative potential consequences of an action.
:::
### Warning
```md
:::warning
Critical content demanding immediate user attention due to potential risks.
:::
```
:::warning
Critical content demanding immediate user attention due to potential risks.
:::
## Customising the admonition title
You can customise the admonition title using the following markup:
```md
:::note[My custom title]
This is a note with a custom title.
:::
```
Outputs:
:::note[My custom title]
This is a note with a custom title.
:::
## GitHub Repository Cards
You can add dynamic cards that link to GitHub repositories, on page load, the repository information is pulled from the GitHub API.
::github{repo="chrismwilliams/astro-theme-cactus"}
You can also link a Github user:
::github{user="withastro"}
To use this feature you just use the "Github" directive:
```markdown title="Linking a repo"
::github{repo="chrismwilliams/astro-theme-cactus"}
```
```markdown title="Linking a user"
::github{user="withastro"}
```
+174
View File
@@ -0,0 +1,174 @@
---
title: "A post of Markdown elements"
description: "This post is for testing and listing a number of different markdown elements"
publishDate: "22 Feb 2023"
updatedDate: 22 Jan 2024
tags: ["test", "markdown"]
pinned: true
---
## This is a H2 Heading
### This is a H3 Heading
#### This is a H4 Heading
##### This is a H5 Heading
###### This is a H6 Heading
## Horizontal Rules
---
---
---
## Emphasis
**This is bold text**
_This is italic text_
~~Strikethrough~~
## Quotes
"Double quotes" and 'single quotes'
## Blockquotes
> Blockquotes can also be nested...
>
> > ...by using additional greater-than signs right next to each other...
## References
An example containing a clickable reference[^1] with a link to the source.
Second example containing a reference[^2] with a link to the source.
[^1]: Reference first footnote with a return to content link.
[^2]: Second reference with a link.
If you check out this example in `src/content/post/markdown-elements/index.md`, you'll notice that the references and the heading "Footnotes" are added to the bottom of the page via the [remark-rehype](https://github.com/remarkjs/remark-rehype#options) plugin.
## Lists
Unordered
- Create a list by starting a line with `+`, `-`, or `*`
- Sub-lists are made by indenting 2 spaces:
- Marker character change forces new list start:
- Ac tristique libero volutpat at
- Facilisis in pretium nisl aliquet
- Nulla volutpat aliquam velit
- Very easy!
Ordered
1. Lorem ipsum dolor sit amet
2. Consectetur adipiscing elit
3. Integer molestie lorem at massa
4. You can use sequential numbers...
5. ...or keep all the numbers as `1.`
Start numbering with offset:
57. foo
1. bar
## Code
Inline `code`
Indented code
// Some comments
line 1 of code
line 2 of code
line 3 of code
Block code "fences"
```
Sample text here...
```
Syntax highlighting
```js
var foo = function (bar) {
return bar++;
};
console.log(foo(5));
```
### Expressive code examples
Adding a title
```js title="file.js"
console.log("Title example");
```
A bash terminal
```bash
echo "A base terminal example"
```
Highlighting code lines
```js title="line-markers.js" del={2} ins={3-4} {6}
function demo() {
console.log("this line is marked as deleted");
// This line and the next one are marked as inserted
console.log("this is the second inserted line");
return "this line uses the neutral default marker type";
}
```
[Expressive Code](https://expressive-code.com/) can do a ton more than shown here, and includes a lot of [customisation](https://expressive-code.com/reference/configuration/).
## Tables
| Option | Description |
| ------ | ------------------------------------------------------------------------- |
| data | path to data files to supply the data that will be passed into templates. |
| engine | engine to be used for processing templates. Handlebars is the default. |
| ext | extension to be used for dest files. |
### Table Alignment
| Item | Price | # In stock |
| ------------ | :---: | ---------: |
| Juicy Apples | 1.99 | 739 |
| Bananas | 1.89 | 6 |
### Keyboard elements
| Action | Shortcut |
| --------------------- | ------------------------------------------ |
| Vertical split | <kbd>Alt+Shift++</kbd> |
| Horizontal split | <kbd>Alt+Shift+-</kbd> |
| Auto split | <kbd>Alt+Shift+d</kbd> |
| Switch between splits | <kbd>Alt</kbd> + arrow keys |
| Resizing a split | <kbd>Alt+Shift</kbd> + arrow keys |
| Close a split | <kbd>Ctrl+Shift+W</kbd> |
| Maximize a pane | <kbd>Ctrl+Shift+P</kbd> + Toggle pane zoom |
## Images
Image in the same folder: `src/content/post/markdown-elements/logo.png`
![Astro theme cactus logo](./logo.png)
## Links
[Content from markdown-it](https://markdown-it.github.io/)
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 591 KiB

@@ -0,0 +1,10 @@
---
title: "Example Cover Image"
description: "This post is an example of how to add a cover/hero image"
publishDate: "04 July 2023"
updatedDate: "14 August 2023"
coverImage:
src: "./cover.png"
alt: "Astro build wallpaper"
tags: ["test", "image"]
---
+9
View File
@@ -0,0 +1,9 @@
---
title: "A working draft title"
description: "This post is for testing the draft post functionality"
publishDate: "10 March 2024"
tags: ["test"]
draft: true
---
If this is working correctly, this post should only be accessible in a dev environment, as well as any tags that are unique to this post.
+8
View File
@@ -0,0 +1,8 @@
---
title: "Lorem ipsum dolor sit, amet consectetur adipisicing elit. Id"
description: "This post is purely for testing if the css is correct for the title on the page"
publishDate: "01 Feb 2023"
tags: ["test"]
---
## Testing the title tag
+22
View File
@@ -0,0 +1,22 @@
---
title: "Example OG Social Image"
publishDate: "27 January 2023"
description: "An example post for Astro Cactus, detailing how to add a custom social image card in the frontmatter"
tags: ["example", "blog", "image"]
ogImage: "/social-card.png"
---
## Adding your own social image to a post
This post is an example of how to add a custom [open graph](https://ogp.me/) social image, also known as an OG image, to a blog post.
By adding the optional ogImage property to the frontmatter of a post, you opt out of [satori](https://github.com/vercel/satori) automatically generating an image for this page.
If you open this markdown file `src/content/post/social-image.md` you'll see the ogImage property set to an image which lives in the public folder[^1].
```yaml
ogImage: "/social-card.png"
```
You can view the one set for this template page [here](https://astro-cactus.chriswilliams.dev/social-card.png).
[^1]: The image itself can be located anywhere you like.
+66
View File
@@ -0,0 +1,66 @@
---
title: "Adding Webmentions to Astro Cactus"
description: "This post describes the process of adding webmentions to your own site"
publishDate: "11 Oct 2023"
tags: ["webmentions", "astro", "social"]
updatedDate: 6 December 2024
pinned: true
---
## TLDR
1. Add a link on your homepage to either your GitHub profile and/or email address as per [IndieLogin's](https://indielogin.com/setup) instructions. You _could_ do this via `src/components/SocialList.astro`, just be sure to include `isWebmention` to the relevant link if doing so.
2. Create an account @ [Webmention.io](https://webmention.io/) by entering your website's address.
3. Add the link feed and api key to a `.env` file with the key `WEBMENTION_URL` and `WEBMENTION_API_KEY` respectively, you could rename `.env.example` found in this template. You can also add the optional `WEBMENTION_PINGBACK` link here too.
4. Go to [brid.gy](https://brid.gy/) and sign-in to each social account[s] you wish to link.
5. Publish and build your website, remember to add the api key, and it should now be ready to receive webmentions!
## What are webmentions
Put simply, it's a way to show users who like, comment, repost and more, on various pages on your website via social media.
This theme displays the number of likes, mentions and replies each blog post receives. There are a couple of more webmentions that I haven't included, like reposts, which are currently filtered out, but shouldn't be too difficult to include.
## Steps to add it to your own site
Your going to have to create a couple of accounts to get things up-and-running. But, the first thing you need to ensure is that your social links are correct.
### Add link(s) to your profile(s)
Firstly, you need to add a link on your site to prove ownership. If you have a look at [IndieLogin's](https://indielogin.com/setup) instructions, it gives you 2 options, either an email address and/or GitHub account. I've created the component `src/components/SocialList.astro` where you can add your details into the `socialLinks` array, just include the `isWebmention` property to the relevant link which will add the `rel="me authn"` attribute. Whichever way you do it, make sure you have a link in your markup as per IndieLogin's [instructions](https://indielogin.com/setup)
```html
<a href="https://github.com/your-username" rel="me">GitHub</a>
```
### Sign up to Webmention.io
Next, head over to [Webmention.io](https://webmention.io/) and create an account by signing in with your domain name, e.g. `https://astro-cactus.chriswilliams.dev/`. Please note that .app TLDs don't function correctly. Once in, it will give you a couple of links for your domain to accept webmentions. Make a note of these and create a `.env` file (this template include an example `.env.example` which you could rename). Add the link feed and api key with the key/values of `WEBMENTION_URL` and `WEBMENTION_API_KEY` respectively, and the optional `WEBMENTION_PINGBACK` url if required. Please try not to publish this to a repository!
:::note
You don't have to include the pingback link. Maybe coincidentally, but after adding it I started to receive a higher frequency of spam in my mailbox, informing me that my website could be better. TBH they're not wrong. I've now removed it, but it's up to you.
:::
### Sign up to Brid.gy
You're now going to have to use [brid.gy](https://brid.gy/). As the name suggests, it links your website to your social media accounts. For every account you want to set up (e.g. Mastodon), click on the relevant button and connect each account you want brid.gy to search. Just to note again, brid.gy currently has an issue with .app TLDs.
## Testing everything works
With everything set, it's now time to build and publish your website. **REMEMBER** to set your environment variables `WEBMENTION_API_KEY` & `WEBMENTION_URL` with your host.
You can check to see if everything is working by sending a test webmention via [webmentions.rocks](https://webmention.rocks/receive/1). Log in with your domain, enter the auth code, and then the url of the page you want to test. For example, to test this page I would add `https://astro-cactus.chriswilliams.dev/posts/webmentions/`. To view it on your website, rebuild or (re)start dev mode locally, and you should see the result at the bottom of your page.
You can also view any test mentions in the browser via their [api](https://github.com/aaronpk/webmention.io#api).
## Things to add, things to consider
- At the moment, fresh webmentions are only fetched on a rebuild or restarting dev mode, which obviously means if you don't update your site very often you wont get a lot of new content. It should be quite trivial to add a cron job to run the `getAndCacheWebmentions()` function in `src/utils/webmentions.ts` and populate your blog with new content. This is probably what I'll add next as a github action.
- I have seen some mentions have duplicates. Unfortunately, they're quite difficult to filter out as they have different id's.
- I'm not a huge fan of the little external link icon for linking to comments/replies. It's not particularly great on mobile due to its size, and will likely change it in the future.
## Acknowledgements
Many thanks to [Kieran McGuire](https://github.com/chrismwilliams/astro-theme-cactus/issues/107#issue-1863931105) for sharing this with me, and the helpful posts. I'd never heard of webmentions before, and now with this update hopefully others will be able to make use of them. Additionally, articles and examples from [kld](https://kld.dev/adding-webmentions/) and [ryanmulligan.dev](https://ryanmulligan.dev/blog/) really helped in getting this set up and integrated, both a great resource if you're looking for more information!
+15
View File
@@ -0,0 +1,15 @@
---
title: Test Tag
description: This tag is used for testing various features of the theme.
---
This is an example of a custom intro on a tag page. Its markdown can be found in `src/content/tag/test.md`.
This collection includes posts that demonstrate and test different features of the Astro Theme Cactus, including:
- Markdown rendering capabilities
- Image handling and optimization
- Table of contents generation
- Various edge cases and scenarios
Feel free to explore these posts to understand how the theme handles different content types and configurations.
+49
View File
@@ -0,0 +1,49 @@
import { type CollectionEntry, getCollection } from "astro:content";
/** filter out draft posts based on the environment */
export async function getAllPosts(): Promise<CollectionEntry<"post">[]> {
return await getCollection("post", ({ data }) => {
return import.meta.env.PROD ? !data.draft : true;
});
}
/** Get tag metadata by tag name */
export async function getTagMeta(tag: string): Promise<CollectionEntry<"tag"> | undefined> {
const tagEntries = await getCollection("tag", (entry) => {
return entry.id === tag;
});
return tagEntries[0];
}
/** groups posts by year (based on option siteConfig.sortPostsByUpdatedDate), using the year as the key
* Note: This function doesn't filter draft posts, pass it the result of getAllPosts above to do so.
*/
export function groupPostsByYear(posts: CollectionEntry<"post">[]) {
return Object.groupBy(posts, (post) => post.data.publishDate.getFullYear().toString());
}
/** returns all tags created from posts (inc duplicate tags)
* Note: This function doesn't filter draft posts, pass it the result of getAllPosts above to do so.
* */
export function getAllTags(posts: CollectionEntry<"post">[]) {
return posts.flatMap((post) => [...post.data.tags]);
}
/** returns all unique tags created from posts
* Note: This function doesn't filter draft posts, pass it the result of getAllPosts above to do so.
* */
export function getUniqueTags(posts: CollectionEntry<"post">[]) {
return [...new Set(getAllTags(posts))];
}
/** returns a count of each unique tag - [[tagName, count], ...]
* Note: This function doesn't filter draft posts, pass it the result of getAllPosts above to do so.
* */
export function getUniqueTagsWithCount(posts: CollectionEntry<"post">[]): [string, number][] {
return [
...getAllTags(posts).reduce(
(acc, t) => acc.set(t, (acc.get(t) ?? 0) + 1),
new Map<string, number>(),
),
].sort((a, b) => b[1] - a[1]);
}
+5
View File
@@ -0,0 +1,5 @@
declare module "@pagefind/default-ui" {
declare class PagefindUI {
constructor(arg: unknown);
}
}
+13
View File
@@ -0,0 +1,13 @@
---
import PageLayout from "@/layouts/Base.astro";
const meta = {
description: "Oops! It looks like this page is lost in space!",
title: "Oops! You found a missing page!",
};
---
<PageLayout meta={meta}>
<h1 class="title mb-12">404 | Oops something went wrong</h1>
<p class="mb-8">Please use the navigation to find your way back</p>
</PageLayout>
+36
View File
@@ -0,0 +1,36 @@
---
import PageLayout from "@/layouts/Base.astro";
const meta = {
description: "I'm a starter theme for Astro.build",
title: "About",
};
---
<PageLayout meta={meta}>
<h1 class="title mb-12">About</h1>
<div class="prose prose-sm prose-cactus max-w-none">
<p>
Hi, Im a starter Astro. Im particularly great for getting you started with your own blogging
website.
</p>
<p>Here are my some of my awesome built in features:</p>
<ul class="list-inside list-disc" role="list">
<li>I'm ultra fast as I'm a static site</li>
<li>I'm fully responsive</li>
<li>I come with a light and dark mode</li>
<li>I'm easy to customise and add additional content</li>
<li>I have Tailwind CSS styling</li>
<li>Shiki code syntax highlighting</li>
<li>Satori for auto generating OG images for blog posts</li>
</ul>
<p>
Clone or fork my <a
class="cactus-link inline-block"
href="https://github.com/chrismwilliams/astro-cactus"
rel="noreferrer"
target="_blank">repo</a
> if you like me!
</p>
</div>
</PageLayout>
+84
View File
@@ -0,0 +1,84 @@
---
import { type CollectionEntry, getCollection } from "astro:content";
import PostPreview from "@/components/blog/PostPreview.astro";
import Note from "@/components/note/Note.astro";
import SocialList from "@/components/SocialList.astro";
import { getAllPosts } from "@/data/post";
import PageLayout from "@/layouts/Base.astro";
import { collectionDateSort } from "@/utils/date";
// Posts
const MAX_POSTS = 10;
const allPosts = await getAllPosts();
const allPostsByDate = allPosts.sort(collectionDateSort) as CollectionEntry<"post">[];
const latestPosts = allPostsByDate.slice(0, MAX_POSTS);
// Pinned Posts, set to a max of 3;
const MAX_PINNED_POSTS = 3;
const pinnedPosts = allPostsByDate
.values()
.filter((p) => p.data.pinned)
.take(MAX_PINNED_POSTS)
.toArray();
// Notes, set to a max of 5
const MAX_NOTES = 5;
const allNotes = await getCollection("note");
const latestNotes = allNotes
.sort(collectionDateSort)
.slice(0, MAX_NOTES) as CollectionEntry<"note">[];
---
<PageLayout meta={{ title: "Home" }}>
<section>
<h1 class="title mb-12">Hello World!</h1>
<p class="mb-4">
Hi, Im a theme for Astro, a simple starter that you can use to create your website or blog.
If you want to know more about how you can customise me, add more posts, and make it your own,
click on the GitHub icon link below and it will take you to my repo.
</p>
<SocialList />
</section>
{
pinnedPosts.length > 0 && (
<section class="mt-16">
<h2 class="title mb-6 text-xl">Pinned Posts</h2>
<ul class="space-y-4" role="list">
{pinnedPosts.map((p) => (
<li class="grid gap-1 sm:grid-cols-[auto_1fr]">
<PostPreview post={p} />
</li>
))}
</ul>
</section>
)
}
<section class="mt-16">
<h2 class="title text-accent mb-6 text-xl"><a href="/posts/">Posts</a></h2>
<ul class="space-y-4" role="list">
{
latestPosts.map((p) => (
<li class="grid gap-1 sm:grid-cols-[auto_1fr]">
<PostPreview post={p} />
</li>
))
}
</ul>
</section>
{
latestNotes.length > 0 && (
<section class="mt-16">
<h2 class="title text-accent mb-6 text-xl">
<a href="/notes/">Notes</a>
</h2>
<ul class="space-y-6" role="list">
{latestNotes.map((note) => (
<li>
<Note note={note} as="h3" isPreview />
</li>
))}
</ul>
</section>
)
}
</PageLayout>
+63
View File
@@ -0,0 +1,63 @@
---
import { type CollectionEntry, getCollection } from "astro:content";
import type { GetStaticPaths, Page } from "astro";
import { Icon } from "astro-icon/components";
import Note from "@/components/note/Note.astro";
import Pagination from "@/components/Paginator.astro";
import PageLayout from "@/layouts/Base.astro";
import { collectionDateSort } from "@/utils/date";
export const getStaticPaths = (async ({ paginate }) => {
const MAX_NOTES_PER_PAGE = 10;
const allNotes = await getCollection("note");
return paginate(allNotes.sort(collectionDateSort), { pageSize: MAX_NOTES_PER_PAGE });
}) satisfies GetStaticPaths;
interface Props {
page: Page<CollectionEntry<"note">>;
uniqueTags: string[];
}
const { page } = Astro.props;
const meta = {
description: "Read my collection of notes",
title: "Notes",
};
const paginationProps = {
...(page.url.prev && {
prevUrl: {
text: "← Previous Page",
url: page.url.prev,
},
}),
...(page.url.next && {
nextUrl: {
text: "Next Page →",
url: page.url.next,
},
}),
};
---
<PageLayout meta={meta}>
<section>
<h1 class="title mb-12 flex items-center gap-3">
Notes <a class="text-accent" href="/notes/rss.xml" target="_blank">
<span class="sr-only">RSS feed</span>
<Icon aria-hidden="true" class="h-6 w-6" focusable="false" name="mdi:rss" />
</a>
</h1>
<ul class="mt-6 space-y-8 text-start">
{
page.data.map((note) => (
<li class="">
<Note note={note} as="h2" isPreview />
</li>
))
}
</ul>
<Pagination {...paginationProps} />
</section>
</PageLayout>
+30
View File
@@ -0,0 +1,30 @@
---
import { getCollection } from "astro:content";
import type { GetStaticPaths, InferGetStaticPropsType } from "astro";
import Note from "@/components/note/Note.astro";
import PageLayout from "@/layouts/Base.astro";
// if you're using an adaptor in SSR mode, getStaticPaths wont work -> https://docs.astro.build/en/guides/routing/#modifying-the-slug-example-for-ssr
export const getStaticPaths = (async () => {
const allNotes = await getCollection("note");
return allNotes.map((note) => ({
params: { slug: note.id },
props: { note },
}));
}) satisfies GetStaticPaths;
export type Props = InferGetStaticPropsType<typeof getStaticPaths>;
const { note } = Astro.props;
const meta = {
description:
note.data.description ||
`Read about my note posted on: ${note.data.publishDate.toLocaleDateString()}`,
title: note.data.title,
};
---
<PageLayout meta={meta}>
<Note as="h1" note={note} />
</PageLayout>
+18
View File
@@ -0,0 +1,18 @@
import { getCollection } from "astro:content";
import rss from "@astrojs/rss";
import { siteConfig } from "@/site.config";
export const GET = async () => {
const notes = await getCollection("note");
return rss({
title: siteConfig.title,
description: siteConfig.description,
site: import.meta.env.SITE,
items: notes.map((note) => ({
title: note.data.title,
pubDate: note.data.publishDate,
link: `notes/${note.id}/`,
})),
});
};
+70
View File
@@ -0,0 +1,70 @@
import type { APIContext, InferGetStaticPropsType } from "astro";
import satori, { type SatoriOptions } from "satori";
import sharp from "sharp";
import RobotoMonoBold from "@/assets/roboto-mono-700.ttf";
import RobotoMono from "@/assets/roboto-mono-regular.ttf";
import { getAllPosts } from "@/data/post";
import { getFormattedDate } from "@/utils/date";
import { readCache, writeToCache } from "./_cacheUtil";
import { ogMarkup } from "./_ogMarkup";
const ogOptions: SatoriOptions = {
// debug: true,
fonts: [
{
data: Buffer.from(RobotoMono),
name: "Roboto Mono",
style: "normal",
weight: 400,
},
{
data: Buffer.from(RobotoMonoBold),
name: "Roboto Mono",
style: "normal",
weight: 700,
},
],
height: 630,
width: 1200,
};
type Props = InferGetStaticPropsType<typeof getStaticPaths>;
export async function GET(context: APIContext) {
const { pubDate, title } = context.props as Props;
// check the og-image cache
let pngBuffer = readCache(title, pubDate);
if (!pngBuffer) {
console.info(`Generating new OG image for: ${title}`);
const postDate = getFormattedDate(pubDate, {
month: "long",
weekday: "long",
});
const svg = await satori(ogMarkup(title, postDate), ogOptions);
pngBuffer = await sharp(Buffer.from(svg)).png().toBuffer();
writeToCache(title, pubDate, pngBuffer);
}
return new Response(new Uint8Array(pngBuffer), {
headers: {
"Cache-Control": "public, max-age=31536000, immutable",
"Content-Type": "image/png",
},
});
}
export async function getStaticPaths() {
const posts = await getAllPosts();
return posts
.values()
.filter(({ data }) => !data.ogImage)
.map((post) => ({
params: { slug: post.id },
props: {
pubDate: post.data.updatedDate ?? post.data.publishDate,
title: post.data.title,
},
}))
.toArray();
}
+45
View File
@@ -0,0 +1,45 @@
import { cacheDir } from "astro:config/server";
import crypto from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
// Update/bump version when changing markup in _ogMarkup.ts, or fonts/config in [...slug].png.ts
const CACHE_VERSION = "v1";
// Cache directory, defaults to node_modules/.astro/og-images
const CACHE_DIR = path.join(fileURLToPath(cacheDir), "og-images");
// Check cache directory exists, if not create dir
function checkCacheDirExists() {
if (!fs.existsSync(CACHE_DIR)) {
fs.mkdirSync(CACHE_DIR, { recursive: true });
}
}
// Returns a cache key based on CACHE_VERSION, a post's title & pubDate
function getCacheKey(title: string, pubDate: Date): string {
const content = `${CACHE_VERSION}-${title}-${pubDate.toISOString()}`;
return crypto.createHash("sha256").update(content).digest("hex").slice(0, 16);
}
// Return an OG Image from cache if it exists
export function readCache(title: string, pubDate: Date): Buffer | null {
checkCacheDirExists();
const cacheKey = getCacheKey(title, pubDate);
const cacheFilePath = path.join(CACHE_DIR, `${cacheKey}.png`);
if (fs.existsSync(cacheFilePath)) {
console.info(`Found cached OG image for: ${title}`);
return fs.readFileSync(cacheFilePath);
}
return null;
}
// Save image to cache
export function writeToCache(title: string, pubDate: Date, data: Buffer): void {
const cacheKey = getCacheKey(title, pubDate);
fs.writeFileSync(path.join(CACHE_DIR, `${cacheKey}.png`), data);
}
+15
View File
@@ -0,0 +1,15 @@
import { html } from "satori-html";
import { siteConfig } from "@/site.config";
// OG image markup, use https://og-playground.vercel.app/ to design your own.
export const ogMarkup = (title: string, pubDate: string) =>
html`<div tw="flex flex-col w-full h-full bg-[#1d1f21] text-[#c9cacc]">
<div tw="flex flex-col flex-1 w-full p-10 justify-center">
<p tw="text-2xl mb-6">${pubDate}</p>
<h1 tw="text-6xl font-bold leading-snug text-white">${title}</h1>
</div>
<div tw="flex items-center justify-between w-full p-10 border-t-2 border-[#2bbc89] text-white">
<p tw="text-2xl ml-3 font-semibold">${siteConfig.title}</p>
<p>by ${siteConfig.author}</p>
</div>
</div>`;
+147
View File
@@ -0,0 +1,147 @@
---
import type { CollectionEntry } from "astro:content";
import type { GetStaticPaths, Page } from "astro";
import { Icon } from "astro-icon/components";
import PostPreview from "@/components/blog/PostPreview.astro";
import Pagination from "@/components/Paginator.astro";
import { getAllPosts, getUniqueTags, groupPostsByYear } from "@/data/post";
import PageLayout from "@/layouts/Base.astro";
import { collectionDateSort } from "@/utils/date";
export const getStaticPaths = (async ({ paginate }) => {
const MAX_POSTS_PER_PAGE = 10;
const MAX_TAGS = 7;
const MAX_PINNED_POSTS = 3;
const allPosts = await getAllPosts();
const allPostsByDate = allPosts.sort(collectionDateSort);
const uniqueTags = getUniqueTags(allPosts).slice(0, MAX_TAGS);
const pinnedPosts = allPostsByDate
.values()
.filter((p) => p.data.pinned)
.take(MAX_PINNED_POSTS)
.toArray();
return paginate(allPostsByDate, {
pageSize: MAX_POSTS_PER_PAGE,
props: { uniqueTags, pinnedPosts },
});
}) satisfies GetStaticPaths;
interface Props {
page: Page<CollectionEntry<"post">>;
uniqueTags: string[];
pinnedPosts: CollectionEntry<"post">[];
}
const { page, uniqueTags, pinnedPosts } = Astro.props;
const meta = {
description: "Read my collection of posts and the things that interest me",
title: "Posts",
};
const paginationProps = {
...(page.url.prev && {
prevUrl: {
text: "← Previous Page",
url: page.url.prev,
},
}),
...(page.url.next && {
nextUrl: {
text: "Next Page →",
url: page.url.next,
},
}),
};
const groupedByYear = groupPostsByYear(page.data);
const descYearKeys = Object.keys(groupedByYear).sort((a, b) => +b - +a);
---
<PageLayout meta={meta}>
<div class="mb-12 flex items-center gap-3">
<h1 class="title">Posts</h1>
<a class="text-accent" href="/rss.xml" target="_blank">
<span class="sr-only">RSS feed</span>
<Icon aria-hidden="true" class="h-6 w-6" focusable="false" name="mdi:rss" />
</a>
</div>
<div class="grid sm:grid-cols-[3fr_1fr] sm:gap-x-8 sm:gap-y-16">
<div>
{
pinnedPosts.length > 0 && (
<section class="mb-16">
<h2 class="title mb-6 text-xl">Pinned Posts</h2>
<ul class="space-y-4" role="list">
{pinnedPosts.map((p) => (
<li class="grid gap-1 sm:grid-cols-[auto_1fr]">
<PostPreview post={p} />
</li>
))}
</ul>
</section>
)
}
{
descYearKeys.map((yearKey) => (
<section class="mb-16">
<h2 id={`year-${yearKey}`} class="title text-lg">
<span class="sr-only">Posts in</span>
{yearKey}
</h2>
<ul class="mt-5 space-y-4 text-start">
{groupedByYear[yearKey]?.map((p) => (
<li class="grid gap-1 sm:grid-cols-[auto_1fr] sm:[&_q]:col-start-2">
<PostPreview post={p} />
</li>
))}
</ul>
</section>
))
}
<Pagination {...paginationProps} />
</div>
{
!!uniqueTags.length && (
<aside>
<h2 class="title mb-4 flex items-center gap-1 text-lg">
<svg
aria-hidden="true"
class="h-6 w-6"
fill="none"
stroke="var(--color-muted)"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="1.5"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M0 0h24v24H0z" fill="none" stroke="none" />
<path d="M7.859 6h-2.834a2.025 2.025 0 0 0 -2.025 2.025v2.834c0 .537 .213 1.052 .593 1.432l6.116 6.116a2.025 2.025 0 0 0 2.864 0l2.834 -2.834a2.025 2.025 0 0 0 0 -2.864l-6.117 -6.116a2.025 2.025 0 0 0 -1.431 -.593z" />
<path d="M17.573 18.407l2.834 -2.834a2.025 2.025 0 0 0 0 -2.864l-7.117 -7.116" />
<path d="M6 9h-.01" />
</svg>
Tags
</h2>
<ul class="flex flex-wrap gap-2">
{uniqueTags.map((tag) => (
<li>
<a class="cactus-link flex items-center justify-center" href={`/tags/${tag}/`}>
<span aria-hidden="true">#</span>
<span class="sr-only">View all posts with the tag</span>
{tag}
</a>
</li>
))}
</ul>
<span class="mt-4 block sm:text-end">
<a class="hover:text-link" href="/tags/">
View all <span aria-hidden="true">→</span>
<span class="sr-only">blog tags</span>
</a>
</span>
</aside>
)
}
</div>
</PageLayout>
+24
View File
@@ -0,0 +1,24 @@
---
import { render } from "astro:content";
import type { GetStaticPaths, InferGetStaticPropsType } from "astro";
import { getAllPosts } from "@/data/post";
import PostLayout from "@/layouts/BlogPost.astro";
// if you're using an adaptor in SSR mode, getStaticPaths wont work -> https://docs.astro.build/en/guides/routing/#modifying-the-slug-example-for-ssr
export const getStaticPaths = (async () => {
const blogEntries = await getAllPosts();
return blogEntries.map((post) => ({
params: { slug: post.id },
props: { post },
}));
}) satisfies GetStaticPaths;
type Props = InferGetStaticPropsType<typeof getStaticPaths>;
const { post } = Astro.props;
const { Content } = await render(post);
---
<PostLayout post={post}>
<Content />
</PostLayout>
+19
View File
@@ -0,0 +1,19 @@
import rss from "@astrojs/rss";
import { getAllPosts } from "@/data/post";
import { siteConfig } from "@/site.config";
export const GET = async () => {
const posts = await getAllPosts();
return rss({
title: siteConfig.title,
description: siteConfig.description,
site: import.meta.env.SITE,
items: posts.map((post) => ({
title: post.data.title,
description: post.data.description,
pubDate: post.data.publishDate,
link: `posts/${post.id}/`,
})),
});
};
+79
View File
@@ -0,0 +1,79 @@
---
import { render } from "astro:content";
import type { GetStaticPaths, InferGetStaticPropsType } from "astro";
import { Icon } from "astro-icon/components";
import PostPreview from "@/components/blog/PostPreview.astro";
import Pagination from "@/components/Paginator.astro";
import { getAllPosts, getTagMeta, getUniqueTags } from "@/data/post";
import PageLayout from "@/layouts/Base.astro";
import { collectionDateSort } from "@/utils/date";
export const getStaticPaths = (async ({ paginate }) => {
const allPosts = await getAllPosts();
const sortedPosts = allPosts.sort(collectionDateSort);
const uniqueTags = getUniqueTags(sortedPosts);
return uniqueTags.flatMap((tag) => {
const postsWithTag = sortedPosts.filter((post) => post.data.tags.includes(tag));
return paginate(postsWithTag, {
pageSize: 10,
params: { tag },
});
});
}) satisfies GetStaticPaths;
type Props = InferGetStaticPropsType<typeof getStaticPaths>;
const { page } = Astro.props as Props;
const { tag } = Astro.params;
const tagMeta = await getTagMeta(tag);
const TagContent = tagMeta ? (await render(tagMeta)).Content : null;
const meta = {
description: tagMeta?.data.description ?? `View all posts with the tag - ${tag}`,
title: tagMeta?.data.title ?? `Posts about ${tag}`,
};
const paginationProps = {
...(page.url.prev && {
prevUrl: {
text: "← Previous Tags",
url: page.url.prev,
},
}),
...(page.url.next && {
nextUrl: {
text: "Next Tags →",
url: page.url.next,
},
}),
};
---
<PageLayout meta={meta}>
<nav class="mb-8" aria-label="Breadcrumbs">
<ul class="flex items-center">
<li class="flex items-center">
<a class="text-accent" href="/tags/">Tags</a>
<Icon aria-hidden="true" name="mdi:chevron-right" class="mx-1.5" />
</li>
<li aria-current="page" class=""><span aria-hidden="true">#</span>{tag}</li>
</ul>
</nav>
<h1 class="title capitalize">{tagMeta?.data.title ?? `Posts about ${tag}`}</h1>
<div class="prose prose-sm prose-cactus mb-16 max-w-none">
{tagMeta?.data.description && <p>{tagMeta.data.description}</p>}
{TagContent && <TagContent />}
</div>
<ul class="space-y-4">
{
page.data.map((p) => (
<li class="grid gap-1 sm:grid-cols-[auto_1fr]">
<PostPreview as="h2" post={p} />
</li>
))
}
</ul>
<Pagination {...paginationProps} />
</PageLayout>
+34
View File
@@ -0,0 +1,34 @@
---
import { getAllPosts, getUniqueTagsWithCount } from "@/data/post";
import PageLayout from "@/layouts/Base.astro";
const allPosts = await getAllPosts();
const allTags = getUniqueTagsWithCount(allPosts);
const meta = {
description: "A list of all the topics I've written about in my posts",
title: "All Tags",
};
---
<PageLayout meta={meta}>
<h1 class="title mb-6">Tags</h1>
<ul class="space-y-4">
{
allTags.map(([tag, val]) => (
<li class="flex items-center gap-x-2">
<a
class="cactus-link inline-block"
href={`/tags/${tag}/`}
title={`View posts with the tag: ${tag}`}
>
&#35;{tag}
</a>
<span class="inline-block">
- {val} Post{val > 1 && "s"}
</span>
</li>
))
}
</ul>
</PageLayout>
+82
View File
@@ -0,0 +1,82 @@
import type { Parent, PhrasingContent, Root } from "mdast";
import type { LeafDirective, TextDirective } from "mdast-util-directive";
import { directiveToMarkdown } from "mdast-util-directive";
import { toMarkdown } from "mdast-util-to-markdown";
import { toString as mdastToString } from "mdast-util-to-string";
import type { Plugin } from "unified";
import { visit } from "unist-util-visit";
import type { AdmonitionType } from "@/types";
import { h, isNodeDirective } from "../utils/remark";
// Supported admonition types
const Admonitions = new Set<AdmonitionType>(["tip", "note", "important", "caution", "warning"]);
/** Checks if a string is a supported admonition type. */
function isAdmonition(s: string): s is AdmonitionType {
return Admonitions.has(s as AdmonitionType);
}
/**
* From Astro Starlight:
* Transforms directives not supported back to original form as it can break user content and result in 'broken' output.
*/
function transformUnhandledDirective(
node: LeafDirective | TextDirective,
index: number,
parent: Parent,
) {
const textNode = {
type: "text",
value: toMarkdown(node, { extensions: [directiveToMarkdown()] }),
} as const;
if (node.type === "textDirective") {
parent.children[index] = textNode;
} else {
parent.children[index] = {
children: [textNode],
type: "paragraph",
};
}
}
export const remarkAdmonitions: Plugin<[], Root> = () => (tree) => {
visit(tree, (node, index, parent) => {
if (!parent || index === undefined || !isNodeDirective(node)) return;
if (node.type === "textDirective" || node.type === "leafDirective") {
transformUnhandledDirective(node, index, parent);
return;
}
const admonitionType = node.name;
if (!isAdmonition(admonitionType)) return;
let title: string = admonitionType;
let titleNode: PhrasingContent[] = [{ type: "text", value: title }];
// Check if there's a custom title
const firstChild = node.children[0];
if (
firstChild?.type === "paragraph" &&
firstChild.data &&
"directiveLabel" in firstChild.data &&
firstChild.children.length > 0
) {
titleNode = firstChild.children;
title = mdastToString(firstChild.children);
// The first paragraph contains a custom title, we can safely remove it.
node.children.splice(0, 1);
}
// Do not change prefix to AD, ADM, or similar, adblocks will block the content inside.
const admonition = h(
"aside",
{ "aria-label": title, class: "admonition", "data-admonition-type": admonitionType },
[
h("p", { class: "admonition-title", "aria-hidden": "true" }, [...titleNode]),
h("div", { class: "admonition-content" }, node.children),
],
);
parent.children[index] = admonition;
});
};
+146
View File
@@ -0,0 +1,146 @@
import type { Root } from "mdast";
import type { Plugin } from "unified";
import { visit } from "unist-util-visit";
import { h, isNodeDirective } from "../utils/remark";
const DIRECTIVE_NAME = "github";
export const remarkGithubCard: Plugin<[], Root> = () => (tree) => {
visit(tree, (node, index, parent) => {
if (!parent || index === undefined || !isNodeDirective(node)) return;
// We only want a leaf directive named DIRECTIVE_NAME
if (node.type !== "leafDirective" || node.name !== DIRECTIVE_NAME) return;
let repoName = node.attributes?.repo ?? node.attributes?.user ?? null;
if (!repoName) return; // Leave the directive as-is if no repo is provided
repoName = repoName.endsWith("/") ? repoName.slice(0, -1) : repoName; // Remove trailing slash
repoName = repoName.startsWith("https://github.com/")
? repoName.replace("https://github.com/", "")
: repoName; // Remove leading URL
const repoParts = repoName.split("/");
const SimpleUUID = `GC-${crypto.randomUUID()}`;
const realUrl = `https://github.com/${repoName}`;
// If its a repo link
if (repoParts.length > 1) {
const script = h("script", {}, [
{
type: "text",
value: `
fetch('https://api.github.com/repos/${repoName}', { referrerPolicy: "no-referrer" })
.then(response => response.json())
.then(data => {
const t = document.getElementById('${SimpleUUID}');
t.classList.remove("gh-loading");
if (data.description) {
t.querySelector('.gh-description').innerText = data.description.replace(/:[a-zA-Z0-9_]+:/g, '');
} else {
t.querySelector('.gh-description').style.display = 'none';
}
if (data.language) t.querySelector('.gh-language').innerText = data.language;
t.querySelector('.gh-forks').innerText = Intl.NumberFormat(undefined, { notation: "compact", maximumFractionDigits: 1 }).format(data.forks).replaceAll("\u202f", '');
t.querySelector('.gh-stars').innerText = Intl.NumberFormat(undefined, { notation: "compact", maximumFractionDigits: 1 }).format(data.stargazers_count).replaceAll("\u202f", '');
const avatarEl = t.querySelector('.gh-avatar');
avatarEl.style.backgroundImage = 'url(' + data.owner.avatar_url + ')';
if (data.license?.spdx_id) {
t.querySelector('.gh-license').innerText = data.license?.spdx_id
} else {
t.querySelector('.gh-license').style.display = 'none';
};
})
.catch(err => {
document.getElementById('${SimpleUUID}').classList.add("gh-error")
console.warn("[GITHUB-CARD] Error loading card for ${repoName} | ${SimpleUUID}.", err)
})
`,
},
]);
const hTitle = h("div", { class: "gh-title title" }, [
h("span", { class: "gh-avatar" }),
h("a", { class: "gh-text not-prose cactus-link", href: realUrl }, [
{ type: "text", value: `${repoParts[0]}/${repoParts[1]}` },
]),
h("span", { class: "gh-icon" }),
]);
const hChips = h("div", { class: "gh-chips" }, [
h("span", { class: "gh-stars" }, [{ type: "text", value: "00K" }]),
h("span", { class: "gh-forks" }, [{ type: "text", value: "00K" }]),
h("span", { class: "gh-license" }, [{ type: "text", value: "MIT" }]),
h("span", { class: "gh-language" }, [{ type: "text", value: "" }]),
]);
const hDescription = h("div", { class: "gh-description" }, [
{
type: "text",
value: "Lorem ipsum dolor sit amet, consectetur adipiscing elit.",
},
]);
parent.children.splice(
index,
1,
h("div", { id: SimpleUUID, class: "github-card gh-loading" }, [
hTitle,
hDescription,
hChips,
script,
]),
);
}
// If its a user link
else if (repoParts.length === 1) {
const script = h("script", {}, [
{
type: "text",
value: `
fetch('https://api.github.com/users/${repoName}', { referrerPolicy: "no-referrer" })
.then(response => response.json())
.then(data => {
const t = document.getElementById('${SimpleUUID}');
t.classList.remove("gh-loading");
const avatarEl = t.querySelector('.gh-avatar');
avatarEl.style.backgroundImage = 'url(' + data.avatar_url + ')';
t.querySelector('.gh-followers').innerText = Intl.NumberFormat(undefined, { notation: "compact", maximumFractionDigits: 1 }).format(data.followers).replaceAll("\u202f", '');
t.querySelector('.gh-repositories').innerText = Intl.NumberFormat(undefined, { notation: "compact", maximumFractionDigits: 1 }).format(data.public_repos).replaceAll("\u202f", '');
if (data.location) t.querySelector('.gh-region').innerText = data.location;
})
.catch(err => {
const c = document.getElementById('${SimpleUUID}').classList.add("gh-error")
console.warn("[GITHUB-CARD] Error loading card for ${repoName} | ${SimpleUUID}.", err)
})
`,
},
]);
parent.children.splice(
index,
1,
h("div", { id: SimpleUUID, class: "github-card gh-simple gh-loading" }, [
h("div", { class: "gh-title title" }, [
h("span", { class: "gh-avatar" }),
h("a", { class: "gh-text not-prose cactus-link", href: realUrl }, [
{ type: "text", value: repoParts[0] },
]),
h("span", { class: "gh-icon" }),
]),
h("div", { class: "gh-chips" }, [
h("span", { class: "gh-followers" }, [{ type: "text", value: "00K" }]),
h("span", { class: "gh-repositories" }, [{ type: "text", value: "00K" }]),
h("span", { class: "gh-region" }, [{ type: "text", value: "" }]),
]),
script,
]),
);
}
});
};
+11
View File
@@ -0,0 +1,11 @@
import { toString as mdastToString } from "mdast-util-to-string";
import getReadingTime from "reading-time";
export function remarkReadingTime() {
// @ts-expect-error:next-line
return (tree, { data }) => {
const textOnPage = mdastToString(tree);
const readingTime = getReadingTime(textOnPage);
data.astro.frontmatter.readingTime = readingTime.text;
};
}
+81
View File
@@ -0,0 +1,81 @@
import type { AstroExpressiveCodeOptions } from "astro-expressive-code";
import type { SiteConfig } from "@/types";
export const siteConfig: SiteConfig = {
// ! Please remember to replace the following site property with your own domain, used in astro.config.ts
url: "https://astro-cactus.chriswilliams.dev/",
/*
- Used to construct the meta title property found in src/components/BaseHead.astro L:11
- The webmanifest name found in astro.config.ts L:42
- The link value found in src/components/layout/Header.astro L:35
- In the footer found in src/components/layout/Footer.astro L:12
*/
title: "Astro Cactus",
// Used as both a meta property (src/components/BaseHead.astro L:31 + L:49) & the generated satori png (src/pages/og-image/[slug].png.ts)
author: "Chris Williams",
// Used as the default description meta property and webmanifest description
description: "An opinionated starter theme for Astro",
// HTML lang property, found in src/layouts/Base.astro L:18 & astro.config.ts L:48
lang: "en-GB",
// Meta property, found in src/components/BaseHead.astro L:42
ogLocale: "en_GB",
// Date.prototype.toLocaleDateString() parameters, found in src/utils/date.ts.
date: {
locale: "en-GB",
options: {
day: "numeric",
month: "short",
year: "numeric",
},
},
};
// Used to generate links in both the Header & Footer.
export const menuLinks: { path: string; title: string }[] = [
{
path: "/",
title: "Home",
},
{
path: "/about/",
title: "About",
},
{
path: "/posts/",
title: "Blog",
},
{
path: "/notes/",
title: "Notes",
},
];
// https://expressive-code.com/reference/configuration/
export const expressiveCodeOptions: AstroExpressiveCodeOptions = {
styleOverrides: {
borderRadius: "4px",
codeFontFamily:
'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace',
codeFontSize: "0.875rem",
codeLineHeight: "1.7142857rem",
codePaddingInline: "1rem",
frames: {
frameBoxShadowCssValue: "none",
},
uiLineHeight: "inherit",
},
themeCssSelector(theme, { styleVariants }) {
// If one dark and one light theme are available
// generate theme CSS selectors compatible with cactus-theme dark mode switch
if (styleVariants.length >= 2) {
const baseTheme = styleVariants[0]?.theme;
const altTheme = styleVariants.find((v) => v.theme.type !== baseTheme?.type)?.theme;
if (theme === baseTheme || theme === altTheme) return `[data-theme='${theme.type}']`;
}
// return default selector
return `[data-theme="${theme.name}"]`;
},
// One dark, one light theme => https://expressive-code.com/guides/themes/#available-themes
themes: ["dracula", "github-light"],
useThemedScrollbars: false,
};
+83
View File
@@ -0,0 +1,83 @@
export interface SiteConfig {
author: string;
date: {
locale: string | string[] | undefined;
options: Intl.DateTimeFormatOptions;
};
description: string;
lang: string;
ogLocale: string;
title: string;
url: string;
}
export interface PaginationLink {
srLabel?: string;
text?: string;
url: string;
}
export interface SiteMeta {
articleDate?: string | undefined;
description?: string;
ogImage?: string | undefined;
title: string;
}
/** Webmentions */
export interface WebmentionsFeed {
children: WebmentionsChildren[];
name: string;
type: string;
}
export interface WebmentionsCache {
children: WebmentionsChildren[];
lastFetched: null | string;
}
export interface WebmentionsChildren {
author: Author | null;
content?: Content | null;
"mention-of": string;
name?: null | string;
photo?: null | string[];
published?: null | string;
rels?: Rels | null;
summary?: Summary | null;
syndication?: null | string[];
type: string;
url: string;
"wm-id": number;
"wm-private": boolean;
"wm-property": string;
"wm-protocol": string;
"wm-received": string;
"wm-source": string;
"wm-target": string;
}
export interface Author {
name: string;
photo: string;
type: string;
url: string;
}
export interface Content {
"content-type": string;
html: string;
text: string;
value: string;
}
export interface Rels {
canonical: string;
}
export interface Summary {
"content-type": string;
value: string;
}
export type AdmonitionType = "tip" | "note" | "important" | "caution" | "warning";
+23
View File
@@ -0,0 +1,23 @@
import type { CollectionEntry } from "astro:content";
import { siteConfig } from "@/site.config";
export function getFormattedDate(
date: Date | undefined,
options?: Intl.DateTimeFormatOptions,
): string {
if (date === undefined) {
return "Invalid Date";
}
return new Intl.DateTimeFormat(siteConfig.date.locale, {
...(siteConfig.date.options as Intl.DateTimeFormatOptions),
...options,
}).format(date);
}
export function collectionDateSort(
a: CollectionEntry<"post" | "note">,
b: CollectionEntry<"post" | "note">,
) {
return b.data.publishDate.getTime() - a.data.publishDate.getTime();
}
+11
View File
@@ -0,0 +1,11 @@
export function toggleClass(element: HTMLElement, className: string) {
element.classList.toggle(className);
}
export function elementHasClass(element: HTMLElement, className: string) {
return element.classList.contains(className);
}
export function rootInDarkMode() {
return document.documentElement.getAttribute("data-theme") === "dark";
}
+37
View File
@@ -0,0 +1,37 @@
// Heavy inspiration from starlight: https://github.com/withastro/starlight/blob/main/packages/starlight/utils/generateToC.ts
import type { MarkdownHeading } from "astro";
export interface TocItem extends MarkdownHeading {
children: TocItem[];
}
interface TocOpts {
maxHeadingLevel?: number | undefined;
minHeadingLevel?: number | undefined;
}
/** Inject a ToC entry as deep in the tree as its `depth` property requires. */
function injectChild(items: TocItem[], item: TocItem): void {
const lastItem = items.at(-1);
if (!lastItem || lastItem.depth >= item.depth) {
items.push(item);
} else {
injectChild(lastItem.children, item);
return;
}
}
export function generateToc(
headings: ReadonlyArray<MarkdownHeading>,
{ maxHeadingLevel = 4, minHeadingLevel = 2 }: TocOpts = {},
) {
// by default this ignores/filters out h1 and h5 heading(s)
const bodyHeadings = headings.filter(
({ depth }) => depth >= minHeadingLevel && depth <= maxHeadingLevel,
);
const toc: Array<TocItem> = [];
for (const heading of bodyHeadings) injectChild(toc, { ...heading, children: [] });
return toc;
}
+23
View File
@@ -0,0 +1,23 @@
import { h as _h, type Properties } from "hastscript";
import type { Node, Paragraph as P } from "mdast";
import type { Directives } from "mdast-util-directive";
/** Checks if a node is a directive. */
export function isNodeDirective(node: Node): node is Directives {
return (
node.type === "containerDirective" ||
node.type === "leafDirective" ||
node.type === "textDirective"
);
}
/** From Astro Starlight: Function that generates an mdast HTML tree ready for conversion to HTML by rehype. */
// biome-ignore lint/suspicious/noExplicitAny: allow any children
export function h(el: string, attrs: Properties = {}, children: any[] = []): P {
const { properties, tagName } = _h(el, attrs);
return {
children,
data: { hName: tagName, hProperties: properties },
type: "paragraph",
};
}
+115
View File
@@ -0,0 +1,115 @@
import { WEBMENTION_API_KEY } from "astro:env/server";
import * as fs from "node:fs";
import type { WebmentionsCache, WebmentionsChildren, WebmentionsFeed } from "@/types";
const DOMAIN = import.meta.env.SITE;
const CACHE_DIR = ".data";
const filePath = `${CACHE_DIR}/webmentions.json`;
const validWebmentionTypes = ["like-of", "mention-of", "in-reply-to"];
const hostName = new URL(DOMAIN).hostname;
// Calls webmention.io api.
async function fetchWebmentions(timeFrom: string | null, perPage = 1000) {
if (!DOMAIN) {
console.warn("No domain specified. Please set in astro.config.ts");
return null;
}
if (!WEBMENTION_API_KEY) {
console.warn("No webmention api token specified in .env");
return null;
}
let url = `https://webmention.io/api/mentions.jf2?domain=${hostName}&token=${WEBMENTION_API_KEY}&sort-dir=up&per-page=${perPage}`;
if (timeFrom) url += `&since${timeFrom}`;
const res = await fetch(url);
if (res.ok) {
const data = (await res.json()) as WebmentionsFeed;
return data;
}
return null;
}
// Merge cached entries [a] with fresh webmentions [b], merge by wm-id
function mergeWebmentions(a: WebmentionsCache, b: WebmentionsFeed): WebmentionsChildren[] {
return Array.from(
[...a.children, ...b.children]
.reduce((map, obj) => map.set(obj["wm-id"], obj), new Map())
.values(),
);
}
// filter out WebmentionChildren
export function filterWebmentions(webmentions: WebmentionsChildren[]) {
return webmentions.filter((webmention) => {
// make sure the mention has a property so we can sort them later
if (!validWebmentionTypes.includes(webmention["wm-property"])) return false;
// make sure 'mention-of' or 'in-reply-to' has text content.
if (webmention["wm-property"] === "mention-of" || webmention["wm-property"] === "in-reply-to") {
return webmention.content && webmention.content.text !== "";
}
return true;
});
}
// save combined webmentions in cache file
function writeToCache(data: WebmentionsCache) {
const fileContent = JSON.stringify(data, null, 2);
// create cache folder if it doesn't exist already
if (!fs.existsSync(CACHE_DIR)) {
fs.mkdirSync(CACHE_DIR);
}
// write data to cache json file
fs.writeFile(filePath, fileContent, (err) => {
if (err) throw err;
console.log(`Webmentions saved to ${filePath}`);
});
}
function getFromCache(): WebmentionsCache {
if (fs.existsSync(filePath)) {
const data = fs.readFileSync(filePath, "utf-8");
return JSON.parse(data);
}
// no cache found
return {
lastFetched: null,
children: [],
};
}
async function getAndCacheWebmentions() {
const cache = getFromCache();
const mentions = await fetchWebmentions(cache.lastFetched);
if (mentions) {
mentions.children = filterWebmentions(mentions.children);
const webmentions: WebmentionsCache = {
lastFetched: new Date().toISOString(),
// Make sure the first arg is the cache
children: mergeWebmentions(cache, mentions),
};
writeToCache(webmentions);
return webmentions;
}
return cache;
}
let webMentions: WebmentionsCache;
export async function getWebmentionsForUrl(url: string) {
if (!webMentions) webMentions = await getAndCacheWebmentions();
return webMentions.children.filter((entry) => entry["wm-target"] === url);
}