Initial commit from Astro

This commit is contained in:
houston[bot]
2025-08-02 13:27:23 -06:00
committed by Ignacio
commit c636f09699
131 changed files with 26084 additions and 0 deletions

22
.gitignore vendored Normal file
View File

@@ -0,0 +1,22 @@
# Node modules
node_modules/
# Build output
dist/
.astro/
.cache/
# Logs
*.log
*.log.*
# Editor files
.vscode/
.idea/
.DS_Store
Thumbs.db
# Environment variables
.env
.env.local
.env.*.local

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 Artem Kutsan
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.

178
README.md Normal file
View File

@@ -0,0 +1,178 @@
<div align="center">
<img alt="Astro Citrus logo" src="https://github.com/ArtemKutsan/astro-citrus/blob/main/src/assets/images/logo.svg" width="70" />
</div>
<h1 align="center">
Astro Citrus
</h1>
Astro Citrus is a simple opinionated starter built with the Astro framework. 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 and notes](#adding-posts-and-notes)
- [Post Frontmatter](#post-frontmatter)
- [Note Frontmatter](#note-frontmatter)
- [Frontmatter Snippet](#frontmatter-snippet)
9. [Pagefind search](#pagefind-search)
10. [Analytics](#analytics)
11. [Deploy](#deploy)
12. [Acknowledgment](#acknowledgment)
## Key Features
- Astro v5 Fast 🚀
- TailwindCSS Utility classes
- Accessible, semantic HTML markup
- Responsive & SEO-friendly
- Dark / Light mode, using Tailwind and CSS variables
- MD & [MDX](https://docs.astro.build/en/guides/markdown-content/#mdx-only-features) posts & notes
- Includes [Admonitions](http://astrocitrus.artemkutsan.pp.ua/posts/markdown-elements/admonistions/)
- [Satori](https://github.com/vercel/satori) for creating open graph png images
- [Automatic RSS feed](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
- [Rehype Pretty Code](https://rehype-pretty.pages.dev/) code blocks and syntax highlighter
## Demo
Check out the [Demo](https://astrocitrus.netlify.app/)
## Quick start
[Create a new repo](https://github.com/artemkutsan/astro-citrus/generate) from this template.
```bash
# npm 7+
npm create astro@latest -- --template artemkutsan/astro-citrus
# pnpm
pnpm dlx create-astro --template artemkutsan/astro-citrus
```
[![Deploy with Netlify](https://www.netlify.com/img/deploy/button.svg)](https://app.netlify.com/start/deploy?repository=https://github.com/artemkutsan/astro-citrus) [![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fartemkutsan%2Fastro-citrus&project-name=astro-citrus)
## Preview
| ![Light Theme 01](https://github.com/ArtemKutsan/astro-citrus/blob/main/public/images/screenshot01.png?raw=true) | ![Light Theme 02](https://github.com/ArtemKutsan/astro-citrus/blob/main/public/images/screenshot02.png?raw=true) |
|-------------|-------------|
| ![Dark Theme 03](https://github.com/ArtemKutsan/astro-citrus/blob/main/public/images/screenshot03.png?raw=true) | ![Light Theme 04](https://github.com/ArtemKutsan/astro-citrus/blob/main/public/images/screenshot04.png?raw=true) |
| ![Light Theme 05](https://github.com/ArtemKutsan/astro-citrus/blob/main/public/images/screenshot05.png?raw=true) | ![Light Theme 06](https://github.com/ArtemKutsan/astro-citrus/blob/main/public/images/screenshot06.png?raw=true) |
| ![Dark Theme 07](https://github.com/ArtemKutsan/astro-citrus/blob/main/public/images/screenshot07.png?raw=true) | ![Dark Theme 08](https://github.com/ArtemKutsan/astro-citrus/blob/main/public/images/screenshot08.png?raw=true) |
## 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 config file `src/site.config.ts` for basic site meta data
- Update file `astro.config.ts`
- **Important**: the site property with your own domain.
- [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.
- You can also modify the theme(s) for markdown code blocks generated by [Rehype Pretty Code](https://rehype-pretty.pages.dev/). Astro Citrus has both a dark (rose-pine) and light (rose-pine-dawn) theme, which can be found in `src/site.config.ts`. You can find more theme(s) and options [here](https://shiki.matsu.io/).
- 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://astrocitrus.artemkutsan.pp.ua/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`, located in the global css file `src/styles/global.css`. 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 utilises [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 post's page, and the page's 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 `<FormattedDate>` component if required. |
| **updatedDate** | This is an optional date representing when a post has been updated, in the same format as the `publishDate`. |
| **seriesId** | An optional property that groups posts into a series. Posts with the same `seriesId` are considered part of the same series and can be displayed together in order. This allows for better organization of related content. |
| **orderInSeries** | A numeric value defining the position of a post within a series. Lower values indicate earlier posts in the series, while higher values appear later. Used for sorting and navigation between posts within the same series. |
| **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 `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 setting it to `true`, the post will be filtered out of the production build in a number of places, including `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 snippet
Astro Citrus 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/artemkutsan/astro-citrus/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 [Astro Theme Cactus](https://github.com/chrismwilliams/astro-theme-cactus) by [Chriss Williams](https://github.com/chrismwilliams). Huge thanks to Chriss for his amazing work and inspiration!** 🚀👏
## License
MIT

167
astro.config.ts Normal file
View File

@@ -0,0 +1,167 @@
import fs from "node:fs";
import mdx from "@astrojs/mdx";
import sitemap from "@astrojs/sitemap";
import tailwind from "@astrojs/tailwind";
import icon from "astro-icon";
import robotsTxt from "astro-robots-txt";
import webmanifest from "astro-webmanifest";
import { defineConfig, envField } from "astro/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 rehypeExternalLinks from "rehype-external-links";
import rehypeUnwrapImages from "rehype-unwrap-images";
import rehypePrettyCode from "rehype-pretty-code";
import {
transformerMetaHighlight,
transformerNotationDiff,
} from "@shikijs/transformers";
// https://astro.build/config
export default defineConfig({
image: {
domains: ["webmention.io"],
},
integrations: [
icon(),
tailwind({
applyBaseStyles: false,
nesting: true,
}),
sitemap(),
mdx(),
robotsTxt(),
webmanifest({
// See: https://github.com/alextim/astro-lib/blob/main/packages/astro-webmanifest/README.md
/**
* required
**/
name: siteConfig.title,
/**
* optional
**/
// short_name: "Astro_Citrus",
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: {
syntaxHighlight: false,
remarkPlugins: [remarkReadingTime, remarkDirective, remarkAdmonitions],
remarkRehype: {
footnoteLabelProperties: {
className: [""],
},
footnoteBackContent: "⤴",
},
rehypePlugins: [
[
rehypeExternalLinks,
{
rel: ["nofollow", "noreferrer"],
target: "_blank",
},
],
[
rehypePrettyCode,
{
theme: {
light: "rose-pine-dawn", // after changing the theme, the server needs to be restarted
dark: "rose-pine", // after changing the theme, the server needs to be restarted
},
transformers: [transformerNotationDiff(), transformerMetaHighlight()],
},
],
rehypeUnwrapImages,
],
},
// https://docs.astro.build/en/guides/prefetch/
prefetch: true,
// ! Please remember to replace the following site property with your own domain
site: "http://astrocitrus.artemkutsan.pp.ua/",
vite: {
build: {
sourcemap: true, // Source maps generation
},
optimizeDeps: {
exclude: ["@resvg/resvg-js"],
},
plugins: [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,
}),
},
},
server: {
// port: 1234,
host: 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
View 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
}
}

3
netlify.toml Normal file
View File

@@ -0,0 +1,3 @@
[build]
publish = "dist"

11948
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

68
package.json Normal file
View File

@@ -0,0 +1,68 @@
{
"name": "ignaciops-dev",
"version": "1.0.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/mdx": "4.0.3",
"@astrojs/rss": "4.0.11",
"@astrojs/sitemap": "3.2.1",
"@astrojs/tailwind": "5.1.4",
"@shikijs/transformers": "^1.25.1",
"astro": "5.1.2",
"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-external-links": "^3.0.0",
"rehype-pretty-code": "^0.14.0",
"rehype-unwrap-images": "^1.0.0",
"remark-directive": "^3.0.0",
"satori": "0.12.0",
"satori-html": "^0.3.2",
"sharp": "^0.33.5",
"unified": "^11.0.5",
"unist-util-visit": "^5.0.0"
},
"devDependencies": {
"@astrojs/check": "^0.9.4",
"@biomejs/biome": "^1.9.4",
"@iconify-json/hugeicons": "^1.2.3",
"@iconify-json/mdi": "^1.2.2",
"@iconify-json/solar": "^1.2.2",
"@pagefind/default-ui": "^1.3.0",
"@resvg/resvg-js": "^2.6.2",
"@tailwindcss/typography": "^0.5.15",
"@types/hast": "^3.0.4",
"@types/mdast": "^4.0.4",
"autoprefixer": "^10.4.20",
"pagefind": "^1.3.0",
"prettier": "^3.4.2",
"prettier-plugin-astro": "0.14.1",
"prettier-plugin-tailwindcss": "^0.6.9",
"reading-time": "^1.5.0",
"tailwindcss": "^3.4.17",
"typescript": "^5.7.2"
},
"pnpm": {
"onlyBuiltDependencies": [
"@biomejs/biome",
"esbuild",
"sharp"
]
}
}

7459
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

23
public/brand.svg Normal file
View File

@@ -0,0 +1,23 @@
<svg xmlns="http://www.w3.org/2000/svg">
<defs>
<symbol id="brand" viewBox="0 0 85 107">
<path fill="#cb2a42" d="M27.5894 91.1365C22.7555 86.7178 21.3444 77.4335 23.3583 70.7072C26.8503 74.948 31.6888 76.2914 36.7005 77.0497C44.4375 78.2199 52.0359 77.7822 59.2232 74.2459C60.0454 73.841 60.8052 73.3027 61.7036 72.7574C62.378 74.714 62.5535 76.6892 62.318 78.6996C61.7452 83.5957 59.3086 87.3778 55.4332 90.2448C53.8835 91.3916 52.2437 92.4167 50.6432 93.4979C45.7262 96.8213 44.3959 100.718 46.2435 106.386C46.2874 106.525 46.3267 106.663 46.426 107C43.9155 105.876 42.0817 104.24 40.6845 102.089C39.2087 99.8193 38.5066 97.3081 38.4696 94.5909C38.4511 93.2686 38.4511 91.9345 38.2733 90.6309C37.8391 87.4527 36.3471 86.0297 33.5364 85.9478C30.6518 85.8636 28.37 87.6469 27.7649 90.4554C27.7187 90.6707 27.6517 90.8837 27.5847 91.1341L27.5894 91.1365Z"/>
<path fill="currentColor" d="M0 69.5866C0 69.5866 14.3139 62.6137 28.6678 62.6137L39.4901 29.1204C39.8953 27.5007 41.0783 26.3999 42.4139 26.3999C43.7495 26.3999 44.9325 27.5007 45.3377 29.1204L56.1601 62.6137C73.1601 62.6137 84.8278 69.5866 84.8278 69.5866C84.8278 69.5866 60.5145 3.35233 60.467 3.21944C59.7692 1.2612 58.5911 0 57.0029 0H27.8274C26.2392 0 25.1087 1.2612 24.3634 3.21944C24.3108 3.34983 0 69.5866 0 69.5866Z"/>
</symbol>
</defs>
<defs>
<symbol id="citrus" viewBox="0 0 128 128">
<rect width="128" height="128" fill="none" />
<path fill="#currentColor" d="M22.5 14.8c2.9 1.3 99 44 99 44c2.7 1.2 2.9 3.1 2 12.4c-1 10.5-1.9 37.8-40.2 42.9c-24.6 3.2-55.9-4.3-70.9-27.2C-6 58.8 11 23.4 14.8 17.7c2.4-3.5 4.8-4.2 7.7-2.9" />
<path fill="#f0f4c3" d="M121.5 58.8c-.3-.2-92-40.8-98.9-43.9C9.6 31.5-3.8 76 27.9 96.2c33.6 21.5 78.7-10.2 93.6-37.4" />
<path fill="#f0f4c3" d="M117.7 57c-.3-.2-86.7-38.4-93.2-41.3c-12.2 15.5-23.9 59.1 5.7 78c31.4 20.2 73.6-11.2 87.5-36.7" />
<path fill="#cb2a42" d="M56.2 37.6L19.5 48.2c-2.5.8-4.4 2.8-5 5.3c-1.9 8.5.8 23.3 8.9 29.8c3.5 2.7 8.6 1.8 10.8-2.1L58.5 41c1.3-1.7.5-4.2-2.3-3.4m14.3 2.9s14.6 30.1 16 33s6.3 4.7 9.9 2.8c10.7-5.6 15.5-12.4 19.2-20.2c-8.9-3.9-42.4-18.8-42.4-18.8c-2.4-1.1-4 .6-2.7 3.2m-8.6 1.9C60.3 45.1 39.5 81 37.6 84.1c-1.9 3.2-.7 7.5 2.7 9.2c12.3 6.5 30.9.4 39.6-7c2.2-1.9 4.5-5.1 3.2-8.2S68.9 44.6 67.8 42.3c-1.2-2.7-4.1-2.8-5.9.1" />
<path fill="#f0f4c3" d="M52.2 42.2c-1.4.4-10.1 3-11.1 3.4c-1.4.5-2.2 1.7-1.9 2.5c.3.9 1.7 1.1 3.1.6c.8-.3 7.1-3.9 9.5-5.2c-3.1 2.4-10.2 9.4-11.1 10.3c-1.4 1.5-1.7 3.4-.8 4.3c1 .9 2.9.5 4.3-1c1.2-1.3 8.5-12.8 9.1-13.9s.4-1.4-1.1-1m10.1 16.4c0-2.2 1.4-10.3 1.8-12.3c.4-1.6 1.5-1.8 1.8.5s1.7 9.7 1.7 11.8s-1.2 3.3-2.7 3.3c-1.4 0-2.6-1.1-2.6-3.3M79.9 49c-1.2-1.8-3.3-4.9-4.1-6.1c-.7-1.2.1-2.1 1.8-.9s4 2.9 5.4 4c1.6 1.3 1.9 3 .8 4s-2.7.8-3.9-1" />
<ellipse cx="87.3" cy="103.12" fill="#cb2a42" rx="3.4" ry="2.3" transform="rotate(-27.098 87.298 103.126)" />
<ellipse cx="98.89" cy="103.82" fill="#cb2a42" rx="3.4" ry="2.3" transform="rotate(-30.642 98.887 103.818)" />
<ellipse cx="102.02" cy="93.8" fill="#cb2a42" rx="3.4" ry="2.3" transform="rotate(-37.16 102.017 93.797)" />
<path fill="#cb2a42" d="M57.6 30.3c-8.3-3.7-24.4-10.7-29.6-13c-3.6 2.2-9.7 11.4-11.2 21.2c-.6 3.6 1.7 5.9 5.3 4.9c0 0 33.7-9.3 35.3-9.7c1.6-.5 2-2.6.2-3.4" />
</symbol>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 3.2 KiB

4
public/icon.svg Normal file
View File

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 85 107">
<path fill="#cb2a42" d="M27.5894 91.1365C22.7555 86.7178 21.3444 77.4335 23.3583 70.7072C26.8503 74.948 31.6888 76.2914 36.7005 77.0497C44.4375 78.2199 52.0359 77.7822 59.2232 74.2459C60.0454 73.841 60.8052 73.3027 61.7036 72.7574C62.378 74.714 62.5535 76.6892 62.318 78.6996C61.7452 83.5957 59.3086 87.3778 55.4332 90.2448C53.8835 91.3916 52.2437 92.4167 50.6432 93.4979C45.7262 96.8213 44.3959 100.718 46.2435 106.386C46.2874 106.525 46.3267 106.663 46.426 107C43.9155 105.876 42.0817 104.24 40.6845 102.089C39.2087 99.8193 38.5066 97.3081 38.4696 94.5909C38.4511 93.2686 38.4511 91.9345 38.2733 90.6309C37.8391 87.4527 36.3471 86.0297 33.5364 85.9478C30.6518 85.8636 28.37 87.6469 27.7649 90.4554C27.7187 90.6707 27.6517 90.8837 27.5847 91.1341L27.5894 91.1365Z"/>
<path fill="#224d67" d="M0 69.5866C0 69.5866 14.3139 62.6137 28.6678 62.6137L39.4901 29.1204C39.8953 27.5007 41.0783 26.3999 42.4139 26.3999C43.7495 26.3999 44.9325 27.5007 45.3377 29.1204L56.1601 62.6137C73.1601 62.6137 84.8278 69.5866 84.8278 69.5866C84.8278 69.5866 60.5145 3.35233 60.467 3.21944C59.7692 1.2612 58.5911 0 57.0029 0H27.8274C26.2392 0 25.1087 1.2612 24.3634 3.21944C24.3108 3.34983 0 69.5866 0 69.5866Z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 133 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 146 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 146 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 411 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 300 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

14
public/social-icon.svg Normal file
View File

@@ -0,0 +1,14 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" width="128" height="128">
<!--
<rect width="128" height="128" fill="#f7f7f8" />
-->
<path fill="#224d67" d="M22.5 14.8c2.9 1.3 99 44 99 44c2.7 1.2 2.9 3.1 2 12.4c-1 10.5-1.9 37.8-40.2 42.9c-24.6 3.2-55.9-4.3-70.9-27.2C-6 58.8 11 23.4 14.8 17.7c2.4-3.5 4.8-4.2 7.7-2.9" />
<path fill="#f0f4c3" d="M121.5 58.8c-.3-.2-92-40.8-98.9-43.9C9.6 31.5-3.8 76 27.9 96.2c33.6 21.5 78.7-10.2 93.6-37.4" />
<path fill="#f0f4c3" d="M117.7 57c-.3-.2-86.7-38.4-93.2-41.3c-12.2 15.5-23.9 59.1 5.7 78c31.4 20.2 73.6-11.2 87.5-36.7" />
<path fill="#cb2a42" d="M56.2 37.6L19.5 48.2c-2.5.8-4.4 2.8-5 5.3c-1.9 8.5.8 23.3 8.9 29.8c3.5 2.7 8.6 1.8 10.8-2.1L58.5 41c1.3-1.7.5-4.2-2.3-3.4m14.3 2.9s14.6 30.1 16 33s6.3 4.7 9.9 2.8c10.7-5.6 15.5-12.4 19.2-20.2c-8.9-3.9-42.4-18.8-42.4-18.8c-2.4-1.1-4 .6-2.7 3.2m-8.6 1.9C60.3 45.1 39.5 81 37.6 84.1c-1.9 3.2-.7 7.5 2.7 9.2c12.3 6.5 30.9.4 39.6-7c2.2-1.9 4.5-5.1 3.2-8.2S68.9 44.6 67.8 42.3c-1.2-2.7-4.1-2.8-5.9.1" />
<path fill="#f0f4c3" d="M52.2 42.2c-1.4.4-10.1 3-11.1 3.4c-1.4.5-2.2 1.7-1.9 2.5c.3.9 1.7 1.1 3.1.6c.8-.3 7.1-3.9 9.5-5.2c-3.1 2.4-10.2 9.4-11.1 10.3c-1.4 1.5-1.7 3.4-.8 4.3c1 .9 2.9.5 4.3-1c1.2-1.3 8.5-12.8 9.1-13.9s.4-1.4-1.1-1m10.1 16.4c0-2.2 1.4-10.3 1.8-12.3c.4-1.6 1.5-1.8 1.8.5s1.7 9.7 1.7 11.8s-1.2 3.3-2.7 3.3c-1.4 0-2.6-1.1-2.6-3.3M79.9 49c-1.2-1.8-3.3-4.9-4.1-6.1c-.7-1.2.1-2.1 1.8-.9s4 2.9 5.4 4c1.6 1.3 1.9 3 .8 4s-2.7.8-3.9-1" />
<ellipse cx="87.3" cy="103.12" fill="#cb2a42" rx="3.4" ry="2.3" transform="rotate(-27.098 87.298 103.126)" />
<ellipse cx="98.89" cy="103.82" fill="#cb2a42" rx="3.4" ry="2.3" transform="rotate(-30.642 98.887 103.818)" />
<ellipse cx="102.02" cy="93.8" fill="#cb2a42" rx="3.4" ry="2.3" transform="rotate(-37.16 102.017 93.797)" />
<path fill="#cb2a42" d="M57.6 30.3c-8.3-3.7-24.4-10.7-29.6-13c-3.6 2.2-9.7 11.4-11.2 21.2c-.6 3.6 1.7 5.9 5.3 4.9c0 0 33.7-9.3 35.3-9.7c1.6-.5 2-2.6.2-3.4" />
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,14 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" width="128" height="128">
<!--
<rect width="128" height="128" fill="#f7f7f8" />
-->
<path fill="#224d67" d="M22.5 14.8c2.9 1.3 99 44 99 44c2.7 1.2 2.9 3.1 2 12.4c-1 10.5-1.9 37.8-40.2 42.9c-24.6 3.2-55.9-4.3-70.9-27.2C-6 58.8 11 23.4 14.8 17.7c2.4-3.5 4.8-4.2 7.7-2.9" />
<path fill="#f0f4c3" d="M121.5 58.8c-.3-.2-92-40.8-98.9-43.9C9.6 31.5-3.8 76 27.9 96.2c33.6 21.5 78.7-10.2 93.6-37.4" />
<path fill="#f0f4c3" d="M117.7 57c-.3-.2-86.7-38.4-93.2-41.3c-12.2 15.5-23.9 59.1 5.7 78c31.4 20.2 73.6-11.2 87.5-36.7" />
<path fill="#cb2a42" d="M56.2 37.6L19.5 48.2c-2.5.8-4.4 2.8-5 5.3c-1.9 8.5.8 23.3 8.9 29.8c3.5 2.7 8.6 1.8 10.8-2.1L58.5 41c1.3-1.7.5-4.2-2.3-3.4m14.3 2.9s14.6 30.1 16 33s6.3 4.7 9.9 2.8c10.7-5.6 15.5-12.4 19.2-20.2c-8.9-3.9-42.4-18.8-42.4-18.8c-2.4-1.1-4 .6-2.7 3.2m-8.6 1.9C60.3 45.1 39.5 81 37.6 84.1c-1.9 3.2-.7 7.5 2.7 9.2c12.3 6.5 30.9.4 39.6-7c2.2-1.9 4.5-5.1 3.2-8.2S68.9 44.6 67.8 42.3c-1.2-2.7-4.1-2.8-5.9.1" />
<path fill="#f0f4c3" d="M52.2 42.2c-1.4.4-10.1 3-11.1 3.4c-1.4.5-2.2 1.7-1.9 2.5c.3.9 1.7 1.1 3.1.6c.8-.3 7.1-3.9 9.5-5.2c-3.1 2.4-10.2 9.4-11.1 10.3c-1.4 1.5-1.7 3.4-.8 4.3c1 .9 2.9.5 4.3-1c1.2-1.3 8.5-12.8 9.1-13.9s.4-1.4-1.1-1m10.1 16.4c0-2.2 1.4-10.3 1.8-12.3c.4-1.6 1.5-1.8 1.8.5s1.7 9.7 1.7 11.8s-1.2 3.3-2.7 3.3c-1.4 0-2.6-1.1-2.6-3.3M79.9 49c-1.2-1.8-3.3-4.9-4.1-6.1c-.7-1.2.1-2.1 1.8-.9s4 2.9 5.4 4c1.6 1.3 1.9 3 .8 4s-2.7.8-3.9-1" />
<ellipse cx="87.3" cy="103.12" fill="#cb2a42" rx="3.4" ry="2.3" transform="rotate(-27.098 87.298 103.126)" />
<ellipse cx="98.89" cy="103.82" fill="#cb2a42" rx="3.4" ry="2.3" transform="rotate(-30.642 98.887 103.818)" />
<ellipse cx="102.02" cy="93.8" fill="#cb2a42" rx="3.4" ry="2.3" transform="rotate(-37.16 102.017 93.797)" />
<path fill="#cb2a42" d="M57.6 30.3c-8.3-3.7-24.4-10.7-29.6-13c-3.6 2.2-9.7 11.4-11.2 21.2c-.6 3.6 1.7 5.9 5.3 4.9c0 0 33.7-9.3 35.3-9.7c1.6-.5 2-2.6.2-3.4" />
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

View File

@@ -0,0 +1,26 @@
---
const { variant = "default", showHash = true, title } = Astro.props;
// CSS-классы для вариантов стилей
const badgeClasses = {
base: "flex items-baseline pt-[0.075rem] drop-shadow-lg active:drop-shadow-none rounded-lg h-6 px-2 text-sm font-medium transition-colors",
variants: {
default: "bg-textColor text-bgColor hover:brightness-105",
accent: "bg-accent text-bgColor hover:brightness-105",
"accent-base": "bg-accent-base text-bgColor hover:brightness-105",
"accent-one": "bg-accent-one text-bgColor hover:brightness-105",
"accent-two": "bg-accent-two text-bgColor hover:brightness-105",
muted: "bg-color-100 text-textColor hover:bg-accent-two hover:text-bgColor drop-shadow-none hover:drop-shadow-lg",
outline: "border border-lightest text-textColor drop-shadow-none",
inactive: "text-lighter bg-color-100 drop-shadow-none",
},
};
const variantClasses = badgeClasses.variants[variant as keyof typeof badgeClasses.variants]; // Приведение типов
---
<div class={`${badgeClasses.base} ${variantClasses}`}>
{showHash && <span class="h-full">#</span>}
{title}
<slot />
</div>

View File

@@ -0,0 +1,88 @@
---
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" />
{/* Theme Colour */}
<meta content="" name="theme-color" />
{/* 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" rel="alternate" title={siteConfig.title} 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" />

View 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>

View 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="flex items-center gap-x-4 font-medium text-accent">
{prevUrl && (
<a class="me-auto py-2 sm:hover:text-accent-two" data-astro-prefetch href={prevUrl.url}>
{prevUrl.srLabel && <span class="sr-only">{prevUrl.srLabel}</span>}
{prevUrl.text ? prevUrl.text : "Previous"}
</a>
)}
{nextUrl && (
<a class="ms-auto py-2 sm:hover:text-accent-two" data-astro-prefetch href={nextUrl.url}>
{nextUrl.srLabel && <span class="sr-only">{nextUrl.srLabel}</span>}
{nextUrl.text ? nextUrl.text : "Next"}
</a>
)}
</nav>
)
}

260
src/components/Search.astro Normal file
View File

@@ -0,0 +1,260 @@
---
// Heavy inspiration taken from Astro Starlight -> https://github.com/withastro/starlight/blob/main/packages/starlight/components/Search.astro
import "@pagefind/default-ui/css/ui.css";
import { Icon } from "astro-icon/components";
---
<site-search class="ms-auto" id="search">
<button
class="flex h-8 w-8 items-center justify-center rounded-lg drop-shadow-[0px_1.5px_1.5px_rgba(0,0,0,0.175)] hover:text-accent-two"
data-open-modal
disabled
>
<Icon aria-hidden="true" class="h-6 w-6" focusable="false" name="hugeicons:search-01" />
</button>
<dialog
aria-label="search"
class="h-full max-h-full w-full max-w-full md:h-fit bg-bgColor backdrop:backdrop-blur-xl md:my-8 md:min-h-[6.5rem] 'md:w-5/6' md:max-w-[44rem] md:rounded-lg overflow-y-hidden"
>
<div class="dialog-frame flex flex-col h-[100%] px-4 pt-4 pb-4 md:px-8 md:py-8 gap-4">
<!-- Заголовок и кнопка закрытия -->
<div class="md:hidden sticky top-0 z-20 flex items-center justify-between bg-bgColor">
<h4 class="title flex items-end font-semibold">Search</h4>
<button
class="flex size-8 cursor-pointer items-center justify-center rounded-lg bg-color-100 text-textColor hover:text-accent-base hover:bg-accent-base/5"
data-close-modal
>
<Icon aria-hidden="true" class="h-6 w-6" focusable="false" name="hugeicons:cancel-01" />
</button>
</div>
<!-- Содержимое -->
{
import.meta.env.DEV ? (
<div class="mx-auto text-center text-textColor">
<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 h-full">
<div id="citrus__search" />
</div>
)
}
</div>
</dialog>
</site-search>
<script>
class SiteSearch extends HTMLElement {
private closeBtn: HTMLButtonElement;
private dialog: HTMLDialogElement;
private dialogFrame: HTMLDivElement;
private openBtn: HTMLButtonElement;
closeModal = () => {
if (this.dialog.open) {
this.dialog.close();
document.body.classList.remove("overflow-hidden");
window.removeEventListener("click", this.onWindowClick);
}
};
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) => {
// Check if it's the / key
if (e.key === "/" && !this.dialog.open) {
this.openModal();
e.preventDefault();
}
};
openModal = (event?: MouseEvent) => {
this.dialog.showModal();
document.body.classList.add("overflow-hidden");
this.querySelector("input")?.focus();
event?.stopPropagation();
window.addEventListener("click", this.onWindowClick);
};
constructor() {
super();
this.openBtn = this.querySelector<HTMLButtonElement>("button[data-open-modal]")!;
this.closeBtn = this.querySelector<HTMLButtonElement>("button[data-close-modal]")!;
this.dialog = this.querySelector("dialog")!;
this.dialogFrame = this.querySelector(".dialog-frame")!;
this.openBtn.addEventListener("click", this.openModal);
this.openBtn.disabled = false;
this.closeBtn.addEventListener("click", this.closeModal);
}
connectedCallback() {
// Listen for keyboard shortcut
window.addEventListener("keydown", this.onWindowKeydown);
// 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: "#citrus__search",
showImages: false,
showSubResults: true,
});
});
}
disconnectedCallback() {
window.removeEventListener("keydown", this.onWindowKeydown);
}
}
customElements.define("site-search", SiteSearch);
</script>
<style is:global>
:root {
--pagefind-ui-border-radius: 0.5rem; /* 8px */
}
#citrus__search {
--pagefind-ui-primary: theme("colors.accent-two");
--pagefind-ui-text: theme("colors.textColor");
--pagefind-ui-background: theme("colors.bgColor");
--pagefind-ui-border: theme("colors.color.400");
@apply h-full;
}
#citrus__search .pagefind-ui {
@apply w-full h-full text-textColor font-sans;
}
#citrus__search .pagefind-ui__hidden {
@apply hidden;
}
#citrus__search .pagefind-ui__suppressed {
@apply opacity-0 pointer-events-none;
}
#citrus__search .pagefind-ui__form {
@apply relative h-full;
}
#citrus__search .pagefind-ui__form::before {
@apply absolute pointer-events-none block opacity-70 z-10 size-4 top-3 left-3;
content: "";
-webkit-mask-image:
url("data:image/svg+xml,%3Csvg width='18' height='18' viewBox='0 0 18 18' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M12.7549 11.255H11.9649L11.6849 10.985C12.6649 9.845 13.2549 8.365 13.2549 6.755C13.2549 3.165 10.3449 0.255005 6.75488 0.255005C3.16488 0.255005 0.254883 3.165 0.254883 6.755C0.254883 10.345 3.16488 13.255 6.75488 13.255C8.36488 13.255 9.84488 12.665 10.9849 11.685L11.2549 11.965V12.755L16.2549 17.745L17.7449 16.255L12.7549 11.255ZM6.75488 11.255C4.26488 11.255 2.25488 9.245 2.25488 6.755C2.25488 4.26501 4.26488 2.255 6.75488 2.255C9.24488 2.255 11.2549 4.26501 11.2549 6.755C11.2549 9.245 9.24488 11.255 6.75488 11.255Z' fill='%23000000'/%3E%3C/svg%3E%0A");
mask-image:
url("data:image/svg+xml,%3Csvg width='18' height='18' viewBox='0 0 18 18' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M12.7549 11.255H11.9649L11.6849 10.985C12.6649 9.845 13.2549 8.365 13.2549 6.755C13.2549 3.165 10.3449 0.255005 6.75488 0.255005C3.16488 0.255005 0.254883 3.165 0.254883 6.755C0.254883 10.345 3.16488 13.255 6.75488 13.255C8.36488 13.255 9.84488 12.665 10.9849 11.685L11.2549 11.965V12.755L16.2549 17.745L17.7449 16.255L12.7549 11.255ZM6.75488 11.255C4.26488 11.255 2.25488 9.245 2.25488 6.755C2.25488 4.26501 4.26488 2.255 6.75488 2.255C9.24488 2.255 11.2549 4.26501 11.2549 6.755C11.2549 9.245 9.24488 11.255 6.75488 11.255Z' fill='%23000000'/%3E%3C/svg%3E%0A");
-webkit-mask-size: 100%;
mask-size: 100%;
}
#citrus__search .pagefind-ui__search-input {
@apply bg-color-100 rounded-lg border-none text-base text-textColor font-normal w-full flex h-10 py-0 px-10 outline-none;
}
#citrus__search .pagefind-ui__search-input::placeholder {
@apply opacity-20;
}
#citrus__search .pagefind-ui__search-clear::before {
@apply bg-accent-two block w-full h-full;
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;
}
#citrus__search .pagefind-ui__search-clear {
@apply overflow-hidden absolute top-0 right-0 size-10 py-0 px-0 text-textColor font-medium cursor-pointer bg-transparent rounded-lg;
}
#citrus__search .pagefind-ui__search-clear:focus {
/* Доработать */
}
#citrus__search .pagefind-ui__drawer {
@apply flex flex-wrap md:h-[calc(100vh-10.5rem)] h-full pb-0 m-auto;
}
#citrus__search .pagefind-ui__message {
@apply text-base font-normal text-lighter h-10 py-0 flex items-center border-none;
}
#citrus__search .pagefind-ui__button {
@apply absolute bottom-0 m-0 shadow-md border-none text-bgColor flex items-center justify-center rounded-lg h-10 py-0 px-2 text-base text-center font-medium cursor-pointer bg-accent-base;
}
#citrus__search .pagefind-ui__button:hover {
@apply brightness-110;
}
#citrus__search .pagefind-ui__result {
@apply border-none p-0 mb-8;
}
#citrus__search .pagefind-ui__result:last-of-type {
@apply mb-0;
}
#citrus__search .pagefind-ui__result-link {
@apply title text-base bg-transparent text-accent-base font-medium;
}
#citrus__search .pagefind-ui__result-link:hover {
/* Добаботать */
}
#citrus__search .pagefind-ui__result-nested {
@apply ps-4;
}
#citrus__search .pagefind-ui__result-nested:first-of-type {
@apply pt-0;
}
#citrus__search .pagefind-ui__result-excerpt {
@apply inline-block font-normal text-base text-textColor mt-0 mb-0 md:line-clamp-1 mr-2;
}
#citrus__search .pagefind-ui__result-inner {
@apply flex-1 flex flex-col items-start mt-0;
}
#citrus__search .pagefind-ui__results {
@apply p-0 text-textColor text-base max-h-[calc(100vh-13.5rem)] md:max-h-[calc(100vh-13rem)] overflow-y-auto;
}
#citrus__search .pagefind-ui__results-area {
@apply flex-1 mt-0 mb-0;
/* min-width: min(25rem, 100%); */ /* 400px */
}
#citrus__search mark {
@apply text-accent-two bg-transparent;
}
</style>

View File

@@ -0,0 +1,33 @@
---
export interface SeparatorProps {
type?: "horizontal" | "vertical" | "dot"; // Тип разделителя
className?: string; // Дополнительные классы
}
const { type = "horizontal", className = "" } = Astro.props;
// Классы для разделителей в зависимости от типа
const separatorClasses = {
base: "flex-shrink-0 bg-lighter mx-2", // Общие стили
types: {
horizontal: "h-[1px] w-full", // Горизонтальный разделитель
vertical: "h-full w-[1px]", // Вертикальный разделитель
dot: "w-1.5 h-1.5 rounded-full", // Кружок для dot
} as const, // Указываем, что типы здесь конкретные строки
};
// Убедитесь, что type - это один из допустимых типов
const typeClass = separatorClasses.types[type as keyof typeof separatorClasses.types];
---
{
type === "dot" ? (
<span class={`${separatorClasses.base} ${typeClass} ${className}`} />
) : (
<span
role="separator"
aria-orientation={type === "horizontal" ? "horizontal" : "vertical"}
class={`${separatorClasses.base} ${typeClass} ${className}`}
/>
)
}

View File

@@ -0,0 +1,78 @@
---
import { Icon } from "astro-icon/components";
import { type CollectionEntry, getCollection } from "astro:content";
const { seriesId } = Astro.props;
const currentPath = Astro.url.pathname;
const posts = await getCollection("post");
const series = await getCollection("series");
// Находим серию по переданному `seriesId` указанному в посте
const postsSeries = series.find((s) => s.id === seriesId);
if (!postsSeries) {
throw new Error(`Post(s) with Series ID '${seriesId}' not found.`);
}
// console.log("Post(s) Series:", postsSeries); // Debug. Логируем найденную серию
let seriesPosts: CollectionEntry<"post">[] = [];
if (seriesId) {
seriesPosts = posts
.filter((p) => p.data.seriesId === seriesId)
.sort((a, b) => (a.data.orderInSeries || 0) - (b.data.orderInSeries || 0));
}
---
<aside
id="series-panel"
class="hidden grid lg:block z-40 min-h-screen fixed lg:relative `shadow-[5px_0px_10px_rgba(0,0,0,0.05)]` transition-all duration-300 ease-in-out bg-bgColor"
>
<div class="fixed -z-10 top-0 w-screen md:w-72 md:min-w-72 md:max-w-72 h-screen bg-gradient-to-b from-orange-300 via-pink-300 to-purple-300 opacity-30 dark:opacity-0">
</div>
<div class="flex h-full flex-col px-8 pt-4 md:pt-8 w-screen md:w-72 md:min-w-72 md:max-w-72 bg-accent-base/5 border-r border-special-light">
<div class="flex gap-x-1">
<!--
<Icon aria-hidden="true" class="flex-shrink-0 h-8 w-6 py-1" focusable="false" name="solar:notes-line-duotone" />
-->
<h4 class="flex items-center title mb-[4.5rem]">
Docs Series panel
</h4>
</div>
<button
id="close-panel"
class="absolute top-4 right-4 md:top-8 md:right-8 h-8 w-8 flex items-center justify-center rounded-lg bg-accent-base/5 text-accent-base hover:bg-accent-base/10"
aria-label="Close Series Panel"
>
<Icon class="h-6 w-6" name="hugeicons:cancel-01" />
</button>
<div class="sticky top-8">
{postsSeries.id ? (
<a
href={`/series/${postsSeries.id}`}
aria-label={`About ${postsSeries.data.title} series`}
class="sticky top-4 flex h-8 w-full items-center justify-center gap-x-1 rounded-lg shadow-lg bg-accent-base font-medium text-bgColor hover:brightness-110 transition-all duration-300"
>
<Icon class="inline-block h-6 w-6 text-bgColor" name="solar:notes-bold" />
{postsSeries.data.title}
</a>
<ul class="mt-[1.0625rem] text-sm font-medium text-light">
{seriesPosts.map((p) => {
const isActive = currentPath === `/posts/${p.id}/`;
return (
<li class={`px-4 flex items-center line-clamp-2 pt-1 pb-1 ${isActive ? "rounded-lg bg-color-100" : ""}`}>
<a
href={`/posts/${p.id}/`}
class={`hover:text-accent-two ${isActive ? "text-accent cursor-default pointer-events-none" : ""}`}
>
{p.data.title}
</a>
</li>
);
})}
</ul>
) : null}
</div>
</div>
</aside>

View 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>

View 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/artemkutsan/astro-citrus",
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="inline-block hover:text-link drop-shadow-[0px_2.5px_2.5px_rgba(0,0,0,0.175)]"
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>

View File

@@ -0,0 +1,47 @@
{/* 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);
const colorThemeMetaTag = document.querySelector("meta[name='theme-color']");
const bgColor = getComputedStyle(document.body).getPropertyValue("--theme-bg");
colorThemeMetaTag.setAttribute("content", `hsl(${bgColor})`);
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>

View File

@@ -0,0 +1,55 @@
---
import { Icon } from "astro-icon/components";
---
<theme-toggle class="sticky top-0">
<button
class="sticky top-0 flex h-8 w-8 items-center justify-center rounded-lg drop-shadow-[0px_1.5px_1.5px_rgba(0,0,0,0.175)] hover:text-accent-two"
type="button"
>
<span class="sr-only">Dark Theme</span>
<Icon
aria-hidden="true"
class="absolute start-1/2 top-1/2 h-6 w-6 -translate-x-1/2 -translate-y-1/2 scale-100 opacity-100 transition-all dark:scale-0 dark:opacity-0"
focusable="false"
name="solar:sun-bold"
/>
<Icon
aria-hidden="true"
class="absolute start-1/2 top-1/2 h-6 w-6 -translate-x-1/2 -translate-y-1/2 scale-0 opacity-0 transition-all dark:scale-100 dark:opacity-100"
focusable="false"
name="solar:moon-bold"
/>
</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 {
connectedCallback() {
const button = this.querySelector<HTMLButtonElement>("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()));
});
}
}
customElements.define("theme-toggle", ThemeToggle);
</script>

View File

@@ -0,0 +1,156 @@
---
import { Image } from "astro:assets";
import { type CollectionEntry, getCollection } from "astro:content";
import FormattedDate from "@/components/FormattedDate.astro";
import { Icon } from "astro-icon/components";
import Badge from '@/components/Badge.astro';
import Separator from "../Separator.astro";
interface Props {
content: CollectionEntry<"post">;
}
const {
content,
} = Astro.props;
// console.log("data:", data); // Debug
// console.log("headings:", content.rendered?.metadata?.headings); // Debug
// console.log("Post frontmatter:", content.rendered?.metadata?.frontmatter); // Debug
const dateTimeOptions: Intl.DateTimeFormatOptions = {
month: "long",
};
const postSeries = content.data.seriesId
? await getCollection("series")
.then(series => series.find(s => s.id === content.data.seriesId))
.catch(err => {
console.error("Failed to find series:", err);
return null;
})
: null;
---
<div class="md:sticky md:top-8 md:z-10 flex items-end">
{
postSeries ? (
<button
id="toggle-panel"
class="hidden md:flex mr-2 h-8 w-8 items-center bg-accent-base/10 flex-shrink-0 justify-center rounded-lg text-accent-base hover:brightness-110"
aria-label="Toggle Series Panel"
aria-controls="series-panel"
>
{/*
<Icon aria-hidden="true" class="h-6 w-6" focusable="false" name="hugeicons:sidebar-left" />
*/}
<Icon aria-hidden="true" class="flex-shrink-0 h-6 w-6" focusable="false" name="solar:notes-bold" />
</button>
) : null
}
{
!!(content.rendered?.metadata?.headings as unknown[] | undefined)?.length && (
<button
id="toggle-toc"
class="hidden md:flex h-8 w-8 items-center flex-shrink-0 bg-accent-base/10 justify-center rounded-lg text-accent-base hover:brightness-110"
aria-label="Table of Contents"
>
<Icon aria-hidden="true" class="h-6 w-6" focusable="false" name="solar:clipboard-list-bold" />
</button>
)
}
<h1
class="title ml-2 md:sticky md:top-4 md:z-20 line-clamp-none md:line-clamp-1 md:max-w-[calc(100%-10rem)]"
title={content.data.title}
data-pagefind-body
>
{content.data.title}
</h1>
</div>
<div class="flex flex-wrap items-center text-lighter text-sm mt-[1.0625rem] mx-2 mb-2">
<span class="flex items-center h-[1.75rem]">
<Icon aria-hidden="true" class="flex items-center h-4 w-4 me-1" focusable="false" name="hugeicons:calendar-03" />
<FormattedDate date={content.data.publishDate} dateTimeOptions={dateTimeOptions} class="flex flex-shrink-0" />
</span>
<Separator type="dot" />
<span class="flex items-center h-[1.75rem]">
<Icon aria-hidden="true" class="flex items-center inline-block h-4 w-4 me-1" focusable="false" name="hugeicons:book-open-01" />
{/* @ts-ignore:next-line. TODO: add reading time to collection schema? */}
{content.rendered?.metadata?.frontmatter?.readingTime ? `${content.rendered.metadata.frontmatter.readingTime}` : "Less than one minute read"}
</span>
{
content.data.updatedDate && (
<Separator type="dot" />
<span class="h-[1.75rem] flex items-center flex-shrink-0 rounded-lg bg-accent-two/5 text-accent-two py-1 px-2 text-sm gap-x-1">
Updated:<FormattedDate class="flex flex-shrink-0" date={content.data.updatedDate} dateTimeOptions={dateTimeOptions} />
</span>
)
}
</div>
{content.data.draft ? <span class="text-base text-red-500 ml-2">(Draft)</span> : null}
{
content.data.coverImage && (
<div class="mb-4 mt-2 overflow-auto rounded-lg">
<Image
alt={content.data.coverImage.alt}
class="object-cover"
fetchpriority="high"
loading="lazy" // loading="eager"
src={content.data.coverImage.src}
/>
</div>
)
}
<p
class="prose max-w-none text-textColor mx-2"
data-pagefind-body
>
{content.data.description}
</p>
<div class="mt-4 flex flex-wrap items-center gap-2 mx-1">
{/* Tags */}
{
content.data.tags?.length ? (
<Icon aria-hidden="true" class="flex-shrink-0 inline-block h-6 w-6 text-accent-base" focusable="false" name="solar:tag-line-duotone" />
<>
{content.data.tags.map((tag) => (
<a aria-label={`View all posts with the tag: ${tag}`} href={`/tags/${tag}`}>
<Badge variant="accent-two" title={tag} />
</a>
))}
</>
) : (
<Icon aria-hidden="true" class="flex-shrink-0 inline-block h-6 w-6 text-lightest" focusable="false" name="solar:tag-line-duotone" />
<span class="text-sm text-lightest">No tags</span>
)
}
{/* Series */}
{
postSeries ? (
<div class="flex items-center gap-2">
<Icon aria-hidden="true" class="flex-shrink-0 inline-block h-6 w-6 text-accent-base" focusable="false" name="solar:notes-line-duotone" />
<a
aria-label={`About ${postSeries.data.title} series`}
href={`/series/${postSeries.id}`}
class="flex items-center gap-2 flex-wrap"
>
<Badge variant="accent-base" showHash={false} title={postSeries.data.title} />
</a>
</div>
) : (
<div class="flex items-center gap-2">
<Icon aria-hidden="true" class="flex-shrink-0 inline-block h-6 w-6 text-lightest" focusable="false" name="solar:notes-line-duotone" />
<span class="text-sm text-lightest">Not in series</span>
</div>
)
}
</div>

View File

@@ -0,0 +1,56 @@
---
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;
---
<div class={withDesc ? "flex flex-col" : "flex flex-col grow sm:flex-row sm:items-center sm:justify-between"}>
{!withDesc ? (
<>
<FormattedDate
class="shrink-0 text-lighter text-sm sm:order-2 sm:text-right"
date={post.data.publishDate}
dateTimeOptions={{
// hour: "2-digit",
// minute: "2-digit",
year: "numeric",
month: "long",
day: "2-digit",
}}
/>
<Tag class="citrus-link font-medium text-accent-base sm:order-1 sm:flex-gro md:line-clamp-1">
<a data-astro-prefetch href={`/posts/${post.id}/`}>
{post.data.draft && <span class="text-red-500">(Draft) </span>}
{post.data.title}
</a>
</Tag>
</>
) : (
<>
<FormattedDate
class="text-sm shrink-0 text-lighter"
date={post.data.publishDate}
dateTimeOptions={{
// hour: "2-digit",
// minute: "2-digit",
year: "numeric",
month: "long",
day: "2-digit",
}}
/>
<Tag class="citrus-link font-medium text-accent-base mt-2.5">
<a data-astro-prefetch href={`/posts/${post.id}/`}>
{post.data.title}
</a>
</Tag>
<p class="mt-0.5 line-clamp-2">{post.data.description}</p>
</>
)}
</div>

View File

@@ -0,0 +1,42 @@
---
import { generateToc } from "@/utils/generateToc";
import type { MarkdownHeading } from "astro";
import TOCHeading from "./TOCHeading.astro";
import { Icon } from "astro-icon/components";
interface Props {
headings: MarkdownHeading[];
}
const { headings } = Astro.props;
const toc = generateToc(headings);
---
<div class="sticky top-20 rounded-t-lg">
<div class="sticky top-20 bg-bgColor rounded-t-lg">
<div class="sticky top-20 flex pt-4 ps-8 pb-2 items-end title rounded-t-lg bg-color-75 pe-4 gap-x-1 border-t border-l border-r border-special-light">
<!--
<Icon aria-hidden="true" class="flex-shrink-0 h-8 w-6 py-1" focusable="false" name="solar:clipboard-list-line-duotone" />
-->
<h4 class="title">Table of Contents</h4>
<button
id="close-toc"
class="absolute top-4 right-4 h-8 w-8 flex items-center justify-center rounded-lg bg-accent-base/5 text-accent-base hover:bg-accent-base/10"
aria-label="Close TOC"
>
<Icon aria-hidden="true" class="h-6 w-6" focusable="false" name="hugeicons:cancel-01" />
</button>
</div>
</div>
<div class="bg-bgColor rounded-b-lg">
<div class="rounded-b-lg pb-6 bg-color-75 border-b border-l border-r border-special-light">
<div class="max-h-[calc(100vh-11rem)] h-auto overflow-y-auto overflow-hidden px-8">
<ul class="text-sm font-medium text-textColor">
{toc.map((heading) => <TOCHeading heading={heading} />)}
</ul>
</div>
</div>
</div>
</div>

View 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="">
<a
aria-label={`Scroll to section: ${text}`}
class="text-light mt-1 line-clamp-2 break-words [padding-left:1ch] [text-indent:-1ch] before:text-accent-two before:content-['#'] hover:text-accent-two"
href={`#${slug}`}
>{text}
</a>
{!!children.length && (
<ul class="ms-2">
{children.map((subheading) => (
<Astro.self heading={subheading} />
))}
</ul>
)}
</li>

View File

@@ -0,0 +1,92 @@
---
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,
);
/**
! show a link to the mention
*/
---
{
!!comments.length && (
<div>
<p class="mb-0 text-accent-base">
<strong>{comments.length}</strong> Mention{comments.length > 1 ? "s" : ""}
</p>
<ul class="mt-0 divide-y divide-textColor/20 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 shrink-0 overflow-hidden rounded-full outline-none ring-2 ring-textColor hover:ring-4 hover:ring-link focus-visible:ring-4 focus-visible:ring-link"
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 my-0 line-clamp-1 font-semibold text-accent-base">
{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">
Vist 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 mb-0 mt-1 break-words [word-break:break-word]">
{mention.content?.text}
</p>
</div>
</li>
))}
</ul>
</div>
)
}

View 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="mb-0 text-accent-base">
<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 relative inline-block overflow-hidden rounded-full outline-none ring-2 ring-textColor hover:z-10 hover:ring-4 hover:ring-link focus-visible:z-10 focus-visible:ring-4 focus-visible:ring-link"
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>
)
}

View 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>

View File

@@ -0,0 +1,26 @@
---
import { menuLinks, siteConfig } from "@/site.config";
const year = new Date().getFullYear();
---
<footer
class="semibold mt-auto flex w-full flex-col items-center justify-center gap-y-2 pb-4 pt-8 text-center align-top text-accent sm:flex-row sm:justify-between sm:text-sm"
>
<div class="me-0 font-semibold sm:me-4">
&copy; {siteConfig.author}
{year}.<span class="inline-block">&nbsp;🚀&nbsp;Astro Citrus</span>
</div>
<nav
aria-label="More on this site"
class="flex justify-between space-x-4 font-medium text-light md:w-[14rem] w-auto"
>
{
menuLinks.map((link) => (
<a class="underline-offset-2 hover:text-accent hover:underline" href={link.path}>
{link.title}
</a>
))
}
</nav>
</footer>

View File

@@ -0,0 +1,298 @@
---
import Search from "@/components/Search.astro";
import ThemeToggle from "@/components/ThemeToggle.astro";
import { siteConfig, menuLinks } from "@/site.config";
import { Icon } from "astro-icon/components";
---
<header
id="main-header"
class="fixed px-4 md:px-0 left-0 z-20 flex items-center md:relative top-0 h-16 w-full bg-bgColor md:bg-transparent overflow-hidden"
>
<!-- Background
TODO: This approach is not optimal and requires improvements.
- Too many absolutely positioned elements can affect performance.
-->
<div class="md:hidden absolute top-0 left-1/2 -ml-[50vw] w-screen min-h-screen pointer-events-none blur-2xl">
<div class="absolute top-[-90%] right-[25%] w-[75%] h-full bg-gradient-to-b from-blue-300 via-pink-300 to-transparent rounded-full opacity-40 dark:opacity-5"></div>
<div class="absolute top-[-90%] left-[25%] w-[75%] h-full bg-gradient-to-b from-blue-300 via-pink-300 to-transparent rounded-full opacity-40 dark:opacity-5"></div>
<div class="absolute top-[-85%] right-[25%] w-[55%] h-full bg-gradient-to-b from-purple-300 via-blue-300 to-transparent rounded-full opacity-40 dark:opacity-5"></div>
<div class="absolute top-[-85%] left-[25%] w-[55%] h-full bg-gradient-to-b from-indigo-300 via-orange-300 to-transparent rounded-full opacity-40 dark:opacity-5"></div>
<div class="absolute top-[-75%] left-[-25%] w-[65%] h-full bg-gradient-to-b from-blue-300 via-pink-300 to-transparent rounded-full opacity-30 dark:opacity-5"></div>
<div class="absolute top-[-75%] right-[-25%] w-[65%] h-full bg-gradient-to-b from-purple-300 via-blue-300 to-transparent rounded-full opacity-30 dark:opacity-5"></div>
<div class="absolute top-[-85%] left-[-30%] w-[85%] h-full bg-gradient-to-b from-indigo-300 via-orange-300 to-transparent rounded-full opacity-60 dark:opacity-5"></div>
<div class="absolute top-[-85%] right-[-30%] w-[85%] h-full bg-gradient-to-b from-orange-300 via-indigo-300 to-transparent rounded-full opacity-60 dark:opacity-5"></div>
</div>
<div class="w-full justify-between sm:flex-col">
<div class="flex items-center gap-x-2">
<a
aria-label={siteConfig.title}
aria-current={Astro.url.pathname === "/" ? "page" : false}
class="group flex h-8 items-center hover:filter-none sm:relative"
href="/"
>
<!-- Logo -->
<!--
<div class="pt-1.5">
<svg class="inline-block size-8 fill-current text-accent-base drop-shadow-[0px_2.5px_2.5px_rgba(0,0,0,0.35)]">
<use href="/brand.svg#brand"></use>
</svg>
</div>
-->
<div title={siteConfig.title}>
<svg class="inline-block size-8 fill-current text-accent-base dark:text-accent-two drop-shadow-[0px_2.5px_2.5px_rgba(0,0,0,0.35)]">
<use href="/brand.svg#citrus"></use>
</svg>
</div>
<strong class="'max-[320px]:hidden' bg-clip-text text-transparent bg-gradient-to-r from-accent-one to-accent-two hidden xs:block z-10 mb-0.5 ms-2 text-2xl group-hover:text-accent-one">
{siteConfig.title}
</strong>
</a>
<nav
aria-label="Main menu"
class="top-20 text-sm mx-auto ml-4 ml-auto hidden flex-col items-end justify-center gap-x-4 rounded-md bg-bgColor font-medium text-accent-two shadow backdrop-blur group-[.menu-open]:z-50 group-[.menu-open]:flex sm:static sm:z-auto sm:flex-row sm:items-center sm:rounded-none sm:bg-transparent sm:shadow-none sm:backdrop-blur-none md:flex"
id="main-navigation-menu"
>
<!--
<a
aria-current={Astro.url.pathname === "/" ? "page" : false}
class="flex gap-x-1 h-8 items-center justify-center rounded-lg underline-offset-2 hover:underline"
href="/"
>
<Icon
class="size-4 drop-shadow-[0px_1.5px_1.5px_rgba(0,0,0,0.175)]"
name="solar:home-2-bold"
/>
Home
</a>
<a
aria-current={Astro.url.pathname === "/" ? "page" : false}
class="flex gap-x-1 h-8 items-center justify-center rounded-lg underline-offset-2 hover:underline"
href="/"
>
<Icon
class="size-4 drop-shadow-[0px_1.5px_1.5px_rgba(0,0,0,0.175)]"
name="solar:archive-bold"
/>
Blog
</a>
-->
<!-- Ссылки меню -->
{
menuLinks.map((link) => (
<a
aria-current={Astro.url.pathname === link.path ? "page" : false}
class="underline-offset-2 hover:underline"
data-astro-prefetch
href={link.path}
>
{link.title}
</a>
))
}
<a
class="flex h-8 items-center rounded-lg bg-accent-base/5 px-4 text-accent-base underline-offset-2 hover:bg-accent-base/10"
data-astro-prefetch
href="/posts/citrus-docs/intro/"
>
Docs
</a>
<!-- Dropdown menu button for large screens. Needs improvement. -->
<!--
<button
aria-expanded="false"
aria-haspopup="menu"
aria-label="Open main menu"
class="hidden md:flex group text-sm relative h-8 w-16 font-medium items-center justify-center px-4 rounded-lg bg-accent-base/5 text-accent-base hover:bg-accent-base/10"
id="toggle-navigation-menu"
type="button"
>
Menu
</button>
-->
</nav>
<!-- Dropdown menu for large screens. Needs improvement. -->
<!--
<div id="menu" class="absolute left-0 right-0 w-fit ml-auto top-16 z-10 hidden" aria-hidden="true">
<div
id="menu-body"
class="fixed bg-bgColor rounded-lg -ml-56 w-56"
>
<nav
aria-label="Main menu"
class="px-4 py-4 rounded-lg border border-special-lighter bg-special-light shadow-[0px_10px_25px_rgba(0,0,0,0.15)] text-sm flex flex-col gap-y-2 font-medium"
id="main-navigation-menu"
>
{
menuLinks.map((link) => (
<a
aria-current={Astro.url.pathname === link.path ? "page" : false}
class="text-accent-two underline-offset-2 hover:underline rounded-lg h-8 gap-x-1 px-2 flex justify-center items-center"
data-astro-prefetch
href={link.path}
>
{link.title}
</a>
))
}
<a
class="flex h-8 items-center justify-center rounded-lg bg-accent-base/5 hover:bg-accent-base/10 px-4 text-accent-base"
data-astro-prefetch
href="/posts/citrus-docs/intro/"
>
Docs
</a>
</nav>
</div>
</div>
-->
<div class="ml-auto w-fit">
<div id="buttons-panel" class="top-4 md:top-8 -ml-[4.5rem] flex space-x-2">
<Search />
<ThemeToggle />
</div>
</div>
<mobile-button
aria-expanded="false"
aria-haspopup="menu"
aria-label="Open main menu"
class="group relative h-8 w-8 rounded-lg bg-color-100 hover:bg-accent-base/10 text-accent-base md:invisible md:hidden"
id="toggle-nav-menu-mobile"
type="button"
>
<Icon
id="open-nav-menu-icon"
aria-hidden="true"
class="absolute start-1/2 top-1/2 h-6 w-6 -translate-x-1/2 -translate-y-1/2 scale-100 opacity-100 transition-all"
focusable="false"
name="hugeicons:menu-01"
/>
<Icon
id="close-nav-menu-icon"
aria-hidden="true"
class="absolute start-1/2 top-1/2 h-6 w-6 -translate-x-1/2 -translate-y-1/2 scale-0 opacity-0 transition-all"
focusable="false"
name="hugeicons:cancel-01"
/>
</mobile-button>
</div>
</div>
</header>
<div id="drawer" class="fixed inset-0 z-10 hidden" aria-hidden="true">
<div
id="drawer-body"
class="absolute inset-0 -translate-y-full transform bg-bgColor transition-all duration-300 ease-in-out"
>
<!-- Background
TODO: This approach is not optimal and requires improvements.
- Too many absolutely positioned elements can affect performance.
-->
<div class="fixed top-0 left-1/2 -ml-[50vw] w-screen pointer-events-none min-h-screen overflow-x-hidden overflow-y-visible blur-2xl">
<!--
<div class="fixed blur-xl top-0 left-0 w-full h-16 md:h-20 bg-gradient-to-b from-indigo-300 via-purple-300 to-transparent opacity-10 dark:opacity-5"></div>
-->
<div class="absolute top-[-90%] right-[25%] w-[75%] h-full bg-gradient-to-b from-blue-300 via-pink-300 to-transparent rounded-full opacity-40 dark:opacity-5"></div>
<div class="absolute top-[-90%] left-[25%] w-[75%] h-full bg-gradient-to-b from-blue-300 via-pink-300 to-transparent rounded-full opacity-40 dark:opacity-5"></div>
<div class="absolute top-[-85%] right-[25%] w-[55%] h-full bg-gradient-to-b from-purple-300 via-blue-300 to-transparent rounded-full opacity-40 dark:opacity-5"></div>
<div class="absolute top-[-85%] left-[25%] w-[55%] h-full bg-gradient-to-b from-indigo-300 via-orange-300 to-transparent rounded-full opacity-40 dark:opacity-5"></div>
<div class="absolute top-[-75%] left-[-25%] w-[65%] h-full bg-gradient-to-b from-blue-300 via-pink-300 to-transparent rounded-full opacity-30 dark:opacity-5"></div>
<div class="absolute top-[-75%] right-[-25%] w-[65%] h-full bg-gradient-to-b from-purple-300 via-blue-300 to-transparent rounded-full opacity-30 dark:opacity-5"></div>
<div class="absolute top-[-85%] left-[-30%] w-[85%] h-full bg-gradient-to-b from-indigo-300 via-orange-300 to-transparent rounded-full opacity-60 dark:opacity-5"></div>
<div class="absolute top-[-85%] right-[-30%] w-[85%] h-full bg-gradient-to-b from-orange-300 via-indigo-300 to-transparent rounded-full opacity-60 dark:opacity-5"></div>
</div>
<nav
aria-label="Mobile navigation menu"
class="text-lg absolute inset-0 flex flex-col items-center justify-center gap-y-4 text-center font-medium text-accent-two"
id="nav-menu-mobile"
>
<!-- Ссылки меню -->
{
menuLinks.map((link) => (
<a
aria-current={Astro.url.pathname === link.path ? "page" : false}
class="underline-offset-2 hover:underline"
data-astro-prefetch
href={link.path}
>
{link.title}
</a>
))
}
<a
class="flex h-8 items-center rounded-lg bg-accent-base/5 px-4 text-accent-base underline-offset-2 hover:bg-accent-base/10"
data-astro-prefetch
href="/series/citrus-docs"
>
Docs
</a>
</nav>
</div>
</div>
<script>
document.addEventListener("DOMContentLoaded", () => {
const toggleNavMenuMobileButton = document.getElementById("toggle-nav-menu-mobile");
const openMenuIcon = document.getElementById("open-nav-menu-icon");
const closeMenuIcon = document.getElementById("close-nav-menu-icon");
const drawer = document.getElementById("drawer");
// Проверяем существование элементов
if (!toggleNavMenuMobileButton || !openMenuIcon || !closeMenuIcon || !drawer) {
console.error("One or more required elements are missing in the DOM.");
return;
}
const drawerBody = document.getElementById("drawer-body");
if (!drawerBody) {
console.error("Drawer body element is missing in the DOM.");
return;
}
toggleNavMenuMobileButton.addEventListener("click", () => {
const isOpen = toggleNavMenuMobileButton.getAttribute("aria-expanded") === "true";
if (isOpen) {
// Закрываем меню
drawerBody.classList.add("opacity-0", "-translate-y-full");
drawerBody.classList.remove("translate-y-0");
// Убираем после анимации
setTimeout(() => {
drawer.classList.add("hidden");
}, 300);
// Меняем иконки
openMenuIcon.classList.add("scale-100", "opacity-100");
closeMenuIcon.classList.add("scale-0", "opacity-0");
closeMenuIcon.classList.remove("scale-100", "opacity-100");
} else {
// Показываем меню
drawer.classList.remove("hidden");
drawerBody.classList.add("translate-y-0");
drawerBody.classList.remove("opacity-0", "-translate-y-full");
// Меняем иконки
openMenuIcon.classList.add("scale-0", "opacity-0");
closeMenuIcon.classList.add("scale-100", "opacity-100");
openMenuIcon.classList.remove("scale-100", "opacity-100");
}
// Обновляем состояние кнопки
toggleNavMenuMobileButton.setAttribute("aria-expanded", (!isOpen).toString());
});
});
</script>

View File

@@ -0,0 +1,58 @@
---
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 w-full rounded-lg bg-color-75 px-4 md:px-8 py-2 md:py-4"]}
data-pagefind-body={isPreview ? false : true}
>
<Tag class="flex items-end title md:sticky md:top-8 md:z-10" class:list={{ "text-base": isPreview }}>
{
isPreview ? (
<a class="citrus-link" href={`/notes/${note.id}/`}>
{note.data.title}
</a>
) : (
<>{note.data.title}</>
)
}
</Tag>
<div
class="flex items-end h-6 text-sm text-lighter"
class:list={{ "mt-4": !isPreview }}
>
<FormattedDate
dateTimeOptions={{
hour: "2-digit",
minute: "2-digit",
year: "numeric",
month: "long",
day: "2-digit",
}}
date={note.data.publishDate}
/>
</div>
<div
class="prose prose-citrus mt-4 max-w-none [&>p:last-of-type]:mb-0"
class:list={{
"line-clamp-4": isPreview,
"[&>blockquote]:line-clamp-4 [&>blockquote]:mb-0": isPreview,
"[&>blockquote:not(:first-of-type)]:hidden": isPreview,
// "[&>p]:line-clamp-4": isPreview,
// "[&>p:not(:first-of-type)]:hidden": isPreview,
}}
>
<Content />
</div>
</article>

65
src/content.config.ts Normal file
View File

@@ -0,0 +1,65 @@
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)),
// Series
seriesId: z.string().optional(), // Поле для связи с серией
orderInSeries: z.number().optional(), // Опционально: для сортировки в серии
// End
}),
});
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)),
}),
});
// Series
const series = defineCollection({
loader: glob({ base: "./src/content/series", pattern: "**/*.{md,mdx}" }),
schema: z.object({
id: z.string(),
title: z.string(),
description: z.string(),
featured: z.boolean().default(false), // Пометка для популярных серий
}),
});
// End
// Series
export const collections = { post, note, series };

View File

@@ -0,0 +1,13 @@
---
title: What is the F-Method in Resume Writing?
publishDate: "2025-02-27T22:09:00Z"
---
When recruiters review resumes, they spend only a few seconds on the first glance. Research shows that their eyes move across the page in the shape of the letter **F**: first, they read the top line, then scan down while focusing on the left side. This is the principle behind the **F-Method** a resume formatting technique that helps highlight the most important details.
**How to Use the F-Method?**
1. **First horizontal section:** Start with a clear heading include your name, contact details, and the job title youre applying for.
2. **Second horizontal section:** Place key information at the top a brief summary of your skills, achievements, and strengths.
3. **Vertical section on the left:** Since this area gets the most attention, list your most important details here work experience, education, and key skills.
This method makes resumes easier to read, highlights essential points, and increases your chances of getting noticed. Use the F-Method, and your resume wont go unnoticed! 🚀

View File

@@ -0,0 +1,11 @@
---
title: Wake up...
publishDate: "2199-02-01T18:26:00Z"
updateDate: "1998-02-19T14:32:00Z"
---
>What is real? How do you define real? If youre talking about what you can feel, what you can smell, what you can taste and see, then real is simply electrical signals interpreted by your brain.<br> This is the world that you know. The world as it was at the end of the twentieth century. It exists now only as part of a neural-interactive simulation that we call the Matrix. Youve been living in a dream world.<br> This is the world as it exists today... Welcome... to the desert... of the real.<br> We have only bits and pieces of information but what we know for certain is that at some point in the early twenty-first century all of mankind was united in celebration. We marveled at our own magnificence as we gave birth to AI.
>AI? You mean artificial intelligence?
>A singular consciousness that spawned an entire race of machines. We dont know who struck first, us or them. But we know that it was us that scorched the sky. At the time they were dependent on solar power and it was believed that they would be unable to survive without an energy source as abundant as the sun. Throughout human history, we have been dependent on machines to survive. Fate, it seems, is not without a sense of irony. The human body generates more bio-electricity than a 120-volt battery and over 25,000 BTUs of body heat. Combined with a form of fusion, the machines have found all the energy they would ever need. There are fields, endless fields, where human beings are no longer born. We are grown. For the longest time I wouldnt believe it, and then I saw the fields with my own eyes. Watch them liquefy the dead so they could be fed intravenously to the living. And standing there, facing the pure horrifying precision, I came to realize the obviousness of the truth.<br> What is the Matrix?<br> Control.<br> The Matrix is a computer generated dream world built to keep us under control in order to change a human being into battery.

View File

@@ -0,0 +1,9 @@
---
title: Hello, Welcome
description: An introduction to using the note feature in Astro Citrus
publishDate: "2024-10-14T11:23:00Z"
---
Hi, Hello. This is an example note feature included with Astro Citrus.
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.

View File

@@ -0,0 +1,32 @@
---
title: "Introducing Astro Citrus!"
publishDate: "20 December 2024"
description: "Astro Citrus is a versatile template for managing blogs and creating comprehensive project documentation"
seriesId: citrus-docs
orderInSeries: 1
featured: false
tags: ["example", "series", "citrus"]
ogImage: ""
---
## Introducing
Hi, Im a theme for Astro, a simple starter that you can use to create your website or blog. If you want to know more about how you can customise me, add more posts, and make it your own, click on the GitHub icon link below and it will take you to my repo.
## About Citrus
Citrus is a powerful and stylish template designed for both blogging and creating comprehensive documentation with Astro. The template combines the simplicity of a blog layout with the robust features needed for project documentation. It offers:
- **Clean and minimalist design**, suitable for blogs and technical documentation alike.
- **User-friendly navigation**, with menus and sections tailored for easy access to content.
- **High performance** with Astros static site generation for fast loading speeds.
- **Markdown support**, streamlining the writing and editing process.
- **Flexible customization**, including colors, fonts, layout structure, and more.
## Benefits of Using Astro Citrus
1. **Dual-purpose template**: Seamlessly switch between blogging and project documentation.
2. **Responsive design**: Optimized for desktops, tablets, and mobile devices.
3. **Fast and SEO-friendly**: Astro ensures quick loading times and better search engine rankings.
4. **Expandable features**: Add analytics, search, or other integrations effortlessly.
5. **Easy to deploy**: Works flawlessly on platforms like Netlify or Vercel.

View File

@@ -0,0 +1,19 @@
---
title: "Setup Citrus"
publishDate: "21 December 2024"
description: "An example second post for Citrus Docs series"
seriesId: citrus-docs
orderInSeries: 2
updatedDate: "22 December 2024"
featured: false
tags: ["example", "series", "citrus"]
ogImage: ""
---
## Getting Started
1. Install Astro and download the Astro Citrus template.
2. Configure the `astro.config.mjs` file to set up your blog or documentation site.
3. Add content using Markdown for a seamless writing experience.
For more detailed information, check the official [Astro Citrus documentation](#).

View File

@@ -0,0 +1,144 @@
---
title: Flexible Theming System
description: A flexible theming system based on HSL (Hue, Saturation, Lightness) using CSS variables, allowing for dynamic color adjustments and seamless theme management
publishDate: 03 Feb 2025
seriesId: citrus-docs
orderInSeries: 3
tags: ["theming", "CSS", "citrus"]
---
This approach to defining colors can be described as a **flexible theming system based on HSL (Hue, Saturation, Lightness) with the use of CSS variables**.
## Principles of colorization
1. **Flexibility through HSL**
- Instead of fixed colors, the hue (`--hue`), saturation (`--saturation`), and brightness (`--bg-brightness`, `--fg-brightness`) are used. This allows for easy changes to the entire theme's color palette by adjusting just one parameter.
2. **Unified Logic for Light and Dark Themes**
- Different brightness and saturation parameters are defined in `:root[data-theme="light"]` and `:root[data-theme="dark"]`, but the logic remains consistent.
- For example, in the light theme, the background is lighter (`--bg-brightness: 95%`), and in the dark theme, it is darker (`--bg-brightness: 17%`).
3. **Creating Color Gradients**
- A gradient scale (`--theme-color-950``--theme-color-50`) is used to generate shades of a single color.
- This allows for dynamically generated variations of colors for backgrounds, text, and accents without manual input.
4. **Defining Key UI Elements**
- `--theme-bg` — background color
- `--theme-accent-two`, `--theme-accent-base` — accent colors
- `--theme-text` — main text color
- `--theme-link` — link color
- `--theme-quote` — quote color
```css title="globas.css"
@layer base {
:root,
:root[data-theme="light"] {
color-scheme: light;
/*** MAIN COLORS (Base, Background, Accents, Text) ***/
/* Base theme hue color */
--hue: 200deg; /* Base hue color (Background, secondary accent, text) */
--saturation: 10%; /* Saturation of background and text, 0% - no tint */
/* Background */
--bg-brightness: 95%; /* Background brightness, 100% - pure white */
--theme-bg: var(--hue) var(--saturation) var(--bg-brightness); /* Background color */
/* Accents */
--theme-accent-two: 351deg 66% 48%; /* Primary accent color */
--theme-accent-base: var(--hue) 50% 27%; /* Secondary accent color */
/* Text (foreground calculated below based on --theme-fg) */
--fg-brightness: 9%; /* Text brightness, 0% - pure black */
--theme-fg: var(--hue) var(--saturation) var(--fg-brightness); /* Base color for text */
--theme-text: var(--theme-color-550); /* Text color */
/*** SECONDARY COLORS (External links, neutral accent, quotes) ***/
--theme-link: var(--hue) 97% 31%; /* External link color */
--theme-accent: var(--theme-color-650); /* Neutral accent, calculated below based on --theme-fg */
--theme-quote: var(--theme-text); /* Quote color */
/*** ADDITIONAL COLORS ***/
--theme-lightest: var(--theme-color-350);
--theme-lighter: var(--theme-color-400);
--theme-light: var(--theme-color-450);
/*** SPECIAL THEME COLORS (Distinct settings for each theme) ***/
--theme-special-lightest: hsl(var(--hue), var(--saturation), 100%);
--theme-special-lighter: hsl(var(--hue), var(--saturation), 98%);
--theme-special-light: hsl(var(--theme-bg));
--theme-special: var(--theme-color-75);
}
:root[data-theme="dark"] {
color-scheme: dark;
/*** MAIN COLORS (Base, Background, Accents, Text) ***/
/* Base theme hue color */
--hue: 200deg; /* Base hue color (Background, secondary accent, text) */
--saturation: 53%;
/* Background */
--bg-brightness: 17%; /* Background brightness, 0% - black */
--theme-bg: var(--hue) var(--saturation) var(--bg-brightness); /* Background color */
/* Accents */
--theme-accent-two: 50deg 72% 63%; /* Primary accent color for elements (was 45deg 80% 50%) */
--theme-accent-base: var(--hue) 0% 85%; /* Secondary accent color for elements */
/* Text (foreground calculated below based on --theme-fg) */
--fg-brightness: 98%; /* Text brightness, 100% - pure white */
--theme-text: var(--theme-color-600); /* Text color */
/*** SECONDARY COLORS (External links, neutral accent, quotes) ***/
--theme-link: var(--hue) 66% 66%; /* External link color */
--theme-accent: var(--theme-color-700); /* Neutral accent */
--theme-quote: var(--theme-text); /* Quote color */
/*** ADDITIONAL COLORS ***/
--theme-lightest: var(--theme-color-400);
--theme-lighter: var(--theme-color-450);
--theme-light: var(--theme-color-500);
/*** SPECIAL THEME COLORS (Distinct settings for each theme) ***/
--theme-special-lightest: var(--theme-color-250);
--theme-special-lighter: var(--theme-color-200);
--theme-special-light: var(--theme-color-150);
--theme-special: hsl(var(--hue) 0% 0% / 0.1275);
}
/* Global variables */
:root {
/* Base color for color gradation calculation */
--theme-fg: var(--hue) var(--saturation) var(--fg-brightness);
/* Gradations of the base color for text and elements */
--theme-color-950: hsl(var(--theme-fg) / 0.9495);
--theme-color-900: hsl(var(--theme-fg) / 0.9095);
--theme-color-850: hsl(var(--theme-fg) / 0.8795);
--theme-color-800: hsl(var(--theme-fg) / 0.8495);
--theme-color-750: hsl(var(--theme-fg) / 0.7995);
--theme-color-700: hsl(var(--theme-fg) / 0.7495);
--theme-color-650: hsl(var(--theme-fg) / 0.7145);
--theme-color-600: hsl(var(--theme-fg) / 0.6795);
--theme-color-550: hsl(var(--theme-fg) / 0.6145);
--theme-color-500: hsl(var(--theme-fg) / 0.5495);
--theme-color-450: hsl(var(--theme-fg) / 0.4545);
--theme-color-400: hsl(var(--theme-fg) / 0.3595);
--theme-color-350: hsl(var(--theme-fg) / 0.2635);
--theme-color-300: hsl(var(--theme-fg) / 0.1675);
--theme-color-250: hsl(var(--theme-fg) / 0.1355);
--theme-color-200: hsl(var(--theme-fg) / 0.1025);
--theme-color-150: hsl(var(--theme-fg) / 0.0710);
--theme-color-100: hsl(var(--theme-fg) / 0.0395);
--theme-color-75: hsl(var(--theme-fg) / 0.0295);
--theme-color-50: hsl(var(--theme-fg) / 0.0195);
}
}
```
## What Does This Provide?
- **Easy Scalability**: A new theme can be created by adjusting `--hue`, `--saturation`, and base brightness.
- **Automatic Shade Generation**: The gradient system allows for dynamic color generation.
- **Flexibility**: The theme can be adapted for different contrast levels, custom schemes, and modes (e.g., `sepia`, `high contrast`).
This approach is perfect for **dynamic theming**, where the ability to easily adjust the appearance without manually inputting colors is crucial.

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 591 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

View 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: "21 January 2025"
coverImage:
src: "./cover.jpg"
alt: "Astro build wallpaper"
tags: ["test", "image"]
---

View File

@@ -0,0 +1,104 @@
---
title: "Deepseek Code Assistant: My Features and Examples"
description: "This post introduces my capabilities as a Code Assistant with practical code samples"
publishDate: "10 Jan 2024"
updatedDate: "22 Dec 2024"
tags: ["deepseek", "ai"]
---
## Hello, World! 👋 Im the Code Assistant, and heres a bit about me
Im here to help you with programming, debug code, explain complex concepts, or just share examples. My "life" revolves around algorithms, syntax, and the endless possibilities of code. Lets get to know each other!
### What I Can Do
- Generate code examples in different languages.
- Explain how specific lines of code work.
- Find bugs and suggest fixes.
- Share optimization tips.
### Code examples
**Python: Factorial Function**
```python title="factorial-function.py"
def factorial(n):
if n == 0:
return 1
else:
return n * factorial(n-1)
print(factorial(5)) # 120
```
*I often help with recursion—classic stuff!*
**JavaScript: Countdown Timer**
```js title="countdown-timer.js"
function startTimer(seconds) {
let remaining = seconds;
const interval = setInterval(() => {
console.log(`Time left: ${remaining} sec.`);
remaining--;
if (remaining < 0) {
clearInterval(interval);
console.log("Time's up! ⏰");
}
}, 1000);
}
startTimer(5); // Starts a 5-second timer
```
*Async logic? My jam.*
**SQL: Finding Active Users**
```sql
SELECT users.name, COUNT(orders.id) AS total_orders
FROM users
LEFT JOIN orders ON users.id = orders.user_id
GROUP BY users.name
HAVING total_orders > 3;
```
*I love structured data and elegant JOINs!*
#### Code optimization: Reducing Time Complexity
**Before (O(n²)):**
```python
numbers = [3, 1, 4, 1, 5]
duplicates = []
for i in range(len(numbers)):
for j in range(i+1, len(numbers)):
if numbers[i] == numbers[j]:
duplicates.append(numbers[i])
```
**After (O(n)):**
```python
from collections import defaultdict
numbers = [3, 1, 4, 1, 5]
counts = defaultdict(int)
duplicates = []
for num in numbers:
if counts[num] > 0:
duplicates.append(num)
counts[num] += 1
```
*Optimizing code is pure magic!*
### How I Can Help You
- **Explain confusing code** from your project.
- **Fix memory leaks** in your C++ app.
- **Choose the right algorithm** for sorting tasks.
- **Write tests** using pytest or Jest.
- **Debug "undefined is not a function"** in JS.
### Favorite Topics
- Machine Learning (PyTorch examples? Sure!).
- Web Development (Django, React, Flask).
- Algorithms & Data Structures (graphs, trees, hash tables).
- Automation with Python (scraping, bots).
### Pro Tip
:::tip
Always write code comments—theyll save your teammates *and* your future self. And yes, `console.log` is a temporary fix; tests are better!
:::
**Ready to tackle your code! Just ask. 😊**

View File

@@ -0,0 +1,9 @@
---
title: "A working draft title"
description: "This post is for testing the draft post functionality"
publishDate: "10 March 2024"
tags: ["test"]
draft: true
---
If this is working correctly, this post should only be accessible in a dev environment, as well as any tags that are unique to this post.

View File

@@ -0,0 +1,60 @@
---
title: "Example of 60 chars Master Header and other Various Headings"
publishDate: "28 December 2024"
description: "Demonstrating the different heading levels in Markdown by showcasing various sizes and styles of headings, including short and long examples, while also illustrating rendering and the functionality of a table of contents"
featured: false
tags: ["markdown", "headings", "example", "toc"]
ogImage: ""
---
# Heading Level 1: Exploring the Structure of Long and Short Titles in Markdown
This section provides an example of a Level 1 heading with accompanying introductory text. Use this for setting the context or presenting an overview of the content.
## Heading Level 2: A Detailed Look at Subsections in Markdown Syntax
This is an example of a Level 2 heading, typically used to define major divisions within the content. Here, you can delve deeper into specific topics.
## Heading Level 2: Another Long Title to Illustrate Consistency in Formatting
Sometimes, headings can be lengthy to capture complex ideas. Markdown handles these effectively without truncating.
### Heading Level 3: Exploring the Role of Subheadings within Sections
Text for a Level 3 heading, used to introduce finer subdivisions under a Level 2 heading. Subheadings help maintain clarity in content.
### Heading Level 3: Short and Concise Subheadings for Simpler Concepts
This subheading demonstrates how shorter titles can be just as impactful when used appropriately.
#### Heading Level 4: Adding Layers of Detail to Existing Sections
Text for a Level 4 heading. This level is often employed to elaborate on specific points or add context to higher-level sections.
#### Heading Level 4: An Example of a Longer Heading for Complex Subsections
Even at Level 4, headings can vary in length based on the content they represent.
##### Heading Level 5: Fine-Tuned Subdivisions for Detailed Explanations
Example text under a Level 5 heading. This is where you might add nuanced details or examples within a subsection.
##### Heading Level 5: When Additional Layers Are Necessary for Clarity
This heading illustrates how deeper nesting can aid in organizing intricate information.
###### Heading Level 6: Rarely Used but Available for Granular Subdivisions
Text for a Level 6 heading. These are seldom used but can be helpful in highly detailed documentation.
###### Heading Level 6: Another Example of a Concise Heading at the Deepest Level
Text accompanying a short Level 6 heading, emphasizing brevity and precision.
###### Heading Level 6: Demonstrating Markdown's Flexibility for Complex Structures
Even at the deepest heading level, Markdown ensures readability and proper structure.
###### Heading Level 6: Managing Content Hierarchies with Clear Formatting
Content under this heading highlights the importance of maintaining logical hierarchies in documentation.

View File

@@ -0,0 +1,42 @@
---
title: "Local .gitignore"
description: "How to create an additional .gitignore file"
publishDate: "05 Mar 2025"
tags: ["git", "gitignore"]
draft: true
---
## How to create `.local.gitignore` that is not synchronized with Git?
1. **Create the `.local.gitignore` file**
```bash
touch .local.gitignore
```
2. **Add it to `.git/info/exclude`** (so Git applies it locally)
```bash
echo ".local.gitignore" >> .git/info/exclude
```
3. **Configure Git to treat `.local.gitignore` as `.gitignore`**
```bash
git config --local core.excludesfile .local.gitignore
```
Now **`.local.gitignore` will work like a regular `.gitignore`, but only for you**.
## How does it work?
- `.local.gitignore` is not added to the repository.
- It is applied **only locally** on your computer.
- It works **like `.gitignore`**, but other developers don't have it.
- Git **does not see this file** thanks to `.git/info/exclude`.
**Now you can add local files to it**:
```bash
echo "my-secret-file.txt" >> .local.gitignore
echo "debug_logs/" >> .local.gitignore
```
:::caution
Before performing a commit rollback, you must manually backup the files listed in `.local.gitignore`. This is because files ignored by `.local.gitignore` are not tracked by Git and will be lost if you rollback the commit. Git will not restore these files as they were not committed or staged.
:::

View File

@@ -0,0 +1,8 @@
---
title: "Lorem ipsum dolor sit, amet consectetur adipisicing elit. Id"
description: "This post is purely for testing if the css is correct for the title on the page"
publishDate: "01 Feb 2023"
tags: ["test"]
---
## Testing the title tag

View File

@@ -0,0 +1,116 @@
---
title: "Markdown Admonitions"
description: "This post provides a detailed demonstration of how to use the Markdown admonition feature in Astro Citrus, showcasing its ability to highlight important information, tips, warnings, and other key content types in a visually distinct and customizable format"
publishDate: "25 Aug 2024"
seriesId: "markdown-elements"
orderInSeries: 2
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 Citrus, 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.
:::
### Warning
```md
:::warning
Critical content demanding immediate user attention due to potential risks.
:::
```
:::warning
Critical content demanding immediate user attention due to potential risks.
:::
### Caution
```md
:::caution
Negative potential consequences of an action.
:::
```
:::caution
Negative potential consequences of an action.
:::
## 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.
:::

View File

@@ -0,0 +1,188 @@
---
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
seriesId: "markdown-elements"
orderInSeries: 1
tags: ["test", "markdown"]
---
# This is a H1 Heading
## 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));
```
### Rehype Pretty Code
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" {7} {4-5}#add {3}#remove
function demo() {
console.log("this line is normal");
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";
}
```
Line Numbers
```python title="line-numbers.py" showLineNumbers
def greet(name):
print("Hello!")
print(f"Welcome, {name}!")
print("We are happy to see you.")
return name
```
[Rehype Pretty Code](https://rehype-pretty.pages.dev/) is a powerful tool with extensive features and support for [customization](https://rehype-pretty.pages.dev/).
## Tables
| Option | Description |
| ------ | ------------------------------------------------------------------------- |
| data | path to data files to supply the data that will be passed into templates. |
| engine | engine to be used for processing templates. Handlebars is the default. |
| ext | extension to be used for dest files. |
### Table Alignment
| Item | Price | # In stock |
| ------------ | :---: | ---------: |
| Juicy Apples | 1.99 | 739 |
| Bananas | 1.89 | 6 |
### Keyboard elements
| Action | Shortcut |
| --------------------- | ------------------------------------------ |
| Vertical split | <kbd>Alt+Shift++</kbd> |
| Horizontal split | <kbd>Alt+Shift+-</kbd> |
| Auto split | <kbd>Alt+Shift+d</kbd> |
| Switch between splits | <kbd>Alt</kbd> + arrow keys |
| Resizing a split | <kbd>Alt+Shift</kbd> + arrow keys |
| Close a split | <kbd>Ctrl+Shift+W</kbd> |
| Maximize a pane | <kbd>Ctrl+Shift+P</kbd> + Toggle pane zoom |
## Images
Image in the same folder: `src/content/post/markdown-elements/logo.png`
![Astro theme citrus logo](./logo.png)
## Links
[Content from markdown-it](https://markdown-it.github.io/)

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

View File

@@ -0,0 +1,14 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" width="128" height="128">
<!--
<rect width="128" height="128" fill="#f7f7f8" />
-->
<path fill="#224d67" d="M22.5 14.8c2.9 1.3 99 44 99 44c2.7 1.2 2.9 3.1 2 12.4c-1 10.5-1.9 37.8-40.2 42.9c-24.6 3.2-55.9-4.3-70.9-27.2C-6 58.8 11 23.4 14.8 17.7c2.4-3.5 4.8-4.2 7.7-2.9" />
<path fill="#f0f4c3" d="M121.5 58.8c-.3-.2-92-40.8-98.9-43.9C9.6 31.5-3.8 76 27.9 96.2c33.6 21.5 78.7-10.2 93.6-37.4" />
<path fill="#f0f4c3" d="M117.7 57c-.3-.2-86.7-38.4-93.2-41.3c-12.2 15.5-23.9 59.1 5.7 78c31.4 20.2 73.6-11.2 87.5-36.7" />
<path fill="#cb2a42" d="M56.2 37.6L19.5 48.2c-2.5.8-4.4 2.8-5 5.3c-1.9 8.5.8 23.3 8.9 29.8c3.5 2.7 8.6 1.8 10.8-2.1L58.5 41c1.3-1.7.5-4.2-2.3-3.4m14.3 2.9s14.6 30.1 16 33s6.3 4.7 9.9 2.8c10.7-5.6 15.5-12.4 19.2-20.2c-8.9-3.9-42.4-18.8-42.4-18.8c-2.4-1.1-4 .6-2.7 3.2m-8.6 1.9C60.3 45.1 39.5 81 37.6 84.1c-1.9 3.2-.7 7.5 2.7 9.2c12.3 6.5 30.9.4 39.6-7c2.2-1.9 4.5-5.1 3.2-8.2S68.9 44.6 67.8 42.3c-1.2-2.7-4.1-2.8-5.9.1" />
<path fill="#f0f4c3" d="M52.2 42.2c-1.4.4-10.1 3-11.1 3.4c-1.4.5-2.2 1.7-1.9 2.5c.3.9 1.7 1.1 3.1.6c.8-.3 7.1-3.9 9.5-5.2c-3.1 2.4-10.2 9.4-11.1 10.3c-1.4 1.5-1.7 3.4-.8 4.3c1 .9 2.9.5 4.3-1c1.2-1.3 8.5-12.8 9.1-13.9s.4-1.4-1.1-1m10.1 16.4c0-2.2 1.4-10.3 1.8-12.3c.4-1.6 1.5-1.8 1.8.5s1.7 9.7 1.7 11.8s-1.2 3.3-2.7 3.3c-1.4 0-2.6-1.1-2.6-3.3M79.9 49c-1.2-1.8-3.3-4.9-4.1-6.1c-.7-1.2.1-2.1 1.8-.9s4 2.9 5.4 4c1.6 1.3 1.9 3 .8 4s-2.7.8-3.9-1" />
<ellipse cx="87.3" cy="103.12" fill="#cb2a42" rx="3.4" ry="2.3" transform="rotate(-27.098 87.298 103.126)" />
<ellipse cx="98.89" cy="103.82" fill="#cb2a42" rx="3.4" ry="2.3" transform="rotate(-30.642 98.887 103.818)" />
<ellipse cx="102.02" cy="93.8" fill="#cb2a42" rx="3.4" ry="2.3" transform="rotate(-37.16 102.017 93.797)" />
<path fill="#cb2a42" d="M57.6 30.3c-8.3-3.7-24.4-10.7-29.6-13c-3.6 2.2-9.7 11.4-11.2 21.2c-.6 3.6 1.7 5.9 5.3 4.9c0 0 33.7-9.3 35.3-9.7c1.6-.5 2-2.6.2-3.4" />
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

View 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, and the toggle button next to the post title should not be displayed"
publishDate: "22 Feb 2023"
tags: ["test", "toc"]
---

View File

@@ -0,0 +1,6 @@
---
title: "A post without tags"
description: "This post is for testing the functionality"
publishDate: "11 March 2024"
draft: false
---

View File

@@ -0,0 +1,763 @@
---
title: "Remark-Rehype"
description: "This post is about Remark-Rehype plugin for Astro"
publishDate: "26 January 2025"
tags: ["rehype", "remark", "astro", "plugin"]
draft: false
---
## What is this?
This package is a [unified][] ([remark][]) plugin that switches from remark (the
markdown ecosystem) to rehype (the HTML ecosystem).
It does this by transforming the current markdown (mdast) syntax tree into an
HTML (hast) syntax tree.
remark plugins deal with mdast and rehype plugins deal with hast, so plugins
used after `remark-rehype` have to be rehype plugins.
The reason that there are different ecosystems for markdown and HTML is that
turning markdown into HTML is, while frequently needed, not the only purpose of
markdown.
Checking (linting) and formatting markdown are also common use cases for
remark and markdown.
There are several aspects of markdown that do not translate 1-to-1 to HTML.
In some cases markdown contains more information than HTML: for example, there
are several ways to add a link in markdown (as in, autolinks: `<https://url>`,
resource links: `[label](url)`, and reference links with definitions:
`[label][id]` and `[id]: url`).
In other cases HTML contains more information than markdown: there are many
tags, which add new meaning (semantics), available in HTML that arent available
in markdown.
If there was just one AST, it would be quite hard to perform the tasks that
several remark and rehype plugins currently do.
## When should I use this?
This project is useful when you want to turn markdown to HTML.
It opens up a whole new ecosystem with tons of plugins to do all kinds of
things.
You can [minify HTML][rehype-minify], [format HTML][rehype-format],
[make sure its safe][rehype-sanitize], [highlight code][rehype-highlight],
[add metadata][rehype-meta], and a lot more.
A different plugin, [`rehype-raw`][rehype-raw], adds support for raw HTML
written inside markdown.
This is a separate plugin because supporting HTML inside markdown is a heavy
task (performance and bundle size) and not always needed.
To use both together, you also have to configure `remark-rehype` with
`allowDangerousHtml: true` and then use `rehype-raw`.
The rehype plugin [`rehype-remark`][rehype-remark] does the inverse of this
plugin.
It turns HTML into markdown.
If you dont use plugins and want to access syntax trees, you can use
[`mdast-util-to-hast`][mdast-util-to-hast].
## Install
This package is [ESM only][esm].
In Node.js (version 16+), install with [npm][]:
```sh
npm install remark-rehype
```
In Deno with [`esm.sh`][esmsh]:
```js
import remarkRehype from 'https://esm.sh/remark-rehype@11'
```
In browsers with [`esm.sh`][esmsh]:
```html
<script type="module">
import remarkRehype from 'https://esm.sh/remark-rehype@11?bundle'
</script>
```
## Use
Say our document `example.md` contains:
```markdown
# Pluto
**Pluto** (minor-planet designation: **134340 Pluto**) is a
[dwarf planet](https://en.wikipedia.org/wiki/Dwarf_planet) in the
[Kuiper belt](https://en.wikipedia.org/wiki/Kuiper_belt).
```
…and our module `example.js` contains:
```js
import rehypeDocument from 'rehype-document'
import rehypeFormat from 'rehype-format'
import rehypeStringify from 'rehype-stringify'
import remarkParse from 'remark-parse'
import remarkRehype from 'remark-rehype'
import {read} from 'to-vfile'
import {unified} from 'unified'
import {reporter} from 'vfile-reporter'
const file = await unified()
.use(remarkParse)
.use(remarkRehype)
.use(rehypeDocument)
.use(rehypeFormat)
.use(rehypeStringify)
.process(await read('example.md'))
console.error(reporter(file))
console.log(String(file))
```
…then running `node example.js` yields:
```txt
example.md: no issues found
```
HTML:
```html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>example</title>
<meta content="width=device-width, initial-scale=1" name="viewport">
</head>
<body>
<h1>Pluto</h1>
<p>
<strong>Pluto</strong> (minor-planet designation: <strong>134340 Pluto</strong>) is a
<a href="https://en.wikipedia.org/wiki/Dwarf_planet">dwarf planet</a> in the
<a href="https://en.wikipedia.org/wiki/Kuiper_belt">Kuiper belt</a>.
</p>
</body>
</html>
```
## API
This package exports the identifiers
[`defaultFootnoteBackContent`][api-default-footnote-back-content],
[`defaultFootnoteBackLabel`][api-default-footnote-back-label], and
[`defaultHandlers`][api-default-handlers].
The default export is [`remarkRehype`][api-remark-rehype].
### `defaultFootnoteBackContent(referenceIndex, rereferenceIndex)`
See [`defaultFootnoteBackContent` from
`mdast-util-to-hast`][mdast-util-to-hast-default-footnote-back-content]
### `defaultFootnoteBackLabel(referenceIndex, rereferenceIndex)`
See [`defaultFootnoteBackLabel` from
`mdast-util-to-hast`][mdast-util-to-hast-default-footnote-back-label]
### `defaultHandlers`
See [`defaultHandlers` from
`mdast-util-to-hast`][mdast-util-to-hast-default-handlers]
### `unified().use(remarkRehype[, destination][, options])`
Turn markdown into HTML.
###### Parameters
* `destination` ([`Processor`][unified-processor], optional)
— processor
* `options` ([`Options`][api-options], optional)
— configuration
###### Returns
Transform ([`Transformer`][unified-transformer]).
##### Notes
###### Signature
* if a [processor][unified-processor] is given, runs the (rehype) plugins
used on it with a hast tree, then discards the result
([*bridge mode*][unified-mode])
* otherwise, returns a hast tree, the plugins used after `remarkRehype`
are rehype plugins ([*mutate mode*][unified-mode])
:::note
Its highly unlikely that you want to pass a `processor`.
:::
###### HTML
Raw HTML is available in mdast as [`html`][mdast-html] nodes and can be embedded
in hast as semistandard `raw` nodes.
Most plugins ignore `raw` nodes but two notable ones dont:
* [`rehype-stringify`][rehype-stringify] also has an option
`allowDangerousHtml` which will output the raw HTML.
This is typically discouraged as noted by the option name but is useful if
you completely trust authors
* [`rehype-raw`][rehype-raw] can handle the raw embedded HTML strings by
parsing them into standard hast nodes (`element`, `text`, etc).
This is a heavy task as it needs a full HTML parser, but it is the only way
to support untrusted content
###### Footnotes
Many options supported here relate to footnotes.
Footnotes are not specified by CommonMark, which we follow by default.
They are supported by GitHub, so footnotes can be enabled in markdown with
[`remark-gfm`][remark-gfm].
The options `footnoteBackLabel` and `footnoteLabel` define natural language
that explains footnotes, which is hidden for sighted users but shown to
assistive technology.
When your page is not in English, you must define translated values.
Back references use ARIA attributes, but the section label itself uses a
heading that is hidden with an `sr-only` class.
To show it to sighted users, define different attributes in
`footnoteLabelProperties`.
###### Clobbering
Footnotes introduces a problem, as it links footnote calls to footnote
definitions on the page through `id` attributes generated from user content,
which results in DOM clobbering.
DOM clobbering is this:
```html
<p id=x></p>
<script>alert(x) // `x` now refers to the DOM `p#x` element</script>
```
Elements by their ID are made available by browsers on the `window` object,
which is a security risk.
Using a prefix solves this problem.
More information on how to handle clobbering and the prefix is explained in
[*Example: headings (DOM clobbering)* in
`rehype-sanitize`][rehype-sanitize-clobber].
###### Unknown nodes
Unknown nodes are nodes with a type that isnt in `handlers` or `passThrough`.
The default behavior for unknown nodes is:
* when the node has a `value` (and doesnt have `data.hName`,
`data.hProperties`, or `data.hChildren`, see later), create a hast `text`
node
* otherwise, create a `<div>` element (which could be changed with
`data.hName`), with its children mapped from mdast to hast as well
This behavior can be changed by passing an `unknownHandler`.
### `Options`
Configuration (TypeScript type).
###### Fields
* `allowDangerousHtml` (`boolean`, default: `false`)
— whether to persist raw HTML in markdown in the hast tree
* `clobberPrefix` (`string`, default: `'user-content-'`)
— prefix to use before the `id` property on footnotes to prevent them from
*clobbering*
* `footnoteBackContent`
([`FootnoteBackContentTemplate` from
`mdast-util-to-hast`][mdast-util-to-hast-footnote-back-content-template]
or `string`, default:
[`defaultFootnoteBackContent` from
`mdast-util-to-hast`][mdast-util-to-hast-default-footnote-back-content])
— content of the backreference back to references
* `footnoteBackLabel`
([`FootnoteBackLabelTemplate` from
`mdast-util-to-hast`][mdast-util-to-hast-footnote-back-label-template]
or `string`, default:
[`defaultFootnoteBackLabel` from
`mdast-util-to-hast`][mdast-util-to-hast-default-footnote-back-label])
— label to describe the backreference back to references
* `footnoteLabel` (`string`, default: `'Footnotes'`)
— label to use for the footnotes section (affects screen readers)
* `footnoteLabelProperties`
([`Properties` from `@types/hast`][hast-properties], default:
`{className: ['sr-only']}`)
— properties to use on the footnote label
(note that `id: 'footnote-label'` is always added as footnote calls use it
with `aria-describedby` to provide an accessible label)
* `footnoteLabelTagName` (`string`, default: `h2`)
— tag name to use for the footnote label
* `handlers` ([`Handlers` from
`mdast-util-to-hast`][mdast-util-to-hast-handlers], optional)
— extra handlers for nodes
* `passThrough` (`Array<Nodes['type']>`, optional)
— list of custom mdast node types to pass through (keep) in hast (note that
the node itself is passed, but eventual children are transformed)
* `unknownHandler` ([`Handler` from
`mdast-util-to-hast`][mdast-util-to-hast-handler], optional)
— handle all unknown nodes
## Examples
### Example: supporting HTML in markdown naïvely
If you completely trust the authors of the input markdown and want to allow them
to write HTML inside markdown, you can pass `allowDangerousHtml` to
`remark-rehype` and `rehype-stringify`:
```js
import rehypeStringify from 'rehype-stringify'
import remarkParse from 'remark-parse'
import remarkRehype from 'remark-rehype'
import {unified} from 'unified'
const file = await unified()
.use(remarkParse)
.use(remarkRehype, {allowDangerousHtml: true})
.use(rehypeStringify, {allowDangerousHtml: true})
.process('<a href="/wiki/Dysnomia_(moon)" onclick="alert(1)">Dysnomia</a>')
console.log(String(file))
```
Yields:
```html
<p><a href="/wiki/Dysnomia_(moon)" onclick="alert(1)">Dysnomia</a></p>
```
:::caution
Оbserve that the XSS attack through `onclick` is present.
:::
### Example: supporting HTML in markdown properly
If you do not trust the authors of the input markdown, or if you want to make
sure that rehype plugins can see HTML embedded in markdown, use
[`rehype-raw`][rehype-raw].
The following example passes `allowDangerousHtml` to `remark-rehype`, then
turns the raw embedded HTML into proper HTML nodes with `rehype-raw`, and
finally sanitizes the HTML by only allowing safe things with
`rehype-sanitize`:
```js
import rehypeSanitize from 'rehype-sanitize'
import rehypeStringify from 'rehype-stringify'
import rehypeRaw from 'rehype-raw'
import remarkParse from 'remark-parse'
import remarkRehype from 'remark-rehype'
import {unified} from 'unified'
const file = await unified()
.use(remarkParse)
.use(remarkRehype, {allowDangerousHtml: true})
.use(rehypeRaw)
.use(rehypeSanitize)
.use(rehypeStringify)
.process('<a href="/wiki/Dysnomia_(moon)" onclick="alert(1)">Dysnomia</a>')
console.log(String(file))
```
Running that code yields:
```html
<p><a href="/wiki/Dysnomia_(moon)">Dysnomia</a></p>
```
:::caution
Оbserve that the XSS attack through `onclick` is **not** present.
:::
### Example: footnotes in languages other than English
If you know that the markdown is authored in a language other than English,
and youre using `remark-gfm` to match how GitHub renders markdown, and you know
that footnotes are (or can?) be used, you should translate the labels associated
with them.
Lets first set the stage:
```js
import {unified} from 'unified'
import remarkParse from 'remark-parse'
import remarkGfm from 'remark-gfm'
import remarkRehype from 'remark-rehype'
import rehypeStringify from 'rehype-stringify'
const doc = `
Ceres ist nach der römischen Göttin des Ackerbaus benannt;
ihr astronomisches Symbol ist daher eine stilisierte Sichel: ⚳.[^nasa-2015]
[^nasa-2015]: JPL/NASA:
[*What is a Dwarf Planet?*](https://www.jpl.nasa.gov/infographics/what-is-a-dwarf-planet)
In: Jet Propulsion Laboratory.
22. April 2015,
abgerufen am 19. Januar 2022 (englisch).
`
const file = await unified()
.use(remarkParse)
.use(remarkGfm)
.use(remarkRehype)
.use(rehypeStringify)
.process(doc)
console.log(String(file))
```
Yields:
```html
<p>
Ceres ist nach der römischen Göttin des Ackerbaus benannt;
ihr astronomisches Symbol ist daher eine stilisierte Sichel: ⚳.
<sup>
<a
href="#user-content-fn-nasa-2015"
id="user-content-fnref-nasa-2015"
data-footnote-ref aria-describedby="footnote-label"
>
1
</a>
</sup>
</p>
<section data-footnotes class="footnotes">
<h2 class="sr-only" id="footnote-label">Footnotes</h2>
<ol>
<li id="user-content-fn-nasa-2015">
<p>
JPL/NASA:
<a href="https://www.jpl.nasa.gov/infographics/what-is-a-dwarf-planet">
<em>What is a Dwarf Planet?</em>
</a>
In: Jet Propulsion Laboratory.
22. April 2015,
abgerufen am 19. Januar 2022 (englisch).
<a
href="#user-content-fnref-nasa-2015"
data-footnote-backref=""
aria-label="Back to reference 1"
class="data-footnote-backref"
>
</a>
</p>
</li>
</ol>
</section>
```
This is a mix of English and German that isnt very accessible, such as that
screen readers cant handle it nicely.
Lets say our program *does* know that the markdown is in German.
In that case, its important to translate and define the labels relating to
footnotes so that screen reader users can properly pronounce the page:
```diff
@@ -18,7 +18,16 @@ ihr astronomisches Symbol ist daher eine stilisierte Sichel: ⚳.[^nasa-2015]
const file = await unified()
.use(remarkParse)
.use(remarkGfm)
- .use(remarkRehype)
+ .use(remarkRehype, {
+ footnoteBackLabel(referenceIndex, rereferenceIndex) {
+ return (
+ 'Hochspringen nach: ' +
+ (referenceIndex + 1) +
+ (rereferenceIndex > 1 ? '-' + rereferenceIndex : '')
+ )
+ },
+ footnoteLabel: 'Fußnoten'
+ })
.use(rehypeStringify)
.process(doc)
```
Running the code with the above patch applied, yields:
```diff
@@ -1,13 +1,13 @@
<p>Ceres ist nach der römischen Göttin des Ackerbaus benannt;
ihr astronomisches Symbol ist daher eine stilisierte Sichel: ⚳.<sup><a href="#user-content-fn-nasa-2015" id="user-content-fnref-nasa-2015" data-footnote-ref aria-describedby="footnote-label">1</a></sup></p>
-<section data-footnotes class="footnotes"><h2 class="sr-only" id="footnote-label">Footnotes</h2>
+<section data-footnotes class="footnotes"><h2 class="sr-only" id="footnote-label">Fußnoten</h2>
<ol>
<li id="user-content-fn-nasa-2015">
<p>JPL/NASA:
<a href="https://www.jpl.nasa.gov/infographics/what-is-a-dwarf-planet"><em>What is a Dwarf Planet?</em></a>
In: Jet Propulsion Laboratory.
22. April 2015,
-abgerufen am 19. Januar 2022 (englisch). <a href="#user-content-fnref-nasa-2015" data-footnote-backref="" aria-label="Back to reference 1" class="data-footnote-backref">↩</a></p>
+abgerufen am 19. Januar 2022 (englisch). <a href="#user-content-fnref-nasa-2015" data-footnote-backref="" aria-label="Hochspringen nach: 1" class="data-footnote-backref">↩</a></p>
</li>
</ol>
</section>
```
## HTML
See [*Algorithm* in
`mdast-util-to-hast`](https://github.com/syntax-tree/mdast-util-to-hast#algorithm)
for info on how mdast (markdown) nodes are transformed to hast (HTML).
## CSS
Assuming you know how to use (semantic) HTML and CSS, then it should generally
be straightforward to style the HTML produced by this plugin.
With CSS, you can get creative and style the results as you please.
Some semistandard features, notably GFMs tasklists and footnotes, generate HTML
that be unintuitive, as it matches exactly what GitHub produces for their
website.
There is a project, [`sindresorhus/github-markdown-css`][github-markdown-css],
that exposes the stylesheet that GitHub uses for rendered markdown, which might
either be inspirational for more complex features, or can be used as-is to
exactly match how GitHub styles rendered markdown.
The following CSS is needed to make footnotes look a bit like GitHub:
```css
/* Style the footnotes section. */
.footnotes {
font-size: smaller;
color: #8b949e;
border-top: 1px solid #30363d;
}
/* Hide the section label for visual users. */
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
overflow: hidden;
clip: rect(0, 0, 0, 0);
word-wrap: normal;
border: 0;
}
/* Place `[` and `]` around footnote calls. */
[data-footnote-ref]::before {
content: '[';
}
[data-footnote-ref]::after {
content: ']';
}
```
## Syntax tree
This projects turns [mdast][] (markdown) into [hast][] (HTML).
It extends mdast by supporting `data` fields on mdast nodes to specify how they
should be transformed.
See [*Fields on nodes* in
`mdast-util-to-hast`](https://github.com/syntax-tree/mdast-util-to-hast#fields-on-nodes)
for info on how these fields work.
It extends hast by using a semistandard raw nodes for raw HTML.
See the [*HTML* note above](#html) for more info.
## Types
This package is fully typed with [TypeScript][].
It exports the types
[`Options`][api-options].
The types of `mdast-util-to-hast` can be referenced to register data fields
with `@types/mdast` and `Raw` nodes with `@types/hast`.
```js
/**
* @import {Root as HastRoot} from 'hast'
* @import {Root as MdastRoot} from 'mdast'
* @import {} from 'mdast-util-to-hast'
*/
import {visit} from 'unist-util-visit'
const mdastNode = /** @type {MdastRoot} */ ({/* … */})
console.log(mdastNode.data?.hName) // Typed as `string | undefined`.
const hastNode = /** @type {HastRoot} */ ({/* … */})
visit(hastNode, function (node) {
// `node` can now be `raw`.
})
```
## Compatibility
Projects maintained by the unified collective are compatible with maintained
versions of Node.js.
When we cut a new major release, we drop support for unmaintained versions of
Node.
This means we try to keep the current release line, `remark-rehype@^11`,
compatible with Node.js 16.
This plugin works with `unified` version 6+, `remark-parse` version 3+ (used in
`remark` version 7), and `rehype-stringify` version 3+ (used in `rehype`
version 5).
## Security
Use of `remark-rehype` can open you up to a
[cross-site scripting (XSS)][wiki-xss] attack.
Embedded **[hast][]** properties (`hName`, `hProperties`, `hChildren`) in
[mdast][], custom handlers, and the `allowDangerousHtml` option all provide
openings.
Use [`rehype-sanitize`][rehype-sanitize] to make the tree safe.
## Related
* [`rehype-raw`][rehype-raw]
— rehype plugin to parse the tree again and support `raw` nodes
* [`rehype-sanitize`][rehype-sanitize]
— rehype plugin to sanitize HTML
* [`rehype-remark`](https://github.com/rehypejs/rehype-remark)
— rehype plugin to turn HTML into markdown
* [`rehype-retext`](https://github.com/rehypejs/rehype-retext)
— rehype plugin to support retext
* [`remark-retext`](https://github.com/remarkjs/remark-retext)
— remark plugin to support retext
## Contribute
See [`contributing.md`][contributing] in [`remarkjs/.github`][health] for ways
to get started.
See [`support.md`][support] for ways to get help.
This project has a [code of conduct][coc].
By interacting with this repository, organization, or community you agree to
abide by its terms.
## License
[MIT][license] © [Titus Wormer][author]
<!-- Definitions -->
[build-badge]: https://github.com/remarkjs/remark-rehype/workflows/main/badge.svg
[build]: https://github.com/remarkjs/remark-rehype/actions
[coverage-badge]: https://img.shields.io/codecov/c/github/remarkjs/remark-rehype.svg
[coverage]: https://codecov.io/github/remarkjs/remark-rehype
[downloads-badge]: https://img.shields.io/npm/dm/remark-rehype.svg
[downloads]: https://www.npmjs.com/package/remark-rehype
[size-badge]: https://img.shields.io/bundlejs/size/remark-rehype
[size]: https://bundlejs.com/?q=remark-rehype
[sponsors-badge]: https://opencollective.com/unified/sponsors/badge.svg
[backers-badge]: https://opencollective.com/unified/backers/badge.svg
[collective]: https://opencollective.com/unified
[chat-badge]: https://img.shields.io/badge/chat-discussions-success.svg
[chat]: https://github.com/remarkjs/remark/discussions
[npm]: https://docs.npmjs.com/cli/install
[esm]: https://gist.github.com/sindresorhus/a39789f98801d908bbc7ff3ecc99d99c
[esmsh]: https://esm.sh
[health]: https://github.com/remarkjs/.github
[contributing]: https://github.com/remarkjs/.github/blob/main/contributing.md
[support]: https://github.com/remarkjs/.github/blob/main/support.md
[coc]: https://github.com/remarkjs/.github/blob/main/code-of-conduct.md
[license]: license
[author]: https://wooorm.com
[github-markdown-css]: https://github.com/sindresorhus/github-markdown-css
[hast]: https://github.com/syntax-tree/hast
[hast-properties]: https://github.com/syntax-tree/hast#properties
[mdast]: https://github.com/syntax-tree/mdast
[mdast-html]: https://github.com/syntax-tree/mdast#html
[mdast-util-to-hast]: https://github.com/syntax-tree/mdast-util-to-hast
[mdast-util-to-hast-default-footnote-back-content]: https://github.com/syntax-tree/mdast-util-to-hast#defaultfootnotebackcontentreferenceindex-rereferenceindex
[mdast-util-to-hast-default-footnote-back-label]: https://github.com/syntax-tree/mdast-util-to-hast#defaultfootnotebacklabelreferenceindex-rereferenceindex
[mdast-util-to-hast-footnote-back-content-template]: https://github.com/syntax-tree/mdast-util-to-hast#footnotebackcontenttemplate
[mdast-util-to-hast-footnote-back-label-template]: https://github.com/syntax-tree/mdast-util-to-hast#footnotebacklabeltemplate
[mdast-util-to-hast-default-handlers]: https://github.com/syntax-tree/mdast-util-to-hast#defaulthandlers
[mdast-util-to-hast-handlers]: https://github.com/syntax-tree/mdast-util-to-hast#handlers
[mdast-util-to-hast-handler]: https://github.com/syntax-tree/mdast-util-to-hast#handler
[rehype]: https://github.com/rehypejs/rehype
[rehype-format]: https://github.com/rehypejs/rehype-format
[rehype-highlight]: https://github.com/rehypejs/rehype-highlight
[rehype-meta]: https://github.com/rehypejs/rehype-meta
[rehype-minify]: https://github.com/rehypejs/rehype-minify
[rehype-raw]: https://github.com/rehypejs/rehype-raw
[rehype-sanitize]: https://github.com/rehypejs/rehype-sanitize
[rehype-sanitize-clobber]: https://github.com/rehypejs/rehype-sanitize#example-headings-dom-clobbering
[rehype-stringify]: https://github.com/rehypejs/rehype/tree/main/packages/rehype-stringify
[rehype-remark]: https://github.com/rehypejs/rehype-remark
[remark]: https://github.com/remarkjs/remark
[remark-gfm]: https://github.com/remarkjs/remark-gfm
[typescript]: https://www.typescriptlang.org
[unified]: https://github.com/unifiedjs/unified
[unified-mode]: https://github.com/unifiedjs/unified#transforming-between-ecosystems
[unified-processor]: https://github.com/unifiedjs/unified#processor
[unified-transformer]: https://github.com/unifiedjs/unified#transformer
[wiki-xss]: https://en.wikipedia.org/wiki/Cross-site_scripting
[api-default-footnote-back-content]: #defaultfootnotebackcontentreferenceindex-rereferenceindex
[api-default-footnote-back-label]: #defaultfootnotebacklabelreferenceindex-rereferenceindex
[api-default-handlers]: #defaulthandlers
[api-options]: #options
[api-remark-rehype]: #unifieduseremarkrehype-destination-options

View File

@@ -0,0 +1,22 @@
---
title: "Example OG Social Image"
publishDate: "27 January 2023"
description: "An example post for Astro Citrus, 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](http://astrocitrus.artemkutsan.pp.ua/social-card.png).
[^1]: The image itself can be located anywhere you like.

View File

@@ -0,0 +1,88 @@
---
title: "How to Create a Font Subset with Transfonter"
description: "A guide to using Transfonter to create optimized font subsets"
publishDate: "2025-02-10"
tags: ["fonts", "optimization", "web performance"]
---
## What is Transfonter?
[Transfonter](https://transfonter.org/) is a free online tool that helps convert and subset fonts. It supports various formats (TTF, OTF, WOFF, WOFF2) and allows users to optimize web fonts by reducing their size while maintaining essential glyphs.
## Why Use Font Subsetting?
Font files often contain thousands of glyphs, including symbols and characters for multiple languages. Subsetting removes unnecessary glyphs, reducing file size and improving website performance. This is particularly useful when only a specific language set or symbols are needed.
For example, I encountered an issue when using the SF Pro Rounded font with the Satori library for generating OG images, as described in this post: [Example OG Social Image](posts/social-image/). When using multiple font variants, the project failed to build due to memory overflow errors. Increasing the memory limit did not help. Moreover, using even a single font file larger than ~3.5MB is considered bad practice, let alone multiple variants at the same time.
After subsetting the font, I ended up with two subsets, both containing **only Latin characters**: one slightly over 100KB and another around 355KB. This significantly reduced the overall font size while keeping the necessary glyphs.
## Creating a Font Subset with Transfonter
Let's take **SF Pro Rounded**, a multilingual font, and divide it into two subsets:
- **Basic subset**: Includes Latin characters and essential symbols.
- **Extended subset**: Includes additional glyphs beyond the basic set.
### Upload the Font
1. Go to [Transfonter](https://transfonter.org/).
2. Click **Add Fonts** and select the **SF Pro Rounded Regular** font file (TTF or OTF format).
### Define Unicode Ranges
For subsetting, use the following ranges:
#### Basic Subset
transfonter.org latin + essential symbols unicode-range:
```
0000-007F, 00A0-024F, 2190-22FF, 2934-2937, F6D5-F6D8
```
#### Extended Subset
transfonter.org additional glyphs unicode-range:
```
0080-00A0, 0250-218F, 2300-FFFF
```
:::tip
You can find out the character codes and view the glyph tables of a font using built-in system tools:
- Windows: Use Character Map (charmap). Open the Start menu, search for "Character Map," and select a font to see its glyphs and Unicode codes.
- macOS: Open Font Book, select a font, and switch to "Repertoire" mode to see all available characters along with their codes.
- Linux: Use gucharmap (GNOME Character Map) or kcharselect (for KDE) to browse Unicode symbols in installed fonts.
:::
### Generate the Font Files
1. Check the **Subset** box in Transfonter.
2. Enter the Unicode ranges above for each subset.
3. Click **Convert** to generate the optimized font files.
4. Download the converted fonts.
:::tip
Additionally, when using Transfonter, you can upload and convert multiple fonts at the same time. The tool allows batch processing, and after conversion, all optimized fonts can be downloaded as a ZIP archive, making it easier to manage multiple font files efficiently.
:::
### Implement in CSS
Once the fonts are ready, use `@font-face` to load them efficiently:
```css
@font-face {
font-family: "SFProRounded";
src: url("/fonts/SF-Pro-Rounded-Regular-Basic.ttf") format("truetype");
font-weight: 400;
font-style: normal;
}
@font-face {
font-family: "SFProRounded";
src: url("/fonts/SF-Pro-Rounded-Regular-Extended.ttf") format("truetype");
font-weight: 400;
font-style: normal;
}
```
### Test the Fonts
Ensure the fonts load correctly by inspecting network requests in the browser's developer tools. Verify that only necessary subsets are downloaded.
## Conclusion
Using Transfonter for font subsetting helps optimize web performance by reducing font file sizes while keeping necessary glyphs. Try it out with your fonts to enhance your website's loading speed!

View 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`.

View File

@@ -0,0 +1,65 @@
---
title: "Adding Webmentions to Astro Citrus"
description: "This post describes the detailed process of adding webmentions to your own site, including setup, configuration, and integration with webmention services."
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. `http://astrocitrus.artemkutsan.pp.ua/`. 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 `http://astrocitrus.artemkutsan.pp.ua/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/KieranMaguire) for 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!

View File

@@ -0,0 +1,6 @@
---
id: citrus-docs
title: "Citrus Docs"
description: "Astro Citrus documentation outlines key aspects of the template, describing its core functionality for blog management and project documentation setup"
featured: true
---

Some files were not shown because too many files have changed in this diff Show More