From 8c34d810af95fae0ef846f54370a8c88bfab7123 Mon Sep 17 00:00:00 2001 From: "netop://ウィビ" Date: Sat, 11 Apr 2026 14:24:49 -0700 Subject: initial commit --- src/utility/constant.ts | 64 ++++++++++++++++++++ src/utility/feed/atom.ts | 87 +++++++++++++++++++++++++++ src/utility/feed/helper.ts | 67 +++++++++++++++++++++ src/utility/feed/index.ts | 12 ++++ src/utility/feed/json.ts | 41 +++++++++++++ src/utility/feed/rss.ts | 85 ++++++++++++++++++++++++++ src/utility/markdown.ts | 147 +++++++++++++++++++++++++++++++++++++++++++++ 7 files changed, 503 insertions(+) create mode 100644 src/utility/constant.ts create mode 100644 src/utility/feed/atom.ts create mode 100644 src/utility/feed/helper.ts create mode 100644 src/utility/feed/index.ts create mode 100644 src/utility/feed/json.ts create mode 100644 src/utility/feed/rss.ts create mode 100644 src/utility/markdown.ts (limited to 'src/utility') diff --git a/src/utility/constant.ts b/src/utility/constant.ts new file mode 100644 index 0000000..d734164 --- /dev/null +++ b/src/utility/constant.ts @@ -0,0 +1,64 @@ + + + +/*** IMPORT ------------------------------------------- ***/ + +import { join } from "dep/std.ts"; + +/*** UTILITY ------------------------------------------ ***/ + +const baseDirectory = await Deno.realPath("."); +const isDevelopment = Deno.args.includes("development"); + +/*** EXPORT ------------------------------------------- ***/ + +export const author = "Paul Anthony Webb"; +export const description = "Welcome to Paul Anthony Webb’s corner of the ’Net where he’ll regale you with whatever he finds interesting."; +export const descriptionRemarks = "Nifty notes and snippets I come across that aren’t suitable for long-form posts."; +export const email = "paul+blog@webb.page"; +export const environment = isDevelopment ? "development" : "production"; +export const feedDirectory = join(baseDirectory, "feed"); +export const memoDirectory = join(baseDirectory, "memos"); +export const port = Number(Deno.env.has("PORT") ? Deno.env.get("PORT") : 3465); +export const remarkDirectory = join(baseDirectory, "remarks"); +export const remarkRegex = /^(WR-\d*).txt$/; +export const title = "the webb blog"; +export const titleRemarks = "the webb blog • remarks"; +export const url = "https://blog.webb.page"; +export const urlRemarks = "https://blog.webb.page/remarks"; + +export async function getVersion() { + let version = ""; + + try { + version = await Deno.readTextFile("./version.txt"); + } catch { + /*** ignore ***/ + } + + return version.trim(); +} + +export const errorMessage = ` + + + + + + + +Document: HTTP 404 ERROR + 20XX + + Not Found + +Body + + Not sure what you were looking for but, it’s not here. Welp. Maybe + try the main page[1]? + +References + + [1] + +`; diff --git a/src/utility/feed/atom.ts b/src/utility/feed/atom.ts new file mode 100644 index 0000000..d8827e9 --- /dev/null +++ b/src/utility/feed/atom.ts @@ -0,0 +1,87 @@ + + + +/*** UTILITY ------------------------------------------ ***/ + +import { BaseFeed, escapeXML, type FeedOptions } from "./helper.ts"; + +interface AtomEntry { + content?: { + body: string; + type?: string; + }; + id: string; + image?: string; + link: string; + summary: string; + title: string; + updated?: Date; +} + +/*** EXPORT ------------------------------------------- ***/ + +export class FeedAtom extends BaseFeed { + constructor(options: FeedOptions) { + super(options); + } + + build(): string { + const xmlParts: string[] = [ + `\n`, + `\n`, + ` ${escapeXML(this.options.title)}\n`, + ` ${escapeXML(this.options.description)}\n`, + ` \n`, + ` \n`, + ` ${this.options.updated?.toISOString()}\n`, + ` ${this.options.generator || "the webb blog"}\n`, + ` ${escapeXML(this.options.link)}\n` + ]; + + for (const author of this.options.authors) { + xmlParts.push(` \n`); + + if (author.name) + xmlParts.push(` ${escapeXML(author.name)}\n`); + + if (author.email) + xmlParts.push(` ${escapeXML(author.email)}\n`); + + if (author.link) + xmlParts.push(` ${escapeXML(author.link)}\n`); + + xmlParts.push(` \n`); + } + + if (this.options.icon) { + xmlParts.push(` ${escapeXML(this.options.icon)}\n`); + xmlParts.push(` ${escapeXML(this.options.icon)}\n`); + } + + if (this.options.feed) + xmlParts.push(` \n`); + + for (const entry of this.items) { + xmlParts.push( + ` \n`, + ` ${escapeXML(entry.title)}\n`, + ` \n`, + ` ${escapeXML(entry.id)}\n`, + ` ${entry.updated?.toISOString() || new Date().toISOString()}\n`, + ` ${escapeXML(entry.summary)}\n`, + ` ${escapeXML(entry.content?.body || entry.summary)}\n`, + entry.image ? + ` \n` : + "", + ` \n` + ); + } + + for (const category of this.categories) { + xmlParts.push(` \n`); + } + + xmlParts.push(""); + return xmlParts.join(""); + } +} diff --git a/src/utility/feed/helper.ts b/src/utility/feed/helper.ts new file mode 100644 index 0000000..0177364 --- /dev/null +++ b/src/utility/feed/helper.ts @@ -0,0 +1,67 @@ + + + +/*** EXPORT ------------------------------------------- ***/ + +export interface FeedOptions { + authors: Array<{ + name: string; + email?: string; + link?: string; + }>; + copyright?: string; + description: string; + feed?: string; + feedLinks?: { + atom?: string; + }; + generator?: string; + icon?: string; + id?: string; + language?: string; + link: string; + title: string; + updated?: Date; +} + +export function escapeXML(unsafe: string): string { + const escapeMap: { [key: string]: string } = { + "<": "<", + ">": ">", + "&": "&", + "'": "'", + '"': """ + }; + + return unsafe.replace(/[<>&'"]/g, (c) => escapeMap[c] || c); +} + +export abstract class BaseFeed { + protected categories: Set; + protected items: Array; + protected options: FeedOptions; + + constructor(options: FeedOptions) { + this.categories = new Set(); + this.items = []; + + this.options = { + ...options, + updated: options.updated || new Date() + }; + } + + abstract build(): string; + + addCategory(category: string) { + this.categories.add(category); + } + + addContributor(contributor: { name: string; email: string; link?: string }) { + this.options.authors.push(contributor); + } + + addItem(item: T) { + this.items.push(item); + } +} diff --git a/src/utility/feed/index.ts b/src/utility/feed/index.ts new file mode 100644 index 0000000..d58609a --- /dev/null +++ b/src/utility/feed/index.ts @@ -0,0 +1,12 @@ + + + +/*** EXPORT ------------------------------------------- ***/ + +export { FeedAtom as ATOM } from "./atom.ts"; +export { FeedJSON as JSON } from "./json.ts"; +export { FeedRSS as RSS } from "./rss.ts"; + + + +/*** forked from https://github.com/GabsEdits/feed ***/ diff --git a/src/utility/feed/json.ts b/src/utility/feed/json.ts new file mode 100644 index 0000000..589aeae --- /dev/null +++ b/src/utility/feed/json.ts @@ -0,0 +1,41 @@ + + + +/*** UTILITY ------------------------------------------ ***/ + +import { BaseFeed, type FeedOptions } from "./helper.ts"; + +interface JsonItem { + content_html?: string; + date_published?: Date; + id: string; + title: string; + url: string; +} + +/*** EXPORT ------------------------------------------- ***/ + +export class FeedJSON extends BaseFeed { + constructor(options: FeedOptions) { + super(options); + } + + build(): string { + const json: Record = { + feed_url: this.options.feed, + home_page_url: this.options.link, + icon: this.options.icon, + items: this.items.map(({ content_html, date_published, id, title, url }) => ({ + ...(content_html && { content_html }), + date_published: date_published?.toISOString() || new Date().toUTCString, + id, + title, + url + })), + title: this.options.title, + version: "https://jsonfeed.org/version/1" + }; + + return JSON.stringify(json, null, 2); + } +} diff --git a/src/utility/feed/rss.ts b/src/utility/feed/rss.ts new file mode 100644 index 0000000..c3971bb --- /dev/null +++ b/src/utility/feed/rss.ts @@ -0,0 +1,85 @@ + + + +/*** UTILITY ------------------------------------------ ***/ + +import { BaseFeed, escapeXML, type FeedOptions } from "./helper.ts"; + +interface RssItem { + content: { + body: string; + }; + description: string; + id: string; + image?: string; + link: string; + title: string; + updated?: Date; +} + +/*** EXPORT ------------------------------------------- ***/ + +export class FeedRSS extends BaseFeed { + constructor(options: FeedOptions) { + super(options); + } + + build(): string { + const xmlParts: string[] = [ + `\n`, + `\n`, + ` \n`, + ` <![CDATA[${escapeXML(this.options.title)}]]>\n`, + ` \n`, + ` ${escapeXML(this.options.link)}\n`, + ` ${this.options.updated?.toUTCString()}\n`, + ` ${this.options.language || "en-US"}\n`, + ` ${escapeXML(this.options.generator || "the webb blog")}\n`, + ` \n` + ]; + + if (this.options.authors.length > 0) { + const authorXml = this.options.authors.map((author) => { + const escapedEmail = author.email ? escapeXML(author.email) : ""; + const escapedName = author.name ? escapeXML(author.name) : ""; + const emailPart = escapedEmail ? `${escapedEmail} ` : ""; + + return ( + ` ${emailPart}(${escapedName})\n` + + ` ${emailPart}(${escapedName})\n` + ); + }).join(""); + + xmlParts.push(authorXml); + } + + if (this.items.length > 0) { + const itemsXml = this.items.map((item) => { + const contentXml = item.content ? + ` \n` : + ""; + + const imageXml = item.image ? + ` \n` : + ""; + + return ( + ` \n` + + ` ${escapeXML(item.title)}\n` + + ` ${escapeXML(item.link)}\n` + + ` ${escapeXML(item.id)}\n` + + ` ${item.updated?.toUTCString() || new Date().toUTCString()}\n` + + ` ${escapeXML(item.description)}\n` + + contentXml + + imageXml + + ` \n` + ); + }).join(""); + + xmlParts.push(itemsXml); + } + + xmlParts.push(` \n`, ``); + return xmlParts.join(""); + } +} diff --git a/src/utility/markdown.ts b/src/utility/markdown.ts new file mode 100644 index 0000000..f78cfee --- /dev/null +++ b/src/utility/markdown.ts @@ -0,0 +1,147 @@ + + + +/*** EXPORT ------------------------------------------- ***/ + +export default (input: string): string => { + const codeBlocks: { content: string; index: number; language: string; }[] = []; + const codeBlockPattern = /```(\w*)[ \t]*\r?\n([\s\S]*?)\r?\n[ \t]*```/g; + const inlineCode: string[] = []; + const inlineCodePattern = /`([^`]+)`/g; + const refPattern = /\s*\[(\w+)\]\s+<([^>]+)>$/gm; + const refs = new Map(); + + /*** extract and replace code blocks with placeholders ***/ + let processed = input.replace(codeBlockPattern, (_match, language, content) => { + const index = codeBlocks.length; + + codeBlocks.push({ + content: content.trimEnd(), + index, + language: language || "" + }); + + return ``; + }); + + /*** extract inline code ***/ + processed = processed.replace(inlineCodePattern, (_match, content) => { + const index = inlineCode.length; + inlineCode.push(content); + return ``; + }); + + /*** extract reference definitions (images/links) ***/ + for (const match of processed.matchAll(refPattern)) { + const [, ref, url] = match; + refs.set(ref, url); + } + + /*** remove reference definitions before blockquote processing ***/ + processed = processed.replace(refPattern, ""); + + /*** process blockquotes ***/ + processed = processBlockquotes(processed); + + processed = processed + .replace(/(\n){6}/g, "") /*** remove 6 lines from the top of the memo ***/ + .replace("References", "") /*** remove reference to reference definitions from output ***/ + .replace(/(\*\*|__)(.*?)\1/g, `$2`) /*** bold ***/ + .replace(/(\*|_)(.*?)\1/g, "$2") /*** italic ***/ + .replace(/(\~\~)(.*?)\1/g, `$2`) /*** strikethrough ***/ + .replace(/'/g, "’") /*** fancy apostrophe ***/ + .replace(/(:nbhyp:)/g, "‑") /*** non‑breaking hyphen ***/ + // deno-lint-ignore no-irregular-whitespace + .replace(/(:nbsp:)/g, " ") /*** non‑breaking space ***/ + .replace(/(\.\.\.)/g, "…") /*** ellipsis ***/ + .replace(/---/g, "
") + .replace(/📸\[([^\]]+)\]\[(\w+)\]/g, (match, alt, ref) => { + const url = refs.get(ref); + return url ? `${alt}` : match; + }) + .replace(/📼\[([^\]]+)\]\[(\w+)\]/g, (match, alt, ref) => { + const url = refs.get(ref); + return url ? `` : match; + }) + .replace(/\[(\w+)\](?!\s+<|\()/g, (match, ref) => { + const url = refs.get(ref); + const isExternal = url && url.startsWith("http://") || url && url.startsWith("https://"); + + return url ? + isExternal ? + `[${ref}]` : + `[${ref}]` : + match; + }) + .replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_match, text, url) => { + const isExternal = url.startsWith("http://") || url.startsWith("https://"); + + return isExternal ? + `${text}` : + `${text}`; + }); + + /*** restore inline code ***/ + for (const block in inlineCode) { + processed = processed.replace(``, `${escapeHtml(inlineCode[block])}`); + } + + /*** restore code blocks ***/ + for (const block of codeBlocks) { + const langAttr = block.language ? ` data-language="${block.language}"` : ""; + const html = `
\n${escapeHtml(block.content)}\n\n
`; + + processed = processed.replace(``, html); + } + + // console.log(processed); /*** for troubleshooting ***/ + + return processed.trimEnd(); +} + +/*** HELPER ------------------------------------------- ***/ + +function escapeHtml(str: string): string { + return str + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); +} + +function processBlockquotes(input: string): string { + const lines = input.split("\n"); + const result: string[] = []; + let quoteBuffer: string[] = []; + + const flushQuote = () => { + if (quoteBuffer.length > 0) { + /*** special indentation to fit memo/remark formatting ***/ + result.push(`
\n ${quoteBuffer.join("\n ")}\n\n
`); + quoteBuffer = []; + // TODO + // : handle links within blockquotes (WM-032) + } + }; + + for (const line of lines) { + /*** preserve code block placeholders ***/ + if (line.includes("