diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/asset/type/400.woff2 | bin | 0 -> 29224 bytes | |||
| -rw-r--r-- | src/asset/type/400i.woff2 | bin | 0 -> 30776 bytes | |||
| -rw-r--r-- | src/asset/type/600.woff2 | bin | 0 -> 32284 bytes | |||
| -rw-r--r-- | src/asset/type/600i.woff2 | bin | 0 -> 33224 bytes | |||
| -rw-r--r-- | src/helper/create-layout.ts | 276 | ||||
| -rw-r--r-- | src/helper/get-binary-contents.ts | 13 | ||||
| -rw-r--r-- | src/helper/get-directory-contents.ts | 34 | ||||
| -rw-r--r-- | src/helper/get-documents.ts | 35 | ||||
| -rw-r--r-- | src/helper/get-file-contents.ts | 14 | ||||
| -rw-r--r-- | src/helper/parse-header.ts | 73 | ||||
| -rw-r--r-- | src/helper/populate-document.ts | 21 | ||||
| -rw-r--r-- | src/helper/populate-recents.ts | 47 | ||||
| -rw-r--r-- | src/utility/constant.ts | 64 | ||||
| -rw-r--r-- | src/utility/feed/atom.ts | 87 | ||||
| -rw-r--r-- | src/utility/feed/helper.ts | 67 | ||||
| -rw-r--r-- | src/utility/feed/index.ts | 12 | ||||
| -rw-r--r-- | src/utility/feed/json.ts | 41 | ||||
| -rw-r--r-- | src/utility/feed/rss.ts | 85 | ||||
| -rw-r--r-- | src/utility/markdown.ts | 147 |
19 files changed, 1016 insertions, 0 deletions
diff --git a/src/asset/type/400.woff2 b/src/asset/type/400.woff2 Binary files differnew file mode 100644 index 0000000..6d23ae8 --- /dev/null +++ b/src/asset/type/400.woff2 diff --git a/src/asset/type/400i.woff2 b/src/asset/type/400i.woff2 Binary files differnew file mode 100644 index 0000000..778ec71 --- /dev/null +++ b/src/asset/type/400i.woff2 diff --git a/src/asset/type/600.woff2 b/src/asset/type/600.woff2 Binary files differnew file mode 100644 index 0000000..e1f6248 --- /dev/null +++ b/src/asset/type/600.woff2 diff --git a/src/asset/type/600i.woff2 b/src/asset/type/600i.woff2 Binary files differnew file mode 100644 index 0000000..82f90e8 --- /dev/null +++ b/src/asset/type/600i.woff2 diff --git a/src/helper/create-layout.ts b/src/helper/create-layout.ts new file mode 100644 index 0000000..6bd13cd --- /dev/null +++ b/src/helper/create-layout.ts @@ -0,0 +1,276 @@ + + + +/*** UTILITY ------------------------------------------ ***/ + +import { + author, + description, + title, + url +} from "src/utility/constant.ts"; + +import headerParser from "src/helper/parse-header.ts"; + +/*** EXPORT ------------------------------------------- ***/ + +export default (type: "memo" | "remark", memo: string, recents: string) => { + const { abstract, /*category,*/ document, title: documentTitle } = headerParser(memo); + + return ` + <!DOCTYPE html> + <html lang="en"> + <head> + <meta charset="utf-8"/> + <title>${title} • ${String(documentTitle).toLowerCase()}</title> + + <meta http-equiv="Content-Security-Policy" content="upgrade-insecure-requests"/> + <meta content="${author}" name="author"/> + <meta content="${abstract || description}" name="description"/> + <meta content="${title}" name="title"/> + <meta content="width=device-width, height=device-height, initial-scale=1, maximum-scale=1, viewport-fit=cover" name="viewport"/> + + <!--/ Open Graph /--> + <meta content="${abstract || description}" property="og:description"/> + <meta content="https://🔥.pixels.wtf/blog/asset/og.png" property="og:image"/> + <meta content="800" property="og:image:height"/> + <meta content="1280" property="og:image:width"/> + <meta content="en_US" property="og:locale"/> + <meta content="${title}" property="og:site_name"/> + <meta content="${documentTitle}" property="og:title"/> + <meta content="website" property="og:type"/> + <meta content="${url}" property="og:url"/> + + <!--/ Social/App Stuff /--> + <meta content="${title}" name="apple-mobile-web-app-title"/> + <meta content="${title}" name="application-name"/> + <meta name="fediverse:creator" content="@netopwibby@social.coop"/> + + <!--/ Feeds /--> + <link rel="alternate" href="/feed/atom" type="application/atom+xml"/> + <link rel="alternate" href="/feed/json" type="application/json+xml"/> + <link rel="alternate" href="/feed/rss" type="application/rss+xml"/> + + <!--/ The Rest /--> + <link href="https://🔥.pixels.wtf/blog/asset/apple-touch-icon.png" rel="apple-touch-icon"/> + <link href="${url}" rel="canonical"/> + <link color="#111" href="https://🔥.pixels.wtf/blog/asset/favicon.svg" rel="mask-icon"/> + <link href="https://social.coop/@netopwibby" rel="me"/> + <link href="https://🔥.pixels.wtf/blog/asset/favicon.svg" rel="shortcut icon"/> + <link href="https://uchu.style/color_expanded.css" rel="stylesheet"/> + + <style> + @font-face { + font-display: swap; + font-family: "WEBB MONO"; + font-style: normal; + font-weight: 400; + src: url("/type/400.woff2") format("woff2"); + } + + @font-face { + font-display: swap; + font-family: "WEBB MONO"; + font-style: italic; + font-weight: 400; + src: url("/type/400i.woff2") format("woff2"); + } + </style> + + <style> + *, + *::before, + *::after { + margin: 0; padding: 0; + box-sizing: inherit; + } + + html { + width: 100vw; height: 100vh; + box-sizing: border-box; + font-size: 12px; + } + + body { + width: 100%; height: 100%; + + background-color: var(--uchu-yang); + color: var(--uchu-yin-9); + display: flex; + flex-direction: column; + font-family: "WEBB MONO", monospace; + font-size: 1.15rem; + line-height: 1.33; + + @media (max-width: 800px) { + @supports (hanging-punctuation: first) and (font: -apple-system-body) and (-webkit-appearance: none) { + overflow-x: hidden; + } + } + } + + main { + display: flex; + flex: 1; + flex-direction: row; + + > section { + @media (min-width: 801px) { + max-width: 650px; + padding: 2rem; + } + + @media (max-width: 800px) { + margin-left: auto; + margin-right: auto; + width: 72ch; + } + + > pre { + font-family: inherit; + + @media (max-width: 800px) { + @supports (hanging-punctuation: first) and (font: -apple-system-body) and (-webkit-appearance: none) { + font-size: 1ch; + width: 72ch; + } + + margin: 0 auto; + } + + > code { + background-color: var(--uchu-yellow-1); + } + + > pre { + background-image: linear-gradient(90deg, var(--uchu-pink-1), transparent); + } + } + + blockquote { + background-image: linear-gradient(90deg, var(--uchu-orange-1), transparent); + margin-bottom: -1.5rem; + + a { + color: var(--uchu-orange-7); + + &:visited { + color: var(--uchu-blue-3); + } + } + } + + iframe, + img { + padding-right: 4.75rem; + width: 100%; + } + + iframe { + aspect-ratio: 16 / 9; + } + + hr { + background-image: linear-gradient(90deg, transparent, var(--uchu-yin-1), transparent); + border: 0; + height: 1px; + } + + > a { + color: var(--uchu-blue-3); + + &:visited { + color: var(--uchu-purple-3); + } + } + } + + aside { + display: flex; + flex-direction: column; + flex: 1; + margin-left: 2rem; + padding-top: 17rem; + z-index: 1; + + @media (max-width: 800px) { + display: none; + position: absolute; + } + + a { + background-color: var(--uchu-gray-1); + color: inherit; + padding: 0.5rem; + + &.current { + background-color: var(--uchu-yang); + font-weight: 600; + pointer-events: none; + } + + &:not(:last-of-type) { + margin-bottom: 0.75rem; + } + } + } + } + + header, + footer { + background-color: var(--uchu-gray-1); + color: var(--uchu-yin-3); + padding: 0.5rem calc(2rem - 2px); + text-transform: uppercase; + + @media (max-width: 800px) { + text-align: center; + } + + a { + color: var(--uchu-yin-9); + font-weight: 600; + + &:hover { + color: var(--uchu-blue-3); + } + } + } + + p { + padding-top: 0.5rem; + padding-bottom: 0.5rem; + } + </style> + </head> + + <body> + <header> + [<a href="https://webb.page">homepage</a>|<a href="https://www.webb.page">forum</a>|<a href="https://cv.webb.page">cv</a>] + ${document} [<a href="${type === "memo" ? `/${document}` : `/remarks/${document}`}.txt">text</a>|<!--/<a href="">pdf</a>|/--><a href="${type === "memo" ? `/${document}` : `/remarks/${document}`}">html</a>] + ${type === "memo" ? `[<a href="/remarks">remarks</a>]` : `[<a href="/">memos</a>]`} + </header> + + <main> + <section> + <pre> + ${memo} + </pre> + </section> + + <aside> + ${recents} + </aside> + </main> + + <footer> + [<a href="https://github.com/NetOpWibby/blog" target="_blank" title="source code for this blog">source</a>] + + [<a href="/feed/atom" title="Atom feed for the webb blog">atom</a>|<a href="/feed/json" title="JSON feed for the webb blog">json</a>|<a href="/feed/rss" title="RSS feed for the webb blog">rss</a>] + + [<a href="https://social.coop/@netopwibby" target="_blank">mastodon</a>|<a href="https://cyberspace.online/netopwibby" target="_blank">cyberspace</a>|<a href="https://bsky.app/profile/webb.page" target="_blank">bluesky</a>|<a href="https://www.linkedin.com/in/paulanthonywebb/" target="_blank">linkedin</a>] + </footer> + </body> + </html> + `; +} diff --git a/src/helper/get-binary-contents.ts b/src/helper/get-binary-contents.ts new file mode 100644 index 0000000..df41228 --- /dev/null +++ b/src/helper/get-binary-contents.ts @@ -0,0 +1,13 @@ + + + +/*** EXPORT ------------------------------------------- ***/ + +export default async(filePath: string) => { + const fileExists = await Deno.readFile(filePath); + + if (!fileExists) + return ""; + + return fileExists; +} diff --git a/src/helper/get-directory-contents.ts b/src/helper/get-directory-contents.ts new file mode 100644 index 0000000..80fb20c --- /dev/null +++ b/src/helper/get-directory-contents.ts @@ -0,0 +1,34 @@ + + + +/*** EXPORT ------------------------------------------- ***/ + +export default async(directory: string): Promise<{ filename: string; }[]> => { + const documentArray: { filename: string; }[] = []; + + try { + const documents: Deno.DirEntry[] = []; + + for await (const dirEntry of Deno.readDir(directory)) { + if (dirEntry.isFile) + documents.push(dirEntry); + } + + documents.sort((a, b) => a.name.localeCompare(b.name)).reverse(); + + for (const document of documents) { + if (document.name.startsWith(".")) + return []; + + if (document.name.endsWith(".txt")) { + const data = { filename: document.name }; + documentArray.push(data); + } + } + } catch(error) { + console.error(`Error reading directory contents: ${String(error)}`); + } finally { + // deno-lint-ignore no-unsafe-finally + return documentArray; + } +} diff --git a/src/helper/get-documents.ts b/src/helper/get-documents.ts new file mode 100644 index 0000000..ca9ce93 --- /dev/null +++ b/src/helper/get-documents.ts @@ -0,0 +1,35 @@ + + + +/*** EXPORT ------------------------------------------- ***/ + +export default async(directory: string) => { + const posts: string[] = []; + + try { + const files: Deno.DirEntry[] = []; + + for await (const dirEntry of Deno.readDir(directory)) { + if (dirEntry.isFile) + files.push(dirEntry); + } + + /*** Deno is weird in that if you do NOT call `.reverse()` + it will NOT load everything in the directory…WTF?! ***/ + + files.sort((a, b) => a.name.localeCompare(b.name)).reverse(); + + for (const file of files) { + if (file.name.startsWith(".")) + return; + + if (file.name.endsWith(".txt")) + posts.push(file.name); + } + } catch(error) { + console.error(`Error reading directory for posts: ${String(error)}`); + } finally { + // deno-lint-ignore no-unsafe-finally + return posts; + } +} diff --git a/src/helper/get-file-contents.ts b/src/helper/get-file-contents.ts new file mode 100644 index 0000000..a159f95 --- /dev/null +++ b/src/helper/get-file-contents.ts @@ -0,0 +1,14 @@ + + + +/*** EXPORT ------------------------------------------- ***/ + +export default async(filePath: string) => { + const fileExists = await Deno.stat(filePath); + + if (!fileExists) + return ""; + + const content = await Deno.readTextFile(filePath); + return content; +} diff --git a/src/helper/parse-header.ts b/src/helper/parse-header.ts new file mode 100644 index 0000000..92e0e52 --- /dev/null +++ b/src/helper/parse-header.ts @@ -0,0 +1,73 @@ + + + +/*** EXPORT ------------------------------------------- ***/ + +export interface DocumentMeta { + abstract: string; + category: string; + date: string; + document: string; + title: string; +} + +export default (input: string): DocumentMeta => { + /*** NOTE + This is extremely fragile, the source document must be formatted + specifically. This is my blog so who cares? ***/ + + const lines = input.split("\n"); + + /*** extract document ID ***/ + const documentMatch = lines[1]?.match(/^Document:\s+(\S+)/); + const document = documentMatch?.[1] ?? ""; + + /*** extract category ***/ + const categoryMatch = lines[2]?.match(/^Category:\s+(\S+)/); + const category = categoryMatch?.[1] ?? ""; + + /*** extract date ***/ + const dateMatch = lines[2]?.match(/\d{4}.\d{2}.\d{2}/); + const date = dateMatch?.[0] ?? ""; + + /*** find title (first non-empty line after the header block) ***/ + let title = ""; + let titleIndex = -1; + + for (let i = 3; i < lines.length; i++) { + const trimmed = lines[i].trim(); + + if (trimmed && trimmed !== "Abstract") { + title = trimmed; + titleIndex = i; + break; + } + } + + /*** extract abstract (content between "Abstract" and "Body") ***/ + let abstract = ""; + let inAbstract = false; + + for (let i = titleIndex + 1; i < lines.length; i++) { + const trimmed = lines[i].trim(); + + if (trimmed === "Abstract") { + inAbstract = true; + continue; + } + + if (trimmed === "Body") + break; + + if (inAbstract && trimmed) + abstract += (abstract ? " " : "") + trimmed; + } + + return { + abstract, + category, + date, + document, + title + }; +} diff --git a/src/helper/populate-document.ts b/src/helper/populate-document.ts new file mode 100644 index 0000000..0c57707 --- /dev/null +++ b/src/helper/populate-document.ts @@ -0,0 +1,21 @@ + + + +/*** UTILITY ------------------------------------------ ***/ + +import { memoDirectory, remarkDirectory, remarkRegex } from "src/utility/constant.ts"; + +import getFileContents from "src/helper/get-file-contents.ts"; +import processMarkdown from "src/utility/markdown.ts"; + +/*** EXPORT ------------------------------------------- ***/ + +export default async(document: { filename: string; }): Promise<string> => { + let isMemo = true; + + if (remarkRegex.test(document.filename)) + isMemo = false; + + const contents = await getFileContents(`${isMemo ? memoDirectory : remarkDirectory}/${document.filename}`); + return processMarkdown(contents); +} diff --git a/src/helper/populate-recents.ts b/src/helper/populate-recents.ts new file mode 100644 index 0000000..551434b --- /dev/null +++ b/src/helper/populate-recents.ts @@ -0,0 +1,47 @@ + + + +/*** UTILITY ------------------------------------------ ***/ + +import { remarkRegex } from "src/utility/constant.ts"; + +/*** EXPORT ------------------------------------------- ***/ + +export default (documentList: Array<{ filename: string; }>, currentDocument: string): string => { + const currentIndex = documentList.findIndex(item => item.filename === currentDocument); + let isMemo = true; + let maximumIndex = documentList.length; + let minimumIndex = 0; + + if (remarkRegex.test(currentDocument)) + isMemo = false; + + if (currentIndex < 7) + minimumIndex = 0; + else + minimumIndex = currentIndex - 7; + + if (currentIndex < (maximumIndex - 8)) + maximumIndex = currentIndex + 8; + + const documents = documentList.slice(minimumIndex, maximumIndex).map((arrayItem: { filename: string; }) => { + if (!arrayItem) + return ""; + + const { filename } = arrayItem; + + if (filename === currentDocument) { + if (isMemo) + return `<a class="current" href="/${filename.replace(".txt", "")}">${filename.replace(".txt", "")}</a>`; + else + return `<a class="current" href="/remarks/${filename.replace(".txt", "")}">${filename.replace(".txt", "")}</a>`; + } else { + if (isMemo) + return `<a href="/${filename.replace(".txt", "")}">${filename.replace(".txt", "")}</a>`; + else + return `<a href="/remarks/${filename.replace(".txt", "")}">${filename.replace(".txt", "")}</a>`; + } + }); + + return documents.join(""); +} 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] <https://blog.webb.page> + +`; 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<AtomEntry> { + constructor(options: FeedOptions) { + super(options); + } + + build(): string { + const xmlParts: string[] = [ + `<?xml version="1.0" encoding="UTF-8"?>\n`, + `<feed xmlns="http://www.w3.org/2005/Atom">\n`, + ` <title>${escapeXML(this.options.title)}</title>\n`, + ` <subtitle>${escapeXML(this.options.description)}</subtitle>\n`, + ` <link rel="alternate" href="${escapeXML(this.options.link)}"/>\n`, + ` <link rel="self" href="${escapeXML(this.options.link)}"/>\n`, + ` <updated>${this.options.updated?.toISOString()}</updated>\n`, + ` <generator>${this.options.generator || "the webb blog"}</generator>\n`, + ` <id>${escapeXML(this.options.link)}</id>\n` + ]; + + for (const author of this.options.authors) { + xmlParts.push(` <author>\n`); + + if (author.name) + xmlParts.push(` <name>${escapeXML(author.name)}</name>\n`); + + if (author.email) + xmlParts.push(` <email>${escapeXML(author.email)}</email>\n`); + + if (author.link) + xmlParts.push(` <uri>${escapeXML(author.link)}</uri>\n`); + + xmlParts.push(` </author>\n`); + } + + if (this.options.icon) { + xmlParts.push(` <icon>${escapeXML(this.options.icon)}</icon>\n`); + xmlParts.push(` <logo>${escapeXML(this.options.icon)}</logo>\n`); + } + + if (this.options.feed) + xmlParts.push(` <link rel="self" href="${escapeXML(this.options.feed)}"/>\n`); + + for (const entry of this.items) { + xmlParts.push( + ` <entry>\n`, + ` <title type="html">${escapeXML(entry.title)}</title>\n`, + ` <link href="${escapeXML(entry.link)}"/>\n`, + ` <id>${escapeXML(entry.id)}</id>\n`, + ` <updated>${entry.updated?.toISOString() || new Date().toISOString()}</updated>\n`, + ` <summary type="html">${escapeXML(entry.summary)}</summary>\n`, + ` <content type="${entry.content?.type || "html"}">${escapeXML(entry.content?.body || entry.summary)}</content>\n`, + entry.image ? + ` <media:thumbnail url="${escapeXML(entry.image)}" />\n` : + "", + ` </entry>\n` + ); + } + + for (const category of this.categories) { + xmlParts.push(` <category term="${escapeXML(category)}"/>\n`); + } + + xmlParts.push("</feed>"); + 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<T> { + protected categories: Set<string>; + protected items: Array<T>; + 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<JsonItem> { + constructor(options: FeedOptions) { + super(options); + } + + build(): string { + const json: Record<string, unknown> = { + 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<RssItem> { + constructor(options: FeedOptions) { + super(options); + } + + build(): string { + const xmlParts: string[] = [ + `<?xml version="1.0" encoding="UTF-8"?>\n`, + `<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:slash="http://purl.org/rss/1.0/modules/slash/" xmlns:sy="http://purl.org/rss/1.0/modules/syndication/" xmlns:wfw="http://wellformedweb.org/CommentAPI/">\n`, + ` <channel>\n`, + ` <title><![CDATA[${escapeXML(this.options.title)}]]></title>\n`, + ` <description><![CDATA[${escapeXML(this.options.description)}]]></description>\n`, + ` <link>${escapeXML(this.options.link)}</link>\n`, + ` <lastBuildDate>${this.options.updated?.toUTCString()}</lastBuildDate>\n`, + ` <language>${this.options.language || "en-US"}</language>\n`, + ` <generator>${escapeXML(this.options.generator || "the webb blog")}</generator>\n`, + ` <atom:link href="${escapeXML(this.options.link)}" rel="self" type="application/rss+xml"/>\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 ( + ` <webMaster>${emailPart}(${escapedName})</webMaster>\n` + + ` <managingEditor>${emailPart}(${escapedName})</managingEditor>\n` + ); + }).join(""); + + xmlParts.push(authorXml); + } + + if (this.items.length > 0) { + const itemsXml = this.items.map((item) => { + const contentXml = item.content ? + ` <content:encoded><![CDATA[${escapeXML(item.content.body)}]]></content:encoded>\n` : + ""; + + const imageXml = item.image ? + ` <media:thumbnail url="${escapeXML(item.image)}" />\n` : + ""; + + return ( + ` <item>\n` + + ` <title>${escapeXML(item.title)}</title>\n` + + ` <link>${escapeXML(item.link)}</link>\n` + + ` <guid>${escapeXML(item.id)}</guid>\n` + + ` <pubDate>${item.updated?.toUTCString() || new Date().toUTCString()}</pubDate>\n` + + ` <description>${escapeXML(item.description)}</description>\n` + + contentXml + + imageXml + + ` </item>\n` + ); + }).join(""); + + xmlParts.push(itemsXml); + } + + xmlParts.push(` </channel>\n`, `</rss>`); + 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<string, string>(); + + /*** 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 `<!--CODE:BLOCK:${index}-->`; + }); + + /*** extract inline code ***/ + processed = processed.replace(inlineCodePattern, (_match, content) => { + const index = inlineCode.length; + inlineCode.push(content); + return `<!--INLINE:CODE:${index}-->`; + }); + + /*** 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, `<strong style="white-space: nowrap;">$2</strong>`) /*** bold ***/ + .replace(/(\*|_)(.*?)\1/g, "<em>$2</em>") /*** italic ***/ + .replace(/(\~\~)(.*?)\1/g, `<del>$2</del>`) /*** 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, "<hr/>") + .replace(/📸\[([^\]]+)\]\[(\w+)\]/g, (match, alt, ref) => { + const url = refs.get(ref); + return url ? `<img src="${url}" alt="${alt}" loading="lazy">` : match; + }) + .replace(/📼\[([^\]]+)\]\[(\w+)\]/g, (match, alt, ref) => { + const url = refs.get(ref); + return url ? `<iframe allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" allowfullscreen frameborder="0" referrerpolicy="strict-origin-when-cross-origin" src="${url}" title="${alt}"></iframe>` : 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 ? + `<a href="${url + (url.includes("?") ? "&" : "?")}ref=blog.webb.page" target="_blank">[${ref}]</a>` : + `<a href="${url}">[${ref}]</a>` : + match; + }) + .replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_match, text, url) => { + const isExternal = url.startsWith("http://") || url.startsWith("https://"); + + return isExternal ? + `<a href="${url}?ref=blog.webb.page" target="_blank">${text}</a>` : + `<a href="${url}">${text}</a>`; + }); + + /*** restore inline code ***/ + for (const block in inlineCode) { + processed = processed.replace(`<!--INLINE:CODE:${block}-->`, `<code>${escapeHtml(inlineCode[block])}</code>`); + } + + /*** restore code blocks ***/ + for (const block of codeBlocks) { + const langAttr = block.language ? ` data-language="${block.language}"` : ""; + const html = `<pre><code${langAttr}>\n${escapeHtml(block.content)}\n\n</code></pre>`; + + processed = processed.replace(`<!--CODE:BLOCK:${block.index}-->`, html); + } + + // console.log(processed); /*** for troubleshooting ***/ + + return processed.trimEnd(); +} + +/*** HELPER ------------------------------------------- ***/ + +function escapeHtml(str: string): string { + return str + .replace(/&/g, "&") + .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(`<blockquote>\n ${quoteBuffer.join("\n ")}\n\n</blockquote>`); + quoteBuffer = []; + // TODO + // : handle links within blockquotes (WM-032) + } + }; + + for (const line of lines) { + /*** preserve code block placeholders ***/ + if (line.includes("<!--CODE:BLOCK") || line.includes("<!--INLINE:CODE")) { + flushQuote(); + result.push(line); + continue; + } + + const quoteMatch = line.match(/\s*>\s?(.*)$/); + + if (quoteMatch) { + quoteBuffer.push(quoteMatch[1]); + } else { + flushQuote(); + result.push(line); + } + } + + flushQuote(); + return result.join("\n"); +} |
