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

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

View File

@@ -0,0 +1,5 @@
---
id: markdown-elements
title: "Markdown Elements"
description: "Dive into a comprehensive guide exploring Markdown syntax and elements, from basic formatting to advanced features, designed to help you master their usage with practical examples for enhancing your documentation and writing efficiency"
---

48
src/data/post.ts Normal file
View File

@@ -0,0 +1,48 @@
import { type CollectionEntry, getCollection } from "astro:content";
/** filter out draft posts based on the environment */
export async function getAllPosts(): Promise<CollectionEntry<"post">[]> {
return await getCollection("post", ({ data }) => {
return import.meta.env.PROD ? !data.draft : true;
});
}
/** groups posts by year (based on option siteConfig.sortPostsByUpdatedDate), using the year as the key
* Note: This function doesn't filter draft posts, pass it the result of getAllPosts above to do so.
*/
export function groupPostsByYear(posts: CollectionEntry<"post">[]) {
return posts.reduce<Record<string, CollectionEntry<"post">[]>>((acc, post) => {
const year = post.data.publishDate.getFullYear();
if (!acc[year]) {
acc[year] = [];
}
acc[year]?.push(post);
return acc;
}, {});
}
/** returns all tags created from posts (inc duplicate tags)
* Note: This function doesn't filter draft posts, pass it the result of getAllPosts above to do so.
* */
export function getAllTags(posts: CollectionEntry<"post">[]) {
return posts.flatMap((post) => [...post.data.tags]);
}
/** returns all unique tags created from posts
* Note: This function doesn't filter draft posts, pass it the result of getAllPosts above to do so.
* */
export function getUniqueTags(posts: CollectionEntry<"post">[]) {
return [...new Set(getAllTags(posts))];
}
/** returns a count of each unique tag - [[tagName, count], ...]
* Note: This function doesn't filter draft posts, pass it the result of getAllPosts above to do so.
* */
export function getUniqueTagsWithCount(posts: CollectionEntry<"post">[]): [string, number][] {
return [
...getAllTags(posts).reduce(
(acc, t) => acc.set(t, (acc.get(t) ?? 0) + 1),
new Map<string, number>(),
),
].sort((a, b) => b[1] - a[1]);
}

5
src/env.d.ts vendored Normal file
View File

@@ -0,0 +1,5 @@
declare module "@pagefind/default-ui" {
declare class PagefindUI {
constructor(arg: unknown);
}
}

60
src/layouts/Base.astro Normal file
View File

@@ -0,0 +1,60 @@
---
import BaseHead from "@/components/BaseHead.astro";
import SkipLink from "@/components/SkipLink.astro";
import ThemeProvider from "@/components/ThemeProvider.astro";
import Header from "@/components/layout/Header.astro";
import Footer from "@/components/layout/Footer.astro";
import { siteConfig } from "@/site.config";
import type { SiteMeta } from "@/types";
interface Props {
meta: SiteMeta;
}
const {
meta: { articleDate, description = siteConfig.description, ogImage, title },
} = Astro.props;
---
<html
class="overflow-x-hidden grid scroll-pt-20 scroll-smooth font-sans text-textColor text-xl md:text-base antialiased"
lang={siteConfig.lang}
>
<head>
<BaseHead articleDate={articleDate} description={description} ogImage={ogImage} title={title} />
</head>
<body class="min-w-4xl relative m-auto min-h-screen w-full max-w-6xl grow bg-bgColor">
<ThemeProvider />
<SkipLink />
<!-- 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] min-h-screen w-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="relative flex min-h-screen w-full">
<!-- Sidebar for Docs Series -->
<slot name="sidebar" />
<!-- Main container for the page content -->
<div id="container" class="min-w-3xl relative m-auto max-w-4xl grow">
<div class="m-auto grid min-h-screen md:grid-rows-[auto_auto_1fr] px-4 md:px-8 md:pt-4">
<Header />
<main id="main" class="relative flex-grow mt-32 md:mt-[3.5rem]">
<slot />
</main>
<Footer />
</div>
</div>
</div>
</body>
</html>

383
src/layouts/BlogPost.astro Normal file
View File

@@ -0,0 +1,383 @@
---
import { type CollectionEntry, render } from "astro:content";
import Masthead from "@/components/blog/Masthead.astro";
import TOC from "@/components/blog/TOC.astro";
import WebMentions from "@/components/blog/webmentions/index.astro";
import BaseLayout from "./Base.astro";
import SeriesPanel from "@/components/SeriesPanel.astro";
import { Icon } from "astro-icon/components";
interface Props {
post: CollectionEntry<"post">;
}
const { post } = Astro.props;
const { ogImage, title, description, updatedDate, publishDate, seriesId } = post.data;
const socialImage = ogImage ?? `/og-image/${post.id}.png`;
const articleDate = updatedDate?.toISOString() ?? publishDate.toISOString();
const { headings } = await render(post);
---
<BaseLayout meta={{ articleDate, description, ogImage: socialImage, title }}>
<div class="fixed left-0 top-0 z-10 flex h-16 md:h-20 w-full bg-bgColor overflow-hidden">
<!-- Background
TODO: This approach is not optimal and requires improvements.
- Too many absolutely positioned elements can affect performance.
-->
<div class="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>
<!-- NEEDS IMPROVEMENT!!! -->
<!-- Menu button instead of the main menu -->
<!--
<div class="left-0 right-0 top-0 z-30 ml-auto h-0 w-fit px-2 md:absolute">
<div class="fixed top-0 -ml-[4rem] flex space-x-2 bg-bgColor pt-8">
-->
<!-- Button background -->
<!--
<div class="fixed top-0 h-20 right-[0%] w-[50%] bg-gradient-to-l from-orange-300 via-pink-300 to-transparent opacity-40 dark:opacity-10">
</div>
-->
<!--
<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>
-->
<!--
</div>
</div>
-->
<!-- SeriesPanel is inserted into the named slot "sidebar" -->
{seriesId && (<SeriesPanel slot="sidebar" seriesId={seriesId} />)}
<Masthead content={post} />
<div class="mt-6 flex sm:grid-cols-[auto_1fr] md:items-start gap-x-8">
<article class="grid flex-grow grid-cols-1 break-words pt-4" data-pagefind-body>
<div class="prose prose-citrus max-w-none flex-grow prose-headings:font-semibold prose-headings:text-accent-base prose-headings:before:text-accent-two sm:prose-headings:before:content-['#'] sm:prose-th:before:content-none">
<slot />
</div>
<WebMentions />
</article>
{!!headings.length && (
<aside
id="toc-panel"
class="md:sticky md:top-20 z-10 hidden md:w-[14rem] md:min-w-[14rem] md:rounded-lg md:block"
>
<TOC headings={headings} />
</aside>
)}
</div>
<div class="left-0 right-12 z-50 ml-auto w-fit md:absolute">
<button
id="to-top-button"
class="fixed bottom-14 flex h-12 w-12 text-light translate-y-28 items-center justify-center rounded-full bg-bgColor text-3xl drop-shadow-xl transition-all duration-300 hover:text-accent-two data-[show=true]:translate-y-0 data-[show=true]:opacity-100"
aria-label="Back to Top"
data-show="false"
>
<span class="absolute inset-0 rounded-full bg-special-lighter flex items-center justify-center" aria-hidden="true">
<svg
class="h-6 w-6"
fill="none"
focusable="false"
stroke="currentColor"
stroke-width="2"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M4.5 15.75l7.5-7.5 7.5 7.5" stroke-linecap="round" stroke-linejoin="round"></path>
</svg>
</span>
</button>
</div>
<!-- Series button for mobile screens -->
{seriesId && (
<mobile-button
id="toggle-panel-mobile"
class="size-12 flex items-center justify-center block sm:hidden fixed bottom-4 shadow-[0px_10px_25px_rgba(0,0,0,0.15)] border border-special-lighter right-4 z-50 rounded-lg bg-bgColor text-accent-base hover:text-bg-accent-base/90"
aria-label="Toggle Series Panel"
>
<span class="absolute inset-0 rounded-lg flex items-center justify-center bg-special-light hover:text-accent-base/90">
<Icon class="size-8" name="solar:notes-bold"/>
</span>
</mobile-button>
)}
<!-- NEEDS IMPROVEMENT!!! -->
<!-- Table of Contents button for mobile screens -->
<!--
{!!headings.length && (
<mobile-button
id="toggle-toc-mobile"
class="size-12 flex items-center justify-center block sm:hidden fixed bottom-20 shadow-[0px_10px_25px_rgba(0,0,0,0.15)] border border-special-lighter right-4 z-50 rounded-lg bg-bgColor text-accent-base hover:text-accent-base/90"
aria-label="Toggle TOC Panel"
>
<span class="absolute inset-0 rounded-lg flex items-center justify-center bg-special-light hover:text-accent-base/90">
<Icon class="size-8" name="solar:clipboard-list-bold"/>
</span>
</mobile-button>
)}
-->
</BaseLayout>
<!-- Copy code button -->
<script>
// Wait for the content to fully load
document.addEventListener("DOMContentLoaded", () => {
// Find all pre blocks
document.querySelectorAll("pre").forEach((pre) => {
// Check if the button already exists
if (!pre.querySelector(".copy-code")) {
// Create the button
const copyButton = document.createElement("button");
copyButton.className =
"absolute flex items-center justify-center bg-bgColor h-6 font-medium overflow-hidden rounded-md text-light hover:text-accent-two font-sans text-sm font-medium top-2 right-2";
// Create the span element that will hold the button text
const buttonText = document.createElement("span");
buttonText.innerText = "Copy"; // Initial text
buttonText.className = "flex items-center block w-full h-full px-2 bg-[var(--code-title-bg)]"; // Set the span to take up full width with background color
copyButton.appendChild(buttonText); // Append span to the button
// Add the button inside pre
pre.appendChild(copyButton);
// Event handler for copying text
copyButton.addEventListener("click", async () => {
const code = pre.querySelector("code")?.textContent;
if (code) {
await navigator.clipboard.writeText(code);
buttonText.innerText = "Copied!"; // Change text to "Copied!"
// After 1.5 seconds, change the text back to "Copy"
setTimeout(() => {
buttonText.innerText = "Copy";
}, 1500);
}
});
}
});
});
</script>
<script>
const ANIMATION_DURATION = 300;
// Get the button and panel elements
const togglePanelBtn = document.getElementById("toggle-panel");
const closePanelBtn = document.getElementById("close-panel");
const seriesPanel = document.getElementById("series-panel");
const togglePanelMobileBtn = document.getElementById("toggle-panel-mobile");
// Ensure the seriesPanel exists
if (!seriesPanel) {
console.error("Element series-panel not found");
throw new Error("series-panel is required");
}
// Function to check if the panel is visible
const isPanelVisible = () => {
const isScreenLg = window.matchMedia("(min-width: 1024px)").matches; // Large screens
return (
(isScreenLg && seriesPanel.classList.contains("lg:block")) ||
(!isScreenLg && !seriesPanel.classList.contains("hidden"))
);
};
// Function to hide the panel
const hidePanel = () => {
seriesPanel.classList.add("opacity-0", "-translate-x-full");
setTimeout(() => {
seriesPanel.classList.remove("block", "lg:block");
seriesPanel.classList.add("hidden");
}, ANIMATION_DURATION);
};
// Function to show the panel
const showPanel = () => {
seriesPanel.classList.remove("hidden");
seriesPanel.classList.add("block", "lg:block");
setTimeout(() => {
seriesPanel.classList.remove("opacity-0", "-translate-x-full");
}, 10);
};
// Common event handler for both buttons
const togglePanel = () => {
if (isPanelVisible()) {
hidePanel(); // If visible, hide it
} else {
showPanel(); // If hidden, show it
}
};
// Event handlers for both buttons
if (togglePanelBtn) {
togglePanelBtn.addEventListener("click", togglePanel);
} else {
console.error("Element toggle-panel not found");
}
if (togglePanelMobileBtn) {
togglePanelMobileBtn.addEventListener("click", togglePanel);
} else {
console.error("Element toggle-panel-mobile not found");
}
if (closePanelBtn) {
closePanelBtn.addEventListener("click", hidePanel);
} else {
console.error("Element close-panel not found");
}
</script>
<script>
// Get the button and panel elements
const toggleTocBtn = document.getElementById('toggle-toc');
const closeTocBtn = document.getElementById('close-toc');
const tocPanel = document.getElementById('toc-panel');
const toggleTocMobileBtn = document.getElementById("toggle-toc-mobile");
// Check for the panel's presence (required for functionality)
if (!tocPanel) {
console.error('Element toc-panel not found');
throw new Error('toc-panel is required');
}
// Function to check if the table of contents is visible
const isTocVisible = () => {
const isScreenMd = window.matchMedia("(min-width: 768px)").matches;
return (
(isScreenMd && tocPanel.classList.contains("md:block")) ||
(!isScreenMd && !tocPanel.classList.contains("hidden"))
);
};
// Function to hide the panel
const hideToc = () => {
tocPanel.classList.add('hidden');
tocPanel.classList.remove('md:block');
};
// Function to show the panel
const showToc = () => {
tocPanel.classList.remove('hidden');
tocPanel.classList.add('md:block');
};
// Common event handler for both buttons
const toggleToc = () => {
if (isTocVisible()) {
hideToc(); // If visible, hide it
} else {
showToc(); // If hidden, show it
}
};
// Add event handler for the toggle button
if (toggleTocBtn) {
toggleTocBtn.addEventListener("click", toggleToc);
} else {
console.error('Element toggle-toc not found');
}
// Add event handler for the toggle button (mobile)
if (toggleTocMobileBtn) {
toggleTocMobileBtn.addEventListener("click", toggleToc);
} else {
console.error('Element toggle-toc-mobile not found');
}
// Add event handler for the close button
if (closeTocBtn) {
closeTocBtn.addEventListener('click', hideToc);
} else {
console.error('Element close-toc not found');
}
</script>
<script>
// Wait for the content to fully load
document.addEventListener("DOMContentLoaded", () => {
const buttonsPanel = document.getElementById("buttons-panel");
if (buttonsPanel) {
buttonsPanel.classList.add("fixed");
console.log("Class 'fixed' added to the buttons-panel element.");
} else {
console.error("Element with ID 'buttons-panel' not found.");
}
});
</script>
<!-- Scroll to top button -->
<script>
const scrollBtn = document.getElementById("to-top-button") as HTMLButtonElement;
const targetHeader = document.querySelector("header") as HTMLElement;
function callback(entries: IntersectionObserverEntry[]) {
entries.forEach((entry) => {
// Show the scroll to top button when the <header> is out of view
scrollBtn.dataset.show = (!entry.isIntersecting).toString();
});
}
scrollBtn.addEventListener("click", () => {
document.documentElement.scrollTo({ behavior: "smooth", top: 0 });
});
const observer = new IntersectionObserver(callback);
observer.observe(targetHeader);
</script>
<!-- REQUIRES IMPROVEMENT!!! -->
<!-- Menu button instead of the main menu -->
<!--
<script>
document.addEventListener("DOMContentLoaded", () => {
const menuButton = document.getElementById("toggle-navigation-menu");
const navigationMenu = document.getElementById("menu");
if (!menuButton || !navigationMenu) {
console.error("Menu button or navigation menu is missing in the DOM.");
return;
}
menuButton.addEventListener("click", () => {
const isOpen = menuButton.getAttribute("aria-expanded") === "true";
if (isOpen) {
// Close the menu
navigationMenu.classList.add("hidden");
} else {
// Open the menu
navigationMenu.classList.remove("hidden");
}
// Update the button state
menuButton.setAttribute("aria-expanded", (!isOpen).toString());
});
});
</script>
-->

299
src/layouts/Series.astro Normal file
View File

@@ -0,0 +1,299 @@
---
import { type CollectionEntry, render } from "astro:content";
import TOC from "@/components/blog/TOC.astro";
import BaseLayout from "./Base.astro";
import SeriesPanel from "@/components/SeriesPanel.astro";
import { Icon } from "astro-icon/components";
interface Props {
series: CollectionEntry<"series">;
}
const { series } = Astro.props;
const { title, description } = series.data;
const { headings } = await render(series);
---
<BaseLayout meta={{ description, title }}>
<div class="fixed left-0 top-0 z-10 flex h-16 md:h-20 w-full bg-bgColor overflow-hidden">
<!-- Background
TODO: This approach is not optimal and requires improvements.
- Too many absolutely positioned elements can affect performance.
-->
<div class="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>
<!-- SeriesPanel вставляется в именованный слот "sidebar" -->
{series.id && (<SeriesPanel slot="sidebar" seriesId={series.id} />)}
<div class="md:sticky md:top-8 md:z-20 flex items-end">
<button
id="toggle-panel"
class="hidden md:flex z-30 mr-2 h-8 w-8 items-center bg-accent-base/10 flex-shrink-0 justify-center rounded-lg text-accent-base hover:text-accent-base/90"
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>
<button
id="toggle-toc"
class="hidden md:flex z-30 mr-2 h-8 w-8 items-center flex-shrink-0 bg-accent-base/10 justify-center rounded-lg text-accent-base hover:text-accent-base/90"
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 md:sticky md:top-4 md:z-20 line-clamp-none md:line-clamp-1 md:max-w-lg lg:max-w-xl">
{title}
</h1>
</div>
<p class="prose prose-citrus max-w-none mt-[1.125rem]">
{description}
</p>
<div class="mt-6 flex sm:grid-cols-[auto_1fr] md:items-start gap-x-8">
<article class="grid flex-grow grid-cols-1 break-words pt-4" data-pagefind-body>
<div class="prose prose-citrus max-w-none flex-grow prose-headings:font-semibold prose-headings:text-accent-base prose-headings:before:text-accent-two sm:prose-headings:before:content-['#'] sm:prose-th:before:content-none">
<slot />
</div>
</article>
{
!!headings.length && (
<aside
id="toc-panel"
class="sticky md:top-20 z-10 hidden md:w-[16rem] md:min-w-[16rem] rounded-lg md:block"
>
<TOC headings={headings} />
</aside>
)
}
</div>
<div class="left-0 right-12 z-50 ml-auto w-fit md:absolute">
<button
id="to-top-button"
class="fixed bottom-14 flex h-12 w-12 text-light translate-y-28 items-center justify-center rounded-full border-2 border-special-lighter bg-bgColor text-3xl drop-shadow-xl transition-all duration-300 hover:text-accent-two data-[show=true]:translate-y-0 data-[show=true]:opacity-100"
aria-label="Back to Top"
data-show="false"
>
<span class="absolute inset-0 rounded-full bg-special-light flex items-center justify-center" aria-hidden="true">
<svg
class="h-6 w-6"
fill="none"
focusable="false"
stroke="currentColor"
stroke-width="2"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M4.5 15.75l7.5-7.5 7.5 7.5" stroke-linecap="round" stroke-linejoin="round"></path>
</svg>
</span>
</button>
</div>
<!-- Кнопка серий для мобильных экранов -->
<mobile-button
id="toggle-panel-mobile"
class="size-12 flex items-center justify-center block sm:hidden fixed bottom-4 shadow-[0px_10px_25px_rgba(0,0,0,0.15)] border border-special-lighter right-4 z-50 rounded-lg bg-bgColor text-accent-base hover:text-bg-accent-base/90"
aria-label="Toggle Series Panel"
>
<span class="absolute inset-0 rounded-lg flex items-center justify-center bg-special-light hover:text-accent-base/90">
<Icon class="size-8" name="solar:notes-bold"/>
</span>
</mobile-button>
<!-- Кнопка таблицы контента для мобильных экранов -->
<!--
<mobile-button
id="toggle-toc-mobile"
class="size-12 flex items-center justify-center block sm:hidden fixed bottom-20 shadow-[0px_10px_25px_rgba(0,0,0,0.15)] border border-special-lighter right-4 z-50 rounded-lg bg-bgColor text-accent-base hover:text-bg-accent-base/90"
aria-label="Toggle TOC Panel"
>
<span class="absolute inset-0 rounded-lg flex items-center justify-center bg-special-light hover:text-accent-base/90">
<Icon class="size-8" name="solar:clipboard-list-bold"/>
</span>
</mobile-button>
-->
</BaseLayout>
<script>
const ANIMATION_DURATION = 300;
// Получаем элементы кнопок и панели
const togglePanelBtn = document.getElementById("toggle-panel");
const closePanelBtn = document.getElementById("close-panel");
const seriesPanel = document.getElementById("series-panel");
const togglePanelMobileBtn = document.getElementById("toggle-panel-mobile");
// Убедимся, что seriesPanel существует
if (!seriesPanel) {
console.error("Элемент series-panel не найден");
throw new Error("series-panel is required");
}
// Функция для проверки, видима ли панель
const isPanelVisible = () => {
const isScreenLg = window.matchMedia("(min-width: 1024px)").matches; // Большие экраны
return (
(isScreenLg && seriesPanel.classList.contains("lg:block")) ||
(!isScreenLg && !seriesPanel.classList.contains("hidden"))
);
};
// Функция для скрытия панели
const hidePanel = () => {
seriesPanel.classList.add("opacity-0", "-translate-x-full");
setTimeout(() => {
seriesPanel.classList.remove("block", "lg:block");
seriesPanel.classList.add("hidden");
}, ANIMATION_DURATION);
};
// Функция для показа панели
const showPanel = () => {
seriesPanel.classList.remove("hidden");
seriesPanel.classList.add("block", "lg:block");
setTimeout(() => {
seriesPanel.classList.remove("opacity-0", "-translate-x-full");
}, 10);
};
// Общий обработчик для обеих кнопок
const togglePanel = () => {
if (isPanelVisible()) {
hidePanel(); // Если видима, скрываем
} else {
showPanel(); // Если скрыта, показываем
}
};
// Обработчики событий для обеих кнопок
if (togglePanelBtn) {
togglePanelBtn.addEventListener("click", togglePanel);
} else {
console.error("Элемент toggle-panel не найден");
}
if (togglePanelMobileBtn) {
togglePanelMobileBtn.addEventListener("click", togglePanel);
} else {
console.error("Элемент toggle-panel-mobile не найден");
}
if (closePanelBtn) {
closePanelBtn.addEventListener("click", hidePanel);
} else {
console.error("Элемент close-panel не найден");
}
</script>
<script>
// Получаем элементы кнопок и панели
const toggleTocBtn = document.getElementById('toggle-toc');
const closeTocBtn = document.getElementById('close-toc');
const tocPanel = document.getElementById('toc-panel');
const toggleTocMobileBtn = document.getElementById("toggle-toc-mobile");
// Проверяем наличие панели (обязательно для работы)
if (!tocPanel) {
console.error('Элемент toc-panel не найден');
throw new Error('toc-panel is required');
}
// Функция для проверки, видима ли таблица контента
const isTocVisible = () => {
const isScreenMd = window.matchMedia("(min-width: 768px)").matches;
return (
(isScreenMd && tocPanel.classList.contains("md:block")) ||
(!isScreenMd && !tocPanel.classList.contains("hidden"))
);
};
// Функция для скрытия панели
const hideToc = () => {
tocPanel.classList.add('hidden');
tocPanel.classList.remove('md:block');
};
// Функция для показа панели
const showToc = () => {
tocPanel.classList.remove('hidden');
tocPanel.classList.add('md:block');
};
// Общий обработчик для обеих кнопок
const toggleToc = () => {
if (isTocVisible()) {
hideToc(); // Если видима, скрываем
} else {
showToc(); // Если скрыта, показываем
}
};
// Добавляем обработчик для кнопки переключения
if (toggleTocBtn) {
toggleTocBtn.addEventListener("click", toggleToc);
} else {
console.error('Элемент toggle-toc не найден');
}
// Добавляем обработчик для кнопки переключения
if (toggleTocMobileBtn) {
toggleTocMobileBtn.addEventListener("click", toggleToc);
} else {
console.error('Элемент toggle-toc-mobile не найден');
}
// Добавляем обработчик для кнопки закрытия
if (closeTocBtn) {
closeTocBtn.addEventListener('click', hideToc);
} else {
console.error('Элемент close-toc не найден');
}
</script>
<script>
// Ждем загрузки контента
document.addEventListener("DOMContentLoaded", () => {
const buttonsPanel = document.getElementById("buttons-panel");
if (buttonsPanel) {
buttonsPanel.classList.add("fixed");
console.log("Класс 'fixed' добавлен к элементу buttons-panel.");
} else {
console.error("Элемент с ID 'buttons-panel' не найден.");
}
});
</script>
<script>
const scrollBtn = document.getElementById("to-top-button") as HTMLButtonElement;
const targetHeader = document.querySelector("header") as HTMLElement;
function callback(entries: IntersectionObserverEntry[]) {
entries.forEach((entry) => {
// Show the scroll to top button when the <header> is out of view
scrollBtn.dataset.show = (!entry.isIntersecting).toString();
});
}
scrollBtn.addEventListener("click", () => {
document.documentElement.scrollTo({ behavior: "smooth", top: 0 });
});
const observer = new IntersectionObserver(callback);
observer.observe(targetHeader);
</script>

13
src/pages/404.astro Normal file
View File

@@ -0,0 +1,13 @@
---
import PageLayout from "@/layouts/Base.astro";
const meta = {
description: "Oops! It looks like this page is lost in space!",
title: "Oops! You found a missing page!",
};
---
<PageLayout meta={meta}>
<h1 class="title mb-6">404 | Oops something went wrong</h1>
<p class="mb-8">Please use the navigation to find your way back</p>
</PageLayout>

39
src/pages/about.astro Normal file
View File

@@ -0,0 +1,39 @@
---
import PageLayout from "@/layouts/Base.astro";
const meta = {
description: "I'm a starter theme for Astro.build",
title: "About",
};
---
<PageLayout meta={meta}>
<h1 class="title mb-6">About</h1>
<div class="prose prose-citrus max-w-none">
<p>
Hi, Im a starter Astro. Im particularly great for getting you started with your own blogging
website.
</p>
<p>Here are my some of my awesome built in features:</p>
<ul class="list-inside list-disc" role="list">
<li>I'm ultra fast as I'm a static site</li>
<li>I'm fully responsive</li>
<li>I come with a light and dark mode</li>
<li>I'm easy to customise and add additional content</li>
<li>I have Tailwind CSS styling</li>
<li>Shiki code syntax highlighting</li>
<li>Satori for auto generating OG images for blog posts</li>
</ul>
<p>
Clone or fork my <a
aria-label="github repository"
class="citrus-link inline-block"
href="https://github.com/artemkutsan/astro-citrus"
rel="noreferrer"
target="_blank">repo</a
> if you like me!
</p>
</div>
</PageLayout>

129
src/pages/index.astro Normal file
View File

@@ -0,0 +1,129 @@
---
import { type CollectionEntry, getCollection } from "astro:content";
import SocialList from "@/components/SocialList.astro";
import PostPreview from "@/components/blog/PostPreview.astro";
import Note from "@/components/note/Note.astro";
import { getAllPosts } from "@/data/post";
import PageLayout from "@/layouts/Base.astro";
import { collectionDateSort } from "@/utils/date";
// Posts
const MAX_POSTS = 10;
const allPosts = await getAllPosts();
const allPostsByDate = allPosts
.sort(collectionDateSort)
.slice(0, MAX_POSTS) as CollectionEntry<"post">[];
// Notes
const MAX_NOTES = 2;
const allNotes = await getCollection("note");
const latestNotes = allNotes.sort(collectionDateSort).slice(0, MAX_NOTES);
---
<PageLayout meta={{ title: "Home" }}>
<!-- Hero section -->
<!-- Background blur -->
<div class="absolute top-0 left-1/2 md:top-[-15%] -ml-[50vw] min-h-screen w-screen pointer-events-none blur-3xl opacity-50 overflow-x-hidden">
<div class="absolute top-[10%] right-[-40%] w-[65%] h-full bg-gradient-to-b from-purple-300 via-blue-300 to-transparent rounded-full opacity-30 dark:opacity-10"></div>
<div class="absolute top-[10%] left-[-40%] w-[65%] h-full bg-gradient-to-b from-blue-300 via-pink-300 to-transparent rounded-full opacity-30 dark:opacity-10"></div>
<div class="absolute top-[-20%] left-[-50%] w-[85%] h-full bg-gradient-to-b from-indigo-300 via-orange-300 to-transparent rounded-full opacity-60 dark:opacity-10"></div>
<div class="absolute top-[-20%] right-[-50%] w-[85%] h-full bg-gradient-to-b from-orange-300 via-indigo-300 to-transparent rounded-full opacity-60 dark:opacity-10"></div>
</div>
<section class="max-w-xl mx-auto relative flex items-center justify-center h-screen -mt-24">
<div class="w-full text-center">
<span class="title text-3xl bg-gradient-to-r from-accent-two/85 via-accent-one/85 to-accent-two/85 dark:from-accent-two dark:via-accent-one dark:to-accent-two bg-clip-text text-transparent">
Introducing Astro Citrus!
</span>
<p class="mt-4 mb-4 text-lg font-medium">
Hi, Im an Astro theme for building websites or blogs. To customize, add posts, or make it yours, click the GitHub icon below to visit my repo.
</p>
<div class="flex justify-center mb-4">
<SocialList />
</div>
<div class="flex justify-center space-x-4 mt-4">
<a href="/posts/" class="relative flex items-center justify-center h-8 px-4 rounded-lg shadow-lg hover:brightness-110 transition-all bg-gradient-to-r from-accent-one to-accent-two">
<span class="text-bgColor font-semibold">
Read Blog
</span>
</a>
<a href="/notes/wake-up/" class="relative flex items-center justify-center h-8 px-4 rounded-lg shadow-lg bg-special-lighter hover:brightness-110 hover:bg-special-lightest">
<span class="bg-clip-text text-transparent font-semibold bg-gradient-to-r from-accent-one to-accent-two">
Wake up
</span>
</a>
</div>
</div>
</section>
<!-- Posts Section -->
<section aria-label="Blog post list" class="'mt-[-100vh] pt-[100vh]'">
<h2 class="title mb-6 text-xl text-accent-two">
<a href="/posts/">Posts</a>
</h2>
<ul class="space-y-4 md:space-y-2" role="list">
{
allPostsByDate.map((p) => (
<li class="gap-2 sm:grid-cols-[auto_1fr]">
<PostPreview post={p} />
</li>
))
}
</ul>
</section>
<!-- Notes Section -->
{
latestNotes.length > 0 && (
<section class="mt-12">
<h2 class="title mb-6 text-accent-two">
<a href="/notes/">Notes</a>
</h2>
<div class="grid grid-cols-1 gap-8 sm:grid-cols-2">
{
latestNotes.map((note) => (
<div>
<Note note={note} as="h4" isPreview />
</div>
))
}
</div>
</section>
)
}
<!-- Debug -->
<!--
<div class="flex justify-between flex-wrap gap-y-1.5 mt-6 text-sm font-medium">
<div class="text-center content-center h-10 w-10 bg-color-50"><span class="text-textColor">50</span></div>
<div class="text-center content-center h-10 w-10 bg-color-75"><span class="text-textColor">75</span></div>
<div class="text-center content-center h-10 w-10 bg-color-100"><span class="text-textColor">100</span></div>
<div class="text-center content-center h-10 w-10 bg-color-150"><span class="text-textColor">150</span></div>
<div class="text-center content-center h-10 w-10 bg-color-200"><span class="text-textColor">200</span></div>
<div class="text-center content-center h-10 w-10 bg-color-250"><span class="text-textColor">250</span></div>
<div class="text-center content-center h-10 w-10 bg-color-300"><span class="text-textColor">300</span></div>
<div class="text-center content-center h-10 w-10 bg-color-350"><span class="text-textColor">350</span></div>
<div class="text-center content-center h-10 w-10 bg-color-400">
<span class="text-textColor">400</span>
<span class="text-bgColor">400</span>
</div>
<div class="text-center content-center h-10 w-10 bg-color-450">
<span class="text-textColor">450</span>
<span class="text-bgColor">450</span>
</div>
<div class="text-center content-center h-10 w-10 bg-color-500">
<span class="text-textColor">500</span>
<span class="text-bgColor">500</span>
</div>
<div class="text-center content-center h-10 w-10 bg-color-550"><span class="text-bgColor">550</span></div>
<div class="text-center content-center h-10 w-10 bg-color-600"><span class="text-bgColor">600</span></div>
<div class="text-center content-center h-10 w-10 bg-color-650"><span class="text-bgColor">650</span></div>
<div class="text-center content-center h-10 w-10 bg-color-700"><span class="text-bgColor">700</span></div>
<div class="text-center content-center h-10 w-10 bg-color-750"><span class="text-bgColor">750</span></div>
<div class="text-center content-center h-10 w-10 bg-color-800"><span class="text-bgColor">800</span></div>
<div class="text-center content-center h-10 w-10 bg-color-850"><span class="text-bgColor">850</span></div>
<div class="text-center content-center h-10 w-10 bg-color-900"><span class="text-bgColor">900</span></div>
</div>
-->
</PageLayout>

View File

@@ -0,0 +1,65 @@
---
import { type CollectionEntry, getCollection } from "astro:content";
import Pagination from "@/components/Paginator.astro";
import Note from "@/components/note/Note.astro";
import PageLayout from "@/layouts/Base.astro";
import { collectionDateSort } from "@/utils/date";
import type { GetStaticPaths, Page } from "astro";
import { Icon } from "astro-icon/components";
export const getStaticPaths = (async ({ paginate }) => {
const MAX_NOTES_PER_PAGE = 5;
const allNotes = await getCollection("note");
return paginate(allNotes.sort(collectionDateSort), { pageSize: MAX_NOTES_PER_PAGE });
}) satisfies GetStaticPaths;
interface Props {
page: Page<CollectionEntry<"note">>;
uniqueTags: string[];
}
const { page } = Astro.props;
const meta = {
description: "Read my collection of notes",
title: "Notes",
};
const paginationProps = {
...(page.url.prev && {
prevUrl: {
text: "← Previous Page",
url: page.url.prev,
},
}),
...(page.url.next && {
nextUrl: {
text: "Next Page →",
url: page.url.next,
},
}),
};
---
<PageLayout meta={meta}>
<section>
<h1 class="title mb-6 flex items-center gap-2">
Notes
<a class="text-accent-two" href="/notes/rss.xml" target="_blank">
<span class="sr-only">RSS feed</span>
<Icon aria-hidden="true" class="h-6 w-6" focusable="false" name="mdi:rss" />
</a>
</h1>
<div class="grid grid-cols-1 gap-8 md:grid-cols-2">
{
page.data.map((note) => (
<div>
<Note note={note} as="h4" isPreview />
</div>
))
}
</div>
<Pagination {...paginationProps} />
</section>
</PageLayout>

View File

@@ -0,0 +1,64 @@
---
import { getCollection } from "astro:content";
import Note from "@/components/note/Note.astro";
import PageLayout from "@/layouts/Base.astro";
import type { GetStaticPaths, InferGetStaticPropsType } from "astro";
// If you're using an adaptor in SSR mode, getStaticPaths wont work -> https://docs.astro.build/en/guides/routing/#modifying-the-slug-example-for-ssr
export const getStaticPaths = (async () => {
const allNotes = await getCollection("note");
return allNotes.map((note) => ({
params: { slug: note.id },
props: { note },
}));
}) satisfies GetStaticPaths;
export type Props = InferGetStaticPropsType<typeof getStaticPaths>;
const { note } = Astro.props;
const meta = {
description:
note.data.description ||
`Read about my note posted on: ${note.data.publishDate.toLocaleDateString()}`,
title: note.data.title,
};
---
<PageLayout meta={meta}>
<div class="fixed left-0 top-0 z-10 flex h-16 md:h-20 w-full bg-bgColor overflow-hidden">
<!-- Background
TODO: This approach is not optimal and requires improvements.
- Too many absolutely positioned elements can affect performance.
-->
<div class="absolute top-0 left-1/2 -ml-[50vw] w-screen min-h-screen pointer-events-none 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>
</div>
<Note as="h1" note={note} />
</PageLayout>
<script>
// Wait for the content to fully load
document.addEventListener("DOMContentLoaded", () => {
const buttonsPanel = document.getElementById("buttons-panel");
if (buttonsPanel) {
buttonsPanel.classList.add("fixed");
console.log("Class 'fixed' added to the buttons-panel element.");
} else {
console.error("Element with ID 'buttons-panel' not found.");
}
});
</script>

View File

@@ -0,0 +1,18 @@
import { getCollection } from "astro:content";
import { siteConfig } from "@/site.config";
import rss from "@astrojs/rss";
export const GET = async () => {
const notes = await getCollection("note");
return rss({
title: siteConfig.title,
description: siteConfig.description,
site: import.meta.env.SITE,
items: notes.map((note) => ({
title: note.data.title,
pubDate: note.data.publishDate,
link: `notes/${note.id}/`,
})),
});
};

View File

@@ -0,0 +1,174 @@
import SFProRoundedBold from "@/assets/fonts/SF-Pro-Rounded-Bold.latin.base.ttf";
import SFProRoundedSemibold from "@/assets/fonts/SF-Pro-Rounded-Semibold.latin.base.ttf";
import SFProRoundedMedium from "@/assets/fonts/SF-Pro-Rounded-Medium.latin.base.ttf";
import SFProRoundedRegular from "@/assets/fonts/SF-Pro-Rounded-Regular.latin.base.ttf";
import { getAllPosts } from "@/data/post";
import { siteConfig } from "@/site.config";
import { getFormattedDate } from "@/utils/date";
import { Resvg } from "@resvg/resvg-js";
import type { APIContext, InferGetStaticPropsType } from "astro";
import satori, { type SatoriOptions } from "satori";
import { html } from "satori-html";
const ogOptions: SatoriOptions = {
// debug: true,
fonts: [
{
data: Buffer.from(SFProRoundedRegular),
name: "SF Pro Rounded",
style: "normal",
weight: 400,
},
{
data: Buffer.from(SFProRoundedMedium),
name: "SF Pro Rounded",
style: "normal",
weight: 500,
},
{
data: Buffer.from(SFProRoundedSemibold),
name: "SF Pro Rounded",
style: "normal",
weight: 600,
},
{
data: Buffer.from(SFProRoundedBold),
name: "SF Pro Rounded",
style: "normal",
weight: 700,
},
],
height: 630,
width: 1200,
};
const markup = (title: string, pubDate: string) =>
html` <div tw="flex flex-col w-full h-full bg-[#f2f2f2] text-[#6b6b6b]">
<div tw="flex flex-col flex-1 w-full p-10 justify-center">
<p tw="text-3xl mb-6 text-[#8e8e8e] font-medium">${pubDate}</p>
<h1 tw="text-6xl font-semibold leading-snug text-[#224d67]">${title}</h1>
</div>
<div
tw="flex items-end justify-between w-full p-10 border-t border-[#dbdbdb] text-3xl text-[#6b6b6b]"
>
<div tw="flex items-center">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 128 128"
width="64"
height="64"
>
<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>
<p tw="ml-3 text-5xl text-[#545454] font-bold">${siteConfig.title}</p>
</div>
<p tw="text-3xl text-[#8e8e8e]">by ${siteConfig.author}</p>
</div>
</div>`;
type Props = InferGetStaticPropsType<typeof getStaticPaths>;
export async function GET(context: APIContext) {
const { pubDate, title } = context.props as Props;
const postDate = getFormattedDate(pubDate, {
month: "long",
weekday: "long",
});
const svg = await satori(markup(title, postDate), ogOptions);
// Проверяем, запрашивает ли пользователь PNG
if (context.url.pathname.endsWith(".png")) {
const png = new Resvg(svg).render().asPng();
return new Response(png, {
headers: {
"Cache-Control": "public, max-age=31536000, immutable",
"Content-Type": "image/png",
},
});
}
// Проверяем, запрашивает ли пользователь SVG
if (context.url.pathname.endsWith(".svg")) {
return new Response(svg, {
headers: {
"Cache-Control": "public, max-age=31536000",
"Content-Type": "image/svg+xml; charset=utf-8",
},
});
}
// Если запрос не заканчивается на .png или .svg, возвращаем ошибку
return new Response("Unsupported format", { status: 400 });
}
export async function getStaticPaths() {
const posts = await getAllPosts();
return posts
.filter(({ data }) => !data.ogImage)
.flatMap((post) => {
return [
{
params: { slug: post.id, ext: "png" },
props: {
pubDate: post.data.updatedDate ?? post.data.publishDate,
title: post.data.title,
},
},
{
params: { slug: post.id, ext: "svg" },
props: {
pubDate: post.data.updatedDate ?? post.data.publishDate,
title: post.data.title,
},
},
];
});
}

View File

@@ -0,0 +1,123 @@
---
import type { CollectionEntry } from "astro:content";
import Pagination from "@/components/Paginator.astro";
import PostPreview from "@/components/blog/PostPreview.astro";
import { getAllPosts, getUniqueTags, groupPostsByYear } from "@/data/post";
import PageLayout from "@/layouts/Base.astro";
import { collectionDateSort } from "@/utils/date";
import type { GetStaticPaths, Page } from "astro";
import { Icon } from "astro-icon/components";
import Badge from "@/components/Badge.astro";
export const getStaticPaths = (async ({ paginate }) => {
const MAX_POSTS_PER_PAGE = 5;
const MAX_TAGS = 20;
const allPosts = await getAllPosts();
const uniqueTags = getUniqueTags(allPosts).slice(0, MAX_TAGS);
return paginate(allPosts.sort(collectionDateSort), {
pageSize: MAX_POSTS_PER_PAGE,
props: { uniqueTags },
});
}) satisfies GetStaticPaths;
interface Props {
page: Page<CollectionEntry<"post">>;
uniqueTags: string[];
}
const { page, uniqueTags } = Astro.props;
const meta = {
description: "Read my collection of posts and the things that interest me",
title: "Posts",
};
const paginationProps = {
...(page.url.prev && {
prevUrl: {
text: "← Previous Page",
url: page.url.prev,
},
}),
...(page.url.next && {
nextUrl: {
text: "Next Page →",
url: page.url.next,
},
}),
};
const groupedByYear = groupPostsByYear(page.data);
const descYearKeys = Object.keys(groupedByYear).sort((a, b) => +b - +a);
---
<PageLayout meta={meta}>
<h1 class="title mb-6 flex items-center gap-2">
Posts
<a class="text-accent-two" href="/rss.xml" target="_blank">
<span class="sr-only">RSS feed</span>
<Icon aria-hidden="true" class="h-6 w-6" focusable="false" name="mdi:rss" />
</a>
</h1>
<div class="grid gap-y-8 sm:grid-cols-[1fr_auto] sm:gap-x-8">
<section aria-label="Blog post list" class="grow">
{
descYearKeys.map((yearKey) => (
<>
<h4 class="title">{yearKey}</h4>
<ul class="mb-8 mt-4 space-y-8 text-start">
{groupedByYear[yearKey]?.map((p) => (
<li class="grid gap-2 sm:grid-cols-[auto_1fr] sm:[&_q]:col-start-2">
<PostPreview post={p} withDesc={true} />
</li>
))}
</ul>
</>
))
}
</section>
{
!!uniqueTags.length && (
<aside class="md:min-w-[14rem] md:max-w-[14rem] md:basis-[14rem]">
<h4 class="title mb-4 flex gap-2">
Tags
{/*
<Icon aria-hidden="true" class="mb-1 h-6 w-6" focusable="false" name="hugeicons:tags" />
*/}
</h4>
<ul class="flex flex-wrap gap-2">
{uniqueTags.map((tag) => (
<li>
<a aria-label={`View all posts with the tag: ${tag}`} href={`/tags/${tag}`}>
<Badge variant="muted" title={tag} />
</a>
</li>
))}
<span class="text-base ms-auto inline-flex items-center h-6 sm:text-end">
<a
aria-label="View all blog categories"
class="font-medium text-accent sm:hover:text-accent-two"
href="/tags/"
>
View all →
</a>
</span>
</ul>
{/*
<span class="mt-4 block sm:text-end">
<a
aria-label="View all blog categories"
class="font-medium text-accent sm:hover:text-accent-two"
href="/tags/"
>
View all →
</a>
</span>
*/}
</aside>
)
}
</div>
<Pagination {...paginationProps} />
</PageLayout>

View File

@@ -0,0 +1,24 @@
---
import { render } from "astro:content";
import { getAllPosts } from "@/data/post";
import PostLayout from "@/layouts/BlogPost.astro";
import type { GetStaticPaths, InferGetStaticPropsType } from "astro";
// If you're using an adaptor in SSR mode, getStaticPaths wont work -> https://docs.astro.build/en/guides/routing/#modifying-the-slug-example-for-ssr
export const getStaticPaths = (async () => {
const blogEntries = await getAllPosts();
return blogEntries.map((post) => ({
params: { slug: post.id },
props: { post },
}));
}) satisfies GetStaticPaths;
type Props = InferGetStaticPropsType<typeof getStaticPaths>;
const { post } = Astro.props;
const { Content } = await render(post);
---
<PostLayout post={post}>
<Content />
</PostLayout>

19
src/pages/rss.xml.ts Normal file
View File

@@ -0,0 +1,19 @@
import { getAllPosts } from "@/data/post";
import { siteConfig } from "@/site.config";
import rss from "@astrojs/rss";
export const GET = async () => {
const posts = await getAllPosts();
return rss({
title: siteConfig.title,
description: siteConfig.description,
site: import.meta.env.SITE,
items: posts.map((post) => ({
title: post.data.title,
description: post.data.description,
pubDate: post.data.publishDate,
link: `posts/${post.id}/`,
})),
});
};

View File

@@ -0,0 +1,24 @@
---
import { getCollection, type CollectionEntry, render } from "astro:content";
import SeriesLayout from "@/layouts/Series.astro";
import type { GetStaticPaths, InferGetStaticPropsType } from "astro";
// If you're using an adaptor in SSR mode, getStaticPaths wont work -> https://docs.astro.build/en/guides/routing/#modifying-the-slug-example-for-ssr
export const getStaticPaths = (async () => {
const allSeries = await getCollection("series");
return allSeries.map((series) => ({
params: { slug: series.id },
props: { series },
}));
}) satisfies GetStaticPaths;
type Props = InferGetStaticPropsType<typeof getStaticPaths>;
const { series } = Astro.props;
const { Content } = await render(series);
---
<SeriesLayout series={series}>
<Content />
</SeriesLayout>

View File

@@ -0,0 +1,70 @@
---
import type { CollectionEntry } from "astro:content";
import Pagination from "@/components/Paginator.astro";
import PostPreview from "@/components/blog/PostPreview.astro";
import { getAllPosts, getUniqueTags } from "@/data/post";
import PageLayout from "@/layouts/Base.astro";
import { collectionDateSort } from "@/utils/date";
import type { GetStaticPaths, Page } from "astro";
export const getStaticPaths: GetStaticPaths = async ({ paginate }) => {
const allPosts = await getAllPosts();
const sortedPosts = allPosts.sort(collectionDateSort);
const uniqueTags = getUniqueTags(sortedPosts);
return uniqueTags.flatMap((tag) => {
const filterPosts = sortedPosts.filter((post) => post.data.tags.includes(tag));
return paginate(filterPosts, {
pageSize: 10,
params: { tag },
});
});
};
interface Props {
page: Page<CollectionEntry<"post">>;
}
const { page } = Astro.props;
const { tag } = Astro.params;
const meta = {
description: `View all posts with the tag - ${tag}`,
title: `Tag: ${tag}`,
};
const paginationProps = {
...(page.url.prev && {
prevUrl: {
text: "← Previous Tags",
url: page.url.prev,
},
}),
...(page.url.next && {
nextUrl: {
text: "Next Tags →",
url: page.url.next,
},
}),
};
---
<PageLayout meta={meta}>
<h1 class="title mb-6 flex items-center">
<a class="text-accent-two sm:hover:underline" href="/tags/">Tags</a>
<span class="me-3 ms-2">→</span>
<span class="text-2xl">#{tag}</span>
</h1>
<section aria-label="Blog post list">
<ul class="space-y-4">
{
page.data.map((p) => (
<li class="grid-cols-[auto_1fr]">
<PostPreview post={p} />
</li>
))
}
</ul>
<Pagination {...paginationProps} />
</section>
</PageLayout>

View File

@@ -0,0 +1,58 @@
---
import Badge from "@/components/Badge.astro";
import { getAllPosts, getUniqueTagsWithCount } from "@/data/post";
import PageLayout from "@/layouts/Base.astro";
const allPosts = await getAllPosts();
const allTags = getUniqueTagsWithCount(allPosts);
const meta = {
description: "A list of all the topics I've written about in my posts",
title: "All Tags",
};
---
<PageLayout meta={meta}>
<h1 class="title mb-6">Tags</h1>
<!--
<ul class="space-y-4">
{
allTags.map(([tag, val]) => (
<li class="flex items-center gap-x-2">
<a
class="citrus-link inline-block"
data-astro-prefetch
href={`/tags/${tag}/`}
title={`View posts with the tag: ${tag}`}
>
&#35;{tag}
</a>
<a aria-label={`View all posts with the tag: ${tag}`} href={`/tags/${tag}`}>
<Badge variant="accent-two" title={tag}>
{tag}
</Badge>
</a>
<span class="inline-block">
- {val} Post{val > 1 && "s"}
</span>
</li>
))
}
</ul>
-->
<div class="flex flex-wrap items-center gap-2">
{
allTags.map(([tag, val]) => (
<div class="flex items-center gap-x-2">
<a aria-label={`View all posts with the tag: ${tag}`} href={`/tags/${tag}`}>
<Badge variant="muted" title={tag}>
<span class="text-xs font-normal">
&nbsp;{val} post{val > 1 && "s"}
</span>
</Badge>
</a>
</div>
))
}
</div>
</PageLayout>

View File

@@ -0,0 +1,98 @@
import type { AdmonitionType } from "@/types";
import { type Properties, h as _h } from "hastscript";
import type { Node, Paragraph as P, Parent, PhrasingContent, Root } from "mdast";
import type { Directives, LeafDirective, TextDirective } from "mdast-util-directive";
import { directiveToMarkdown } from "mdast-util-directive";
import { toMarkdown } from "mdast-util-to-markdown";
import { toString as mdastToString } from "mdast-util-to-string";
import type { Plugin } from "unified";
import { visit } from "unist-util-visit";
// Supported admonition types
const Admonitions = new Set<AdmonitionType>(["tip", "note", "important", "caution", "warning"]);
/** Checks if a string is a supported admonition type. */
function isAdmonition(s: string): s is AdmonitionType {
return Admonitions.has(s as AdmonitionType);
}
/** Checks if a node is a directive. */
function isNodeDirective(node: Node): node is Directives {
return (
node.type === "containerDirective" ||
node.type === "leafDirective" ||
node.type === "textDirective"
);
}
/**
* From Astro Starlight:
* Transforms directives not supported back to original form as it can break user content and result in 'broken' output.
*/
function transformUnhandledDirective(
node: LeafDirective | TextDirective,
index: number,
parent: Parent,
) {
const textNode = {
type: "text",
value: toMarkdown(node, { extensions: [directiveToMarkdown()] }),
} as const;
if (node.type === "textDirective") {
parent.children[index] = textNode;
} else {
parent.children[index] = {
children: [textNode],
type: "paragraph",
};
}
}
/** From Astro Starlight: Function that generates an mdast HTML tree ready for conversion to HTML by rehype. */
// biome-ignore lint/suspicious/noExplicitAny: <explanation>
function h(el: string, attrs: Properties = {}, children: any[] = []): P {
const { properties, tagName } = _h(el, attrs);
return {
children,
data: { hName: tagName, hProperties: properties },
type: "paragraph",
};
}
export const remarkAdmonitions: Plugin<[], Root> = () => (tree) => {
visit(tree, (node, index, parent) => {
if (!parent || index === undefined || !isNodeDirective(node)) return;
if (node.type === "textDirective" || node.type === "leafDirective") {
transformUnhandledDirective(node, index, parent);
return;
}
const admonitionType = node.name;
if (!isAdmonition(admonitionType)) return;
let title: string = admonitionType;
let titleNode: PhrasingContent[] = [{ type: "text", value: title }];
// Check if there's a custom title
const firstChild = node.children[0];
if (
firstChild?.type === "paragraph" &&
firstChild.data &&
"directiveLabel" in firstChild.data &&
firstChild.children.length > 0
) {
titleNode = firstChild.children;
title = mdastToString(firstChild.children);
// The first paragraph contains a custom title, we can safely remove it.
node.children.splice(0, 1);
}
// Do not change prefix to AD, ADM, or similar, adblocks will block the content inside.
const aside = h("aside", { "aria-label": title, class: `aside aside-${admonitionType}` }, [
h("p", { class: "aside-title", "aria-hidden": "true" }, [...titleNode]),
h("div", { class: "aside-content" }, node.children),
]);
parent.children[index] = aside;
});
};

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