mirror of
https://github.com/prdlk/prdlk.git
synced 2026-11-05 22:47:57 +00:00
feat(utils): add date, DOM, TOC, remark, SEO, and webmentions utility functions
This commit is contained in:
1 parent
8e4ed92ece
commit
ec3da9a082
6 files changed
+361
No files matched your search
@@ -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();
|
||||||
|
}
|
||||||
@@ -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";
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
// Heavy inspiration from starlight: https://github.com/withastro/starlight/blob/main/packages/starlight/utils/generateToC.ts
|
||||||
|
import type { MarkdownHeading } from "astro";
|
||||||
|
|
||||||
|
export interface TocItem extends MarkdownHeading {
|
||||||
|
children: TocItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TocOpts {
|
||||||
|
maxHeadingLevel?: number | undefined;
|
||||||
|
minHeadingLevel?: number | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Inject a ToC entry as deep in the tree as its `depth` property requires. */
|
||||||
|
function injectChild(items: TocItem[], item: TocItem): void {
|
||||||
|
const lastItem = items.at(-1);
|
||||||
|
if (!lastItem || lastItem.depth >= item.depth) {
|
||||||
|
items.push(item);
|
||||||
|
} else {
|
||||||
|
injectChild(lastItem.children, item);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateToc(
|
||||||
|
headings: ReadonlyArray<MarkdownHeading>,
|
||||||
|
{ maxHeadingLevel = 4, minHeadingLevel = 2 }: TocOpts = {},
|
||||||
|
) {
|
||||||
|
// by default this ignores/filters out h1 and h5 heading(s)
|
||||||
|
const bodyHeadings = headings.filter(
|
||||||
|
({ depth }) => depth >= minHeadingLevel && depth <= maxHeadingLevel,
|
||||||
|
);
|
||||||
|
const toc: Array<TocItem> = [];
|
||||||
|
|
||||||
|
for (const heading of bodyHeadings) injectChild(toc, { ...heading, children: [] });
|
||||||
|
|
||||||
|
return toc;
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
import { siteConfig, socialLinks } from "@/site.config";
|
||||||
|
|
||||||
|
type Schema = Record<string, unknown>;
|
||||||
|
|
||||||
|
/** 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(", ") } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
Reference in new issue
Block a user