diff --git a/src/utils/date.ts b/src/utils/date.ts new file mode 100644 index 0000000..e7da15b --- /dev/null +++ b/src/utils/date.ts @@ -0,0 +1,31 @@ +import type { CollectionEntry } from "astro:content"; +import { siteConfig } from "@/site.config"; + +export function getFormattedDate( + date: Date | undefined, + options?: Intl.DateTimeFormatOptions, +): string { + if (date === undefined) { + return "Invalid Date"; + } + + return new Intl.DateTimeFormat(siteConfig.date.locale, { + ...(siteConfig.date.options as Intl.DateTimeFormatOptions), + ...options, + }).format(date); +} + +export function collectionDateSort( + a: CollectionEntry<"writing" | "speaking">, + b: CollectionEntry<"writing" | "speaking">, +) { + return b.data.publishDate.getTime() - a.data.publishDate.getTime(); +} + +/** Sort projects by end date, most recent first; ongoing (no end date) first. */ +export function projectDateSort(a: CollectionEntry<"projects">, b: CollectionEntry<"projects">) { + const ae = a.data.endDate?.getTime() ?? Number.POSITIVE_INFINITY; + const be = b.data.endDate?.getTime() ?? Number.POSITIVE_INFINITY; + if (be !== ae) return be - ae; + return b.data.startDate.getTime() - a.data.startDate.getTime(); +} diff --git a/src/utils/domElement.ts b/src/utils/domElement.ts new file mode 100644 index 0000000..09361fc --- /dev/null +++ b/src/utils/domElement.ts @@ -0,0 +1,11 @@ +export function toggleClass(element: HTMLElement, className: string) { + element.classList.toggle(className); +} + +export function elementHasClass(element: HTMLElement, className: string) { + return element.classList.contains(className); +} + +export function rootInDarkMode() { + return document.documentElement.getAttribute("data-theme") === "dark"; +} diff --git a/src/utils/generateToc.ts b/src/utils/generateToc.ts new file mode 100644 index 0000000..f63f0bf --- /dev/null +++ b/src/utils/generateToc.ts @@ -0,0 +1,37 @@ +// Heavy inspiration from starlight: https://github.com/withastro/starlight/blob/main/packages/starlight/utils/generateToC.ts +import type { MarkdownHeading } from "astro"; + +export interface TocItem extends MarkdownHeading { + children: TocItem[]; +} + +interface TocOpts { + maxHeadingLevel?: number | undefined; + minHeadingLevel?: number | undefined; +} + +/** Inject a ToC entry as deep in the tree as its `depth` property requires. */ +function injectChild(items: TocItem[], item: TocItem): void { + const lastItem = items.at(-1); + if (!lastItem || lastItem.depth >= item.depth) { + items.push(item); + } else { + injectChild(lastItem.children, item); + return; + } +} + +export function generateToc( + headings: ReadonlyArray, + { maxHeadingLevel = 4, minHeadingLevel = 2 }: TocOpts = {}, +) { + // by default this ignores/filters out h1 and h5 heading(s) + const bodyHeadings = headings.filter( + ({ depth }) => depth >= minHeadingLevel && depth <= maxHeadingLevel, + ); + const toc: Array = []; + + for (const heading of bodyHeadings) injectChild(toc, { ...heading, children: [] }); + + return toc; +} diff --git a/src/utils/remark.ts b/src/utils/remark.ts new file mode 100644 index 0000000..9f0834e --- /dev/null +++ b/src/utils/remark.ts @@ -0,0 +1,23 @@ +import { h as _h, type Properties } from "hastscript"; +import type { Node, Paragraph as P } from "mdast"; +import type { Directives } from "mdast-util-directive"; + +/** Checks if a node is a directive. */ +export function isNodeDirective(node: Node): node is Directives { + return ( + node.type === "containerDirective" || + node.type === "leafDirective" || + node.type === "textDirective" + ); +} + +/** From Astro Starlight: Function that generates an mdast HTML tree ready for conversion to HTML by rehype. */ +// biome-ignore lint/suspicious/noExplicitAny: allow any children +export function h(el: string, attrs: Properties = {}, children: any[] = []): P { + const { properties, tagName } = _h(el, attrs); + return { + children, + data: { hName: tagName, hProperties: properties }, + type: "paragraph", + }; +} diff --git a/src/utils/seo.ts b/src/utils/seo.ts new file mode 100644 index 0000000..e850cd8 --- /dev/null +++ b/src/utils/seo.ts @@ -0,0 +1,144 @@ +import { siteConfig, socialLinks } from "@/site.config"; + +type Schema = Record; + +/** Absolute URL from a site-root path. */ +export const abs = (path: string) => new URL(path, siteConfig.url).href; + +const authorRef = () => ({ + "@type": "Person", + name: siteConfig.author, + url: siteConfig.url, +}); + +/** schema.org BreadcrumbList — Google renders these as the breadcrumb trail in results. */ +export function breadcrumbList(items: { name: string; path: string }[]): Schema { + return { + "@context": "https://schema.org", + "@type": "BreadcrumbList", + itemListElement: items.map((it, i) => ({ + "@type": "ListItem", + position: i + 1, + name: it.name, + item: abs(it.path), + })), + }; +} + +/** Person — connects social profiles (sameAs) and skills (knowsAbout) to the author. */ +export function personSchema(knowsAbout: string[] = []): Schema { + return { + "@context": "https://schema.org", + "@type": "Person", + name: siteConfig.author, + url: siteConfig.url, + image: abs("/avatar.png"), + jobTitle: "Engineer & Founder", + description: siteConfig.description, + sameAs: socialLinks.filter((s) => !s.link.startsWith("mailto:")).map((s) => s.link), + ...(knowsAbout.length ? { knowsAbout } : {}), + }; +} + +export function websiteSchema(): Schema { + return { + "@context": "https://schema.org", + "@type": "WebSite", + name: siteConfig.title, + url: siteConfig.url, + description: siteConfig.description, + author: authorRef(), + }; +} + +export function articleSchema(opts: { + title: string; + description?: string; + path: string; + image: string; + datePublished: Date; + dateModified?: Date; + keywords?: string[]; +}): Schema { + return { + "@context": "https://schema.org", + "@type": "BlogPosting", + headline: opts.title, + description: opts.description, + image: abs(opts.image), + datePublished: opts.datePublished.toISOString(), + dateModified: (opts.dateModified ?? opts.datePublished).toISOString(), + author: authorRef(), + publisher: authorRef(), + mainEntityOfPage: { "@type": "WebPage", "@id": abs(opts.path) }, + ...(opts.keywords?.length ? { keywords: opts.keywords.join(", ") } : {}), + }; +} + +export function videoSchema(opts: { + title: string; + description: string; + path: string; + thumbnail: string; + uploadDate: Date; + embedUrl?: string; + contentUrl?: string; +}): Schema { + return { + "@context": "https://schema.org", + "@type": "VideoObject", + name: opts.title, + description: opts.description, + thumbnailUrl: abs(opts.thumbnail), + uploadDate: opts.uploadDate.toISOString(), + ...(opts.embedUrl ? { embedUrl: opts.embedUrl } : {}), + ...(opts.contentUrl ? { contentUrl: opts.contentUrl } : {}), + }; +} + +/** CollectionPage wrapping an ItemList — for section/list pages. */ +export function collectionPageSchema(opts: { + name: string; + description?: string; + path: string; + items: { name: string; path: string }[]; +}): Schema { + return { + "@context": "https://schema.org", + "@type": "CollectionPage", + name: opts.name, + ...(opts.description ? { description: opts.description } : {}), + url: abs(opts.path), + mainEntity: { + "@type": "ItemList", + itemListElement: opts.items.map((it, i) => ({ + "@type": "ListItem", + position: i + 1, + name: it.name, + url: abs(it.path), + })), + }, + }; +} + +export function creativeWorkSchema(opts: { + title: string; + description?: string; + path: string; + url?: string; + repo?: string; + dateCreated: Date; + keywords?: string[]; +}): Schema { + return { + "@context": "https://schema.org", + "@type": "CreativeWork", + name: opts.title, + description: opts.description, + url: opts.url ?? abs(opts.path), + dateCreated: opts.dateCreated.toISOString(), + author: authorRef(), + ...(opts.repo ? { codeRepository: opts.repo } : {}), + ...(opts.keywords?.length ? { keywords: opts.keywords.join(", ") } : {}), + }; +} diff --git a/src/utils/webmentions.ts b/src/utils/webmentions.ts new file mode 100644 index 0000000..8edfd90 --- /dev/null +++ b/src/utils/webmentions.ts @@ -0,0 +1,115 @@ +import { WEBMENTION_API_KEY } from "astro:env/server"; +import * as fs from "node:fs"; +import type { WebmentionsCache, WebmentionsChildren, WebmentionsFeed } from "@/types"; + +const DOMAIN = import.meta.env.SITE; +const CACHE_DIR = ".data"; +const filePath = `${CACHE_DIR}/webmentions.json`; +const validWebmentionTypes = ["like-of", "mention-of", "in-reply-to"]; + +const hostName = new URL(DOMAIN).hostname; + +// Calls webmention.io api. +async function fetchWebmentions(timeFrom: string | null, perPage = 1000) { + if (!DOMAIN) { + console.warn("No domain specified. Please set in astro.config.ts"); + return null; + } + + if (!WEBMENTION_API_KEY) { + console.warn("No webmention api token specified in .env"); + return null; + } + + let url = `https://webmention.io/api/mentions.jf2?domain=${hostName}&token=${WEBMENTION_API_KEY}&sort-dir=up&per-page=${perPage}`; + + if (timeFrom) url += `&since${timeFrom}`; + + const res = await fetch(url); + + if (res.ok) { + const data = (await res.json()) as WebmentionsFeed; + return data; + } + + return null; +} + +// Merge cached entries [a] with fresh webmentions [b], merge by wm-id +function mergeWebmentions(a: WebmentionsCache, b: WebmentionsFeed): WebmentionsChildren[] { + return Array.from( + [...a.children, ...b.children] + .reduce((map, obj) => map.set(obj["wm-id"], obj), new Map()) + .values(), + ); +} + +// filter out WebmentionChildren +export function filterWebmentions(webmentions: WebmentionsChildren[]) { + return webmentions.filter((webmention) => { + // make sure the mention has a property so we can sort them later + if (!validWebmentionTypes.includes(webmention["wm-property"])) return false; + + // make sure 'mention-of' or 'in-reply-to' has text content. + if (webmention["wm-property"] === "mention-of" || webmention["wm-property"] === "in-reply-to") { + return webmention.content && webmention.content.text !== ""; + } + + return true; + }); +} + +// save combined webmentions in cache file +function writeToCache(data: WebmentionsCache) { + const fileContent = JSON.stringify(data, null, 2); + + // create cache folder if it doesn't exist already + if (!fs.existsSync(CACHE_DIR)) { + fs.mkdirSync(CACHE_DIR); + } + + // write data to cache json file + fs.writeFile(filePath, fileContent, (err) => { + if (err) throw err; + console.log(`Webmentions saved to ${filePath}`); + }); +} + +function getFromCache(): WebmentionsCache { + if (fs.existsSync(filePath)) { + const data = fs.readFileSync(filePath, "utf-8"); + return JSON.parse(data); + } + // no cache found + return { + lastFetched: null, + children: [], + }; +} + +async function getAndCacheWebmentions() { + const cache = getFromCache(); + const mentions = await fetchWebmentions(cache.lastFetched); + + if (mentions) { + mentions.children = filterWebmentions(mentions.children); + const webmentions: WebmentionsCache = { + lastFetched: new Date().toISOString(), + // Make sure the first arg is the cache + children: mergeWebmentions(cache, mentions), + }; + + writeToCache(webmentions); + return webmentions; + } + + return cache; +} + +let webMentions: WebmentionsCache; + +export async function getWebmentionsForUrl(url: string) { + if (!webMentions) webMentions = await getAndCacheWebmentions(); + + return webMentions.children.filter((entry) => entry["wm-target"] === url); +}