From: Lady Date: Sun, 7 Aug 2022 22:54:40 +0000 (-0700) Subject: Initial commit X-Git-Tag: 0.1.0~4 X-Git-Url: https://git.ladys.computer/Beorn/commitdiff_plain/87e79e5a942a55f386f349242b8b554d6a50db4d Initial commit --- 87e79e5a942a55f386f349242b8b554d6a50db4d diff --git a/README.markdown b/README.markdown new file mode 100644 index 0000000..6cce62f --- /dev/null +++ b/README.markdown @@ -0,0 +1,104 @@ +# 🧸📔 Bjørn + +A minimal static‐site generator with an emphasis on Atom feed +generation. + +## Dependencies + +🧸📔 Bjørn is written in Ecmascript and intended for use with +[Deno][Deno]. + +## Usage + +Assuming `deno` is installed on your computer, you can simply do :— + +```sh +./build.js ❲path-to-my-blog❳ +``` + +`❲path-to-my-blog❳` is optional; if you don’t supply a path, the +default is `.`. + +You can (of course) also run the build script using `deno run`. Bjørn +requires both `--allow-read` and `--allow-write` functionality for +everything inside `❲path-to-my-blog❳`. + +**Note that 🧸📔 Bjørn will silently overwrite existing `index.xhtml` +files in the blog directory!!** + +## Files and Locations + +The **feed metadata** file (also used for generating the blog homepage) +should be located at `❲path-to-my-blog❳/#feed.rdf`. **Entry metadata** +files (used for blogposts) are any files located at +`❲path-to-my-blog❳/❲YYYY-MM-DD❳/❲identifier❳/#entry.rdf`. It is +recommended that you make `❲YYYY-MM-DD❳` the date of publication for +the entry. `❲identifier❳` must not contain any characters not allowed +in U·R·L’s (e·g spaces). + +As the extensions of these files suggest, they are +[R·D·F∕X·M·L][RDF11-XML] files. These can be a little cumbersome to +write, but they are very easy to process and manipulate with tools, so +they are a forward‐thinking format. Your `feed.rdf` file should +probably look something like this :— + +```xml + + My Cool Blog + + + + +``` + +Your `entry.rdf` will look similar, but it needs a `sioc:content` +property :— + +```xml + + A Sample Post + + +``` + +As you can see from the above example, 🧸📔 Bjørn supports a +(nonstandard) `Markdown` value for `rdf:parseType` in addition to +`Literal`. Some words of caution regarding this :— + +1. You should always put your markdown in a `` element. Metadata will +be inserted into the `` for you automatically. + +To modify what content is generated, simply edit `build.js` :) . + +[CommonMark]: +[Deno]: +[RDF11-XML]: +[rusty_markdown]: diff --git a/build.js b/build.js new file mode 100755 index 0000000..743de64 --- /dev/null +++ b/build.js @@ -0,0 +1,1058 @@ +#!/usr/bin/env -S deno run --allow-read --allow-write +// 🧸📔 Bjørn ∷ build.js +// ==================================================================== +// +// Copyright © 2022 Lady [@ Lady’s Computer]. +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at . + +// As the shebang at the top of this file indicates, it must be run via +// Deno with read and write permissions. You can get Deno from +// . +// +// This file generates a minimal, Atom‐supporting blog from a handful +// of R·D·F∕X·M·L files and a couple of templates. It will silently +// overwrite the files it generates. This is usually what you want. +// +// ※ The actual run script is at the very end of this file. It is +// preceded by a number of helper function definitions, of which the +// most important is `applyMetadata` (which is used to generate the +// HTML and Atom elements from the processed metadata). +// +// ※ The list of supported metadata properties and their R·D·F +// representations is provided by `context`. You can add support for +// new metadata fields simply by adding them to the `context` and then +// handling them appropriately in `applyMetadata`. +// +// This script is a bit of a mess and you shouldn’t bother trying to +// understand the whole thing before you start hacking on it. Just find +// the parts relevant to whatever you want to change, and assume that +// things will work in sensible (if cumbersome) browser‐like ways. + +// This import polyfills a (mediocre but sufficient) D·O·M environment +// onto the global object. +import "https://git.ladys.computer/Lemon/blob_plain/0.2.2:/window/mod.js"; + +// Much of the H·T·M·L generation in this script uses the 🍋🏷 Lemon +// library for convenience (). +// +// Frequently this will be bound to a different document than the +// global one and called as `LMN`. +import Lemon from "https://git.ladys.computer/Lemon/blob_plain/0.2.2:/mod.js"; + +// Markdown processing uses rusty_markdown, which uses Rust’s +// pulldown-cmark behind the scenes via WebAssembly. +import { + html as markdownTokensToHTML, + tokens as markdownTokens, +} from "https://deno.land/x/rusty_markdown@v0.4.1/mod.ts"; + +// Various namespaces. +const AWOL = "http://bblfish.net/work/atom-owl/2006-06-06/"; +const DC11 = "http://purl.org/dc/elements/1.1/"; +const FOAF = "http://xmlns.com/foaf/0.1/"; +const RDF = "http://www.w3.org/1999/02/22-rdf-syntax-ns#"; +const SIOC = "http://rdfs.org/sioc/ns#"; +const XML = "http://www.w3.org/XML/1998/namespace"; +const XHTML = "http://www.w3.org/1999/xhtml"; + +/** + * Adds the provided content to the provided element. + * + * Content may be either nullish (in which case nothing is added), a + * string (object or literal), a NodeList, or an array of zero or more + * of these values. Nodes need not belong to the same document as the + * provided element; they will be imported. During this process, + * special care is taken to ensure that the resulting content is + * correctly language‐tagged for its new context. + * + * ☡ If the provided element is not attached to anything, then it won’t + * be possible to walk its parent nodes in search of language + * information. Generally, it is best to attach elements to a document + * BEFORE calling this function. + */ +const addContent = (element, content) => { + const { ownerDocument: document } = element; + const LMN = Lemon.bind({ document }); + if (content == null) { + // The provided content is nullish. + /* do nothing */ + } else if (Array.isArray(content)) { + // The provided content is an array. + content.forEach(addContent.bind(null, element)); + } else if (Object(content) instanceof String) { + // The provided content is a string (object or literal). + const { lang } = content; + if (lang && lang != getLanguage(element)) { + const newChild = element.appendChild(LMN.span`${content}`); + setLanguage(newChild, lang); + } else { + element.appendChild(document.createTextNode(`${content}`)); + } + } else { + // Assume the provided content is a NodeList. + if (element.hasAttribute("property")) { + // The provided element has an R·D·F∕A property; note that its + // datatype is `XMLLiteral`. + element.setAttribute("datatype", "XMLLiteral"); + } else { + // The provided element does not have an R·D·F∕A property. + /* do nothing */ + } + for (const child of Array.from(content)) { + // Iterate over the nodes in the provided NodeList and handle + // them appropriately. + const lang = getLanguage(child); + const newChild = (() => { + const imported = document.importNode(child, true); + if (lang && lang != getLanguage(element)) { + // The imported node’s language would change if it were just + // imported directly into the provided element. + if (imported.nodeType == 1) { + // The imported node is an element node. + setLanguage(imported, lang); + return imported; + } else if (imported.nodeType <= 4) { + // The imported node is a text node. + const result = LMN.span`${imported}`; + setLanguage(result, lang); + return result; + } else { + // The imported node is not an element or text. + return imported; + } + } else { + // The imported node’s language will not change if imported + // directly into the provided element. + return imported; + } + })(); + element.appendChild(newChild); + } + } + return element; +}; + +/** + * Adds HTML for the provided people to the provided element, tagging + * them with the provided property. + * + * ☡ As with `addContent`, it is best to attach elements to a document + * PRIOR to providing them to this function, for language‐detection + * reasons. + */ +const addPeople = (element, people, property) => { + const { ownerDocument: document } = element; + const LMN = Lemon.bind({ document }); + const { length } = people; + for (const [index, { uri, name }] of people.entries()) { + const personElement = element.appendChild( + uri + ? LMN.a.rel(`${property}`).href(`${uri}`)`` + : LMN.span.rel(`${property}`)``, + ); + if (name == null) { + // The current person has no name; provide its `uri`. + personElement.appendChild(LMN.code`${uri}`); + } else { + // The current person has a name. + addContent( + personElement.appendChild( + LMN.span.property(`${FOAF}name`)``, + ), + name, + ); + } + if (index < length - 2) { + // The current person is two or greater from the end. + addContent(element, ", "); + } else if (index < length - 1) { + // The current person is one from the end. + addContent(element, " & "); + } else { + // The current person is the last. + /* do nothing */ + } + } + return element; +}; + +/** + * Applies the provided metadata to the provided node by creating and + * inserting the appropriate elements, then returns the provided node. + * + * If the provided node is a document, it is assumed to be an entry + * template, and full entry H·T·M·L is generated. If it is a document + * fragment, it is assumed to be a document fragment collecting entries + * for the H·T·M·L feed index page, and entry H·T·M·L links are + * generated. Otherwise, the provided node is assumed to be an Atom + * element, and Atom metadata elements are generated. + */ +const applyMetadata = (node, metadata) => { + if (node.nodeType == 9) { + // The provided node is a document. + // + // Assume it is an entry template document and insert the full + // entry H·T·M·L accordingly. + const document = node; + const { documentElement } = document; + if (hasExpandedName(documentElement, XHTML, "html")) { + // This is an XHTML template. + const LMN = Lemon.bind({ document }); + const head = Array.from(documentElement.childNodes).find(($) => + hasExpandedName($, XHTML, "head") + ) ?? documentElement.insertBefore( + LMN.head``, + documentElement.childNodes.item(0), + ); + const titleElement = Array.from(head.childNodes).find(($) => + hasExpandedName($, XHTML, "title") + ) ?? head.appendChild(LMN.title``); + const { + id, + title, + author, + summary, + contributor, + published, + content, + rights, + updated, + } = metadata; + titleElement.textContent = Object(title) instanceof String + ? title + : Array.from(title ?? []).map(($) => + $.textContent + ).join(""); + for (const person of author) { + // Iterate over authors and add appropriate meta tags. + head.appendChild( + LMN.meta({ name: "author" }) + .content(`${person.name ?? person.uri}`)``, + ); + } + if (summary) { + // The entry has a summary. + head.appendChild( + LMN.meta({ name: "description" }) + .content( + `${ + Object(summary) instanceof String + ? summary + : Array.from(summary).map(($) => $.textContent).join( + "", + ) + }`, + )``, + ); + } else { + /* do nothing */ + } + const contentPlaceholder = document.getElementsByTagNameNS( + XHTML, + "bjørn-content", + ).item(0); + if (contentPlaceholder != null) { + // The content placeholder exists; replace it with the content + // nodes. + const { parentNode: contentParent } = contentPlaceholder; + const contentElement = contentParent.insertBefore( + LMN.article.about(`${id}`)`${"\n"}`, + contentPlaceholder, + ); + + // Handle the entry content header. + contentElement.appendChild( + document.createComment(" BEGIN ENTRY HEADER "), + ); + addContent(contentElement, "\n"); + const contentHeader = contentElement.appendChild( + LMN.header.id("entry.header")`${"\n\t"}`, + ); + addContent( + contentHeader.appendChild( + LMN.h1.id("entry.title").property(`${DC11}title`)``, + ), + title, + ); + if (author.length > 0) { + // This entry has authors. + addContent(contentHeader, "\n\t"); + addPeople( + contentHeader.appendChild( + LMN.p.id("entry.author")``, + ), + author, + `${DC11}creator`, + ); + } else { + // This entry does not have authors. + /* do nothing */ + } + if (contributor.length > 0) { + // This entry has contributors. + addContent(contentHeader, "\n\t"); + addContent( + addPeople( + contentHeader.appendChild( + LMN.p.id( + "entry.contributor", + )`With contributions from `, + ), + contributor, + `${DC11}contributor`, + ), + ".", + ); + } else { + // This entry does not have contributors. + /* do nothing */ + } + if (published) { + // This entry has a publication date. + addContent(contentHeader, "\n\t"); + contentHeader.appendChild( + LMN.p.id("entry.published")`Published: ${LMN.time.property( + `${DC11}date`, + )`${published}`}.`, + ); + } else { + // This entry does not have a publication date. + /* do nothing */ + } + addContent(contentHeader, "\n"); + addContent(contentElement, "\n"); + contentElement.appendChild( + document.createComment(" END ENTRY HEADER "), + ); + addContent(contentElement, "\n"); + + // Handle the entry content. + contentElement.appendChild( + document.createComment(" BEGIN ENTRY CONTENT "), + ); + addContent(contentElement, "\n"); + addContent( + contentElement.appendChild( + LMN.div.id("entry.content").property(`${SIOC}content`)``, + ), + content, + ); + addContent(contentElement, "\n"); + contentElement.appendChild( + document.createComment(" END ENTRY CONTENT "), + ); + addContent(contentElement, "\n"); + + // Handle the entry content footer. + contentElement.appendChild( + document.createComment(" BEGIN ENTRY FOOTER "), + ); + addContent(contentElement, "\n"); + const contentFooter = contentElement.appendChild( + LMN.footer.id("entry.footer")`${"\n\t"}`, + ); + if (rights) { + addContent( + contentFooter.appendChild( + LMN.div.id("entry.rights").property(`${DC11}rights`)``, + ), + rights, + ); + addContent(contentFooter, "\n\t"); + } + contentFooter.appendChild( + LMN.p.id("entry.updated")`Last updated: ${LMN.time.property( + `${AWOL}updated`, + )`${updated}`}.`, + ); + addContent(contentFooter, "\n"); + addContent(contentElement, "\n"); + contentElement.appendChild( + document.createComment(" END ENTRY FOOTER "), + ); + addContent(contentElement, "\n"); + + // Remove the placeholder. + contentParent.removeChild(contentPlaceholder); + } else { + // There is no content placeholder. + /* do nothing */ + } + } else { + // This is not an XHTML template. + /* do nothing */ + } + } else if (node.nodeType == 11) { + // The provided node is a document fragment. + // + // Assume it is collecting H·T·M·L feed entry links and insert a + // new one for the provided metadata. + const { ownerDocument: document } = node; + const LMN = Lemon.bind({ document }); + const { + id, + title, + author, + published, + summary, + } = metadata; + // The content placeholder exists; replace it with the content + // nodes. + node.appendChild( + document.createComment(` <${id}> `), + ); + addContent(node, "\n"); + const contentElement = node.appendChild( + LMN.li.resource(`${id}`)`${"\n"}`, + ); + addContent( + contentElement.appendChild( + LMN.a.href(`${id}`)``, + ).appendChild( + LMN.h3.property(`${DC11}title`)``, + ), + title, + ); + if (author.length > 0) { + // This entry has authors. + addContent(contentElement, "\n"); + addPeople( + contentElement.appendChild( + LMN.p``, + ), + author, + `${DC11}creator`, + ); + } else { + // This entry does not have authors. + /* do nothing */ + } + if (published) { + // This entry has a publication date. + addContent(contentElement, "\n"); + contentElement.appendChild( + LMN.time.property(`${DC11}date`)`${published}`, + ); + } else { + // This entry does not have a publication date. + /* do nothing */ + } + addContent(contentElement, "\n"); + addContent( + contentElement.appendChild( + LMN.div.property(`${DC11}abstract`)``, + ), + summary, + ); + addContent(contentElement, "\n"); + addContent(node, "\n"); + } else { + // The provided node is not a document or document fragment. + // + // Assume it is an Atom element of some sort and add the + // the appropriate metadata as child elements. + const { ownerDocument: document } = node; + for (const [property, values] of Object.entries(metadata)) { + for (const value of Array.isArray(values) ? values : [values]) { + const propertyNode = document.createElement(property); + switch (context[property]?.type) { + case "person": { + // The property describes a person. + const { name, uri } = value; + if (uri) { + // The person has a U·R·I. + const subnode = document.createElement("uri"); + subnode.textContent = uri; + propertyNode.appendChild(subnode); + } else { + // The person does not have a U·R·I. + /* do nothing */ + } + if (name != null) { + // The person has a name. + const subnode = document.createElement("name"); + subnode.textContent = name; + propertyNode.appendChild(subnode); + } else { + // The person does not have a name. + /* do nothing */ + } + if (propertyNode.childNodes.length == 0) { + // Neither a U·R·I nor a name was added; skip adding this + // property. + continue; + } else { + break; + } + } + default: { + // The property describes (potentially rich) text. + if (value == null) { + // The property has no value; skip appending it to the node. + continue; + } else if (Object(value) instanceof String) { + // The property has a string value. + propertyNode.textContent = value; + break; + } else { + // The property value is a list of nodes. + propertyNode.setAttribute("type", "xhtml"); + const div = document.createElementNS(XHTML, "div"); + for (const child of Array.from(value)) { + div.appendChild(document.importNode(child, true)); + } + propertyNode.appendChild(div); + break; + } + } + } + node.appendChild(propertyNode); + } + } + } + return node; +}; + +/** + * The base path from which to pull files and generate resulting + * documents. + */ +const basePath = `./${Deno.args[0] ?? ""}`; + +/** + * Mappings from Atom concepts to R·D·F∕X·M·L ones. + * + * `namespace` and `localName` give the R·D·F representation for the + * concept. Three `type`s are supported :— + * + * - "person": Has a `name` (`foaf:name`) and an `iri` (`rdf:about`). + * Emails are NOT supported. + * + * - "text": Can be string, markdown, or X·M·L content. + * + * - "literal": This is a plaintext field. + */ +const context = { + author: { namespace: DC11, localName: "creator", type: "person" }, + // category is not supported at this time + content: { namespace: SIOC, localName: "content", type: "text" }, + contributor: { + namespace: DC11, + localName: "contributor", + type: "person", + }, + // generator is provided by the build script + icon: { namespace: AWOL, localName: "icon", type: "literal" }, + // link is provided by the build script + logo: { namespace: AWOL, localName: "logo", type: "literal" }, + published: { + namespace: DC11, + localName: "date", + type: "literal", + }, + rights: { namespace: DC11, localName: "rights", type: "text" }, + // source is not supported at this time + // subtitle is not supported at this time + summary: { namespace: DC11, localName: "abstract", type: "text" }, + title: { namespace: DC11, localName: "title", type: "text" }, + // updated is provided by the build script +}; + +const { + /** + * Returns a new document created from the source code of the named + * template. + */ + documentFromTemplate, +} = (() => { + const cache = Object.create(null); + return { + documentFromTemplate: async (name) => + parser.parseFromString( + name in cache ? cache[name] : ( + cache[name] = await Deno.readTextFile( + `${basePath}/index#${name}.xhtml`, + ) + ), + "application/xml", + ), + }; +})(); + +/** + * Returns the language of the provided node, or null if it has no + * language. + * + * ※ This function returns null regardless of whether a node has no + * language implicitly (because no parent element had a language set) + * or explicitly (because the value of the @xml:lang attribute was ""). + * + * ※ If no language is set, the language of the node is ambiguous. + * Because this typically occurs when a node is expected to inherit its + * language from some parent context, this lack of language information + * SHOULD NOT be preserved when inserting the node into a document + * where language information is present (instead allowing the node to + * inherit language information from the node it is being inserted + * into). There are explicit language codes which may be used if this + * behaviour is undesirable: If you want to signal that a node does not + * contain linguistic content, use the language code `zxx`. If you want + * to signal that a node’s language is undetermined in a way which will + * never inherit from a parent node, use the language code `und`. + */ +const getLanguage = (node) => { + const { nodeType } = node; + if (nodeType == 1) { + // The provided node is an element. + const ownLanguage = + node.namespaceURI == XHTML && node.hasAttribute("lang") && + node.getAttribute("lang") || + node.hasAttributeNS(XML, "lang") && + node.getAttributeNS(XML, "lang"); + if (ownLanguage) { + // The element has a recognized language attribute set to a + // nonempty value. + return ownLanguage; + } else if (ownLanguage === "") { + // The element explicitly has no language. + return null; + } else { + // The element has no language attribute, but may inherit a + // language from its parent. + const { parentNode } = node; + if (parentNode != null && parentNode.nodeType != 9) { + // The provided node has a nondocument parent; get the language + // from there. + return getLanguage(parentNode); + } else { + // The provided node has no parent and consequently no language. + return null; + } + } + } else if (nodeType == 9) { + // The provided node is a document. + return getLanguage(node.documentElement); + } else if (nodeType == 11) { + // The provided node is a document fragment. + return getLanguage(node.ownerDocument.documentElement); + } else { + // The provided node may inherit a language from a parent node. + const { parentNode } = node; + if (parentNode != null) { + // The provided node has a parent; get the language from there. + return getLanguage(parentNode); + } else { + // The provided node has no parent and consequently no language. + return null; + } + } +}; + +/** + * Returns whether the provided node has the provided namespace and + * local name. + */ +const hasExpandedName = (node, namespace, localName) => + node.namespaceURI == namespace && node.localName == localName; + +/** + * Processes an RDF document and returns an object of Atom metadata. + * + * See `context` for the metadata properties supported. + */ +const metadataFromDocument = ( + { documentElement: root, lastModified }, +) => { + const contextEntries = [...Object.entries(context)]; + const documentType = hasExpandedName(root, AWOL, "Feed") + ? "feed" + : "entry"; + const result = Object.assign(Object.create(null), { + id: root.getAttributeNS(RDF, "about"), + updated: documentType == "feed" || lastModified == null + ? new Date().toISOString() + : lastModified.toISOString(), + title: null, + author: [], + contributor: [], + rights: null, + ...(documentType == "feed" + ? { // additional feed properties + subtitle: null, + logo: null, + icon: null, + } + : { // additional entry properties + published: null, + summary: null, + content: null, + }), + }); + for ( + const node of [ + ...Array.from(root.attributes), + ...Array.from(root.childNodes), + ] + ) { + // Iterate over all child nodes and attributes, finding ones which + // correspond to Atom properties and assigning the appropriate + // metadata. + const [name, { type }] = contextEntries.find( + ([_, value]) => + hasExpandedName(node, value.namespace, value.localName), + ) ?? [, {}]; + if (name != null && name in result) { + // The current node corresponds with an Atom property. + const { [name]: existing } = result; + const content = (() => { + switch (type) { + case "person": { + // The node describes a person. + const base = + node.getAttributeNS?.(RDF, "parseType") == "Resource" + ? node + : Array.from(node.childNodes).find(($) => + $.nodeType == 1 + ); + const person = { + name: null, + uri: base.getAttributeNS?.(RDF, "about") || null, + }; + for ( + const subnode of [ + ...Array.from(base.attributes), + ...Array.from(base.childNodes), + ] + ) { + // Process child nodes and attributes for the current + // person, looking for name metadata. + if (hasExpandedName(subnode, FOAF, "name")) { + // This is a name node. + if (person.name == null) { + // No name has been set yet. + person.name = subnode.textContent; + } else { + // A name has already been set. + throw new TypeError( + `Duplicate name found for person${ + person.id != null ? ` <${person.id}>` : "" + } while processing <${result.id}>.`, + ); + } + } else { + // This is not a name node + /* do nothing */ + } + } + return person; + } + case "text": { + // The node describes (potentially rich) textual content. + // + // ☡ Don’t return an Array here or it will look like a list + // of multiple values. Return the NodeList of child nodes + // directly. + const parseType = node.getAttributeNS?.(RDF, "parseType"); + if (parseType == "Markdown") { + // This is an element with Markdown content (which + // hopefully can be converted into X·M·L). + return parser.parseFromString( + `<福 xmlns="${XHTML}" lang="${ + getLanguage(node) ?? "" + }">${ + markdownTokensToHTML( + markdownTokens(node.textContent), + ) + }`, + "application/xml", + ).documentElement.childNodes; + } else if (parseType == "Literal") { + // This is an element with literal X·M·L contents. + return node.childNodes; + } else { + // This is an element without literal X·M·L contents. + /* do nothing */ + } + } // falls through + default: { + // The node describes something in plaintext. + const text = new String(node.textContent); + const lang = getLanguage(node); + if (lang) { + text.lang = lang; + } else { + /* do nothing */ + } + return text; + } + } + })(); + if (existing == null) { + // The property takes at most one value, but none has been set. + result[name] = content; + } else if (Array.isArray(existing)) { + // The property takes multiple values. + existing.push(content); + } else { + // The property takes at most one value, and one has already + // been set. + throw new TypeError( + `Duplicate content found for ${name} while processing <${result.id}>.`, + ); + } + } else { + // The current node does not correspond with an Atom property. + /* do nothing */ + } + } + return validateMetadata(result, documentType); +}; + +/** The DOMParser used by this script. */ +const parser = new DOMParser(); + +/** The XMLSerializer used by this script. */ +const serializer = new XMLSerializer(); + +/** + * Sets the @xml:lang attribute of the provided element, and if it is + * an H·T·M·L element also sets the @lang. + */ +const setLanguage = (element, lang) => { + element.setAttributeNS(XML, "xml:lang", lang ?? ""); + if (element.namespaceURI == XHTML) { + element.setAttribute("lang", lang ?? ""); + } else { + /* do nothing */ + } +}; + +/** + * Throws if the provided metadata does not conform to expectations for + * the provided type, and otherwise returns it. + */ +const validateMetadata = (metadata, type) => { + if (metadata.id == null) { + throw new TypeError("Missing id."); + } else if (metadata.title == null) { + throw new TypeError(`Missing title for item <${metadata.id}>.`); + } else if (type == "feed" && metadata.author == null) { + throw new TypeError(`Missing author for feed <${metadata.id}>.`); + } else if (type == "entry" && metadata.content == null) { + throw new TypeError(`Missing content for entry <${metadata.id}>.`); + } else { + return metadata; + } +}; + +await (async () => { // this is the run script + const writes = []; + + // Set up the Atom feed. + const document = parser.parseFromString( + ` +`, + "application/xml", + ); + const { documentElement: feed } = document; + const feedMetadata = metadataFromDocument( + parser.parseFromString( + await Deno.readTextFile(`${basePath}/#feed.rdf`), + "application/xml", + ), + ); + const feedURI = new URL(feedMetadata.id); + applyMetadata(feed, feedMetadata); + + // Set up the index page. + const feedTemplate = await documentFromTemplate("feed"); + const feedEntries = feedTemplate.createDocumentFragment(); + + // Process entries and save the resulting index files. + for await ( + const { name: date, isDirectory } of Deno.readDir( + `${basePath}/`, + ) + ) { + // Iterate over each directory and process the ones which are + // dates. + if (!isDirectory || !/[0-9]{4}-[0-9]{2}-[0-9]{2}/u.test(date)) { + // This isn’t a dated directory. + continue; + } else { + // This is a dated directory. + for await ( + const { name: entryName, isDirectory } of Deno.readDir( + `${basePath}/${date}/`, + ) + ) { + // Iterate over each directory and process the ones which look + // like entries. + if ( + !isDirectory || + //deno-lint-ignore no-control-regex + /[\x00-\x20\x22#%/<>?\\^\x60{|}\x7F]/u.test(entryName) + ) { + // This isn’t an entry directory. + continue; + } else { + // Process the entry. + const entry = document.createElement("entry"); + const entryPath = + `${basePath}/${date}/${entryName}/#entry.rdf`; + const entryDocument = parser.parseFromString( + await Deno.readTextFile(entryPath), + "application/xml", + ); + const { documentElement: entryRoot } = entryDocument; + entryDocument.lastModified = + (await Deno.lstat(entryPath)).mtime; + if (!entryRoot.hasAttributeNS(RDF, "about")) { + // The entry doesn’t have an identifier; let’s give it one. + entryRoot.setAttributeNS( + RDF, + "about", + new URL(`./${date}/${entryName}/`, feedURI), + ); + } else { + // The entry already has an identifier. + /* do nothing */ + } + const entryMetadata = metadataFromDocument(entryDocument); + if (entryMetadata.author.length == 0) { + // The entry metadata did not supply an author. + entryMetadata.author = feedMetadata.author; + } else { + // The entry metadata supplied its own author. + /* do nothing */ + } + const entryTemplate = await documentFromTemplate("entry"); + const { documentElement: templateRoot } = entryTemplate; + const lang = getLanguage(entryRoot); + if (lang && !getLanguage(templateRoot)) { + // The root element of the template does not have an + // assigned language, but the entry does. + setLanguage(templateRoot, lang); + } else { + // Either the template root already has a language, or the + // entry doesn’t either. + /* do nothing */ + } + writes.push( + Deno.writeTextFile( + `${basePath}/${date}/${entryName}/index.xhtml`, + serializer.serializeToString( + applyMetadata(entryTemplate, entryMetadata), + ) + "\n", + ), + ); + applyMetadata(entry, entryMetadata); + applyMetadata(feedEntries, entryMetadata); + feed.appendChild(entry); + } + } + } + } + + // Apply the feed metadata to the feed template and save the + // resulting index file. + const { documentElement: feedRoot } = feedTemplate; + if (hasExpandedName(feedRoot, XHTML, "html")) { + // This is an XHTML template. + const LMN = Lemon.bind({ document: feedTemplate }); + const head = Array.from(feedRoot.childNodes).find(($) => + hasExpandedName($, XHTML, "head") + ) ?? feedRoot.insertBefore( + LMN.head``, + feedRoot.childNodes.item(0), + ); + const titleElement = Array.from(head.childNodes).find(($) => + hasExpandedName($, XHTML, "title") + ) ?? head.appendChild(LMN.title``); + const { + id, + title, + author, + rights, + updated, + } = feedMetadata; + titleElement.textContent = Object(title) instanceof String + ? title + : Array.from(title ?? []).map(($) => + $.textContent + ).join(""); + for (const person of author) { + // Iterate over authors and add appropriate meta tags. + head.appendChild( + LMN.meta({ name: "author" }) + .content(`${person.name ?? person.uri}`)``, + ); + } + const contentPlaceholder = feedTemplate.getElementsByTagNameNS( + XHTML, + "bjørn-content", + ).item(0); + if (contentPlaceholder != null) { + const { parentNode: contentParent } = contentPlaceholder; + const contentElement = contentParent.insertBefore( + LMN.nav.about(`${id}`)`${"\n"}`, + contentPlaceholder, + ); + const contentHeader = contentElement.appendChild( + LMN.header`${"\n\t"}`, + ); + addContent( + contentHeader.appendChild(LMN.h1.property(`${DC11}title`)``), + title, + ); + addContent(contentHeader, "\n"); + addContent(contentElement, "\n"); + const entriesElement = contentElement.appendChild( + LMN.ul.rel(`${AWOL}entry`)`${"\n"}`, + ); + entriesElement.appendChild(feedEntries); + addContent(contentElement, "\n"); + const contentFooter = contentElement.appendChild( + LMN.footer`${"\n\t"}`, + ); + if (rights) { + addContent( + contentFooter.appendChild( + LMN.div.property(`${DC11}rights`)``, + ), + rights, + ); + addContent(contentFooter, "\n\t"); + } + contentFooter.appendChild( + LMN.p.id("entry.updated")`Last updated: ${LMN.time.property( + `${AWOL}updated`, + )`${updated}`}.`, + ); + addContent(contentFooter, "\n"); + addContent(contentElement, "\n"); + contentParent.removeChild(contentPlaceholder); + } else { + /* do nothing */ + } + } + writes.push( + Deno.writeTextFile( + "index.xhtml", + serializer.serializeToString(feedTemplate) + "\n", + ), + ); + + // Save the feed Atom file. + writes.push( + Deno.writeTextFile( + "feed.atom", + serializer.serializeToString(document) + "\n", + ), + ); + + // Await all writes. + await Promise.all(writes); +})(); diff --git a/deno.json b/deno.json new file mode 100644 index 0000000..e5f93e5 --- /dev/null +++ b/deno.json @@ -0,0 +1,4 @@ +{ + "fmt": { "options": { "lineWidth": 71 } }, + "lint": { "rules": { "exclude": ["no-irregular-whitespace"] } } +} diff --git a/index#entry.xhtml b/index#entry.xhtml new file mode 100644 index 0000000..9feb54e --- /dev/null +++ b/index#entry.xhtml @@ -0,0 +1,6 @@ + + + + + + diff --git a/index#feed.xhtml b/index#feed.xhtml new file mode 100644 index 0000000..9feb54e --- /dev/null +++ b/index#feed.xhtml @@ -0,0 +1,6 @@ + + + + + +