Initial commit from Astro
Some checks failed
ci / Check for build and type issues (push) Has been cancelled
Some checks failed
ci / Check for build and type issues (push) Has been cancelled
This commit is contained in:
commit
46709a2b20
9
.editorconfig
Normal file
9
.editorconfig
Normal 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
.example.env
Normal file
3
.example.env
Normal file
@ -0,0 +1,3 @@
|
||||
WEBMENTION_API_KEY=
|
||||
WEBMENTION_URL=
|
||||
WEBMENTION_PINGBACK=#optional
|
2
.github/FUNDING.yml
vendored
Normal file
2
.github/FUNDING.yml
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
# These are supported funding model platforms
|
||||
buy_me_a_coffee: chris.williams
|
1
.github/config/exclude.txt
vendored
Normal file
1
.github/config/exclude.txt
vendored
Normal file
@ -0,0 +1 @@
|
||||
.vscode/
|
14
.github/dependabot.yml
vendored
Normal file
14
.github/dependabot.yml
vendored
Normal file
@ -0,0 +1,14 @@
|
||||
# To get started with Dependabot version updates, you'll need to specify which
|
||||
# package ecosystems to update and where the package manifests are located.
|
||||
# Please see the documentation for all configuration options:
|
||||
# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates
|
||||
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "npm" # See documentation for possible values
|
||||
directory: "/" # Location of package manifests
|
||||
schedule:
|
||||
interval: "monthly"
|
||||
allow:
|
||||
- dependency-type: "direct"
|
||||
open-pull-requests-limit: 20
|
21
.github/pull_request_template.md
vendored
Normal file
21
.github/pull_request_template.md
vendored
Normal file
@ -0,0 +1,21 @@
|
||||
<!-- Thank you for opening a PR and making this theme even better, I appreciate you taking the time to help out 🙌 -->
|
||||
|
||||
#### What kind of changes does this PR include?
|
||||
|
||||
<!-- Delete any that don’t apply -->
|
||||
|
||||
- Minor fixes (broken links, typos, css, etc.)
|
||||
- Changes with larger consequences (logic, library updates, etc.)
|
||||
- Something else!
|
||||
|
||||
#### Description
|
||||
|
||||
- Closes # <!-- Add an issue number if this PR will close it. -->
|
||||
- What does this PR change? A brief description would be great.
|
||||
- Did you change something visual? A before/after screenshot can be helpful.
|
||||
|
||||
<!--
|
||||
Here’s what will happen next:
|
||||
Hopefully I'll get time soon after your pull request to take a look and may ask you to make changes.
|
||||
I'll try to be responsive, but don’t worry if this takes a week or 2.
|
||||
-->
|
54
.github/workflows/ci.yml
vendored
Normal file
54
.github/workflows/ci.yml
vendored
Normal file
@ -0,0 +1,54 @@
|
||||
name: ci
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
name: Check for build and type issues
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: install node
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 18
|
||||
|
||||
- name: install pnpm
|
||||
uses: pnpm/action-setup@v2
|
||||
with:
|
||||
version: 8
|
||||
run_install: false
|
||||
|
||||
- name: Get pnpm store directory
|
||||
shell: bash
|
||||
run: |
|
||||
echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
|
||||
|
||||
- name: Setup pnpm cache
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: ${{ env.STORE_PATH }}
|
||||
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pnpm-store-
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install
|
||||
|
||||
# use astro check for issues
|
||||
- name: Run check
|
||||
run: pnpm astro check
|
||||
|
||||
# ensure build works
|
||||
- name: Run build
|
||||
run: pnpm build
|
32
.github/workflows/stale.yml
vendored
Normal file
32
.github/workflows/stale.yml
vendored
Normal file
@ -0,0 +1,32 @@
|
||||
# This workflow warns and then closes issues and PRs that have had no activity for a specified amount of time.
|
||||
#
|
||||
# You can adjust the behavior by modifying this file.
|
||||
# For more information, see:
|
||||
# https://github.com/actions/stale
|
||||
name: Mark stale issues and pull requests
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "39 23 * * *"
|
||||
|
||||
jobs:
|
||||
stale:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
|
||||
steps:
|
||||
- uses: actions/stale@v7
|
||||
with:
|
||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
stale-issue-label: "no-issue-activity"
|
||||
stale-pr-label: "no-pr-activity"
|
||||
stale-issue-message: "This issue has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs. Thank you for your contributions."
|
||||
stale-pr-message: "This PR has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs. Thank you for your contributions."
|
||||
close-issue-message: "This issue was closed because it has been stalled for 5 days with no activity."
|
||||
days-before-stale: 30
|
||||
days-before-close: 5
|
||||
days-before-pr-close: -1
|
||||
exempt-issue-labels: "not-stale,bug,pinned,security,pending,awaiting-approval,work-in-progress"
|
||||
exempt-pr-labels: "not-stale,bug,pinned,security,pending,awaiting-approval,work-in-progress"
|
25
.gitignore
vendored
Normal file
25
.gitignore
vendored
Normal file
@ -0,0 +1,25 @@
|
||||
# build output
|
||||
dist/
|
||||
.output/
|
||||
|
||||
# dependencies
|
||||
node_modules/
|
||||
|
||||
# logs
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
|
||||
|
||||
# environment variables
|
||||
.env
|
||||
.env.production
|
||||
|
||||
# macOS-specific files
|
||||
.DS_Store
|
||||
|
||||
# misc
|
||||
*.pem
|
||||
.cache
|
||||
.astro
|
8
.prettierignore
Normal file
8
.prettierignore
Normal file
@ -0,0 +1,8 @@
|
||||
*.min.js
|
||||
node_modules
|
||||
|
||||
# cache-dirs
|
||||
**/.cache
|
||||
|
||||
pnpm-lock.yaml
|
||||
dist
|
23
.prettierrc.js
Normal file
23
.prettierrc.js
Normal file
@ -0,0 +1,23 @@
|
||||
/** @type {import("@types/prettier").Options} */
|
||||
module.exports = {
|
||||
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,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
4
.vscode/extensions.json
vendored
Normal file
4
.vscode/extensions.json
vendored
Normal file
@ -0,0 +1,4 @@
|
||||
{
|
||||
"recommendations": ["astro-build.astro-vscode", "unifiedjs.vscode-mdx"],
|
||||
"unwantedRecommendations": []
|
||||
}
|
46
.vscode/post.code-snippets
vendored
Normal file
46
.vscode/post.code-snippets
vendored
Normal file
@ -0,0 +1,46 @@
|
||||
{
|
||||
// Place your astro-cactus workspace snippets here. Each snippet is defined under a snippet name and has a scope, prefix, body and
|
||||
// description. Add comma separated ids of the languages where the snippet is applicable in the scope field. If scope
|
||||
// is left empty or omitted, the snippet gets applied to all languages. The prefix is what is
|
||||
// used to trigger the snippet and the body will be expanded and inserted. Possible variables are:
|
||||
// $1, $2 for tab stops, $0 for the final cursor position, and ${1:label}, ${2:another} for placeholders.
|
||||
// Placeholders with the same ids are connected.
|
||||
// Example:
|
||||
// "Print to console": {
|
||||
// "scope": "javascript,typescript",
|
||||
// "prefix": "log",
|
||||
// "body": [
|
||||
// "console.log('$1');",
|
||||
// "$2"
|
||||
// ],
|
||||
// "description": "Log output to console"
|
||||
// }
|
||||
"Add frontmatter to an Astro Cactus Post": {
|
||||
"scope": "markdown,mdx",
|
||||
"prefix": "frontmatter-post",
|
||||
"body": [
|
||||
"---",
|
||||
"title: ${TM_FILENAME_BASE/(.*)/${1:/capitalize}/}",
|
||||
"description: 'Please enter a description of your post here, between 50-160 chars!'",
|
||||
"publishDate: $CURRENT_DATE $CURRENT_MONTH_NAME $CURRENT_YEAR",
|
||||
"tags: []",
|
||||
"draft: false",
|
||||
"---",
|
||||
"$2",
|
||||
],
|
||||
"description": "Add frontmatter for new Markdown post",
|
||||
},
|
||||
"Add frontmatter to an Astro Cactus Note": {
|
||||
"scope": "markdown,mdx",
|
||||
"prefix": "frontmatter-note",
|
||||
"body": [
|
||||
"---",
|
||||
"title: ${TM_FILENAME_BASE/(.*)/${1:/capitalize}/}",
|
||||
"description: 'Enter a description here (optional)'",
|
||||
"publishDate: \"${CURRENT_YEAR}-${CURRENT_MONTH}-${CURRENT_DATE}T${CURRENT_HOUR}:${CURRENT_MINUTE}:00Z\"",
|
||||
"---",
|
||||
"$2",
|
||||
],
|
||||
"description": "Add frontmatter for a new Markdown note",
|
||||
},
|
||||
}
|
24
.vscode/settings.json
vendored
Normal file
24
.vscode/settings.json
vendored
Normal file
@ -0,0 +1,24 @@
|
||||
{
|
||||
"[javascript]": { "editor.defaultFormatter": "biomejs.biome" },
|
||||
"[typescript]": { "editor.defaultFormatter": "biomejs.biome" },
|
||||
"[javascriptreact]": { "editor.defaultFormatter": "biomejs.biome" },
|
||||
"[typescriptreact]": { "editor.defaultFormatter": "biomejs.biome" },
|
||||
"[json]": { "editor.defaultFormatter": "biomejs.biome" },
|
||||
"[jsonc]": { "editor.defaultFormatter": "biomejs.biome" },
|
||||
"editor.formatOnSave": true,
|
||||
"prettier.documentSelectors": ["**/*.astro"],
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.organizeImports": "never",
|
||||
"source.organizeImports.biome": "explicit",
|
||||
"quickfix.biome": "explicit"
|
||||
},
|
||||
"[markdown]": {
|
||||
"editor.wordWrap": "on"
|
||||
},
|
||||
"typescript.tsdk": "node_modules/typescript/lib",
|
||||
"astro.content-intellisense": true,
|
||||
"files.associations": {
|
||||
"*.css": "tailwindcss"
|
||||
},
|
||||
"tailwindCSS.experimental.configFile": "./src/styles/global.css"
|
||||
}
|
21
LICENSE
Normal file
21
LICENSE
Normal 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.
|
173
README.md
Normal file
173
README.md
Normal file
@ -0,0 +1,173 @@
|
||||
<div align="center">
|
||||
<img alt="Astro Cactus logo" src="https://github.com/chrismwilliams/astro-theme-cactus/assets/12715988/85aa0d3c-ef6a-44e2-954d-ef035b4f4315" width="70" />
|
||||
</div>
|
||||
<h1 align="center">
|
||||
Astro Cactus
|
||||
</h1>
|
||||
|
||||
Astro Cactus is a simple opinionated starter built with [Astro](https://astro.build). Use it to create an easy-to-use blog or website.
|
||||
|
||||
## Table Of Contents
|
||||
|
||||
1. [Key Features](#key-features)
|
||||
2. [Demo](#demo-💻)
|
||||
3. [Quick start](#quick-start)
|
||||
4. [Preview](#preview)
|
||||
5. [Commands](#commands)
|
||||
6. [Configure](#configure)
|
||||
7. [Updating](#updating)
|
||||
8. [Adding Posts](#adding-posts)
|
||||
- [Frontmatter](#frontmatter)
|
||||
- [Frontmatter Snippet](#frontmatter-snippet)
|
||||
9. [Pagefind search](#pagefind-search)
|
||||
10. [Analytics](#analytics)
|
||||
11. [Deploy](#deploy)
|
||||
12. [Acknowledgment](#acknowledgment)
|
||||
|
||||
## Key Features
|
||||
|
||||
- Astro v5 Fast 🚀
|
||||
- Tailwind v4
|
||||
- Accessible, semantic HTML markup
|
||||
- Responsive & SEO-friendly
|
||||
- Dark & Light mode
|
||||
- MD & [MDX](https://docs.astro.build/en/guides/markdown-content/#mdx-only-features) posts & notes
|
||||
- Includes [Admonitions](https://astro-cactus.chriswilliams.dev/posts/markdown-elements/admonistions/)
|
||||
- [Satori](https://github.com/vercel/satori) for creating open graph png images
|
||||
- [Automatic RSS feeds](https://docs.astro.build/en/guides/rss)
|
||||
- [Webmentions](https://webmention.io/)
|
||||
- Auto-generated:
|
||||
- [sitemap](https://docs.astro.build/en/guides/integrations-guide/sitemap/)
|
||||
- [robots.txt](https://github.com/alextim/astro-lib/blob/main/packages/astro-robots-txt/README.md)
|
||||
- [web app manifest](https://github.com/alextim/astro-lib/blob/main/packages/astro-webmanifest/README.md)
|
||||
- [Pagefind](https://pagefind.app/) static search library integration
|
||||
- [Astro Icon](https://github.com/natemoo-re/astro-icon) svg icon component
|
||||
- [Expressive Code](https://expressive-code.com/) code blocks and syntax highlighter
|
||||
|
||||
## Demo 💻
|
||||
|
||||
Check out the [Demo](https://astro-cactus.chriswilliams.dev/), hosted on Netlify
|
||||
|
||||
## Quick start
|
||||
|
||||
[Create a new repo](https://github.com/chrismwilliams/astro-theme-cactus/generate) from this template.
|
||||
|
||||
```bash
|
||||
# npm 7+
|
||||
npm create astro@latest -- --template chrismwilliams/astro-theme-cactus
|
||||
|
||||
# pnpm
|
||||
pnpm dlx create-astro --template chrismwilliams/astro-theme-cactus
|
||||
```
|
||||
|
||||
[](https://app.netlify.com/start/deploy?repository=https://github.com/chrismwilliams/astro-theme-cactus) [](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fchrismwilliams%2Fastro-theme-cactus&project-name=astro-theme-cactus)
|
||||
|
||||
## Preview
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
## Commands
|
||||
|
||||
Replace pnpm with your choice of npm / yarn
|
||||
|
||||
| Command | Action |
|
||||
| :--------------- | :------------------------------------------------------------- |
|
||||
| `pnpm install` | Installs dependencies |
|
||||
| `pnpm dev` | Starts local dev server at `localhost:3000` |
|
||||
| `pnpm build` | Build your production site to `./dist/` |
|
||||
| `pnpm postbuild` | Pagefind script to build the static search of your blog posts |
|
||||
| `pnpm preview` | Preview your build locally, before deploying |
|
||||
| `pnpm sync` | Generate types based on your config in `src/content/config.ts` |
|
||||
|
||||
## Configure
|
||||
|
||||
- Edit the template's config file `src/site.config.ts`
|
||||
- **Important**: set the url property with your own domain.
|
||||
- Modify the settings for markdown code blocks, generated by [Expressive Code](https://expressive-code.com). Astro Cactus has both a dark (dracula) and light (github-light) theme. You can find more options [here](https://expressive-code.com/guides/themes/#available-themes).
|
||||
- Update file `astro.config.ts`
|
||||
- [astro-webmanifest options](https://github.com/alextim/astro-lib/blob/main/packages/astro-webmanifest/README.md)
|
||||
- Replace & update files within the `/public` folder:
|
||||
- icon.svg - used as the source to create favicons & manifest icons
|
||||
- social-card.png - used as the default og:image
|
||||
- Modify file `src/styles/global.css` with your own light and dark styles, and customise [Tailwind's theme settings](https://tailwindcss.com/docs/theme#customizing-your-theme).
|
||||
- Edit social links in `src/components/SocialList.astro` to add/replace your media profile. Icons can be found @ [icones.js.org](https://icones.js.org/), per [Astro Icon's instructions](https://www.astroicon.dev/guides/customization/#find-an-icon-set).
|
||||
- Create/edit posts & notes for your blog within `src/content/post/` & `src/content/note/` with .md/mdx file(s). See [below](#adding-posts-and-notes) for more details.
|
||||
- Read [this post](http://astro-cactus.chriswilliams.dev/posts/webmentions/) for adding webmentions to your site.
|
||||
- OG Image:
|
||||
- If you would like to change the style of the generated image the Satori library creates, open up `src/pages/og-image/[slug].png.ts` to the markup function where you can edit the html/tailwind-classes as necessary. You can use this [playground](https://og-playground.vercel.app/) to aid your design.
|
||||
- You can also create your own og images and skip satori generating it for you by adding an ogImage property in the frontmatter with a link to the asset, an example can be found in `src/content/post/social-image.md`. More info on frontmatter can be found [here](#frontmatter)
|
||||
- Optional:
|
||||
- Fonts: This theme sets the body element to the font family `font-mono`, in `src/layouts/Base.astro` on the `<body>`. You can change fonts by removing the variant `font-mono`, after which TailwindCSS will default to the `font-sans` [font family stack](https://tailwindcss.com/docs/font-family).
|
||||
|
||||
## Updating
|
||||
|
||||
If you've forked the template, you can [sync the fork](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks/syncing-a-fork) with your own project, remembering to **not** click Discard Changes as you will lose your own.
|
||||
|
||||
If you have a template repository, you can add this template as a remote, as discussed [here](https://stackoverflow.com/questions/56577184/github-pull-changes-from-a-template-repository).
|
||||
|
||||
## Adding posts and notes
|
||||
|
||||
This theme uses [Content Collections](https://docs.astro.build/en/guides/content-collections/) to organise local Markdown and MDX files, as well as type-checking frontmatter with a schema -> `src/content.config.ts`.
|
||||
|
||||
Adding a post/note is as simple as adding your .md(x) files to the `src/content/post` and/or `src/content/note` folder, the filename of which will be used as the slug/url. The posts included with this template are there as an example of how to structure your frontmatter. Additionally, the [Astro docs](https://docs.astro.build/en/guides/markdown-content/) has a detailed section on markdown pages.
|
||||
|
||||
### Post Frontmatter
|
||||
|
||||
| Property (\* required) | Description |
|
||||
| ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| title \* | Self explanatory. Used as the text link to the post, the h1 on the posts' page, and the pages title property. Has a max length of 60 chars, set in `src/content/config.ts` |
|
||||
| description \* | Similar to above, used as the seo description property. Has a min length of 50 and a max length of 160 chars, set in the post schema. |
|
||||
| publishDate \* | Again pretty simple. To change the date format/locale, currently **en-GB**, update the date option in `src/site.config.ts`. Note you can also pass additional options to the component `<FormattedDate>` if required. |
|
||||
| updatedDate | This is an optional date representing when a post has been updated, in the same format as the publishDate. |
|
||||
| tags | Tags are optional with any created post. Any new tag(s) will be shown in `yourdomain.com/posts` & `yourdomain.com/tags`, and generate the page(s) `yourdomain.com/tags/[yourTag]` |
|
||||
| coverImage | This is an optional object that will add a cover image to the top of a post. Include both a `src`: "_path-to-image_" and `alt`: "_image alt_". You can view an example in `src/content/post/cover-image.md`. |
|
||||
| ogImage | This is an optional property. An OG Image will be generated automatically for every post where this property **isn't** provided. If you would like to create your own for a specific post, include this property and a link to your image, the theme will then skip automatically generating one. |
|
||||
| draft | This is an optional property as it is set to false by default in the schema. By adding true, the post will be filtered out of the production build in a number of places, inc. getAllPosts() calls, og-images, rss feeds, and generated page[s]. You can view an example in `src/content/post/draft-post.md` |
|
||||
|
||||
### Note Frontmatter
|
||||
|
||||
| Property (\* required) | Description |
|
||||
| ---------------------- | -------------------------------------------------- |
|
||||
| title \* | string, max length 60 chars. |
|
||||
| description | to be used for the head meta description property. |
|
||||
| publishDate \* | ISO 8601 format with offsets allowed. |
|
||||
|
||||
### Frontmatter snippets
|
||||
|
||||
Astro Cactus includes a helpful VSCode snippet which creates a frontmatter 'stub' for posts and note's, found here -> `.vscode/post.code-snippets`. Start typing the word `frontmatter` on your newly created .md(x) file to trigger it. Visual Studio Code snippets appear in IntelliSense via (⌃Space) on mac, (Ctrl+Space) on windows.
|
||||
|
||||
## Pagefind search
|
||||
|
||||
This integration brings a static search feature for searching blog posts and notes. In its current form, pagefind only works once the site has been built. This theme adds a postbuild script that should be run after Astro has built the site. You can preview locally by running both build && postbuild.
|
||||
|
||||
Search results only includes pages from posts and notes. If you would like to include other/all your pages, remove/re-locate the attribute `data-pagefind-body` to the article tag found in `src/layouts/BlogPost.astro` and `src/components/note/Note.astro`.
|
||||
|
||||
It also allows you to filter posts by tags added in the frontmatter of blog posts. If you would rather remove this, remove the data attribute `data-pagefind-filter="tag"` from the link in `src/components/blog/Masthead.astro`.
|
||||
|
||||
If you would rather not include this integration, simply remove the component `src/components/Search.astro`, and uninstall both `@pagefind/default-ui` & `pagefind` from package.json. You will also need to remove the postbuild script from here as well.
|
||||
|
||||
You can reduce the initial css payload of your css, as demonstrated [here](https://github.com/chrismwilliams/astro-theme-cactus/pull/145#issue-1943779868), by lazy loading the web components styles.
|
||||
|
||||
## Analytics
|
||||
|
||||
You may want to track the number of visitors you receive to your blog/website in order to understand trends and popular posts/pages you've created. There are a number of providers out there one could use, including web hosts such as [vercel](https://vercel.com/analytics), [netlify](https://www.netlify.com/products/analytics/), and [cloudflare](https://www.cloudflare.com/web-analytics/).
|
||||
|
||||
This theme/template doesn't include a specific solution due to there being a number of use cases and/or options which some people may or may not use.
|
||||
|
||||
You may be asked to included a snippet inside the **HEAD** tag of your website when setting it up, which can be found in `src/layouts/Base.astro`. Alternatively, you can add the snippet in `src/components/BaseHead.astro`.
|
||||
|
||||
## Deploy
|
||||
|
||||
[Astro docs](https://docs.astro.build/en/guides/deploy/) has a great section and breakdown of how to deploy your own Astro site on various platforms and their idiosyncrasies.
|
||||
|
||||
By default the site will be built (see [Commands](#commands) section above) to a `/dist` directory.
|
||||
|
||||
## Acknowledgment
|
||||
|
||||
This theme was inspired by [Hexo Theme Cactus](https://github.com/probberechts/hexo-theme-cactus)
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
122
astro.config.ts
Normal file
122
astro.config.ts
Normal file
@ -0,0 +1,122 @@
|
||||
import fs from "node:fs";
|
||||
import mdx from "@astrojs/mdx";
|
||||
import sitemap from "@astrojs/sitemap";
|
||||
import tailwind from "@tailwindcss/vite";
|
||||
import expressiveCode from "astro-expressive-code";
|
||||
import icon from "astro-icon";
|
||||
import robotsTxt from "astro-robots-txt";
|
||||
import webmanifest from "astro-webmanifest";
|
||||
import { defineConfig, envField } from "astro/config";
|
||||
import { expressiveCodeOptions } from "./src/site.config";
|
||||
import { siteConfig } from "./src/site.config";
|
||||
|
||||
// Remark plugins
|
||||
import remarkDirective from "remark-directive"; /* Handle ::: directives as nodes */
|
||||
import { remarkAdmonitions } from "./src/plugins/remark-admonitions"; /* Add admonitions */
|
||||
import { remarkReadingTime } from "./src/plugins/remark-reading-time";
|
||||
|
||||
// Rehype plugins
|
||||
import { rehypeHeadingIds } from "@astrojs/markdown-remark";
|
||||
import rehypeAutolinkHeadings from "rehype-autolink-headings";
|
||||
import rehypeExternalLinks from "rehype-external-links";
|
||||
import rehypeUnwrapImages from "rehype-unwrap-images";
|
||||
|
||||
// https://astro.build/config
|
||||
export default defineConfig({
|
||||
site: siteConfig.url,
|
||||
image: {
|
||||
domains: ["webmention.io"],
|
||||
},
|
||||
integrations: [
|
||||
expressiveCode(expressiveCodeOptions),
|
||||
icon(),
|
||||
sitemap(),
|
||||
mdx(),
|
||||
robotsTxt(),
|
||||
webmanifest({
|
||||
// See: https://github.com/alextim/astro-lib/blob/main/packages/astro-webmanifest/README.md
|
||||
name: siteConfig.title,
|
||||
short_name: "Astro_Cactus", // optional
|
||||
description: siteConfig.description,
|
||||
lang: siteConfig.lang,
|
||||
icon: "public/icon.svg", // the source for generating favicon & icons
|
||||
icons: [
|
||||
{
|
||||
src: "icons/apple-touch-icon.png", // used in src/components/BaseHead.astro L:26
|
||||
sizes: "180x180",
|
||||
type: "image/png",
|
||||
},
|
||||
{
|
||||
src: "icons/icon-192.png",
|
||||
sizes: "192x192",
|
||||
type: "image/png",
|
||||
},
|
||||
{
|
||||
src: "icons/icon-512.png",
|
||||
sizes: "512x512",
|
||||
type: "image/png",
|
||||
},
|
||||
],
|
||||
start_url: "/",
|
||||
background_color: "#1d1f21",
|
||||
theme_color: "#2bbc8a",
|
||||
display: "standalone",
|
||||
config: {
|
||||
insertFaviconLinks: false,
|
||||
insertThemeColorMeta: false,
|
||||
insertManifestLink: false,
|
||||
},
|
||||
}),
|
||||
],
|
||||
markdown: {
|
||||
rehypePlugins: [
|
||||
rehypeHeadingIds,
|
||||
[rehypeAutolinkHeadings, { behavior: "wrap", properties: { className: ["not-prose"] } }],
|
||||
[
|
||||
rehypeExternalLinks,
|
||||
{
|
||||
rel: ["noreferrer", "noopener"],
|
||||
target: "_blank",
|
||||
},
|
||||
],
|
||||
rehypeUnwrapImages,
|
||||
],
|
||||
remarkPlugins: [remarkReadingTime, remarkDirective, remarkAdmonitions],
|
||||
remarkRehype: {
|
||||
footnoteLabelProperties: {
|
||||
className: [""],
|
||||
},
|
||||
},
|
||||
},
|
||||
// https://docs.astro.build/en/guides/prefetch/
|
||||
prefetch: true,
|
||||
vite: {
|
||||
optimizeDeps: {
|
||||
exclude: ["@resvg/resvg-js"],
|
||||
},
|
||||
plugins: [tailwind(), rawFonts([".ttf", ".woff"])],
|
||||
},
|
||||
env: {
|
||||
schema: {
|
||||
WEBMENTION_API_KEY: envField.string({ context: "server", access: "secret", optional: true }),
|
||||
WEBMENTION_URL: envField.string({ context: "client", access: "public", optional: true }),
|
||||
WEBMENTION_PINGBACK: envField.string({ context: "client", access: "public", optional: true }),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
function rawFonts(ext: string[]) {
|
||||
return {
|
||||
name: "vite-plugin-raw-fonts",
|
||||
// @ts-expect-error:next-line
|
||||
transform(_, id) {
|
||||
if (ext.some((e) => id.endsWith(e))) {
|
||||
const buffer = fs.readFileSync(id);
|
||||
return {
|
||||
code: `export default ${JSON.stringify(buffer)}`,
|
||||
map: null,
|
||||
};
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
36
biome.json
Normal file
36
biome.json
Normal file
@ -0,0 +1,36 @@
|
||||
{
|
||||
"$schema": "./node_modules/@biomejs/biome/configuration_schema.json",
|
||||
"formatter": {
|
||||
"indentStyle": "tab",
|
||||
"indentWidth": 2,
|
||||
"lineWidth": 100,
|
||||
"formatWithErrors": true,
|
||||
"ignore": ["*.astro"]
|
||||
},
|
||||
"organizeImports": {
|
||||
"enabled": true
|
||||
},
|
||||
"linter": {
|
||||
"enabled": true,
|
||||
"rules": {
|
||||
"recommended": true,
|
||||
"a11y": {
|
||||
"noSvgWithoutTitle": "off"
|
||||
},
|
||||
"suspicious": {
|
||||
"noExplicitAny": "warn"
|
||||
}
|
||||
}
|
||||
},
|
||||
"javascript": {
|
||||
"formatter": {
|
||||
"trailingCommas": "all",
|
||||
"semicolons": "always"
|
||||
}
|
||||
},
|
||||
"vcs": {
|
||||
"clientKind": "git",
|
||||
"enabled": true,
|
||||
"useIgnoreFile": true
|
||||
}
|
||||
}
|
2
netlify.toml
Normal file
2
netlify.toml
Normal file
@ -0,0 +1,2 @@
|
||||
[build]
|
||||
command = 'pnpm build'
|
17112
package-lock.json
generated
Normal file
17112
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
67
package.json
Normal file
67
package.json
Normal file
@ -0,0 +1,67 @@
|
||||
{
|
||||
"name": "migrate-tiff-to-astro",
|
||||
"version": "6.4.0",
|
||||
"scripts": {
|
||||
"dev": "astro dev",
|
||||
"start": "astro dev",
|
||||
"build": "astro build",
|
||||
"postbuild": "pagefind --site dist",
|
||||
"preview": "astro preview",
|
||||
"lint": "biome lint .",
|
||||
"format": "pnpm run format:code && pnpm run format:imports",
|
||||
"format:code": "biome format . --write && prettier -w \"**/*\" \"!**/*.{md,mdx}\" --ignore-unknown --cache",
|
||||
"format:imports": "biome check --formatter-enabled=false --write",
|
||||
"check": "astro check"
|
||||
},
|
||||
"dependencies": {
|
||||
"@astrojs/markdown-remark": "^6.3.1",
|
||||
"@astrojs/mdx": "4.2.4",
|
||||
"@astrojs/rss": "4.0.11",
|
||||
"@astrojs/sitemap": "3.3.0",
|
||||
"@tailwindcss/vite": "4.1.3",
|
||||
"astro": "5.6.2",
|
||||
"astro-expressive-code": "^0.41.1",
|
||||
"astro-icon": "^1.1.5",
|
||||
"astro-robots-txt": "^1.0.0",
|
||||
"astro-webmanifest": "^1.0.0",
|
||||
"cssnano": "^7.0.6",
|
||||
"hastscript": "^9.0.0",
|
||||
"mdast-util-directive": "^3.0.0",
|
||||
"mdast-util-to-markdown": "^2.1.2",
|
||||
"mdast-util-to-string": "^4.0.0",
|
||||
"rehype-autolink-headings": "^7.1.0",
|
||||
"rehype-external-links": "^3.0.0",
|
||||
"rehype-unwrap-images": "^1.0.0",
|
||||
"remark-directive": "^4.0.0",
|
||||
"satori": "0.12.2",
|
||||
"satori-html": "^0.3.2",
|
||||
"sharp": "^0.34.1",
|
||||
"unified": "^11.0.5",
|
||||
"unist-util-visit": "^5.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@astrojs/check": "^0.9.4",
|
||||
"@biomejs/biome": "^1.9.4",
|
||||
"@iconify-json/mdi": "^1.2.2",
|
||||
"@pagefind/default-ui": "^1.3.0",
|
||||
"@resvg/resvg-js": "^2.6.2",
|
||||
"@tailwindcss/typography": "^0.5.16",
|
||||
"@types/hast": "^3.0.4",
|
||||
"@types/mdast": "^4.0.4",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"pagefind": "^1.3.0",
|
||||
"prettier": "^3.4.2",
|
||||
"prettier-plugin-astro": "0.14.1",
|
||||
"prettier-plugin-tailwindcss": "^0.6.11",
|
||||
"reading-time": "^1.5.0",
|
||||
"tailwindcss": "4.1.3",
|
||||
"typescript": "^5.8.3"
|
||||
},
|
||||
"pnpm": {
|
||||
"onlyBuiltDependencies": [
|
||||
"@biomejs/biome",
|
||||
"esbuild",
|
||||
"sharp"
|
||||
]
|
||||
}
|
||||
}
|
7211
pnpm-lock.yaml
generated
Normal file
7211
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
1
public/icon.svg
Normal file
1
public/icon.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 500 500"><path fill="#B04304" d="M295.334 103.333v-40L340.667 90v40l-45.333-26.667ZM250.001 63.333l-45.334-26.666v426.666L250.001 490V63.333Z"/><path fill="#FF5D01" d="m250.001 129.944 45.333-26.667 45.333 26.667-45.333 26.667-45.333-26.667ZM204.667 36.667 250.001 10l45.333 26.667-45.333 26.666-45.334-26.666ZM295.334 63.277l45.333-26.666L386 63.277l-45.333 26.667-45.333-26.667ZM114 223.277l45.333-26.667 45.334 26.667-45.334 26.667L114 223.277ZM250 249.944l-45.333-26.667v53.333L250 249.944Z"/><path fill="#53C68C" d="m250 63.333 45.333-26.666v120L340.667 130V90L386 63.333V170l-90.667 53.333v240L250 490V316.667L159.333 370V250l45.334-26.667v53.334L250 250V63.333Z"/><path fill="#B04304" d="M159.333 250 114 223.334v120L159.333 370V250Z"/></svg>
|
After Width: | Height: | Size: 814 B |
BIN
public/social-card.png
Normal file
BIN
public/social-card.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 8.2 KiB |
BIN
src/assets/roboto-mono-700.ttf
Normal file
BIN
src/assets/roboto-mono-700.ttf
Normal file
Binary file not shown.
BIN
src/assets/roboto-mono-regular.ttf
Normal file
BIN
src/assets/roboto-mono-regular.ttf
Normal file
Binary file not shown.
86
src/components/BaseHead.astro
Normal file
86
src/components/BaseHead.astro
Normal file
@ -0,0 +1,86 @@
|
||||
---
|
||||
import { WEBMENTION_PINGBACK, WEBMENTION_URL } from "astro:env/client";
|
||||
import { siteConfig } from "@/site.config";
|
||||
import type { SiteMeta } from "@/types";
|
||||
import "@/styles/global.css";
|
||||
|
||||
type Props = SiteMeta;
|
||||
|
||||
const { articleDate, description, ogImage, title } = Astro.props;
|
||||
|
||||
const titleSeparator = "•";
|
||||
const siteTitle = `${title} ${titleSeparator} ${siteConfig.title}`;
|
||||
const canonicalURL = new URL(Astro.url.pathname, Astro.site);
|
||||
const socialImageURL = new URL(ogImage ? ogImage : "/social-card.png", Astro.url).href;
|
||||
---
|
||||
|
||||
<meta charset="utf-8" />
|
||||
<meta content="width=device-width, initial-scale=1.0" name="viewport" />
|
||||
<title>{siteTitle}</title>
|
||||
|
||||
{/* Icons */}
|
||||
<link href="/icon.svg" rel="icon" type="image/svg+xml" />
|
||||
{
|
||||
import.meta.env.PROD && (
|
||||
<>
|
||||
{/* Favicon & Apple Icon */}
|
||||
<link rel="icon" href="/favicon-32x32.png" type="image/png" />
|
||||
<link href="/icons/apple-touch-icon.png" rel="apple-touch-icon" />
|
||||
{/* Manifest */}
|
||||
<link href="/manifest.webmanifest" rel="manifest" />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
{/* Canonical URL */}
|
||||
<link href={canonicalURL} rel="canonical" />
|
||||
|
||||
{/* Primary Meta Tags */}
|
||||
<meta content={siteTitle} name="title" />
|
||||
<meta content={description} name="description" />
|
||||
<meta content={siteConfig.author} name="author" />
|
||||
|
||||
{/* Open Graph / Facebook */}
|
||||
<meta content={articleDate ? "article" : "website"} property="og:type" />
|
||||
<meta content={title} property="og:title" />
|
||||
<meta content={description} property="og:description" />
|
||||
<meta content={canonicalURL} property="og:url" />
|
||||
<meta content={siteConfig.title} property="og:site_name" />
|
||||
<meta content={siteConfig.ogLocale} property="og:locale" />
|
||||
<meta content={socialImageURL} property="og:image" />
|
||||
<meta content="1200" property="og:image:width" />
|
||||
<meta content="630" property="og:image:height" />
|
||||
{
|
||||
articleDate && (
|
||||
<>
|
||||
<meta content={siteConfig.author} property="article:author" />
|
||||
<meta content={articleDate} property="article:published_time" />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
{/* Twitter */}
|
||||
<meta content="summary_large_image" property="twitter:card" />
|
||||
<meta content={canonicalURL} property="twitter:url" />
|
||||
<meta content={title} property="twitter:title" />
|
||||
<meta content={description} property="twitter:description" />
|
||||
<meta content={socialImageURL} property="twitter:image" />
|
||||
|
||||
{/* Sitemap */}
|
||||
<link href="/sitemap-index.xml" rel="sitemap" />
|
||||
|
||||
{/* RSS auto-discovery */}
|
||||
<link href="/rss.xml" title="Blog" rel="alternate" type="application/rss+xml" />
|
||||
<link href="/notes/rss.xml" title="Notes" rel="alternate" type="application/rss+xml" />
|
||||
|
||||
{/* Webmentions */}
|
||||
{
|
||||
WEBMENTION_URL && (
|
||||
<>
|
||||
<link href={WEBMENTION_URL} rel="webmention" />
|
||||
{WEBMENTION_PINGBACK && <link href={WEBMENTION_PINGBACK} rel="pingback" />}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
<meta content={Astro.generator} name="generator" />
|
16
src/components/FormattedDate.astro
Normal file
16
src/components/FormattedDate.astro
Normal file
@ -0,0 +1,16 @@
|
||||
---
|
||||
import { getFormattedDate } from "@/utils/date";
|
||||
import type { HTMLAttributes } from "astro/types";
|
||||
|
||||
type Props = HTMLAttributes<"time"> & {
|
||||
date: Date;
|
||||
dateTimeOptions?: Intl.DateTimeFormatOptions;
|
||||
};
|
||||
|
||||
const { date, dateTimeOptions, ...attrs } = Astro.props;
|
||||
|
||||
const postDate = getFormattedDate(date, dateTimeOptions);
|
||||
const ISO = date.toISOString();
|
||||
---
|
||||
|
||||
<time datetime={ISO} title={ISO} {...attrs}>{postDate}</time>
|
29
src/components/Paginator.astro
Normal file
29
src/components/Paginator.astro
Normal file
@ -0,0 +1,29 @@
|
||||
---
|
||||
import type { PaginationLink } from "@/types";
|
||||
|
||||
interface Props {
|
||||
nextUrl?: PaginationLink;
|
||||
prevUrl?: PaginationLink;
|
||||
}
|
||||
|
||||
const { nextUrl, prevUrl } = Astro.props;
|
||||
---
|
||||
|
||||
{
|
||||
(prevUrl || nextUrl) && (
|
||||
<nav class="mt-8 flex items-center gap-x-4">
|
||||
{prevUrl && (
|
||||
<a class="hover:text-accent me-auto py-2" data-astro-prefetch href={prevUrl.url}>
|
||||
{prevUrl.srLabel && <span class="sr-only">{prevUrl.srLabel}</span>}
|
||||
{prevUrl.text ? prevUrl.text : "Previous"}
|
||||
</a>
|
||||
)}
|
||||
{nextUrl && (
|
||||
<a class="hover:text-accent ms-auto py-2" data-astro-prefetch href={nextUrl.url}>
|
||||
{nextUrl.srLabel && <span class="sr-only">{nextUrl.srLabel}</span>}
|
||||
{nextUrl.text ? nextUrl.text : "Next"}
|
||||
</a>
|
||||
)}
|
||||
</nav>
|
||||
)
|
||||
}
|
162
src/components/Search.astro
Normal file
162
src/components/Search.astro
Normal file
@ -0,0 +1,162 @@
|
||||
---
|
||||
// Heavy inspiration taken from Astro Starlight -> https://github.com/withastro/starlight/blob/main/packages/starlight/components/Search.astro
|
||||
|
||||
import "@/styles/blocks/search.css";
|
||||
---
|
||||
|
||||
<site-search class="ms-auto" id="search">
|
||||
<button
|
||||
class="hover:text-accent flex h-9 w-9 cursor-pointer items-center justify-center rounded-md"
|
||||
aria-keyshortcuts="Control+K Meta+K"
|
||||
data-open-modal
|
||||
disabled
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
class="h-7 w-7"
|
||||
fill="none"
|
||||
height="16"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="1.5"
|
||||
viewBox="0 0 24 24"
|
||||
width="16"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path d="M0 0h24v24H0z" stroke="none"></path>
|
||||
<path d="M3 10a7 7 0 1 0 14 0 7 7 0 1 0-14 0M21 21l-6-6"></path>
|
||||
</svg>
|
||||
<span class="sr-only">Open Search</span>
|
||||
</button>
|
||||
<dialog
|
||||
aria-label="search"
|
||||
class="bg-global-bg h-full max-h-full w-full max-w-full border border-zinc-400 shadow-sm backdrop:backdrop-blur-sm open:flex sm:mx-auto sm:mt-16 sm:mb-auto sm:h-max sm:max-h-[calc(100%-8rem)] sm:min-h-[15rem] sm:w-5/6 sm:max-w-[48rem] sm:rounded-md"
|
||||
>
|
||||
<div class="dialog-frame flex grow flex-col gap-4 p-6 pt-12 sm:pt-6">
|
||||
<button
|
||||
class="ms-auto cursor-pointer rounded-md bg-zinc-200 p-2 font-semibold dark:bg-zinc-700"
|
||||
data-close-modal>Close</button
|
||||
>
|
||||
{
|
||||
import.meta.env.DEV ? (
|
||||
<div class="mx-auto text-center">
|
||||
<p>
|
||||
Search is only available in production builds. <br />
|
||||
Try building and previewing the site to test it out locally.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div class="search-container">
|
||||
<div id="cactus__search" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</dialog>
|
||||
</site-search>
|
||||
|
||||
<script>
|
||||
class SiteSearch extends HTMLElement {
|
||||
#closeBtn: HTMLButtonElement | null;
|
||||
#dialog: HTMLDialogElement | null;
|
||||
#dialogFrame: HTMLDivElement | null;
|
||||
#openBtn: HTMLButtonElement | null;
|
||||
#controller: AbortController;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.#openBtn = this.querySelector<HTMLButtonElement>("button[data-open-modal]");
|
||||
this.#closeBtn = this.querySelector<HTMLButtonElement>("button[data-close-modal]");
|
||||
this.#dialog = this.querySelector<HTMLDialogElement>("dialog");
|
||||
this.#dialogFrame = this.querySelector(".dialog-frame");
|
||||
this.#controller = new AbortController();
|
||||
|
||||
// Set up events
|
||||
if (this.#openBtn) {
|
||||
this.#openBtn.addEventListener("click", this.openModal);
|
||||
this.#openBtn.disabled = false;
|
||||
} else {
|
||||
console.warn("Search button not found");
|
||||
}
|
||||
|
||||
if (this.#closeBtn) {
|
||||
this.#closeBtn.addEventListener("click", this.closeModal);
|
||||
} else {
|
||||
console.warn("Close button not found");
|
||||
}
|
||||
|
||||
if (this.#dialog) {
|
||||
this.#dialog.addEventListener("close", () => {
|
||||
window.removeEventListener("click", this.onWindowClick);
|
||||
});
|
||||
} else {
|
||||
console.warn("Dialog not found");
|
||||
}
|
||||
|
||||
// only add pagefind in production
|
||||
if (import.meta.env.DEV) return;
|
||||
const onIdle = window.requestIdleCallback || ((cb) => setTimeout(cb, 1));
|
||||
onIdle(async () => {
|
||||
const { PagefindUI } = await import("@pagefind/default-ui");
|
||||
new PagefindUI({
|
||||
baseUrl: import.meta.env.BASE_URL,
|
||||
bundlePath: import.meta.env.BASE_URL.replace(/\/$/, "") + "/pagefind/",
|
||||
element: "#cactus__search",
|
||||
showImages: false,
|
||||
showSubResults: true,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
// window events, requires cleanup
|
||||
window.addEventListener("keydown", this.onWindowKeydown, { signal: this.#controller.signal });
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
this.#controller.abort();
|
||||
}
|
||||
|
||||
openModal = (event?: MouseEvent) => {
|
||||
if (!this.#dialog) {
|
||||
console.warn("Dialog not found");
|
||||
return;
|
||||
}
|
||||
|
||||
this.#dialog.showModal();
|
||||
this.querySelector("input")?.focus();
|
||||
event?.stopPropagation();
|
||||
window.addEventListener("click", this.onWindowClick, { signal: this.#controller.signal });
|
||||
};
|
||||
|
||||
closeModal = () => this.#dialog?.close();
|
||||
|
||||
onWindowClick = (event: MouseEvent) => {
|
||||
// check if it's a link
|
||||
const isLink = "href" in (event.target || {});
|
||||
// make sure the click is either a link or outside of the dialog
|
||||
if (
|
||||
isLink ||
|
||||
(document.body.contains(event.target as Node) &&
|
||||
!this.#dialogFrame?.contains(event.target as Node))
|
||||
) {
|
||||
this.closeModal();
|
||||
}
|
||||
};
|
||||
|
||||
onWindowKeydown = (e: KeyboardEvent) => {
|
||||
if (!this.#dialog) {
|
||||
console.warn("Dialog not found");
|
||||
return;
|
||||
}
|
||||
// check if it's the Control+K or ⌘+K shortcut
|
||||
if ((e.metaKey === true || e.ctrlKey === true) && e.key === "k") {
|
||||
this.#dialog.open ? this.closeModal() : this.openModal();
|
||||
e.preventDefault();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
customElements.define("site-search", SiteSearch);
|
||||
</script>
|
3
src/components/SkipLink.astro
Normal file
3
src/components/SkipLink.astro
Normal file
@ -0,0 +1,3 @@
|
||||
<a class="sr-only focus:not-sr-only focus:fixed focus:start-1 focus:top-1.5" href="#main"
|
||||
>skip to content
|
||||
</a>
|
42
src/components/SocialList.astro
Normal file
42
src/components/SocialList.astro
Normal file
@ -0,0 +1,42 @@
|
||||
---
|
||||
import { Icon } from "astro-icon/components";
|
||||
|
||||
/**
|
||||
Uses https://www.astroicon.dev/getting-started/
|
||||
Find icons via guide: https://www.astroicon.dev/guides/customization/#open-source-icon-sets
|
||||
Only installed pack is: @iconify-json/mdi
|
||||
*/
|
||||
const socialLinks: {
|
||||
friendlyName: string;
|
||||
isWebmention?: boolean;
|
||||
link: string;
|
||||
name: string;
|
||||
}[] = [
|
||||
{
|
||||
friendlyName: "Github",
|
||||
link: "https://github.com/chrismwilliams/astro-cactus",
|
||||
name: "mdi:github",
|
||||
},
|
||||
];
|
||||
---
|
||||
|
||||
<div class="flex flex-wrap items-end gap-x-2">
|
||||
<p>Find me on</p>
|
||||
<ul class="flex flex-1 items-center gap-x-2 sm:flex-initial">
|
||||
{
|
||||
socialLinks.map(({ friendlyName, isWebmention, link, name }) => (
|
||||
<li class="flex">
|
||||
<a
|
||||
class="hover:text-link inline-block"
|
||||
href={link}
|
||||
rel={`noreferrer ${isWebmention ? "me authn" : ""}`}
|
||||
target="_blank"
|
||||
>
|
||||
<Icon aria-hidden="true" class="h-8 w-8" focusable="false" name={name} />
|
||||
<span class="sr-only">{friendlyName}</span>
|
||||
</a>
|
||||
</li>
|
||||
))
|
||||
}
|
||||
</ul>
|
||||
</div>
|
44
src/components/ThemeProvider.astro
Normal file
44
src/components/ThemeProvider.astro
Normal file
@ -0,0 +1,44 @@
|
||||
{/* Inlined to avoid FOUC. This is a parser blocking script. */}
|
||||
<script is:inline>
|
||||
const lightModePref = window.matchMedia("(prefers-color-scheme: light)");
|
||||
|
||||
function getUserPref() {
|
||||
const storedTheme = typeof localStorage !== "undefined" && localStorage.getItem("theme");
|
||||
return storedTheme || (lightModePref.matches ? "light" : "dark");
|
||||
}
|
||||
|
||||
function setTheme(newTheme) {
|
||||
if (newTheme !== "light" && newTheme !== "dark") {
|
||||
return console.warn(
|
||||
`Invalid theme value '${newTheme}' received. Expected 'light' or 'dark'.`,
|
||||
);
|
||||
}
|
||||
|
||||
const root = document.documentElement;
|
||||
|
||||
// root already set to newTheme, exit early
|
||||
if (newTheme === root.getAttribute("data-theme")) {
|
||||
return;
|
||||
}
|
||||
|
||||
root.setAttribute("data-theme", newTheme);
|
||||
|
||||
if (typeof localStorage !== "undefined") {
|
||||
localStorage.setItem("theme", newTheme);
|
||||
}
|
||||
}
|
||||
|
||||
// initial setup
|
||||
setTheme(getUserPref());
|
||||
|
||||
// View Transitions hook to restore theme
|
||||
document.addEventListener("astro:after-swap", () => setTheme(getUserPref()));
|
||||
|
||||
// listen for theme-change custom event, fired in src/components/ThemeToggle.astro
|
||||
document.addEventListener("theme-change", (e) => {
|
||||
setTheme(e.detail.theme);
|
||||
});
|
||||
|
||||
// listen for prefers-color-scheme change.
|
||||
lightModePref.addEventListener("change", (e) => setTheme(e.matches ? "light" : "dark"));
|
||||
</script>
|
90
src/components/ThemeToggle.astro
Normal file
90
src/components/ThemeToggle.astro
Normal file
@ -0,0 +1,90 @@
|
||||
<theme-toggle class="ms-2 sm:ms-4">
|
||||
<button class="hover:text-accent relative h-9 w-9 cursor-pointer rounded-md p-2" type="button">
|
||||
<span class="sr-only">Dark Theme</span>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
class="absolute start-1/2 top-1/2 h-7 w-7 -translate-x-1/2 -translate-y-1/2 scale-100 opacity-100 transition-all dark:scale-0 dark:opacity-0"
|
||||
fill="none"
|
||||
focusable="false"
|
||||
id="sun-svg"
|
||||
stroke-width="1.5"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M12 18C15.3137 18 18 15.3137 18 12C18 8.68629 15.3137 6 12 6C8.68629 6 6 8.68629 6 12C6 15.3137 8.68629 18 12 18Z"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"></path>
|
||||
<path d="M22 12L23 12" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"
|
||||
></path>
|
||||
<path d="M12 2V1" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"></path>
|
||||
<path d="M12 23V22" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"
|
||||
></path>
|
||||
<path d="M20 20L19 19" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"
|
||||
></path>
|
||||
<path d="M20 4L19 5" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"
|
||||
></path>
|
||||
<path d="M4 20L5 19" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"
|
||||
></path>
|
||||
<path d="M4 4L5 5" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"
|
||||
></path>
|
||||
<path d="M1 12L2 12" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"
|
||||
></path>
|
||||
</svg>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
class="absolute start-1/2 top-1/2 h-7 w-7 -translate-x-1/2 -translate-y-1/2 scale-0 opacity-0 transition-all dark:scale-100 dark:opacity-100"
|
||||
fill="none"
|
||||
focusable="false"
|
||||
id="moon-svg"
|
||||
stroke="currentColor"
|
||||
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>
|
||||
<path d="M12 3c.132 0 .263 0 .393 0a7.5 7.5 0 0 0 7.92 12.446a9 9 0 1 1 -8.313 -12.454z"
|
||||
></path>
|
||||
<path d="M17 4a2 2 0 0 0 2 2a2 2 0 0 0 -2 2a2 2 0 0 0 -2 -2a2 2 0 0 0 2 -2"></path>
|
||||
<path d="M19 11h2m-1 -1v2"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</theme-toggle>
|
||||
|
||||
<script>
|
||||
// Note that if you fire the theme-change event outside of this component, it will not be reflected in the button's aria-checked attribute. You will need to add an event listener if you want that.
|
||||
import { rootInDarkMode } from "@/utils/domElement";
|
||||
|
||||
class ThemeToggle extends HTMLElement {
|
||||
constructor() {
|
||||
super();
|
||||
const button = this.querySelector<HTMLButtonElement>("button");
|
||||
|
||||
if (button) {
|
||||
// set aria role value
|
||||
button.setAttribute("role", "switch");
|
||||
button.setAttribute("aria-checked", String(rootInDarkMode()));
|
||||
|
||||
// button event
|
||||
button.addEventListener("click", () => {
|
||||
// invert theme
|
||||
let themeChangeEvent = new CustomEvent("theme-change", {
|
||||
detail: {
|
||||
theme: rootInDarkMode() ? "light" : "dark",
|
||||
},
|
||||
});
|
||||
// dispatch event -> ThemeProvider.astro
|
||||
document.dispatchEvent(themeChangeEvent);
|
||||
|
||||
// set the aria-checked attribute
|
||||
button.setAttribute("aria-checked", String(rootInDarkMode()));
|
||||
});
|
||||
} else {
|
||||
console.warn("Theme Toggle: No button found");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define("theme-toggle", ThemeToggle);
|
||||
</script>
|
83
src/components/blog/Masthead.astro
Normal file
83
src/components/blog/Masthead.astro
Normal file
@ -0,0 +1,83 @@
|
||||
---
|
||||
import { Image } from "astro:assets";
|
||||
import type { CollectionEntry } from "astro:content";
|
||||
import FormattedDate from "@/components/FormattedDate.astro";
|
||||
|
||||
interface Props {
|
||||
content: CollectionEntry<"post">;
|
||||
readingTime: string;
|
||||
}
|
||||
|
||||
const {
|
||||
content: { data },
|
||||
readingTime,
|
||||
} = Astro.props;
|
||||
|
||||
const dateTimeOptions: Intl.DateTimeFormatOptions = {
|
||||
month: "long",
|
||||
};
|
||||
---
|
||||
|
||||
{
|
||||
data.coverImage && (
|
||||
<div class="mb-6 aspect-video">
|
||||
<Image
|
||||
alt={data.coverImage.alt}
|
||||
class="object-cover"
|
||||
fetchpriority="high"
|
||||
loading="eager"
|
||||
src={data.coverImage.src}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
{data.draft ? <span class="text-base text-red-500">(Draft)</span> : null}
|
||||
<h1 class="title">
|
||||
{data.title}
|
||||
</h1>
|
||||
<div class="flex flex-wrap items-center gap-x-3 gap-y-2">
|
||||
<p class="font-semibold">
|
||||
<FormattedDate date={data.publishDate} dateTimeOptions={dateTimeOptions} /> /{" "}
|
||||
{readingTime}
|
||||
</p>
|
||||
{
|
||||
data.updatedDate && (
|
||||
<span class="bg-quote/5 text-quote rounded-lg px-2 py-1">
|
||||
Updated:
|
||||
<FormattedDate class="ms-1" date={data.updatedDate} dateTimeOptions={dateTimeOptions} />
|
||||
</span>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
{
|
||||
!!data.tags?.length && (
|
||||
<div class="mt-2">
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
class="inline-block h-6 w-6"
|
||||
fill="none"
|
||||
focusable="false"
|
||||
stroke="currentColor"
|
||||
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>
|
||||
{data.tags.map((tag, i) => (
|
||||
<>
|
||||
{/* prettier-ignore */}
|
||||
<span class="contents">
|
||||
<a class="cactus-link inline-block before:content-['#']" data-pagefind-filter={`tag:${tag}`} href={`/tags/${tag}/`}><span class="sr-only">View more blogs with the tag </span>{tag}
|
||||
</a>{i < data.tags.length - 1 && ", "}
|
||||
</span>
|
||||
</>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
24
src/components/blog/PostPreview.astro
Normal file
24
src/components/blog/PostPreview.astro
Normal file
@ -0,0 +1,24 @@
|
||||
---
|
||||
import type { CollectionEntry } from "astro:content";
|
||||
import FormattedDate from "@/components/FormattedDate.astro";
|
||||
import type { HTMLTag, Polymorphic } from "astro/types";
|
||||
|
||||
type Props<Tag extends HTMLTag> = Polymorphic<{ as: Tag }> & {
|
||||
post: CollectionEntry<"post">;
|
||||
withDesc?: boolean;
|
||||
};
|
||||
|
||||
const { as: Tag = "div", post, withDesc = false } = Astro.props;
|
||||
---
|
||||
|
||||
<FormattedDate
|
||||
class="min-w-30 font-semibold text-gray-600 dark:text-gray-400"
|
||||
date={post.data.publishDate}
|
||||
/>
|
||||
<Tag>
|
||||
{post.data.draft && <span class="text-red-500">(Draft) </span>}
|
||||
<a class="cactus-link" data-astro-prefetch href={`/posts/${post.id}/`}>
|
||||
{post.data.title}
|
||||
</a>
|
||||
</Tag>
|
||||
{withDesc && <q class="line-clamp-3 italic">{post.data.description}</q>}
|
22
src/components/blog/TOC.astro
Normal file
22
src/components/blog/TOC.astro
Normal file
@ -0,0 +1,22 @@
|
||||
---
|
||||
import { generateToc } from "@/utils/generateToc";
|
||||
import type { MarkdownHeading } from "astro";
|
||||
import TOCHeading from "./TOCHeading.astro";
|
||||
|
||||
interface Props {
|
||||
headings: MarkdownHeading[];
|
||||
}
|
||||
|
||||
const { headings } = Astro.props;
|
||||
|
||||
const toc = generateToc(headings);
|
||||
---
|
||||
|
||||
<details open class="lg:sticky lg:top-12 lg:order-2 lg:-me-32 lg:basis-64">
|
||||
<summary class="title hover:marker:text-accent cursor-pointer text-lg">Table of Contents</summary>
|
||||
<nav class="ms-4 lg:w-full">
|
||||
<ol class="mt-4">
|
||||
{toc.map((heading) => <TOCHeading heading={heading} />)}
|
||||
</ol>
|
||||
</nav>
|
||||
</details>
|
27
src/components/blog/TOCHeading.astro
Normal file
27
src/components/blog/TOCHeading.astro
Normal file
@ -0,0 +1,27 @@
|
||||
---
|
||||
import type { TocItem } from "@/utils/generateToc";
|
||||
|
||||
interface Props {
|
||||
heading: TocItem;
|
||||
}
|
||||
|
||||
const {
|
||||
heading: { children, depth, slug, text },
|
||||
} = Astro.props;
|
||||
---
|
||||
|
||||
<li class={`${depth > 2 ? "ms-2" : ""}`}>
|
||||
<a
|
||||
class={`line-clamp-2 hover:text-accent ${depth <= 2 ? "mt-3" : "mt-2 text-xs"}`}
|
||||
href={`#${slug}`}><span aria-hidden="true" class="me-0.5">#</span>{text}</a
|
||||
>
|
||||
{
|
||||
!!children.length && (
|
||||
<ol>
|
||||
{children.map((subheading) => (
|
||||
<Astro.self heading={subheading} />
|
||||
))}
|
||||
</ol>
|
||||
)
|
||||
}
|
||||
</li>
|
87
src/components/blog/webmentions/Comments.astro
Normal file
87
src/components/blog/webmentions/Comments.astro
Normal file
@ -0,0 +1,87 @@
|
||||
---
|
||||
import { Image } from "astro:assets";
|
||||
import type { WebmentionsChildren } from "@/types";
|
||||
import { Icon } from "astro-icon/components";
|
||||
|
||||
interface Props {
|
||||
mentions: WebmentionsChildren[];
|
||||
}
|
||||
|
||||
const { mentions } = Astro.props;
|
||||
|
||||
const validComments = ["mention-of", "in-reply-to"];
|
||||
|
||||
const comments = mentions.filter(
|
||||
(mention) => validComments.includes(mention["wm-property"]) && mention.content?.text,
|
||||
);
|
||||
---
|
||||
|
||||
{
|
||||
!!comments.length && (
|
||||
<div>
|
||||
<p class="text-accent-2 mb-0">
|
||||
<strong>{comments.length}</strong> Mention{comments.length > 1 ? "s" : ""}
|
||||
</p>
|
||||
<ul class="divide-global-text/20 mt-0 divide-y ps-0" role="list">
|
||||
{comments.map((mention) => (
|
||||
<li class="p-comment h-cite my-0 flex items-start gap-x-5 py-5">
|
||||
{mention.author?.photo && mention.author.photo !== "" ? (
|
||||
mention.author.url && mention.author.url !== "" ? (
|
||||
<a
|
||||
class="u-author not-prose ring-global-text hover:ring-link focus-visible:ring-link shrink-0 overflow-hidden rounded-full ring-2 hover:ring-4 focus-visible:ring-4"
|
||||
href={mention.author.url}
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
title={mention.author.name}
|
||||
>
|
||||
<Image
|
||||
alt={mention.author?.name}
|
||||
class="u-photo my-0 h-12 w-12"
|
||||
height={48}
|
||||
src={mention.author?.photo}
|
||||
width={48}
|
||||
/>
|
||||
</a>
|
||||
) : (
|
||||
<Image
|
||||
alt={mention.author?.name}
|
||||
class="u-photo my-0 h-12 w-12 rounded-full"
|
||||
height={48}
|
||||
src={mention.author?.photo}
|
||||
width={48}
|
||||
/>
|
||||
)
|
||||
) : null}
|
||||
<div class="flex-auto">
|
||||
<div class="p-author h-card flex items-center justify-between gap-x-2">
|
||||
<p class="p-name text-accent-2 my-0 line-clamp-1 font-semibold">
|
||||
{mention.author?.name}
|
||||
</p>
|
||||
<a
|
||||
aria-labelledby="cmt-source"
|
||||
class="u-url not-prose hover:text-link"
|
||||
href={mention.url}
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
<span class="hidden" id="cmt-source">
|
||||
Visit the source of this webmention
|
||||
</span>
|
||||
<Icon
|
||||
aria-hidden="true"
|
||||
class="h-5 w-5"
|
||||
focusable="false"
|
||||
name="mdi:open-in-new"
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
<p class="comment-content mt-1 mb-0 break-words [word-break:break-word]">
|
||||
{mention.content?.text}
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
52
src/components/blog/webmentions/Likes.astro
Normal file
52
src/components/blog/webmentions/Likes.astro
Normal file
@ -0,0 +1,52 @@
|
||||
---
|
||||
import { Image } from "astro:assets";
|
||||
import type { WebmentionsChildren } from "@/types";
|
||||
|
||||
interface Props {
|
||||
mentions: WebmentionsChildren[];
|
||||
}
|
||||
|
||||
const { mentions } = Astro.props;
|
||||
const MAX_LIKES = 10;
|
||||
|
||||
const likes = mentions.filter((mention) => mention["wm-property"] === "like-of");
|
||||
const likesToShow = likes
|
||||
.filter((like) => like.author?.photo && like.author.photo !== "")
|
||||
.slice(0, MAX_LIKES);
|
||||
---
|
||||
|
||||
{
|
||||
!!likes.length && (
|
||||
<div>
|
||||
<p class="text-accent-2 mb-0">
|
||||
<strong>{likes.length}</strong>
|
||||
{likes.length > 1 ? " People" : " Person"} liked this
|
||||
</p>
|
||||
{!!likesToShow.length && (
|
||||
<ul class="flex list-none flex-wrap overflow-hidden ps-2" role="list">
|
||||
{likesToShow.map((like) => (
|
||||
<li class="p-like h-cite -ms-2">
|
||||
<a
|
||||
class="u-url not-prose ring-global-text hover:ring-link focus-visible:ring-link relative inline-block overflow-hidden rounded-full ring-2 hover:z-10 hover:ring-4 focus-visible:z-10 focus-visible:ring-4"
|
||||
href={like.author?.url}
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
title={like.author?.name}
|
||||
>
|
||||
<span class="p-author h-card">
|
||||
<Image
|
||||
alt={like.author!.name}
|
||||
class="u-photo my-0 inline-block h-12 w-12"
|
||||
height={48}
|
||||
src={like.author!.photo}
|
||||
width={48}
|
||||
/>
|
||||
</span>
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
23
src/components/blog/webmentions/index.astro
Normal file
23
src/components/blog/webmentions/index.astro
Normal file
@ -0,0 +1,23 @@
|
||||
---
|
||||
import { getWebmentionsForUrl } from "@/utils/webmentions";
|
||||
import Comments from "./Comments.astro";
|
||||
import Likes from "./Likes.astro";
|
||||
|
||||
const url = new URL(Astro.url.pathname, Astro.site);
|
||||
|
||||
const webMentions = await getWebmentionsForUrl(`${url}`);
|
||||
|
||||
// Return if no webmentions
|
||||
if (!webMentions.length) return;
|
||||
---
|
||||
|
||||
<hr class="border-solid" />
|
||||
<h2 class="mb-8 before:hidden">Webmentions for this post</h2>
|
||||
<div class="space-y-10">
|
||||
<Likes mentions={webMentions} />
|
||||
<Comments mentions={webMentions} />
|
||||
</div>
|
||||
<p class="mt-8">
|
||||
Responses powered by{" "}
|
||||
<a href="https://webmention.io" rel="noreferrer" target="_blank">Webmentions</a>
|
||||
</p>
|
27
src/components/layout/Footer.astro
Normal file
27
src/components/layout/Footer.astro
Normal file
@ -0,0 +1,27 @@
|
||||
---
|
||||
import { menuLinks, siteConfig } from "@/site.config";
|
||||
|
||||
const year = new Date().getFullYear();
|
||||
---
|
||||
|
||||
<footer
|
||||
class="mt-auto flex w-full flex-col items-center justify-center gap-y-2 pt-20 pb-4 text-center align-top font-semibold text-gray-600 sm:flex-row sm:justify-between sm:text-xs dark:text-gray-400"
|
||||
>
|
||||
<div class="me-0 sm:me-4">
|
||||
© {siteConfig.author}
|
||||
{year}.<span class="inline-block"> 🚀 {siteConfig.title}</span>
|
||||
</div>
|
||||
<nav
|
||||
aria-labelledby="footer_links"
|
||||
class="flex gap-x-2 sm:gap-x-0 sm:divide-x sm:divide-gray-500"
|
||||
>
|
||||
<p id="footer_links" class="sr-only">More on this site</p>
|
||||
{
|
||||
menuLinks.map((link) => (
|
||||
<a class="hover:text-global-text px-4 py-2 hover:underline sm:py-0" href={link.path}>
|
||||
{link.title}
|
||||
</a>
|
||||
))
|
||||
}
|
||||
</nav>
|
||||
</footer>
|
118
src/components/layout/Header.astro
Normal file
118
src/components/layout/Header.astro
Normal file
@ -0,0 +1,118 @@
|
||||
---
|
||||
import Search from "@/components/Search.astro";
|
||||
import ThemeToggle from "@/components/ThemeToggle.astro";
|
||||
import { menuLinks } from "@/site.config";
|
||||
import {siteConfig} from "../../site.config";
|
||||
---
|
||||
|
||||
<header class="group relative mb-28 flex items-center sm:ps-18" id="main-header">
|
||||
<div class="flex sm:flex-col">
|
||||
<a
|
||||
aria-current={Astro.url.pathname === "/" ? "page" : false}
|
||||
class="inline-flex items-center grayscale hover:filter-none sm:relative sm:inline-block"
|
||||
href="/"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
class="me-3 h-10 w-6 sm:absolute sm:-start-18 sm:me-0 sm:h-20 sm:w-12"
|
||||
fill="none"
|
||||
focusable="false"
|
||||
viewBox="0 0 272 480"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<title>Logo</title>
|
||||
<path
|
||||
d="M181.334 93.333v-40L226.667 80v40l-45.333-26.667ZM136.001 53.333 90.667 26.667v426.666L136.001 480V53.333Z"
|
||||
fill="#B04304"></path>
|
||||
<path
|
||||
d="m136.001 119.944 45.333-26.667 45.333 26.667-45.333 26.667-45.333-26.667ZM90.667 26.667 136.001 0l45.333 26.667-45.333 26.666-45.334-26.666ZM181.334 53.277l45.333-26.666L272 53.277l-45.333 26.667-45.333-26.667ZM0 213.277l45.333-26.667 45.334 26.667-45.334 26.667L0 213.277ZM136 239.944l-45.333-26.667v53.333L136 239.944Z"
|
||||
fill="#FF5D01"></path>
|
||||
<path
|
||||
d="m136 53.333 45.333-26.666v120L226.667 120V80L272 53.333V160l-90.667 53.333v240L136 480V306.667L45.334 360V240l45.333-26.667v53.334L136 240V53.333Z"
|
||||
fill="#53C68C"></path>
|
||||
<path d="M45.334 240 0 213.334v120L45.334 360V240Z" fill="#B04304"></path>
|
||||
</svg>
|
||||
<span class="text-xl font-bold sm:text-2xl">{siteConfig.title}</span>
|
||||
</a>
|
||||
<nav
|
||||
aria-label="Main menu"
|
||||
class="bg-global-bg/85 text-accent sm:divide-accent absolute -inset-x-4 top-14 hidden flex-col items-end gap-y-4 rounded-md py-4 shadow backdrop-blur-sm group-[.menu-open]:z-50 group-[.menu-open]:flex sm:static sm:z-auto sm:-ms-4 sm:mt-1 sm:flex sm:flex-row sm:items-center sm:divide-x sm:rounded-none sm:bg-transparent sm:py-0 sm:shadow-none sm:backdrop-blur-none"
|
||||
id="navigation-menu"
|
||||
>
|
||||
{
|
||||
menuLinks.map((link) => (
|
||||
<a
|
||||
aria-current={Astro.url.pathname === link.path ? "page" : false}
|
||||
class="px-4 py-4 underline-offset-2 hover:underline sm:py-0"
|
||||
data-astro-prefetch
|
||||
href={link.path}
|
||||
>
|
||||
{link.title}
|
||||
</a>
|
||||
))
|
||||
}
|
||||
</nav>
|
||||
</div>
|
||||
<Search />
|
||||
<ThemeToggle />
|
||||
<mobile-button>
|
||||
<button
|
||||
aria-expanded="false"
|
||||
aria-haspopup="menu"
|
||||
class="group relative ms-4 h-7 w-7 sm:invisible sm:hidden"
|
||||
id="toggle-navigation-menu"
|
||||
type="button"
|
||||
>
|
||||
<span class="sr-only">Open main menu</span>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
class="absolute start-1/2 top-1/2 h-full w-full -translate-x-1/2 -translate-y-1/2 transition-all group-aria-expanded:scale-0 group-aria-expanded:opacity-0"
|
||||
fill="none"
|
||||
focusable="false"
|
||||
id="line-svg"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path d="M3.75 9h16.5m-16.5 6.75h16.5" stroke-linecap="round" stroke-linejoin="round"
|
||||
></path>
|
||||
</svg>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
class="text-accent absolute start-1/2 top-1/2 h-full w-full -translate-x-1/2 -translate-y-1/2 scale-0 opacity-0 transition-all group-aria-expanded:scale-100 group-aria-expanded:opacity-100"
|
||||
class="text-accent"
|
||||
fill="none"
|
||||
focusable="false"
|
||||
id="cross-svg"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path d="M6 18L18 6M6 6l12 12" stroke-linecap="round" stroke-linejoin="round"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</mobile-button>
|
||||
</header>
|
||||
|
||||
<script>
|
||||
import { toggleClass } from "@/utils/domElement";
|
||||
|
||||
class MobileNavBtn extends HTMLElement {
|
||||
#menuOpen: boolean = false;
|
||||
|
||||
connectedCallback() {
|
||||
const headerEl = document.getElementById("main-header")!;
|
||||
const mobileButtonEl = this.querySelector<HTMLButtonElement>("button");
|
||||
|
||||
mobileButtonEl?.addEventListener("click", () => {
|
||||
if (headerEl) toggleClass(headerEl, "menu-open");
|
||||
this.#menuOpen = !this.#menuOpen;
|
||||
mobileButtonEl.setAttribute("aria-expanded", this.#menuOpen.toString());
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define("mobile-button", MobileNavBtn);
|
||||
</script>
|
48
src/components/note/Note.astro
Normal file
48
src/components/note/Note.astro
Normal file
@ -0,0 +1,48 @@
|
||||
---
|
||||
import { type CollectionEntry, render } from "astro:content";
|
||||
import FormattedDate from "@/components/FormattedDate.astro";
|
||||
import type { HTMLTag, Polymorphic } from "astro/types";
|
||||
|
||||
type Props<Tag extends HTMLTag> = Polymorphic<{ as: Tag }> & {
|
||||
note: CollectionEntry<"note">;
|
||||
isPreview?: boolean | undefined;
|
||||
};
|
||||
|
||||
const { as: Tag = "div", note, isPreview = false } = Astro.props;
|
||||
const { Content } = await render(note);
|
||||
---
|
||||
|
||||
<article
|
||||
class:list={[
|
||||
isPreview && "inline-grid rounded-md bg-[rgb(240,240,240)] px-4 py-3 dark:bg-[rgb(33,35,38)]",
|
||||
]}
|
||||
data-pagefind-body={isPreview ? false : true}
|
||||
>
|
||||
<Tag class="title" class:list={{ "text-base": isPreview }}>
|
||||
{
|
||||
isPreview ? (
|
||||
<a class="cactus-link" href={`/notes/${note.id}/`}>
|
||||
{note.data.title}
|
||||
</a>
|
||||
) : (
|
||||
<>{note.data.title}</>
|
||||
)
|
||||
}
|
||||
</Tag>
|
||||
<FormattedDate
|
||||
dateTimeOptions={{
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
year: "2-digit",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
}}
|
||||
date={note.data.publishDate}
|
||||
/>
|
||||
<div
|
||||
class="prose prose-sm prose-cactus mt-4 max-w-none [&>p:last-of-type]:mb-0"
|
||||
class:list={{ "line-clamp-6": isPreview }}
|
||||
>
|
||||
<Content />
|
||||
</div>
|
||||
</article>
|
48
src/content.config.ts
Normal file
48
src/content.config.ts
Normal file
@ -0,0 +1,48 @@
|
||||
import { defineCollection, z } from "astro:content";
|
||||
import { glob } from "astro/loaders";
|
||||
|
||||
function removeDupsAndLowerCase(array: string[]) {
|
||||
return [...new Set(array.map((str) => str.toLowerCase()))];
|
||||
}
|
||||
|
||||
const baseSchema = z.object({
|
||||
title: z.string().max(60),
|
||||
});
|
||||
|
||||
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)),
|
||||
}),
|
||||
});
|
||||
|
||||
const note = defineCollection({
|
||||
loader: glob({ base: "./src/content/note", pattern: "**/*.{md,mdx}" }),
|
||||
schema: baseSchema.extend({
|
||||
description: z.string().optional(),
|
||||
publishDate: z
|
||||
.string()
|
||||
.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)),
|
||||
}),
|
||||
});
|
||||
|
||||
export const collections = { post, note };
|
9
src/content/note/welcome.md
Normal file
9
src/content/note/welcome.md
Normal 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, concise "post's" that you'd like to share, they generally don't include headings, but hey, that's entirely up to you.
|
BIN
src/content/post/cover-image/cover.png
Normal file
BIN
src/content/post/cover-image/cover.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 591 KiB |
10
src/content/post/cover-image/index.md
Normal file
10
src/content/post/cover-image/index.md
Normal file
@ -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"]
|
||||
---
|
115
src/content/post/markdown-elements/admonistions.md
Normal file
115
src/content/post/markdown-elements/admonistions.md
Normal file
@ -0,0 +1,115 @@
|
||||
---
|
||||
title: "Markdown Admonitions"
|
||||
description: "This post showcases using the markdown admonition feature in Astro Cactus"
|
||||
publishDate: "25 Aug 2024"
|
||||
updatedDate: "7 Jan 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.
|
||||
:::
|
173
src/content/post/markdown-elements/index.md
Normal file
173
src/content/post/markdown-elements/index.md
Normal file
@ -0,0 +1,173 @@
|
||||
---
|
||||
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"]
|
||||
---
|
||||
|
||||
## 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`
|
||||
|
||||

|
||||
|
||||
## Links
|
||||
|
||||
[Content from markdown-it](https://markdown-it.github.io/)
|
BIN
src/content/post/markdown-elements/logo.png
Normal file
BIN
src/content/post/markdown-elements/logo.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 4.2 KiB |
22
src/content/post/social-image.md
Normal file
22
src/content/post/social-image.md
Normal 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.
|
9
src/content/post/testing/draft-post.md
Normal file
9
src/content/post/testing/draft-post.md
Normal 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
src/content/post/testing/long-title.md
Normal file
8
src/content/post/testing/long-title.md
Normal 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
|
6
src/content/post/testing/missing-content.md
Normal file
6
src/content/post/testing/missing-content.md
Normal file
@ -0,0 +1,6 @@
|
||||
---
|
||||
title: "This post doesn't have any content"
|
||||
description: "This post is purely for testing the table of content, which should not be rendered"
|
||||
publishDate: "22 Feb 2023"
|
||||
tags: ["test", "toc"]
|
||||
---
|
12
src/content/post/testing/unique-tags.md
Normal file
12
src/content/post/testing/unique-tags.md
Normal file
@ -0,0 +1,12 @@
|
||||
---
|
||||
title: "Unique tags validation"
|
||||
publishDate: "30 January 2023"
|
||||
description: "This post is used for validating if duplicate tags are removed, regardless of the string case"
|
||||
tags: ["blog", "blog", "Blog", "test", "bloG", "Test", "BLOG"]
|
||||
---
|
||||
|
||||
## This post is to test zod transform
|
||||
|
||||
If you open the file `src/content/post/unique-tags.md`, the tags array has a number of duplicate blog strings of various cases.
|
||||
|
||||
These are removed as part of the removeDupsAndLowercase function found in `src/content/config.ts`.
|
65
src/content/post/webmentions.md
Normal file
65
src/content/post/webmentions.md
Normal file
@ -0,0 +1,65 @@
|
||||
---
|
||||
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
|
||||
---
|
||||
|
||||
## 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!
|
48
src/data/post.ts
Normal file
48
src/data/post.ts
Normal file
@ -0,0 +1,48 @@
|
||||
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;
|
||||
});
|
||||
}
|
||||
|
||||
/** 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 posts.reduce<Record<string, CollectionEntry<"post">[]>>((acc, post) => {
|
||||
const year = post.data.publishDate.getFullYear();
|
||||
if (!acc[year]) {
|
||||
acc[year] = [];
|
||||
}
|
||||
acc[year]?.push(post);
|
||||
return acc;
|
||||
}, {});
|
||||
}
|
||||
|
||||
/** 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
src/env.d.ts
vendored
Normal file
5
src/env.d.ts
vendored
Normal file
@ -0,0 +1,5 @@
|
||||
declare module "@pagefind/default-ui" {
|
||||
declare class PagefindUI {
|
||||
constructor(arg: unknown);
|
||||
}
|
||||
}
|
34
src/layouts/Base.astro
Normal file
34
src/layouts/Base.astro
Normal file
@ -0,0 +1,34 @@
|
||||
---
|
||||
import BaseHead from "@/components/BaseHead.astro";
|
||||
import SkipLink from "@/components/SkipLink.astro";
|
||||
import ThemeProvider from "@/components/ThemeProvider.astro";
|
||||
import Footer from "@/components/layout/Footer.astro";
|
||||
import Header from "@/components/layout/Header.astro";
|
||||
import { siteConfig } from "@/site.config";
|
||||
import type { SiteMeta } from "@/types";
|
||||
|
||||
interface Props {
|
||||
meta: SiteMeta;
|
||||
}
|
||||
|
||||
const {
|
||||
meta: { articleDate, description = siteConfig.description, ogImage, title },
|
||||
} = Astro.props;
|
||||
---
|
||||
|
||||
<html class="scroll-smooth" lang={siteConfig.lang}>
|
||||
<head>
|
||||
<BaseHead articleDate={articleDate} description={description} ogImage={ogImage} title={title} />
|
||||
</head>
|
||||
<body
|
||||
class="bg-global-bg text-global-text mx-auto flex min-h-screen max-w-3xl flex-col px-4 pt-16 font-mono text-sm font-normal antialiased sm:px-8"
|
||||
>
|
||||
<ThemeProvider />
|
||||
<SkipLink />
|
||||
<Header />
|
||||
<main id="main">
|
||||
<slot />
|
||||
</main>
|
||||
<Footer />
|
||||
</body>
|
||||
</html>
|
80
src/layouts/BlogPost.astro
Normal file
80
src/layouts/BlogPost.astro
Normal file
@ -0,0 +1,80 @@
|
||||
---
|
||||
import { type CollectionEntry, render } from "astro:content";
|
||||
|
||||
import Masthead from "@/components/blog/Masthead.astro";
|
||||
import TOC from "@/components/blog/TOC.astro";
|
||||
import WebMentions from "@/components/blog/webmentions/index.astro";
|
||||
|
||||
import BaseLayout from "./Base.astro";
|
||||
|
||||
interface Props {
|
||||
post: CollectionEntry<"post">;
|
||||
}
|
||||
|
||||
const { post } = Astro.props;
|
||||
const { ogImage, title, description, updatedDate, publishDate } = post.data;
|
||||
const socialImage = ogImage ?? `/og-image/${post.id}.png`;
|
||||
const articleDate = updatedDate?.toISOString() ?? publishDate.toISOString();
|
||||
const { headings, remarkPluginFrontmatter } = await render(post);
|
||||
const readingTime: string = remarkPluginFrontmatter.readingTime;
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
meta={{
|
||||
articleDate,
|
||||
description,
|
||||
ogImage: socialImage,
|
||||
title,
|
||||
}}
|
||||
>
|
||||
<article class="grow break-words" data-pagefind-body>
|
||||
<div id="blog-hero" class="mb-12"><Masthead content={post} readingTime={readingTime} /></div>
|
||||
<div class="flex flex-col gap-10 lg:flex-row lg:items-start">
|
||||
{!!headings.length && <TOC headings={headings} />}
|
||||
<div
|
||||
class="prose prose-sm prose-headings:font-semibold prose-headings:text-accent-2 prose-headings:before:absolute prose-headings:before:-ms-4 prose-headings:before:text-gray-600 prose-headings:hover:before:text-accent sm:prose-headings:before:content-['#'] sm:prose-th:before:content-none"
|
||||
>
|
||||
<slot />
|
||||
<WebMentions />
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
<button
|
||||
class="hover:border-link fixed end-4 bottom-8 z-90 flex h-10 w-10 translate-y-28 cursor-pointer items-center justify-center rounded-full border-2 border-transparent bg-zinc-200 text-3xl opacity-0 transition-all transition-discrete duration-300 data-[show=true]:translate-y-0 data-[show=true]:opacity-100 sm:end-8 sm:h-12 sm:w-12 dark:bg-zinc-700"
|
||||
data-show="false"
|
||||
id="to-top-btn"
|
||||
>
|
||||
<span class="sr-only">Back to top</span>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
class="h-6 w-6"
|
||||
fill="none"
|
||||
focusable="false"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path d="M4.5 15.75l7.5-7.5 7.5 7.5" stroke-linecap="round" stroke-linejoin="round"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</BaseLayout>
|
||||
|
||||
<script>
|
||||
const scrollBtn = document.getElementById("to-top-btn") as HTMLButtonElement;
|
||||
const targetHeader = document.getElementById("blog-hero") as HTMLDivElement;
|
||||
|
||||
function callback(entries: IntersectionObserverEntry[]) {
|
||||
entries.forEach((entry) => {
|
||||
// only show the scroll to top button when the heading is out of view
|
||||
scrollBtn.dataset.show = (!entry.isIntersecting).toString();
|
||||
});
|
||||
}
|
||||
|
||||
scrollBtn.addEventListener("click", () => {
|
||||
document.documentElement.scrollTo({ behavior: "smooth", top: 0 });
|
||||
});
|
||||
|
||||
const observer = new IntersectionObserver(callback);
|
||||
observer.observe(targetHeader);
|
||||
</script>
|
13
src/pages/404.astro
Normal file
13
src/pages/404.astro
Normal 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-6">404 | Oops something went wrong</h1>
|
||||
<p class="mb-8">Please use the navigation to find your way back</p>
|
||||
</PageLayout>
|
36
src/pages/about.astro
Normal file
36
src/pages/about.astro
Normal 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-6">About</h1>
|
||||
<div class="prose prose-sm prose-cactus max-w-none">
|
||||
<p>
|
||||
Hi, I’m a starter Astro. I’m 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>
|
61
src/pages/index.astro
Normal file
61
src/pages/index.astro
Normal file
@ -0,0 +1,61 @@
|
||||
---
|
||||
import { type CollectionEntry, getCollection } from "astro:content";
|
||||
import SocialList from "@/components/SocialList.astro";
|
||||
import PostPreview from "@/components/blog/PostPreview.astro";
|
||||
import Note from "@/components/note/Note.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)
|
||||
.slice(0, MAX_POSTS) as CollectionEntry<"post">[];
|
||||
|
||||
// Notes
|
||||
const MAX_NOTES = 5;
|
||||
const allNotes = await getCollection("note");
|
||||
const latestNotes = allNotes.sort(collectionDateSort).slice(0, MAX_NOTES);
|
||||
---
|
||||
|
||||
<PageLayout meta={{ title: "Home" }}>
|
||||
<section>
|
||||
<h1 class="title mb-6">Hello World!</h1>
|
||||
<p class="mb-4">
|
||||
Hi, I’m 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>
|
||||
<section class="mt-16">
|
||||
<h2 class="title text-accent mb-6 text-xl"><a href="/posts/">Posts</a></h2>
|
||||
<ul class="space-y-6" role="list">
|
||||
{
|
||||
allPostsByDate.map((p) => (
|
||||
<li class="grid gap-2 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
src/pages/notes/[...page].astro
Normal file
63
src/pages/notes/[...page].astro
Normal file
@ -0,0 +1,63 @@
|
||||
---
|
||||
import { type CollectionEntry, getCollection } from "astro:content";
|
||||
import Pagination from "@/components/Paginator.astro";
|
||||
import Note from "@/components/note/Note.astro";
|
||||
import PageLayout from "@/layouts/Base.astro";
|
||||
import { collectionDateSort } from "@/utils/date";
|
||||
import type { GetStaticPaths, Page } from "astro";
|
||||
import { Icon } from "astro-icon/components";
|
||||
|
||||
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-6 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>
|
31
src/pages/notes/[...slug].astro
Normal file
31
src/pages/notes/[...slug].astro
Normal file
@ -0,0 +1,31 @@
|
||||
---
|
||||
import { getCollection } from "astro:content";
|
||||
|
||||
import Note from "@/components/note/Note.astro";
|
||||
import PageLayout from "@/layouts/Base.astro";
|
||||
import type { GetStaticPaths, InferGetStaticPropsType } from "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
src/pages/notes/rss.xml.ts
Normal file
18
src/pages/notes/rss.xml.ts
Normal file
@ -0,0 +1,18 @@
|
||||
import { getCollection } from "astro:content";
|
||||
import { siteConfig } from "@/site.config";
|
||||
import rss from "@astrojs/rss";
|
||||
|
||||
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}/`,
|
||||
})),
|
||||
});
|
||||
};
|
90
src/pages/og-image/[...slug].png.ts
Normal file
90
src/pages/og-image/[...slug].png.ts
Normal file
@ -0,0 +1,90 @@
|
||||
import RobotoMonoBold from "@/assets/roboto-mono-700.ttf";
|
||||
import RobotoMono from "@/assets/roboto-mono-regular.ttf";
|
||||
import { getAllPosts } from "@/data/post";
|
||||
import { siteConfig } from "@/site.config";
|
||||
import { getFormattedDate } from "@/utils/date";
|
||||
import { Resvg } from "@resvg/resvg-js";
|
||||
import type { APIContext, InferGetStaticPropsType } from "astro";
|
||||
import satori, { type SatoriOptions } from "satori";
|
||||
import { html } from "satori-html";
|
||||
|
||||
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,
|
||||
};
|
||||
|
||||
const markup = (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 border-[#2bbc89] text-xl">
|
||||
<div tw="flex items-center">
|
||||
<svg height="60" fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 272 480">
|
||||
<path
|
||||
d="M181.334 93.333v-40L226.667 80v40l-45.333-26.667ZM136.001 53.333 90.667 26.667v426.666L136.001 480V53.333Z"
|
||||
fill="#B04304"
|
||||
></path>
|
||||
<path
|
||||
d="m136.001 119.944 45.333-26.667 45.333 26.667-45.333 26.667-45.333-26.667ZM90.667 26.667 136.001 0l45.333 26.667-45.333 26.666-45.334-26.666ZM181.334 53.277l45.333-26.666L272 53.277l-45.333 26.667-45.333-26.667ZM0 213.277l45.333-26.667 45.334 26.667-45.334 26.667L0 213.277ZM136 239.944l-45.333-26.667v53.333L136 239.944Z"
|
||||
fill="#FF5D01"
|
||||
></path>
|
||||
<path
|
||||
d="m136 53.333 45.333-26.666v120L226.667 120V80L272 53.333V160l-90.667 53.333v240L136 480V306.667L45.334 360V240l45.333-26.667v53.334L136 240V53.333Z"
|
||||
fill="#53C68C"
|
||||
></path>
|
||||
<path d="M45.334 240 0 213.334v120L45.334 360V240Z" fill="#B04304"></path>
|
||||
</svg>
|
||||
<p tw="ml-3 font-semibold">${siteConfig.title}</p>
|
||||
</div>
|
||||
<p>by ${siteConfig.author}</p>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
type Props = InferGetStaticPropsType<typeof getStaticPaths>;
|
||||
|
||||
export async function GET(context: APIContext) {
|
||||
const { pubDate, title } = context.props as Props;
|
||||
|
||||
const postDate = getFormattedDate(pubDate, {
|
||||
month: "long",
|
||||
weekday: "long",
|
||||
});
|
||||
const svg = await satori(markup(title, postDate), ogOptions);
|
||||
const png = new Resvg(svg).render().asPng();
|
||||
return new Response(png, {
|
||||
headers: {
|
||||
"Cache-Control": "public, max-age=31536000, immutable",
|
||||
"Content-Type": "image/png",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function getStaticPaths() {
|
||||
const posts = await getAllPosts();
|
||||
return posts
|
||||
.filter(({ data }) => !data.ogImage)
|
||||
.map((post) => ({
|
||||
params: { slug: post.id },
|
||||
props: {
|
||||
pubDate: post.data.updatedDate ?? post.data.publishDate,
|
||||
title: post.data.title,
|
||||
},
|
||||
}));
|
||||
}
|
125
src/pages/posts/[...page].astro
Normal file
125
src/pages/posts/[...page].astro
Normal file
@ -0,0 +1,125 @@
|
||||
---
|
||||
import type { CollectionEntry } from "astro:content";
|
||||
import Pagination from "@/components/Paginator.astro";
|
||||
import PostPreview from "@/components/blog/PostPreview.astro";
|
||||
import { getAllPosts, getUniqueTags, groupPostsByYear } from "@/data/post";
|
||||
import PageLayout from "@/layouts/Base.astro";
|
||||
import { collectionDateSort } from "@/utils/date";
|
||||
import type { GetStaticPaths, Page } from "astro";
|
||||
import { Icon } from "astro-icon/components";
|
||||
|
||||
export const getStaticPaths = (async ({ paginate }) => {
|
||||
const MAX_POSTS_PER_PAGE = 10;
|
||||
const MAX_TAGS = 7;
|
||||
const allPosts = await getAllPosts();
|
||||
const uniqueTags = getUniqueTags(allPosts).slice(0, MAX_TAGS);
|
||||
return paginate(allPosts.sort(collectionDateSort), {
|
||||
pageSize: MAX_POSTS_PER_PAGE,
|
||||
props: { uniqueTags },
|
||||
});
|
||||
}) satisfies GetStaticPaths;
|
||||
|
||||
interface Props {
|
||||
page: Page<CollectionEntry<"post">>;
|
||||
uniqueTags: string[];
|
||||
}
|
||||
|
||||
const { page, uniqueTags } = 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-6 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>
|
||||
{
|
||||
descYearKeys.map((yearKey) => (
|
||||
<section aria-labelledby={`year-${yearKey}`}>
|
||||
<h2 id={`year-${yearKey}`} class="title text-lg">
|
||||
<span class="sr-only">Posts in</span>
|
||||
{yearKey}
|
||||
</h2>
|
||||
<ul class="mt-5 mb-16 space-y-6 text-start">
|
||||
{groupedByYear[yearKey]?.map((p) => (
|
||||
<li class="grid gap-2 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-2 text-lg">
|
||||
Tags
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
class="h-6 w-6"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
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>
|
||||
</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
src/pages/posts/[...slug].astro
Normal file
24
src/pages/posts/[...slug].astro
Normal file
@ -0,0 +1,24 @@
|
||||
---
|
||||
import { render } from "astro:content";
|
||||
import { getAllPosts } from "@/data/post";
|
||||
import PostLayout from "@/layouts/BlogPost.astro";
|
||||
import type { GetStaticPaths, InferGetStaticPropsType } from "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
src/pages/rss.xml.ts
Normal file
19
src/pages/rss.xml.ts
Normal file
@ -0,0 +1,19 @@
|
||||
import { getAllPosts } from "@/data/post";
|
||||
import { siteConfig } from "@/site.config";
|
||||
import rss from "@astrojs/rss";
|
||||
|
||||
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}/`,
|
||||
})),
|
||||
});
|
||||
};
|
72
src/pages/tags/[tag]/[...page].astro
Normal file
72
src/pages/tags/[tag]/[...page].astro
Normal file
@ -0,0 +1,72 @@
|
||||
---
|
||||
import type { CollectionEntry } from "astro:content";
|
||||
import Pagination from "@/components/Paginator.astro";
|
||||
import PostPreview from "@/components/blog/PostPreview.astro";
|
||||
import { getAllPosts, getUniqueTags } from "@/data/post";
|
||||
import PageLayout from "@/layouts/Base.astro";
|
||||
import { collectionDateSort } from "@/utils/date";
|
||||
import type { GetStaticPaths, Page } from "astro";
|
||||
|
||||
export const getStaticPaths: GetStaticPaths = async ({ paginate }) => {
|
||||
const allPosts = await getAllPosts();
|
||||
const sortedPosts = allPosts.sort(collectionDateSort);
|
||||
const uniqueTags = getUniqueTags(sortedPosts);
|
||||
|
||||
return uniqueTags.flatMap((tag) => {
|
||||
const filterPosts = sortedPosts.filter((post) => post.data.tags.includes(tag));
|
||||
return paginate(filterPosts, {
|
||||
pageSize: 10,
|
||||
params: { tag },
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
interface Props {
|
||||
page: Page<CollectionEntry<"post">>;
|
||||
}
|
||||
|
||||
const { page } = Astro.props;
|
||||
const { tag } = Astro.params;
|
||||
|
||||
const meta = {
|
||||
description: `View all posts with the tag - ${tag}`,
|
||||
title: `Tag: ${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}>
|
||||
<div class="mb-6 flex items-center">
|
||||
<h1 class="sr-only">Posts with the tag {tag}</h1>
|
||||
<a class="title text-accent" href="/tags/"><span class="sr-only">All {" "}</span>Tags</a>
|
||||
<span aria-hidden="true" class="ms-2 me-3 text-xl">→</span>
|
||||
<span aria-hidden="true" class="text-xl">#{tag}</span>
|
||||
</div>
|
||||
<section aria-labelledby={`tags-${tag}`}>
|
||||
<h2 id={`tags-${tag}`} class="sr-only">Post List</h2>
|
||||
<ul class="space-y-6">
|
||||
{
|
||||
page.data.map((p) => (
|
||||
<li class="grid gap-2 sm:grid-cols-[auto_1fr]">
|
||||
<PostPreview as="h2" post={p} />
|
||||
</li>
|
||||
))
|
||||
}
|
||||
</ul>
|
||||
<Pagination {...paginationProps} />
|
||||
</section>
|
||||
</PageLayout>
|
35
src/pages/tags/index.astro
Normal file
35
src/pages/tags/index.astro
Normal file
@ -0,0 +1,35 @@
|
||||
---
|
||||
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-6">
|
||||
{
|
||||
allTags.map(([tag, val]) => (
|
||||
<li class="flex items-center gap-x-2">
|
||||
<a
|
||||
class="cactus-link inline-block"
|
||||
data-astro-prefetch
|
||||
href={`/tags/${tag}/`}
|
||||
title={`View posts with the tag: ${tag}`}
|
||||
>
|
||||
#{tag}
|
||||
</a>
|
||||
<span class="inline-block">
|
||||
- {val} Post{val > 1 && "s"}
|
||||
</span>
|
||||
</li>
|
||||
))
|
||||
}
|
||||
</ul>
|
||||
</PageLayout>
|
102
src/plugins/remark-admonitions.ts
Normal file
102
src/plugins/remark-admonitions.ts
Normal file
@ -0,0 +1,102 @@
|
||||
import type { AdmonitionType } from "@/types";
|
||||
import { type Properties, h as _h } from "hastscript";
|
||||
import type { Node, Paragraph as P, Parent, PhrasingContent, Root } from "mdast";
|
||||
import type { Directives, 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";
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
/** Checks if a node is a directive. */
|
||||
function isNodeDirective(node: Node): node is Directives {
|
||||
return (
|
||||
node.type === "containerDirective" ||
|
||||
node.type === "leafDirective" ||
|
||||
node.type === "textDirective"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/** From Astro Starlight: Function that generates an mdast HTML tree ready for conversion to HTML by rehype. */
|
||||
// biome-ignore lint/suspicious/noExplicitAny: <explanation>
|
||||
function h(el: string, attrs: Properties = {}, children: any[] = []): P {
|
||||
const { properties, tagName } = _h(el, attrs);
|
||||
return {
|
||||
children,
|
||||
data: { hName: tagName, hProperties: properties },
|
||||
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;
|
||||
});
|
||||
};
|
11
src/plugins/remark-reading-time.ts
Normal file
11
src/plugins/remark-reading-time.ts
Normal 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
src/site.config.ts
Normal file
81
src/site.config.ts
Normal file
@ -0,0 +1,81 @@
|
||||
import type { SiteConfig } from "@/types";
|
||||
import type { AstroExpressiveCodeOptions } from "astro-expressive-code";
|
||||
|
||||
export const siteConfig: SiteConfig = {
|
||||
// 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",
|
||||
// Date.prototype.toLocaleDateString() parameters, found in src/utils/date.ts.
|
||||
date: {
|
||||
locale: "en-GB",
|
||||
options: {
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
},
|
||||
},
|
||||
// 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",
|
||||
/*
|
||||
- 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",
|
||||
// ! 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 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,
|
||||
};
|
73
src/styles/blocks/search.css
Normal file
73
src/styles/blocks/search.css
Normal file
@ -0,0 +1,73 @@
|
||||
@import "@pagefind/default-ui/css/ui.css";
|
||||
|
||||
:root {
|
||||
--pagefind-ui-font: inherit;
|
||||
}
|
||||
|
||||
#cactus__search {
|
||||
--pagefind-ui-primary: var(--color-accent);
|
||||
--pagefind-ui-text: var(--color-global-text);
|
||||
--pagefind-ui-background: var(--color-zinc-100);
|
||||
--pagefind-ui-border: var(--color-zinc-300);
|
||||
--pagefind-ui-border-width: 1px;
|
||||
|
||||
[data-theme="dark"] & {
|
||||
--pagefind-ui-background: var(--color-zinc-800);
|
||||
--pagefind-ui-border: var(--color-zinc-500);
|
||||
}
|
||||
}
|
||||
|
||||
#cactus__search .pagefind-ui__search-clear {
|
||||
width: calc(60px * var(--pagefind-ui-scale));
|
||||
padding: 0;
|
||||
background-color: transparent;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#cactus__search .pagefind-ui__search-clear:focus {
|
||||
outline: 1px solid var(--color-accent-2);
|
||||
}
|
||||
|
||||
#cactus__search .pagefind-ui__search-clear::before {
|
||||
content: "";
|
||||
-webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24' stroke-width='1.5' stroke='currentColor' %3E%3Cpath stroke-linecap='round' stroke-linejoin='round' d='M6 18L18 6M6 6l12 12'%3E%3C/path%3E%3C/svg%3E")
|
||||
center / 60% no-repeat;
|
||||
mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24' stroke-width='1.5' stroke='currentColor' %3E%3Cpath stroke-linecap='round' stroke-linejoin='round' d='M6 18L18 6M6 6l12 12'%3E%3C/path%3E%3C/svg%3E")
|
||||
center / 60% no-repeat;
|
||||
background-color: var(--color-accent);
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
#cactus__search .pagefind-ui__result {
|
||||
border: 0;
|
||||
}
|
||||
|
||||
#cactus__search .pagefind-ui__result-link {
|
||||
background-size: 100% 6px;
|
||||
background-position: bottom;
|
||||
background-repeat: repeat-x;
|
||||
background-image: linear-gradient(
|
||||
transparent,
|
||||
transparent 5px,
|
||||
var(--color-global-text) 5px,
|
||||
var(--color-global-text)
|
||||
);
|
||||
}
|
||||
|
||||
#cactus__search .pagefind-ui__result-link:hover {
|
||||
text-decoration: none;
|
||||
background-image: linear-gradient(
|
||||
transparent,
|
||||
transparent 4px,
|
||||
var(--color-link) 4px,
|
||||
var(--color-link)
|
||||
);
|
||||
}
|
||||
|
||||
#cactus__search mark {
|
||||
color: var(--color-quote);
|
||||
background-color: transparent;
|
||||
font-weight: 600;
|
||||
}
|
134
src/styles/global.css
Normal file
134
src/styles/global.css
Normal file
@ -0,0 +1,134 @@
|
||||
/* would like to ignore ./src/pages/og-image/[slug].png.ts */
|
||||
@import "tailwindcss";
|
||||
/* config for tailwindcss-typography plugin */
|
||||
@config "../../tailwind.config.ts";
|
||||
|
||||
/* use a selector-based strategy for dark mode */
|
||||
@variant dark (&:where([data-theme="dark"], [data-theme="dark"] *));
|
||||
|
||||
/* you could refactor below to use light-dark(), depending on your target audience */
|
||||
@theme {
|
||||
--color-global-bg: oklch(98.48% 0 0);
|
||||
--color-global-text: oklch(26.99% 0.0096 235.05);
|
||||
--color-link: oklch(55.44% 0.0431 185.69);
|
||||
--color-accent: oklch(55.27% 0.195 19.06);
|
||||
--color-accent-2: oklch(18.15% 0 0);
|
||||
--color-quote: oklch(55.27% 0.195 19.06);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
html {
|
||||
color-scheme: light dark;
|
||||
accent-color: var(--color-accent);
|
||||
|
||||
&[data-theme="light"] {
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
&[data-theme="dark"] {
|
||||
color-scheme: dark;
|
||||
--color-global-bg: oklch(23.64% 0.0045 248);
|
||||
--color-global-text: oklch(83.54% 0 264);
|
||||
--color-link: oklch(70.44% 0.1133 349);
|
||||
--color-accent: oklch(70.91% 0.1415 163.7);
|
||||
--color-accent-2: oklch(94.66% 0 0);
|
||||
--color-quote: oklch(94.8% 0.106 136.49);
|
||||
}
|
||||
}
|
||||
|
||||
:target {
|
||||
scroll-margin-block: 5ex;
|
||||
}
|
||||
|
||||
@view-transition {
|
||||
navigation: auto;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.cactus-link {
|
||||
@apply hover:decoration-link underline underline-offset-2 hover:decoration-2;
|
||||
}
|
||||
|
||||
.title {
|
||||
@apply text-accent-2 text-2xl font-semibold;
|
||||
}
|
||||
|
||||
.admonition {
|
||||
--admonition-color: var(--tw-prose-quotes);
|
||||
@apply my-4 border-s-2 border-(--admonition-color) py-4 ps-4;
|
||||
|
||||
.admonition-title {
|
||||
@apply my-0! flex items-center gap-2 text-base font-bold text-(--admonition-color) capitalize;
|
||||
&:before {
|
||||
@apply inline-block h-4 w-4 shrink-0 overflow-visible bg-(--admonition-color) align-middle content-[''];
|
||||
mask-size: contain;
|
||||
mask-position: center;
|
||||
mask-repeat: no-repeat;
|
||||
}
|
||||
}
|
||||
|
||||
.admonition-content {
|
||||
> :last-child {
|
||||
@apply mb-0!;
|
||||
}
|
||||
}
|
||||
|
||||
&[data-admonition-type="note"] {
|
||||
--admonition-color: var(--color-blue-400);
|
||||
@apply bg-blue-400/5;
|
||||
|
||||
.admonition-title::before {
|
||||
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' version='1.1' width='16' height='16' aria-hidden='true'%3E%3Cpath fill='var(--admonitions-color-tip)' d='M0 8a8 8 0 1 1 16 0A8 8 0 0 1 0 8Zm8-6.5a6.5 6.5 0 1 0 0 13 6.5 6.5 0 0 0 0-13ZM6.5 7.75A.75.75 0 0 1 7.25 7h1a.75.75 0 0 1 .75.75v2.75h.25a.75.75 0 0 1 0 1.5h-2a.75.75 0 0 1 0-1.5h.25v-2h-.25a.75.75 0 0 1-.75-.75ZM8 6a1 1 0 1 1 0-2 1 1 0 0 1 0 2Z'%3E%3C/path%3E%3C/svg%3E");
|
||||
}
|
||||
}
|
||||
|
||||
&[data-admonition-type="tip"] {
|
||||
--admonition-color: var(--color-lime-500);
|
||||
@apply bg-lime-500/5;
|
||||
|
||||
.admonition-title::before {
|
||||
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' version='1.1' width='16' height='16' aria-hidden='true'%3E%3Cpath d='M8 1.5c-2.363 0-4 1.69-4 3.75 0 .984.424 1.625.984 2.304l.214.253c.223.264.47.556.673.848.284.411.537.896.621 1.49a.75.75 0 0 1-1.484.211c-.04-.282-.163-.547-.37-.847a8.456 8.456 0 0 0-.542-.68c-.084-.1-.173-.205-.268-.32C3.201 7.75 2.5 6.766 2.5 5.25 2.5 2.31 4.863 0 8 0s5.5 2.31 5.5 5.25c0 1.516-.701 2.5-1.328 3.259-.095.115-.184.22-.268.319-.207.245-.383.453-.541.681-.208.3-.33.565-.37.847a.751.751 0 0 1-1.485-.212c.084-.593.337-1.078.621-1.489.203-.292.45-.584.673-.848.075-.088.147-.173.213-.253.561-.679.985-1.32.985-2.304 0-2.06-1.637-3.75-4-3.75ZM5.75 12h4.5a.75.75 0 0 1 0 1.5h-4.5a.75.75 0 0 1 0-1.5ZM6 15.25a.75.75 0 0 1 .75-.75h2.5a.75.75 0 0 1 0 1.5h-2.5a.75.75 0 0 1-.75-.75Z'%3E%3C/path%3E%3C/svg%3E");
|
||||
}
|
||||
}
|
||||
|
||||
&[data-admonition-type="important"] {
|
||||
--admonition-color: var(--color-purple-400);
|
||||
@apply bg-purple-400/5;
|
||||
|
||||
.admonition-title::before {
|
||||
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' version='1.1' width='16' height='16' aria-hidden='true'%3E%3Cpath d='M0 1.75C0 .784.784 0 1.75 0h12.5C15.216 0 16 .784 16 1.75v9.5A1.75 1.75 0 0 1 14.25 13H8.06l-2.573 2.573A1.458 1.458 0 0 1 3 14.543V13H1.75A1.75 1.75 0 0 1 0 11.25Zm1.75-.25a.25.25 0 0 0-.25.25v9.5c0 .138.112.25.25.25h2a.75.75 0 0 1 .75.75v2.19l2.72-2.72a.749.749 0 0 1 .53-.22h6.5a.25.25 0 0 0 .25-.25v-9.5a.25.25 0 0 0-.25-.25Zm7 2.25v2.5a.75.75 0 0 1-1.5 0v-2.5a.75.75 0 0 1 1.5 0ZM9 9a1 1 0 1 1-2 0 1 1 0 0 1 2 0Z'%3E%3C/path%3E%3C/svg%3E");
|
||||
}
|
||||
}
|
||||
|
||||
&[data-admonition-type="caution"] {
|
||||
--admonition-color: var(--color-orange-400);
|
||||
@apply bg-orange-400/5;
|
||||
|
||||
.admonition-title::before {
|
||||
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' version='1.1' width='16' height='16' aria-hidden='true'%3E%3Cpath d='M4.47.22A.749.749 0 0 1 5 0h6c.199 0 .389.079.53.22l4.25 4.25c.141.14.22.331.22.53v6a.749.749 0 0 1-.22.53l-4.25 4.25A.749.749 0 0 1 11 16H5a.749.749 0 0 1-.53-.22L.22 11.53A.749.749 0 0 1 0 11V5c0-.199.079-.389.22-.53Zm.84 1.28L1.5 5.31v5.38l3.81 3.81h5.38l3.81-3.81V5.31L10.69 1.5ZM8 4a.75.75 0 0 1 .75.75v3.5a.75.75 0 0 1-1.5 0v-3.5A.75.75 0 0 1 8 4Zm0 8a1 1 0 1 1 0-2 1 1 0 0 1 0 2Z'%3E%3C/path%3E%3C/svg%3E");
|
||||
}
|
||||
}
|
||||
|
||||
&[data-admonition-type="warning"] {
|
||||
--admonition-color: var(--color-red-500);
|
||||
@apply bg-red-500/5;
|
||||
|
||||
.admonition-title::before {
|
||||
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' version='1.1' width='16' height='16' aria-hidden='true'%3E%3Cpath d='M6.457 1.047c.659-1.234 2.427-1.234 3.086 0l6.082 11.378A1.75 1.75 0 0 1 14.082 15H1.918a1.75 1.75 0 0 1-1.543-2.575Zm1.763.707a.25.25 0 0 0-.44 0L1.698 13.132a.25.25 0 0 0 .22.368h12.164a.25.25 0 0 0 .22-.368Zm.53 3.996v2.5a.75.75 0 0 1-1.5 0v-2.5a.75.75 0 0 1 1.5 0ZM9 11a1 1 0 1 1-2 0 1 1 0 0 1 2 0Z'%3E%3C/path%3E%3C/svg%3E");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@utility prose {
|
||||
--tw-prose-body: var(--color-global-text);
|
||||
--tw-prose-bold: var(--color-global-text);
|
||||
--tw-prose-bullets: var(--color-global-text);
|
||||
--tw-prose-code: var(--color-global-text);
|
||||
--tw-prose-headings: var(--color-accent-2);
|
||||
--tw-prose-hr: 0.5px dashed #666;
|
||||
--tw-prose-links: var(--color-global-text);
|
||||
--tw-prose-quotes: var(--color-quote);
|
||||
--tw-prose-th-borders: #666;
|
||||
}
|
83
src/types.ts
Normal file
83
src/types.ts
Normal 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
src/utils/date.ts
Normal file
23
src/utils/date.ts
Normal 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
src/utils/domElement.ts
Normal file
11
src/utils/domElement.ts
Normal 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
src/utils/generateToc.ts
Normal file
37
src/utils/generateToc.ts
Normal 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;
|
||||
}
|
115
src/utils/webmentions.ts
Normal file
115
src/utils/webmentions.ts
Normal file
@ -0,0 +1,115 @@
|
||||
import * as fs from "node:fs";
|
||||
import { WEBMENTION_API_KEY } from "astro:env/server";
|
||||
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);
|
||||
}
|
89
tailwind.config.ts
Normal file
89
tailwind.config.ts
Normal file
@ -0,0 +1,89 @@
|
||||
import type { Config } from "tailwindcss";
|
||||
|
||||
export default {
|
||||
plugins: [require("@tailwindcss/typography")],
|
||||
theme: {
|
||||
extend: {
|
||||
typography: () => ({
|
||||
DEFAULT: {
|
||||
css: {
|
||||
a: {
|
||||
textUnderlineOffset: "2px",
|
||||
"&:hover": {
|
||||
"@media (hover: hover)": {
|
||||
textDecorationColor: "var(--color-link)",
|
||||
textDecorationThickness: "2px",
|
||||
},
|
||||
},
|
||||
},
|
||||
blockquote: {
|
||||
borderLeftWidth: "0",
|
||||
},
|
||||
code: {
|
||||
border: "1px dotted #666",
|
||||
borderRadius: "2px",
|
||||
},
|
||||
kbd: {
|
||||
"&:where([data-theme='dark'], [data-theme='dark'] *)": {
|
||||
background: "var(--color-global-text)",
|
||||
},
|
||||
},
|
||||
hr: {
|
||||
borderTopStyle: "dashed",
|
||||
},
|
||||
strong: {
|
||||
fontWeight: "700",
|
||||
},
|
||||
sup: {
|
||||
marginInlineStart: "calc(var(--spacing) * 0.5)",
|
||||
a: {
|
||||
"&:after": {
|
||||
content: "']'",
|
||||
},
|
||||
"&:before": {
|
||||
content: "'['",
|
||||
},
|
||||
"&:hover": {
|
||||
"@media (hover: hover)": {
|
||||
color: "var(--color-link)",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
/* Table */
|
||||
"tbody tr": {
|
||||
borderBottomWidth: "none",
|
||||
},
|
||||
tfoot: {
|
||||
borderTop: "1px dashed #666",
|
||||
},
|
||||
thead: {
|
||||
borderBottomWidth: "none",
|
||||
},
|
||||
"thead th": {
|
||||
borderBottom: "1px dashed #666",
|
||||
fontWeight: "700",
|
||||
},
|
||||
'th[align="center"], td[align="center"]': {
|
||||
"text-align": "center",
|
||||
},
|
||||
'th[align="right"], td[align="right"]': {
|
||||
"text-align": "right",
|
||||
},
|
||||
'th[align="left"], td[align="left"]': {
|
||||
"text-align": "left",
|
||||
},
|
||||
},
|
||||
},
|
||||
sm: {
|
||||
css: {
|
||||
code: {
|
||||
fontSize: "var(--text-sm)",
|
||||
fontWeight: "400",
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
} satisfies Config;
|
12
tsconfig.json
Normal file
12
tsconfig.json
Normal file
@ -0,0 +1,12 @@
|
||||
{
|
||||
"extends": "astro/tsconfigs/strictest",
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"lib": ["es2022", "dom", "dom.iterable"],
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
}
|
||||
},
|
||||
"include": [".astro/types.d.ts", "**/*"],
|
||||
"exclude": ["node_modules", "**/node_modules/*", ".vscode", "dist"]
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user