Initial commit

This commit is contained in:
bufferstack 2024-12-25 02:14:10 -05:00
commit 383f8bc667
91 changed files with 9972 additions and 0 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 169 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 158 KiB

211
src/assets/socialIcons.ts Normal file
View file

@ -0,0 +1,211 @@
const socialIcons = {
Github: `<svg
xmlns="http://www.w3.org/2000/svg"
class="icon-tabler"
stroke-linecap="round"
stroke-linejoin="round"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path
d="M9 19c-4.3 1.4 -4.3 -2.5 -6 -3m12 5v-3.5c0 -1 .1 -1.4 -.5 -2c2.8 -.3 5.5 -1.4 5.5 -6a4.6 4.6 0 0 0 -1.3 -3.2a4.2 4.2 0 0 0 -.1 -3.2s-1.1 -.3 -3.5 1.3a12.3 12.3 0 0 0 -6.2 0c-2.4 -1.6 -3.5 -1.3 -3.5 -1.3a4.2 4.2 0 0 0 -.1 3.2a4.6 4.6 0 0 0 -1.3 3.2c0 4.6 2.7 5.7 5.5 6c-.6 .6 -.6 1.2 -.5 2v3.5"
></path>
</svg>`,
Facebook: `<svg
xmlns="http://www.w3.org/2000/svg"
class="icon-tabler"
stroke-linecap="round"
stroke-linejoin="round"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path
d="M7 10v4h3v7h4v-7h3l1 -4h-4v-2a1 1 0 0 1 1 -1h3v-4h-3a5 5 0 0 0 -5 5v2h-3"
></path>
</svg>`,
Instagram: `<svg
xmlns="http://www.w3.org/2000/svg"
class="icon-tabler"
stroke-linecap="round"
stroke-linejoin="round"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<rect x="4" y="4" width="16" height="16" rx="4"></rect>
<circle cx="12" cy="12" r="3"></circle>
<line x1="16.5" y1="7.5" x2="16.5" y2="7.501"></line>
</svg>`,
LinkedIn: `<svg
xmlns="http://www.w3.org/2000/svg"
class="icon-tabler"
stroke-linecap="round"
stroke-linejoin="round"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<rect x="4" y="4" width="16" height="16" rx="2"></rect>
<line x1="8" y1="11" x2="8" y2="16"></line>
<line x1="8" y1="8" x2="8" y2="8.01"></line>
<line x1="12" y1="16" x2="12" y2="11"></line>
<path d="M16 16v-3a2 2 0 0 0 -4 0"></path>
</svg>`,
Mail: `<svg
xmlns="http://www.w3.org/2000/svg"
class="icon-tabler"
stroke-linecap="round"
stroke-linejoin="round"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<rect x="3" y="5" width="18" height="14" rx="2"></rect>
<polyline points="3 7 12 13 21 7"></polyline>
</svg>`,
X: `<svg
xmlns="http://www.w3.org/2000/svg"
class="icon-tabler"
stroke-linecap="round"
stroke-linejoin="round"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
<path d="M4 4l11.733 16h4.267l-11.733 -16z" /><path d="M4 20l6.768 -6.768m2.46 -2.46l6.772 -6.772" />
</svg>`,
Twitch: `<svg
xmlns="http://www.w3.org/2000/svg"
class="icon-tabler"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M21 2H3v16h5v4l4-4h5l4-4V2zm-10 9V7m5 4V7"></path>
</svg>`,
YouTube: `<svg
xmlns="http://www.w3.org/2000/svg"
class="icon-tabler"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M22.54 6.42a2.78 2.78 0 0 0-1.94-2C18.88 4 12 4 12 4s-6.88 0-8.6.46a2.78 2.78 0 0 0-1.94 2A29 29 0 0 0 1 11.75a29 29 0 0 0 .46 5.33A2.78 2.78 0 0 0 3.4 19c1.72.46 8.6.46 8.6.46s6.88 0 8.6-.46a2.78 2.78 0 0 0 1.94-2 29 29 0 0 0 .46-5.25 29 29 0 0 0-.46-5.33z"></path>
<polygon points="9.75 15.02 15.5 11.75 9.75 8.48 9.75 15.02"></polygon>
</svg>`,
WhatsApp: `<svg
xmlns="http://www.w3.org/2000/svg"
class="icon-tabler"
stroke-linecap="round"
stroke-linejoin="round"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path d="M3 21l1.65 -3.8a9 9 0 1 1 3.4 2.9l-5.05 .9"></path>
<path d="M9 10a0.5 .5 0 0 0 1 0v-1a0.5 .5 0 0 0 -1 0v1a5 5 0 0 0 5 5h1a0.5 .5 0 0 0 0 -1h-1a0.5 .5 0 0 0 0 1"></path>
</svg>`,
Snapchat: `<svg
xmlns="http://www.w3.org/2000/svg"
class="icon-tabler"
stroke-linecap="round"
stroke-linejoin="round"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path d="M16.882 7.842a4.882 4.882 0 0 0 -9.764 0c0 4.273 -.213 6.409 -4.118 8.118c2 .882 2 .882 3 3c3 0 4 2 6 2s3 -2 6 -2c1 -2.118 1 -2.118 3 -3c-3.906 -1.709 -4.118 -3.845 -4.118 -8.118zm-13.882 8.119c4 -2.118 4 -4.118 1 -7.118m17 7.118c-4 -2.118 -4 -4.118 -1 -7.118"></path>
</svg>`,
Pinterest: `<svg
xmlns="http://www.w3.org/2000/svg"
class="icon-tabler"
stroke-linecap="round"
stroke-linejoin="round"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<line x1="8" y1="20" x2="12" y2="11"></line>
<path d="M10.7 14c.437 1.263 1.43 2 2.55 2c2.071 0 3.75 -1.554 3.75 -4a5 5 0 1 0 -9.7 1.7"></path>
<circle cx="12" cy="12" r="9"></circle>
</svg>`,
TikTok: `<svg
xmlns="http://www.w3.org/2000/svg"
class="icon-tabler"
stroke-linecap="round"
stroke-linejoin="round"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path d="M9 12a4 4 0 1 0 4 4v-12a5 5 0 0 0 5 5"></path>
</svg>`,
CodePen: `<svg
xmlns="http://www.w3.org/2000/svg"
class="icon-tabler"
stroke-linecap="round"
stroke-linejoin="round"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path d="M3 15l9 6l9 -6l-9 -6l-9 6"></path>
<path d="M3 9l9 6l9 -6l-9 -6l-9 6"></path>
<line x1="3" y1="9" x2="3" y2="15"></line>
<line x1="21" y1="9" x2="21" y2="15"></line>
<line x1="12" y1="3" x2="12" y2="9"></line>
<line x1="12" y1="15" x2="12" y2="21"></line>
</svg>`,
Discord: `<svg
xmlns="http://www.w3.org/2000/svg"
class="icon-tabler"
stroke-linecap="round"
stroke-linejoin="round"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<circle cx="9" cy="12" r="1"></circle>
<circle cx="15" cy="12" r="1"></circle>
<path d="M7.5 7.5c3.5 -1 5.5 -1 9 0"></path>
<path d="M7 16.5c3.5 1 6.5 1 10 0"></path>
<path d="M15.5 17c0 1 1.5 3 2 3c1.5 0 2.833 -1.667 3.5 -3c.667 -1.667 .5 -5.833 -1.5 -11.5c-1.457 -1.015 -3 -1.34 -4.5 -1.5l-1 2.5"></path>
<path d="M8.5 17c0 1 -1.356 3 -1.832 3c-1.429 0 -2.698 -1.667 -3.333 -3c-.635 -1.667 -.476 -5.833 1.428 -11.5c1.388 -1.015 2.782 -1.34 4.237 -1.5l1 2.5"></path>
</svg>`,
GitLab: `<svg
xmlns="http://www.w3.org/2000/svg"
class="icon-tabler"
stroke-linecap="round"
stroke-linejoin="round"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path d="M21 14l-9 7l-9 -7l3 -11l3 7h6l3 -7z"></path>
</svg>`,
Reddit: `<svg
xmlns="http://www.w3.org/2000/svg"
class="icon-tabler"
stroke-linecap="round"
stroke-linejoin="round"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path d="M12 8c2.648 0 5.028 .826 6.675 2.14a2.5 2.5 0 0 1 2.326 4.36c0 3.59 -4.03 6.5 -9 6.5c-4.875 0 -8.845 -2.8 -9 -6.294l-1 -.206a2.5 2.5 0 0 1 2.326 -4.36c1.646 -1.313 4.026 -2.14 6.674 -2.14z"></path>
<path d="M12 8l1 -5l6 1"></path>
<circle cx="19" cy="4" r="1"></circle>
<circle cx="9" cy="13" r=".5" fill="currentColor"></circle>
<circle cx="15" cy="13" r=".5" fill="currentColor"></circle>
<path d="M10 17c.667 .333 1.333 .5 2 .5s1.333 -.167 2 -.5"></path>
</svg>`,
Skype: `<svg
xmlns="http://www.w3.org/2000/svg"
class="icon-tabler"
stroke-linecap="round"
stroke-linejoin="round"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path d="M12 3a9 9 0 0 1 8.603 11.65a4.5 4.5 0 0 1 -5.953 5.953a9 9 0 0 1 -11.253 -11.253a4.5 4.5 0 0 1 5.953 -5.954a8.987 8.987 0 0 1 2.65 -.396z"></path>
<path d="M8 14.5c.5 2 2.358 2.5 4 2.5c2.905 0 4 -1.187 4 -2.5c0 -1.503 -1.927 -2.5 -4 -2.5s-4 -.997 -4 -2.5c0 -1.313 1.095 -2.5 4 -2.5c1.642 0 3.5 .5 4 2.5"></path>
</svg>`,
Steam: `<svg
xmlns="http://www.w3.org/2000/svg"
class="icon-tabler"
stroke-linecap="round"
stroke-linejoin="round"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path d="M16.5 5a4.5 4.5 0 1 1 -.653 8.953l-4.347 3.009l0 .038a3 3 0 0 1 -2.824 2.995l-.176 .005a3 3 0 0 1 -2.94 -2.402l-2.56 -1.098v-3.5l3.51 1.755a2.989 2.989 0 0 1 2.834 -.635l2.727 -3.818a4.5 4.5 0 0 1 4.429 -5.302z"></path>
<circle fill="currentColor" cx="16.5" cy="9.5" r="1"></circle>
</svg>`,
Telegram: `<svg
xmlns="http://www.w3.org/2000/svg"
class="icon-tabler"
stroke-linecap="round"
stroke-linejoin="round"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path d="M15 10l-4 4l6 6l4 -16l-18 7l4 2l2 6l3 -4"></path>
</svg>`,
Mastodon: `<svg class="icon-tabler" viewBox="-10 -5 1034 1034" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1">
<path fill="currentColor"
d="M499 112q-93 1 -166 11q-81 11 -128 33l-14 8q-16 10 -32 25q-22 21 -38 47q-21 33 -32 73q-14 47 -14 103v37q0 77 1 119q3 113 18 188q19 95 62 154q50 67 134 89q109 29 210 24q46 -3 88 -12q30 -7 55 -17l19 -8l-4 -75l-22 6q-28 6 -57 10q-41 6 -78 4q-53 -1 -80 -7
q-43 -8 -67 -30q-29 -25 -35 -72q-2 -14 -2 -29l25 6q31 6 65 10q48 7 93 9q42 2 92 -2q32 -2 88 -9t107 -30q49 -23 81.5 -54.5t38.5 -63.5q9 -45 13 -109q4 -46 5 -97v-41q0 -56 -14 -103q-11 -40 -32 -73q-16 -26 -38 -47q-15 -15 -32 -25q-12 -8 -14 -8
q-46 -22 -127 -33q-74 -10 -166 -11h-3zM367 267q73 0 109 56l24 39l24 -39q36 -56 109 -56q63 0 101 43t38 117v239h-95v-232q0 -74 -61 -74q-69 0 -69 88v127h-94v-127q0 -88 -69 -88q-61 0 -61 74v232h-95v-239q0 -74 38 -117t101 -43z" />
</svg>`,
};
export default socialIcons;

View file

@ -0,0 +1,72 @@
---
// Remove current url path and remove trailing slash if exists
const currentUrlPath = Astro.url.pathname.replace(/\/+$/, "");
// Get url array from path
// eg: /tags/tailwindcss => ['tags', 'tailwindcss']
const breadcrumbList = currentUrlPath.split("/").slice(1);
// if breadcrumb is Home > Posts > 1 <etc>
// replace Posts with Posts (page number)
breadcrumbList[0] === "posts" &&
breadcrumbList.splice(0, 2, `Posts (page ${breadcrumbList[1] || 1})`);
// if breadcrumb is Home > Tags > [tag] > [page] <etc>
// replace [tag] > [page] with [tag] (page number)
breadcrumbList[0] === "tags" &&
!isNaN(Number(breadcrumbList[2])) &&
breadcrumbList.splice(
1,
3,
`${breadcrumbList[1]} ${
Number(breadcrumbList[2]) === 1 ? "" : "(page " + breadcrumbList[2] + ")"
}`
);
---
<nav class="breadcrumb" aria-label="breadcrumb">
<ul>
<li>
<a href="/">Home</a>
<span aria-hidden="true">&raquo;</span>
</li>
{
breadcrumbList.map((breadcrumb, index) =>
index + 1 === breadcrumbList.length ? (
<li>
<span
class={`${index > 0 ? "lowercase" : "capitalize"}`}
aria-current="page"
>
{/* make the last part lowercase in Home > Tags > some-tag */}
{decodeURIComponent(breadcrumb)}
</span>
</li>
) : (
<li>
<a href={`/${breadcrumb}/`}>{breadcrumb}</a>
<span aria-hidden="true">&raquo;</span>
</li>
)
)
}
</ul>
</nav>
<style>
.breadcrumb {
@apply mx-auto mb-1 mt-8 w-full max-w-3xl px-4;
}
.breadcrumb ul li {
@apply inline;
}
.breadcrumb ul li a {
@apply capitalize opacity-70;
}
.breadcrumb ul li span {
@apply opacity-70;
}
.breadcrumb ul li:not(:last-child) a {
@apply hover:opacity-100;
}
</style>

35
src/components/Card.tsx Normal file
View file

@ -0,0 +1,35 @@
import { slugifyStr } from "@utils/slugify";
import Datetime from "./Datetime";
import type { CollectionEntry } from "astro:content";
export interface Props {
href?: string;
frontmatter: CollectionEntry<"blog">["data"];
secHeading?: boolean;
}
export default function Card({ href, frontmatter, secHeading = true }: Props) {
const { title, pubDatetime, modDatetime, description } = frontmatter;
const headerProps = {
style: { viewTransitionName: slugifyStr(title) },
className: "text-lg font-medium decoration-dashed hover:underline",
};
return (
<li className="my-6">
<a
href={href}
className="inline-block text-lg font-medium text-skin-accent decoration-dashed underline-offset-4 focus-visible:no-underline focus-visible:underline-offset-0"
>
{secHeading ? (
<h2 {...headerProps}>{title}</h2>
) : (
<h3 {...headerProps}>{title}</h3>
)}
</a>
<Datetime pubDatetime={pubDatetime} modDatetime={modDatetime} />
<p>{description}</p>
</li>
);
}

120
src/components/Datetime.tsx Normal file
View file

@ -0,0 +1,120 @@
import { LOCALE, SITE } from "@config";
import type { CollectionEntry } from "astro:content";
interface DatetimesProps {
pubDatetime: string | Date;
modDatetime: string | Date | undefined | null;
}
interface EditPostProps {
editPost?: CollectionEntry<"blog">["data"]["editPost"];
postId?: CollectionEntry<"blog">["id"];
}
interface Props extends DatetimesProps, EditPostProps {
size?: "sm" | "lg";
className?: string;
}
export default function Datetime({
pubDatetime,
modDatetime,
size = "sm",
className = "",
editPost,
postId,
}: Props) {
return (
<div
className={`flex items-center space-x-2 opacity-80 ${className}`.trim()}
>
<svg
xmlns="http://www.w3.org/2000/svg"
className={`${
size === "sm" ? "scale-90" : "scale-100"
} inline-block h-6 w-6 min-w-[1.375rem] fill-skin-base`}
aria-hidden="true"
>
<path d="M7 11h2v2H7zm0 4h2v2H7zm4-4h2v2h-2zm0 4h2v2h-2zm4-4h2v2h-2zm0 4h2v2h-2z"></path>
<path d="M5 22h14c1.103 0 2-.897 2-2V6c0-1.103-.897-2-2-2h-2V2h-2v2H9V2H7v2H5c-1.103 0-2 .897-2 2v14c0 1.103.897 2 2 2zM19 8l.001 12H5V8h14z"></path>
</svg>
{modDatetime && modDatetime > pubDatetime ? (
<span className={`italic ${size === "sm" ? "text-sm" : "text-base"}`}>
Updated:
</span>
) : (
<span className="sr-only">Published:</span>
)}
<span className={`italic ${size === "sm" ? "text-sm" : "text-base"}`}>
<FormattedDatetime
pubDatetime={pubDatetime}
modDatetime={modDatetime}
/>
{size === "lg" && <EditPost editPost={editPost} postId={postId} />}
</span>
</div>
);
}
const FormattedDatetime = ({ pubDatetime, modDatetime }: DatetimesProps) => {
const myDatetime = new Date(
modDatetime && modDatetime > pubDatetime ? modDatetime : pubDatetime
);
const date = myDatetime.toLocaleDateString(LOCALE.langTag, {
year: "numeric",
month: "short",
day: "numeric",
});
const time = myDatetime.toLocaleTimeString(LOCALE.langTag, {
hour: "2-digit",
minute: "2-digit",
});
return (
<>
<time dateTime={myDatetime.toISOString()}>{date}</time>
<span aria-hidden="true"> | </span>
<span className="sr-only">&nbsp;at&nbsp;</span>
<span className="text-nowrap">{time}</span>
</>
);
};
const EditPost = ({ editPost, postId }: EditPostProps) => {
let editPostUrl = editPost?.url ?? SITE?.editPost?.url ?? "";
const showEditPost = !editPost?.disabled && editPostUrl.length > 0;
const appendFilePath =
editPost?.appendFilePath ?? SITE?.editPost?.appendFilePath ?? false;
if (appendFilePath && postId) {
editPostUrl += `/${postId}`;
}
const editPostText = editPost?.text ?? SITE?.editPost?.text ?? "Edit";
return (
showEditPost && (
<>
<span aria-hidden="true"> | </span>
<a
className="space-x-1.5 hover:opacity-75"
href={editPostUrl}
rel="noopener noreferrer"
target="_blank"
>
<svg
xmlns="http://www.w3.org/2000/svg"
className="icon icon-tabler icons-tabler-outline icon-tabler-edit inline-block !scale-90 fill-skin-base"
aria-hidden="true"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M7 7h-1a2 2 0 0 0 -2 2v9a2 2 0 0 0 2 2h9a2 2 0 0 0 2 -2v-1" />
<path d="M20.385 6.585a2.1 2.1 0 0 0 -2.97 -2.97l-8.415 8.385v3h3l8.385 -8.415z" />
<path d="M16 5l3 3" />
</svg>
<span className="text-base italic">{editPostText}</span>
</a>
</>
)
);
};

View file

@ -0,0 +1,45 @@
---
import Hr from "./Hr.astro";
import Socials from "./Socials.astro";
const currentYear = new Date().getFullYear();
export interface Props {
noMarginTop?: boolean;
}
const { noMarginTop = false } = Astro.props;
---
<footer class={`${noMarginTop ? "" : "mt-auto"}`}>
<Hr noPadding />
<div class="footer-wrapper">
<Socials centered />
<div class="copyright-wrapper">
<span>Copyright &#169; {currentYear}</span>
<span class="separator">&nbsp;|&nbsp;</span>
<span>All rights reserved.</span>
</div>
</div>
</footer>
<style>
footer {
@apply w-full;
}
.footer-wrapper {
@apply flex flex-col items-center justify-between py-6 sm:flex-row-reverse sm:py-4;
}
.link-button {
@apply my-1 p-2 hover:rotate-6;
}
.link-button svg {
@apply scale-125;
}
.copyright-wrapper {
@apply my-2 flex flex-col items-center whitespace-nowrap sm:flex-row;
}
.separator {
@apply hidden sm:inline;
}
</style>

250
src/components/Header.astro Normal file
View file

@ -0,0 +1,250 @@
---
import { LOGO_IMAGE, SITE } from "@config";
import Hr from "./Hr.astro";
import LinkButton from "./LinkButton.astro";
export interface Props {
activeNav?: "posts" | "archives" | "tags" | "about" | "search";
}
const { activeNav } = Astro.props;
---
<header>
<a id="skip-to-content" href="#main-content">Skip to content</a>
<div class="nav-container">
<div class="top-nav-wrap">
<a href="/" class="logo whitespace-nowrap">
{
LOGO_IMAGE.enable ? (
<img
src={`/assets/${LOGO_IMAGE.svg ? "logo.svg" : "logo.png"}`}
alt={SITE.title}
width={LOGO_IMAGE.width}
height={LOGO_IMAGE.height}
/>
) : (
SITE.title
)
}
</a>
<nav id="nav-menu">
<button
class="hamburger-menu focus-outline"
aria-label="Open Menu"
aria-expanded="false"
aria-controls="menu-items"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
class="menu-icon"
>
<line x1="7" y1="12" x2="21" y2="12" class="line"></line>
<line x1="3" y1="6" x2="21" y2="6" class="line"></line>
<line x1="12" y1="18" x2="21" y2="18" class="line"></line>
<line x1="18" y1="6" x2="6" y2="18" class="close"></line>
<line x1="6" y1="6" x2="18" y2="18" class="close"></line>
</svg>
</button>
<ul id="menu-items" class="display-none sm:flex">
<li>
<a href="/posts/" class={activeNav === "posts" ? "active" : ""}>
Posts
</a>
</li>
<li>
<a href="/tags/" class={activeNav === "tags" ? "active" : ""}>
Tags
</a>
</li>
<li>
<a href="/about/" class={activeNav === "about" ? "active" : ""}>
About
</a>
</li>
{
SITE.showArchives && (
<li>
<LinkButton
href="/archives/"
className={`focus-outline flex justify-center p-3 sm:p-1`}
ariaLabel="archives"
title="Archives"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class:list={[
"icon icon-tabler icons-tabler-outline !hidden sm:!inline-block",
activeNav === "archives" && "!stroke-skin-accent",
]}
>
<>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M3 4m0 2a2 2 0 0 1 2 -2h14a2 2 0 0 1 2 2v0a2 2 0 0 1 -2 2h-14a2 2 0 0 1 -2 -2z" />
<path d="M5 8v10a2 2 0 0 0 2 2h10a2 2 0 0 0 2 -2v-10" />
<path d="M10 12l4 0" />
</>
</svg>
<span
class:list={[
"sm:sr-only",
activeNav === "archives" && "active",
]}
>
Archives
</span>
</LinkButton>
</li>
)
}
<li>
<LinkButton
href="/search/"
className={`focus-outline p-3 sm:p-1 ${
activeNav === "search" ? "active" : ""
} flex`}
ariaLabel="search"
title="Search"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="scale-125 sm:scale-100"
><path
d="M19.023 16.977a35.13 35.13 0 0 1-1.367-1.384c-.372-.378-.596-.653-.596-.653l-2.8-1.337A6.962 6.962 0 0 0 16 9c0-3.859-3.14-7-7-7S2 5.141 2 9s3.14 7 7 7c1.763 0 3.37-.66 4.603-1.739l1.337 2.8s.275.224.653.596c.387.363.896.854 1.384 1.367l1.358 1.392.604.646 2.121-2.121-.646-.604c-.379-.372-.885-.866-1.391-1.36zM9 14c-2.757 0-5-2.243-5-5s2.243-5 5-5 5 2.243 5 5-2.243 5-5 5z"
></path>
</svg>
<span class="sr-only">Search</span>
</LinkButton>
</li>
{
SITE.lightAndDarkMode && (
<li>
<button
id="theme-btn"
class="focus-outline"
title="Toggles light & dark"
aria-label="auto"
aria-live="polite"
>
<svg xmlns="http://www.w3.org/2000/svg" id="moon-svg">
<path d="M20.742 13.045a8.088 8.088 0 0 1-2.077.271c-2.135 0-4.14-.83-5.646-2.336a8.025 8.025 0 0 1-2.064-7.723A1 1 0 0 0 9.73 2.034a10.014 10.014 0 0 0-4.489 2.582c-3.898 3.898-3.898 10.243 0 14.143a9.937 9.937 0 0 0 7.072 2.93 9.93 9.93 0 0 0 7.07-2.929 10.007 10.007 0 0 0 2.583-4.491 1.001 1.001 0 0 0-1.224-1.224zm-2.772 4.301a7.947 7.947 0 0 1-5.656 2.343 7.953 7.953 0 0 1-5.658-2.344c-3.118-3.119-3.118-8.195 0-11.314a7.923 7.923 0 0 1 2.06-1.483 10.027 10.027 0 0 0 2.89 7.848 9.972 9.972 0 0 0 7.848 2.891 8.036 8.036 0 0 1-1.484 2.059z" />
</svg>
<svg xmlns="http://www.w3.org/2000/svg" id="sun-svg">
<path d="M6.993 12c0 2.761 2.246 5.007 5.007 5.007s5.007-2.246 5.007-5.007S14.761 6.993 12 6.993 6.993 9.239 6.993 12zM12 8.993c1.658 0 3.007 1.349 3.007 3.007S13.658 15.007 12 15.007 8.993 13.658 8.993 12 10.342 8.993 12 8.993zM10.998 19h2v3h-2zm0-17h2v3h-2zm-9 9h3v2h-3zm17 0h3v2h-3zM4.219 18.363l2.12-2.122 1.415 1.414-2.12 2.122zM16.24 6.344l2.122-2.122 1.414 1.414-2.122 2.122zM6.342 7.759 4.22 5.637l1.415-1.414 2.12 2.122zm13.434 10.605-1.414 1.414-2.122-2.122 1.414-1.414z" />
</svg>
</button>
</li>
)
}
</ul>
</nav>
</div>
</div>
<Hr />
</header>
<style>
#skip-to-content {
@apply absolute -top-full left-16 z-50 bg-skin-accent px-3 py-2 text-skin-inverted transition-all focus:top-4;
}
.nav-container {
@apply mx-auto flex max-w-3xl flex-col items-center justify-between sm:flex-row;
}
.top-nav-wrap {
@apply relative flex w-full items-start justify-between p-4 sm:items-center sm:py-8;
}
.logo {
@apply absolute py-1 text-xl font-semibold sm:static sm:text-2xl;
}
.hamburger-menu {
@apply self-end p-2 sm:hidden;
}
.hamburger-menu svg {
@apply h-6 w-6 scale-125 fill-skin-base;
}
nav {
@apply flex w-full flex-col items-center sm:ml-2 sm:flex-row sm:justify-end sm:space-x-4 sm:py-0;
}
nav ul {
@apply mt-4 grid w-44 grid-cols-2 grid-rows-4 gap-x-2 gap-y-2 sm:ml-0 sm:mt-0 sm:w-auto sm:gap-x-5 sm:gap-y-0;
}
nav ul li {
@apply col-span-2 flex items-center justify-center;
}
nav ul li a {
@apply w-full px-4 py-3 text-center font-medium hover:text-skin-accent sm:my-0 sm:px-2 sm:py-1;
}
nav ul li:nth-last-child(2) a {
@apply w-auto;
}
nav ul li:nth-last-child(1),
nav ul li:nth-last-child(2) {
@apply col-span-1;
}
nav .active {
@apply underline decoration-wavy decoration-2 underline-offset-4;
}
nav a.active svg {
@apply fill-skin-accent;
}
nav button {
@apply p-1;
}
nav button svg {
@apply h-6 w-6 fill-skin-base hover:fill-skin-accent;
}
#theme-btn {
@apply p-3 sm:p-1;
}
#theme-btn svg {
@apply scale-125 hover:rotate-12 sm:scale-100;
}
.menu-icon line {
@apply transition-opacity duration-75 ease-in-out;
}
.menu-icon .close {
opacity: 0;
}
.menu-icon.is-active .line {
@apply opacity-0;
}
.menu-icon.is-active .close {
@apply opacity-100;
}
</style>
<script>
function toggleNav() {
// Toggle menu
const menuBtn = document.querySelector(".hamburger-menu");
const menuIcon = document.querySelector(".menu-icon");
const menuItems = document.querySelector("#menu-items");
menuBtn?.addEventListener("click", () => {
const menuExpanded = menuBtn.getAttribute("aria-expanded") === "true";
menuIcon?.classList.toggle("is-active");
menuBtn.setAttribute("aria-expanded", menuExpanded ? "false" : "true");
menuBtn.setAttribute(
"aria-label",
menuExpanded ? "Open Menu" : "Close Menu"
);
menuItems?.classList.toggle("display-none");
});
}
toggleNav();
// Runs on view transitions navigation
document.addEventListener("astro:after-swap", toggleNav);
</script>

12
src/components/Hr.astro Normal file
View file

@ -0,0 +1,12 @@
---
export interface Props {
noPadding?: boolean;
ariaHidden?: boolean;
}
const { noPadding = false, ariaHidden = true } = Astro.props;
---
<div class={`max-w-3xl mx-auto ${noPadding ? "px-0" : "px-4"}`}>
<hr class="border-skin-line" aria-hidden={ariaHidden} />
</div>

View file

@ -0,0 +1,38 @@
---
export interface Props {
href: string;
className?: string;
ariaLabel?: string;
title?: string;
disabled?: boolean;
}
const {
href,
className = "",
ariaLabel,
title,
disabled = false,
} = Astro.props;
---
{
disabled ? (
<span
class:list={["group inline-block", className]}
title={title}
aria-disabled={disabled}
>
<slot />
</span>
) : (
<a
{href}
class:list={["group inline-block hover:text-skin-accent", className]}
aria-label={ariaLabel}
title={title}
>
<slot />
</a>
)
}

View file

@ -0,0 +1,59 @@
---
import type { Page } from "astro";
import LinkButton from "./LinkButton.astro";
import type { CollectionEntry } from "astro:content";
export interface Props {
page: Page<CollectionEntry<"blog">>;
}
const { page } = Astro.props;
---
{
page.lastPage > 1 && (
<nav class="pagination-wrapper" aria-label="Pagination">
<LinkButton
disabled={!page.url.prev}
href={page.url.prev as string}
className={`mr-4 select-none ${page.url.prev ? "" : "disabled"}`}
ariaLabel="Previous"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class:list={[{ "disabled-svg": !page.url.prev }]}
>
<path d="M12.707 17.293 8.414 13H18v-2H8.414l4.293-4.293-1.414-1.414L4.586 12l6.707 6.707z" />
</svg>
Prev
</LinkButton>
{page.currentPage} / {page.lastPage}
<LinkButton
disabled={!page.url.next}
href={page.url.next as string}
className={`mx-4 select-none ${page.url.next ? "" : "disabled"}`}
ariaLabel="Next"
>
Next
<svg
xmlns="http://www.w3.org/2000/svg"
class:list={[{ "disabled-svg": !page.url.next }]}
>
<path d="m11.293 17.293 1.414 1.414L19.414 12l-6.707-6.707-1.414 1.414L15.586 11H6v2h9.586z" />
</svg>
</LinkButton>
</nav>
)
}
<style>
.pagination-wrapper {
@apply mb-8 mt-auto flex justify-center;
}
.disabled {
@apply pointer-events-none select-none opacity-50 hover:text-skin-base group-hover:fill-skin-base;
}
.disabled-svg {
@apply group-hover:!fill-skin-base;
}
</style>

120
src/components/Search.tsx Normal file
View file

@ -0,0 +1,120 @@
import Fuse from "fuse.js";
import { useEffect, useRef, useState, useMemo, type FormEvent } from "react";
import Card from "@components/Card";
import type { CollectionEntry } from "astro:content";
export type SearchItem = {
title: string;
description: string;
data: CollectionEntry<"blog">["data"];
slug: string;
};
interface Props {
searchList: SearchItem[];
}
interface SearchResult {
item: SearchItem;
refIndex: number;
}
export default function SearchBar({ searchList }: Props) {
const inputRef = useRef<HTMLInputElement>(null);
const [inputVal, setInputVal] = useState("");
const [searchResults, setSearchResults] = useState<SearchResult[] | null>(
null
);
const handleChange = (e: FormEvent<HTMLInputElement>) => {
setInputVal(e.currentTarget.value);
};
const fuse = useMemo(
() =>
new Fuse(searchList, {
keys: ["title", "description"],
includeMatches: true,
minMatchCharLength: 2,
threshold: 0.5,
}),
[searchList]
);
useEffect(() => {
// if URL has search query,
// insert that search query in input field
const searchUrl = new URLSearchParams(window.location.search);
const searchStr = searchUrl.get("q");
if (searchStr) setInputVal(searchStr);
// put focus cursor at the end of the string
setTimeout(function () {
inputRef.current!.selectionStart = inputRef.current!.selectionEnd =
searchStr?.length || 0;
}, 50);
}, []);
useEffect(() => {
// Add search result only if
// input value is more than one character
const inputResult = inputVal.length > 1 ? fuse.search(inputVal) : [];
setSearchResults(inputResult);
// Update search string in URL
if (inputVal.length > 0) {
const searchParams = new URLSearchParams(window.location.search);
searchParams.set("q", inputVal);
const newRelativePathQuery =
window.location.pathname + "?" + searchParams.toString();
history.replaceState(history.state, "", newRelativePathQuery);
} else {
history.replaceState(history.state, "", window.location.pathname);
}
}, [inputVal]);
return (
<>
<label className="relative block">
<span className="absolute inset-y-0 left-0 flex items-center pl-2 opacity-75">
<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<path d="M19.023 16.977a35.13 35.13 0 0 1-1.367-1.384c-.372-.378-.596-.653-.596-.653l-2.8-1.337A6.962 6.962 0 0 0 16 9c0-3.859-3.14-7-7-7S2 5.141 2 9s3.14 7 7 7c1.763 0 3.37-.66 4.603-1.739l1.337 2.8s.275.224.653.596c.387.363.896.854 1.384 1.367l1.358 1.392.604.646 2.121-2.121-.646-.604c-.379-.372-.885-.866-1.391-1.36zM9 14c-2.757 0-5-2.243-5-5s2.243-5 5-5 5 2.243 5 5-2.243 5-5 5z"></path>
</svg>
<span className="sr-only">Search</span>
</span>
<input
className="block w-full rounded border border-skin-fill/40 bg-skin-fill py-3 pl-10 pr-3 placeholder:italic focus:border-skin-accent focus:outline-none"
placeholder="Search for anything..."
type="text"
name="search"
value={inputVal}
onChange={handleChange}
autoComplete="off"
// autoFocus
ref={inputRef}
/>
</label>
{inputVal.length > 1 && (
<div className="mt-8">
Found {searchResults?.length}
{searchResults?.length && searchResults?.length === 1
? " result"
: " results"}{" "}
for '{inputVal}'
</div>
)}
<ul>
{searchResults &&
searchResults.map(({ item, refIndex }) => (
<Card
href={`/posts/${item.slug}/`}
frontmatter={item.data}
key={`${refIndex}-${item.slug}`}
/>
))}
</ul>
</>
);
}

View file

@ -0,0 +1,66 @@
---
import LinkButton from "./LinkButton.astro";
import socialIcons from "@assets/socialIcons";
const URL = Astro.url;
const shareLinks = [
{
name: "WhatsApp",
href: "https://wa.me/?text=",
linkTitle: `Share this post via WhatsApp`,
},
{
name: "Facebook",
href: "https://www.facebook.com/sharer.php?u=",
linkTitle: `Share this post on Facebook`,
},
{
name: "X",
href: "https://x.com/intent/post?url=",
linkTitle: `Share this post on X`,
},
{
name: "Telegram",
href: "https://t.me/share/url?url=",
linkTitle: `Share this post via Telegram`,
},
{
name: "Pinterest",
href: "https://pinterest.com/pin/create/button/?url=",
linkTitle: `Share this post on Pinterest`,
},
{
name: "Mail",
href: "mailto:?subject=See%20this%20post&body=",
linkTitle: `Share this post via email`,
},
] as const;
---
<div class={`social-icons`}>
<span class="italic">Share this post on:</span>
<div class="text-center">
{
shareLinks.map(social => (
<LinkButton
href={`${social.href + URL}`}
className="link-button"
title={social.linkTitle}
>
<Fragment set:html={socialIcons[social.name]} />
<span class="sr-only">{social.linkTitle}</span>
</LinkButton>
))
}
</div>
</div>
<style>
.social-icons {
@apply flex flex-col flex-wrap items-center justify-center gap-1 sm:items-start;
}
.link-button {
@apply scale-90 p-2 hover:rotate-6 sm:p-1;
}
</style>

35
src/components/Socials.astro Executable file
View file

@ -0,0 +1,35 @@
---
import { SOCIALS } from "@config";
import LinkButton from "./LinkButton.astro";
import socialIcons from "@assets/socialIcons";
export interface Props {
centered?: boolean;
}
const { centered = false } = Astro.props;
---
<div class={`social-icons ${centered ? "flex" : ""}`}>
{
SOCIALS.filter(social => social.active).map(social => (
<LinkButton
href={social.href}
className="link-button"
title={social.linkTitle}
>
<Fragment set:html={socialIcons[social.name]} />
<span class="sr-only">{social.linkTitle}</span>
</LinkButton>
))
}
</div>
<style>
.social-icons {
@apply flex-wrap justify-center gap-1;
}
.link-button {
@apply p-2 hover:rotate-6 sm:p-1;
}
</style>

38
src/components/Tag.astro Normal file
View file

@ -0,0 +1,38 @@
---
export interface Props {
tag: string;
size?: "sm" | "lg";
}
const { tag, size = "sm" } = Astro.props;
---
<li
class={`inline-block ${
size === "sm" ? "my-1 underline-offset-4" : "my-3 mx-1 underline-offset-8"
}`}
>
<a
href={`/tags/${tag}/`}
transition:name={tag}
class={`${size === "sm" ? "text-sm" : "text-lg"} pr-2 group`}
>
<svg
xmlns="http://www.w3.org/2000/svg"
class={`${size === "sm" ? " scale-75" : "scale-110"}`}
><path
d="M16.018 3.815 15.232 8h-4.966l.716-3.815-1.964-.37L8.232 8H4v2h3.857l-.751 4H3v2h3.731l-.714 3.805 1.965.369L8.766 16h4.966l-.714 3.805 1.965.369.783-4.174H20v-2h-3.859l.751-4H21V8h-3.733l.716-3.815-1.965-.37zM14.106 14H9.141l.751-4h4.966l-.752 4z"
></path>
</svg>
&nbsp;<span>{tag}</span>
</a>
</li>
<style>
a {
@apply relative underline decoration-dashed hover:-top-0.5 hover:text-skin-accent focus-visible:p-1;
}
a svg {
@apply -mr-5 h-6 w-6 scale-95 text-skin-base opacity-80 group-hover:fill-skin-accent;
}
</style>

155
src/config.ts Normal file
View file

@ -0,0 +1,155 @@
import type { Site, SocialObjects } from "./types";
export const SITE: Site = {
website: "https://tiff.engineer/", // replace this with your deployed domain
author: "tiff w",
profile: "https://about.tiff.engineer/",
desc: "A software blog by someone named tiff.",
title: "tiff on software",
ogImage: "astropaper-og.jpg",
lightAndDarkMode: true,
postPerIndex: 4,
postPerPage: 3,
scheduledPostMargin: 15 * 60 * 1000, // 15 minutes
showArchives: true,
editPost: {
url: "https://github.com/satnaing/astro-paper/edit/main/src/content/blog",
text: "Suggest Changes",
appendFilePath: true,
},
};
export const LOCALE = {
lang: "en", // html lang code. Set this empty and default will be "en"
langTag: ["en-EN"], // BCP 47 Language Tags. Set this empty [] to use the environment default
} as const;
export const LOGO_IMAGE = {
enable: false,
svg: true,
width: 216,
height: 46,
};
export const SOCIALS: SocialObjects = [
{
name: "Github",
href: "https://github.com/satnaing/astro-paper",
linkTitle: ` ${SITE.title} on Github`,
active: true,
},
{
name: "Facebook",
href: "https://github.com/satnaing/astro-paper",
linkTitle: `${SITE.title} on Facebook`,
active: true,
},
{
name: "Instagram",
href: "https://github.com/satnaing/astro-paper",
linkTitle: `${SITE.title} on Instagram`,
active: true,
},
{
name: "LinkedIn",
href: "https://github.com/satnaing/astro-paper",
linkTitle: `${SITE.title} on LinkedIn`,
active: true,
},
{
name: "Mail",
href: "mailto:yourmail@gmail.com",
linkTitle: `Send an email to ${SITE.title}`,
active: false,
},
{
name: "X",
href: "https://github.com/satnaing/astro-paper",
linkTitle: `${SITE.title} on X`,
active: false,
},
{
name: "Twitch",
href: "https://github.com/satnaing/astro-paper",
linkTitle: `${SITE.title} on Twitch`,
active: false,
},
{
name: "YouTube",
href: "https://github.com/satnaing/astro-paper",
linkTitle: `${SITE.title} on YouTube`,
active: false,
},
{
name: "WhatsApp",
href: "https://github.com/satnaing/astro-paper",
linkTitle: `${SITE.title} on WhatsApp`,
active: false,
},
{
name: "Snapchat",
href: "https://github.com/satnaing/astro-paper",
linkTitle: `${SITE.title} on Snapchat`,
active: false,
},
{
name: "Pinterest",
href: "https://github.com/satnaing/astro-paper",
linkTitle: `${SITE.title} on Pinterest`,
active: false,
},
{
name: "TikTok",
href: "https://github.com/satnaing/astro-paper",
linkTitle: `${SITE.title} on TikTok`,
active: false,
},
{
name: "CodePen",
href: "https://github.com/satnaing/astro-paper",
linkTitle: `${SITE.title} on CodePen`,
active: false,
},
{
name: "Discord",
href: "https://github.com/satnaing/astro-paper",
linkTitle: `${SITE.title} on Discord`,
active: false,
},
{
name: "GitLab",
href: "https://github.com/satnaing/astro-paper",
linkTitle: `${SITE.title} on GitLab`,
active: false,
},
{
name: "Reddit",
href: "https://github.com/satnaing/astro-paper",
linkTitle: `${SITE.title} on Reddit`,
active: false,
},
{
name: "Skype",
href: "https://github.com/satnaing/astro-paper",
linkTitle: `${SITE.title} on Skype`,
active: false,
},
{
name: "Steam",
href: "https://github.com/satnaing/astro-paper",
linkTitle: `${SITE.title} on Steam`,
active: false,
},
{
name: "Telegram",
href: "https://github.com/satnaing/astro-paper",
linkTitle: `${SITE.title} on Telegram`,
active: false,
},
{
name: "Mastodon",
href: "https://github.com/satnaing/astro-paper",
linkTitle: `${SITE.title} on Mastodon`,
active: false,
},
];

View file

@ -0,0 +1,39 @@
---
pubDatetime: 2024-10-18
title: Back Again
description: "Life is uncertain, but one thing remains constant- my need to keep my brain engaged."
featured: false
---
> This is going to be a brief post; I have not gathered my thoughts on my current career situation, however that will come.
## What's Up?
Well, I've given up on pivoting to cybersecurity. I have also given up on IT/DevOps as a career. I will be talking about this
more in another post somewhere else which I'll link to, but it is what it is for me.
This means I have more time to build software when I get a chance and for fun and for my next endeavour which means
this blog is my main one for software dev for the foreseeable future.
I am glad to consolidate things and pare things down to just this blog and my next endeavour [Tiff Labs](https://tifflabs.org).
## What is Tiff Labs?
First, I've quit trying to have a career in tech proper. It's just not going to happen for someone my age during a recession
like this. I don't have a degree and am fully self-taught. This is a big strike against me returning to the workforce in tech and
if I can't do that, I will do my own thing. I have income from other sources to be able to do this so I am not too concerned about
making ends meet for now.
Tiff Labs is an umbrella of things- a YouTube channel, a blog, a smart home resource, and a homelab resource. I can make money from that once I get more content up and posted[^1].
I will also be doing more DIY repairing of devices and DIY building of things in general. I enjoy working with my hands more than working with code. I love both, don't get me wrong. However working with my hands gives me immense joy I don't find while writing software.
## What this blog will be
It will be a place for me to talk about the software I build for my other projects and hopefully a small business at some point. That's it.
Here is a project I'd like to build in Go:
<blockquote class="reddit-embed-bq" data-embed-height="364"><a href="https://www.reddit.com/r/golang/comments/1e6cwgi/comment/lds2v63/">Comment</a><br> by<a href="https://www.reddit.com/user/TheBrownViking20/">u/TheBrownViking20</a> from discussion<a href="https://www.reddit.com/r/golang/comments/1e6cwgi/what_is_the_most_interesting_golang_cli_app_youve/"></a><br> in<a href="https://www.reddit.com/r/golang/">golang</a></blockquote><script async="" src="https://embed.reddit.com/widgets.js" charset="UTF-8"></script>
[^1]: I know that affiliate links are frowned upon but other than ads which I don't want to run, this will have to do for now.

View file

@ -0,0 +1,7 @@
---
title: Designing projects
pubDatetime: 2024-12-24
description: Listless and needing focus, I found the perfect repo to build my skills.
featured: true
draft: true
---

View file

@ -0,0 +1,10 @@
---
pubDatetime: 2024-04-01
title: Go Project Structure for gURL
description: Helpful Go tip.
featured: true
---
Over the past week I've been working on changing the structure of gURL.
One of my old CodeNewbie friends on LinkedIn gave me a template to start from on GitHub called [Standard Go Project Layout](https://github.com/golang-standards/project-layout). I intend to use this as a guide.

View file

@ -0,0 +1,80 @@
---
pubDatetime: 2024-03-24
title: "gURL: A cURL Wrapper Written in Go"
description: Moving on from frontend to something new.
featured: true
---
Briefly: I am writing a CLI tool that fetches info from GitHub's REST API as a wrapper around cURL.
Why?
Just to keep my finger on the pulse of software development and to practice my programming skills because believe it or not, being able to code as a cybersecurity professional is extremely useful, even though it isn't _technically necessary_.
I'm currently wading though documentation on Go modules and Go standard library packages, as well as reading other code examples to see how I want to structure my application.
First, I found a [repo that](https://github.com/wick3dr0se/github-api-curl/tree/master) uses shell scripts to wrap around the GitHub API. This gave me the idea for splitting up my tool into different directories.
Then, I fiddled around with [Google's GitHub Go library](https://github.com/google/go-github/tree/master) to see how they used Go to build the library.
## Fits and Starts
My first inkling was to crib this code from the Google repo:
```go
var defaultBaseURL = "https://github.com/"
// Client is a GitHub scraping client.
type Client struct {
*http.Client
// base URL for github.com pages. Exposed primarily for testing. Also
// used for saving and restoring cookies on the Client.
baseURL *url.URL
}
```
I wasn't exactly sure what I was looking at; I have a `baseURL` variable that is assigned the GitHub url. I've imported the `net/http` package, as it would have been in this example. We have a `Client` type that is also a `struct` which I have forgetten what they are exactly, as it has been a while since I've read the C++ docs, which also has a `struct` but I think that `struct` in this instance has the `type` keyword to denote that it is a `class` or something.
In any case, I was trying to figure out how to use string interpolation in the URL parameters like you would in JavaScript template strings:
```js
// Octokit.js
// https://github.com/octokit/core.js#readme
const octokit = new Octokit({
auth: "YOUR-TOKEN",
});
// the request method contains template strings which is
// JavaScript's way of doing string interpolation
await octokit.request("GET /repos/{owner}/{repo}", {
owner: "OWNER",
repo: "REPO",
headers: {
"X-GitHub-Api-Version": "2022-11-28",
},
});
```
How does that work in Go?
At first I was thinking of using `Stripln()` to do string interpolation but as a [StackOverflow user said](https://stackoverflow.com/a/71181938/3800146), to put that junk in a query parameter would be messy and unsafe. The user suggested the op use the `net/http` library which solves all the issues I'd face trying to make `GET` requests to GitHub's REST API.
The example given:
```go
params := url.Values{
"page[size]": []string{"100"},
"page[" + key + "]": []string{"1"},
}
u := &url.URL{
Scheme: "https",
Host: "url.com",
Path: "/path",
RawQuery: params.Encode(),
}
req, err := http.NewRequest("GET", u.String(), nil)
```
This should do it for now.

15
src/content/blog/hello.md Normal file
View file

@ -0,0 +1,15 @@
---
pubDatetime: 2024-03-17
title: "Hello, y'all"
description: Introductions.
---
This is a learning and accountability blog.
This is my first post. It will be updated as time permits.
One of the things frustrating me lately is when I was just starting out, I blogged about literally everything; the industry was new to me and everyone was excited, I was excited at the possibility of pulling myself out of extreme poverty. I was younger, bright-eyed, without any cynicism.
Fast forward 10 years and a lot has changed. I won't get into the gory details but y'all know. Tech is not what it used to be, I've grown to despise most JavaScript anything, and I'm an industry vet who is disillusioned by the state of the industry and the world in general. That kind of pessimism weighs on me daily, so much so I don't write about anything I learn. It sucks.
So this is my blog post I'm sending you, I hope you read it. (Bonus points is you get that reference).

View file

@ -0,0 +1,26 @@
---
pubDatetime: 2024-04-23
title: This Week's Update
description: Brief note.
featured: true
---
Things I have been up to while off LinkedIn:
- Working on my [home lab](http://homelab.tifflabs.org) by setting up k3s for High Availabilty servers for critical services in the lab
- Learning more about VMs, [Nginx](https://notes.0x8c.org/home-lab/nginx-configs/), and Reverse Proxies
- Learning about [Certbot](https://certbot.eff.org/)
- Failing to set up [TWO "beginner-friendly"](https://github.com/tobychui/zoraxy) [reverse proxy managers](https://nginxproxymanager.com/) and realizing that [learning how to use the CLI more](https://lemmy.brendan.ie/comment/223126) is the best course of action
- Learning how to [reconfigure Proxmox IP addresses](https://gist.github.com/twhite96/2ed4c6f3d50ed0009947c69e7bded6ca), failing to keep the cluster, and wiping three nodes clean
- Deciding that I'd keep one node out of the cluster for having one less entrypoint to my network[^1]
- Setting up InfluxDB for use of monitoring my Proxmox cluster and separate node in Grafana
- Learning Linux commands such as `fstab`, `lsblk`, and that you can pretty much do everything you need system wide (rebooting, etc), by prefixing `systemctl` to those commands
- Creating my own Docker image and container to fix a bug in a repo I want to use but want it to install dependencies and "just work", as they say
- Installing a self-hosted Git client/website
- Learning that `ini` files are basically the same as `toml` files
- Researching how to use WebSockets in Python or Go to understand how someone could use that to exploit a website/machine by keeping the socket alive and using the website/machine as a C2 server
- Working on creating my own C2 server
I've been at it nonstop these past two weeks, but I am having serious fun.
[^1]: I have too many VMs and LXCs now or I'd change the IP to help with this as well.

36
src/content/config.ts Normal file
View file

@ -0,0 +1,36 @@
import { SITE } from "@config";
import { glob } from "astro/loaders";
import { defineCollection, z } from "astro:content";
const blog = defineCollection({
type: "content_layer",
loader: glob({ pattern: "**/*.md", base: "./src/content/blog" }),
schema: ({ image }) =>
z.object({
author: z.string().default(SITE.author),
pubDatetime: z.date(),
modDatetime: z.date().optional().nullable(),
title: z.string(),
featured: z.boolean().optional(),
draft: z.boolean().optional(),
tags: z.array(z.string()).default(["others"]),
ogImage: image()
.refine(img => img.width >= 1200 && img.height >= 630, {
message: "OpenGraph image must be at least 1200 X 630 pixels!",
})
.or(z.string())
.optional(),
description: z.string(),
canonicalURL: z.string().optional(),
editPost: z
.object({
disabled: z.boolean().optional(),
url: z.string().optional(),
text: z.string().optional(),
appendFilePath: z.boolean().optional(),
})
.optional(),
}),
});
export const collections = { blog };

2
src/env.d.ts vendored Normal file
View file

@ -0,0 +1,2 @@
/// <reference path="../.astro/types.d.ts" />
/// <reference types="astro/client" />

View file

@ -0,0 +1,28 @@
---
import { SITE } from "@config";
import Breadcrumbs from "@components/Breadcrumbs.astro";
import Footer from "@components/Footer.astro";
import Header from "@components/Header.astro";
import Layout from "./Layout.astro";
export interface Props {
frontmatter: {
title: string;
description?: string;
};
}
const { frontmatter } = Astro.props;
---
<Layout title={`${frontmatter.title} | ${SITE.title}`}>
<Header activeNav="about" />
<Breadcrumbs />
<main id="main-content">
<section id="about" class="prose mb-28 max-w-3xl prose-img:border-0">
<h1 class="text-2xl tracking-wider sm:text-3xl">{frontmatter.title}</h1>
<slot />
</section>
</main>
<Footer />
</Layout>

142
src/layouts/Layout.astro Normal file
View file

@ -0,0 +1,142 @@
---
import { LOCALE, SITE } from "@config";
import "@styles/base.css";
import { ViewTransitions } from "astro:transitions";
const googleSiteVerification = import.meta.env.PUBLIC_GOOGLE_SITE_VERIFICATION;
export interface Props {
title?: string;
author?: string;
profile?: string;
description?: string;
ogImage?: string;
canonicalURL?: string;
pubDatetime?: Date;
modDatetime?: Date | null;
scrollSmooth?: boolean;
}
const {
title = SITE.title,
author = SITE.author,
profile = SITE.profile,
description = SITE.desc,
ogImage = SITE.ogImage,
canonicalURL = new URL(Astro.url.pathname, Astro.site).href,
pubDatetime,
modDatetime,
scrollSmooth = false,
} = Astro.props;
const socialImageURL = new URL(
ogImage ?? SITE.ogImage ?? "og.png",
Astro.url.origin
).href;
const structuredData = {
"@context": "https://schema.org",
"@type": "BlogPosting",
headline: `${title}`,
image: `${socialImageURL}`,
datePublished: `${pubDatetime?.toISOString()}`,
...(modDatetime && { dateModified: modDatetime.toISOString() }),
author: [
{
"@type": "Person",
name: `${author}`,
url: `${profile}`,
},
],
};
---
<!doctype html>
<html
lang=`${LOCALE.lang ?? "en"}`
class={`${scrollSmooth && "scroll-smooth"}`}
>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="canonical" href={canonicalURL} />
<meta name="generator" content={Astro.generator} />
<!-- General Meta Tags -->
<title>{title}</title>
<meta name="title" content={title} />
<meta name="description" content={description} />
<meta name="author" content={author} />
<link rel="sitemap" href="/sitemap-index.xml" />
<!-- Open Graph / Facebook -->
<meta property="og:title" content={title} />
<meta property="og:description" content={description} />
<meta property="og:url" content={canonicalURL} />
<meta property="og:image" content={socialImageURL} />
<!-- Article Published/Modified time -->
{
pubDatetime && (
<meta
property="article:published_time"
content={pubDatetime.toISOString()}
/>
)
}
{
modDatetime && (
<meta
property="article:modified_time"
content={modDatetime.toISOString()}
/>
)
}
<!-- Twitter -->
<meta property="twitter:card" content="summary_large_image" />
<meta property="twitter:url" content={canonicalURL} />
<meta property="twitter:title" content={title} />
<meta property="twitter:description" content={description} />
<meta property="twitter:image" content={socialImageURL} />
<!-- Google JSON-LD Structured data -->
<script
type="application/ld+json"
set:html={JSON.stringify(structuredData)}
/>
<!-- Google Font -->
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:ital,wght@0,400;0,500;0,600;0,700;1,400;1,600&display=swap"
rel="preload"
as="style"
onload="this.onload=null; this.rel='stylesheet';"
crossorigin
/>
<meta name="theme-color" content="" />
{
// If PUBLIC_GOOGLE_SITE_VERIFICATION is set in the environment variable,
// include google-site-verification tag in the heading
// Learn more: https://support.google.com/webmasters/answer/9008080#meta_tag_verification&zippy=%2Chtml-tag
googleSiteVerification && (
<meta
name="google-site-verification"
content={googleSiteVerification}
/>
)
}
<ViewTransitions />
<script is:inline src="/toggle-theme.js" async></script>
</head>
<body>
<slot />
</body>
</html>

48
src/layouts/Main.astro Normal file
View file

@ -0,0 +1,48 @@
---
import Breadcrumbs from "@components/Breadcrumbs.astro";
interface StringTitleProp {
pageTitle: string;
pageDesc?: string;
}
interface ArrayTitleProp {
pageTitle: [string, string];
titleTransition: string;
pageDesc?: string;
}
export type Props = StringTitleProp | ArrayTitleProp;
const { props } = Astro;
---
<Breadcrumbs />
<main id="main-content">
{
"titleTransition" in props ? (
<h1>
{props.pageTitle[0]}
<span transition:name={props.titleTransition}>
{props.pageTitle[1]}
</span>
</h1>
) : (
<h1>{props.pageTitle}</h1>
)
}
<p>{props.pageDesc}</p>
<slot />
</main>
<style>
#main-content {
@apply mx-auto w-full max-w-3xl px-4 pb-4;
}
#main-content h1 {
@apply text-2xl font-semibold sm:text-3xl;
}
#main-content p {
@apply mb-6 mt-2 italic;
}
</style>

View file

@ -0,0 +1,313 @@
---
import Layout from "@layouts/Layout.astro";
import Header from "@components/Header.astro";
import Footer from "@components/Footer.astro";
import Tag from "@components/Tag.astro";
import Datetime from "@components/Datetime";
import type { CollectionEntry } from "astro:content";
import { slugifyStr } from "@utils/slugify";
import ShareLinks from "@components/ShareLinks.astro";
import { SITE } from "@config";
export interface Props {
post: CollectionEntry<"blog">;
posts: CollectionEntry<"blog">[];
}
const { post, posts } = Astro.props;
const {
title,
author,
description,
ogImage,
canonicalURL,
pubDatetime,
modDatetime,
tags,
editPost,
} = post.data;
const { Content } = await post.render();
const ogImageUrl = typeof ogImage === "string" ? ogImage : ogImage?.src;
const ogUrl = new URL(
ogImageUrl ?? `/posts/${slugifyStr(title)}.png`,
Astro.url.origin
).href;
const layoutProps = {
title: `${title} | ${SITE.title}`,
author,
description,
pubDatetime,
modDatetime,
canonicalURL,
ogImage: ogUrl,
scrollSmooth: true,
};
/* ========== Prev/Next Posts ========== */
const allPosts = posts.map(({ data: { title }, slug }) => ({
slug,
title,
}));
const currentPostIndex = allPosts.findIndex(a => a.slug === post.slug);
const prevPost = currentPostIndex !== 0 ? allPosts[currentPostIndex - 1] : null;
const nextPost =
currentPostIndex !== allPosts.length ? allPosts[currentPostIndex + 1] : null;
---
<Layout {...layoutProps}>
<Header />
<div class="mx-auto flex w-full max-w-3xl justify-start px-2">
<button
class="focus-outline mb-2 mt-8 flex hover:opacity-75"
onclick="(() => (history.length === 1) ? window.location = '/' : history.back())()"
>
<svg xmlns="http://www.w3.org/2000/svg"
><path
d="M13.293 6.293 7.586 12l5.707 5.707 1.414-1.414L10.414 12l4.293-4.293z"
></path>
</svg><span>Go back</span>
</button>
</div>
<main id="main-content">
<h1 transition:name={slugifyStr(title)} class="post-title">{title}</h1>
<Datetime
pubDatetime={pubDatetime}
modDatetime={modDatetime}
size="lg"
className="my-2"
editPost={editPost}
postId={post.id}
/>
<article id="article" class="prose mx-auto mt-8 max-w-3xl">
<Content />
</article>
<ul class="my-8">
{tags.map(tag => <Tag tag={slugifyStr(tag)} />)}
</ul>
<div
class="flex flex-col-reverse items-center justify-between gap-6 sm:flex-row-reverse sm:items-end sm:gap-4"
>
<button
id="back-to-top"
class="focus-outline whitespace-nowrap py-1 hover:opacity-75"
>
<svg xmlns="http://www.w3.org/2000/svg" class="rotate-90">
<path
d="M13.293 6.293 7.586 12l5.707 5.707 1.414-1.414L10.414 12l4.293-4.293z"
></path>
</svg>
<span>Back to Top</span>
</button>
<ShareLinks />
</div>
<hr class="my-6 border-dashed" />
<!-- Previous/Next Post Buttons -->
<div class="grid grid-cols-1 gap-6 sm:grid-cols-2">
{
prevPost && (
<a
href={`/posts/${prevPost.slug}`}
class="flex w-full gap-1 hover:opacity-75"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="icon icon-tabler icons-tabler-outline icon-tabler-chevron-left flex-none"
>
<>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M15 6l-6 6l6 6" />
</>
</svg>
<div>
<span>Previous Post</span>
<div class="text-sm text-skin-accent/85">{prevPost.title}</div>
</div>
</a>
)
}
{
nextPost && (
<a
href={`/posts/${nextPost.slug}`}
class="flex w-full justify-end gap-1 text-right hover:opacity-75 sm:col-start-2"
>
<div>
<span>Next Post</span>
<div class="text-sm text-skin-accent/85">{nextPost.title}</div>
</div>
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="icon icon-tabler icons-tabler-outline icon-tabler-chevron-right flex-none"
>
<>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M9 6l6 6l-6 6" />
</>
</svg>
</a>
)
}
</div>
</main>
<Footer />
</Layout>
<style>
main {
@apply mx-auto w-full max-w-3xl px-4 pb-12;
}
.post-title {
@apply text-2xl font-semibold text-skin-accent;
}
</style>
<script is:inline data-astro-rerun>
/** Create a progress indicator
* at the top */
function createProgressBar() {
// Create the main container div
const progressContainer = document.createElement("div");
progressContainer.className =
"progress-container fixed top-0 z-10 h-1 w-full bg-skin-fill";
// Create the progress bar div
const progressBar = document.createElement("div");
progressBar.className = "progress-bar h-1 w-0 bg-skin-accent";
progressBar.id = "myBar";
// Append the progress bar to the progress container
progressContainer.appendChild(progressBar);
// Append the progress container to the document body or any other desired parent element
document.body.appendChild(progressContainer);
}
createProgressBar();
/** Update the progress bar
* when user scrolls */
function updateScrollProgress() {
document.addEventListener("scroll", () => {
const winScroll =
document.body.scrollTop || document.documentElement.scrollTop;
const height =
document.documentElement.scrollHeight -
document.documentElement.clientHeight;
const scrolled = (winScroll / height) * 100;
if (document) {
const myBar = document.getElementById("myBar");
if (myBar) {
myBar.style.width = scrolled + "%";
}
}
});
}
updateScrollProgress();
/** Attaches links to headings in the document,
* allowing sharing of sections easily */
function addHeadingLinks() {
const headings = Array.from(
document.querySelectorAll("h2, h3, h4, h5, h6")
);
for (const heading of headings) {
heading.classList.add("group");
const link = document.createElement("a");
link.className =
"heading-link ml-2 opacity-0 group-hover:opacity-100 focus:opacity-100";
link.href = "#" + heading.id;
const span = document.createElement("span");
span.ariaHidden = "true";
span.innerText = "#";
link.appendChild(span);
heading.appendChild(link);
}
}
addHeadingLinks();
/** Attaches copy buttons to code blocks in the document,
* allowing users to copy code easily. */
function attachCopyButtons() {
const copyButtonLabel = "Copy";
const codeBlocks = Array.from(document.querySelectorAll("pre"));
for (const codeBlock of codeBlocks) {
const wrapper = document.createElement("div");
wrapper.style.position = "relative";
const copyButton = document.createElement("button");
copyButton.className =
"copy-code absolute right-3 -top-3 rounded bg-skin-card px-2 py-1 text-xs leading-4 text-skin-base font-medium";
copyButton.innerHTML = copyButtonLabel;
codeBlock.setAttribute("tabindex", "0");
codeBlock.appendChild(copyButton);
// wrap codebock with relative parent element
codeBlock?.parentNode?.insertBefore(wrapper, codeBlock);
wrapper.appendChild(codeBlock);
copyButton.addEventListener("click", async () => {
await copyCode(codeBlock, copyButton);
});
}
async function copyCode(block, button) {
const code = block.querySelector("code");
const text = code?.innerText;
await navigator.clipboard.writeText(text ?? "");
// visual feedback that task is completed
button.innerText = "Copied";
setTimeout(() => {
button.innerText = copyButtonLabel;
}, 700);
}
}
attachCopyButtons();
/** Scrolls the document to the top when
* the "Back to Top" button is clicked. */
function backToTop() {
document.querySelector("#back-to-top")?.addEventListener("click", () => {
document.body.scrollTop = 0; // For Safari
document.documentElement.scrollTop = 0; // For Chrome, Firefox, IE and Opera
});
}
backToTop();
/* Go to page start after page swap */
document.addEventListener("astro:after-swap", () =>
window.scrollTo({ left: 0, top: 0, behavior: "instant" })
);
</script>

34
src/layouts/Posts.astro Normal file
View file

@ -0,0 +1,34 @@
---
import Layout from "@layouts/Layout.astro";
import Main from "@layouts/Main.astro";
import Header from "@components/Header.astro";
import Footer from "@components/Footer.astro";
import Pagination from "@components/Pagination.astro";
import Card from "@components/Card";
import { SITE } from "@config";
import type { Page } from "astro";
import type { CollectionEntry } from "astro:content";
export interface Props {
page: Page<CollectionEntry<"blog">>;
}
const { page } = Astro.props;
---
<Layout title={`Posts | ${SITE.title}`}>
<Header activeNav="posts" />
<Main pageTitle="Posts" pageDesc="All the articles I've posted.">
<ul>
{
page.data.map(({ data, slug }) => (
<Card href={`/posts/${slug}/`} frontmatter={data} />
))
}
</ul>
</Main>
<Pagination {page} />
<Footer noMarginTop={page.lastPage > 1} />
</Layout>

View file

@ -0,0 +1,41 @@
---
import Layout from "@layouts/Layout.astro";
import Main from "@layouts/Main.astro";
import Header from "@components/Header.astro";
import Footer from "@components/Footer.astro";
import Card from "@components/Card";
import Pagination from "@components/Pagination.astro";
import { SITE } from "@config";
import type { Page } from "astro";
import type { CollectionEntry } from "astro:content";
export interface Props {
page: Page<CollectionEntry<"blog">>;
tag: string;
tagName: string;
}
const { page, tag, tagName } = Astro.props;
---
<Layout title={`Tag: ${tagName} | ${SITE.title}`}>
<Header activeNav="tags" />
<Main
pageTitle={[`Tag:`, `${tagName}`]}
titleTransition={tag}
pageDesc={`All the articles with the tag "${tagName}".`}
>
<h1 slot="title" transition:name={tag}>{`Tag:${tag}`}</h1>
<ul>
{
page.data.map(({ data, slug }) => (
<Card href={`/posts/${slug}/`} frontmatter={data} />
))
}
</ul>
</Main>
<Pagination {page} />
<Footer noMarginTop={page.lastPage > 1} />
</Layout>

42
src/pages/404.astro Normal file
View file

@ -0,0 +1,42 @@
---
import { SITE } from "@config";
import Layout from "@layouts/Layout.astro";
import Header from "@components/Header.astro";
import Footer from "@components/Footer.astro";
import LinkButton from "@components/LinkButton.astro";
---
<Layout title={`404 Not Found | ${SITE.title}`}>
<Header />
<main id="main-content">
<div class="not-found-wrapper">
<h1>404</h1>
<span aria-hidden="true">¯\_(ツ)_/¯</span>
<p>Page Not Found</p>
<LinkButton
href="/"
className="my-6 text-lg underline decoration-dashed underline-offset-8"
>
Go back home
</LinkButton>
</div>
</main>
<Footer />
</Layout>
<style>
#main-content {
@apply mx-auto flex max-w-3xl flex-1 items-center justify-center;
}
.not-found-wrapper {
@apply mb-14 flex flex-col items-center justify-center;
}
.not-found-wrapper h1 {
@apply text-9xl font-bold text-skin-accent;
}
.not-found-wrapper p {
@apply mt-4 text-2xl sm:text-3xl;
}
</style>

20
src/pages/about.md Normal file
View file

@ -0,0 +1,20 @@
---
layout: ../layouts/AboutLayout.astro
title: "About"
---
A software blog by tiff.
## Why did you make another blog, tiff?
Because I no longer code on the frontend and am focusing on cybersecurity and IT, I wanted a space to write about learning low level languages like C, C++, Rust, and Go.
I wanted a speedy implementation of all the JavaScript static site frameworks out there and to rely less on Jekyll as my only solution. I could have gone with GitBook or Mkdocs but I wanted something with a smaller footprint.
## What can I expect?
I will only post here occasionally. What that will be will be dependent on the things I am learning and building.
And that's also another reason I started this blog: I need a way to hold myself accountable. I aspire to build tools using the languages I learn. I've always half-assed the things I built, the scope too large, my experience not matching the attempts, and once I get part of the way there, there is some obstacle I feel doesn't warrant more time. I have hundreds of repos that are private that I've given up on.
The deal for me is this: start small. You don't have to [build a compiler in WebAssembly](https://healeycodes.com/a-custom-webassembly-compiler) to make something useful. _Just build, baby_.

View file

@ -0,0 +1,74 @@
---
import { getCollection } from "astro:content";
import Card from "@components/Card";
import Footer from "@components/Footer.astro";
import Header from "@components/Header.astro";
import { SITE } from "@config";
import Layout from "@layouts/Layout.astro";
import Main from "@layouts/Main.astro";
import getPostsByGroupCondition from "@utils/getPostsByGroupCondition";
// Redirect to 404 page if `showArchives` config is false
if (!SITE.showArchives) {
return Astro.redirect("/404");
}
const posts = await getCollection("blog", ({ data }) => !data.draft);
const MonthMap: Record<string, string> = {
"1": "January",
"2": "February",
"3": "March",
"4": "April",
"5": "May",
"6": "June",
"7": "July",
"8": "August",
"9": "September",
"10": "October",
"11": "November",
"12": "December",
};
---
<Layout title={`Archives | ${SITE.title}`}>
<Header activeNav="archives" />
<Main pageTitle="Archives" pageDesc="All the articles I've archived.">
{
Object.entries(
getPostsByGroupCondition(posts, post =>
post.data.pubDatetime.getFullYear()
)
)
.sort(([yearA], [yearB]) => Number(yearB) - Number(yearA))
.map(([year, yearGroup]) => (
<div>
<span class="text-2xl font-bold">{year}</span>
<sup class="text-sm">{yearGroup.length}</sup>
{Object.entries(
getPostsByGroupCondition(
yearGroup,
post => post.data.pubDatetime.getMonth() + 1
)
)
.sort(([monthA], [monthB]) => Number(monthB) - Number(monthA))
.map(([month, monthGroup]) => (
<div class="flex flex-col sm:flex-row">
<div class="mt-6 min-w-36 text-lg sm:my-6">
<span class="font-bold">{MonthMap[month]}</span>
<sup class="text-xs">{monthGroup.length}</sup>
</div>
<ul>
{monthGroup.map(({ data, slug }) => (
<Card href={`/posts/${slug}`} frontmatter={data} />
))}
</ul>
</div>
))}
</div>
))
}
</Main>
<Footer />
</Layout>

149
src/pages/index.astro Normal file
View file

@ -0,0 +1,149 @@
---
import { getCollection } from "astro:content";
import Layout from "@layouts/Layout.astro";
import Header from "@components/Header.astro";
import Footer from "@components/Footer.astro";
import LinkButton from "@components/LinkButton.astro";
import Hr from "@components/Hr.astro";
import Card from "@components/Card";
import Socials from "@components/Socials.astro";
import getSortedPosts from "@utils/getSortedPosts";
import { SITE, SOCIALS } from "@config";
const posts = await getCollection("blog");
const sortedPosts = getSortedPosts(posts);
const featuredPosts = sortedPosts.filter(({ data }) => data.featured);
const recentPosts = sortedPosts.filter(({ data }) => !data.featured);
const socialCount = SOCIALS.filter(social => social.active).length;
---
<Layout>
<Header />
<main id="main-content">
<section id="hero">
<h1>tiff on software</h1>
<a
target="_blank"
href="/rss.xml"
class="rss-link"
aria-label="rss feed"
title="RSS Feed"
>
<svg xmlns="http://www.w3.org/2000/svg" class="rss-icon"
><path
d="M19 20.001C19 11.729 12.271 5 4 5v2c7.168 0 13 5.832 13 13.001h2z"
></path><path
d="M12 20.001h2C14 14.486 9.514 10 4 10v2c4.411 0 8 3.589 8 8.001z"
></path><circle cx="6" cy="18" r="2"></circle>
</svg>
<span class="sr-only">RSS Feed</span>
</a>
<p>A software blog by tiff.</p>
{
// only display if at least one social link is enabled
socialCount > 0 && (
<div class="social-wrapper">
<div class="social-links">Social Links:</div>
<Socials />
</div>
)
}
</section>
<Hr />
{
featuredPosts.length > 0 && (
<>
<section id="featured">
<h2>Featured</h2>
<ul>
{featuredPosts.map(({ data, slug }) => (
<Card
href={`/posts/${slug}/`}
frontmatter={data}
secHeading={false}
/>
))}
</ul>
</section>
{recentPosts.length > 0 && <Hr />}
</>
)
}
{
recentPosts.length > 0 && (
<section id="recent-posts">
<h2>Recent Posts</h2>
<ul>
{recentPosts.map(
({ data, slug }, index) =>
index < SITE.postPerIndex && (
<Card
href={`/posts/${slug}/`}
frontmatter={data}
secHeading={false}
/>
)
)}
</ul>
</section>
)
}
<div class="all-posts-btn-wrapper">
<LinkButton href="/posts/">
All Posts
<svg xmlns="http://www.w3.org/2000/svg"
><path
d="m11.293 17.293 1.414 1.414L19.414 12l-6.707-6.707-1.414 1.414L15.586 11H6v2h9.586z"
></path>
</svg>
</LinkButton>
</div>
</main>
<Footer />
</Layout>
<style>
/* ===== Hero Section ===== */
#hero {
@apply pb-6 pt-8;
}
#hero h1 {
@apply my-4 inline-block text-3xl font-bold sm:my-8 sm:text-5xl;
}
#hero .rss-link {
@apply mb-6;
}
#hero .rss-icon {
@apply mb-2 h-6 w-6 scale-110 fill-skin-accent sm:mb-3 sm:scale-125;
}
#hero p {
@apply my-2;
}
.social-wrapper {
@apply mt-4 flex flex-col sm:flex-row sm:items-center;
}
.social-links {
@apply mb-1 mr-2 whitespace-nowrap sm:mb-0;
}
/* ===== Featured & Recent Posts Sections ===== */
#featured,
#recent-posts {
@apply pb-6 pt-12;
}
#featured h2,
#recent-posts h2 {
@apply text-2xl font-semibold tracking-wide;
}
.all-posts-btn-wrapper {
@apply my-8 text-center;
}
</style>

7
src/pages/og.png.ts Normal file
View file

@ -0,0 +1,7 @@
import type { APIRoute } from "astro";
import { generateOgImageForSite } from "@utils/generateOgImages";
export const GET: APIRoute = async () =>
new Response(await generateOgImageForSite(), {
headers: { "Content-Type": "image/png" },
});

View file

@ -0,0 +1,16 @@
---
import { SITE } from "@config";
import Posts from "@layouts/Posts.astro";
import type { GetStaticPaths } from "astro";
import { getCollection } from "astro:content";
import getSortedPosts from "@utils/getSortedPosts";
export const getStaticPaths = (async ({ paginate }) => {
const posts = await getCollection("blog", ({ data }) => !data.draft);
return paginate(getSortedPosts(posts), { pageSize: SITE.postPerPage });
}) satisfies GetStaticPaths;
const { page } = Astro.props;
---
<Posts {page} />

View file

@ -0,0 +1,27 @@
---
import { type CollectionEntry, getCollection } from "astro:content";
import PostDetails from "@layouts/PostDetails.astro";
import getSortedPosts from "@utils/getSortedPosts";
export interface Props {
post: CollectionEntry<"blog">;
}
export async function getStaticPaths() {
const posts = await getCollection("blog", ({ data }) => !data.draft);
const postResult = posts.map(post => ({
params: { slug: post.slug },
props: { post },
}));
return postResult;
}
const { post } = Astro.props;
const posts = await getCollection("blog");
const sortedPosts = getSortedPosts(posts);
---
<PostDetails post={post} posts={sortedPosts} />

View file

@ -0,0 +1,20 @@
import type { APIRoute } from "astro";
import { getCollection, type CollectionEntry } from "astro:content";
import { generateOgImageForPost } from "@utils/generateOgImages";
import { slugifyStr } from "@utils/slugify";
export async function getStaticPaths() {
const posts = await getCollection("blog").then(p =>
p.filter(({ data }) => !data.draft && !data.ogImage)
);
return posts.map(post => ({
params: { slug: slugifyStr(post.data.title) },
props: post,
}));
}
export const GET: APIRoute = async ({ props }) =>
new Response(await generateOgImageForPost(props as CollectionEntry<"blog">), {
headers: { "Content-Type": "image/png" },
});

17
src/pages/robots.txt.ts Normal file
View file

@ -0,0 +1,17 @@
import type { APIRoute } from "astro";
import { SITE } from "@config";
const robots = `
User-agent: Googlebot
Disallow: /nogooglebot/
User-agent: *
Allow: /
Sitemap: ${new URL("sitemap-index.xml", SITE.website).href}
`.trim();
export const GET: APIRoute = () =>
new Response(robots, {
headers: { "Content-Type": "text/plain" },
});

20
src/pages/rss.xml.ts Normal file
View file

@ -0,0 +1,20 @@
import rss from "@astrojs/rss";
import { getCollection } from "astro:content";
import getSortedPosts from "@utils/getSortedPosts";
import { SITE } from "@config";
export async function GET() {
const posts = await getCollection("blog");
const sortedPosts = getSortedPosts(posts);
return rss({
title: SITE.title,
description: SITE.desc,
site: SITE.website,
items: sortedPosts.map(({ data, slug }) => ({
link: `posts/${slug}/`,
title: data.title,
description: data.description,
pubDate: new Date(data.modDatetime ?? data.pubDatetime),
})),
});
}

30
src/pages/search.astro Normal file
View file

@ -0,0 +1,30 @@
---
import { getCollection } from "astro:content";
import { SITE } from "@config";
import Layout from "@layouts/Layout.astro";
import Main from "@layouts/Main.astro";
import Header from "@components/Header.astro";
import Footer from "@components/Footer.astro";
import SearchBar from "@components/Search";
import getSortedPosts from "@utils/getSortedPosts";
// Retrieve all published articles
const posts = await getCollection("blog", ({ data }) => !data.draft);
const sortedPosts = getSortedPosts(posts);
// List of items to search in
const searchList = sortedPosts.map(({ data, slug }) => ({
title: data.title,
description: data.description,
data,
slug,
}));
---
<Layout title={`Search | ${SITE.title}`}>
<Header activeNav="search" />
<Main pageTitle="Search" pageDesc="Search any article ...">
<SearchBar client:load searchList={searchList} />
</Main>
<Footer />
</Layout>

View file

@ -0,0 +1,29 @@
---
import { getCollection } from "astro:content";
import TagPosts from "@layouts/TagPosts.astro";
import getUniqueTags from "@utils/getUniqueTags";
import getPostsByTag from "@utils/getPostsByTag";
import type { GetStaticPathsOptions } from "astro";
import { SITE } from "@config";
export async function getStaticPaths({ paginate }: GetStaticPathsOptions) {
const posts = await getCollection("blog");
const tags = getUniqueTags(posts);
return tags.flatMap(({ tag, tagName }) => {
const tagPosts = getPostsByTag(posts, tag);
return paginate(tagPosts, {
params: { tag },
props: { tagName },
pageSize: SITE.postPerPage,
});
});
}
const params = Astro.params;
const { tag } = params;
const { page, tagName } = Astro.props;
---
<TagPosts {page} {tag} {tagName} />

View file

@ -0,0 +1,24 @@
---
import { getCollection } from "astro:content";
import Header from "@components/Header.astro";
import Footer from "@components/Footer.astro";
import Layout from "@layouts/Layout.astro";
import Main from "@layouts/Main.astro";
import Tag from "@components/Tag.astro";
import getUniqueTags from "@utils/getUniqueTags";
import { SITE } from "@config";
const posts = await getCollection("blog");
let tags = getUniqueTags(posts);
---
<Layout title={`Tags | ${SITE.title}`}>
<Header activeNav="tags" />
<Main pageTitle="Tags" pageDesc="All the tags used in posts.">
<ul>
{tags.map(({ tag }) => <Tag {tag} size="lg" />)}
</ul>
</Main>
<Footer />
</Layout>

137
src/styles/base.css Normal file
View file

@ -0,0 +1,137 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root,
html[data-theme="light"] {
--color-fill: 238, 238, 238;
--color-text-base: 53, 53, 56;
--color-accent: 210, 104, 120;
--color-card: 206, 213, 180;
--color-card-muted: 187, 199, 137;
--color-border: 124, 173, 255;
}
html[data-theme="dark"] {
--color-fill: 0, 1, 35;
--color-text-base: 234, 237, 243;
--color-accent: 97, 123, 255;
--color-card: 33, 34, 83;
--color-card-muted: 12, 14, 79;
--color-border: 48, 63, 138;
}
#sun-svg,
html[data-theme="dark"] #moon-svg {
display: none;
}
#moon-svg,
html[data-theme="dark"] #sun-svg {
display: block;
}
body {
@apply flex min-h-[100svh] flex-col bg-skin-fill font-mono text-skin-base selection:bg-skin-accent/70 selection:text-skin-inverted;
}
section,
footer {
@apply mx-auto max-w-3xl px-4;
}
a {
@apply outline-2 outline-offset-1 outline-skin-fill focus-visible:no-underline focus-visible:outline-dashed;
}
svg {
@apply inline-block h-6 w-6 fill-skin-base group-hover:fill-skin-accent;
}
svg.icon-tabler {
@apply inline-block h-6 w-6 scale-125 fill-transparent stroke-current stroke-2 opacity-90 group-hover:fill-transparent sm:scale-110;
}
.prose {
@apply prose-headings:!mb-3 prose-headings:!text-skin-base prose-h3:italic prose-p:!text-skin-base prose-a:!text-skin-base prose-a:!decoration-dashed prose-a:underline-offset-8 hover:prose-a:text-skin-accent prose-blockquote:!border-l-skin-accent/50 prose-blockquote:opacity-80 prose-figcaption:!text-skin-base prose-figcaption:opacity-70 prose-strong:!text-skin-base prose-code:rounded prose-code:bg-skin-card/75 prose-code:p-1 prose-code:before:!content-none prose-code:after:!content-none prose-ol:!text-skin-base prose-ul:overflow-x-clip prose-ul:!text-skin-base prose-li:marker:!text-skin-accent prose-table:text-skin-base prose-th:border prose-th:border-skin-line prose-td:border prose-td:border-skin-line prose-img:!my-2 prose-img:mx-auto prose-img:border-2 prose-img:border-skin-line prose-hr:!border-skin-line;
}
.prose a {
@apply break-words hover:!text-skin-accent;
}
.prose thead th:first-child,
tbody td:first-child,
tfoot td:first-child {
padding-left: 0.5714286em;
}
.prose h2#table-of-contents {
@apply mb-2;
}
.prose details {
@apply inline-block cursor-pointer select-none text-skin-base;
}
.prose summary {
@apply focus-outline;
}
.prose h2#table-of-contents + p {
@apply hidden;
}
/* ===== scrollbar ===== */
html {
overflow-y: scroll;
}
/* width */
::-webkit-scrollbar {
@apply w-3;
}
/* Track */
::-webkit-scrollbar-track {
@apply bg-slate-100;
}
/* Handle */
::-webkit-scrollbar-thumb {
@apply bg-slate-100;
}
/* Handle on hover */
::-webkit-scrollbar-thumb:hover {
@apply bg-slate-100;
}
/* ===== Code Blocks & Syntax Highlighting ===== */
pre:has(code) {
@apply border border-gray-200;
}
code,
blockquote {
word-wrap: break-word;
}
.prose :is(:where(code):not(:where([class~="not-prose"], [class~="not-prose"] *))) {
border-radius: 0.25rem !important;
color: #D26878 !important;
background-color: #e8e8e8;
padding: 0.25rem !important;
}
pre > code {
white-space: pre;
}
button.copy-code {
@apply bg-slate-200;
}
/* Apply Dark Theme (if multi-theme specified) */
html[data-theme="dark"] pre:has(code),
html[data-theme="dark"] pre:has(code) span {
color: var(--shiki-dark) !important;
background-color: var(--shiki-dark-bg) !important;
font-style: var(--shiki-dark-font-style) !important;
font-weight: var(--shiki-dark-font-weight) !important;
text-decoration: var(--shiki-dark-text-decoration) !important;
}
}
@layer components {
.display-none {
@apply hidden;
}
.focus-outline {
@apply outline-2 outline-offset-1 outline-skin-fill focus-visible:no-underline focus-visible:outline-dashed;
}
}

27
src/types.ts Normal file
View file

@ -0,0 +1,27 @@
import type socialIcons from "@assets/socialIcons";
export type Site = {
website: string;
author: string;
profile: string;
desc: string;
title: string;
ogImage?: string;
lightAndDarkMode: boolean;
postPerIndex: number;
postPerPage: number;
scheduledPostMargin: number;
showArchives?: boolean;
editPost?: {
url?: URL["href"];
text?: string;
appendFilePath?: boolean;
};
};
export type SocialObjects = {
name: keyof typeof socialIcons;
href: string;
active: boolean;
linkTitle: string;
}[];

View file

@ -0,0 +1,20 @@
import { Resvg } from "@resvg/resvg-js";
import { type CollectionEntry } from "astro:content";
import postOgImage from "./og-templates/post";
import siteOgImage from "./og-templates/site";
function svgBufferToPngBuffer(svg: string) {
const resvg = new Resvg(svg);
const pngData = resvg.render();
return pngData.asPng();
}
export async function generateOgImageForPost(post: CollectionEntry<"blog">) {
const svg = await postOgImage(post);
return svgBufferToPngBuffer(svg);
}
export async function generateOgImageForSite() {
const svg = await siteOgImage();
return svgBufferToPngBuffer(svg);
}

View file

@ -0,0 +1,25 @@
import type { CollectionEntry } from "astro:content";
type GroupKey = string | number | symbol;
interface GroupFunction<T> {
(item: T, index?: number): GroupKey;
}
const getPostsByGroupCondition = (
posts: CollectionEntry<"blog">[],
groupFunction: GroupFunction<CollectionEntry<"blog">>
) => {
const result: Record<GroupKey, CollectionEntry<"blog">[]> = {};
for (let i = 0; i < posts.length; i++) {
const item = posts[i];
const groupKey = groupFunction(item, i);
if (!result[groupKey]) {
result[groupKey] = [];
}
result[groupKey].push(item);
}
return result;
};
export default getPostsByGroupCondition;

View file

@ -0,0 +1,10 @@
import type { CollectionEntry } from "astro:content";
import getSortedPosts from "./getSortedPosts";
import { slugifyAll } from "./slugify";
const getPostsByTag = (posts: CollectionEntry<"blog">[], tag: string) =>
getSortedPosts(
posts.filter(post => slugifyAll(post.data.tags).includes(tag))
);
export default getPostsByTag;

View file

@ -0,0 +1,18 @@
import type { CollectionEntry } from "astro:content";
import postFilter from "./postFilter";
const getSortedPosts = (posts: CollectionEntry<"blog">[]) => {
return posts
.filter(postFilter)
.sort(
(a, b) =>
Math.floor(
new Date(b.data.modDatetime ?? b.data.pubDatetime).getTime() / 1000
) -
Math.floor(
new Date(a.data.modDatetime ?? a.data.pubDatetime).getTime() / 1000
)
);
};
export default getSortedPosts;

View file

@ -0,0 +1,23 @@
import { slugifyStr } from "./slugify";
import type { CollectionEntry } from "astro:content";
import postFilter from "./postFilter";
interface Tag {
tag: string;
tagName: string;
}
const getUniqueTags = (posts: CollectionEntry<"blog">[]) => {
const tags: Tag[] = posts
.filter(postFilter)
.flatMap(post => post.data.tags)
.map(tag => ({ tag: slugifyStr(tag), tagName: tag }))
.filter(
(value, index, self) =>
self.findIndex(tag => tag.tag === value.tag) === index
)
.sort((tagA, tagB) => tagA.tag.localeCompare(tagB.tag));
return tags;
};
export default getUniqueTags;

View file

@ -0,0 +1,71 @@
import type { FontStyle, FontWeight } from "satori";
export type FontOptions = {
name: string;
data: ArrayBuffer;
weight: FontWeight | undefined;
style: FontStyle | undefined;
};
async function loadGoogleFont(
font: string,
text: string
): Promise<ArrayBuffer> {
const API = `https://fonts.googleapis.com/css2?family=${font}&text=${encodeURIComponent(text)}`;
const css = await (
await fetch(API, {
headers: {
"User-Agent":
"Mozilla/5.0 (Macintosh; U; Intel Mac OS X 10_6_8; de-at) AppleWebKit/533.21.1 (KHTML, like Gecko) Version/5.0.5 Safari/533.21.1",
},
})
).text();
const resource = css.match(
/src: url\((.+)\) format\('(opentype|truetype)'\)/
);
if (!resource) throw new Error("Failed to download dynamic font");
const res = await fetch(resource[1]);
if (!res.ok) {
throw new Error("Failed to download dynamic font. Status: " + res.status);
}
const fonts: ArrayBuffer = await res.arrayBuffer();
return fonts;
}
async function loadGoogleFonts(
text: string
): Promise<
Array<{ name: string; data: ArrayBuffer; weight: number; style: string }>
> {
const fontsConfig = [
{
name: "IBM Plex Mono",
font: "IBM+Plex+Mono",
weight: 400,
style: "normal",
},
{
name: "IBM Plex Mono",
font: "IBM+Plex+Mono:wght@700",
weight: 700,
style: "bold",
},
];
const fonts = await Promise.all(
fontsConfig.map(async ({ name, font, weight, style }) => {
const data = await loadGoogleFont(font, text);
return { name, data, weight, style };
})
);
return fonts;
}
export default loadGoogleFonts;

View file

@ -0,0 +1,106 @@
import satori from "satori";
import type { CollectionEntry } from "astro:content";
import { SITE } from "@config";
import loadGoogleFonts, { type FontOptions } from "../loadGoogleFont";
export default async (post: CollectionEntry<"blog">) => {
return satori(
<div
style={{
background: "#fefbfb",
width: "100%",
height: "100%",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<div
style={{
position: "absolute",
top: "-1px",
right: "-1px",
border: "4px solid #000",
background: "#ecebeb",
opacity: "0.9",
borderRadius: "4px",
display: "flex",
justifyContent: "center",
margin: "2.5rem",
width: "88%",
height: "80%",
}}
/>
<div
style={{
border: "4px solid #000",
background: "#fefbfb",
borderRadius: "4px",
display: "flex",
justifyContent: "center",
margin: "2rem",
width: "88%",
height: "80%",
}}
>
<div
style={{
display: "flex",
flexDirection: "column",
justifyContent: "space-between",
margin: "20px",
width: "90%",
height: "90%",
}}
>
<p
style={{
fontSize: 72,
fontWeight: "bold",
maxHeight: "84%",
overflow: "hidden",
}}
>
{post.data.title}
</p>
<div
style={{
display: "flex",
justifyContent: "space-between",
width: "100%",
marginBottom: "8px",
fontSize: 28,
}}
>
<span>
by{" "}
<span
style={{
color: "transparent",
}}
>
"
</span>
<span style={{ overflow: "hidden", fontWeight: "bold" }}>
{post.data.author}
</span>
</span>
<span style={{ overflow: "hidden", fontWeight: "bold" }}>
{SITE.title}
</span>
</div>
</div>
</div>
</div>,
{
width: 1200,
height: 630,
embedFont: true,
fonts: (await loadGoogleFonts(
post.data.title + post.data.author + SITE.title + "by"
)) as FontOptions[],
}
);
};

View file

@ -0,0 +1,97 @@
import satori from "satori";
import { SITE } from "@config";
import loadGoogleFonts, { type FontOptions } from "../loadGoogleFont";
export default async () => {
return satori(
<div
style={{
background: "#fefbfb",
width: "100%",
height: "100%",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<div
style={{
position: "absolute",
top: "-1px",
right: "-1px",
border: "4px solid #000",
background: "#ecebeb",
opacity: "0.9",
borderRadius: "4px",
display: "flex",
justifyContent: "center",
margin: "2.5rem",
width: "88%",
height: "80%",
}}
/>
<div
style={{
border: "4px solid #000",
background: "#fefbfb",
borderRadius: "4px",
display: "flex",
justifyContent: "center",
margin: "2rem",
width: "88%",
height: "80%",
}}
>
<div
style={{
display: "flex",
flexDirection: "column",
justifyContent: "space-between",
margin: "20px",
width: "90%",
height: "90%",
}}
>
<div
style={{
display: "flex",
flexDirection: "column",
justifyContent: "center",
alignItems: "center",
height: "90%",
maxHeight: "90%",
overflow: "hidden",
textAlign: "center",
}}
>
<p style={{ fontSize: 72, fontWeight: "bold" }}>{SITE.title}</p>
<p style={{ fontSize: 28 }}>{SITE.desc}</p>
</div>
<div
style={{
display: "flex",
justifyContent: "flex-end",
width: "100%",
marginBottom: "8px",
fontSize: 28,
}}
>
<span style={{ overflow: "hidden", fontWeight: "bold" }}>
{new URL(SITE.website).hostname}
</span>
</div>
</div>
</div>
</div>,
{
width: 1200,
height: 630,
embedFont: true,
fonts: (await loadGoogleFonts(
SITE.title + SITE.desc + SITE.website
)) as FontOptions[],
}
);
};

11
src/utils/postFilter.ts Normal file
View file

@ -0,0 +1,11 @@
import { SITE } from "@config";
import type { CollectionEntry } from "astro:content";
const postFilter = ({ data }: CollectionEntry<"blog">) => {
const isPublishTimePassed =
Date.now() >
new Date(data.pubDatetime).getTime() - SITE.scheduledPostMargin;
return !data.draft && (import.meta.env.DEV || isPublishTimePassed);
};
export default postFilter;

5
src/utils/slugify.ts Normal file
View file

@ -0,0 +1,5 @@
import kebabcase from "lodash.kebabcase";
export const slugifyStr = (str: string) => kebabcase(str);
export const slugifyAll = (arr: string[]) => arr.map(str => slugifyStr(str));