Add generator
Tim Izzo tim@octree.ch
Fri, 28 Oct 2022 18:36:41 +0200
10 files changed,
270 insertions(+),
3 deletions(-)
M
README.md
→
README.md
@@ -13,8 +13,7 @@ 3. Go to `home` and generate posts from JSON-LD with `yarn build`
## ToDo -- [ ] Use Tailwind through Deno to avoid node_modules -- [ ] Merge `weblogg` and `home` +- [ ] Use Tailwind through Deno and remove package.json and all npm related - [ ] Adapt git hook on server to deploy on each push
A
generator/atom.ts
@@ -0,0 +1,35 @@
+// @deno-types="./types.d.ts" + +import { stringify } from "./deps.ts"; + +export const getAtomFeed = (collection: Collection) => { + return stringify({ + xml: { "@version": "1.0", "@encoding": "UTF-8" }, + feed: { + "@xmlns": "http://www.w3.org/2005/Atom", + title: collection.name, + subtitle: collection.summary, + updated: collection.published, + link: [ + { + "@href": collection.url, + "@rel": "alternate", + }, + { + "@href": `${collection.url}/atom.xml`, + "@rel": "self", + }, + ], + entry: collection.items?.map(item => ({ + id: item["@id"], + title: item.name, + updated: item.published, + author: item.attributedTo, + link: { + "@href": item.url, + "@rel": "alternate", + }, + })), + }, + }); +};
A
generator/collection.ts
@@ -0,0 +1,18 @@
+// @deno-types="./types.d.ts" +import { getJsonLdCollection, postToJsonLd } from "./json-ld.ts"; +import { getFileContent } from "./lib.ts"; + +export const getCollection = async ( + postsPath: string, + mediaType: MediaType +) => { + let posts = []; + + for await (const post of Deno.readDir(postsPath)) { + const mdContent = await getFileContent(`${postsPath}/${post.name}`); + const jsonLdPost = await postToJsonLd(mdContent, mediaType); + posts.push(jsonLdPost); + } + + return getJsonLdCollection(posts); +};
A
generator/deps.ts
@@ -0,0 +1,8 @@
+export { unified } from "https://esm.sh/unified@10.1.2"; +export { default as remarkParse } from "https://esm.sh/remark-parse@10"; +export { default as remarkRehype } from "https://esm.sh/remark-rehype@10"; +export { default as rehypeRaw } from "https://esm.sh/rehype-raw"; +export { default as rehypeSanitize } from "https://esm.sh/rehype-sanitize"; +export { default as rehypeStringify } from "https://esm.sh/rehype-stringify@9.0.3"; +export { datetime } from "https://deno.land/x/ptera/mod.ts"; +export { stringify } from "https://deno.land/x/xml/mod.ts";
A
generator/json-ld.ts
@@ -0,0 +1,72 @@
+// @deno-types="./types.d.ts" +import { slugify } from "./lib.ts"; +import { + datetime, + remarkParse, + unified, + rehypeStringify, + remarkRehype, + rehypeRaw, + rehypeSanitize, +} from "./deps.ts"; +import { getExtension } from "./lib.ts"; + +const author = { + type: "Person", + name: "Tim Izzo", + email: "tim@5ika.ch", + url: "https://5ika.ch", +}; + +export const getJsonLdCollection = (items: Post[]): Collection => ({ + "@context": "https://www.w3.org/ns/activitystreams", + name: "Le blog de 5ika", + url: "https://5ika.ch/posts", + type: "Collection", + summary: "Retrouvez les postes de 5ika.ch dans votre feed", + totalItems: items.length, + items: items, + attributedTo: author, + published: datetime("").toISO(), +}); + +export const postToJsonLd = async ( + mdContent: string, + mediaType: MediaType +): Promise<Post> => { + const tokens: MdTokens = unified().use(remarkParse).parse(mdContent); + const title = tokens.children.find( + token => token.type === "heading" && token.depth === 1 + )?.children?.[0]?.value; + const rawDate = tokens.children?.find(token => token.type === "blockquote") + ?.children?.[0]?.children?.[0]?.value; + const dateTime = rawDate && datetime().parse(rawDate, "dd.MM.YYYY").toISO(); + const slug = title ? slugify(title) : "No title"; + const ext = getExtension(mediaType); + const url = `https://5ika.ch/posts/${slug}.${ext}`; + + let content = mdContent; + + if (mediaType === "text/html") { + const vfile = await unified() + .use(remarkParse) + .use(remarkRehype, { allowDangerousHtml: true }) + .use(rehypeRaw) + .use(rehypeSanitize) + .use(rehypeStringify) + .process(mdContent); + content = vfile.value; + } + + return { + "@context": "https://www.w3.org/ns/activitystreams", + "@id": slug, + url, + mediaType, + content, + type: "Article", + name: title || "No title", + attributedTo: author, + published: dateTime, + }; +};
A
generator/lib.ts
@@ -0,0 +1,28 @@
+export const getExtension = (mediaType: MediaType) => + ({ + "text/html": "html", + "text/markdown": "md", + "text/gemini": "gmi", + }[mediaType] || "md"); + +export const getFileContent = async (filePath: string): Promise<string> => { + const decoder = new TextDecoder("utf-8"); + const mdFile = await Deno.readFile(filePath); + return decoder.decode(mdFile); +}; + +export const slugify = (str: string) => { + str = str.replace(/^\s+|\s+$/g, ""); + str = str.toLowerCase(); + const from = + "ÁÄÂÀÃÅČÇĆĎÉĚËÈÊẼĔȆÍÌÎÏŇÑÓÖÒÔÕØŘŔŠŤÚŮÜÙÛÝŸŽáäâàãåčçćďéěëèêẽĕȇíìîïňñóöòôõøðřŕšťúůüùûýÿžþÞĐđ߯a·/_,:;"; + const to = + "AAAAAACCCDEEEEEEEEIIIINNOOOOOORRSTUUUUUYYZaaaaaacccdeeeeeeeeiiiinnooooooorrstuuuuuyyzbBDdBAa------"; + for (var i = 0, l = from.length; i < l; i++) { + str = str.replace(new RegExp(from.charAt(i), "g"), to.charAt(i)); + } + return str + .replace(/[^a-z0-9 -]/g, "") + .replace(/\s+/g, "-") + .replace(/-+/g, "-"); +};
A
generator/mod.ts
@@ -0,0 +1,37 @@
+// @deno-types="./types.d.ts" +import { getCollection } from "./collection.ts"; +import { getRssFeed } from "./rss.ts"; +import { getAtomFeed } from "./atom.ts"; +import { getExtension } from "./lib.ts"; + +const POSTS_PATH = "../../writing/published"; +const OUTPUT_PATH = "../build/posts"; + +await Deno.mkdir(OUTPUT_PATH, { recursive: true }); + +const collection = await getCollection(POSTS_PATH, "text/html"); +const jsonLdFilename = `${OUTPUT_PATH}/ld.json`; +await Deno.writeTextFile( + jsonLdFilename, + JSON.stringify(collection, undefined, 2) +); +console.log(`JSON-LD written to ${jsonLdFilename}`); + +const generateFeeds = async (mediaType: MediaType) => { + const extension = getExtension(mediaType); + const collection = await getCollection(POSTS_PATH, mediaType); + + const atomFilename = `${OUTPUT_PATH}/atom-${extension}.xml`; + const atomFeed = getAtomFeed(collection); + await Deno.writeTextFile(atomFilename, atomFeed); + console.log(`Atom feed for '${mediaType} written to ${atomFilename}`); + + const rssFilename = `${OUTPUT_PATH}/rss-${extension}.xml`; + const rssFeed = getRssFeed(collection); + await Deno.writeTextFile(rssFilename, rssFeed); + console.log(`RSS feed for '${mediaType} written to ${rssFilename}`); +}; + +await generateFeeds("text/markdown"); +await generateFeeds("text/html"); +await generateFeeds("text/gemini");
A
generator/rss.ts
@@ -0,0 +1,26 @@
+// @deno-types="./types.d.ts" + +import { stringify } from "./deps.ts"; + +export const getRssFeed = (collection: Collection) => { + return stringify({ + xml: { "@version": "1.0", "@encoding": "UTF-8" }, + rss: { + "@version": "2.0", + channel: { + title: collection.name, + description: collection.summary, + link: collection.url, + lastBuildDate: collection.published, + ttl: 1800, + + item: collection.items.map(item => ({ + title: item.name, + link: item.url, + guid: item["@id"], + pubDate: item.published, + })), + }, + }, + }); +};
A
generator/types.d.ts
@@ -0,0 +1,42 @@
+interface Collection { + "@context": "https://www.w3.org/ns/activitystreams"; + type: "Collection"; + name: string; + url: string; + items: Post[]; + summary: string; + published: string; + totalItems: number; + attributedTo: Person; +} + +interface Post { + "@context": "https://www.w3.org/ns/activitystreams"; + "@id": string; + type: "Article"; + mediaType: MediaType; + name: string; + url: string; + attributedTo: Person; + published?: string; + content: string; +} + +interface Person { + name: string; + email?: string; + url?: string; +} + +type MediaType = "text/markdown" | "text/html" | "text/gemini"; + +interface MdTokens { + children: MdToken[]; +} + +interface MdToken { + type: string; + value: string; + children: MdToken[]; + depth?: number; +}