--- /dev/null
+#!/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 <https://mozilla.org/MPL/2.0/>.
+
+// 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
+// <https://deno.land/>.
+//
+// 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 (<https://git.ladys.computer/Lemon>).
+//
+// 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(
+ `<?xml version="1.0" encoding="utf-8"?>
+<feed xmlns="http://www.w3.org/2005/Atom"></feed>`,
+ "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);
+})();